From e49640498a31830463c32172e5e87c82f8fa175c Mon Sep 17 00:00:00 2001 From: Brandon Li Date: Thu, 24 Sep 2026 17:22:43 -0500 Subject: [PATCH] Tradovate: yesterday's realized P&L no longer reads as today's after the roll Tradovate rolls each account's cash record to the new trade date on its own schedule (18 minutes after the 17:00 CT open on 2026-09-24, or not until the account trades), and until then the latest record still carries the ended day's realizedPnL. Plans sized legs off it, one-and-done accounts read as already traded, the reporter scored every account partial/loss, and an entry across the record roll took a whole day's P&L as its "commission", parking the take-profit out of reach. - Cash records keep their trade date; realized P&L from an earlier trade date reads as 0 (judged when read, so a record stored before the roll counts as ended after it). Archived or older records never overwrite a newer one, whichever order the roll pair arrives in. - The entry-commission delta falls back to $2.50/contract when it exceeds any real fee ($10/contract). Co-Authored-By: Claude Opus 5.5 --- lib/auto-trade.ts | 27 +++++++++++++- lib/tradovate-class.ts | 85 +++++++++++++++++++++++++++++++++--------- 2 files changed, 92 insertions(+), 20 deletions(-) diff --git a/lib/auto-trade.ts b/lib/auto-trade.ts index 00bc837..a8e91f1 100644 --- a/lib/auto-trade.ts +++ b/lib/auto-trade.ts @@ -353,6 +353,29 @@ async function legInstrument( return { contract: mnq, pointValue: POINT_VALUES['MNQ'] }; } +/** An entry's fee when the balance update hasn't shown it yet — at least one + * extra tick on the exit. */ +const FALLBACK_COMMISSION_PER_CONTRACT = 2.5; +/** No real entry fee comes near this per contract. */ +const MAX_COMMISSION_PER_CONTRACT = 10; + +/** + * The entry fee, read off the realized-P&L delta around the fill. Anything + * past a real per-contract fee is something else moving realized P&L — a + * broker record rolling to the new trade date under the entry swapped a whole + * day's P&L into the "fee", and doubled into the exit target that parked the + * take-profit out of reach — so it falls back like a missing update does. + */ +function entryCommissionFrom(before: number, after: number, contracts: number): number { + const delta = Math.abs(after - before); + if (delta > 0 && delta <= MAX_COMMISSION_PER_CONTRACT * contracts) return delta; + if (delta > 0) { + console.warn(`[auto-trade] realized P&L moved $${Math.round(delta * 100) / 100} around a ${contracts}-contract entry` + + ` — not a fee; using $${FALLBACK_COMMISSION_PER_CONTRACT}/contract`); + } + return FALLBACK_COMMISSION_PER_CONTRACT * contracts; +} + // ── per-account trade evaluation (shared by runTrade / copyTrade / hasRemaining…) ── /** The ineligible reason for an account whose history isn't loaded yet — still @@ -776,7 +799,7 @@ async function runTradeLocked( // After entry, realizedPnL drops by the entry commission; the delta from the // pre-entry value is the entry fee paid (pre-entry may be nonzero mid-day). // Fall back to $2.50/contract if the WS hasn't updated yet (guarantees at least 1 extra tick). - const entryCommission = Math.abs(updatedCash.realizedPnL - item.cash.realizedPnL) || (2.5 * contracts); + const entryCommission = entryCommissionFrom(item.cash.realizedPnL, updatedCash.realizedPnL, contracts); const totalCommission = entryCommission * 2; // entry + exit round-trip const grossTarget = legAmount + totalCommission; @@ -1064,7 +1087,7 @@ async function copyTradeLocked(lease: number): Promise { await new Promise(r => setTimeout(r, 1000)); const updatedCash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 }; - const entryCommission = Math.abs(updatedCash.realizedPnL - item.cash.realizedPnL) || (2.5 * contracts); + const entryCommission = entryCommissionFrom(item.cash.realizedPnL, updatedCash.realizedPnL, contracts); const totalCommission = entryCommission * 2; const grossTarget = legAmount + totalCommission; diff --git a/lib/tradovate-class.ts b/lib/tradovate-class.ts index 9b4c36e..60a3f8d 100644 --- a/lib/tradovate-class.ts +++ b/lib/tradovate-class.ts @@ -3,7 +3,7 @@ import axios from 'axios'; import type { AccountItem, AuthLoginResponse, Contract } from './tradovate-helpers'; import { computeSec, randomUUIDV4 } from './tradovate-helpers'; -import { POINT_VALUES } from './trading-logic'; +import { POINT_VALUES, currentTradeDateCT } from './trading-logic'; import { getCachedContract, resolveContracts } from './contract-resolver'; import { saveDailyPnL, loadDailyPnL, saveAccountMeta, loadAccountMeta, saveFundTransactions, loadFundTransactions, saveEndedCycleStart } from './db'; import { brokerWebSocket } from './outbound-proxy'; @@ -117,6 +117,62 @@ function reconnectBackoffMs(failures: number): number { return Math.min(BACKOFF_BASE_MS * 2 ** Math.max(0, failures - 1), BACKOFF_MAX_MS); } +/** A Tradovate cashBalance entity, as the sync snapshot and socket carry it. */ +interface CashBalanceEntity { + accountId: number; + amount: number; + realizedPnL: number; + tradeDate?: { year: number; month: number; day: number } | null; + archived?: 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. + */ +function todaysCash(cb: CashBalanceEntity): { amount: number; realizedPnL: number; tradeDate: string | null; archived: boolean } { + 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; + 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. + get realizedPnL(): number { + return tradeDate !== null && tradeDate < currentTradeDateCT() ? 0 : 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 + * new one together, in either order; the older must never win back. + */ +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; + } + into[cb.accountId] = next; +} + /** ±20%, so several clients on one IP that failed together don't retry in lockstep. */ function jitter(ms: number): number { return Math.round(ms * (0.8 + Math.random() * 0.4)); @@ -146,7 +202,12 @@ export class TradovateClient { 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; }; } = {}; public positions: { @@ -575,13 +636,8 @@ export class TradovateClient { // Update cash balances from WebSocket cashBalance events if (response.d?.entityType === 'cashBalance' && response.d?.entity) { - const cb = response.d.entity; - if (cb.accountId) { - this.accountCashBalances[cb.accountId] = { - amount: cb.amount, - realizedPnL: cb.realizedPnL, - }; - } + const cb = response.d.entity as CashBalanceEntity; + if (cb.accountId) storeCash(this.accountCashBalances, cb); } } } @@ -837,16 +893,9 @@ export class TradovateClient { return account; }); - this.accountCashBalances = (response.cashBalances ?? []).reduce( - ( - acc: { [accountId: number]: { amount: number; realizedPnL: number } }, - item: { accountId: number; amount: number; realizedPnL: number } - ) => { - acc[item.accountId] = { amount: item.amount, realizedPnL: item.realizedPnL }; - return acc; - }, - {} as { [accountId: number]: { amount: number; realizedPnL: number } } - ); + const balances: TradovateClient['accountCashBalances'] = {}; + for (const item of (response.cashBalances ?? []) as CashBalanceEntity[]) storeCash(balances, item); + this.accountCashBalances = balances; this.positions = (response.positions ?? []) .filter((item: any) => item.netPos !== 0)