diff --git a/lib/auto-trade.ts b/lib/auto-trade.ts index c0b26e5..25533e3 100644 --- a/lib/auto-trade.ts +++ b/lib/auto-trade.ts @@ -381,6 +381,9 @@ function entryCommissionFrom(before: number, after: number, contracts: number): /** 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'; +/** Likewise for an account whose broker hasn't rolled its cash record to the + * new trade date, when this process didn't see the old day end. */ +const ROLL_PENDING = "waiting for the broker to roll the account's day"; interface AccountEvaluation { eligible: boolean; @@ -431,6 +434,7 @@ function evaluateAccountForTrade(client: any, acc: { id: number; name: string; a // 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 }; + if (cash.pendingRoll) return { ...base, eligible: false, reason: ROLL_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. @@ -1214,7 +1218,7 @@ function hasRemainingConfiguredAccounts(): boolean { // during the session and before it opens alike (a restart between // the 5 PM roll and the open loads histories right then). Only // once the session has closed is a still-loading account let go. - if (ev?.reason === HISTORY_PENDING && !isAfterSessionClose()) return true; + if ((ev?.reason === HISTORY_PENDING || ev?.reason === ROLL_PENDING) && !isAfterSessionClose()) return true; } } diff --git a/lib/platform-client.ts b/lib/platform-client.ts index 2a547ae..5309d64 100644 --- a/lib/platform-client.ts +++ b/lib/platform-client.ts @@ -23,7 +23,10 @@ export interface PlatformContract { export interface PlatformClient { accountList: PlatformAccount[]; - accountCashBalances: { [accountId: number]: { amount: number; realizedPnL: number } }; + /** realizedPnL is today's. pendingRoll (Tradovate): the broker still shows + * the ended day's record and today's share of it can't be told apart — + * the account waits for the broker to roll it. */ + accountCashBalances: { [accountId: number]: { amount: number; realizedPnL: number; pendingRoll?: boolean } }; positions: { [accountId: number]: { contractId: number | string; diff --git a/lib/topstepx-class.ts b/lib/topstepx-class.ts index 1947dd9..91193b8 100644 --- a/lib/topstepx-class.ts +++ b/lib/topstepx-class.ts @@ -881,14 +881,16 @@ export class TopstepXClient implements PlatformClient { // Withdrawal/deposit detection — the books must explain the balance. // Never off a failed fetch: a fresh balance against stale trades // would book the unseen P&L as a (permanent) fund event. + let reconciled = false; try { - if (accountFresh) this.reconcileFunds(acc); + if (accountFresh) reconciled = this.reconcileFunds(acc); } catch (err) { console.error(`[topstepx] fund reconcile failed for ${acc.name}: ${errorMessage(err)}`); } // A freshly fetched history is tradable once a fresh pass has - // had its go at booking the funds behind the balance. - if (accountFresh && this.reconcilePendingIds.delete(acc.id)) this.historyLoadedIds.add(acc.id); + // actually checked the funds behind the balance (a skipped + // check — a position still open — waits for a later pass). + if (reconciled && this.reconcilePendingIds.delete(acc.id)) this.historyLoadedIds.add(acc.id); try { this.updateMllFloor(acc); @@ -965,21 +967,24 @@ export class TopstepXClient implements PlatformClient { * the drain flow is well above it. Skipped while a position is open (fills * in flight) or while the trade history is known-stale. */ - private reconcileFunds(account: PlatformAccount): void { - if (this.positions[account.id]) return; - if (this.lastFetchErrors[account.name]) return; + /** Books a withdrawal/deposit the trades can't explain. True once it has + * actually checked the books against the balance (false when it had to + * skip: a position open, no history or balance yet, no account type). */ + private reconcileFunds(account: PlatformAccount): boolean { + if (this.positions[account.id]) return false; + if (this.lastFetchErrors[account.name]) return false; const history = this.fullDailyPnL[account.id]; - if (!history) return; // first daily-P&L fetch hasn't landed yet + if (!history) return false; // first daily-P&L fetch hasn't landed yet const cash = this.accountCashBalances[account.id]; - if (!cash) return; + if (!cash) return false; // The account's starting balance ($0 for Express, the funded size // otherwise) is part of the balance the books must explain — without // it a funded account books a phantom deposit for its whole size. Until // the account type is resolved that number is unknown, so hold off // rather than guess $0 and misfire on a funded account. const acctType = getAccountConfig(account.id).accountType; - if (!acctType) return; + if (!acctType) return false; const startBal = acctType.accountSize; const today = currentTradeDateCT(); const tradePnL = history @@ -993,7 +998,7 @@ export class TopstepXClient implements PlatformClient { // P&L and past payouts, not the end of a cycle this instance watched. const firstCheck = loadAccountMeta(account.id, 'funds_reconciled') === null; if (firstCheck) saveAccountMeta(account.id, 'funds_reconciled', today); - if (Math.abs(delta) < 200) return; + if (Math.abs(delta) < 200) return true; console.log(`[topstepx] ${account.name}: balance ${cash.amount} vs books ${Math.round((tradePnL + funds) * 100) / 100} — recording ${delta < 0 ? 'withdrawal' : 'deposit'} of ${delta}`); // A new cycle starts today: note where the one it ends began, by @@ -1014,6 +1019,7 @@ export class TopstepXClient implements PlatformClient { this.fundTransactions[account.id] = loadFundTransactions(account.id); this.lastFundDates[account.id] = today; this.applyCycleSlice(account.id); + return true; } /** @@ -1270,10 +1276,11 @@ export class TopstepXClient implements PlatformClient { } this.fetchDaysComplete = true; // The accounts just fetched wait on a reconciling sync: run one now - // rather than leave them out for up to a minute. (The firm's first - // pass syncs from maybeFetchDaysTraded once readiness allows.) + // 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.) if (this.reconcilePendingIds.size > 0 && this.historyLoaded && !this.intentionalDisconnect) { - void this.requestSync(); + await this.requestSync(); } return { failedAccounts }; } diff --git a/lib/tradovate-class.ts b/lib/tradovate-class.ts index 6df213d..da384a4 100644 --- a/lib/tradovate-class.ts +++ b/lib/tradovate-class.ts @@ -118,15 +118,36 @@ function reconnectBackoffMs(failures: number): number { } interface CashBaseline { dayStart: number; realized: number } -interface HistoryHold { reason: 'balance' | 'wipe'; fundCount: number; since: number; checks: number; retryAt: number } +/** fundSig: the fund transactions (date:amount) held when the hold began — a + * Cash History "books" the change when they differ. checks counts Cash + * History reports, attempts every refetch. */ +interface HistoryHold { reason: 'balance' | 'wipe'; fundSig: string; since: number; checks: number; attempts: number; retryAt: number } -/** A held account's refetch pace. */ +/** A held account's first refetch comes after this, then doubling to an hour. */ const HOLD_RETRY_MS = 5 * 60_000; +const HOLD_RETRY_MAX_MS = 60 * 60_000; /** A balance hold no Cash History ever explains with a fund transaction — a * fee, or an adjustment the report files as trading — is let go after this * many clean reports spanning at least HOLD_RELEASE_MS, with a warning. */ const HOLD_MAX_CHECKS = 3; const HOLD_RELEASE_MS = 15 * 60_000; +/** One no Cash History can ever check (the account's report fails for good — + * a passed account's 404s) is let go after this long, with a warning. */ +const HOLD_UNCHECKED_RELEASE_MS = 60 * 60_000; +/** account_meta keys: the baseline a Cash History vouched for, and a wipe's + * hold (so a restart onto a failing report keeps it). */ +const BASELINE_META = 'cash_baseline'; +const HOLD_META = 'history_hold'; + +function holdRetryMs(attempts: number): number { + return Math.min(HOLD_RETRY_MAX_MS, HOLD_RETRY_MS * 2 ** Math.max(0, attempts - 1)); +} + +/** Fund transactions as one comparable value: a second payout on a date that + * already has one changes the amount, not the row count. */ +function fundSignature(txns: { date: string; amount: number }[] | undefined): string { + return (txns ?? []).map((f) => `${f.date}:${f.amount}`).join('|'); +} /** 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 @@ -147,31 +168,55 @@ interface CashBalanceEntity { archived?: boolean; } +/** A stored cash record. realizedPnL and pendingRoll are judged when read. */ +interface StoredCash { + amount: number; + realizedPnL: number; + tradeDate: string | null; + archived: boolean; + /** The record's realizedPnL as last seen while its trade date was the + * current one — the ended day's final figure once the 5 PM roll passes. */ + rollBase: number | undefined; + /** Past the roll with no rollBase seen (the process started after it): + * today's share of the record can't be told apart, so the account waits + * for Tradovate to roll it. */ + pendingRoll: boolean; +} + /** * 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 * record and opening one with realizedPnL 0 — well after the 17:00 CT roll - * (18 minutes on 2026-09-24), or not until the account first trades. Until - * then the latest record still carries the ended day's realizedPnL, which the - * plan would read as today's: legs sized off yesterday, one-and-done accounts - * read as already traded, the entry-commission delta swallowing a whole day's - * P&L. That day is over, and its P&L is already in amount: nothing is - * realized today yet. + * (17-21 minutes on 2026-09-24). Until then the latest record still carries + * the ended day's realizedPnL, which the plan would read as today's: legs + * sized off yesterday, one-and-done accounts read as already traded, the + * entry-commission delta swallowing a whole day's P&L. And tonight's own + * fills before the roll may be booked onto that same old record. So past the + * 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. */ -function todaysCash(cb: CashBalanceEntity): { amount: number; realizedPnL: number; tradeDate: string | null; archived: boolean } { +function todaysCash(cb: CashBalanceEntity, prev: StoredCash | undefined): StoredCash { const t = cb.tradeDate; const tradeDate = t?.year ? `${t.year}-${String(t.month).padStart(2, '0')}-${String(t.day).padStart(2, '0')}` : null; 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(); return { amount: cb.amount, tradeDate, archived: cb.archived === true, - // Judged when read, not when stored: a record stored at 16:59 has - // ended by 17:01, whether or not a sync has come in since. + rollBase, get realizedPnL(): number { - return tradeDate !== null && tradeDate < currentTradeDateCT() ? 0 : recorded; + if (!ended()) return recorded; + return rollBase === undefined ? 0 : Math.round((recorded - rollBase) * 100) / 100; + }, + get pendingRoll(): boolean { + return ended() && rollBase === undefined; }, }; } @@ -179,17 +224,21 @@ function todaysCash(cb: CashBalanceEntity): { amount: number; realizedPnL: numbe /** * 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 - * new one together, in either order; the older must never win back. + * new one together, in either order; the older must never win back. carried + * is the entry a fresh snapshot map replaces: it hands on the roll base, and + * a snapshot requested before the socket delivered a newer record can't + * undo it. */ -function storeCash( - into: { [accountId: number]: { amount: number; realizedPnL: number; tradeDate?: string | null; archived?: boolean } }, - cb: CashBalanceEntity, -): void { - const next = todaysCash(cb); - const prev = into[cb.accountId]; - if (prev?.tradeDate && next.tradeDate) { - if (next.tradeDate < prev.tradeDate) return; - if (next.tradeDate === prev.tradeDate && next.archived && !prev.archived) return; +function storeCash(into: { [accountId: number]: StoredCash }, cb: CashBalanceEntity, carried?: StoredCash): void { + const held = into[cb.accountId] ?? carried; + const next = todaysCash(cb, held); + if (held?.tradeDate && next.tradeDate) { + const older = next.tradeDate < held.tradeDate + || (next.tradeDate === held.tradeDate && next.archived && !held.archived); + if (older) { + into[cb.accountId] ??= held; + return; + } } into[cb.accountId] = next; } @@ -220,17 +269,9 @@ export class TradovateClient { private deviceId = randomUUIDV4(); public accountList: AccountItem[] = []; - public accountCashBalances: { - [accountId: number]: { - amount: number; - /** Today's realized P&L — see todaysCash. */ - realizedPnL: number; - /** The trade date of the Tradovate record it came from (YYYY-MM-DD). */ - tradeDate?: string | null; - /** That record was superseded at a roll. */ - archived?: boolean; - }; - } = {}; + /** Per account, its latest cash record — today's realized P&L judged at + * read time (see todaysCash). */ + public accountCashBalances: { [accountId: number]: StoredCash } = {}; public positions: { [accountId: number]: { contractId: number; @@ -921,7 +962,9 @@ export class TradovateClient { }); const balances: TradovateClient['accountCashBalances'] = {}; - for (const item of (response.cashBalances ?? []) as CashBalanceEntity[]) storeCash(balances, item); + for (const item of (response.cashBalances ?? []) as CashBalanceEntity[]) { + storeCash(balances, item, this.accountCashBalances[item.accountId]); + } this.accountCashBalances = balances; this.positions = (response.positions ?? []) @@ -1234,6 +1277,7 @@ export class TradovateClient { // while the report is in flight isn't absorbed unexplained. const cashNow = this.accountCashBalances[account.id]; const cashBefore = cashNow ? { amount: cashNow.amount, realizedPnL: cashNow.realizedPnL } : null; + const fundSigBefore = fundSignature(this.fundTransactions[account.id]); // --- Try Cash History first (true after-fee daily P&L) --- try { @@ -1319,7 +1363,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, cashBefore); + this.markHistoryLoaded(account.id, true, cashBefore, fundSigBefore); continue; } catch { // fall through to Fills fallback @@ -1427,10 +1471,11 @@ export class TradovateClient { /** Drop the account's history from memory (a wipe): it reads unloaded, and * stays out of trading, until a Cash History rebuilds it — the wipe took * its fund transactions, which neither the Fills report nor the cache can - * bring back. */ + * 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.historyHolds.set(accountId, { reason: 'wipe', fundCount: 0, since: Date.now(), checks: 0, retryAt: 0 }); + this.hold(accountId, 'wipe', ''); + saveAccountMeta(accountId, HOLD_META, 'wipe'); delete this.balanceBaseline[accountId]; delete this.dailyPnL[accountId]; delete this.fullDailyPnL[accountId]; @@ -1442,22 +1487,32 @@ export class TradovateClient { delete this.lastFetchRaw[accountName]; } + private hold(accountId: number, reason: HistoryHold['reason'], fundSig: string, retryAt = 0): void { + this.historyHolds.set(accountId, { reason, fundSig, since: Date.now(), checks: 0, attempts: 0, retryAt }); + } + + private accountName(accountId: number): string | number { + return this.accountList.find((a) => a.id === accountId)?.name ?? accountId; + } + /** * A fetch pass reached the account. Cash History is the only source that - * books fund transactions, so only it can lift a hold, and a balance hold - * only once it books a fund transaction the account didn't have when the - * hold began. The Fills fallback and the DB cache still count as loaded. + * books fund transactions, so only it can lift a hold — a balance hold + * only once its fund transactions differ from when the hold began. The + * Fills fallback and the DB cache still count as loaded. * - * cashBefore is the balance when the Cash History was requested: the - * baseline comes from it, not from the balance when the report lands, so - * a payout posting in between is still caught — and if the balance did - * move in between, this report may not have seen it, so the account is - * held for the next one. + * cashBefore / fundSigBefore are the balance and fund transactions when + * the Cash History was requested. The balance is judged against the + * baseline this history last vouched for: if it moved and this report + * booked nothing, the move is held for a later report rather than taken + * as the new baseline; if it moved while the report was in flight, this + * report may not have seen it, so it is held for the next one. */ private markHistoryLoaded( accountId: number, fromCashHistory: boolean, cashBefore: { amount: number; realizedPnL: number } | null = null, + fundSigBefore = '', ): void { if (this.dailyPnL[accountId] === undefined || this.fundTransactions[accountId] === undefined) { this.historyLoadedIds.delete(accountId); @@ -1465,80 +1520,108 @@ export class TradovateClient { } this.historyLoadedIds.add(accountId); const now = Date.now(); + if (!this.historyHolds.has(accountId) && loadAccountMeta(accountId, HOLD_META) === 'wipe') { + this.hold(accountId, 'wipe', ''); // a wipe from before a restart + } const hold = this.historyHolds.get(accountId); + if (!this.balanceBaseline[accountId]) { + const saved = this.savedBaseline(accountId); + if (saved) this.balanceBaseline[accountId] = saved; + } if (!fromCashHistory) { if (hold) { - hold.retryAt = now + HOLD_RETRY_MS; + hold.attempts++; + hold.retryAt = now + holdRetryMs(hold.attempts); + if (hold.reason === 'balance' && 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 — Cash History never came back; releasing it`); + this.historyHolds.delete(accountId); + this.setBaseline(accountId, this.accountCashBalances[accountId] ?? null); + } return; } // Keep the baseline a Cash History set — this process's, or the - // last one on record (a restart onto a failing report server) — - // rather than take today's balance, which may hold an unbooked - // payout; the drift check then judges it. - if (!this.balanceBaseline[accountId]) { - const saved = loadAccountMeta(accountId, 'cash_baseline'); - try { - const parsed = saved ? JSON.parse(saved) as CashBaseline : null; - if (parsed && Number.isFinite(parsed.dayStart) && Number.isFinite(parsed.realized)) { - this.balanceBaseline[accountId] = parsed; - } - } catch { /* unreadable: judged from the live balance below */ } - } + // last one on record — rather than take today's balance, which may + // hold an unbooked payout; the drift check then judges it. this.balanceDrifted(accountId); return; } + const fundSigNow = fundSignature(this.fundTransactions[accountId]); const cashAfter = this.accountCashBalances[accountId]; const before = cashBefore ?? cashAfter ?? null; - const base: CashBaseline | null = before - ? { dayStart: before.amount - before.realizedPnL, realized: before.realizedPnL } - : null; - const movedDuringFetch = base !== null && cashAfter !== undefined && cashExplained(base, cashAfter) === null; - const name = this.accountList.find((a) => a.id === accountId)?.name ?? accountId; if (hold) { hold.checks++; - const booked = (this.fundTransactions[accountId]?.length ?? 0) > hold.fundCount; - if (hold.reason === 'balance' && !booked) { + hold.attempts++; + if (hold.reason === 'balance' && fundSigNow === hold.fundSig) { if (hold.checks < HOLD_MAX_CHECKS || now - hold.since < HOLD_RELEASE_MS) { - hold.retryAt = now + HOLD_RETRY_MS; + hold.retryAt = now + holdRetryMs(hold.attempts); return; } - console.warn(`${this.tag} ${name}: ${hold.checks} Cash History reports over` + console.warn(`${this.tag} ${this.accountName(accountId)}: ${hold.checks} Cash History reports over` + ` ${Math.round((now - hold.since) / 60_000)}m booked no fund transaction for its balance change —` + ' releasing it (a fee, or an adjustment the report files as trading)'); - // Nothing will explain it: take the balance as it now stands. this.historyHolds.delete(accountId); this.setBaseline(accountId, cashAfter ?? null); return; } + // Booked (balance), or rebuilt from Cash History (wipe). this.historyHolds.delete(accountId); + if (hold.reason === 'wipe') saveAccountMeta(accountId, HOLD_META, ''); + this.setBaseline(accountId, before); + return; } - if (movedDuringFetch) { - this.historyHolds.set(accountId, { - reason: 'balance', - fundCount: this.fundTransactions[accountId]?.length ?? 0, - since: now, checks: 0, retryAt: now + 60_000, - }); - this.balanceBaseline[accountId] = base!; - console.warn(`${this.tag} ${name}: balance moved while its Cash History was in flight — held for the next report`); + // 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. + const known = this.balanceBaseline[accountId]; + if (known && before && cashExplained(known, before) === null && fundSigNow === fundSigBefore) { + this.hold(accountId, 'balance', fundSigBefore, now + 60_000); + console.warn(`${this.tag} ${this.accountName(accountId)}: balance moved $` + + `${Math.round((before.amount - before.realizedPnL - known.dayStart) * 100) / 100}` + + ' and its Cash History booked nothing yet — held for a later report'); + return; + } + const base = before ? { dayStart: before.amount - before.realizedPnL, realized: before.realizedPnL } : null; + if (base && cashAfter && cashExplained(base, cashAfter) === null) { + // Held against the fund transactions from before this report, so + // the next report lifts it if this one already booked the change. + this.hold(accountId, 'balance', fundSigBefore, now + 60_000); + this.balanceBaseline[accountId] = base; + console.warn(`${this.tag} ${this.accountName(accountId)}: balance moved while its Cash History was in flight — held for the next report`); return; } this.setBaseline(accountId, before); } - /** Set (and keep on record, for a restart that can't reach Cash History) - * the baseline a Cash History vouched for. */ + /** The baseline last vouched for, as kept on record across restarts. */ + private savedBaseline(accountId: number): CashBaseline | null { + try { + const saved = loadAccountMeta(accountId, BASELINE_META); + const parsed = saved ? JSON.parse(saved) as Partial : null; + if (parsed && Number.isFinite(parsed.dayStart) && Number.isFinite(parsed.realized)) { + return { dayStart: parsed.dayStart!, realized: parsed.realized! }; + } + } catch { /* unreadable: none */ } + return null; + } + + /** Set the baseline and keep it on record, so a restart that can't reach + * Cash History judges the balance against it. */ private setBaseline(accountId: number, cash: { amount: number; realizedPnL: number } | null): void { if (!cash) { delete this.balanceBaseline[accountId]; return; } - const base = { dayStart: cash.amount - cash.realizedPnL, realized: cash.realizedPnL }; - this.balanceBaseline[accountId] = base; - saveAccountMeta(accountId, 'cash_baseline', JSON.stringify(base)); + this.balanceBaseline[accountId] = { dayStart: cash.amount - cash.realizedPnL, realized: cash.realizedPnL }; + this.saveBaseline(accountId); + } + + private saveBaseline(accountId: number): void { + const base = this.balanceBaseline[accountId]; + if (base) saveAccountMeta(accountId, BASELINE_META, JSON.stringify({ ...base, tradeDate: currentTradeDateCT() })); } /** @@ -1549,33 +1632,33 @@ export class TradovateClient { * 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). + * it on the next sync). What the baseline learns is kept on record, so a + * restart judges against the day's last realized P&L, not a stale one. */ private balanceDrifted(accountId: number): boolean { const cash = this.accountCashBalances[accountId]; if (!cash) return false; const base = this.balanceBaseline[accountId]; if (!base) { - this.balanceBaseline[accountId] = { dayStart: cash.amount - cash.realizedPnL, realized: cash.realizedPnL }; + this.setBaseline(accountId, cash); return false; } const how = cashExplained(base, cash); if (how === 'same') { - base.realized = cash.realizedPnL; + if (Math.abs(cash.realizedPnL - base.realized) >= 1) { + base.realized = cash.realizedPnL; + this.saveBaseline(accountId); + } return false; } if (how === 'folded') { - this.balanceBaseline[accountId] = { dayStart: cash.amount - cash.realizedPnL, realized: cash.realizedPnL }; + this.setBaseline(accountId, cash); return false; } if (!this.historyHolds.has(accountId)) { - this.historyHolds.set(accountId, { - reason: 'balance', - fundCount: this.fundTransactions[accountId]?.length ?? 0, - since: Date.now(), checks: 0, retryAt: 0, - }); - const name = this.accountList.find((a) => a.id === accountId)?.name ?? accountId; - console.warn(`${this.tag} ${name}: balance moved $${Math.round((cash.amount - cash.realizedPnL - base.dayStart) * 100) / 100}` + this.hold(accountId, 'balance', fundSignature(this.fundTransactions[accountId])); + console.warn(`${this.tag} ${this.accountName(accountId)}: balance moved` + + ` $${Math.round((cash.amount - cash.realizedPnL - base.dayStart) * 100) / 100}` + ' that its history does not explain — held out of trading until Cash History books it'); } return true;