diff --git a/lib/auto-trade.ts b/lib/auto-trade.ts index b90a38e..62b0c0d 100644 --- a/lib/auto-trade.ts +++ b/lib/auto-trade.ts @@ -1540,66 +1540,74 @@ export async function fireManualLeg(accountId: number, action: 'Buy' | 'Sell' | const acc = client.accountList.find((a) => a.id === accountId)!; if (isInNoTradeWindow(firm)) throw new Error(`${firm.name} is outside its trading hours`); - const tradeDate = currentTradeDateCT(); - const ev = evaluateAccountForTrade(client, acc, tradeDate); - if (!ev || !ev.plan) throw new Error(ev?.reason || 'account has no account type'); - if (!ev.eligible && !ev.reason.startsWith('already traded today')) throw new Error(ev.reason); - if (ev.isExtraDay) throw new Error('extra-day accounts only take the scheduled in-and-out'); - if (ev.plan.remainingToday <= LEG_COMPLETION_EPSILON) throw new Error(`day target met ($${ev.plan.remainingToday} left)`); - const cfg = getAccountConfig(acc.id).accountType!; - // The once-a-day return comes before the daily-loss check: run it here. - let room = ev.dllRoom; - if (!ev.eligible) { - const gate = dailyLossGate(client, acc.id, cfg, ev.cash, ev.cash.realizedPnL, ev.plan.remainingToday, ev.drain); - if ('reason' in gate) throw new Error(gate.reason); - room = gate.room; - } - - // A lane with positions open may only be joined on the same instrument - // and side — what a batch of like accounts would have done — never the - // other side (that is hedging). + // The lane's lock before anything is read, as a batch takes it: a batch + // that fired between this leg's checks and its entry would leave it + // trading on numbers from before — a second leg, or the other side of + // the position the batch just opened. const lane = laneOf(firm); - const held = inLane(firmClients().ready, lane).flatMap(({ client: c }) => c.accountList - .map((a) => c.positions[a.id]) - .filter((p): p is NonNullable => !!p)); - const heldSides = new Set(held.map((p) => (p.netPos > 0 ? 'Buy' : 'Sell'))); - const heldContracts = new Set(held.map((p) => String(p.contractId))); - if (heldSides.size > 1 || heldContracts.size > 1) throw new Error(`the ${laneName(lane)} lane holds mixed positions — not adding to it`); - const heldSide: 'Buy' | 'Sell' | null = held.length > 0 ? (held[0].netPos > 0 ? 'Buy' : 'Sell') : null; - 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); - const sizingItem: SizingItem = { - firmName: firm.name, firm, client, acc, - cash: ev.cash, dailyPnL: ev.dailyPnL, plan: ev.plan, drain: ev.drain, - prefix: cfg.prefix, dllRoom: room, - }; - // The instrument: a DXtrade firm ranks its list; a futures firm joins the - // held root, else trades an explicit scheduler symbol it lists, else - // draws from its list. - let symbol = '*'; - if (firm.platform !== 'dxtrade') { - const listed = firmInstruments(firm); - const state = getState(); - const found = held.length > 0 ? await heldRoot(client, held[0].contractId, [...listed, housekeepingFor(firm)]) : null; - const drawn = found ?? (state.symbol !== 'Auto' && listed.includes(state.symbol) - ? state.symbol - : await drawFuturesInstrument(listed, legShapeFor(sizingItem, tradeDate))); - if (!drawn) throw new Error(held.length > 0 ? `can't tell which listed instrument the lane holds` : 'nothing on the firm\'s list fits right now'); - symbol = drawn; - } - const contract = await client.findFrontMonthContract(symbol, ev.drain ? undefined : pickContextFor([sizingItem], tradeDate)); - if (!contract) throw new Error(`no instrument for ${symbol}`); - if (held.length > 0 && String(contract.id) !== [...heldContracts][0]) { - throw new Error(`the ${laneName(lane)} lane holds ${[...heldContracts][0]}, not ${contract.id} — not adding to it`); - } - const pv = contract.pointValue ?? POINT_VALUES[symbol]; - if (!pv) throw new Error(`no point value for ${symbol}`); - const lease = acquireTradeMutex('[manual-leg]', lane); if (lease === null) throw new Error('another batch is in flight in this lane'); try { + const tradeDate = currentTradeDateCT(); + const ev = evaluateAccountForTrade(client, acc, tradeDate); + if (!ev || !ev.plan) throw new Error(ev?.reason || 'account has no account type'); + if (!ev.eligible && !ev.reason.startsWith('already traded today')) throw new Error(ev.reason); + if (ev.isExtraDay) throw new Error('extra-day accounts only take the scheduled in-and-out'); + if (ev.plan.remainingToday <= LEG_COMPLETION_EPSILON) throw new Error(`day target met ($${ev.plan.remainingToday} left)`); + const cfg = getAccountConfig(acc.id).accountType!; + // The once-a-day return comes before the daily-loss check: run it here. + let room = ev.dllRoom; + if (!ev.eligible) { + const gate = dailyLossGate(client, acc.id, cfg, ev.cash, ev.cash.realizedPnL, ev.plan.remainingToday, ev.drain); + if ('reason' in gate) throw new Error(gate.reason); + room = gate.room; + } + + // A lane with positions open may only be joined on the same instrument + // and side — what a batch of like accounts would have done — never the + // other side (that is hedging). + const held = inLane(firmClients().ready, lane).flatMap(({ client: c }) => c.accountList + .map((a) => c.positions[a.id]) + .filter((p): p is NonNullable => !!p)); + const heldSides = new Set(held.map((p) => (p.netPos > 0 ? 'Buy' : 'Sell'))); + const heldContracts = new Set(held.map((p) => String(p.contractId))); + if (heldSides.size > 1 || heldContracts.size > 1) throw new Error(`the ${laneName(lane)} lane holds mixed positions — not adding to it`); + const heldSide: 'Buy' | 'Sell' | null = held.length > 0 ? (held[0].netPos > 0 ? 'Buy' : 'Sell') : null; + 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); + const sizingItem: SizingItem = { + firmName: firm.name, firm, client, acc, + cash: ev.cash, dailyPnL: ev.dailyPnL, plan: ev.plan, drain: ev.drain, + prefix: cfg.prefix, dllRoom: room, + }; + // The instrument: a DXtrade firm ranks its list; a futures firm joins the + // held root, else trades an explicit scheduler symbol it lists, else + // draws from its list. + let symbol = '*'; + if (firm.platform !== 'dxtrade') { + const listed = firmInstruments(firm); + const state = getState(); + const found = held.length > 0 ? await heldRoot(client, held[0].contractId, [...listed, housekeepingFor(firm)]) : null; + const drawn = found ?? (state.symbol !== 'Auto' && listed.includes(state.symbol) + ? state.symbol + : await drawFuturesInstrument(listed, legShapeFor(sizingItem, tradeDate))); + if (!drawn) throw new Error(held.length > 0 ? `can't tell which listed instrument the lane holds` : 'nothing on the firm\'s list fits right now'); + symbol = drawn; + } + const contract = await client.findFrontMonthContract(symbol, ev.drain ? undefined : pickContextFor([sizingItem], tradeDate)); + if (!contract) throw new Error(`no instrument for ${symbol}`); + if (held.length > 0 && String(contract.id) !== [...heldContracts][0]) { + throw new Error(`the ${laneName(lane)} lane holds ${[...heldContracts][0]}, not ${contract.id} — not adding to it`); + } + const pv = contract.pointValue ?? POINT_VALUES[symbol]; + if (!pv) throw new Error(`no point value for ${symbol}`); + + // The lookups above took long enough for a batch to presume this one + // wedged and take the lane: it traded on fresher numbers. + if (!holdsTradeMutex(lease, lane)) throw new Error('superseded by a batch while choosing — not firing'); + const item: LegItem = { ...sizingItem, contract, pv }; console.log(`[manual-leg] ${acc.name} (${firm.name}): firing one leg by hand`); runtime().lastFireAt = Date.now();