One-and-done counts confirmed fills; holds need a persistent refusal; wipes keep their yardstick
Fourth review round (52ed9c7):
- The one-and-done guard counts only entries the broker confirmed filled,
so an entry that timed out or was rejected unfilled no longer benches the
account for the day.
- TopstepX: an account is promoted only by a sync that actually checked its
funds; one with no account type stays out (not traded) without costing a
sync per pass, and once typed waits for that check. Wipe routes don't
count untyped accounts as failures.
- Tradovate: an unchecked balance-hold release needs Cash History refused
for a full hour straight (a 4xx other than 401/403/408/429, or an answer
without data); any other outcome resets it, so one stray reply during a
5xx outage can't release a payout.
- A wipe keeps the pre-wipe baseline and fund rows as its yardstick: the
rebuild must explain today's balance against them, so a payout the
rebuilt report hasn't booked yet stays held.
- The roll base of an unrolled record is kept per trade date in
account_meta, so a restart later the same trade date counts from the same
point instead of from a value that already includes the day's fills.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
52ed9c75e4
commit
1eb0736fbe
+17
-5
@@ -37,6 +37,10 @@ interface TradeRuntime {
|
||||
* how a stop-out is detected. Resets on restart, which is fine — day
|
||||
* completion is derived from realized PnL, not from this counter. */
|
||||
legCounts: Map<number, { date: string; count: number; sum: number }>;
|
||||
/** Per account, the trade date of its last entry the broker confirmed as
|
||||
* filled — the one-and-done guard's "already traded", which an entry
|
||||
* whose outcome is unknown or that never filled must not trip. */
|
||||
filledEntries?: Map<number, string>;
|
||||
/** Today's session-open delay, keyed by Central calendar day (see openJitterMinutes). */
|
||||
sessionOpenJitter: Map<string, number>;
|
||||
/** Accounts whose orders are being placed right now. Their position only
|
||||
@@ -120,6 +124,10 @@ function legSumToday(accountId: number, tradeDate: string): number {
|
||||
return entry && entry.date === tradeDate ? entry.sum : 0;
|
||||
}
|
||||
|
||||
function filledEntryToday(accountId: number, tradeDate: string): boolean {
|
||||
return runtime().filledEntries?.get(accountId) === tradeDate;
|
||||
}
|
||||
|
||||
function recordLeg(accountId: number, tradeDate: string, legAmount: number): void {
|
||||
runtime().legCounts.set(accountId, {
|
||||
date: tradeDate,
|
||||
@@ -168,7 +176,9 @@ async function sendEntry(
|
||||
legAmount: number,
|
||||
) {
|
||||
try {
|
||||
return await client.sendOrder(acc.id, contractName, contracts, action, 'Market');
|
||||
const fill = await client.sendOrder(acc.id, contractName, contracts, action, 'Market');
|
||||
(runtime().filledEntries ??= new Map()).set(acc.id, tradeDate);
|
||||
return fill;
|
||||
} catch (err) {
|
||||
if (isOrderNotPlaced(err)) {
|
||||
unrecordLeg(acc.id, tradeDate, legAmount);
|
||||
@@ -479,10 +489,12 @@ function evaluateAccountForTrade(client: any, acc: { id: number; name: string; a
|
||||
if (combined.realizedPnL !== 0) {
|
||||
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)' };
|
||||
// And any entry of today's the broker confirmed filled, whatever its
|
||||
// realized P&L says (a record replaced under it, a fill not booked
|
||||
// yet). Only a confirmed fill: an entry that timed out or was
|
||||
// rejected unfilled leaves the account free to retry.
|
||||
if (filledEntryToday(acc.id, tradeDate)) {
|
||||
return { ...evaluated, eligible: false, reason: 'already traded today (entry filled)' };
|
||||
}
|
||||
if (!isExtraDay && plan.remainingToday <= 0) {
|
||||
const reason = effective.drain && plan.dayTarget === 0
|
||||
|
||||
@@ -894,10 +894,11 @@ export class TopstepXClient implements PlatformClient {
|
||||
console.error(`[topstepx] fund reconcile failed for ${acc.name}: ${errorMessage(err)}`);
|
||||
}
|
||||
// A freshly fetched history is tradable once a fresh pass has
|
||||
// actually checked the funds behind the balance (a skipped
|
||||
// check — a position still open — waits for a later pass).
|
||||
const ours = Boolean(getAccountConfig(acc.id).accountType);
|
||||
if ((reconciled || !ours) && this.reconcilePendingIds.delete(acc.id)) this.historyLoadedIds.add(acc.id);
|
||||
// actually checked the funds behind the balance. A skipped
|
||||
// check waits for a later pass — a position still open, or no
|
||||
// account type yet (not traded until one is assigned, and then
|
||||
// only after a check).
|
||||
if (reconciled && this.reconcilePendingIds.delete(acc.id)) this.historyLoadedIds.add(acc.id);
|
||||
|
||||
try {
|
||||
this.updateMllFloor(acc);
|
||||
@@ -1290,8 +1291,11 @@ export class TopstepXClient implements PlatformClient {
|
||||
// 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
|
||||
// maybeFetchDaysTraded once readiness allows.)
|
||||
// Only ours: an account with no type stays pending without costing
|
||||
// a sync on every pass.
|
||||
const waiting = () => this.accountList.some((a) => this.reconcilePendingIds.has(a.id)
|
||||
&& (!onlyAccountIds || onlyAccountIds.includes(a.id)));
|
||||
&& (!onlyAccountIds || onlyAccountIds.includes(a.id))
|
||||
&& Boolean(getAccountConfig(a.id).accountType));
|
||||
if (waiting() && this.historyLoaded && !this.intentionalDisconnect) {
|
||||
await this.requestSync(); // a pass already running may have gone past them
|
||||
if (waiting()) await this.requestSync();
|
||||
|
||||
+56
-14
@@ -138,6 +138,7 @@ const HOLD_UNCHECKED_RELEASE_MS = 60 * 60_000;
|
||||
* hold (so a restart onto a failing report keeps it). */
|
||||
const BASELINE_META = 'cash_baseline';
|
||||
const HOLD_META = 'history_hold';
|
||||
const ROLL_BASE_META = 'roll_base';
|
||||
|
||||
function holdRetryMs(attempts: number): number {
|
||||
return Math.min(HOLD_RETRY_MAX_MS, HOLD_RETRY_MS * 2 ** Math.max(0, attempts - 1));
|
||||
@@ -149,6 +150,13 @@ function fundSignature(txns: { date: string; amount: number }[] | undefined): st
|
||||
return (txns ?? []).map((f) => `${f.date}:${f.amount}`).join('|');
|
||||
}
|
||||
|
||||
/** Whether `now` holds a fund transaction `before` didn't (a rebuild after a
|
||||
* wipe can lose rows older than the report's window without booking any). */
|
||||
function bookedNew(before: string, now: string): boolean {
|
||||
const had = new Set(before ? before.split('|') : []);
|
||||
return (now ? now.split('|') : []).some((row) => !had.has(row));
|
||||
}
|
||||
|
||||
/** Whether cash is still what the baseline accounts for: the same start-of-day
|
||||
* balance ('same'), or the daily reset having folded the day's realized P&L
|
||||
* into the balance ('folded'). */
|
||||
@@ -227,7 +235,7 @@ function todaysCash(cb: CashBalanceEntity, prev: StoredCash | undefined): Stored
|
||||
get realizedPnL(): number {
|
||||
if (!ended()) return recorded;
|
||||
if (inRollWindow()) return 0;
|
||||
entry.rollBase ??= recorded;
|
||||
entry.rollBase ??= latchRollBase(cb.accountId, tradeDate!, recorded);
|
||||
return Math.round((recorded - entry.rollBase) * 100) / 100;
|
||||
},
|
||||
get pendingRoll(): boolean {
|
||||
@@ -237,6 +245,20 @@ function todaysCash(cb: CashBalanceEntity, prev: StoredCash | undefined): Stored
|
||||
return entry;
|
||||
}
|
||||
|
||||
/** The roll base for an unrolled record, kept on record per trade date: a
|
||||
* restart later the same trade date must count from the same point, not
|
||||
* from a value that already includes the day's fills. */
|
||||
function latchRollBase(accountId: number, recordDate: string, recorded: number): number {
|
||||
const day = currentTradeDateCT();
|
||||
try {
|
||||
const saved = JSON.parse(loadAccountMeta(accountId, ROLL_BASE_META) ?? 'null') as
|
||||
{ record?: string; day?: string; base?: number } | null;
|
||||
if (saved?.record === recordDate && saved.day === day && Number.isFinite(saved.base)) return saved.base!;
|
||||
} catch { /* unreadable: latch afresh */ }
|
||||
saveAccountMeta(accountId, ROLL_BASE_META, JSON.stringify({ record: recordDate, day, base: recorded }));
|
||||
return recorded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a cash record unless the one already held is newer. At the roll the
|
||||
* snapshot and the socket deliver the ended day's record (archived) and the
|
||||
@@ -335,9 +357,11 @@ export class TradovateClient {
|
||||
* targeted pass doesn't request the same reports again (a wipe fetches
|
||||
* outside maybeFetchDaysTraded). */
|
||||
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>();
|
||||
/** Per account, since when every Cash History try has been refused
|
||||
* outright (a 4xx other than auth/rate limits, or an answer without data
|
||||
* — a passed account's 404s), as against merely failing. Any other
|
||||
* outcome clears it. */
|
||||
private cashHistoryRefusedSince = new Map<number, number>();
|
||||
/** Interval handles tracked so they can be cleared on reconnect */
|
||||
private syncInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private heartbeatInterval: ReturnType<typeof setInterval> | null = null;
|
||||
@@ -1383,7 +1407,7 @@ export class TradovateClient {
|
||||
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
|
||||
delete this.lastFetchErrors[account.name];
|
||||
this.cashHistoryUnavailable.delete(account.id);
|
||||
this.cashHistoryRefusedSince.delete(account.id);
|
||||
this.markHistoryLoaded(account.id, true, cashBefore, fundSigBefore, knownBefore);
|
||||
continue;
|
||||
} catch (err) {
|
||||
@@ -1391,10 +1415,11 @@ export class TradovateClient {
|
||||
// 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)
|
||||
const refused = (typeof status === 'number' && status >= 400 && status < 500
|
||||
&& ![401, 403, 408, 429].includes(status))
|
||||
|| /without data/.test(errorMessage(err));
|
||||
if (refused) this.cashHistoryUnavailable.add(account.id);
|
||||
else this.cashHistoryUnavailable.delete(account.id);
|
||||
if (!refused) this.cashHistoryRefusedSince.delete(account.id);
|
||||
else if (!this.cashHistoryRefusedSince.has(account.id)) this.cashHistoryRefusedSince.set(account.id, Date.now());
|
||||
// fall through to Fills fallback
|
||||
}
|
||||
|
||||
@@ -1503,9 +1528,13 @@ export class TradovateClient {
|
||||
* bring back. Kept on record too, so a restart doesn't forget it. */
|
||||
public forgetHistory(accountId: number, accountName: string): void {
|
||||
this.historyLoadedIds.delete(accountId);
|
||||
this.hold(accountId, 'wipe', '');
|
||||
// The fund transactions and baseline from before the wipe stay as the
|
||||
// yardstick: the rebuild must explain today's balance against them,
|
||||
// so a payout the rebuilt report hasn't booked yet stays held.
|
||||
const preWipe = this.historyHolds.get(accountId)?.fundSig ?? fundSignature(this.fundTransactions[accountId]);
|
||||
this.hold(accountId, 'wipe', preWipe);
|
||||
saveAccountMeta(accountId, HOLD_META, 'wipe');
|
||||
delete this.balanceBaseline[accountId];
|
||||
this.saveBaseline(accountId); // wipeAccountData dropped the saved one
|
||||
delete this.dailyPnL[accountId];
|
||||
delete this.fullDailyPnL[accountId];
|
||||
delete this.daysTraded[accountId];
|
||||
@@ -1565,8 +1594,9 @@ export class TradovateClient {
|
||||
hold.retryAt = now + holdRetryMs(hold.attempts);
|
||||
// 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) {
|
||||
const refusedSince = this.cashHistoryRefusedSince.get(accountId);
|
||||
if (hold.reason === 'balance' && refusedSince !== undefined
|
||||
&& now - refusedSince >= HOLD_UNCHECKED_RELEASE_MS && now - hold.since >= HOLD_UNCHECKED_RELEASE_MS) {
|
||||
console.warn(`${this.tag} ${this.accountName(accountId)}: its balance change stayed unchecked for`
|
||||
+ ` ${Math.round((now - hold.since) / 60_000)}m — its Cash History is refused; releasing it`);
|
||||
this.historyHolds.delete(accountId);
|
||||
@@ -1600,9 +1630,21 @@ export class TradovateClient {
|
||||
this.setBaseline(accountId, cashAfter ?? null);
|
||||
return;
|
||||
}
|
||||
// Booked (balance), or rebuilt from Cash History (wipe).
|
||||
if (hold.reason === 'wipe') {
|
||||
saveAccountMeta(accountId, HOLD_META, '');
|
||||
// Rebuilt — but the balance must still be explained against
|
||||
// the pre-wipe baseline, unless the rebuild booked a fund
|
||||
// transaction the pre-wipe rows didn't have.
|
||||
const known = knownBefore ?? this.balanceBaseline[accountId];
|
||||
if (known && before && cashExplained(known, before) === null && !bookedNew(hold.fundSig, fundSigNow)) {
|
||||
this.hold(accountId, 'balance', fundSigNow, now + 60_000);
|
||||
console.warn(`${this.tag} ${this.accountName(accountId)}: rebuilt after the wipe, but its balance change`
|
||||
+ ' is still unbooked — held for a later report');
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Booked (balance), or rebuilt and explained (wipe).
|
||||
this.historyHolds.delete(accountId);
|
||||
if (hold.reason === 'wipe') saveAccountMeta(accountId, HOLD_META, '');
|
||||
this.setBaseline(accountId, before);
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user