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'); +});