Legs: cheapest symbol that reaches the target hold, last stands, a reserve that grows with size

- A batch sizes its leg on every mapped candidate, each at the leverage
  the broker states for it, and trades the cheapest (spread, commissions,
  expected swap) that reaches the target hold — else the quickest. The
  dashboard ranks the same way from a half-hourly candidate survey.
- Last stand: when a daily-loss stop-out would leave the account within
  15% of that loss above its max-drawdown floor (the account type's static
  max loss, or the broker's liquidation floor), the leg carries no
  daily-loss stop and is sized against the firm's own breach.
- The daily-loss reserve is 10% of the limit or 0.1% of the position,
  whichever is more, since a stop's slippage grows with the position.
- The account type's max notional is gone: the broker's margin rates cap
  each symbol. planLegSize holds the one sizing rule fireLeg, the pick and
  the dashboard share.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-09-26 18:26:12 -05:00
co-authored by Claude Opus 5.5
parent a2d9df5ba4
commit a2cd3a94c9
12 changed files with 477 additions and 172 deletions
+51 -22
View File
@@ -31,7 +31,7 @@
*/
import axios from 'axios';
import { currentTradeDateCT, FIRM_RESET_UTC_HOUR, DEFAULT_TARGET_HOLD_HOURS } from './trading-logic';
import { currentTradeDateCT, FIRM_RESET_UTC_HOUR, DEFAULT_TARGET_HOLD_HOURS, rankForTarget } from './trading-logic';
import {
saveDailyPnL, loadDailyPnL, loadAccountMeta, saveAccountMeta, loadFundTransactions, saveFundTransactions,
saveEndedCycleStart,
@@ -39,7 +39,7 @@ import {
import { getAccountConfig } from './account-type-resolver';
import { getCachedFirm } from './firm-catalog';
import type { SymbolMapCandidate } from './master-protocol';
import type { PlatformClient, PlatformAccount, PlatformContract, ClientHealth } from './platform-client';
import type { PlatformClient, PlatformAccount, PlatformContract, ClientHealth, PickContext } from './platform-client';
import { OrderNotPlacedError, isOrderNotPlaced, errorMessage } from './platform-client';
import {
dxAccountId, accountDisplayName, accountCodeOf, encodeContractName, decodeContractName, roundToStep,
@@ -78,6 +78,8 @@ const OCO_POLL_MS = 5_000;
/** A pick is reused this long, so a batch's lookups (Phase 1, drain legs)
* all land on the same symbol. */
const PICK_TTL_MS = 45_000;
/** How often the dashboard's candidates (quotes, spreads) are refreshed. */
const SURVEY_INTERVAL_MS = 30 * 60_000;
const INSTRUMENT_TTL_MS = 60 * 60_000;
const ACCOUNT_INSTRUMENT_TTL_MS = 10 * 60_000;
const CANDLE_TTL_MS = 15 * 60_000;
@@ -304,10 +306,11 @@ export class DXTradeClient implements PlatformClient {
private accountInstrumentCache = new Map<string, { det: Record<string, unknown>; 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 = '';
/** Every mapped instrument that could trade at the last full look — what
* the dashboard ranks each account's next leg across. */
public lastCandidates: PlatformContract[] = [];
private lastSurveyTradeDate = '';
private lastSurveyAt = 0;
private unmappedLoggedAt = new Map<string, number>();
constructor(cfg: DXTradeConfig, onSync: () => Promise<void>) {
@@ -676,12 +679,14 @@ 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)}`));
// Measure the day's volatility at the first good sync of each trade
// date, and refresh the candidates the dashboard sizes against
// every half hour, whether or not a batch fires.
if (this.historyLoaded && (this.lastSurveyTradeDate !== currentTradeDateCT() || Date.now() - this.lastSurveyAt >= SURVEY_INTERVAL_MS)) {
this.lastSurveyTradeDate = currentTradeDateCT();
this.lastSurveyAt = Date.now();
void this.surveyCandidates().catch((err) =>
console.warn(`${this.tag} candidate survey failed: ${errorMessage(err)}`));
}
this.syncComplete = true;
@@ -1157,12 +1162,15 @@ export class DXTradeClient implements PlatformClient {
}
/**
* The contract a CME root trades as on this firm right now: the cheapest
* tradable candidate from the symbol map, per unit of volatility (see
* scoreCandidate). Sticky while an account
* holds one of the candidates, cached briefly so a batch's lookups agree.
* The contract a CME root trades as on this firm right now, from the
* symbol map. With a pick context (the batch's leg), each candidate is
* sized as the leg would be — at its own leverage — and the cheapest one
* that reaches the target hold wins (else the quickest); without one, the
* cheapest per unit of volatility (see scoreCandidate). Sticky while an
* account holds one of the candidates; a batch's pick is reused briefly
* so its other lookups (drain legs) agree.
*/
public async findFrontMonthContract(root: string): Promise<PlatformContract | null> {
public async findFrontMonthContract(root: string, ctx?: PickContext): Promise<PlatformContract | null> {
if (!this.token) return null;
const firm = getCachedFirm(this.cfg.firmId);
const map = firm?.symbolMap ?? {};
@@ -1177,16 +1185,26 @@ export class DXTradeClient implements PlatformClient {
}
const held = new Set([...this.positionsDetail.values()].flat().map((p) => p.symbol));
const sticky = candidates.find((c) => held.has(c.symbol));
if (!sticky) {
if (!sticky && !ctx) {
const cached = this.pickCache.get(root);
if (cached && now - cached.at < PICK_TTL_MS) return cached.contract;
}
const pick = await this.pickCandidate(root, sticky ? [sticky] : candidates, sticky !== undefined);
const pick = await this.pickCandidate(root, sticky ? [sticky] : candidates, sticky !== undefined, ctx);
if (pick && !sticky) this.pickCache.set(root, { contract: pick, at: now });
return pick;
}
private async pickCandidate(root: string, candidates: SymbolMapCandidate[], sticky: boolean): Promise<PlatformContract | null> {
/** Every mapped symbol, looked at afresh whatever the accounts hold: the
* day's volatility, and the candidates the dashboard ranks. */
private async surveyCandidates(): Promise<void> {
const all = new Map<string, SymbolMapCandidate>();
for (const list of Object.values(getCachedFirm(this.cfg.firmId)?.symbolMap ?? {})) {
for (const c of list) if (!all.has(c.symbol)) all.set(c.symbol, c);
}
if (all.size > 0) await this.pickCandidate('survey', [...all.values()], false);
}
private async pickCandidate(root: string, candidates: SymbolMapCandidate[], sticky: boolean, ctx?: PickContext): Promise<PlatformContract | null> {
const ref = this.referenceAccount();
if (!ref) return null;
const firm = getCachedFirm(this.cfg.firmId);
@@ -1270,14 +1288,25 @@ export class DXTradeClient implements PlatformClient {
});
}
const ranked = board.filter((b) => b.contract).sort((a, b) => a.score! - b.score!);
let ranked = board.filter((b) => b.contract).sort((a, b) => a.score! - b.score!);
if (ctx) {
// Size the batch's leg on each: cheapest that reaches the target
// hold, else quickest.
const sized = ranked.flatMap((b) => {
const ev = ctx.evaluate(b.contract!);
if (!ev) { b.note += ', can\'t size'; return []; }
b.note += `, $${Math.round(ev.notional / 1000)}k → ${ev.holdHours.toFixed(1)}h, ~$${Math.round(ev.cost)}`;
return [{ b, holdHours: ev.holdHours, cost: ev.cost }];
});
ranked = rankForTarget(sized, ctx.targetHoldHours).map((i) => sized[i].b);
}
const winner = ranked[0];
const summary = board
.map((b) => `${b.symbol}${b.score !== null ? ` ${b.score.toFixed(4)}` : ''} (${b.note})`)
.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;
if (!sticky) this.lastCandidates = board.flatMap((b) => (b.contract ? [b.contract] : []));
return winner?.contract ?? null;
}