diff --git a/app/api/state/route.ts b/app/api/state/route.ts index 63ed09b..38d3fb8 100644 --- a/app/api/state/route.ts +++ b/app/api/state/route.ts @@ -3,10 +3,12 @@ import { loadDailyPnL } from '@/lib/db'; import { getClients } from '@/lib/clients'; import { getCachedCatalog } from '@/lib/firm-catalog'; 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 { 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, } from '@/lib/trading-logic'; @@ -86,6 +88,7 @@ export async function GET() { let effectiveProfitTarget: number | null = null; let nextStop: number | null = null; let dllRoom: number | null = null; + let holdEstimate: AccountState['holdEstimate'] = null; if (cfg && effective && !isDead) { const equityProfit = cash.amount - cfg.accountSize; const plan = computeDayPlan( @@ -121,6 +124,40 @@ export async function GET() { 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). // If a big day forces the consistency rule, the account must reach maxDay/consistency // in total trading profit for the stage, not just profitTarget. @@ -228,6 +265,7 @@ export async function GET() { dailyTarget, nextStop, dllRoom, + holdEstimate, remainingToday, effectiveProfitTarget, dailyPnL, diff --git a/app/page.tsx b/app/page.tsx index 7555cc0..98def78 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -390,6 +390,15 @@ function AccountRow({ account, firm, hideDead, privacy, onAssigned }: { {typeof account.dllRoom === 'number' && ( · DLL room ${fmt(account.dllRoom)} )} + {account.holdEstimate && ( + + {' · hold ~'}{account.holdEstimate.hours < 48 ? `${Math.round(account.holdEstimate.hours)}h` : `${(account.holdEstimate.hours / 24).toFixed(1)}d`} + + )} )} diff --git a/lib/dxtrade-class.ts b/lib/dxtrade-class.ts index 667089d..84316bd 100644 --- a/lib/dxtrade-class.ts +++ b/lib/dxtrade-class.ts @@ -93,6 +93,11 @@ const REAP_AFTER_MS = 60_000; /** A position this old with no working order is flagged as unprotected. */ 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 round2 = (n: number) => Math.round(n * 100) / 100; @@ -329,7 +334,12 @@ export class DXTradeClient implements PlatformClient { private pickCache = new Map(); private instrumentCache = new Map; at: number }>(); private accountInstrumentCache = new Map; at: number }>(); - private candleCache = new Map(); + /** Volatility per symbol, measured once per trade date (see volatility). */ + private candleCache = new Map(); + /** 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(); constructor(cfg: DXTradeConfig, onSync: () => Promise) { @@ -680,7 +690,10 @@ export class DXTradeClient implements PlatformClient { 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.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); // Keep the reset snapshot handy for the debug view. 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)`); 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; 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 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); - 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.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]}`); @@ -1120,7 +1141,13 @@ export class DXTradeClient implements PlatformClient { private async volatility(account: string, symbols: string[]): Promise> { 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) { const closes = new Map(); let fromDx = false; @@ -1151,7 +1178,9 @@ export class DXTradeClient implements PlatformClient { } for (const s of missing) { 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 }])); @@ -1275,6 +1304,7 @@ export class DXTradeClient implements PlatformClient { .join('; '); this.lastFetchRaw[`pick:${root}`] = `${new Date().toISOString()} → ${winner?.symbol ?? 'none'} — ${summary}`; console.log(`${this.tag} ${root}${sticky ? ' (held)' : ''} → ${winner?.symbol ?? 'nothing tradable'} — ${summary}`); + if (winner?.contract) this.lastPickedContract = winner.contract; return winner?.contract ?? null; } diff --git a/types.ts b/types.ts index c2c38fe..f5fbd48 100644 --- a/types.ts +++ b/types.ts @@ -94,6 +94,16 @@ export interface AccountState { /** 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). */ 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). */ effectiveProfitTarget: number | null; /** Current cycle daily P&L — used for target calculations. */