Manual leg: take the lane's lock before reading the account

It checked the account, then awaited the instrument draw and the contract
lookup, and only then locked the lane. A batch that fired in that gap left
it trading on numbers from before: a second leg, or on DXtrade the other
side of the position the batch had just opened. Now it locks first, as the
batch and copy trade do, and checks the lease still holds before firing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-09-27 13:59:23 -05:00
co-authored by Claude Opus 5.5
parent 5a4e85cc18
commit 934620e8e9
+64 -56
View File
@@ -1540,66 +1540,74 @@ export async function fireManualLeg(accountId: number, action: 'Buy' | 'Sell' |
const acc = client.accountList.find((a) => a.id === accountId)!; const acc = client.accountList.find((a) => a.id === accountId)!;
if (isInNoTradeWindow(firm)) throw new Error(`${firm.name} is outside its trading hours`); if (isInNoTradeWindow(firm)) throw new Error(`${firm.name} is outside its trading hours`);
const tradeDate = currentTradeDateCT(); // The lane's lock before anything is read, as a batch takes it: a batch
const ev = evaluateAccountForTrade(client, acc, tradeDate); // that fired between this leg's checks and its entry would leave it
if (!ev || !ev.plan) throw new Error(ev?.reason || 'account has no account type'); // trading on numbers from before — a second leg, or the other side of
if (!ev.eligible && !ev.reason.startsWith('already traded today')) throw new Error(ev.reason); // the position the batch just opened.
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 lane = laneOf(firm); 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<typeof p> => !!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); const lease = acquireTradeMutex('[manual-leg]', lane);
if (lease === null) throw new Error('another batch is in flight in this lane'); if (lease === null) throw new Error('another batch is in flight in this lane');
try { 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<typeof p> => !!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 }; const item: LegItem = { ...sizingItem, contract, pv };
console.log(`[manual-leg] ${acc.name} (${firm.name}): firing one leg by hand`); console.log(`[manual-leg] ${acc.name} (${firm.name}): firing one leg by hand`);
runtime().lastFireAt = Date.now(); runtime().lastFireAt = Date.now();