Roll window instead of a roll-time base; transient outages keep payout holds
Third review round (088886e):
- Tradovate: a record still on the ended trade date waits only inside
17:00-17:45 CT (every account rolled 17:17-17:21 on 2026-09-24); nothing
trades on it there, so no pre-roll fill can be lost or double-counted by
the broker's roll. Past the window an unrolled record (an idle account)
counts from its value when first read, so nothing waits indefinitely —
across restarts, stale bases or accounts the broker rolls only on a fill.
- One-and-done accounts also stop once this process fired a leg today.
- A balance hold is released unchecked only when Cash History is refused
outright (4xx / errorText, e.g. a passed account's 404s); a 5xx, timeout
or queue outage keeps the payout held until it recovers.
- A Cash History is judged against the baseline as it stood when requested,
so a fold observed while it was in flight is not taken for a payout.
- TopstepX: a cache fallback for an account not loaded before waits for a
funds check; accounts with no account type (not ours) aren't left
pending; a fetch awaits a sync already running and syncs again if its
accounts still wait, so wipe routes report the real outcome.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
088886ed73
commit
52ed9c75e4
@@ -479,6 +479,11 @@ function evaluateAccountForTrade(client: any, acc: { id: number; name: string; a
|
|||||||
if (combined.realizedPnL !== 0) {
|
if (combined.realizedPnL !== 0) {
|
||||||
return { ...evaluated, eligible: false, reason: `already traded today (realizedPnL=${combined.realizedPnL})` };
|
return { ...evaluated, eligible: false, reason: `already traded today (realizedPnL=${combined.realizedPnL})` };
|
||||||
}
|
}
|
||||||
|
// And any leg this process fired today, whatever the broker's
|
||||||
|
// realized P&L says (a record replaced under it, a fill not booked yet).
|
||||||
|
if (legsToday(acc.id, tradeDate) > 0) {
|
||||||
|
return { ...evaluated, eligible: false, reason: 'already traded today (leg fired)' };
|
||||||
|
}
|
||||||
if (!isExtraDay && plan.remainingToday <= 0) {
|
if (!isExtraDay && plan.remainingToday <= 0) {
|
||||||
const reason = effective.drain && plan.dayTarget === 0
|
const reason = effective.drain && plan.dayTarget === 0
|
||||||
? 'drain days booked — waiting for withdrawal'
|
? 'drain days booked — waiting for withdrawal'
|
||||||
|
|||||||
+19
-5
@@ -242,6 +242,9 @@ export class TopstepXClient implements PlatformClient {
|
|||||||
* fetched without it (after a wipe took the fund rows) would plan the
|
* fetched without it (after a wipe took the fund rows) would plan the
|
||||||
* account as stage 1 until the next sync. */
|
* account as stage 1 until the next sync. */
|
||||||
private reconcilePendingIds = new Set<number>();
|
private reconcilePendingIds = new Set<number>();
|
||||||
|
/** Settles when the sync in flight ends (requestSync hands it to a caller
|
||||||
|
* that arrives mid-pass). */
|
||||||
|
private syncRunning: Promise<void> = Promise.resolve();
|
||||||
/** Accounts a fetchDaysTraded call is working through right now, so the
|
/** Accounts a fetchDaysTraded call is working through right now, so the
|
||||||
* targeted pass doesn't fetch them again (a wipe fetches outside it). */
|
* targeted pass doesn't fetch them again (a wipe fetches outside it). */
|
||||||
private fetchingIds = new Set<number>();
|
private fetchingIds = new Set<number>();
|
||||||
@@ -812,13 +815,16 @@ export class TopstepXClient implements PlatformClient {
|
|||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
if (this.syncInFlight) {
|
if (this.syncInFlight) {
|
||||||
const runningMs = startedAt - this.syncInFlightSince;
|
const runningMs = startedAt - this.syncInFlightSince;
|
||||||
if (runningMs < SYNC_WEDGED_MS) return;
|
// A caller that awaits gets the running pass's end.
|
||||||
|
if (runningMs < SYNC_WEDGED_MS) return this.syncRunning;
|
||||||
console.warn(`[topstepx] sync running for ${Math.round(runningMs / 1000)}s — looks wedged, starting a fresh one`);
|
console.warn(`[topstepx] sync running for ${Math.round(runningMs / 1000)}s — looks wedged, starting a fresh one`);
|
||||||
}
|
}
|
||||||
const gen = ++this.syncGeneration;
|
const gen = ++this.syncGeneration;
|
||||||
const superseded = () => gen !== this.syncGeneration || this.intentionalDisconnect;
|
const superseded = () => gen !== this.syncGeneration || this.intentionalDisconnect;
|
||||||
this.syncInFlight = true;
|
this.syncInFlight = true;
|
||||||
this.syncInFlightSince = startedAt;
|
this.syncInFlightSince = startedAt;
|
||||||
|
let finished!: () => void;
|
||||||
|
this.syncRunning = new Promise<void>((resolve) => { finished = resolve; });
|
||||||
try {
|
try {
|
||||||
const accRes = await this.post<{ accounts: { id: number; name: string; balance: number; canTrade: boolean; isVisible: boolean }[] }>(
|
const accRes = await this.post<{ accounts: { id: number; name: string; balance: number; canTrade: boolean; isVisible: boolean }[] }>(
|
||||||
'/api/Account/search', { onlyActiveAccounts: false });
|
'/api/Account/search', { onlyActiveAccounts: false });
|
||||||
@@ -890,7 +896,8 @@ export class TopstepXClient implements PlatformClient {
|
|||||||
// A freshly fetched history is tradable once a fresh pass has
|
// A freshly fetched history is tradable once a fresh pass has
|
||||||
// actually checked the funds behind the balance (a skipped
|
// actually checked the funds behind the balance (a skipped
|
||||||
// check — a position still open — waits for a later pass).
|
// check — a position still open — waits for a later pass).
|
||||||
if (reconciled && this.reconcilePendingIds.delete(acc.id)) this.historyLoadedIds.add(acc.id);
|
const ours = Boolean(getAccountConfig(acc.id).accountType);
|
||||||
|
if ((reconciled || !ours) && this.reconcilePendingIds.delete(acc.id)) this.historyLoadedIds.add(acc.id);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.updateMllFloor(acc);
|
this.updateMllFloor(acc);
|
||||||
@@ -931,6 +938,7 @@ export class TopstepXClient implements PlatformClient {
|
|||||||
this.syncInFlight = false;
|
this.syncInFlight = false;
|
||||||
this.syncInFlightSince = 0;
|
this.syncInFlightSince = 0;
|
||||||
}
|
}
|
||||||
|
finished();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1264,7 +1272,10 @@ export class TopstepXClient implements PlatformClient {
|
|||||||
this.dailyFees[account.id] = new Map(cached.map((e) => [e.date, e.fees ?? 0]));
|
this.dailyFees[account.id] = new Map(cached.map((e) => [e.date, e.fees ?? 0]));
|
||||||
this.applyCycleSlice(account.id);
|
this.applyCycleSlice(account.id);
|
||||||
this.lastFetchErrors[account.name] = `${msg} (using ${cached.length} cached entries from database)`;
|
this.lastFetchErrors[account.name] = `${msg} (using ${cached.length} cached entries from database)`;
|
||||||
this.historyLoadedIds.add(account.id);
|
// Not loaded before: no funds check has run on this cache,
|
||||||
|
// and none will while the fetch error stands — so it waits
|
||||||
|
// for a fetch that lands (reconcileFunds, requestSync).
|
||||||
|
if (!this.historyLoadedIds.has(account.id)) this.reconcilePendingIds.add(account.id);
|
||||||
} else {
|
} else {
|
||||||
// No history at all is not an empty one: the account
|
// No history at all is not an empty one: the account
|
||||||
// stays out of trading until a fetch lands, instead of
|
// stays out of trading until a fetch lands, instead of
|
||||||
@@ -1279,8 +1290,11 @@ export class TopstepXClient implements PlatformClient {
|
|||||||
// rather than leave them out for up to a minute, and before returning,
|
// rather than leave them out for up to a minute, and before returning,
|
||||||
// so a wipe route reads the result. (The firm's first pass syncs from
|
// so a wipe route reads the result. (The firm's first pass syncs from
|
||||||
// maybeFetchDaysTraded once readiness allows.)
|
// maybeFetchDaysTraded once readiness allows.)
|
||||||
if (this.reconcilePendingIds.size > 0 && this.historyLoaded && !this.intentionalDisconnect) {
|
const waiting = () => this.accountList.some((a) => this.reconcilePendingIds.has(a.id)
|
||||||
await this.requestSync();
|
&& (!onlyAccountIds || onlyAccountIds.includes(a.id)));
|
||||||
|
if (waiting() && this.historyLoaded && !this.intentionalDisconnect) {
|
||||||
|
await this.requestSync(); // a pass already running may have gone past them
|
||||||
|
if (waiting()) await this.requestSync();
|
||||||
}
|
}
|
||||||
return { failedAccounts };
|
return { failedAccounts };
|
||||||
}
|
}
|
||||||
|
|||||||
+62
-27
@@ -131,8 +131,8 @@ const HOLD_RETRY_MAX_MS = 60 * 60_000;
|
|||||||
* many clean reports spanning at least HOLD_RELEASE_MS, with a warning. */
|
* many clean reports spanning at least HOLD_RELEASE_MS, with a warning. */
|
||||||
const HOLD_MAX_CHECKS = 3;
|
const HOLD_MAX_CHECKS = 3;
|
||||||
const HOLD_RELEASE_MS = 15 * 60_000;
|
const HOLD_RELEASE_MS = 15 * 60_000;
|
||||||
/** One no Cash History can ever check (the account's report fails for good —
|
/** One no Cash History can ever check (the account's report is refused for
|
||||||
* a passed account's 404s) is let go after this long, with a warning. */
|
* good — a passed account's 404s) is let go after this long, with a warning. */
|
||||||
const HOLD_UNCHECKED_RELEASE_MS = 60 * 60_000;
|
const HOLD_UNCHECKED_RELEASE_MS = 60 * 60_000;
|
||||||
/** account_meta keys: the baseline a Cash History vouched for, and a wipe's
|
/** account_meta keys: the baseline a Cash History vouched for, and a wipe's
|
||||||
* hold (so a restart onto a failing report keeps it). */
|
* hold (so a restart onto a failing report keeps it). */
|
||||||
@@ -174,26 +174,43 @@ interface StoredCash {
|
|||||||
realizedPnL: number;
|
realizedPnL: number;
|
||||||
tradeDate: string | null;
|
tradeDate: string | null;
|
||||||
archived: boolean;
|
archived: boolean;
|
||||||
/** The record's realizedPnL as last seen while its trade date was the
|
/** For a record the broker hasn't rolled past its trade date: its
|
||||||
* current one — the ended day's final figure once the 5 PM roll passes. */
|
* realizedPnL when first read after the roll window — what later fills
|
||||||
|
* on the same record are counted from. */
|
||||||
rollBase: number | undefined;
|
rollBase: number | undefined;
|
||||||
/** Past the roll with no rollBase seen (the process started after it):
|
/** Inside the roll window with the ended day's record still in place:
|
||||||
* today's share of the record can't be told apart, so the account waits
|
* the account waits for the broker to roll it. */
|
||||||
* for Tradovate to roll it. */
|
|
||||||
pendingRoll: boolean;
|
pendingRoll: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Tradovate rolled every account seen on 2026-09-24 at 17:17–17:21 CT. Until
|
||||||
|
* this long after 5 PM, an account still on the ended day's record waits. */
|
||||||
|
const ROLL_WINDOW_END_MIN = 17 * 60 + 45;
|
||||||
|
|
||||||
|
function inRollWindow(now: Date = new Date()): boolean {
|
||||||
|
const parts = new Intl.DateTimeFormat('en-US', {
|
||||||
|
hour: 'numeric', minute: 'numeric', hour12: false, timeZone: 'America/Chicago',
|
||||||
|
}).formatToParts(now);
|
||||||
|
const get = (type: string) => parseInt(parts.find((p) => p.type === type)?.value ?? '0', 10);
|
||||||
|
const minutes = (get('hour') % 24) * 60 + get('minute');
|
||||||
|
return minutes >= 17 * 60 && minutes < ROLL_WINDOW_END_MIN;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An account's cash as the plan must read it. Tradovate rolls each account's
|
* An account's cash as the plan must read it. Tradovate rolls each account's
|
||||||
* cash record to the new trade date on its own schedule — archiving the old
|
* cash record to the new trade date on its own schedule — archiving the old
|
||||||
* record and opening one with realizedPnL 0 — well after the 17:00 CT roll
|
* record and opening one with realizedPnL 0 — well after the 17:00 CT roll
|
||||||
* (17-21 minutes on 2026-09-24). Until then the latest record still carries
|
* (17-21 minutes on 2026-09-24), and an idle account perhaps not until it
|
||||||
* the ended day's realizedPnL, which the plan would read as today's: legs
|
* trades. Until then the latest record still carries the ended day's
|
||||||
* sized off yesterday, one-and-done accounts read as already traded, the
|
* realizedPnL, which the plan would read as today's: legs sized off
|
||||||
* entry-commission delta swallowing a whole day's P&L. And tonight's own
|
* yesterday, one-and-done accounts read as already traded, the
|
||||||
* fills before the roll may be booked onto that same old record. So past the
|
* entry-commission delta swallowing a whole day's P&L.
|
||||||
* roll an old-dated record's realized P&L counts only what it gained since
|
*
|
||||||
* the roll (recorded - rollBase); its amount already holds the ended day.
|
* So inside the roll window an account on the ended day's record waits
|
||||||
|
* (pendingRoll) — nothing is traded on it, so nothing is booked onto it that
|
||||||
|
* the roll could lose. Past the window, a record still not rolled counts from
|
||||||
|
* its value when first read then: 0 now, and any fill booked onto it later
|
||||||
|
* shows. Its amount already holds the ended day.
|
||||||
*/
|
*/
|
||||||
function todaysCash(cb: CashBalanceEntity, prev: StoredCash | undefined): StoredCash {
|
function todaysCash(cb: CashBalanceEntity, prev: StoredCash | undefined): StoredCash {
|
||||||
const t = cb.tradeDate;
|
const t = cb.tradeDate;
|
||||||
@@ -201,24 +218,23 @@ function todaysCash(cb: CashBalanceEntity, prev: StoredCash | undefined): Stored
|
|||||||
? `${t.year}-${String(t.month).padStart(2, '0')}-${String(t.day).padStart(2, '0')}`
|
? `${t.year}-${String(t.month).padStart(2, '0')}-${String(t.day).padStart(2, '0')}`
|
||||||
: null;
|
: null;
|
||||||
const recorded = cb.realizedPnL;
|
const recorded = cb.realizedPnL;
|
||||||
// Seen while current: this is the base. Seen past the roll: keep the base
|
|
||||||
// the same record carried before it, if this process saw one.
|
|
||||||
const current = tradeDate === null || tradeDate >= currentTradeDateCT();
|
|
||||||
const rollBase = current ? recorded : prev && prev.tradeDate === tradeDate ? prev.rollBase : undefined;
|
|
||||||
const ended = () => tradeDate !== null && tradeDate < currentTradeDateCT();
|
const ended = () => tradeDate !== null && tradeDate < currentTradeDateCT();
|
||||||
return {
|
const entry: StoredCash = {
|
||||||
amount: cb.amount,
|
amount: cb.amount,
|
||||||
tradeDate,
|
tradeDate,
|
||||||
archived: cb.archived === true,
|
archived: cb.archived === true,
|
||||||
rollBase,
|
rollBase: prev && prev.tradeDate === tradeDate ? prev.rollBase : undefined,
|
||||||
get realizedPnL(): number {
|
get realizedPnL(): number {
|
||||||
if (!ended()) return recorded;
|
if (!ended()) return recorded;
|
||||||
return rollBase === undefined ? 0 : Math.round((recorded - rollBase) * 100) / 100;
|
if (inRollWindow()) return 0;
|
||||||
|
entry.rollBase ??= recorded;
|
||||||
|
return Math.round((recorded - entry.rollBase) * 100) / 100;
|
||||||
},
|
},
|
||||||
get pendingRoll(): boolean {
|
get pendingRoll(): boolean {
|
||||||
return ended() && rollBase === undefined;
|
return ended() && inRollWindow();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -319,6 +335,9 @@ export class TradovateClient {
|
|||||||
* targeted pass doesn't request the same reports again (a wipe fetches
|
* targeted pass doesn't request the same reports again (a wipe fetches
|
||||||
* outside maybeFetchDaysTraded). */
|
* outside maybeFetchDaysTraded). */
|
||||||
private fetchingIds = new Set<number>();
|
private fetchingIds = new Set<number>();
|
||||||
|
/** Accounts whose Cash History was refused outright on its last try (a
|
||||||
|
* 4xx or an errorText — a passed account's 404s), not merely failing. */
|
||||||
|
private cashHistoryUnavailable = new Set<number>();
|
||||||
/** Interval handles tracked so they can be cleared on reconnect */
|
/** Interval handles tracked so they can be cleared on reconnect */
|
||||||
private syncInterval: ReturnType<typeof setInterval> | null = null;
|
private syncInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
private heartbeatInterval: ReturnType<typeof setInterval> | null = null;
|
private heartbeatInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
@@ -1278,6 +1297,7 @@ export class TradovateClient {
|
|||||||
const cashNow = this.accountCashBalances[account.id];
|
const cashNow = this.accountCashBalances[account.id];
|
||||||
const cashBefore = cashNow ? { amount: cashNow.amount, realizedPnL: cashNow.realizedPnL } : null;
|
const cashBefore = cashNow ? { amount: cashNow.amount, realizedPnL: cashNow.realizedPnL } : null;
|
||||||
const fundSigBefore = fundSignature(this.fundTransactions[account.id]);
|
const fundSigBefore = fundSignature(this.fundTransactions[account.id]);
|
||||||
|
const knownBefore = this.balanceBaseline[account.id] ? { ...this.balanceBaseline[account.id] } : null;
|
||||||
|
|
||||||
// --- Try Cash History first (true after-fee daily P&L) ---
|
// --- Try Cash History first (true after-fee daily P&L) ---
|
||||||
try {
|
try {
|
||||||
@@ -1363,9 +1383,18 @@ export class TradovateClient {
|
|||||||
this.daysTraded[account.id] = active.filter((d) => d.pnl !== 0).length;
|
this.daysTraded[account.id] = active.filter((d) => d.pnl !== 0).length;
|
||||||
saveDailyPnL(account.id, account.name, fresh); // upsert only fresh entries — preserves older cached rows
|
saveDailyPnL(account.id, account.name, fresh); // upsert only fresh entries — preserves older cached rows
|
||||||
delete this.lastFetchErrors[account.name];
|
delete this.lastFetchErrors[account.name];
|
||||||
this.markHistoryLoaded(account.id, true, cashBefore, fundSigBefore);
|
this.cashHistoryUnavailable.delete(account.id);
|
||||||
|
this.markHistoryLoaded(account.id, true, cashBefore, fundSigBefore, knownBefore);
|
||||||
continue;
|
continue;
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
// Refused outright (a 4xx, an errorText) — a passed account's
|
||||||
|
// Cash History 404s for good — as against a server that may
|
||||||
|
// answer next time (5xx, a timeout, still queued).
|
||||||
|
const status = (err as { response?: { status?: number } } | null)?.response?.status;
|
||||||
|
const refused = (typeof status === 'number' && status >= 400 && status < 500 && status !== 408 && status !== 429)
|
||||||
|
|| /without data/.test(errorMessage(err));
|
||||||
|
if (refused) this.cashHistoryUnavailable.add(account.id);
|
||||||
|
else this.cashHistoryUnavailable.delete(account.id);
|
||||||
// fall through to Fills fallback
|
// fall through to Fills fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1513,6 +1542,7 @@ export class TradovateClient {
|
|||||||
fromCashHistory: boolean,
|
fromCashHistory: boolean,
|
||||||
cashBefore: { amount: number; realizedPnL: number } | null = null,
|
cashBefore: { amount: number; realizedPnL: number } | null = null,
|
||||||
fundSigBefore = '',
|
fundSigBefore = '',
|
||||||
|
knownBefore: CashBaseline | null = null,
|
||||||
): void {
|
): void {
|
||||||
if (this.dailyPnL[accountId] === undefined || this.fundTransactions[accountId] === undefined) {
|
if (this.dailyPnL[accountId] === undefined || this.fundTransactions[accountId] === undefined) {
|
||||||
this.historyLoadedIds.delete(accountId);
|
this.historyLoadedIds.delete(accountId);
|
||||||
@@ -1533,9 +1563,12 @@ export class TradovateClient {
|
|||||||
if (hold) {
|
if (hold) {
|
||||||
hold.attempts++;
|
hold.attempts++;
|
||||||
hold.retryAt = now + holdRetryMs(hold.attempts);
|
hold.retryAt = now + holdRetryMs(hold.attempts);
|
||||||
if (hold.reason === 'balance' && now - hold.since >= HOLD_UNCHECKED_RELEASE_MS) {
|
// Only when Cash History is refused for good (a passed account):
|
||||||
|
// an outage that may end keeps the payout held for it.
|
||||||
|
if (hold.reason === 'balance' && this.cashHistoryUnavailable.has(accountId)
|
||||||
|
&& now - hold.since >= HOLD_UNCHECKED_RELEASE_MS) {
|
||||||
console.warn(`${this.tag} ${this.accountName(accountId)}: its balance change stayed unchecked for`
|
console.warn(`${this.tag} ${this.accountName(accountId)}: its balance change stayed unchecked for`
|
||||||
+ ` ${Math.round((now - hold.since) / 60_000)}m — Cash History never came back; releasing it`);
|
+ ` ${Math.round((now - hold.since) / 60_000)}m — its Cash History is refused; releasing it`);
|
||||||
this.historyHolds.delete(accountId);
|
this.historyHolds.delete(accountId);
|
||||||
this.setBaseline(accountId, this.accountCashBalances[accountId] ?? null);
|
this.setBaseline(accountId, this.accountCashBalances[accountId] ?? null);
|
||||||
}
|
}
|
||||||
@@ -1576,7 +1609,9 @@ export class TradovateClient {
|
|||||||
|
|
||||||
// A balance the last vouched-for baseline can't explain, with nothing
|
// A balance the last vouched-for baseline can't explain, with nothing
|
||||||
// booked by this report: a payout it hasn't caught up with yet.
|
// booked by this report: a payout it hasn't caught up with yet.
|
||||||
const known = this.balanceBaseline[accountId];
|
// Judged against the baseline as it stood when the report was asked
|
||||||
|
// for: a fold the sync saw while it was in flight is not a payout.
|
||||||
|
const known = knownBefore ?? this.balanceBaseline[accountId];
|
||||||
if (known && before && cashExplained(known, before) === null && fundSigNow === fundSigBefore) {
|
if (known && before && cashExplained(known, before) === null && fundSigNow === fundSigBefore) {
|
||||||
this.hold(accountId, 'balance', fundSigBefore, now + 60_000);
|
this.hold(accountId, 'balance', fundSigBefore, now + 60_000);
|
||||||
console.warn(`${this.tag} ${this.accountName(accountId)}: balance moved $`
|
console.warn(`${this.tag} ${this.accountName(accountId)}: balance moved $`
|
||||||
|
|||||||
Reference in New Issue
Block a user