DXtrade: volatility measured once a day, next-leg hold on the dashboard, closed accounts dead

Each instrument's volatility is measured at the first good sync (or pick) of
a trade date and held for the day, so a day's sizing and hold estimate stay
put. The dashboard shows each DXtrade account's next-leg hold ("hold ~Nh")
with its symbol, notional, volatility and win odds. Accounts the firm has
closed (TERMINATED, EXPIRED, NO_TRADING) read as dead instead of inactive,
and skip the books check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-09-26 17:40:42 -05:00
co-authored by Claude Opus 5.5
parent 9fc1ed6908
commit 6876223fa4
4 changed files with 94 additions and 7 deletions
+40 -2
View File
@@ -3,10 +3,12 @@ import { loadDailyPnL } from '@/lib/db';
import { getClients } from '@/lib/clients'; import { getClients } from '@/lib/clients';
import { getCachedCatalog } from '@/lib/firm-catalog'; import { getCachedCatalog } from '@/lib/firm-catalog';
import { getAccountConfig, getInferredSize, matchAccountType } from '@/lib/account-type-resolver'; import { getAccountConfig, getInferredSize, matchAccountType } from '@/lib/account-type-resolver';
import { historyFresh } from '@/lib/platform-client'; import { historyFresh, type PlatformContract } from '@/lib/platform-client';
import type { AccountState } from '@/types';
import { import {
combineCmeSessions, computeDayPlan, cycleCompletion, resolveEffectiveConfig, currentTradeDateCT, combineCmeSessions, computeDayPlan, cycleCompletion, resolveEffectiveConfig, currentTradeDateCT,
obfuscationStopDollars, dllRoom as computeDllRoom, dllStopDollars, botStopsActive, obfuscationStopDollars, dllRoom as computeDllRoom, dllStopDollars, botStopsActive, sizeNotional,
DEFAULT_TARGET_HOLD_HOURS,
LEG_COMPLETION_EPSILON, LEG_COMPLETION_EPSILON,
} from '@/lib/trading-logic'; } from '@/lib/trading-logic';
@@ -86,6 +88,7 @@ export async function GET() {
let effectiveProfitTarget: number | null = null; let effectiveProfitTarget: number | null = null;
let nextStop: number | null = null; let nextStop: number | null = null;
let dllRoom: number | null = null; let dllRoom: number | null = null;
let holdEstimate: AccountState['holdEstimate'] = null;
if (cfg && effective && !isDead) { if (cfg && effective && !isDead) {
const equityProfit = cash.amount - cfg.accountSize; const equityProfit = cash.amount - cfg.accountSize;
const plan = computeDayPlan( const plan = computeDayPlan(
@@ -121,6 +124,40 @@ export async function GET() {
if (Number.isFinite(stop) && stop > 0) nextStop = Math.round(stop * 100) / 100; if (Number.isFinite(stop) && stop > 0) nextStop = Math.round(stop * 100) / 100;
} }
// DXtrade: size the next leg the way fireLeg would, on the
// instrument the selector last picked and today's measured
// volatility, to show how long it is expected to hold.
const pick = (client as { lastPickedContract?: PlatformContract | null }).lastPickedContract;
if (plan && plan.remainingToday > 0 && pick?.stepNotional && pick.volatilityPerHour) {
const pct = pick.commissionPct ?? f.commissionPct ?? 0;
const obf = obfuscationStopDollars(cfg, dailyPnL, plan, combined.tradeDate, plan.remainingToday);
const room = dllRoom;
const sizing = sizeNotional({
leg: plan.remainingToday,
stopDollarsAt: (n) => Math.min(
Number.isFinite(obf) && obf > 0 ? obf : Infinity,
room !== null && dll > 0
? dllStopDollars({ room, dailyLossLimit: dll, entryFee: (pct / 100) * n, commissionPct: pct })
: Infinity,
),
volatilityPerHour: pick.volatilityPerHour,
targetHoldHours: f.targetHoldHours || DEFAULT_TARGET_HOLD_HOURS,
commissionPct: pct,
spreadFraction: pick.spreadFraction,
financingPctPerDay: f.financingPctPerDay ?? 0,
maxNotional: cfg.maxNotional ?? 0,
});
if (sizing) {
holdEstimate = {
hours: Math.round(sizing.predictedHoldHours * 10) / 10,
symbol: String(pick.id),
notional: Math.round(sizing.notional),
volatilityPerDay: pick.volatilityPerHour * Math.sqrt(24),
winProbability: sizing.winProbability,
};
}
}
// Effective profit target = max(stage target, consistency realTarget). // Effective profit target = max(stage target, consistency realTarget).
// If a big day forces the consistency rule, the account must reach maxDay/consistency // If a big day forces the consistency rule, the account must reach maxDay/consistency
// in total trading profit for the stage, not just profitTarget. // in total trading profit for the stage, not just profitTarget.
@@ -228,6 +265,7 @@ export async function GET() {
dailyTarget, dailyTarget,
nextStop, nextStop,
dllRoom, dllRoom,
holdEstimate,
remainingToday, remainingToday,
effectiveProfitTarget, effectiveProfitTarget,
dailyPnL, dailyPnL,
+9
View File
@@ -390,6 +390,15 @@ function AccountRow({ account, firm, hideDead, privacy, onAssigned }: {
{typeof account.dllRoom === 'number' && ( {typeof account.dllRoom === 'number' && (
<span className="text-slate-500" title="Hard daily loss limit: what the firm's day may still lose before a breach. 10% of the limit is never spent."> · DLL room ${fmt(account.dllRoom)}</span> <span className="text-slate-500" title="Hard daily loss limit: what the firm's day may still lose before a breach. 10% of the limit is never spent."> · DLL room ${fmt(account.dllRoom)}</span>
)} )}
{account.holdEstimate && (
<span
className="text-slate-500"
title={`Next leg on ${account.holdEstimate.symbol}: $${Math.round(account.holdEstimate.notional).toLocaleString()} notional at today's measured volatility of ${(account.holdEstimate.volatilityPerDay * 100).toFixed(2)}%/day`
+ (account.holdEstimate.winProbability !== null ? `, wins ~${Math.round(account.holdEstimate.winProbability * 100)}% of the time` : '')}
>
{' · hold ~'}{account.holdEstimate.hours < 48 ? `${Math.round(account.holdEstimate.hours)}h` : `${(account.holdEstimate.hours / 24).toFixed(1)}d`}
</span>
)}
</span> </span>
)} )}
</div> </div>
+35 -5
View File
@@ -93,6 +93,11 @@ const REAP_AFTER_MS = 60_000;
/** A position this old with no working order is flagged as unprotected. */ /** A position this old with no working order is flagged as unprotected. */
const NAKED_AFTER_MS = 2 * 60_000; const NAKED_AFTER_MS = 2 * 60_000;
/** accountStatus values that mean the firm has closed the account for good
* (TERMINATED after a breach, EXPIRED, NO_TRADING when nothing may trade).
* CLOSE_ONLY is left inactive rather than dead: it can still unwind. */
const DEAD_STATUSES = new Set(['TERMINATED', 'EXPIRED', 'NO_TRADING']);
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const round2 = (n: number) => Math.round(n * 100) / 100; const round2 = (n: number) => Math.round(n * 100) / 100;
@@ -329,7 +334,12 @@ export class DXTradeClient implements PlatformClient {
private pickCache = new Map<string, { contract: PlatformContract; at: number }>(); private pickCache = new Map<string, { contract: PlatformContract; at: number }>();
private instrumentCache = new Map<string, { inst: Record<string, unknown>; at: number }>(); private instrumentCache = new Map<string, { inst: Record<string, unknown>; at: number }>();
private accountInstrumentCache = new Map<string, { det: Record<string, unknown>; at: number }>(); private accountInstrumentCache = new Map<string, { det: Record<string, unknown>; at: number }>();
private candleCache = new Map<string, { vol: number | null; candles: number; at: number }>(); /** Volatility per symbol, measured once per trade date (see volatility). */
private candleCache = new Map<string, { vol: number | null; candles: number; at: number; tradeDate: string }>();
/** The last instrument the selector picked — what the dashboard's hold
* estimate sizes against. */
public lastPickedContract: PlatformContract | null = null;
private lastPickTradeDate = '';
private unmappedLoggedAt = new Map<string, number>(); private unmappedLoggedAt = new Map<string, number>();
constructor(cfg: DXTradeConfig, onSync: () => Promise<void>) { constructor(cfg: DXTradeConfig, onSync: () => Promise<void>) {
@@ -680,7 +690,10 @@ export class DXTradeClient implements PlatformClient {
if (!isFundType(t.type) && currentTradeDateCT(new Date(t.time)) === today) realized += t.value; if (!isFundType(t.type) && currentTradeDateCT(new Date(t.time)) === today) realized += t.value;
} }
this.accountCashBalances[acc.id] = { amount: balance, realizedPnL: round2(realized), equity: ciNum(m, 'equity') }; this.accountCashBalances[acc.id] = { amount: balance, realizedPnL: round2(realized), equity: ciNum(m, 'equity') };
this.autoLiqThresholds[acc.id] = 0; // No floor to report — except for an account the firm has
// closed for good (a breach): the balance itself, so the
// dashboards and the reporter count it dead, not just inactive.
this.autoLiqThresholds[acc.id] = DEAD_STATUSES.has(this.accountMeta.get(acc.id)?.status ?? '') ? balance : 0;
this.dayFresh.add(acc.id); this.dayFresh.add(acc.id);
// Keep the reset snapshot handy for the debug view. // Keep the reset snapshot handy for the debug view.
this.lastFetchRaw[`dayStart:${acc.name}`] = String(this.dayStartFromTxns(acc.id, balance, resetMs)); this.lastFetchRaw[`dayStart:${acc.name}`] = String(this.dayStartFromTxns(acc.id, balance, resetMs));
@@ -693,6 +706,13 @@ export class DXTradeClient implements PlatformClient {
console.log(`${this.tag} sync — ${list.length} account(s), ${Object.keys(this.positions).length} open position(s)`); console.log(`${this.tag} sync — ${list.length} account(s), ${Object.keys(this.positions).length} open position(s)`);
this.maybeFetchDaysTraded(); this.maybeFetchDaysTraded();
// Measure the day's volatility (and so the hold estimate) at the
// first good sync of each trade date, whether or not a batch fires.
if (this.historyLoaded && this.lastPickTradeDate !== currentTradeDateCT()) {
this.lastPickTradeDate = currentTradeDateCT();
void this.findFrontMonthContract('*').catch((err) =>
console.warn(`${this.tag} daily volatility check failed: ${errorMessage(err)}`));
}
this.syncComplete = true; this.syncComplete = true;
if (!fresh || this.dayFresh.size < list.length) { if (!fresh || this.dayFresh.size < list.length) {
@@ -964,7 +984,8 @@ export class DXTradeClient implements PlatformClient {
const size = getAccountConfig(account.id).accountType?.accountSize ?? 0; const size = getAccountConfig(account.id).accountType?.accountSize ?? 0;
const hasDeposit = mine.some((t) => t.type === 'DEPOSIT'); const hasDeposit = mine.some((t) => t.type === 'DEPOSIT');
const expected = mine.reduce((s, t) => s + (isFundType(t.type) ? signedFundValue(t.type, t.value) : t.value), 0) + (hasDeposit ? 0 : size); const expected = mine.reduce((s, t) => s + (isFundType(t.type) ? signedFundValue(t.type, t.value) : t.value), 0) + (hasDeposit ? 0 : size);
if (cash && !this.positions[account.id] && Math.abs(cash.amount - expected) >= BOOKS_TOLERANCE) { const tradable = this.accountMeta.get(account.id)?.status === 'FULL_TRADING';
if (tradable && cash && !this.positions[account.id] && Math.abs(cash.amount - expected) >= BOOKS_TOLERANCE) {
this.booksHeld.add(account.id); this.booksHeld.add(account.id);
this.lastFetchErrors[account.name] = `balance ${round2(cash.amount)} vs transactions ${round2(expected)} — held until they agree`; this.lastFetchErrors[account.name] = `balance ${round2(cash.amount)} vs transactions ${round2(expected)} — held until they agree`;
console.warn(`${this.tag} ${account.name}: ${this.lastFetchErrors[account.name]}`); console.warn(`${this.tag} ${account.name}: ${this.lastFetchErrors[account.name]}`);
@@ -1120,7 +1141,13 @@ export class DXTradeClient implements PlatformClient {
private async volatility(account: string, symbols: string[]): Promise<Map<string, { vol: number | null; candles: number }>> { private async volatility(account: string, symbols: string[]): Promise<Map<string, { vol: number | null; candles: number }>> {
const now = Date.now(); const now = Date.now();
const missing = symbols.filter((s) => !(now - (this.candleCache.get(s)?.at ?? 0) < CANDLE_TTL_MS)); // Measured once per trade date, so a day's sizing and hold estimate
// stay put; a measurement that came back empty retries after a while.
const today = currentTradeDateCT();
const missing = symbols.filter((s) => {
const c = this.candleCache.get(s);
return !c || c.tradeDate !== today || (c.vol === null && now - c.at >= CANDLE_TTL_MS);
});
if (missing.length > 0) { if (missing.length > 0) {
const closes = new Map<string, number[]>(); const closes = new Map<string, number[]>();
let fromDx = false; let fromDx = false;
@@ -1151,7 +1178,9 @@ export class DXTradeClient implements PlatformClient {
} }
for (const s of missing) { for (const s of missing) {
const list = closes.get(s) ?? []; const list = closes.get(s) ?? [];
this.candleCache.set(s, { vol: realizedVolPerHour(list), candles: list.length, at: now }); const vol = realizedVolPerHour(list);
this.candleCache.set(s, { vol, candles: list.length, at: now, tradeDate: today });
if (vol !== null) console.log(`${this.tag} ${s} volatility for ${today}: ${(vol * Math.sqrt(24) * 100).toFixed(2)}%/day (${list.length} hourly closes)`);
} }
} }
return new Map(symbols.map((s) => [s, this.candleCache.get(s) ?? { vol: null, candles: 0 }])); return new Map(symbols.map((s) => [s, this.candleCache.get(s) ?? { vol: null, candles: 0 }]));
@@ -1275,6 +1304,7 @@ export class DXTradeClient implements PlatformClient {
.join('; '); .join('; ');
this.lastFetchRaw[`pick:${root}`] = `${new Date().toISOString()} → ${winner?.symbol ?? 'none'} — ${summary}`; this.lastFetchRaw[`pick:${root}`] = `${new Date().toISOString()} → ${winner?.symbol ?? 'none'} — ${summary}`;
console.log(`${this.tag} ${root}${sticky ? ' (held)' : ''} → ${winner?.symbol ?? 'nothing tradable'} — ${summary}`); console.log(`${this.tag} ${root}${sticky ? ' (held)' : ''} → ${winner?.symbol ?? 'nothing tradable'} — ${summary}`);
if (winner?.contract) this.lastPickedContract = winner.contract;
return winner?.contract ?? null; return winner?.contract ?? null;
} }
+10
View File
@@ -94,6 +94,16 @@ export interface AccountState {
/** What the firm's day may still lose before a hard daily-loss breach. /** What the firm's day may still lose before a hard daily-loss breach.
* null = no daily loss limit (or its day-start balance isn't known yet). */ * null = no daily loss limit (or its day-start balance isn't known yet). */
dllRoom: number | null; dllRoom: number | null;
/** DXtrade: how the next leg would be sized and how long it is expected to
* hold, from today's measured volatility of the last-picked instrument.
* null on other platforms, or before the day's first pick. */
holdEstimate: {
hours: number;
symbol: string;
notional: number;
volatilityPerDay: number;
winProbability: number | null;
} | null;
/** Actual profit target to hit — max(stage profit target, consistency realTarget = maxDay/consistency). */ /** Actual profit target to hit — max(stage profit target, consistency realTarget = maxDay/consistency). */
effectiveProfitTarget: number | null; effectiveProfitTarget: number | null;
/** Current cycle daily P&L — used for target calculations. */ /** Current cycle daily P&L — used for target calculations. */