diff --git a/app/api/state/route.ts b/app/api/state/route.ts index a7e6b2d..880facb 100644 --- a/app/api/state/route.ts +++ b/app/api/state/route.ts @@ -3,13 +3,13 @@ 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, type PlatformContract } from '@/lib/platform-client'; +import { historyFresh } from '@/lib/platform-client'; +import { rankNextLeg } from '@/lib/leg-estimate'; import type { AccountState } from '@/types'; import { combineCmeSessions, computeDayPlan, cycleCompletion, resolveEffectiveConfig, currentTradeDateCT, - obfuscationStopDollars, dllRoom as computeDllRoom, dllStopDollars, botStopsActive, planLegSize, rankForTarget, + obfuscationStopDollars, dllRoom as computeDllRoom, dllStopDollars, botStopsActive, maxLossRoom, lastStand as isLastStand, - DEFAULT_TARGET_HOLD_HOURS, LEG_COMPLETION_EPSILON, } from '@/lib/trading-logic'; @@ -133,44 +133,25 @@ 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 each - // instrument the client last surveyed — at its leverage and - // measured volatility — and take the one the batch would: - // the cheapest that reaches the target hold. The next leg - // opens flat, so while one is open (its margin tied up) it - // sizes against the balance. - const candidates = (client as { lastCandidates?: PlatformContract[] }).lastCandidates ?? []; - if (plan && plan.remainingToday > 0 && candidates.length > 0) { - const obf = obfuscationStopDollars(cfg, dailyPnL, plan, combined.tradeDate, plan.remainingToday); - const target = f.targetHoldHours || DEFAULT_TARGET_HOLD_HOURS; - const sized = candidates.flatMap((c) => { - const p = planLegSize({ - leg: plan.remainingToday, - contract: c, - commissionPct: c.commissionPct ?? f.commissionPct ?? 0, - targetHoldHours: target, - financingPctPerDay: f.financingPctPerDay ?? 0, - dailyLossLimit: dll, - dllRoom: dllRoom ?? Infinity, - maxLossRoom: floorRoom, - obfuscationDollars: obf, - availableFunds: client.positions[acc.id] ? cash.amount : (cash.availableFunds ?? cash.amount), - }); - return p.sizing && p.cost !== null && c.volatilityPerHour - ? [{ c, sizing: p.sizing, holdHours: p.sizing.predictedHoldHours, cost: p.cost, volatility: c.volatilityPerHour }] - : []; - }); - const best = sized[rankForTarget(sized, target)[0]]; + // DXtrade: the next leg sized on every surveyed instrument as + // fireLeg would, and the one a batch would pick. + if (plan && plan.remainingToday > 0) { + const best = rankNextLeg({ + client, accountId: acc.id, cfg, firm: f, leg: plan.remainingToday, dllRoom, + obfuscationDollars: obfuscationStopDollars(cfg, dailyPnL, plan, combined.tradeDate, plan.remainingToday), + cash, + })[0]; if (best) { holdEstimate = { - hours: Math.round(best.holdHours * 10) / 10, - symbol: String(best.c.id), - notional: Math.round(best.sizing.notional), - volatilityPerDay: best.volatility * Math.sqrt(24), - winProbability: best.sizing.winProbability, - swapReserve: best.sizing.swapReserve, - leverage: best.c.marginRate ? Math.round(10 / best.c.marginRate) / 10 : null, - cost: Math.round(best.cost), + hours: best.holdHours, + symbol: best.symbol, + notional: best.notional, + multiplier: best.multiplier, + volatilityPerDay: best.volatilityPerDay, + winProbability: best.winProbability, + swapReserve: best.swapReserve, + leverage: best.leverage, + cost: best.cost, }; } } diff --git a/app/page.tsx b/app/page.tsx index 53fe155..a0b018c 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -396,8 +396,8 @@ function AccountRow({ account, firm, hideDead, privacy, onAssigned }: { {account.holdEstimate && ( 0 ? `; its take-profit carries ~$${fmt(account.holdEstimate.swapReserve)} of expected swap` : '') diff --git a/lib/dxtrade-class.ts b/lib/dxtrade-class.ts index 6853c87..aac6264 100644 --- a/lib/dxtrade-class.ts +++ b/lib/dxtrade-class.ts @@ -1258,7 +1258,7 @@ export class DXTradeClient implements PlatformClient { accountCurrency: ref.currency, }); if (rejection) { board.push({ symbol: cand.symbol, score: null, note: rejection }); continue; } - const pct = cand.commissionPct ?? firm?.commissionPct ?? 0; + const pct = firm?.commissionPct ?? 0; const score = scoreCandidate({ spreadFraction: spread, commissionPct: pct, diff --git a/lib/firm-catalog.ts b/lib/firm-catalog.ts index 7522b1f..7f68e77 100644 --- a/lib/firm-catalog.ts +++ b/lib/firm-catalog.ts @@ -156,9 +156,7 @@ function sanitizeSymbolMap(raw: unknown): SymbolMap { if (typeof c !== 'object' || c === null) return []; const cand = c as Record; if (typeof cand.symbol !== 'string' || !cand.symbol || cand.symbol.includes('|')) return []; - const pct = typeof cand.commissionPct === 'number' && Number.isFinite(cand.commissionPct) && cand.commissionPct >= 0 - ? cand.commissionPct : undefined; - return [{ symbol: cand.symbol, ...(pct !== undefined ? { commissionPct: pct } : {}) }]; + return [{ symbol: cand.symbol }]; }); if (candidates.length > 0) map[root.toUpperCase()] = candidates; } diff --git a/lib/leg-estimate.ts b/lib/leg-estimate.ts new file mode 100644 index 0000000..a848515 --- /dev/null +++ b/lib/leg-estimate.ts @@ -0,0 +1,129 @@ +/** + * An account's next leg, sized on every instrument its DXtrade client last + * surveyed — the numbers the local dashboard's hold estimate and the + * master's Firms page show. Same rule as the trade: planLegSize for the size, + * rankForTarget for the order (the cheapest that reaches the target hold, + * else the quickest), so what is shown is what a batch would pick. + */ + +import { getAccountConfig } from './account-type-resolver'; +import type { PlatformContract } from './platform-client'; +import type { SymbolStats } from './master-protocol'; +import { + combineCmeSessions, computeDayPlan, resolveEffectiveConfig, obfuscationStopDollars, dllRoom as computeDllRoom, + planLegSize, rankForTarget, maxLossRoom, lastStand, DEFAULT_TARGET_HOLD_HOURS, +} from './trading-logic'; + +export type CandidateLeg = SymbolStats['symbols'][number] & { swapReserve: number; leverage: number | null }; + +interface FirmSizing { + commissionPct?: number; + targetHoldHours?: number; + financingPctPerDay?: number; +} + +/** The next leg of `leg` dollars on each surveyed candidate, best first. */ +export function rankNextLeg(o: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + client: any; + accountId: number; + cfg: { accountSize: number; maxLoss?: number; dailyLossLimit?: number }; + firm: FirmSizing; + leg: number; + /** null = daily loss limit set but its day start isn't known yet. */ + dllRoom: number | null; + obfuscationDollars: number; + cash: { amount: number; availableFunds?: number }; +}): CandidateLeg[] { + const candidates: PlatformContract[] = o.client.lastCandidates ?? []; + if (!(o.leg > 0) || candidates.length === 0) return []; + const target = o.firm.targetHoldHours || DEFAULT_TARGET_HOLD_HOURS; + const floorRoom = maxLossRoom({ + balance: o.cash.amount, accountSize: o.cfg.accountSize, maxLoss: o.cfg.maxLoss ?? 0, + autoLiqThreshold: o.client.autoLiqThresholds?.[o.accountId], + }); + // The next leg opens flat: while one is open its margin is tied up, so + // size against the balance then. + const funds = o.client.positions?.[o.accountId] ? o.cash.amount : (o.cash.availableFunds ?? o.cash.amount); + const sized = candidates.flatMap((c): CandidateLeg[] => { + if (!c.volatilityPerHour) return []; + const p = planLegSize({ + leg: o.leg, + contract: c, + commissionPct: c.commissionPct ?? o.firm.commissionPct ?? 0, + targetHoldHours: target, + financingPctPerDay: o.firm.financingPctPerDay ?? 0, + dailyLossLimit: o.cfg.dailyLossLimit ?? 0, + dllRoom: o.dllRoom ?? Infinity, + maxLossRoom: floorRoom, + obfuscationDollars: o.obfuscationDollars, + availableFunds: funds, + }); + if (!p.sizing || p.cost === null) return []; + const leverage = c.marginRate ? Math.round(10 / c.marginRate) / 10 : null; + return [{ + symbol: String(c.id), + notional: Math.round(p.sizing.notional), + multiplier: o.cfg.accountSize > 0 ? Math.round((p.sizing.notional / o.cfg.accountSize) * 100) / 100 : 0, + maxMultiplier: leverage, + leverage, + holdHours: Math.round(p.sizing.predictedHoldHours * 10) / 10, + cost: Math.round(p.cost), + winProbability: p.sizing.winProbability, + volatilityPerDay: c.volatilityPerHour * Math.sqrt(24), + spreadFraction: c.spreadFraction ?? 0, + swapReserve: p.sizing.swapReserve, + }]; + }); + return rankForTarget(sized, target).map((i) => sized[i]); +} + +/** + * The same for an account worked out from scratch — its day plan, daily-loss + * room and stops as the dashboard computes them. null when it has no + * account type, no plan, or nothing left to trade today. + */ +export function accountNextLeg( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + client: any, + acc: { id: number }, + firm: FirmSizing, + tradeDate: string, +): { leg: number; lastStand: boolean; candidates: CandidateLeg[] } | null { + const cfg = getAccountConfig(acc.id).accountType; + if (!cfg) return null; + const cash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 }; + const combined = combineCmeSessions(cfg.combineCmeHolidays === true, client.dailyPnL[acc.id] ?? [], tradeDate, cash.realizedPnL); + const effective = resolveEffectiveConfig( + cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity, cfg.withdrawalStages, + client.priorProfit?.[acc.id] ?? 0, client.fundTransactions?.[acc.id] ?? [], cfg.minDayPnL, + ); + const plan = computeDayPlan( + acc.id, combined.tradeDate, effective.profitTarget, effective.consistency, + combined.dailyPnL, effective.minDayPnL, effective.minTradingDays, cash.amount - cfg.accountSize, combined.realizedPnL, + cfg.splitEnabled, effective.drain, + ); + if (!plan || !(plan.remainingToday > 0)) return null; + const dll = cfg.dailyLossLimit ?? 0; + let room: number | null = null; + if (dll > 0) { + const dayStart = client.dayStartBalance?.(acc.id); + room = dayStart === null ? null : computeDllRoom({ + dailyLossLimit: dll, closedBalance: cash.amount, dayStartBalance: dayStart, + realizedToday: Math.min(cash.realizedPnL, combined.realizedPnL), + }); + } + const floorRoom = maxLossRoom({ + balance: cash.amount, accountSize: cfg.accountSize, maxLoss: cfg.maxLoss ?? 0, + autoLiqThreshold: client.autoLiqThresholds?.[acc.id], + }); + return { + leg: plan.remainingToday, + lastStand: room !== null && lastStand({ dllRoom: room, dailyLossLimit: dll, maxLossRoom: floorRoom }), + candidates: rankNextLeg({ + client, accountId: acc.id, cfg, firm, leg: plan.remainingToday, dllRoom: room, + obfuscationDollars: obfuscationStopDollars(cfg, combined.dailyPnL, plan, combined.tradeDate, plan.remainingToday), + cash, + }), + }; +} diff --git a/lib/master-protocol.ts b/lib/master-protocol.ts index fc5e0a3..05cc5cc 100644 --- a/lib/master-protocol.ts +++ b/lib/master-protocol.ts @@ -112,6 +112,36 @@ export interface FirmStats { /** Per account type, summing to the firm's own totals. Optional so a master * on an older build simply ignores it. */ accountTypes?: AccountTypeStats[]; + /** DXtrade firms: one account's next leg sized on every symbol-map + * candidate, best first — what the master's Firms page shows beside each + * symbol. Optional. */ + symbolStats?: SymbolStats; +} + +/** How one account's next leg sizes on each candidate (see planLegSize). */ +export interface SymbolStats { + account: string; + /** The leg's dollar target, before fees and swap. */ + leg: number; + targetHoldHours: number; + /** The leg rides without a daily-loss stop (see lastStand). */ + lastStand: boolean; + /** ISO time the stats were computed. */ + at: string; + symbols: { + symbol: string; + notional: number; + /** Notional ÷ account size. */ + multiplier: number; + /** The broker's leverage on the symbol (5 = 5:1); null when unstated. */ + maxMultiplier: number | null; + holdHours: number; + /** Expected spread, commissions and swap. */ + cost: number; + winProbability: number | null; + volatilityPerDay: number; + spreadFraction: number; + }[]; } export interface SchedulerState { @@ -226,13 +256,12 @@ export function isFirmPlatform(value: unknown): value is FirmPlatform { } /** One symbol a DXTrade firm may trade for a CME root. Position size comes - * from volatility and the firm's target hold, never from the map (an older - * master's notionalPerContract is ignored). */ + * from volatility and the firm's target hold, and the commission is the + * firm's (an older master's notionalPerContract or per-symbol commissionPct + * is ignored). */ export interface SymbolMapCandidate { /** The platform's symbol, e.g. "BTC/USD". */ symbol: string; - /** Per-side commission % for this symbol; absent = the firm's. */ - commissionPct?: number; } /** CME root the batch drew (NQ, MNQ, GC…) or '*' for any root not listed → diff --git a/lib/reporter.ts b/lib/reporter.ts index 54f046e..834bfd9 100644 --- a/lib/reporter.ts +++ b/lib/reporter.ts @@ -18,8 +18,9 @@ import { getClients } from './clients'; import { getTradableFirms } from './firm-catalog'; import { getAccountConfig } from './account-type-resolver'; import { getSchedulerStatus } from './auto-trade'; -import { botStopsActive, combineCmeSessions, computeDayPlan, currentTradeDateCT, cycleCompletion, endedCycleCompletion, LEG_COMPLETION_EPSILON, projectPerfectPath, resolveEffectiveConfig } from './trading-logic'; -import type { AccountTypeStats, FirmStats } from './master-protocol'; +import { botStopsActive, combineCmeSessions, computeDayPlan, currentTradeDateCT, cycleCompletion, endedCycleCompletion, LEG_COMPLETION_EPSILON, projectPerfectPath, resolveEffectiveConfig, DEFAULT_TARGET_HOLD_HOURS } from './trading-logic'; +import type { AccountTypeStats, FirmStats, SymbolStats } from './master-protocol'; +import { accountNextLeg } from './leg-estimate'; import { historyFresh } from './platform-client'; function collectFirmStats() { @@ -387,10 +388,55 @@ function collectFirmStats() { // Unknown until the firm has synced, not empty. ...(client.syncComplete ? { accounts } : {}), accountTypes, + ...symbolStatsFor(f, client, rawToday), }; }); } +/** + * A DXtrade firm's next leg on each symbol-map candidate, for the master's + * Firms page: one live account from the firm's largest prefix group — the + * group a batch fires — with something left to trade today. Nothing for + * other platforms, or when no account has a leg to size. + */ +function symbolStatsFor( + f: { name: string; commissionPct?: number; targetHoldHours?: number; financingPctPerDay?: number }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + client: any, + tradeDate: string, +): { symbolStats?: SymbolStats } { + if (!(client.lastCandidates?.length > 0)) return {}; + const groups = new Map(); + for (const acc of client.accountList as { id: number; name: string; active: boolean }[]) { + const cfg = getAccountConfig(acc.id).accountType; + const floor = client.autoLiqThresholds?.[acc.id] ?? 0; + const cash = client.accountCashBalances?.[acc.id]; + if (!cfg || !acc.active || !cash || (floor !== 0 && cash.amount <= floor)) continue; + groups.set(cfg.prefix, [...(groups.get(cfg.prefix) ?? []), acc]); + } + for (const group of [...groups.values()].sort((a, b) => b.length - a.length)) { + for (const acc of group) { + const next = accountNextLeg(client, acc, f, tradeDate); + if (!next || next.candidates.length === 0) continue; + return { + symbolStats: { + account: acc.name, + leg: next.leg, + targetHoldHours: f.targetHoldHours || DEFAULT_TARGET_HOLD_HOURS, + lastStand: next.lastStand, + at: new Date().toISOString(), + symbols: next.candidates.map((c) => ({ + symbol: c.symbol, notional: c.notional, multiplier: c.multiplier, maxMultiplier: c.maxMultiplier, + holdHours: c.holdHours, cost: c.cost, winProbability: c.winProbability, + volatilityPerDay: c.volatilityPerDay, spreadFraction: c.spreadFraction, + })), + }, + }; + } + } + return {}; +} + // HMR-safe global to avoid duplicate intervals const _g = globalThis as typeof globalThis & { __reporterInterval?: ReturnType }; diff --git a/types.ts b/types.ts index 1572fba..d520b14 100644 --- a/types.ts +++ b/types.ts @@ -99,6 +99,8 @@ export interface AccountState { hours: number; symbol: string; notional: number; + /** Notional ÷ account size. */ + multiplier: number; volatilityPerDay: number; winProbability: number | null; /** Expected swap a winning leg pays, added to its take-profit. */