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:
Brandon Li
2026-09-24 17:04:09 -05:00
co-authored by Claude Opus 5.5
parent 61384c9fa6
commit 77d8c2ecdb
7 changed files with 292 additions and 46 deletions
+122 -19
View File
@@ -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,