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:
co-authored by
Claude Fable 5
parent
8350601cf0
commit
de9cbffd83
+14
-7
@@ -3,12 +3,13 @@ import { loadDailyPnL } from '@/lib/db';
|
|||||||
import { getClients } from '@/lib/clients';
|
import { getClients } from '@/lib/clients';
|
||||||
import { getCachedCatalog } from '@/lib/firm-catalog';
|
import { getCachedCatalog } from '@/lib/firm-catalog';
|
||||||
import { getAccountConfig, getInferredSize, matchAccountType } from '@/lib/account-type-resolver';
|
import { getAccountConfig, getInferredSize, matchAccountType } from '@/lib/account-type-resolver';
|
||||||
import { computeDailyTarget, resolveEffectiveConfig } from '@/lib/trading-logic';
|
import { computeDayPlan, resolveEffectiveConfig, currentTradeDateCT, LEG_COMPLETION_EPSILON } from '@/lib/trading-logic';
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const firms = getCachedCatalog();
|
const firms = getCachedCatalog();
|
||||||
const clients = getClients();
|
const clients = getClients();
|
||||||
|
const tradeDate = currentTradeDateCT();
|
||||||
|
|
||||||
const state = firms.map((f) => {
|
const state = firms.map((f) => {
|
||||||
const client = clients.get(f.id);
|
const client = clients.get(f.id);
|
||||||
@@ -53,6 +54,7 @@ export async function GET() {
|
|||||||
: false;
|
: false;
|
||||||
let targetHit = false;
|
let targetHit = false;
|
||||||
let dailyTarget: { amount: number; path: 'first_day' | 'normal_day' | 'reduced_day' } | null = null;
|
let dailyTarget: { amount: number; path: 'first_day' | 'normal_day' | 'reduced_day' } | null = null;
|
||||||
|
let remainingToday: number | null = null;
|
||||||
let effectiveProfitTarget: number | null = null;
|
let effectiveProfitTarget: number | null = null;
|
||||||
if (cfg && !isDead) {
|
if (cfg && !isDead) {
|
||||||
const effective = resolveEffectiveConfig(
|
const effective = resolveEffectiveConfig(
|
||||||
@@ -65,8 +67,12 @@ export async function GET() {
|
|||||||
allFundTxns
|
allFundTxns
|
||||||
);
|
);
|
||||||
const equityProfit = cash.amount - cfg.accountSize;
|
const equityProfit = cash.amount - cfg.accountSize;
|
||||||
const target = computeDailyTarget(effective.profitTarget, effective.consistency, dailyPnL, cfg.minDayPnL, effective.minTradingDays, equityProfit);
|
const plan = computeDayPlan(
|
||||||
dailyTarget = target;
|
acc.id, tradeDate, effective.profitTarget, effective.consistency,
|
||||||
|
dailyPnL, cfg.minDayPnL, effective.minTradingDays, equityProfit, cash.realizedPnL
|
||||||
|
);
|
||||||
|
dailyTarget = plan ? { amount: plan.dayTarget, path: plan.path } : null;
|
||||||
|
remainingToday = plan?.remainingToday ?? null;
|
||||||
|
|
||||||
// Effective profit target = max(stage target, consistency realTarget).
|
// Effective profit target = max(stage target, consistency realTarget).
|
||||||
// If a big day forces the consistency rule, the account must reach maxDay/consistency
|
// If a big day forces the consistency rule, the account must reach maxDay/consistency
|
||||||
@@ -77,11 +83,11 @@ export async function GET() {
|
|||||||
? maxDay / effective.consistency : 0;
|
? maxDay / effective.consistency : 0;
|
||||||
effectiveProfitTarget = Math.max(effective.profitTarget, consistencyReal);
|
effectiveProfitTarget = Math.max(effective.profitTarget, consistencyReal);
|
||||||
// Condition 1: profit target already exceeded (target=0), still need days → any activity counts
|
// Condition 1: profit target already exceeded (target=0), still need days → any activity counts
|
||||||
// Condition 2: target > 0 → must have made at least the computed daily target
|
// Condition 2: target > 0 → all legs done (day's remaining target used up)
|
||||||
if (target) {
|
if (plan) {
|
||||||
targetHit =
|
targetHit =
|
||||||
(target.amount === 0 && Math.abs(cash.realizedPnL) > 0 && client.daysTraded[acc.id] <= effective.minTradingDays) ||
|
(plan.dayTarget === 0 && Math.abs(cash.realizedPnL) > 0 && client.daysTraded[acc.id] <= effective.minTradingDays) ||
|
||||||
(cash.realizedPnL >= target.amount);
|
(cash.realizedPnL > 0 && plan.remainingToday <= LEG_COMPLETION_EPSILON);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,6 +106,7 @@ export async function GET() {
|
|||||||
targetHit,
|
targetHit,
|
||||||
stage,
|
stage,
|
||||||
dailyTarget,
|
dailyTarget,
|
||||||
|
remainingToday,
|
||||||
effectiveProfitTarget,
|
effectiveProfitTarget,
|
||||||
dailyPnL,
|
dailyPnL,
|
||||||
fullDailyPnL: client.fullDailyPnL?.[acc.id] ?? loadDailyPnL(acc.id),
|
fullDailyPnL: client.fullDailyPnL?.[acc.id] ?? loadDailyPnL(acc.id),
|
||||||
|
|||||||
+148
-127
@@ -11,7 +11,11 @@ import { getInstruments } from './db';
|
|||||||
import { getClients } from './clients';
|
import { getClients } from './clients';
|
||||||
import { getTradableFirms } from './firm-catalog';
|
import { getTradableFirms } from './firm-catalog';
|
||||||
import { getAccountConfig } from './account-type-resolver';
|
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';
|
import { getSetting, setSetting } from './db';
|
||||||
|
|
||||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||||
@@ -20,6 +24,98 @@ function isAccountDead(amount: number, autoLiqThreshold: number): boolean {
|
|||||||
return autoLiqThreshold > 0 && amount <= autoLiqThreshold;
|
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];
|
const pointValue = POINT_VALUES[resolvedSymbol];
|
||||||
if (!pointValue) throw new Error(`Unknown symbol: ${resolvedSymbol}`);
|
if (!pointValue) throw new Error(`Unknown symbol: ${resolvedSymbol}`);
|
||||||
|
|
||||||
|
const tradeDate = currentTradeDateCT();
|
||||||
const maxConcurrent = parseInt(getSetting('max_concurrent_accounts') ?? '5', 10);
|
const maxConcurrent = parseInt(getSetting('max_concurrent_accounts') ?? '5', 10);
|
||||||
const firms = getTradableFirms();
|
const firms = getTradableFirms();
|
||||||
const clients = getClients();
|
const clients = getClients();
|
||||||
@@ -127,6 +224,8 @@ export async function runTrade(action: 'Buy' | 'Sell' | 'Auto', symbol: string)
|
|||||||
cash: { amount: number; realizedPnL: number };
|
cash: { amount: number; realizedPnL: number };
|
||||||
dailyPnL: { date: string; pnl: number }[];
|
dailyPnL: { date: string; pnl: number }[];
|
||||||
daysTraded: number;
|
daysTraded: number;
|
||||||
|
plan: DayPlan;
|
||||||
|
isExtraDay: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const allEligible: EligibleItem[] = [];
|
const allEligible: EligibleItem[] = [];
|
||||||
@@ -146,42 +245,14 @@ export async function runTrade(action: 'Buy' | 'Sell' | 'Auto', symbol: string)
|
|||||||
if (!contract) return;
|
if (!contract) return;
|
||||||
|
|
||||||
for (const acc of client.accountList) {
|
for (const acc of client.accountList) {
|
||||||
const cash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 };
|
const ev = evaluateAccountForTrade(client, acc, tradeDate);
|
||||||
const autoLiqThreshold = client.autoLiqThresholds[acc.id] ?? 0;
|
if (!ev || !ev.eligible || !ev.plan) continue;
|
||||||
const dailyPnL: { date: string; pnl: number }[] = client.dailyPnL[acc.id] ?? [];
|
|
||||||
const daysTraded: number = client.daysTraded[acc.id] ?? 0;
|
|
||||||
|
|
||||||
if (isAccountDead(cash.amount, autoLiqThreshold)) continue;
|
allEligible.push({
|
||||||
if (!acc.active) continue;
|
firmName: firm.name, client, acc, contract,
|
||||||
if (client.positions[acc.id]) continue;
|
cash: ev.cash, dailyPnL: ev.dailyPnL, daysTraded: ev.daysTraded,
|
||||||
|
plan: ev.plan, isExtraDay: ev.isExtraDay,
|
||||||
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 });
|
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -197,22 +268,12 @@ export async function runTrade(action: 'Buy' | 'Sell' | 'Auto', symbol: string)
|
|||||||
|
|
||||||
// ── 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) => {
|
||||||
const { client, acc, contract, cash, dailyPnL, daysTraded } = item;
|
const { client, acc, contract } = item;
|
||||||
const cfg = getAccountConfig(acc.id).accountType!;
|
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.
|
// 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.
|
// Just trade 1 MNQ in and out at market immediately — P&L doesn't matter.
|
||||||
const isExtraDay = cfg.minDayPnL <= 0
|
if (item.isExtraDay) {
|
||||||
&& effective.minTradingDays > daysTraded
|
|
||||||
&& totalProfit >= effective.profitTarget;
|
|
||||||
|
|
||||||
if (isExtraDay) {
|
|
||||||
const mnqContract = await client.findFrontMonthContract('MNQ');
|
const mnqContract = await client.findFrontMonthContract('MNQ');
|
||||||
if (!mnqContract) throw new Error('MNQ contract not found for extra-day trade');
|
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;
|
// Trade one leg toward today's remaining target. Split-allowed big days carve a
|
||||||
const target = computeDailyTarget(effective.profitTarget, effective.consistency, dailyPnL, cfg.minDayPnL, effective.minTradingDays, equityProfit);
|
// random ≥$800 piece; everything else trades the full remaining amount in one go.
|
||||||
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 contracts = cfg.maxPositionSize > 0 ? Math.min(rawContracts, cfg.maxPositionSize) : rawContracts;
|
||||||
const fill = await client.sendOrder(acc.id, contract.name, contracts, resolvedAction, 'Market');
|
const fill = await client.sendOrder(acc.id, contract.name, contracts, resolvedAction, 'Market');
|
||||||
|
|
||||||
// Wait briefly for the cash balance WebSocket update to reflect entry commission
|
// Wait briefly for the cash balance WebSocket update to reflect entry commission
|
||||||
await new Promise(r => setTimeout(r, 1000));
|
await new Promise(r => setTimeout(r, 1000));
|
||||||
const updatedCash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 };
|
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).
|
// 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 totalCommission = entryCommission * 2; // entry + exit round-trip
|
||||||
const grossTarget = target.amount + totalCommission;
|
const grossTarget = legAmount + totalCommission;
|
||||||
|
|
||||||
const targetPoints = grossTarget / (pointValue * contracts);
|
const targetPoints = grossTarget / (pointValue * contracts);
|
||||||
const ticks = Math.ceil(targetPoints / contract.tickSize);
|
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 exitAction: 'Buy' | 'Sell' = resolvedAction === 'Buy' ? 'Sell' : 'Buy';
|
||||||
const exitOrder = await client.placeOrderNoWait(acc.id, contract.name, contracts, exitAction, 'Limit', exitPrice);
|
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 {
|
return {
|
||||||
account: acc.name,
|
account: acc.name,
|
||||||
firm: item.firmName,
|
firm: item.firmName,
|
||||||
status: 'filled',
|
status: 'filled',
|
||||||
contracts,
|
contracts,
|
||||||
target: target.amount,
|
target: legAmount,
|
||||||
|
dayTarget: item.plan.dayTarget,
|
||||||
|
remainingBefore: item.plan.remainingToday,
|
||||||
grossTarget,
|
grossTarget,
|
||||||
totalCommission,
|
totalCommission,
|
||||||
targetPath: target.path,
|
targetPath: item.plan.path,
|
||||||
entryPrice: fill.price,
|
entryPrice: fill.price,
|
||||||
exitPrice,
|
exitPrice,
|
||||||
commission: entryCommission,
|
commission: entryCommission,
|
||||||
@@ -377,15 +442,17 @@ export async function copyTrade() {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tradeDate = currentTradeDateCT();
|
||||||
|
|
||||||
// Collect eligible accounts (same logic as Phase 1 of runTrade)
|
// Collect eligible accounts (same logic as Phase 1 of runTrade)
|
||||||
type CopyItem = {
|
type CopyItem = {
|
||||||
firmName: string;
|
firmName: string;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
client: any;
|
client: any;
|
||||||
acc: { id: number; name: string; active: boolean };
|
acc: { id: number; name: string; active: boolean };
|
||||||
contract: { name: string; tickSize: number };
|
contract: { name: string; tickSize: number };
|
||||||
cash: { amount: number; realizedPnL: number };
|
cash: { amount: number; realizedPnL: number };
|
||||||
dailyPnL: { date: string; pnl: number }[];
|
plan: DayPlan;
|
||||||
daysTraded: number;
|
|
||||||
};
|
};
|
||||||
const eligible: CopyItem[] = [];
|
const eligible: CopyItem[] = [];
|
||||||
|
|
||||||
@@ -407,31 +474,13 @@ export async function copyTrade() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const acc of client.accountList) {
|
for (const acc of client.accountList) {
|
||||||
if (client.positions[acc.id]) { console.log(`[copy-trade] ${acc.name}: already in trade — skip`); continue; }
|
const ev = evaluateAccountForTrade(client, acc, tradeDate);
|
||||||
const cash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 };
|
if (!ev) { console.log(`[copy-trade] ${acc.name}: no matching config prefix — skip`); continue; }
|
||||||
const autoLiqThreshold = client.autoLiqThresholds[acc.id] ?? 0;
|
if (!ev.eligible || !ev.plan) { console.log(`[copy-trade] ${acc.name}: ${ev.reason} — skip`); continue; }
|
||||||
if (isAccountDead(cash.amount, autoLiqThreshold)) { console.log(`[copy-trade] ${acc.name}: dead — skip`); continue; }
|
// copyTrade has no extra-day branch — those accounts wait for the scheduler
|
||||||
if (!acc.active) { console.log(`[copy-trade] ${acc.name}: inactive (DLL) — skip`); continue; }
|
if (ev.isExtraDay) { console.log(`[copy-trade] ${acc.name}: extra-day account — 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 dailyPnL: { date: string; pnl: number }[] = client.dailyPnL[acc.id] ?? [];
|
eligible.push({ firmName: firm.name, client, acc, contract, cash: ev.cash, plan: ev.plan });
|
||||||
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 });
|
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -445,28 +494,21 @@ export async function copyTrade() {
|
|||||||
|
|
||||||
// Fire orders (same as Phase 2 of runTrade)
|
// Fire orders (same as Phase 2 of runTrade)
|
||||||
const tradeResults = await Promise.allSettled(batch.map(async (item) => {
|
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 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 legAmount = item.plan.splitAllowed ? pickLegAmount(item.plan.remainingToday) : item.plan.remainingToday;
|
||||||
const target = computeDailyTarget(effective.profitTarget, effective.consistency, dailyPnL, cfg.minDayPnL, effective.minTradingDays, equityProfit);
|
recordLeg(acc.id, tradeDate);
|
||||||
if (!target) throw new Error(`${acc.name}: invalid balance, cannot compute target`);
|
|
||||||
|
|
||||||
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 contracts = cfg.maxPositionSize > 0 ? Math.min(rawContracts, cfg.maxPositionSize) : rawContracts;
|
||||||
const fill = await client.sendOrder(acc.id, contract.name, contracts, resolvedAction!, 'Market');
|
const fill = await client.sendOrder(acc.id, contract.name, contracts, resolvedAction!, 'Market');
|
||||||
|
|
||||||
await new Promise(r => setTimeout(r, 1000));
|
await new Promise(r => setTimeout(r, 1000));
|
||||||
const updatedCash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 };
|
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 totalCommission = entryCommission * 2;
|
||||||
const grossTarget = target.amount + totalCommission;
|
const grossTarget = legAmount + totalCommission;
|
||||||
|
|
||||||
const targetPoints = grossTarget / (pointValue * contracts);
|
const targetPoints = grossTarget / (pointValue * contracts);
|
||||||
const ticks = Math.ceil(targetPoints / contract.tickSize);
|
const ticks = Math.ceil(targetPoints / contract.tickSize);
|
||||||
@@ -477,17 +519,19 @@ export async function copyTrade() {
|
|||||||
const exitAction: 'Buy' | 'Sell' = resolvedAction === 'Buy' ? 'Sell' : 'Buy';
|
const exitAction: 'Buy' | 'Sell' = resolvedAction === 'Buy' ? 'Sell' : 'Buy';
|
||||||
const exitOrder = await client.placeOrderNoWait(acc.id, contract.name, contracts, exitAction, 'Limit', exitPrice);
|
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 {
|
return {
|
||||||
account: acc.name,
|
account: acc.name,
|
||||||
firm: item.firmName,
|
firm: item.firmName,
|
||||||
status: 'filled',
|
status: 'filled',
|
||||||
contracts,
|
contracts,
|
||||||
target: target.amount,
|
target: legAmount,
|
||||||
|
dayTarget: item.plan.dayTarget,
|
||||||
|
remainingBefore: item.plan.remainingToday,
|
||||||
grossTarget,
|
grossTarget,
|
||||||
totalCommission,
|
totalCommission,
|
||||||
targetPath: target.path,
|
targetPath: item.plan.path,
|
||||||
entryPrice: fill.price,
|
entryPrice: fill.price,
|
||||||
exitPrice,
|
exitPrice,
|
||||||
commission: entryCommission,
|
commission: entryCommission,
|
||||||
@@ -508,16 +552,16 @@ export async function copyTrade() {
|
|||||||
|
|
||||||
// ── eligibility check ────────────────────────────────────────────────────────
|
// ── 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 {
|
function hasRemainingConfiguredAccounts(): boolean {
|
||||||
const firms = getTradableFirms();
|
const firms = getTradableFirms();
|
||||||
const clients = getClients();
|
const clients = getClients();
|
||||||
|
const tradeDate = currentTradeDateCT();
|
||||||
|
|
||||||
for (const firm of firms) {
|
for (const firm of firms) {
|
||||||
const client = clients.get(firm.id);
|
const client = clients.get(firm.id);
|
||||||
if (!client || client.accountList.length === 0) continue;
|
if (!client || client.accountList.length === 0) continue;
|
||||||
|
|
||||||
|
|
||||||
for (const acc of client.accountList) {
|
for (const acc of client.accountList) {
|
||||||
const cfg = getAccountConfig(acc.id).accountType;
|
const cfg = getAccountConfig(acc.id).accountType;
|
||||||
if (!cfg) continue; // no config = not our account
|
if (!cfg) continue; // no config = not our account
|
||||||
@@ -525,31 +569,8 @@ function hasRemainingConfiguredAccounts(): boolean {
|
|||||||
// Account with open position = still in play
|
// Account with open position = still in play
|
||||||
if (client.positions[acc.id]) return true;
|
if (client.positions[acc.id]) return true;
|
||||||
|
|
||||||
const cash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 };
|
const ev = evaluateAccountForTrade(client, acc, tradeDate);
|
||||||
const autoLiqThreshold = client.autoLiqThresholds[acc.id] ?? 0;
|
if (ev?.eligible) return true;
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+8
-5
@@ -17,7 +17,7 @@ import { getClients } from './clients';
|
|||||||
import { getTradableFirms } from './firm-catalog';
|
import { getTradableFirms } from './firm-catalog';
|
||||||
import { getAccountConfig } from './account-type-resolver';
|
import { getAccountConfig } from './account-type-resolver';
|
||||||
import { getSchedulerStatus } from './auto-trade';
|
import { getSchedulerStatus } from './auto-trade';
|
||||||
import { computeDailyTarget } from './trading-logic';
|
import { computeDayPlan, currentTradeDateCT, LEG_COMPLETION_EPSILON } from './trading-logic';
|
||||||
import type { AccountTypeStats } from './master-protocol';
|
import type { AccountTypeStats } from './master-protocol';
|
||||||
|
|
||||||
function collectFirmStats() {
|
function collectFirmStats() {
|
||||||
@@ -83,10 +83,13 @@ function collectFirmStats() {
|
|||||||
if (cfg) {
|
if (cfg) {
|
||||||
const dailyPnL = client.dailyPnL[acc.id] ?? [];
|
const dailyPnL = client.dailyPnL[acc.id] ?? [];
|
||||||
const equityProfit = cash.amount - cfg.accountSize;
|
const equityProfit = cash.amount - cfg.accountSize;
|
||||||
const target = computeDailyTarget(cfg.profitTarget, cfg.consistency, dailyPnL, cfg.minDayPnL, cfg.minTradingDays, equityProfit);
|
const plan = computeDayPlan(
|
||||||
const targetHit = !target ? false :
|
acc.id, currentTradeDateCT(), cfg.profitTarget, cfg.consistency,
|
||||||
((target.amount === 0 && Math.abs(cash.realizedPnL) > 0 && (client.daysTraded[acc.id] ?? 0) <= cfg.minTradingDays) ||
|
dailyPnL, cfg.minDayPnL, cfg.minTradingDays, equityProfit, cash.realizedPnL
|
||||||
(cash.realizedPnL >= target.amount));
|
);
|
||||||
|
const targetHit = !plan ? false :
|
||||||
|
((plan.dayTarget === 0 && Math.abs(cash.realizedPnL) > 0 && (client.daysTraded[acc.id] ?? 0) <= cfg.minTradingDays) ||
|
||||||
|
(cash.realizedPnL > 0 && plan.remainingToday <= LEG_COMPLETION_EPSILON));
|
||||||
|
|
||||||
if (targetHit || cash.realizedPnL !== 0) {
|
if (targetHit || cash.realizedPnL !== 0) {
|
||||||
accountsTraded++;
|
accountsTraded++;
|
||||||
|
|||||||
+129
-4
@@ -48,6 +48,63 @@ export function resolveEffectiveProfitTarget(
|
|||||||
return resolveEffectiveConfig(profitTarget, 0, 0, targetSameEquity, withdrawalStages, priorProfit, fundTransactions).profitTarget;
|
return resolveEffectiveConfig(profitTarget, 0, 0, targetSameEquity, withdrawalStages, priorProfit, fundTransactions).profitTarget;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── randomized targets / split legs ──────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Split a day's target into multiple legs when it exceeds this. */
|
||||||
|
export const BIG_DAY_THRESHOLD = 1700;
|
||||||
|
/** Every split leg must be at least this. */
|
||||||
|
export const MIN_LEG = 800;
|
||||||
|
/** After at least one trade, a residual at or below this counts as "day done". */
|
||||||
|
export const LEG_COMPLETION_EPSILON = 25;
|
||||||
|
/** Safety valve: never fire more than this many legs per account per trade date. */
|
||||||
|
export const MAX_LEGS_PER_DAY = 8;
|
||||||
|
|
||||||
|
const round2 = (n: number) => Math.round(n * 100) / 100;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deterministic random bump in {0, 5, 10, 15, 20, 25} for an (account, trade date) pair.
|
||||||
|
* Seeded (FNV-1a) rather than Math.random() so the dashboard, reporter, and trade path —
|
||||||
|
* which each compute the target independently — always agree on the same number.
|
||||||
|
*/
|
||||||
|
export function dailyBump(accountId: number, tradeDate: string): number {
|
||||||
|
const key = `${accountId}|${tradeDate}`;
|
||||||
|
let hash = 0x811c9dc5;
|
||||||
|
for (let i = 0; i < key.length; i++) {
|
||||||
|
hash ^= key.charCodeAt(i);
|
||||||
|
hash = Math.imul(hash, 0x01000193) >>> 0;
|
||||||
|
}
|
||||||
|
return (hash % 6) * 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current Tradovate trade date as YYYY-MM-DD: the Chicago calendar date, rolled to the
|
||||||
|
* next day once the evening session opens (≥ 5:00 PM CT) — matching when Tradovate
|
||||||
|
* resets realizedPnL.
|
||||||
|
*/
|
||||||
|
export function currentTradeDateCT(now: Date = new Date()): string {
|
||||||
|
const parts = new Intl.DateTimeFormat('en-US', {
|
||||||
|
timeZone: 'America/Chicago',
|
||||||
|
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', hour12: false,
|
||||||
|
}).formatToParts(now);
|
||||||
|
const get = (t: string) => parts.find((p) => p.type === t)?.value ?? '0';
|
||||||
|
let date = Date.UTC(Number(get('year')), Number(get('month')) - 1, Number(get('day')));
|
||||||
|
if (Number(get('hour')) % 24 >= 17) date += 24 * 60 * 60 * 1000;
|
||||||
|
return new Date(date).toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick the next leg to trade toward today's remaining target.
|
||||||
|
* remaining ≤ BIG_DAY_THRESHOLD → trade it all in one final leg; otherwise carve a random
|
||||||
|
* $5-rounded piece from [MIN_LEG, remaining − MIN_LEG] so the remainder also stays ≥ MIN_LEG.
|
||||||
|
*/
|
||||||
|
export function pickLegAmount(remainingToday: number, rand: () => number = Math.random): number {
|
||||||
|
if (remainingToday <= BIG_DAY_THRESHOLD) return round2(remainingToday);
|
||||||
|
const lo = MIN_LEG;
|
||||||
|
const hi = remainingToday - MIN_LEG;
|
||||||
|
const leg = Math.round((lo + rand() * (hi - lo)) / 5) * 5;
|
||||||
|
return Math.min(Math.max(leg, lo), hi);
|
||||||
|
}
|
||||||
|
|
||||||
/** Dollar-per-point value for common futures products. */
|
/** Dollar-per-point value for common futures products. */
|
||||||
export const POINT_VALUES: { [symbol: string]: number } = {
|
export const POINT_VALUES: { [symbol: string]: number } = {
|
||||||
NQ: 20, MNQ: 2, ES: 50, MES: 5,
|
NQ: 20, MNQ: 2, ES: 50, MES: 5,
|
||||||
@@ -83,7 +140,8 @@ export function computeDailyTarget(
|
|||||||
dailyPnL: { date: string; pnl: number }[],
|
dailyPnL: { date: string; pnl: number }[],
|
||||||
minDayPnL: number, // 0 = no minimum per day
|
minDayPnL: number, // 0 = no minimum per day
|
||||||
minTradingDays: number, // 0 = no minimum trading days
|
minTradingDays: number, // 0 = no minimum trading days
|
||||||
equityProfit: number | null | undefined // amount − accountSize. Null/undefined → skip (return null). 0 is valid.
|
equityProfit: number | null | undefined, // amount − accountSize. Null/undefined → skip (return null). 0 is valid.
|
||||||
|
bump: number = 0 // random extra target. No consistency rule: applied when the min-day floor binds. Strict consistency: first trade ever only.
|
||||||
): { amount: number; path: 'first_day' | 'normal_day' | 'reduced_day' } | null {
|
): { amount: number; path: 'first_day' | 'normal_day' | 'reduced_day' } | null {
|
||||||
// Don't run if we don't have a real account balance to compute against — 0 is valid.
|
// Don't run if we don't have a real account balance to compute against — 0 is valid.
|
||||||
if (typeof equityProfit !== 'number' || !Number.isFinite(equityProfit)) {
|
if (typeof equityProfit !== 'number' || !Number.isFinite(equityProfit)) {
|
||||||
@@ -109,10 +167,14 @@ export function computeDailyTarget(
|
|||||||
|
|
||||||
const remaining = Math.max(equityShortfall, consistencyShortfall);
|
const remaining = Math.max(equityShortfall, consistencyShortfall);
|
||||||
|
|
||||||
|
const noConsistencyRule = consistency === 0 || consistency >= 1;
|
||||||
|
|
||||||
// 1. Both target and consistency already satisfied — coast on min-day if mandatory days remain.
|
// 1. Both target and consistency already satisfied — coast on min-day if mandatory days remain.
|
||||||
if (remaining <= 0) {
|
if (remaining <= 0) {
|
||||||
const needsMoreDays = minTradingDays > daysTraded;
|
const needsMoreDays = minTradingDays > daysTraded;
|
||||||
return { amount: needsMoreDays ? effectiveMinDay : 0, path: 'reduced_day' };
|
let coastAmount = needsMoreDays ? effectiveMinDay : 0;
|
||||||
|
if (coastAmount > 0 && noConsistencyRule) coastAmount += bump;
|
||||||
|
return { amount: coastAmount, path: 'reduced_day' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Min-day reservation: future mandatory days each reserve minDayPnL.
|
// 2. Min-day reservation: future mandatory days each reserve minDayPnL.
|
||||||
@@ -137,7 +199,7 @@ export function computeDailyTarget(
|
|||||||
let consistencyCap: number;
|
let consistencyCap: number;
|
||||||
let path: 'first_day' | 'normal_day' | 'reduced_day';
|
let path: 'first_day' | 'normal_day' | 'reduced_day';
|
||||||
|
|
||||||
if (consistency === 0 || consistency >= 1) {
|
if (noConsistencyRule) {
|
||||||
consistencyCap = Infinity;
|
consistencyCap = Infinity;
|
||||||
path = daysTraded === 0 ? 'first_day' : 'reduced_day';
|
path = daysTraded === 0 ? 'first_day' : 'reduced_day';
|
||||||
} else {
|
} else {
|
||||||
@@ -157,7 +219,70 @@ export function computeDailyTarget(
|
|||||||
// 4. Combine caps and apply min-day floor when mandatory days remain.
|
// 4. Combine caps and apply min-day floor when mandatory days remain.
|
||||||
const raw = Math.min(consistencyCap, cappedByFuture);
|
const raw = Math.min(consistencyCap, cappedByFuture);
|
||||||
const mustFloor = minTradingDays > daysTraded;
|
const mustFloor = minTradingDays > daysTraded;
|
||||||
const amount = mustFloor ? Math.max(effectiveMinDay, raw) : Math.max(0, raw);
|
let amount = mustFloor ? Math.max(effectiveMinDay, raw) : Math.max(0, raw);
|
||||||
|
|
||||||
|
// 5. Random bump. Without a consistency rule, bump the days where the min-day floor is
|
||||||
|
// what set the target (big days get their randomness from leg splitting instead).
|
||||||
|
// With a strict consistency rule, only the first trade ever gets a bump — a slight
|
||||||
|
// overshoot there just raises consistencyShortfall, which later days self-correct.
|
||||||
|
if (bump > 0) {
|
||||||
|
if (noConsistencyRule) {
|
||||||
|
if (mustFloor && raw <= effectiveMinDay && effectiveMinDay > 0) amount = effectiveMinDay + bump;
|
||||||
|
} else if (daysTraded === 0) {
|
||||||
|
amount += bump;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return { amount: Math.round(amount * 100) / 100, path };
|
return { amount: Math.round(amount * 100) / 100, path };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DayPlan {
|
||||||
|
/** Full target for this trade date (bump included) — a fixed anchor all day. */
|
||||||
|
dayTarget: number;
|
||||||
|
/** dayTarget minus what's already realized today; what's left to trade. */
|
||||||
|
remainingToday: number;
|
||||||
|
path: 'first_day' | 'normal_day' | 'reduced_day';
|
||||||
|
/** False for strict-consistency accounts past their first trade ever — single unsplit trade. */
|
||||||
|
splitAllowed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute today's full target and what's still left of it, as of right now.
|
||||||
|
*
|
||||||
|
* Unlike calling computeDailyTarget directly, this anchors the day: today's PnL row (which
|
||||||
|
* the hourly Cash History refresh may write mid-day) is excluded, and day-start equity is
|
||||||
|
* reconstructed as equityProfit − realizedPnL. So the same (account, trade date) always
|
||||||
|
* yields the same dayTarget no matter when or where it's computed — the dashboard, the
|
||||||
|
* reporter, and the trade path agree, and completed legs only move remainingToday.
|
||||||
|
*/
|
||||||
|
export function computeDayPlan(
|
||||||
|
accountId: number,
|
||||||
|
tradeDate: string,
|
||||||
|
profitTarget: number,
|
||||||
|
consistency: number,
|
||||||
|
dailyPnL: { date: string; pnl: number }[],
|
||||||
|
minDayPnL: number,
|
||||||
|
minTradingDays: number,
|
||||||
|
equityProfit: number | null | undefined,
|
||||||
|
realizedPnL: number
|
||||||
|
): DayPlan | null {
|
||||||
|
if (typeof equityProfit !== 'number' || !Number.isFinite(equityProfit)) return null;
|
||||||
|
|
||||||
|
const priorDays = dailyPnL.filter((d) => d.date !== tradeDate);
|
||||||
|
const dayStartEquity = equityProfit - realizedPnL;
|
||||||
|
const target = computeDailyTarget(
|
||||||
|
profitTarget, consistency, priorDays, minDayPnL, minTradingDays,
|
||||||
|
dayStartEquity, dailyBump(accountId, tradeDate)
|
||||||
|
);
|
||||||
|
if (!target) return null;
|
||||||
|
|
||||||
|
const qualifyingDays = minDayPnL === 0 ? priorDays : priorDays.filter((d) => d.pnl >= minDayPnL);
|
||||||
|
const splitAllowed = consistency === 0 || consistency >= 1 || qualifyingDays.length === 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
dayTarget: target.amount,
|
||||||
|
remainingToday: round2(target.amount - realizedPnL),
|
||||||
|
path: target.path,
|
||||||
|
splitAllowed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user