Randomize daily targets: split big days into legs, bump min-days

Big days (> $1700) now trade as multiple separate legs of random size
(each >= $800, $5-rounded), resumable across pauses since the remaining
target is derived from realized PnL against a day-anchored target.
Min-day targets get a deterministic random $0-25 bump seeded per
(account, trade date) so the dashboard, reporter, and orders agree.
Strict-consistency accounts randomize only their first trade ever.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-09-02 11:32:48 -05:00
co-authored by Claude Fable 5
parent 8350601cf0
commit de9cbffd83
4 changed files with 299 additions and 143 deletions
+148 -127
View File
@@ -11,7 +11,11 @@ import { getInstruments } from './db';
import { getClients } from './clients';
import { getTradableFirms } from './firm-catalog';
import { getAccountConfig } from './account-type-resolver';
import { computeDailyTarget, resolveEffectiveConfig, POINT_VALUES } from './trading-logic';
import {
computeDayPlan, pickLegAmount, resolveEffectiveConfig, currentTradeDateCT,
LEG_COMPLETION_EPSILON, MAX_LEGS_PER_DAY, POINT_VALUES,
type DayPlan,
} from './trading-logic';
import { getSetting, setSetting } from './db';
// ── helpers ──────────────────────────────────────────────────────────────────
@@ -20,6 +24,98 @@ function isAccountDead(amount: number, autoLiqThreshold: number): boolean {
return autoLiqThreshold > 0 && amount <= autoLiqThreshold;
}
// In-memory runaway guard: legs fired per account per trade date. Resets on restart,
// which is fine — day completion is derived from realized PnL, not from this counter.
const _legCounts = new Map<number, { date: string; count: number }>();
function legsToday(accountId: number, tradeDate: string): number {
const entry = _legCounts.get(accountId);
return entry && entry.date === tradeDate ? entry.count : 0;
}
function recordLeg(accountId: number, tradeDate: string): void {
_legCounts.set(accountId, { date: tradeDate, count: legsToday(accountId, tradeDate) + 1 });
}
// ── per-account trade evaluation (shared by runTrade / copyTrade / hasRemaining…) ──
interface AccountEvaluation {
eligible: boolean;
reason: string;
plan: DayPlan | null;
isExtraDay: boolean;
cash: { amount: number; realizedPnL: number };
dailyPnL: { date: string; pnl: number }[];
daysTraded: number;
}
/**
* Evaluate whether an account can trade right now. Returns null when the account has no
* matching config (not ours). Split-allowed accounts stay eligible mid-day until
* remainingToday drops to LEG_COMPLETION_EPSILON or the leg cap is hit; extra-day and
* strict-consistency (past day 1) accounts keep the legacy one-trade-per-day gate.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function evaluateAccountForTrade(client: any, acc: { id: number; name: string; active: boolean }, tradeDate: string): AccountEvaluation | null {
const cfg = getAccountConfig(acc.id).accountType;
if (!cfg) return null;
const cash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 };
const autoLiqThreshold = client.autoLiqThresholds[acc.id] ?? 0;
const dailyPnL: { date: string; pnl: number }[] = client.dailyPnL[acc.id] ?? [];
const daysTraded: number = client.daysTraded[acc.id] ?? 0;
const base = { plan: null, isExtraDay: false, cash, dailyPnL, daysTraded };
if (isAccountDead(cash.amount, autoLiqThreshold)) return { ...base, eligible: false, reason: 'dead' };
if (!acc.active) return { ...base, eligible: false, reason: 'inactive (DLL)' };
if (client.positions[acc.id]) return { ...base, eligible: false, reason: 'already in trade' };
const priorProfit = client.priorProfit?.[acc.id] ?? 0;
const allFundTxns = client.fundTransactions?.[acc.id] ?? [];
const effective = resolveEffectiveConfig(
cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity, cfg.withdrawalStages, priorProfit, allFundTxns
);
const equityProfit = cash.amount - cfg.accountSize;
const plan = computeDayPlan(
acc.id, tradeDate, effective.profitTarget, effective.consistency,
dailyPnL, cfg.minDayPnL, effective.minTradingDays, equityProfit, cash.realizedPnL
);
if (!plan) return { ...base, eligible: false, reason: 'invalid balance' };
const totalProfit = dailyPnL.reduce((sum, d) => sum + d.pnl, 0);
const isExtraDay = cfg.minDayPnL <= 0
&& effective.minTradingDays > daysTraded
&& totalProfit >= effective.profitTarget;
const evaluated = { plan, isExtraDay, cash, dailyPnL, daysTraded };
if (isExtraDay || !plan.splitAllowed) {
// Legacy one-and-done gate: any realized PnL today means this account is finished.
if (cash.realizedPnL !== 0) {
return { ...evaluated, eligible: false, reason: `already traded today (realizedPnL=${cash.realizedPnL})` };
}
if (!isExtraDay && plan.remainingToday <= 0) {
return { ...evaluated, eligible: false, reason: `target=$${plan.dayTarget} (challenge complete)` };
}
} else {
// Split-leg mode: keep trading until the day's remaining target is used up.
// The epsilon only applies once a leg has completed, so tiny day-start targets still trade.
const doneThreshold = cash.realizedPnL === 0 ? 0 : LEG_COMPLETION_EPSILON;
if (plan.remainingToday <= doneThreshold) {
const reason = cash.realizedPnL === 0
? `target=$${plan.dayTarget} (challenge complete)`
: `day done ($${plan.remainingToday} left of $${plan.dayTarget})`;
return { ...evaluated, eligible: false, reason };
}
if (legsToday(acc.id, tradeDate) >= MAX_LEGS_PER_DAY) {
return { ...evaluated, eligible: false, reason: `leg cap reached (${MAX_LEGS_PER_DAY} today)` };
}
}
return { ...evaluated, eligible: true, reason: '' };
}
@@ -113,6 +209,7 @@ export async function runTrade(action: 'Buy' | 'Sell' | 'Auto', symbol: string)
const pointValue = POINT_VALUES[resolvedSymbol];
if (!pointValue) throw new Error(`Unknown symbol: ${resolvedSymbol}`);
const tradeDate = currentTradeDateCT();
const maxConcurrent = parseInt(getSetting('max_concurrent_accounts') ?? '5', 10);
const firms = getTradableFirms();
const clients = getClients();
@@ -127,6 +224,8 @@ export async function runTrade(action: 'Buy' | 'Sell' | 'Auto', symbol: string)
cash: { amount: number; realizedPnL: number };
dailyPnL: { date: string; pnl: number }[];
daysTraded: number;
plan: DayPlan;
isExtraDay: boolean;
};
const allEligible: EligibleItem[] = [];
@@ -146,42 +245,14 @@ export async function runTrade(action: 'Buy' | 'Sell' | 'Auto', symbol: string)
if (!contract) return;
for (const acc of client.accountList) {
const cash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 };
const autoLiqThreshold = client.autoLiqThresholds[acc.id] ?? 0;
const dailyPnL: { date: string; pnl: number }[] = client.dailyPnL[acc.id] ?? [];
const daysTraded: number = client.daysTraded[acc.id] ?? 0;
const ev = evaluateAccountForTrade(client, acc, tradeDate);
if (!ev || !ev.eligible || !ev.plan) continue;
if (isAccountDead(cash.amount, autoLiqThreshold)) continue;
if (!acc.active) continue;
if (client.positions[acc.id]) continue;
const cfg = getAccountConfig(acc.id).accountType;
if (!cfg) continue;
const totalProfit = dailyPnL.reduce((sum, d) => sum + d.pnl, 0);
// Only trade accounts that haven't traded yet today
if (cash.realizedPnL !== 0) continue;
const priorProfit = client.priorProfit?.[acc.id] ?? 0;
const allFundTxns = client.fundTransactions?.[acc.id] ?? [];
const effective = resolveEffectiveConfig(
cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity, cfg.withdrawalStages, priorProfit, allFundTxns
);
// Use the same target formula as the dashboard — skip if $0 (challenge complete)
const equityProfit = cash.amount - cfg.accountSize;
const target = computeDailyTarget(effective.profitTarget, effective.consistency, dailyPnL, cfg.minDayPnL, effective.minTradingDays, equityProfit);
if (!target) continue; // skip accounts without a valid balance
// Allow through if it's an MNQ extra-day trade: no min day P&L, profit done, days still needed
const isMnqExtraDay = cfg.minDayPnL <= 0
&& effective.minTradingDays > daysTraded
&& totalProfit >= effective.profitTarget;
if (target.amount <= 0 && !isMnqExtraDay) continue;
allEligible.push({ firmName: firm.name, client, acc, contract, cash, dailyPnL, daysTraded });
allEligible.push({
firmName: firm.name, client, acc, contract,
cash: ev.cash, dailyPnL: ev.dailyPnL, daysTraded: ev.daysTraded,
plan: ev.plan, isExtraDay: ev.isExtraDay,
});
}
}));
@@ -197,22 +268,12 @@ export async function runTrade(action: 'Buy' | 'Sell' | 'Auto', symbol: string)
// ── Phase 2: fire the batch simultaneously ──
const tradeResults = await Promise.allSettled(batch.map(async (item) => {
const { client, acc, contract, cash, dailyPnL, daysTraded } = item;
const { client, acc, contract } = item;
const cfg = getAccountConfig(acc.id).accountType!;
const totalProfit = dailyPnL.reduce((sum, d) => sum + d.pnl, 0);
const priorProfit = client.priorProfit?.[acc.id] ?? 0;
const allFundTxns = client.fundTransactions?.[acc.id] ?? [];
const effective = resolveEffectiveConfig(
cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity, cfg.withdrawalStages, priorProfit, allFundTxns
);
// Extra-day mode: profit target already met, no min day P&L, days still needed.
// Just trade 1 MNQ in and out at market immediately — P&L doesn't matter.
const isExtraDay = cfg.minDayPnL <= 0
&& effective.minTradingDays > daysTraded
&& totalProfit >= effective.profitTarget;
if (isExtraDay) {
if (item.isExtraDay) {
const mnqContract = await client.findFrontMonthContract('MNQ');
if (!mnqContract) throw new Error('MNQ contract not found for extra-day trade');
@@ -237,22 +298,24 @@ export async function runTrade(action: 'Buy' | 'Sell' | 'Auto', symbol: string)
};
}
const equityProfit = cash.amount - cfg.accountSize;
const target = computeDailyTarget(effective.profitTarget, effective.consistency, dailyPnL, cfg.minDayPnL, effective.minTradingDays, equityProfit);
if (!target) throw new Error(`${acc.name}: invalid balance, cannot compute target`);
// Trade one leg toward today's remaining target. Split-allowed big days carve a
// random ≥$800 piece; everything else trades the full remaining amount in one go.
const legAmount = item.plan.splitAllowed ? pickLegAmount(item.plan.remainingToday) : item.plan.remainingToday;
recordLeg(acc.id, tradeDate);
const rawContracts = Math.max(1, Math.ceil(target.amount / 1000));
const rawContracts = Math.max(1, Math.ceil(legAmount / 1000));
const contracts = cfg.maxPositionSize > 0 ? Math.min(rawContracts, cfg.maxPositionSize) : rawContracts;
const fill = await client.sendOrder(acc.id, contract.name, contracts, resolvedAction, 'Market');
// Wait briefly for the cash balance WebSocket update to reflect entry commission
await new Promise(r => setTimeout(r, 1000));
const updatedCash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 };
// After entry, realizedPnL = -entryCommission (was 0 before), so abs = entry fee paid.
// After entry, realizedPnL drops by the entry commission; the delta from the
// pre-entry value is the entry fee paid (pre-entry may be nonzero mid-day).
// Fall back to $2.50/contract if the WS hasn't updated yet (guarantees at least 1 extra tick).
const entryCommission = Math.abs(updatedCash.realizedPnL) || (2.5 * contracts);
const entryCommission = Math.abs(updatedCash.realizedPnL - item.cash.realizedPnL) || (2.5 * contracts);
const totalCommission = entryCommission * 2; // entry + exit round-trip
const grossTarget = target.amount + totalCommission;
const grossTarget = legAmount + totalCommission;
const targetPoints = grossTarget / (pointValue * contracts);
const ticks = Math.ceil(targetPoints / contract.tickSize);
@@ -263,17 +326,19 @@ export async function runTrade(action: 'Buy' | 'Sell' | 'Auto', symbol: string)
const exitAction: 'Buy' | 'Sell' = resolvedAction === 'Buy' ? 'Sell' : 'Buy';
const exitOrder = await client.placeOrderNoWait(acc.id, contract.name, contracts, exitAction, 'Limit', exitPrice);
console.log(`[auto-trade] ${acc.name} (${item.firmName}) ${resolvedAction} ${contracts}x${resolvedSymbol} @ ${fill.price} | target $${target.amount} [${target.path}] (+$${totalCommission.toFixed(2)} comm) | exit @ ${exitPrice} (orderId=${exitOrder.orderId})`);
console.log(`[auto-trade] ${acc.name} (${item.firmName}) ${resolvedAction} ${contracts}x${resolvedSymbol} @ ${fill.price} | leg $${legAmount} of day $${item.plan.dayTarget} (remaining $${item.plan.remainingToday}) [${item.plan.path}] (+$${totalCommission.toFixed(2)} comm) | exit @ ${exitPrice} (orderId=${exitOrder.orderId})`);
return {
account: acc.name,
firm: item.firmName,
status: 'filled',
contracts,
target: target.amount,
target: legAmount,
dayTarget: item.plan.dayTarget,
remainingBefore: item.plan.remainingToday,
grossTarget,
totalCommission,
targetPath: target.path,
targetPath: item.plan.path,
entryPrice: fill.price,
exitPrice,
commission: entryCommission,
@@ -377,15 +442,17 @@ export async function copyTrade() {
return [];
}
const tradeDate = currentTradeDateCT();
// Collect eligible accounts (same logic as Phase 1 of runTrade)
type CopyItem = {
firmName: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
client: any;
acc: { id: number; name: string; active: boolean };
contract: { name: string; tickSize: number };
cash: { amount: number; realizedPnL: number };
dailyPnL: { date: string; pnl: number }[];
daysTraded: number;
plan: DayPlan;
};
const eligible: CopyItem[] = [];
@@ -407,31 +474,13 @@ export async function copyTrade() {
}
for (const acc of client.accountList) {
if (client.positions[acc.id]) { console.log(`[copy-trade] ${acc.name}: already in trade — skip`); continue; }
const cash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 };
const autoLiqThreshold = client.autoLiqThresholds[acc.id] ?? 0;
if (isAccountDead(cash.amount, autoLiqThreshold)) { console.log(`[copy-trade] ${acc.name}: dead — skip`); continue; }
if (!acc.active) { console.log(`[copy-trade] ${acc.name}: inactive (DLL) — skip`); continue; }
const cfg = getAccountConfig(acc.id).accountType;
if (!cfg) { console.log(`[copy-trade] ${acc.name}: no matching config prefix — skip`); continue; }
if (cash.realizedPnL !== 0) { console.log(`[copy-trade] ${acc.name}: already traded today (realizedPnL=${cash.realizedPnL}) — skip`); continue; }
const ev = evaluateAccountForTrade(client, acc, tradeDate);
if (!ev) { console.log(`[copy-trade] ${acc.name}: no matching config prefix — skip`); continue; }
if (!ev.eligible || !ev.plan) { console.log(`[copy-trade] ${acc.name}: ${ev.reason} — skip`); continue; }
// 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; }
const dailyPnL: { date: string; pnl: number }[] = client.dailyPnL[acc.id] ?? [];
const daysTraded: number = client.daysTraded[acc.id] ?? 0;
const totalProfit = dailyPnL.reduce((sum, d) => sum + d.pnl, 0);
const priorProfit = client.priorProfit?.[acc.id] ?? 0;
const allFundTxns = client.fundTransactions?.[acc.id] ?? [];
const effective = resolveEffectiveConfig(
cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity, cfg.withdrawalStages, priorProfit, allFundTxns
);
const equityProfit = cash.amount - cfg.accountSize;
const target = computeDailyTarget(effective.profitTarget, effective.consistency, dailyPnL, cfg.minDayPnL, effective.minTradingDays, equityProfit);
if (!target) { console.log(`[copy-trade] ${acc.name}: target=null (invalid balance) — skip`); continue; }
if (target.amount <= 0) { console.log(`[copy-trade] ${acc.name}: target=$${target.amount} (challenge complete) — skip`); continue; }
eligible.push({ firmName: firm.name, client, acc, contract, cash, dailyPnL, daysTraded });
eligible.push({ firmName: firm.name, client, acc, contract, cash: ev.cash, plan: ev.plan });
}
}));
@@ -445,28 +494,21 @@ export async function copyTrade() {
// Fire orders (same as Phase 2 of runTrade)
const tradeResults = await Promise.allSettled(batch.map(async (item) => {
const { client, acc, contract, cash, dailyPnL } = item;
const { client, acc, contract } = item;
const cfg = getAccountConfig(acc.id).accountType!;
const totalProfit = dailyPnL.reduce((sum, d) => sum + d.pnl, 0);
const priorProfit = client.priorProfit?.[acc.id] ?? 0;
const allFundTxns = client.fundTransactions?.[acc.id] ?? [];
const effective = resolveEffectiveConfig(
cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity, cfg.withdrawalStages, priorProfit, allFundTxns
);
const equityProfit = cash.amount - cfg.accountSize;
const target = computeDailyTarget(effective.profitTarget, effective.consistency, dailyPnL, cfg.minDayPnL, effective.minTradingDays, equityProfit);
if (!target) throw new Error(`${acc.name}: invalid balance, cannot compute target`);
const legAmount = item.plan.splitAllowed ? pickLegAmount(item.plan.remainingToday) : item.plan.remainingToday;
recordLeg(acc.id, tradeDate);
const rawContracts = Math.max(1, Math.ceil(target.amount / 1000));
const rawContracts = Math.max(1, Math.ceil(legAmount / 1000));
const contracts = cfg.maxPositionSize > 0 ? Math.min(rawContracts, cfg.maxPositionSize) : rawContracts;
const fill = await client.sendOrder(acc.id, contract.name, contracts, resolvedAction!, 'Market');
await new Promise(r => setTimeout(r, 1000));
const updatedCash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 };
const entryCommission = Math.abs(updatedCash.realizedPnL) || (2.5 * contracts);
const entryCommission = Math.abs(updatedCash.realizedPnL - item.cash.realizedPnL) || (2.5 * contracts);
const totalCommission = entryCommission * 2;
const grossTarget = target.amount + totalCommission;
const grossTarget = legAmount + totalCommission;
const targetPoints = grossTarget / (pointValue * contracts);
const ticks = Math.ceil(targetPoints / contract.tickSize);
@@ -477,17 +519,19 @@ export async function copyTrade() {
const exitAction: 'Buy' | 'Sell' = resolvedAction === 'Buy' ? 'Sell' : 'Buy';
const exitOrder = await client.placeOrderNoWait(acc.id, contract.name, contracts, exitAction, 'Limit', exitPrice);
console.log(`[copy-trade] ${acc.name} (${item.firmName}) ${resolvedAction} ${contracts}x${resolvedSymbol} @ ${fill.price} | target $${target.amount} [${target.path}] | exit @ ${exitPrice} (orderId=${exitOrder.orderId})`);
console.log(`[copy-trade] ${acc.name} (${item.firmName}) ${resolvedAction} ${contracts}x${resolvedSymbol} @ ${fill.price} | leg $${legAmount} of day $${item.plan.dayTarget} (remaining $${item.plan.remainingToday}) [${item.plan.path}] | exit @ ${exitPrice} (orderId=${exitOrder.orderId})`);
return {
account: acc.name,
firm: item.firmName,
status: 'filled',
contracts,
target: target.amount,
target: legAmount,
dayTarget: item.plan.dayTarget,
remainingBefore: item.plan.remainingToday,
grossTarget,
totalCommission,
targetPath: target.path,
targetPath: item.plan.path,
entryPrice: fill.price,
exitPrice,
commission: entryCommission,
@@ -508,16 +552,16 @@ export async function copyTrade() {
// ── eligibility check ────────────────────────────────────────────────────────
/** Returns true if any configured account could still trade today (not dead, not inactive, hasn't traded, target > 0 or extra-day, or has open position). */
/** Returns true if any configured account could still trade today (eligible for a next leg / extra-day, or has an open position). */
function hasRemainingConfiguredAccounts(): boolean {
const firms = getTradableFirms();
const clients = getClients();
const tradeDate = currentTradeDateCT();
for (const firm of firms) {
const client = clients.get(firm.id);
if (!client || client.accountList.length === 0) continue;
for (const acc of client.accountList) {
const cfg = getAccountConfig(acc.id).accountType;
if (!cfg) continue; // no config = not our account
@@ -525,31 +569,8 @@ function hasRemainingConfiguredAccounts(): boolean {
// Account with open position = still in play
if (client.positions[acc.id]) return true;
const cash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 };
const autoLiqThreshold = client.autoLiqThresholds[acc.id] ?? 0;
if (isAccountDead(cash.amount, autoLiqThreshold)) continue;
if (!acc.active) continue;
if (cash.realizedPnL !== 0) continue; // already traded today
const dailyPnL: { date: string; pnl: number }[] = client.dailyPnL[acc.id] ?? [];
const daysTraded: number = client.daysTraded[acc.id] ?? 0;
const totalProfit = dailyPnL.reduce((sum, d) => sum + d.pnl, 0);
const priorProfit = client.priorProfit?.[acc.id] ?? 0;
const allFundTxns = client.fundTransactions?.[acc.id] ?? [];
const effective = resolveEffectiveConfig(
cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity, cfg.withdrawalStages, priorProfit, allFundTxns
);
const equityProfit = cash.amount - cfg.accountSize;
const target = computeDailyTarget(effective.profitTarget, effective.consistency, dailyPnL, cfg.minDayPnL, effective.minTradingDays, equityProfit);
if (!target) continue; // no valid balance → skip this account
const isMnqExtraDay = cfg.minDayPnL <= 0
&& effective.minTradingDays > daysTraded
&& totalProfit >= effective.profitTarget;
if (target.amount > 0 || isMnqExtraDay) return true;
const ev = evaluateAccountForTrade(client, acc, tradeDate);
if (ev?.eligible) return true;
}
}