diff --git a/app/api/accounts/[id]/wipe/route.ts b/app/api/accounts/[id]/wipe/route.ts index c124f15..394500b 100644 --- a/app/api/accounts/[id]/wipe/route.ts +++ b/app/api/accounts/[id]/wipe/route.ts @@ -23,23 +23,19 @@ export async function POST(_req: Request, { params }: { params: Promise<{ id: st wipeAccountData(accountId); // Clear the in-memory view too — a failed refetch must leave the - // account visibly empty, not showing the data that was just wiped. - delete client.dailyPnL[accountId]; - delete client.fullDailyPnL[accountId]; - delete client.daysTraded[accountId]; - delete client.priorProfit[accountId]; - delete client.lastFundDates[accountId]; - delete client.fundTransactions[accountId]; - delete client.lastFetchErrors[account.name]; - delete client.lastFetchRaw[account.name]; + // account visibly empty, not showing the data that was just wiped — + // and out of trading until a fetch rebuilds it. + client.forgetHistory(accountId, account.name); await client.fetchDaysTraded([accountId]); + const loaded = client.historyReady(accountId); return NextResponse.json({ ok: true, entries: (client.dailyPnL[accountId] ?? []).length, daysTraded: client.daysTraded[accountId] ?? 0, - fetchError: client.lastFetchErrors[account.name] ?? null, + fetchError: client.lastFetchErrors[account.name] + ?? (loaded ? null : 'History did not reload — the account stays out of trading until it does'), }); } diff --git a/app/api/accounts/wipe-all/route.ts b/app/api/accounts/wipe-all/route.ts index 334796a..c230793 100644 --- a/app/api/accounts/wipe-all/route.ts +++ b/app/api/accounts/wipe-all/route.ts @@ -19,20 +19,18 @@ export async function POST() { let failedAccounts = 0; await Promise.all(clients.map(async (client) => { + // Forgotten, the accounts read unloaded and stay out of trading + // until the refetch below (or, if it fails, a later one) lands. + const ids = client.accountList.map((account) => account.id); for (const account of client.accountList) { wipeAccountData(account.id); - delete client.dailyPnL[account.id]; - delete client.fullDailyPnL[account.id]; - delete client.daysTraded[account.id]; - delete client.priorProfit[account.id]; - delete client.lastFundDates[account.id]; - delete client.fundTransactions[account.id]; - delete client.lastFetchErrors[account.name]; - delete client.lastFetchRaw[account.name]; + client.forgetHistory(account.id, account.name); wiped++; } - const result = await client.fetchDaysTraded(); - failedAccounts += result.failedAccounts; + 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; })); return NextResponse.json({ ok: true, wiped, failedAccounts }); diff --git a/lib/auto-trade.ts b/lib/auto-trade.ts index eda0935..00bc837 100644 --- a/lib/auto-trade.ts +++ b/lib/auto-trade.ts @@ -355,6 +355,10 @@ async function legInstrument( // ── per-account trade evaluation (shared by runTrade / copyTrade / hasRemaining…) ── +/** The ineligible reason for an account whose history isn't loaded yet — still + * in play for stop-after-all, since it trades once the fetch lands. */ +const HISTORY_PENDING = 'history not loaded yet — waiting for the fetch'; + interface AccountEvaluation { eligible: boolean; reason: string; @@ -400,6 +404,10 @@ function evaluateAccountForTrade(client: any, acc: { id: number; name: string; a // Another batch is placing this account's order right now; its position // won't show until the fill comes back, so the check above can't see it. if (runtime().accountsInFlight.has(acc.id)) return { ...base, eligible: false, reason: 'order in flight' }; + // History this client hasn't loaded (a restart, a rebuild, a wipe, a new + // account) or that a payout has overtaken would plan the account as a + // fresh stage-1 first day: an oversized leg, or a re-opened finished cycle. + if (!client.historyReady(acc.id)) return { ...base, eligible: false, reason: HISTORY_PENDING }; // Obfuscation stops: a stop-out shows as realized P&L falling short of the // legs fired today — the day is booked; the make-back ride is tomorrow. @@ -1166,6 +1174,9 @@ function hasRemainingConfiguredAccounts(): boolean { const ev = evaluateAccountForTrade(client, acc, tradeDate); if (ev?.eligible) return true; + // Waiting on its history, not done: it may still owe today's trade. + // Only while the session is open, like the unready firms above. + if (ev?.reason === HISTORY_PENDING && !isInNoTradeWindow()) return true; } } diff --git a/lib/platform-client.ts b/lib/platform-client.ts index 4e739e9..2a547ae 100644 --- a/lib/platform-client.ts +++ b/lib/platform-client.ts @@ -51,6 +51,18 @@ export interface PlatformClient { products: { id: number; name: string }[]; fetchDaysTraded(onlyAccountIds?: number[]): Promise<{ failedAccounts: number }>; + /** + * Whether the account's history can be planned on. False until this + * client has loaded it (right after a restart, a client rebuild, a wipe, + * or for an account that just appeared), and on Tradovate while a balance + * change the history can't explain — a payout between reports — waits + * for a Cash History. The missing rows and withdrawals would otherwise + * read as a fresh stage-1 cycle, so such an account must not trade. + */ + historyReady(accountId: number): boolean; + /** Drop the account's history from memory (the wipe routes), so it reads + * unloaded until the next fetch rebuilds it. */ + forgetHistory(accountId: number, accountName: string): void; findFrontMonthContract(productName: string): Promise; fetchInstrumentFees(symbols: string[]): Promise<{ [symbol: string]: number }>; diff --git a/lib/topstepx-class.ts b/lib/topstepx-class.ts index f93868b..f7c0eee 100644 --- a/lib/topstepx-class.ts +++ b/lib/topstepx-class.ts @@ -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(); /** 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 { @@ -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 { + 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, diff --git a/lib/trading-logic.ts b/lib/trading-logic.ts index d97a748..cf0ebcb 100644 --- a/lib/trading-logic.ts +++ b/lib/trading-logic.ts @@ -818,8 +818,8 @@ export function endedCycleCompletion(opts: { /** Dollar-per-point value for common futures products. */ export const POINT_VALUES: { [symbol: string]: number } = { NQ: 20, MNQ: 2, ES: 50, MES: 5, - YM: 5, MYM: 0.5, RTY: 50, M2K: 10, - GC: 100, MGC: 10, SI: 50, CL: 1000, + YM: 5, MYM: 0.5, RTY: 50, M2K: 5, + GC: 100, MGC: 10, SI: 5000, CL: 1000, MCL: 100, NG: 10000, ZB: 1000, ZN: 1000, ZF: 1000, '6E': 125000, '6J': 12500000, '6B': 62500, }; diff --git a/lib/tradovate-class.ts b/lib/tradovate-class.ts index 2de94f3..9b4c36e 100644 --- a/lib/tradovate-class.ts +++ b/lib/tradovate-class.ts @@ -103,6 +103,11 @@ const TOKEN_REUSE_MIN_MS = 5 * 60_000; /** An order ack later than this is treated as lost (ambiguous — it may have been placed). */ const ACK_TIMEOUT_MS = 15_000; const FILL_TIMEOUT_MS = 30_000; +/** How far the start-of-day balance may move without a fill before the + * account's history is treated as overtaken (balanceDrifted). Fills move + * amount and realizedPnL together, so this only has to absorb rounding; + * a payout is hundreds. */ +const FUND_DRIFT_TOLERANCE = 25; const CONTRACT_FIND_TIMEOUT_MS = 15_000; /** On-demand front-month resolve (several Yahoo lookups, then Tradovate). */ const CONTRACT_RESOLVE_DEADLINE_MS = 30_000; @@ -173,6 +178,18 @@ export class TradovateClient { private lastDaysFetchStartedAt = 0; private lastDaysFetchSucceededAt = 0; private nextDaysFetchRetryAt = 0; + /** Accounts whose history this process has loaded: a report came back, or + * failed with a DB cache to fall back on. Until then the plan would read + * a fresh stage-1 cycle, so historyReady() keeps them out of trading. */ + private historyLoadedIds = new Set(); + /** Accounts whose balance moved in a way the loaded history can't explain + * (a payout posting between the hourly reports): held out of trading + * until a Cash History books it. */ + private historyRefreshIds = new Set(); + /** Per account, the start-of-day balance (amount − realizedPnL) the loaded + * history was taken at, and the day's realized P&L as last seen — what the + * daily reset folds into the balance. */ + private balanceBaseline: { [accountId: number]: { dayStart: number; realized: number } } = {}; /** Interval handles tracked so they can be cleared on reconnect */ private syncInterval: ReturnType | null = null; private heartbeatInterval: ReturnType | null = null; @@ -854,6 +871,9 @@ export class TradovateClient { this.consecutiveSocketFailures = 0; console.log(`${this.tag} sync:${this.accountList.length} account(s), ${Object.keys(this.accountCashBalances).length} balance(s)`); + // Ahead of the fetch, so a balance change flagged here is + // fetched on this same pass. + this.observeBalances(); this.maybeFetchDaysTraded(); // The product list only feeds fetchInstrumentFees. Wait for it @@ -897,20 +917,32 @@ export class TradovateClient { !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, wiped, or flagged by a balance change — get their own + // fetch now rather than wait out the hour. A failure falls back to + // the 5-minute retry like any other. + const waiting = !initialFetchDue && !retryPending + ? this.accountList + .filter((a) => !this.historyLoadedIds.has(a.id) || this.historyRefreshIds.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; const reason = retryDue ? 'retry after failure' : initialFetchDue ? 'initial sync' - : 'hourly refresh'; + : hourlyRefreshDue + ? 'hourly refresh' + : `history for ${waiting.length} waiting account(s)`; this.daysFetchInFlight = true; this.lastDaysFetchStartedAt = now; if (retryDue) this.nextDaysFetchRetryAt = 0; - this.fetchDaysTraded() + this.fetchDaysTraded(targeted ? waiting : undefined) .then(({ failedAccounts }) => { if (failedAccounts > 0) { this.nextDaysFetchRetryAt = Date.now() + 5 * 60 * 1_000; @@ -918,7 +950,8 @@ export class TradovateClient { 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; @@ -1178,6 +1211,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.markHistoryLoaded(account.id, true); continue; } catch { // fall through to Fills fallback @@ -1256,16 +1290,108 @@ export class TradovateClient { this.lastFetchErrors[account.name] = `${msg} (using ${cached.length} cached entries from database)`; console.log(`${this.tag} fetchDaysTraded: using ${cached.length} cached entries from DB for ${account.name}`); } else { - this.dailyPnL[account.id] ??= []; + // No history at all is not an empty one: leave dailyPnL + // unset so 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; } } + // The Fills fallback, or the DB cache after both reports failed. + this.markHistoryLoaded(account.id, false); } this.fetchDaysComplete = true; return { failedAccounts }; } + /** + * Whether this account's history can be planned on — see PlatformClient. + * Loaded by this process, not wiped since, and no balance change the + * history can't explain. + */ + public historyReady(accountId: number): boolean { + if (!this.historyLoadedIds.has(accountId)) return false; + if (this.dailyPnL[accountId] === undefined || this.fundTransactions[accountId] === undefined) return false; + if (this.historyRefreshIds.has(accountId)) return false; + return !this.balanceDrifted(accountId); + } + + /** 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); + this.historyRefreshIds.delete(accountId); + delete this.balanceBaseline[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.lastFetchErrors[accountName]; + delete this.lastFetchRaw[accountName]; + } + + /** + * A fetch pass reached the account. Cash History is the only source that + * books fund transactions, so only it clears a balance-change hold; the + * Fills fallback and the DB cache still count as loaded otherwise. + */ + private markHistoryLoaded(accountId: number, fromCashHistory: boolean): void { + if (this.dailyPnL[accountId] === undefined || this.fundTransactions[accountId] === undefined) { + this.historyLoadedIds.delete(accountId); + return; + } + if (this.historyRefreshIds.has(accountId) && !fromCashHistory) return; + this.historyRefreshIds.delete(accountId); + this.historyLoadedIds.add(accountId); + const cash = this.accountCashBalances[accountId]; + if (cash) this.balanceBaseline[accountId] = { dayStart: cash.amount - cash.realizedPnL, realized: cash.realizedPnL }; + else delete this.balanceBaseline[accountId]; + } + + /** + * True when the balance moved in a way the loaded history can't explain — + * a payout or other fund transaction posting between the hourly Cash + * History runs. Planned on as is, the ended cycle's rows against the + * post-payout balance re-open a completed account. Today's trades move + * amount and realizedPnL together, and the daily reset folds the day's + * realized P&L into the balance; any other move past a few dollars holds + * the account until a Cash History books it (maybeFetchDaysTraded fetches + * it on the next sync). + */ + private balanceDrifted(accountId: number): boolean { + const cash = this.accountCashBalances[accountId]; + if (!cash) return false; + const dayStart = cash.amount - cash.realizedPnL; + const base = this.balanceBaseline[accountId]; + if (!base) { + this.balanceBaseline[accountId] = { dayStart, realized: cash.realizedPnL }; + return false; + } + if (Math.abs(dayStart - base.dayStart) <= FUND_DRIFT_TOLERANCE) { + base.realized = cash.realizedPnL; + return false; + } + if (Math.abs(dayStart - (base.dayStart + base.realized)) <= FUND_DRIFT_TOLERANCE) { + this.balanceBaseline[accountId] = { dayStart, realized: cash.realizedPnL }; + return false; + } + if (!this.historyRefreshIds.has(accountId)) { + this.historyRefreshIds.add(accountId); + const name = this.accountList.find((a) => a.id === accountId)?.name ?? accountId; + console.warn(`${this.tag} ${name}: balance moved $${Math.round((dayStart - base.dayStart) * 100) / 100}` + + ' that its history does not explain — held out of trading until Cash History refreshes'); + } + return true; + } + + /** Run the drift check on every loaded account each sync, so the day's + * last realized P&L is on record before the daily reset folds it in. */ + private observeBalances(): void { + for (const accountId of this.historyLoadedIds) this.balanceDrifted(accountId); + } + private async login(pTicket?: string): Promise { const chl = '' + (Date.now() - 1581e9); const sec = computeSec(this.name, this.password, this.deviceId, chl);