Report each symbol's next-leg stats to the master; drop per-symbol commission

- lib/leg-estimate: an account's next leg sized on every surveyed
  candidate (planLegSize, ranked by rankForTarget) — notional, its
  multiple of the account and the broker's maximum, expected hold, cost
  and win chance. The dashboard's hold estimate and the reporter share it.
- The reporter sends it per DXtrade firm as symbolStats, for one live
  account from the largest prefix group with a leg left to trade.
- The symbol map's per-symbol commission is gone: the firm's applies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-09-26 18:41:01 -05:00
co-authored by Claude Opus 5.5
parent a2cd3a94c9
commit 169fe19fad
8 changed files with 236 additions and 51 deletions
+20 -39
View File
@@ -3,13 +3,13 @@ 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, type PlatformContract } from '@/lib/platform-client'; import { historyFresh } from '@/lib/platform-client';
import { rankNextLeg } from '@/lib/leg-estimate';
import type { AccountState } from '@/types'; import type { AccountState } from '@/types';
import { import {
combineCmeSessions, computeDayPlan, cycleCompletion, resolveEffectiveConfig, currentTradeDateCT, combineCmeSessions, computeDayPlan, cycleCompletion, resolveEffectiveConfig, currentTradeDateCT,
obfuscationStopDollars, dllRoom as computeDllRoom, dllStopDollars, botStopsActive, planLegSize, rankForTarget, obfuscationStopDollars, dllRoom as computeDllRoom, dllStopDollars, botStopsActive,
maxLossRoom, lastStand as isLastStand, maxLossRoom, lastStand as isLastStand,
DEFAULT_TARGET_HOLD_HOURS,
LEG_COMPLETION_EPSILON, LEG_COMPLETION_EPSILON,
} from '@/lib/trading-logic'; } from '@/lib/trading-logic';
@@ -133,44 +133,25 @@ 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 each // DXtrade: the next leg sized on every surveyed instrument as
// instrument the client last surveyed — at its leverage and // fireLeg would, and the one a batch would pick.
// measured volatility — and take the one the batch would: if (plan && plan.remainingToday > 0) {
// the cheapest that reaches the target hold. The next leg const best = rankNextLeg({
// opens flat, so while one is open (its margin tied up) it client, accountId: acc.id, cfg, firm: f, leg: plan.remainingToday, dllRoom,
// sizes against the balance. obfuscationDollars: obfuscationStopDollars(cfg, dailyPnL, plan, combined.tradeDate, plan.remainingToday),
const candidates = (client as { lastCandidates?: PlatformContract[] }).lastCandidates ?? []; cash,
if (plan && plan.remainingToday > 0 && candidates.length > 0) { })[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]];
if (best) { if (best) {
holdEstimate = { holdEstimate = {
hours: Math.round(best.holdHours * 10) / 10, hours: best.holdHours,
symbol: String(best.c.id), symbol: best.symbol,
notional: Math.round(best.sizing.notional), notional: best.notional,
volatilityPerDay: best.volatility * Math.sqrt(24), multiplier: best.multiplier,
winProbability: best.sizing.winProbability, volatilityPerDay: best.volatilityPerDay,
swapReserve: best.sizing.swapReserve, winProbability: best.winProbability,
leverage: best.c.marginRate ? Math.round(10 / best.c.marginRate) / 10 : null, swapReserve: best.swapReserve,
cost: Math.round(best.cost), leverage: best.leverage,
cost: best.cost,
}; };
} }
} }
+2 -2
View File
@@ -396,8 +396,8 @@ function AccountRow({ account, firm, hideDead, privacy, onAssigned }: {
{account.holdEstimate && ( {account.holdEstimate && (
<span <span
className="text-slate-500" className="text-slate-500"
title={`Next leg on ${account.holdEstimate.symbol}: $${Math.round(account.holdEstimate.notional).toLocaleString()} notional` title={`Next leg on ${account.holdEstimate.symbol}: $${Math.round(account.holdEstimate.notional).toLocaleString()} notional, ${account.holdEstimate.multiplier}× the account`
+ (account.holdEstimate.leverage !== null ? ` (${account.holdEstimate.leverage}:1 max)` : '') + (account.holdEstimate.leverage !== null ? ` (${account.holdEstimate.leverage}× max)` : '')
+ ` at a measured volatility of ${(account.holdEstimate.volatilityPerDay * 100).toFixed(2)}%/day over the past week` + ` at a measured volatility of ${(account.holdEstimate.volatilityPerDay * 100).toFixed(2)}%/day over the past week`
+ (account.holdEstimate.winProbability !== null ? `, wins ~${Math.round(account.holdEstimate.winProbability * 100)}% of the time` : '') + (account.holdEstimate.winProbability !== null ? `, wins ~${Math.round(account.holdEstimate.winProbability * 100)}% of the time` : '')
+ (account.holdEstimate.swapReserve > 0 ? `; its take-profit carries ~$${fmt(account.holdEstimate.swapReserve)} of expected swap` : '') + (account.holdEstimate.swapReserve > 0 ? `; its take-profit carries ~$${fmt(account.holdEstimate.swapReserve)} of expected swap` : '')
+1 -1
View File
@@ -1258,7 +1258,7 @@ export class DXTradeClient implements PlatformClient {
accountCurrency: ref.currency, accountCurrency: ref.currency,
}); });
if (rejection) { board.push({ symbol: cand.symbol, score: null, note: rejection }); continue; } 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({ const score = scoreCandidate({
spreadFraction: spread, spreadFraction: spread,
commissionPct: pct, commissionPct: pct,
+1 -3
View File
@@ -156,9 +156,7 @@ function sanitizeSymbolMap(raw: unknown): SymbolMap {
if (typeof c !== 'object' || c === null) return []; if (typeof c !== 'object' || c === null) return [];
const cand = c as Record<string, unknown>; const cand = c as Record<string, unknown>;
if (typeof cand.symbol !== 'string' || !cand.symbol || cand.symbol.includes('|')) return []; if (typeof cand.symbol !== 'string' || !cand.symbol || cand.symbol.includes('|')) return [];
const pct = typeof cand.commissionPct === 'number' && Number.isFinite(cand.commissionPct) && cand.commissionPct >= 0 return [{ symbol: cand.symbol }];
? cand.commissionPct : undefined;
return [{ symbol: cand.symbol, ...(pct !== undefined ? { commissionPct: pct } : {}) }];
}); });
if (candidates.length > 0) map[root.toUpperCase()] = candidates; if (candidates.length > 0) map[root.toUpperCase()] = candidates;
} }
+129
View File
@@ -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,
}),
};
}
+33 -4
View File
@@ -112,6 +112,36 @@ export interface FirmStats {
/** Per account type, summing to the firm's own totals. Optional so a master /** Per account type, summing to the firm's own totals. Optional so a master
* on an older build simply ignores it. */ * on an older build simply ignores it. */
accountTypes?: AccountTypeStats[]; 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 { 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 /** 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 * from volatility and the firm's target hold, and the commission is the
* master's notionalPerContract is ignored). */ * firm's (an older master's notionalPerContract or per-symbol commissionPct
* is ignored). */
export interface SymbolMapCandidate { export interface SymbolMapCandidate {
/** The platform's symbol, e.g. "BTC/USD". */ /** The platform's symbol, e.g. "BTC/USD". */
symbol: string; 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 → /** CME root the batch drew (NQ, MNQ, GC…) or '*' for any root not listed →
+48 -2
View File
@@ -18,8 +18,9 @@ import { getClients } from './clients';
import { getTradableFirms } from './firm-catalog'; import { getTradableFirms } from './firm-catalog';
import { getAccountConfig } from './account-type-resolver'; import { getAccountConfig } from './account-type-resolver';
import { getSchedulerStatus } from './auto-trade'; import { getSchedulerStatus } from './auto-trade';
import { botStopsActive, combineCmeSessions, computeDayPlan, currentTradeDateCT, cycleCompletion, endedCycleCompletion, LEG_COMPLETION_EPSILON, projectPerfectPath, resolveEffectiveConfig } from './trading-logic'; import { botStopsActive, combineCmeSessions, computeDayPlan, currentTradeDateCT, cycleCompletion, endedCycleCompletion, LEG_COMPLETION_EPSILON, projectPerfectPath, resolveEffectiveConfig, DEFAULT_TARGET_HOLD_HOURS } from './trading-logic';
import type { AccountTypeStats, FirmStats } from './master-protocol'; import type { AccountTypeStats, FirmStats, SymbolStats } from './master-protocol';
import { accountNextLeg } from './leg-estimate';
import { historyFresh } from './platform-client'; import { historyFresh } from './platform-client';
function collectFirmStats() { function collectFirmStats() {
@@ -387,10 +388,55 @@ function collectFirmStats() {
// Unknown until the firm has synced, not empty. // Unknown until the firm has synced, not empty.
...(client.syncComplete ? { accounts } : {}), ...(client.syncComplete ? { accounts } : {}),
accountTypes, 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<string, { id: number; name: string }[]>();
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 // HMR-safe global to avoid duplicate intervals
const _g = globalThis as typeof globalThis & { __reporterInterval?: ReturnType<typeof setInterval> }; const _g = globalThis as typeof globalThis & { __reporterInterval?: ReturnType<typeof setInterval> };
+2
View File
@@ -99,6 +99,8 @@ export interface AccountState {
hours: number; hours: number;
symbol: string; symbol: string;
notional: number; notional: number;
/** Notional ÷ account size. */
multiplier: number;
volatilityPerDay: number; volatilityPerDay: number;
winProbability: number | null; winProbability: number | null;
/** Expected swap a winning leg pays, added to its take-profit. */ /** Expected swap a winning leg pays, added to its take-profit. */