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