Trade only on loaded history; catch early TopstepX fills; fix SI/M2K point values
- Both clients track which accounts this process has loaded history for (historyReady). An account restarted, rebuilt, wiped or newly listed is ineligible until its fetch lands, instead of trading as a fresh stage-1 first day; a failed fetch with no cache no longer reads as empty history. Waiting accounts get a targeted fetch on the next sync. - Tradovate holds an account whose start-of-day balance moves without a fill (a payout between hourly Cash History runs) until a Cash History books it, so a payout-pending account isn't re-traded on its ended cycle. - Wipe routes forget history through the client and count accounts whose history didn't come back as failures. - TopstepX sendOrder picks up a fill that arrived before the place response, and its REST backstop uses the documented Trade/search and Order/search. - POINT_VALUES: SI 5000 (was 50), M2K 5 (was 10). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
61384c9fa6
commit
77d8c2ecdb
+122
-19
@@ -137,6 +137,17 @@ interface TsxTrade {
|
||||
const ORDER_FILLED = 2;
|
||||
const ORDER_GONE = new Set([3, 4, 5]);
|
||||
|
||||
/** An order's first fill price (by trade id), or null when it has none —
|
||||
* the price the hub's first trade event carries, so a whole tick. */
|
||||
function firstFillPrice(trades: TsxTrade[]): number | null {
|
||||
let first: TsxTrade | null = null;
|
||||
for (const t of trades) {
|
||||
if (t.voided || typeof t.price !== 'number') continue;
|
||||
if (!first || t.id < first.id) first = t;
|
||||
}
|
||||
return first ? first.price : null;
|
||||
}
|
||||
|
||||
const SIDE_BUY = 0;
|
||||
const SIDE_SELL = 1;
|
||||
const TYPE_LIMIT = 1;
|
||||
@@ -220,6 +231,11 @@ export class TopstepXClient implements PlatformClient {
|
||||
/** The first full daily P&L pass has landed (cache fallbacks included).
|
||||
* No sync counts as good before it — see requestSync. */
|
||||
private historyLoaded = false;
|
||||
/** Accounts whose history this client has loaded (a trade search came
|
||||
* back, or failed with a DB cache to fall back on). historyLoaded above
|
||||
* is the firm's first pass; this is per account, so one that appears
|
||||
* later, or is wiped, stays out of trading until its own fetch lands. */
|
||||
private historyLoadedIds = new Set<number>();
|
||||
|
||||
/** Trades seen for the current CT trade date, per account — realizedPnL is
|
||||
* recomputed from these on every REST sync and every live trade event. */
|
||||
@@ -1086,15 +1102,22 @@ export class TopstepXClient implements PlatformClient {
|
||||
!retryPending &&
|
||||
this.lastDaysFetchSucceededAt > 0 &&
|
||||
now - this.lastDaysFetchSucceededAt >= 60 * 60 * 1_000;
|
||||
// Accounts held out of trading for want of history (new since the
|
||||
// last pass, or wiped) get their own fetch now rather than wait out
|
||||
// the hour. A failure falls back to the 5-minute retry.
|
||||
const waiting = !initialFetchDue && !retryPending
|
||||
? this.accountList.filter((a) => !this.historyLoadedIds.has(a.id)).map((a) => a.id)
|
||||
: [];
|
||||
const targeted = !retryDue && !hourlyRefreshDue && !initialFetchDue && waiting.length > 0;
|
||||
|
||||
if (!initialFetchDue && !retryDue && !hourlyRefreshDue) return;
|
||||
if (!initialFetchDue && !retryDue && !hourlyRefreshDue && !targeted) return;
|
||||
|
||||
this.daysFetchInFlight = true;
|
||||
this.daysFetchInFlightSince = now;
|
||||
this.lastDaysFetchStartedAt = now;
|
||||
if (retryDue) this.nextDaysFetchRetryAt = 0;
|
||||
|
||||
this.fetchDaysTraded()
|
||||
this.fetchDaysTraded(targeted ? waiting : undefined)
|
||||
.then(({ failedAccounts }) => {
|
||||
if (!this.historyLoaded && !this.intentionalDisconnect) {
|
||||
// Readiness waited on this; sync again now rather than
|
||||
@@ -1107,7 +1130,8 @@ export class TopstepXClient implements PlatformClient {
|
||||
console.warn(`[topstepx] ${failedAccounts} account(s) failed daily P&L fetch — retrying in 5 minutes`);
|
||||
return;
|
||||
}
|
||||
this.lastDaysFetchSucceededAt = Date.now();
|
||||
// A partial pass doesn't restart the hourly clock.
|
||||
if (!targeted) this.lastDaysFetchSucceededAt = Date.now();
|
||||
})
|
||||
.catch((err) => {
|
||||
this.nextDaysFetchRetryAt = Date.now() + 5 * 60 * 1_000;
|
||||
@@ -1192,6 +1216,7 @@ export class TopstepXClient implements PlatformClient {
|
||||
this.applyCycleSlice(account.id);
|
||||
saveDailyPnL(account.id, account.name, fresh);
|
||||
delete this.lastFetchErrors[account.name];
|
||||
this.historyLoadedIds.add(account.id);
|
||||
this.updateMllFloor(account);
|
||||
} catch (err) {
|
||||
const msg = errorMessage(err);
|
||||
@@ -1202,8 +1227,11 @@ export class TopstepXClient implements PlatformClient {
|
||||
this.dailyFees[account.id] = new Map(cached.map((e) => [e.date, e.fees ?? 0]));
|
||||
this.applyCycleSlice(account.id);
|
||||
this.lastFetchErrors[account.name] = `${msg} (using ${cached.length} cached entries from database)`;
|
||||
this.historyLoadedIds.add(account.id);
|
||||
} else {
|
||||
this.dailyPnL[account.id] ??= [];
|
||||
// No history at all is not an empty one: the account
|
||||
// stays out of trading until a fetch lands, instead of
|
||||
// trading as a fresh stage-1 cycle.
|
||||
this.daysTraded[account.id] ??= 0;
|
||||
this.lastFetchErrors[account.name] = msg;
|
||||
}
|
||||
@@ -1213,6 +1241,30 @@ export class TopstepXClient implements PlatformClient {
|
||||
return { failedAccounts };
|
||||
}
|
||||
|
||||
/** Whether this account's history can be planned on — see PlatformClient.
|
||||
* (Fund transactions come from reconcileFunds within a sync, so no
|
||||
* balance-drift hold is needed here.) */
|
||||
public historyReady(accountId: number): boolean {
|
||||
return this.historyLoadedIds.has(accountId)
|
||||
&& this.dailyPnL[accountId] !== undefined
|
||||
&& this.fundTransactions[accountId] !== undefined;
|
||||
}
|
||||
|
||||
/** Drop the account's history from memory (a wipe): it reads unloaded, and
|
||||
* stays out of trading, until the next fetch rebuilds it. */
|
||||
public forgetHistory(accountId: number, accountName: string): void {
|
||||
this.historyLoadedIds.delete(accountId);
|
||||
delete this.dailyPnL[accountId];
|
||||
delete this.fullDailyPnL[accountId];
|
||||
delete this.daysTraded[accountId];
|
||||
delete this.priorProfit[accountId];
|
||||
delete this.lastFundDates[accountId];
|
||||
delete this.fundTransactions[accountId];
|
||||
delete this.dailyFees[accountId];
|
||||
delete this.lastFetchErrors[accountName];
|
||||
delete this.lastFetchRaw[accountName];
|
||||
}
|
||||
|
||||
// ── contracts ────────────────────────────────────────────────────────────
|
||||
|
||||
public async findFrontMonthContract(productName: string): Promise<PlatformContract | null> {
|
||||
@@ -1360,10 +1412,27 @@ export class TopstepXClient implements PlatformClient {
|
||||
);
|
||||
console.log(`[topstepx] order ${orderId}: ${action} ${quantity}x${contractSymbol} ${orderType}${price ? ` @ ${price}` : ''}`);
|
||||
|
||||
// A market fill can reach the hub before /api/Order/place answers,
|
||||
// when no waiter existed yet to hear it. The handler still recorded
|
||||
// it, so look there first. No await between here and the waiter
|
||||
// registered below, so nothing can slip in between.
|
||||
const early = this.bufferedFillPrice(accountId, orderId);
|
||||
if (early !== null) {
|
||||
console.log(`[topstepx] order ${orderId} filled @ ${early} (before the place response)`);
|
||||
return { orderId, price: early, accountId, contractId: contract.id };
|
||||
}
|
||||
const since = new Date(Date.now() - 60_000).toISOString();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const timeout = setTimeout(() => {
|
||||
if (settled) return;
|
||||
const late = this.bufferedFillPrice(accountId, orderId);
|
||||
if (late !== null) {
|
||||
this.fillWaiters = this.fillWaiters.filter((w) => w.orderId !== orderId);
|
||||
settle(late);
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearInterval(poll);
|
||||
this.fillWaiters = this.fillWaiters.filter((w) => w.orderId !== orderId);
|
||||
@@ -1389,29 +1458,63 @@ export class TopstepXClient implements PlatformClient {
|
||||
|
||||
this.fillWaiters.push({ orderId, callback: (trade) => settle(trade.price) });
|
||||
|
||||
// REST backstop for when the hub is down or the event is missed.
|
||||
// REST backstop for when the hub is down or the event is missed:
|
||||
// the order's trades (the same Trade/search fetchDaysTraded
|
||||
// reads), and every fifth poll whether the order died unfilled.
|
||||
// Both are documented Gateway endpoints; Order has no searchById.
|
||||
let polls = 0;
|
||||
const poll = setInterval(() => {
|
||||
void this.post<{ order?: { status: number; filledPrice?: number } }>(
|
||||
'/api/Order/searchById', { accountId, orderId })
|
||||
.then((res) => {
|
||||
const order = res.order;
|
||||
if (!order) return;
|
||||
if (order.status === ORDER_FILLED && typeof order.filledPrice === 'number') {
|
||||
this.fillWaiters = this.fillWaiters.filter((w) => w.orderId !== orderId);
|
||||
settle(order.filledPrice);
|
||||
} else if (ORDER_GONE.has(order.status) && !settled) {
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
clearInterval(poll);
|
||||
this.fillWaiters = this.fillWaiters.filter((w) => w.orderId !== orderId);
|
||||
reject(new Error(`Order ${orderId} ended without fill (status=${order.status})`));
|
||||
polls++;
|
||||
void this.searchOrderFillPrice(accountId, orderId, since)
|
||||
.then((fillPrice) => {
|
||||
if (fillPrice === null) return;
|
||||
this.fillWaiters = this.fillWaiters.filter((w) => w.orderId !== orderId);
|
||||
settle(fillPrice);
|
||||
})
|
||||
.catch(() => { /* transient — the next poll tries again */ });
|
||||
if (polls % 5 !== 0) return;
|
||||
void this.post<{ orders?: { id: number; status: number }[] }>(
|
||||
'/api/Order/search', { accountId, startTimestamp: since })
|
||||
.then(async (res) => {
|
||||
const order = res.orders?.find((o) => o.id === orderId);
|
||||
if (!order || !ORDER_GONE.has(order.status) || settled) return;
|
||||
// Cancelled after a partial fill still left a position.
|
||||
const fillPrice = this.bufferedFillPrice(accountId, orderId)
|
||||
?? await this.searchOrderFillPrice(accountId, orderId, since);
|
||||
this.fillWaiters = this.fillWaiters.filter((w) => w.orderId !== orderId);
|
||||
if (fillPrice !== null) {
|
||||
settle(fillPrice);
|
||||
return;
|
||||
}
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
clearInterval(poll);
|
||||
reject(new Error(`Order ${orderId} ended without fill (status=${order.status})`));
|
||||
})
|
||||
.catch(() => { /* transient — the next poll tries again */ });
|
||||
}, 2_000);
|
||||
});
|
||||
}
|
||||
|
||||
/** The price of an order's first fill already heard over the hub — the
|
||||
* price the fill waiter would have resolved with — or null. */
|
||||
private bufferedFillPrice(accountId: number, orderId: number): number | null {
|
||||
const fills: TsxTrade[] = [...(this.todayTrades[accountId]?.values() ?? [])];
|
||||
for (const event of this.recentEntityEvents) {
|
||||
if (event.entityType === 'trade') fills.push(event.entity as TsxTrade);
|
||||
}
|
||||
return firstFillPrice(fills.filter((t) => t?.accountId === accountId && t.orderId === orderId));
|
||||
}
|
||||
|
||||
/** The same, from the Gateway's trade history (REST). */
|
||||
private async searchOrderFillPrice(accountId: number, orderId: number, since: string): Promise<number | null> {
|
||||
const res = await this.post<{ trades?: TsxTrade[] }>('/api/Trade/search', {
|
||||
accountId, startTimestamp: since, endTimestamp: new Date(Date.now() + 60_000).toISOString(),
|
||||
});
|
||||
return firstFillPrice((res.trades ?? []).filter((t) => t.orderId === orderId));
|
||||
}
|
||||
|
||||
/** Place an order and resolve on placement (does not wait for fill). */
|
||||
public async placeOrderNoWait(
|
||||
accountId: number,
|
||||
|
||||
Reference in New Issue
Block a user