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:
co-authored by
Claude Opus 5.5
parent
77d8c2ecdb
commit
e49640498a
+25
-2
@@ -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<unknown[]> {
|
||||
|
||||
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;
|
||||
|
||||
|
||||
+67
-18
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user