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:
co-authored by
Claude Opus 5.5
parent
5a4e85cc18
commit
934620e8e9
+64
-56
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user