diff --git a/app/api/accounts/[id]/wipe/route.ts b/app/api/accounts/[id]/wipe/route.ts index 394500b..f12e10c 100644 --- a/app/api/accounts/[id]/wipe/route.ts +++ b/app/api/accounts/[id]/wipe/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from 'next/server'; import { getClients } from '@/lib/clients'; +import { getAccountConfig } from '@/lib/account-type-resolver'; import { wipeAccountData } from '@/lib/db'; /** @@ -29,7 +30,7 @@ export async function POST(_req: Request, { params }: { params: Promise<{ id: st await client.fetchDaysTraded([accountId]); - const loaded = client.historyReady(accountId); + const loaded = client.historyReady(accountId) || !getAccountConfig(accountId).accountType; return NextResponse.json({ ok: true, entries: (client.dailyPnL[accountId] ?? []).length, diff --git a/app/api/accounts/wipe-all/route.ts b/app/api/accounts/wipe-all/route.ts index c230793..5bfb16b 100644 --- a/app/api/accounts/wipe-all/route.ts +++ b/app/api/accounts/wipe-all/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from 'next/server'; import { getClients } from '@/lib/clients'; +import { getAccountConfig } from '@/lib/account-type-resolver'; import { wipeAccountData } from '@/lib/db'; /** @@ -29,8 +30,9 @@ export async function POST() { } await client.fetchDaysTraded(); // Counted from the result, not the fetch's own tally: a client with - // no session fetches nothing and reports no failures. - failedAccounts += ids.filter((id) => !client.historyReady(id)).length; + // no session fetches nothing and reports no failures. Accounts with + // no type aren't traded, so they aren't failures either. + failedAccounts += ids.filter((id) => getAccountConfig(id).accountType && !client.historyReady(id)).length; })); return NextResponse.json({ ok: true, wiped, failedAccounts }); diff --git a/lib/auto-trade.ts b/lib/auto-trade.ts index 75a2fac..2483af9 100644 --- a/lib/auto-trade.ts +++ b/lib/auto-trade.ts @@ -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; + /** 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; /** Today's session-open delay, keyed by Central calendar day (see openJitterMinutes). */ sessionOpenJitter: Map; /** 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 diff --git a/lib/topstepx-class.ts b/lib/topstepx-class.ts index 20da9dc..6fe7213 100644 --- a/lib/topstepx-class.ts +++ b/lib/topstepx-class.ts @@ -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(); diff --git a/lib/tradovate-class.ts b/lib/tradovate-class.ts index f700c91..99b23e9 100644 --- a/lib/tradovate-class.ts +++ b/lib/tradovate-class.ts @@ -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(); - /** 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(); + /** 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(); /** Interval handles tracked so they can be cleared on reconnect */ private syncInterval: ReturnType | null = null; private heartbeatInterval: ReturnType | 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; }