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 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-09-24 17:22:43 -05:00
co-authored by Claude Opus 5.5
parent 77d8c2ecdb
commit e49640498a
2 changed files with 92 additions and 20 deletions
+67 -18
View File
@@ -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)