diff --git a/app/api/state/route.ts b/app/api/state/route.ts index d15f042..03f0f0e 100644 --- a/app/api/state/route.ts +++ b/app/api/state/route.ts @@ -166,7 +166,7 @@ export async function GET() { const best = rankNextLeg({ client, accountId: acc.id, cfg, firm: f, leg: plan.remainingToday, dllRoom, obfuscationDollars: obfuscationStopDollars(cfg, dailyPnL, plan, combined.tradeDate, plan.remainingToday), - cash, + cash, tradeDate: combined.tradeDate, })[0]; if (best) { holdEstimate = { diff --git a/lib/auto-trade.ts b/lib/auto-trade.ts index 505d121..29533df 100644 --- a/lib/auto-trade.ts +++ b/lib/auto-trade.ts @@ -14,7 +14,6 @@ import { combineCmeSessions, computeDayPlan, groupLegFraction, pickLegAmount, resolveEffectiveConfig, currentTradeDateCT, obfuscationStopDollars, takeProfitPlan, onTick, percentFee, dllRoom, dllStopDollars, dllViable, stopTicks, expectedSwapToWin, planLegSize, maxLossRoom, breachDistance, - DEFAULT_TARGET_HOLD_HOURS, isNoTradeTime, isPastSessionClose, BIG_DAY_THRESHOLD, LEG_COMPLETION_EPSILON, MAX_LEGS_PER_DAY, FALLBACK_COMMISSION_PER_CONTRACT, MAX_COMMISSION_PER_CONTRACT, DLL_MIN_STOP, @@ -27,6 +26,7 @@ import type { LaneTick, TickReason } from './master-protocol'; import { POINT_VALUES, housekeepingRoot, HOUSEKEEPING_ROOT, MAX_HOLD_HOURS, futuresContracts } from './instrument-specs'; import { firmInstruments, drawFuturesInstrument, spreadBlock, type LegShape } from './instruments'; import { recordEntry } from './trade-mix'; +import { sizingHoldHours } from './person-size'; import { errorMessage, isClientReady, isOrderNotPlaced, type PlatformClient, type PlatformContract, type PickContext } from './platform-client'; // ── process-wide state ─────────────────────────────────────────────────────── @@ -1329,7 +1329,7 @@ function sizeLeg(item: SizingItem, contract: LegContract, legAmount: number, tra leg: legAmount, contract, commissionPct: pct, - targetHoldHours: item.firm.targetHoldHours || DEFAULT_TARGET_HOLD_HOURS, + targetHoldHours: sizingHoldHours(item.firm, tradeDate), financingPctPerDay: item.firm.financingPctPerDay ?? 0, dailyLossLimit: cfg.dailyLossLimit ?? 0, dllRoom: item.dllRoom, @@ -1352,7 +1352,7 @@ function pickContextFor(items: SizingItem[], tradeDate: string): PickContext | u if (!lead) return undefined; const legAmount = legAmountFor(lead, tradeDate); return { - targetHoldHours: lead.firm.targetHoldHours || DEFAULT_TARGET_HOLD_HOURS, + targetHoldHours: sizingHoldHours(lead.firm, tradeDate), evaluate: (contract) => { const { sizing, cost } = sizeLeg(lead, contract, legAmount, tradeDate); return sizing && cost !== null ? { notional: sizing.notional, holdHours: sizing.predictedHoldHours, cost } : null; @@ -1366,9 +1366,10 @@ function pickContextFor(items: SizingItem[], tradeDate: string): PickContext | u * * Size: futures trade one contract per $1,000 of leg; a platform that prices * positions (stepNotional) sizes by notional instead — whatever makes the - * leg's expected hold the firm's target, from the instrument's recent - * volatility — under the account type's notional cap and the instrument's - * margin rate (the firm's leverage). + * leg's expected hold the firm's target as this person sizes it today + * (sizingHoldHours), from the instrument's recent volatility — under the + * account type's notional cap and the instrument's margin rate (the firm's + * leverage). * * Fees: a percent-of-notional firm's fee is computed off the fill; otherwise * it is read off the realized-P&L change a second after it. A firm that diff --git a/lib/dxtrade-class.ts b/lib/dxtrade-class.ts index 5e72a44..cf963f2 100644 --- a/lib/dxtrade-class.ts +++ b/lib/dxtrade-class.ts @@ -32,7 +32,8 @@ */ import axios from './http-client'; -import { currentTradeDateCT, FIRM_RESET_UTC_HOUR, DEFAULT_TARGET_HOLD_HOURS, rankForTarget } from './trading-logic'; +import { currentTradeDateCT, FIRM_RESET_UTC_HOUR, rankForTarget } from './trading-logic'; +import { sizingHoldHours } from './person-size'; import { saveDailyPnL, loadDailyPnL, loadAccountMeta, saveAccountMeta, loadFundTransactions, saveFundTransactions, saveEndedCycleStart, @@ -1222,7 +1223,7 @@ export class DXTradeClient implements PlatformClient { const ref = this.referenceAccount(); if (!ref) return null; const firm = getCachedFirm(this.cfg.firmId); - const targetHold = firm?.targetHoldHours || DEFAULT_TARGET_HOLD_HOURS; + const targetHold = sizingHoldHours(firm); const symbols = [...new Set(candidates.map((c) => c.symbol))]; const [insts, dets, quotes, vols] = await Promise.all([ this.instruments(symbols), diff --git a/lib/leg-estimate.ts b/lib/leg-estimate.ts index 8686a8d..b233ade 100644 --- a/lib/leg-estimate.ts +++ b/lib/leg-estimate.ts @@ -11,9 +11,10 @@ import type { PlatformContract } from './platform-client'; import type { SymbolStats } from './master-protocol'; import { combineCmeSessions, computeDayPlan, resolveEffectiveConfig, obfuscationStopDollars, dllRoom as computeDllRoom, - planLegSize, rankForTarget, maxLossRoom, lastStand, dllStopDollars, DEFAULT_TARGET_HOLD_HOURS, + planLegSize, rankForTarget, maxLossRoom, lastStand, dllStopDollars, FALLBACK_COMMISSION_PER_CONTRACT, } from './trading-logic'; +import { sizingHoldHours } from './person-size'; export type CandidateLeg = SymbolStats['symbols'][number] & { swapReserve: number; @@ -39,10 +40,12 @@ export function rankNextLeg(o: { dllRoom: number | null; obfuscationDollars: number; cash: { amount: number; availableFunds?: number }; + /** The trade date the hold is drawn for (sizingHoldHours); default today. */ + tradeDate?: string; }): 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 target = sizingHoldHours(o.firm, o.tradeDate); const floorRoom = maxLossRoom({ balance: o.cash.amount, accountSize: o.cfg.accountSize, maxLoss: o.cfg.maxLoss ?? 0, autoLiqThreshold: o.client.autoLiqThresholds?.[o.accountId], @@ -145,7 +148,7 @@ export function accountNextLeg( lastStand: p.room !== null && lastStand({ dllRoom: p.room, dailyLossLimit: p.dll, maxLossRoom: floorRoom }), candidates: rankNextLeg({ client, accountId: acc.id, cfg: p.cfg, firm, leg: p.plan.remainingToday, dllRoom: p.room, - obfuscationDollars: p.obfuscation, cash: p.cash, + obfuscationDollars: p.obfuscation, cash: p.cash, tradeDate, }), }; } diff --git a/lib/person-size.ts b/lib/person-size.ts new file mode 100644 index 0000000..a5f440b --- /dev/null +++ b/lib/person-size.ts @@ -0,0 +1,24 @@ +/** + * The hold this machine sizes DXtrade legs for. Every person's machine would + * otherwise size a like account in a like state to the same position as + * everyone else's; each person's own draw (personHoldHours) puts their sizes + * within ±PERSON_SIZE_SPREAD of the plan instead, the same for all of their + * accounts and new each trade date. + */ + +import { getSetting } from './db'; +import { currentTradeDateCT, personHoldHours, DEFAULT_TARGET_HOLD_HOURS } from './trading-logic'; + +/** Who this machine trades for: the master's uuid for the person, else the + * instance name it reports under. */ +function personKey(): string { + return getSetting('instance_id') || getSetting('instance_name') || ''; +} + +/** The hold a DXtrade firm's legs are sized for on this machine today. */ +export function sizingHoldHours( + firm: { targetHoldHours?: number } | null | undefined, + tradeDate: string = currentTradeDateCT(), +): number { + return personHoldHours(firm?.targetHoldHours || DEFAULT_TARGET_HOLD_HOURS, personKey(), tradeDate); +} diff --git a/lib/reporter.ts b/lib/reporter.ts index 75fff3c..ff379bf 100644 --- a/lib/reporter.ts +++ b/lib/reporter.ts @@ -20,9 +20,10 @@ import { getClients } from './clients'; import { getAccountTypeById, getKeptRuleIdsForFirm, 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, DEFAULT_TARGET_HOLD_HOURS } from './trading-logic'; +import { botStopsActive, combineCmeSessions, computeDayPlan, currentTradeDateCT, cycleCompletion, endedCycleCompletion, LEG_COMPLETION_EPSILON, projectPerfectPath, resolveEffectiveConfig } from './trading-logic'; import type { AccountTypeStats, FirmStats, InstrumentStats, SymbolStats } from './master-protocol'; import { accountNextLeg, accountLegShape } from './leg-estimate'; +import { sizingHoldHours } from './person-size'; import { instrumentStatsFor, isFuturesFirm } from './instruments'; import { accountStatusFields, brokerFacts, emptyFirmStats, firmStatusFields, historyFresh, keptRuleIdsField } from './platform-client'; @@ -494,7 +495,7 @@ function symbolStatsFor( symbolStats: { account: acc.name, leg: next.leg, - targetHoldHours: f.targetHoldHours || DEFAULT_TARGET_HOLD_HOURS, + targetHoldHours: Math.round(sizingHoldHours(f, tradeDate) * 10) / 10, lastStand: next.lastStand, at: new Date().toISOString(), symbols: next.candidates.map((c) => ({ diff --git a/lib/trading-logic.ts b/lib/trading-logic.ts index ff6521d..6e7ea2d 100644 --- a/lib/trading-logic.ts +++ b/lib/trading-logic.ts @@ -1440,6 +1440,44 @@ export function botStopsActive(cfg: { firstDayStop: boolean; minDayStop: boolean /** The hold a leg is sized for when the firm doesn't set one. */ export const DEFAULT_TARGET_HOLD_HOURS = 12; +/** How far one person's DXtrade sizes move off the plan, each way. */ +export const PERSON_SIZE_SPREAD = 0.15; + +/** + * One person's size factor for a trade date, in [1 − spread, 1 + spread]. + * Seeded (FNV-1a) by the person, so all of one person's accounts size alike + * while another person's land elsewhere, and drawn afresh each trade date. + * FNV alone barely moves its top bits when only the key's last character + * changes (the next day's date), so the hash is mixed (murmur3's finalizer) + * before it is scaled. + */ +export function personSizeFactor(personKey: string, tradeDate: string): number { + const key = `size|${personKey}|${tradeDate}`; + let hash = 0x811c9dc5; + for (let i = 0; i < key.length; i++) { + hash ^= key.charCodeAt(i); + hash = Math.imul(hash, 0x01000193) >>> 0; + } + hash ^= hash >>> 16; + hash = Math.imul(hash, 0x85ebca6b); + hash ^= hash >>> 13; + hash = Math.imul(hash, 0xc2b2ae35); + hash = (hash ^ (hash >>> 16)) >>> 0; + return 1 - PERSON_SIZE_SPREAD + (hash / 0x100000000) * 2 * PERSON_SIZE_SPREAD; +} + +/** + * The hold a person's DXtrade leg is sized for: the firm's target, moved so + * the size comes out personSizeFactor times the plan. Size goes as 1/√hold + * (sizeNotional), so hold / factor² does it, and every rule that ranks or + * checks against the hold sees the same number the size was made for. + */ +export function personHoldHours(targetHoldHours: number, personKey: string, tradeDate: string): number { + const hold = targetHoldHours > 0 ? targetHoldHours : DEFAULT_TARGET_HOLD_HOURS; + const factor = personSizeFactor(personKey, tradeDate); + return hold / (factor * factor); +} + /** Share of the broker's margin headroom a new position may use: the rest * absorbs the price moving between sizing and the fill, and the entry fee. */ export const MARGIN_HEADROOM = 0.97; diff --git a/scripts/check-trading-logic.mjs b/scripts/check-trading-logic.mjs index d758d54..7b1f4e8 100644 --- a/scripts/check-trading-logic.mjs +++ b/scripts/check-trading-logic.mjs @@ -13,7 +13,7 @@ import { botStopsActive, sizeNotional, isNoTradeTime, isPastSessionClose, nextTradingDate, effectiveCombine, DEFAULT_TARGET_HOLD_HOURS, projectPerfectPath, cycleCompletion, DLL_RESERVE_FRACTION, DLL_MIN_STOP, notionalCap, expectedSwapToWin, MARGIN_HEADROOM, dllReserve, DLL_SLIPPAGE_FRACTION, maxLossRoom, lastStand, breachDistance, LAST_STAND_FRACTION, planLegSize, rankForTarget, - currentTradeDateCT, foldReason, withinFirmCaps, + currentTradeDateCT, foldReason, withinFirmCaps, personSizeFactor, personHoldHours, PERSON_SIZE_SPREAD, } from '../lib/trading-logic.ts'; // Deterministic PRNG so a failure reproduces. @@ -475,6 +475,49 @@ test('planLegSize: the stop, a last stand, the margin cap and the cost, as fireL assert.equal(planLegSize({ ...base, maxLossRoom: null, contract: {} }).sizing, null); }); +test('personSizeFactor: one draw per person and trade date, within the spread', () => { + const people = Array.from({ length: 300 }, (_, i) => `0b6f${i.toString(16).padStart(4, '0')}-5c1e-4a7e-9d2b-${i}`); + const draws = people.map((p) => personSizeFactor(p, '2026-10-20')); + for (const f of draws) assert.ok(f >= 1 - PERSON_SIZE_SPREAD && f <= 1 + PERSON_SIZE_SPREAD, `factor ${f}`); + // Spread over the whole range, not bunched at 1. + assert.ok(Math.min(...draws) < 0.88 && Math.max(...draws) > 1.12, `${Math.min(...draws)}–${Math.max(...draws)}`); + // Repeatable, so every sizing of the day agrees. + assert.equal(personSizeFactor(people[0], '2026-10-20'), draws[0]); + // Two people land within half a percent of each other about as often as + // uniform draws do (2 × 0.005 / 0.3 ≈ 3%), not bunched together. + const close = draws.filter((f, i) => i > 0 && Math.abs(f - draws[i - 1]) < 0.005).length; + assert.ok(close < draws.length * 0.08, `${close} near-identical neighbours`); + // A new trade date, a new draw: a month of one person's days covers the + // range too, not a slow drift (the next date differs in its last digit). + const days = Array.from({ length: 30 }, (_, i) => personSizeFactor(people[0], `2026-11-${String(i + 1).padStart(2, '0')}`)); + assert.ok(Math.max(...days) - Math.min(...days) > PERSON_SIZE_SPREAD, `days span ${Math.min(...days)}–${Math.max(...days)}`); + const steps = days.slice(1).map((f, i) => Math.abs(f - days[i])); + assert.ok(steps.reduce((a, b) => a + b, 0) / steps.length > 0.05, 'day to day the draw should jump, not creep'); +}); + +test('personHoldHours: the size comes out the person\'s factor times the plan, at the hold it was sized for', () => { + const eth = { stepNotional: 26.86, volatilityPerHour: 0.022 / Math.sqrt(24), spreadFraction: 0.00004, marginRate: 0.2 }; + const base = { + leg: 12239, contract: eth, commissionPct: 0.04, targetHoldHours: 12, financingPctPerDay: 0.033, + dailyLossLimit: 3000, dllRoom: 2757.39, maxLossRoom: 5757.39, obfuscationDollars: Infinity, availableFunds: 99757.39, + }; + const plan = planLegSize(base).sizing; + for (const person of ['me-uuid', 'nik-uuid', 'ahmad-uuid', 'ziad-uuid']) { + const factor = personSizeFactor(person, '2026-10-20'); + const hold = personHoldHours(12, person, '2026-10-20'); + const sized = planLegSize({ ...base, targetHoldHours: hold }).sizing; + // Fees and the daily-loss stop move a little with the size, so the + // ratio sits a touch nearer 1 than the factor. + const ratio = sized.notional / plan.notional; + assert.ok(Math.abs(ratio - factor) < 0.02, `${person}: ×${ratio.toFixed(4)} vs factor ${factor.toFixed(4)}`); + assert.ok((ratio - 1) * (factor - 1) > 0, `${person}: moved the wrong way`); + // The pick ranks against this hold, and the size reaches it. + assert.ok(Math.abs(sized.predictedHoldHours - hold) < 0.1, `${person}: ${sized.predictedHoldHours}h vs ${hold}h`); + } + // No firm hold: the default, moved the same way. + assert.equal(personHoldHours(0, 'me-uuid', '2026-10-20'), personHoldHours(DEFAULT_TARGET_HOLD_HOURS, 'me-uuid', '2026-10-20')); +}); + test('planLegSize: the broker\'s order size limits bind the sizing, so the pick sees the real hold', () => { const eth = { stepNotional: 26.86, volatilityPerHour: 0.022 / Math.sqrt(24), spreadFraction: 0.00004, marginRate: 0.2 }; const base = {