From 77ae4971f87aca87f458cfd05208a0d42fa03ac4 Mon Sep 17 00:00:00 2001 From: Brandon Li Date: Tue, 8 Sep 2026 11:37:09 -0500 Subject: [PATCH] Batch like accounts together by prefix before any mismatches runTrade now draws each batch from a single account-type prefix group (largest group first) instead of slicing across prefixes; a group's remainder fires alone on a later tick. copyTrade fills open slots with accounts matching the positioned accounts' prefix before mismatched ones. Co-Authored-By: Claude Fable 5 --- lib/auto-trade.ts | 34 ++++++++++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/lib/auto-trade.ts b/lib/auto-trade.ts index 2d98881..dc14550 100644 --- a/lib/auto-trade.ts +++ b/lib/auto-trade.ts @@ -255,6 +255,7 @@ export async function runTrade(action: 'Buy' | 'Sell' | 'Auto', symbol: string) daysTraded: number; plan: DayPlan; isExtraDay: boolean; + prefix: string; }; const allEligible: EligibleItem[] = []; @@ -281,6 +282,7 @@ export async function runTrade(action: 'Buy' | 'Sell' | 'Auto', symbol: string) firmName: firm.name, client, acc, contract, cash: ev.cash, dailyPnL: ev.dailyPnL, daysTraded: ev.daysTraded, plan: ev.plan, isExtraDay: ev.isExtraDay, + prefix: getAccountConfig(acc.id).accountType?.prefix ?? '', }); } })); @@ -292,8 +294,21 @@ export async function runTrade(action: 'Buy' | 'Sell' | 'Auto', symbol: string) // Take only the first batch — all fired simultaneously, no rolling pool. // Remaining accounts wait for the next tick (which only fires once all positions are flat). - const batch = allEligible.slice(0, maxConcurrent); - console.log(`[auto-trade] ${allEligible.length} eligible account(s) — firing batch of ${batch.length}`); + // Like accounts trade together: a batch is drawn from a single prefix group + // (largest group first), never mixed across prefixes — a group's remainder + // fires as a smaller batch on a later tick instead of being padded from + // another prefix. E.g. 4×FTDFY, 5×MFFUEV, 7×MFFUSF at limit 5 fires as + // batches of 5, 5, 4, 2. + const groups = new Map(); + for (const item of allEligible) { + const group = groups.get(item.prefix); + if (group) group.push(item); + else groups.set(item.prefix, [item]); + } + const [batchPrefix, batchGroup] = [...groups.entries()] + .sort((a, b) => b[1].length - a[1].length || a[0].localeCompare(b[0]))[0]; + const batch = batchGroup.slice(0, maxConcurrent); + console.log(`[auto-trade] ${allEligible.length} eligible account(s) across ${groups.size} prefix group(s) — firing batch of ${batch.length} ${batchPrefix || '(no prefix)'}`); // ── Phase 2: fire the batch simultaneously ── const tradeResults = await Promise.allSettled(batch.map(async (item) => { @@ -414,6 +429,7 @@ export async function copyTrade() { let positionedCount = 0; // eslint-disable-next-line @typescript-eslint/no-explicit-any let positionedClient: any = null; + const positionedPrefixes = new Set(); for (const firm of firms) { const client = clients.get(firm.id); @@ -422,6 +438,8 @@ export async function copyTrade() { const pos = client.positions[acc.id]; if (!pos) continue; positionedCount++; + const prefix = getAccountConfig(acc.id).accountType?.prefix; + if (prefix) positionedPrefixes.add(prefix); if (!resolvedAction) { resolvedAction = pos.netPos > 0 ? 'Buy' : 'Sell'; positionContractId = pos.contractId; @@ -482,6 +500,7 @@ export async function copyTrade() { contract: { name: string; tickSize: number }; cash: { amount: number; realizedPnL: number }; plan: DayPlan; + prefix: string; }; const eligible: CopyItem[] = []; @@ -509,11 +528,18 @@ export async function copyTrade() { // copyTrade has no extra-day branch — those accounts wait for the scheduler if (ev.isExtraDay) { console.log(`[copy-trade] ${acc.name}: extra-day account — skip`); continue; } - eligible.push({ firmName: firm.name, client, acc, contract, cash: ev.cash, plan: ev.plan }); + eligible.push({ + firmName: firm.name, client, acc, contract, cash: ev.cash, plan: ev.plan, + prefix: getAccountConfig(acc.id).accountType?.prefix ?? '', + }); } })); - const batch = eligible.slice(0, slotsAvailable); + // Fill the open slots with accounts whose prefix matches the already-positioned + // accounts before falling back to mismatched prefixes. + const matchesPositioned = (item: CopyItem) => positionedPrefixes.has(item.prefix); + const ordered = [...eligible.filter(matchesPositioned), ...eligible.filter((i) => !matchesPositioned(i))]; + const batch = ordered.slice(0, slotsAvailable); if (batch.length === 0) { console.log('[copy-trade] no eligible accounts to copy to'); return [];