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 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-09-08 11:37:09 -05:00
co-authored by Claude Fable 5
parent 48f3c97ec5
commit 77ae4971f8
+30 -4
View File
@@ -255,6 +255,7 @@ export async function runTrade(action: 'Buy' | 'Sell' | 'Auto', symbol: string)
daysTraded: number; daysTraded: number;
plan: DayPlan; plan: DayPlan;
isExtraDay: boolean; isExtraDay: boolean;
prefix: string;
}; };
const allEligible: EligibleItem[] = []; const allEligible: EligibleItem[] = [];
@@ -281,6 +282,7 @@ export async function runTrade(action: 'Buy' | 'Sell' | 'Auto', symbol: string)
firmName: firm.name, client, acc, contract, firmName: firm.name, client, acc, contract,
cash: ev.cash, dailyPnL: ev.dailyPnL, daysTraded: ev.daysTraded, cash: ev.cash, dailyPnL: ev.dailyPnL, daysTraded: ev.daysTraded,
plan: ev.plan, isExtraDay: ev.isExtraDay, 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. // 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). // Remaining accounts wait for the next tick (which only fires once all positions are flat).
const batch = allEligible.slice(0, maxConcurrent); // Like accounts trade together: a batch is drawn from a single prefix group
console.log(`[auto-trade] ${allEligible.length} eligible account(s) — firing batch of ${batch.length}`); // (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<string, EligibleItem[]>();
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 ── // ── Phase 2: fire the batch simultaneously ──
const tradeResults = await Promise.allSettled(batch.map(async (item) => { const tradeResults = await Promise.allSettled(batch.map(async (item) => {
@@ -414,6 +429,7 @@ export async function copyTrade() {
let positionedCount = 0; let positionedCount = 0;
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
let positionedClient: any = null; let positionedClient: any = null;
const positionedPrefixes = new Set<string>();
for (const firm of firms) { for (const firm of firms) {
const client = clients.get(firm.id); const client = clients.get(firm.id);
@@ -422,6 +438,8 @@ export async function copyTrade() {
const pos = client.positions[acc.id]; const pos = client.positions[acc.id];
if (!pos) continue; if (!pos) continue;
positionedCount++; positionedCount++;
const prefix = getAccountConfig(acc.id).accountType?.prefix;
if (prefix) positionedPrefixes.add(prefix);
if (!resolvedAction) { if (!resolvedAction) {
resolvedAction = pos.netPos > 0 ? 'Buy' : 'Sell'; resolvedAction = pos.netPos > 0 ? 'Buy' : 'Sell';
positionContractId = pos.contractId; positionContractId = pos.contractId;
@@ -482,6 +500,7 @@ export async function copyTrade() {
contract: { name: string; tickSize: number }; contract: { name: string; tickSize: number };
cash: { amount: number; realizedPnL: number }; cash: { amount: number; realizedPnL: number };
plan: DayPlan; plan: DayPlan;
prefix: string;
}; };
const eligible: CopyItem[] = []; const eligible: CopyItem[] = [];
@@ -509,11 +528,18 @@ export async function copyTrade() {
// copyTrade has no extra-day branch — those accounts wait for the scheduler // 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; } 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) { if (batch.length === 0) {
console.log('[copy-trade] no eligible accounts to copy to'); console.log('[copy-trade] no eligible accounts to copy to');
return []; return [];