From 335af486d4f54d0737d0ddcd69e9eece84589cdf Mon Sep 17 00:00:00 2001 From: Brandon Li Date: Mon, 26 Oct 2026 15:27:18 -0500 Subject: [PATCH] One side per machine: no lane opens against a position another lane holds Each lane chose its batch's side on its own coin flip. So one person's machine could be long NQ in the futures lane while its Vest accounts sat short NDX, hedging that person against themselves across firms. - Every new entry now takes the side of whatever the machine holds in any lane (machineSide in trading-logic). That includes another firm's positions and hand trades on any of the machine's accounts. - With positions on both sides already, nothing is added: the batch reports positions-open with the reason, and a manual leg is refused. - A side chosen with nothing held is reserved while its batch places it, so a lane deciding meanwhile follows it. - It works on the side, not the instrument: NQ against an NDX perp is the same bet, and the indices and crypto are close enough that one side for everything is the only rule that never hedges. - Unchanged: one direction per batch within a lane, and the DXtrade and Vest clients' own guards against opening opposite another of their accounts. The fleet still doesn't coordinate sides across people. Co-Authored-By: Claude Opus 5.5 --- lib/auto-trade.ts | 59 +++++++++++++++++++++++++++++---- lib/trading-logic.ts | 19 +++++++++++ scripts/check-trading-logic.mjs | 17 +++++++++- 3 files changed, 88 insertions(+), 7 deletions(-) diff --git a/lib/auto-trade.ts b/lib/auto-trade.ts index c3358bf..9a1e193 100644 --- a/lib/auto-trade.ts +++ b/lib/auto-trade.ts @@ -18,7 +18,7 @@ import { BIG_DAY_THRESHOLD, LEG_COMPLETION_EPSILON, MAX_LEGS_PER_DAY, FALLBACK_COMMISSION_PER_CONTRACT, MAX_COMMISSION_PER_CONTRACT, DLL_MIN_STOP, CME_EVENING_OPEN_MIN, foldReason, withinFirmCaps, - fundRowsKey, markFinishedCycle, unbookedPayoutDrop, + fundRowsKey, markFinishedCycle, unbookedPayoutDrop, machineSide, type DayPlan, type FinishedCycleMark, } from './trading-logic'; import { getSetting, setSetting, loadAccountMeta, saveAccountMeta } from './db'; @@ -65,6 +65,10 @@ interface TradeRuntime { /** Lane → when the batch holding that lane's trade mutex took it (absent * = no batch in flight there). See laneOf. */ tradeInFlight?: Map; + /** The side a lane's batch (or manual leg) chose with nothing held on the + * machine, while it places it: another lane deciding meanwhile takes the + * same side (see sideForEntry). Only counts while its lease holds. */ + sideReservation?: { side: 'Buy' | 'Sell'; lane: string; lease: number }; /** Accounts whose exit the broker refused — a bracket while a hard daily * loss limit was on, or a lone take-profit: the position was flattened * and the account sits out the trade date rather than bleed a round trip @@ -453,6 +457,32 @@ function holdsTradeMutex(lease: number, lane: string): boolean { * release the batch that took over from it. */ function releaseTradeMutex(lease: number, lane: string): void { if (holdsTradeMutex(lease, lane)) runtime().tradeInFlight?.delete(lane); + const r = runtime().sideReservation; + if (r && r.lane === lane && r.lease === lease) runtime().sideReservation = undefined; +} + +/** + * The side a new entry in `lane` must take (machineSide): the side of every + * position on this machine in any lane, else the side another lane's batch is + * placing right now. One person's machine must never be long in one firm and + * short in another, whatever the instruments. + */ +function sideForEntry(lane: string): 'Buy' | 'Sell' | 'mixed' | null { + const net = firmClients().ready.flatMap(({ client }) => client.accountList + .map((a) => (client.positions[a.id]?.netPos ?? 0) as number) + .filter((n) => n !== 0)); + const r = runtime().sideReservation; + const reserved = r && r.lane !== lane && holdsTradeMutex(r.lease, r.lane) ? r.side : null; + return machineSide(net, reserved); +} + +/** Why `action` can't be entered in `lane` without a hedge, or null. */ +function hedgeBlock(side: ReturnType, action: 'Buy' | 'Sell' | 'Auto'): string | null { + if (side === 'mixed') return 'this machine holds long and short positions — no side to join until one of them closes'; + if (side && action !== 'Auto' && action !== side) { + return `this machine is ${side === 'Buy' ? 'long' : 'short'} elsewhere — a ${action} would hedge it`; + } + return null; } /** What a firm's tiny legs (drain, extra-day) trade: a futures firm the @@ -973,10 +1003,21 @@ async function runTradeLocked( } const open = laneReady.filter(({ firm }) => !isInNoTradeWindow(firm)); - // One direction per batch: every account trades the same side. - const resolvedAction: 'Buy' | 'Sell' = action === 'Auto' - ? (Math.random() < 0.5 ? 'Buy' : 'Sell') - : action; + // One direction per batch, and per machine: every account trades the side + // of whatever this machine already holds in any lane (another firm, a hand + // trade), so nothing the batch opens hedges the person against themselves. + // Free only when nothing is held or being placed; the choice is then held + // while the batch places it, so a lane deciding meanwhile follows it. + const side = sideForEntry(lane); + const blocked = hedgeBlock(side, action); + if (blocked) { + console.log(`[auto-trade] ${laneName(lane)}: ${blocked} — skipping`); + return { outcome: 'positions-open', results: [], detail: blocked }; + } + const resolvedAction: 'Buy' | 'Sell' = side === 'Buy' || side === 'Sell' + ? side + : action === 'Auto' ? (Math.random() < 0.5 ? 'Buy' : 'Sell') : action; + if (!side) runtime().sideReservation = { side: resolvedAction, lane, lease }; const futuresLane = lane === FUTURES_LANE; // An explicit symbol (the scheduler's, or a manual Trade) is the // operator's pick; 'Auto' is drawn per batch from the batch firm's list. @@ -1826,7 +1867,13 @@ export async function fireManualLeg(accountId: number, action: 'Buy' | 'Sell' | if (heldSide && action !== 'Auto' && action !== heldSide) { throw new Error(`the ${laneName(lane)} lane is ${heldSide === 'Buy' ? 'long' : 'short'} — a ${action} would hedge it`); } - const resolvedAction: 'Buy' | 'Sell' = heldSide ?? (action === 'Auto' ? (Math.random() < 0.5 ? 'Buy' : 'Sell') : action); + // …nor against what the machine holds in any other lane. + const side = sideForEntry(lane); + const blocked = hedgeBlock(side, heldSide ?? action); + if (blocked) throw new Error(blocked); + const resolvedAction: 'Buy' | 'Sell' = heldSide + ?? (side === 'Buy' || side === 'Sell' ? side : action === 'Auto' ? (Math.random() < 0.5 ? 'Buy' : 'Sell') : action); + if (!side) runtime().sideReservation = { side: resolvedAction, lane, lease }; const sizingItem: SizingItem = { firmName: firm.name, firm, client, acc, cash: ev.cash, dailyPnL: ev.dailyPnL, plan: ev.plan, drain: ev.drain, diff --git a/lib/trading-logic.ts b/lib/trading-logic.ts index 6e7ea2d..2688f07 100644 --- a/lib/trading-logic.ts +++ b/lib/trading-logic.ts @@ -1435,6 +1435,25 @@ export function botStopsActive(cfg: { firstDayStop: boolean; minDayStop: boolean return cfg.firstDayStop || cfg.minDayStop || (cfg.dailyLossLimit ?? 0) > 0; } +/** + * The side every new entry on this machine must take, so nothing it opens + * hedges the person against themselves: the side of every position the + * machine's accounts hold, in any lane (another firm's, a hand trade's), else + * the side another lane's batch is placing right now. 'mixed' = it holds both + * sides already (hand trades, or from before this rule), so no side can be + * joined without hedging one of them. null = free to choose. + * + * The side, not the instrument: NQ futures against an NDX perp is the same + * bet, and ES or a crypto index close enough to it that one side for + * everything is the only rule that never hedges. + */ +export function machineSide(netPositions: number[], reserved: 'Buy' | 'Sell' | null): 'Buy' | 'Sell' | 'mixed' | null { + const long = netPositions.some((n) => n > 0) || reserved === 'Buy'; + const short = netPositions.some((n) => n < 0) || reserved === 'Sell'; + if (long && short) return 'mixed'; + return long ? 'Buy' : short ? 'Sell' : null; +} + // ── position size ──────────────────────────────────────────────────────────── /** The hold a leg is sized for when the firm doesn't set one. */ diff --git a/scripts/check-trading-logic.mjs b/scripts/check-trading-logic.mjs index 7b1f4e8..2ee3332 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, personSizeFactor, personHoldHours, PERSON_SIZE_SPREAD, + currentTradeDateCT, foldReason, withinFirmCaps, personSizeFactor, personHoldHours, PERSON_SIZE_SPREAD, machineSide, } from '../lib/trading-logic.ts'; // Deterministic PRNG so a failure reproduces. @@ -952,3 +952,18 @@ test('fund reconcile: a transient that outlasts the confirmation is taken back w assert.deepEqual(imported.kinds, ['unconfirmed', 'book']); assert.equal(imported.row, -1); }); + +test('machineSide: one side per machine, across every lane', () => { + // Nothing held, nothing being placed: free. + assert.equal(machineSide([], null), null); + // Vest shorts held (hand trades or the bot's): every lane must short. + assert.equal(machineSide([-42.6545, -12.1], null), 'Sell'); + // A long NQ in the futures lane: the Vest lane must go long. + assert.equal(machineSide([3], null), 'Buy'); + // Another lane's batch is placing longs right now, nothing filled yet. + assert.equal(machineSide([], 'Buy'), 'Buy'); + assert.equal(machineSide([2], 'Buy'), 'Buy'); + // Both sides already: nothing may be added. + assert.equal(machineSide([1, -1], null), 'mixed'); + assert.equal(machineSide([-5], 'Buy'), 'mixed'); +});