Add per-stage withdrawal targets with consistency and min trading days
- Add withdrawal stage system: each stage defines profit target, consistency, and min trading days for post-withdrawal challenge cycles - Target Same Equity mode accounts for withdrawn amounts when computing effective profit target (profitTarget - remainingProfit) - Store fund transaction timestamps for time-aware cycle filtering (withdrawals before 9 AM CT include that day in new cycle) - Expose full P&L history (fullDailyPnL) for calendar/equity curve display across all cycles, with DB fallback for pre-restart data - Show stage number (#1, #2, etc.) on calendar cells - Hide consistency reference line when consistency is 0% or 100% - Settings UI: "After First W/D" column with same-equity checkbox, expandable stage sub-rows with profit/consistency/days inputs - Default target_same_equity to 1 for new and existing account configs Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
c54073e4b8
commit
70b1362d3e
+36
-16
@@ -86,7 +86,7 @@ const MONTH_NAMES = [
|
|||||||
];
|
];
|
||||||
const DOW_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
const DOW_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||||
|
|
||||||
function CalendarMonth({ year, month, pnlMap, fundMap }: { year: number; month: number; pnlMap: Map<string, number>; fundMap: Map<string, number> }) {
|
function CalendarMonth({ year, month, pnlMap, fundMap, stageMap }: { year: number; month: number; pnlMap: Map<string, number>; fundMap: Map<string, number>; stageMap: Map<string, number> }) {
|
||||||
const daysInMonth = new Date(year, month, 0).getDate();
|
const daysInMonth = new Date(year, month, 0).getDate();
|
||||||
const firstDow = new Date(year, month - 1, 1).getDay(); // 0 = Sunday
|
const firstDow = new Date(year, month - 1, 1).getDay(); // 0 = Sunday
|
||||||
|
|
||||||
@@ -133,12 +133,13 @@ function CalendarMonth({ year, month, pnlMap, fundMap }: { year: number; month:
|
|||||||
const key = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
const key = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||||
const pnl = pnlMap.get(key);
|
const pnl = pnlMap.get(key);
|
||||||
const fundAmt = fundMap.get(key);
|
const fundAmt = fundMap.get(key);
|
||||||
|
const stage = stageMap.get(key);
|
||||||
const hasData = pnl !== undefined;
|
const hasData = pnl !== undefined;
|
||||||
const positive = hasData && pnl! >= 0;
|
const positive = hasData && pnl! >= 0;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={key}
|
key={key}
|
||||||
className={`rounded-lg px-1.5 pt-1.5 pb-2 min-h-14 flex flex-col ${
|
className={`rounded-lg px-1.5 pt-1.5 pb-2 min-h-14 flex flex-col relative ${
|
||||||
hasData
|
hasData
|
||||||
? positive
|
? positive
|
||||||
? 'bg-green-50 border border-green-100'
|
? 'bg-green-50 border border-green-100'
|
||||||
@@ -148,11 +149,18 @@ function CalendarMonth({ year, month, pnlMap, fundMap }: { year: number; month:
|
|||||||
: 'bg-slate-50 border border-transparent'
|
: 'bg-slate-50 border border-transparent'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className={`text-[11px] font-medium leading-none mb-1.5 ${
|
<div className="flex items-start justify-between">
|
||||||
|
<span className={`text-[11px] font-medium leading-none ${
|
||||||
hasData ? (positive ? 'text-green-700' : 'text-red-600') : fundAmt !== undefined ? 'text-amber-700' : 'text-slate-400'
|
hasData ? (positive ? 'text-green-700' : 'text-red-600') : fundAmt !== undefined ? 'text-amber-700' : 'text-slate-400'
|
||||||
}`}>
|
}`}>
|
||||||
{day}
|
{day}
|
||||||
</span>
|
</span>
|
||||||
|
{stage !== undefined && (
|
||||||
|
<span className="text-[8px] font-semibold text-slate-400 leading-none">
|
||||||
|
#{stage}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{hasData && (
|
{hasData && (
|
||||||
<span className={`text-[11px] font-bold tabular-nums leading-tight ${
|
<span className={`text-[11px] font-bold tabular-nums leading-tight ${
|
||||||
positive ? 'text-green-700' : 'text-red-600'
|
positive ? 'text-green-700' : 'text-red-600'
|
||||||
@@ -220,8 +228,9 @@ export default function AccountPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// dailyPnL comes from state — same source as dailyTarget, no separate fetch needed.
|
// dailyPnL = current cycle (for targets); fullDailyPnL = all history (for display)
|
||||||
const dailyPnL = account.dailyPnL;
|
const dailyPnL = account.dailyPnL;
|
||||||
|
const displayPnL = account.fullDailyPnL ?? account.dailyPnL;
|
||||||
const fundTransactions = account.fundTransactions ?? [];
|
const fundTransactions = account.fundTransactions ?? [];
|
||||||
const fundMap = new Map(fundTransactions.map((f) => [f.date, f.amount]));
|
const fundMap = new Map(fundTransactions.map((f) => [f.date, f.amount]));
|
||||||
|
|
||||||
@@ -245,17 +254,17 @@ export default function AccountPage() {
|
|||||||
|
|
||||||
// Consistency target: the total profit level at which the best day no longer
|
// Consistency target: the total profit level at which the best day no longer
|
||||||
// violates the consistency ratio. Only meaningful once a positive day exists.
|
// violates the consistency ratio. Only meaningful once a positive day exists.
|
||||||
const maxDayPnL = dailyPnL.length > 0 ? Math.max(...dailyPnL.filter(d => d.pnl > 0).map(d => d.pnl)) : 0;
|
const maxDayPnL = displayPnL.length > 0 ? Math.max(...displayPnL.filter(d => d.pnl > 0).map(d => d.pnl)) : 0;
|
||||||
const consistencyTarget = cfg && cfg.consistency > 0 && maxDayPnL > 0
|
const consistencyTarget = cfg && cfg.consistency > 0 && cfg.consistency < 1 && maxDayPnL > 0
|
||||||
? Math.round(maxDayPnL / cfg.consistency * 100) / 100
|
? Math.round(maxDayPnL / cfg.consistency * 100) / 100
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// Build equity curve: FIFO daily increments + withdrawal step-downs, origin at $0
|
// Build equity curve: FIFO daily increments + withdrawal step-downs, origin at $0
|
||||||
// Merge daily P&L and fund transactions into a single sorted timeline
|
// Merge daily P&L and fund transactions into a single sorted timeline
|
||||||
type EquityEvent = { date: string; pnl?: number; fundAmt?: number };
|
type EquityEvent = { date: string; pnl?: number; fundAmt?: number };
|
||||||
const allDates = new Set([...dailyPnL.map((d) => d.date), ...fundTransactions.map((f) => f.date)]);
|
const allDates = new Set([...displayPnL.map((d) => d.date), ...fundTransactions.map((f) => f.date)]);
|
||||||
const eventsByDate = new Map<string, EquityEvent>();
|
const eventsByDate = new Map<string, EquityEvent>();
|
||||||
for (const d of dailyPnL) eventsByDate.set(d.date, { date: d.date, pnl: d.pnl });
|
for (const d of displayPnL) eventsByDate.set(d.date, { date: d.date, pnl: d.pnl });
|
||||||
for (const f of fundTransactions) {
|
for (const f of fundTransactions) {
|
||||||
const existing = eventsByDate.get(f.date);
|
const existing = eventsByDate.get(f.date);
|
||||||
eventsByDate.set(f.date, { ...existing, date: f.date, fundAmt: f.amount });
|
eventsByDate.set(f.date, { ...existing, date: f.date, fundAmt: f.amount });
|
||||||
@@ -304,10 +313,21 @@ export default function AccountPage() {
|
|||||||
const strokeGradId = 'equityStroke';
|
const strokeGradId = 'equityStroke';
|
||||||
|
|
||||||
// Build calendar data
|
// Build calendar data
|
||||||
const pnlMap = new Map(dailyPnL.map((d) => [d.date, d.pnl]));
|
const pnlMap = new Map(displayPnL.map((d) => [d.date, d.pnl]));
|
||||||
const allCalendarDates = [...dailyPnL.map((d) => d.date), ...fundTransactions.map((f) => f.date)];
|
const allCalendarDates = [...displayPnL.map((d) => d.date), ...fundTransactions.map((f) => f.date)];
|
||||||
const calendarMonths = [...new Set(allCalendarDates.map((d) => d.slice(0, 7)))].sort();
|
const calendarMonths = [...new Set(allCalendarDates.map((d) => d.slice(0, 7)))].sort();
|
||||||
|
|
||||||
|
// Build stage map: stage increments after each withdrawal (negative fund txn)
|
||||||
|
const sortedWithdrawalDates = fundTransactions
|
||||||
|
.filter((f) => f.amount < 0)
|
||||||
|
.map((f) => f.date)
|
||||||
|
.sort();
|
||||||
|
const stageMap = new Map<string, number>();
|
||||||
|
for (const d of displayPnL) {
|
||||||
|
const stage = sortedWithdrawalDates.filter((wd) => wd < d.date).length + 1;
|
||||||
|
stageMap.set(d.date, stage);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-50 p-8">
|
<div className="min-h-screen bg-slate-50 p-8">
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
@@ -388,13 +408,13 @@ export default function AccountPage() {
|
|||||||
<div className="bg-white border border-slate-200 rounded-xl shadow-sm p-6 mb-5">
|
<div className="bg-white border border-slate-200 rounded-xl shadow-sm p-6 mb-5">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h2 className="text-slate-900 font-bold text-base">Equity Curve</h2>
|
<h2 className="text-slate-900 font-bold text-base">Equity Curve</h2>
|
||||||
{dailyPnL.length > 0 && (
|
{displayPnL.length > 0 && (
|
||||||
<span className={`text-sm font-bold tabular-nums ${isPositive ? 'text-green-600' : 'text-red-500'}`}>
|
<span className={`text-sm font-bold tabular-nums ${isPositive ? 'text-green-600' : 'text-red-500'}`}>
|
||||||
{isPositive ? '+' : ''}${fmt(totalProfit)}
|
{isPositive ? '+' : ''}${fmt(totalProfit)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{dailyPnL.length === 0 ? (
|
{displayPnL.length === 0 ? (
|
||||||
<p className="text-slate-400 italic text-sm text-center py-8">No trading history available</p>
|
<p className="text-slate-400 italic text-sm text-center py-8">No trading history available</p>
|
||||||
) : (
|
) : (
|
||||||
<ResponsiveContainer width="100%" height={260}>
|
<ResponsiveContainer width="100%" height={260}>
|
||||||
@@ -485,7 +505,7 @@ export default function AccountPage() {
|
|||||||
strokeDasharray="4 3"
|
strokeDasharray="4 3"
|
||||||
label={{
|
label={{
|
||||||
value: 'W/D',
|
value: 'W/D',
|
||||||
position: 'insideTopRight',
|
position: 'insideBottomRight',
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
fill: '#d97706',
|
fill: '#d97706',
|
||||||
@@ -552,14 +572,14 @@ export default function AccountPage() {
|
|||||||
<div className="grid grid-cols-1 gap-8" style={{ gridTemplateColumns: `repeat(${Math.min(calendarMonths.length, 3)}, minmax(0, 1fr))` }}>
|
<div className="grid grid-cols-1 gap-8" style={{ gridTemplateColumns: `repeat(${Math.min(calendarMonths.length, 3)}, minmax(0, 1fr))` }}>
|
||||||
{calendarMonths.map((ym) => {
|
{calendarMonths.map((ym) => {
|
||||||
const [y, m] = ym.split('-').map(Number);
|
const [y, m] = ym.split('-').map(Number);
|
||||||
return <CalendarMonth key={ym} year={y} month={m} pnlMap={pnlMap} fundMap={fundMap} />;
|
return <CalendarMonth key={ym} year={y} month={m} pnlMap={pnlMap} fundMap={fundMap} stageMap={stageMap} />;
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Cash History Table */}
|
{/* Cash History Table */}
|
||||||
{(dailyPnL.length > 0 || fundTransactions.length > 0) && (
|
{(displayPnL.length > 0 || fundTransactions.length > 0) && (
|
||||||
<div className="bg-white border border-slate-200 rounded-xl shadow-sm p-6 mt-5">
|
<div className="bg-white border border-slate-200 rounded-xl shadow-sm p-6 mt-5">
|
||||||
<h2 className="text-slate-900 font-bold text-base mb-4">Cash History</h2>
|
<h2 className="text-slate-900 font-bold text-base mb-4">Cash History</h2>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
@@ -573,7 +593,7 @@ export default function AccountPage() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{[
|
{[
|
||||||
...dailyPnL.map((d) => ({ date: d.date, type: 'Trade' as const, amount: d.pnl })),
|
...displayPnL.map((d) => ({ date: d.date, type: 'Trade' as const, amount: d.pnl })),
|
||||||
...fundTransactions.map((f) => ({ date: f.date, type: 'W/D' as const, amount: f.amount })),
|
...fundTransactions.map((f) => ({ date: f.date, type: 'W/D' as const, amount: f.amount })),
|
||||||
]
|
]
|
||||||
.sort((a, b) => b.date.localeCompare(a.date))
|
.sort((a, b) => b.date.localeCompare(a.date))
|
||||||
|
|||||||
@@ -21,9 +21,11 @@ export async function PUT(
|
|||||||
accountSize?: number;
|
accountSize?: number;
|
||||||
maxLoss?: number;
|
maxLoss?: number;
|
||||||
maxPositionSize?: number;
|
maxPositionSize?: number;
|
||||||
|
targetSameEquity?: boolean;
|
||||||
|
withdrawalStages?: { profit: number; consistency: number; minTradingDays: number }[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const { prefix, profitTarget, consistency, minDayPnL, minTradingDays, accountSize, maxLoss, maxPositionSize } = body;
|
const { prefix, profitTarget, consistency, minDayPnL, minTradingDays, accountSize, maxLoss, maxPositionSize, targetSameEquity, withdrawalStages } = body;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
typeof prefix !== 'string' || !prefix.trim() ||
|
typeof prefix !== 'string' || !prefix.trim() ||
|
||||||
@@ -46,6 +48,8 @@ export async function PUT(
|
|||||||
accountSize,
|
accountSize,
|
||||||
maxLoss: maxLoss ?? 0,
|
maxLoss: maxLoss ?? 0,
|
||||||
maxPositionSize: maxPositionSize ?? 0,
|
maxPositionSize: maxPositionSize ?? 0,
|
||||||
|
targetSameEquity: targetSameEquity ?? false,
|
||||||
|
withdrawalStages: withdrawalStages ?? [],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!updated) {
|
if (!updated) {
|
||||||
|
|||||||
@@ -25,9 +25,11 @@ export async function POST(
|
|||||||
accountSize?: number;
|
accountSize?: number;
|
||||||
maxLoss?: number;
|
maxLoss?: number;
|
||||||
maxPositionSize?: number;
|
maxPositionSize?: number;
|
||||||
|
targetSameEquity?: boolean;
|
||||||
|
withdrawalStages?: { profit: number; consistency: number; minTradingDays: number }[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const { prefix, profitTarget, consistency, minDayPnL, minTradingDays, accountSize, maxLoss, maxPositionSize } = body;
|
const { prefix, profitTarget, consistency, minDayPnL, minTradingDays, accountSize, maxLoss, maxPositionSize, targetSameEquity, withdrawalStages } = body;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
typeof prefix !== 'string' || !prefix.trim() ||
|
typeof prefix !== 'string' || !prefix.trim() ||
|
||||||
@@ -50,6 +52,8 @@ export async function POST(
|
|||||||
accountSize,
|
accountSize,
|
||||||
maxLoss: maxLoss ?? 0,
|
maxLoss: maxLoss ?? 0,
|
||||||
maxPositionSize: maxPositionSize ?? 0,
|
maxPositionSize: maxPositionSize ?? 0,
|
||||||
|
targetSameEquity: targetSameEquity ?? false,
|
||||||
|
withdrawalStages: withdrawalStages ?? [],
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
@@ -62,6 +66,8 @@ export async function POST(
|
|||||||
accountSize: row.account_size,
|
accountSize: row.account_size,
|
||||||
maxLoss: row.max_loss,
|
maxLoss: row.max_loss,
|
||||||
maxPositionSize: row.max_position_size,
|
maxPositionSize: row.max_position_size,
|
||||||
|
targetSameEquity: row.target_same_equity === 1,
|
||||||
|
withdrawalStages: (() => { try { return JSON.parse(row.withdrawal_stages ?? '[]') as { profit: number; consistency: number; minTradingDays: number }[]; } catch { return []; } })(),
|
||||||
}, { status: 201 });
|
}, { status: 201 });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[POST /api/firms/:id/accounts]', err);
|
console.error('[POST /api/firms/:id/accounts]', err);
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ export async function GET(
|
|||||||
accountSize: a.account_size,
|
accountSize: a.account_size,
|
||||||
maxLoss: a.max_loss,
|
maxLoss: a.max_loss,
|
||||||
maxPositionSize: a.max_position_size,
|
maxPositionSize: a.max_position_size,
|
||||||
|
targetSameEquity: a.target_same_equity === 1,
|
||||||
|
withdrawalStages: (() => { try { return JSON.parse(a.withdrawal_stages ?? '[]') as { profit: number; consistency: number; minTradingDays: number }[]; } catch { return []; } })(),
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-5
@@ -1,7 +1,7 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { getFirms } from '@/lib/db';
|
import { getFirms, loadDailyPnL } from '@/lib/db';
|
||||||
import { getClients } from '@/lib/clients';
|
import { getClients } from '@/lib/clients';
|
||||||
import { computeDailyTarget } from '@/lib/trading-logic';
|
import { computeDailyTarget, resolveEffectiveConfig } from '@/lib/trading-logic';
|
||||||
import type { AccountConfigRow } from '@/lib/db';
|
import type { AccountConfigRow } from '@/lib/db';
|
||||||
|
|
||||||
function getAccountConfig(name: string, accounts: AccountConfigRow[]): AccountConfigRow | undefined {
|
function getAccountConfig(name: string, accounts: AccountConfigRow[]): AccountConfigRow | undefined {
|
||||||
@@ -30,16 +30,32 @@ export async function GET() {
|
|||||||
const cfg = getAccountConfig(acc.name, f.accounts);
|
const cfg = getAccountConfig(acc.name, f.accounts);
|
||||||
const autoLiqThreshold = client.autoLiqThresholds[acc.id] ?? 0;
|
const autoLiqThreshold = client.autoLiqThresholds[acc.id] ?? 0;
|
||||||
const isDead = autoLiqThreshold > 0 && cash.amount <= autoLiqThreshold;
|
const isDead = autoLiqThreshold > 0 && cash.amount <= autoLiqThreshold;
|
||||||
|
const allFundTxns = client.fundTransactions?.[acc.id] ?? [];
|
||||||
|
const priorProfit = client.priorProfit?.[acc.id] ?? 0;
|
||||||
|
// Hide initial funding (amount === accountSize) from display
|
||||||
|
const displayFundTxns = cfg
|
||||||
|
? allFundTxns.filter((f) => f.amount !== cfg.account_size)
|
||||||
|
: allFundTxns;
|
||||||
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;
|
||||||
if (cfg && !isDead) {
|
if (cfg && !isDead) {
|
||||||
const target = computeDailyTarget(cfg.profit_target, cfg.consistency, totalProfit, dailyPnL, cfg.min_day_pnl, cfg.min_trading_days);
|
const withdrawalStages: { profit: number; consistency: number; minTradingDays: number }[] = (() => { try { return JSON.parse(cfg.withdrawal_stages ?? '[]'); } catch { return []; } })();
|
||||||
|
const effective = resolveEffectiveConfig(
|
||||||
|
cfg.profit_target,
|
||||||
|
cfg.consistency,
|
||||||
|
cfg.min_trading_days,
|
||||||
|
cfg.target_same_equity === 1,
|
||||||
|
withdrawalStages,
|
||||||
|
priorProfit,
|
||||||
|
allFundTxns
|
||||||
|
);
|
||||||
|
const target = computeDailyTarget(effective.profitTarget, effective.consistency, totalProfit, dailyPnL, cfg.min_day_pnl, effective.minTradingDays);
|
||||||
dailyTarget = target;
|
dailyTarget = target;
|
||||||
// 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 → must have made at least the computed daily target
|
||||||
targetHit =
|
targetHit =
|
||||||
// If we are just flipping take any activity as target hit
|
// If we are just flipping take any activity as target hit
|
||||||
(target.amount === 0 && Math.abs(cash.realizedPnL) > 0 && client.daysTraded[acc.id] <= cfg.min_trading_days) ||
|
(target.amount === 0 && Math.abs(cash.realizedPnL) > 0 && client.daysTraded[acc.id] <= effective.minTradingDays) ||
|
||||||
(cash.realizedPnL >= target.amount);
|
(cash.realizedPnL >= target.amount);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,7 +72,8 @@ export async function GET() {
|
|||||||
targetHit,
|
targetHit,
|
||||||
dailyTarget,
|
dailyTarget,
|
||||||
dailyPnL,
|
dailyPnL,
|
||||||
fundTransactions: client.fundTransactions[acc.id] ?? [],
|
fullDailyPnL: client.fullDailyPnL?.[acc.id] ?? loadDailyPnL(acc.id),
|
||||||
|
fundTransactions: displayFundTxns,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
return { firm: f.name, connected: true, accounts, perContractFees: client.perContractFees };
|
return { firm: f.name, connected: true, accounts, perContractFees: client.perContractFees };
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useRouter, useParams } from 'next/navigation';
|
import { useRouter, useParams } from 'next/navigation';
|
||||||
|
|
||||||
const SIZE_PRESETS = [5_000, 10_000, 25_000, 50_000, 75_000, 100_000, 150_000];
|
const SIZE_PRESETS = [5_000, 10_000, 25_000, 50_000, 75_000, 100_000, 150_000];
|
||||||
@@ -14,6 +14,8 @@ interface AccountConfig {
|
|||||||
minTradingDays: number;
|
minTradingDays: number;
|
||||||
accountSize: number;
|
accountSize: number;
|
||||||
maxPositionSize: number;
|
maxPositionSize: number;
|
||||||
|
targetSameEquity: boolean;
|
||||||
|
withdrawalStages: { profit: number; consistency: number; minTradingDays: number }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FirmConfig {
|
interface FirmConfig {
|
||||||
@@ -51,6 +53,8 @@ function newRow(): RowState {
|
|||||||
minTradingDays: 5,
|
minTradingDays: 5,
|
||||||
accountSize: 50_000,
|
accountSize: 50_000,
|
||||||
maxPositionSize: 0,
|
maxPositionSize: 0,
|
||||||
|
targetSameEquity: true,
|
||||||
|
withdrawalStages: [],
|
||||||
dirty: true,
|
dirty: true,
|
||||||
saving: false,
|
saving: false,
|
||||||
error: '',
|
error: '',
|
||||||
@@ -148,6 +152,8 @@ export default function FirmSettingsPage() {
|
|||||||
minTradingDays: row.minTradingDays,
|
minTradingDays: row.minTradingDays,
|
||||||
accountSize: row.accountSize,
|
accountSize: row.accountSize,
|
||||||
maxPositionSize: row.maxPositionSize,
|
maxPositionSize: row.maxPositionSize,
|
||||||
|
targetSameEquity: row.targetSameEquity,
|
||||||
|
withdrawalStages: row.withdrawalStages,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (row.id < 0) {
|
if (row.id < 0) {
|
||||||
@@ -257,20 +263,22 @@ export default function FirmSettingsPage() {
|
|||||||
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">Min Day P&L</th>
|
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">Min Day P&L</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">Min Trading Days</th>
|
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">Min Trading Days</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">Max Contracts</th>
|
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">Max Contracts</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">After First W/D</th>
|
||||||
<th className="px-3 py-3 w-32" />
|
<th className="px-3 py-3 w-32" />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{rows.length === 0 && firmName && (
|
{rows.length === 0 && firmName && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={8} className="px-4 py-6 text-center text-sm text-slate-400 italic">
|
<td colSpan={9} className="px-4 py-6 text-center text-sm text-slate-400 italic">
|
||||||
No account types yet
|
No account types yet
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{rows.map((row) => (
|
{rows.map((row) => (
|
||||||
<tr key={row.id} className="border-b border-slate-100 last:border-0">
|
<React.Fragment key={row.id}>
|
||||||
|
<tr className="border-b border-slate-100 last:border-0">
|
||||||
|
|
||||||
{/* Prefix */}
|
{/* Prefix */}
|
||||||
<td className="px-4 py-2.5">
|
<td className="px-4 py-2.5">
|
||||||
@@ -405,6 +413,21 @@ export default function FirmSettingsPage() {
|
|||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
|
{/* After Withdrawal */}
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
<label className="flex items-center gap-1.5 cursor-pointer select-none">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={row.targetSameEquity}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateRow(row.id, { targetSameEquity: e.target.checked })
|
||||||
|
}
|
||||||
|
className="rounded border-slate-300 text-blue-500 focus:ring-blue-400"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-slate-600">Same equity</span>
|
||||||
|
</label>
|
||||||
|
</td>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<td className="px-3 py-2.5">
|
<td className="px-3 py-2.5">
|
||||||
<div className="flex items-center justify-end gap-1.5">
|
<div className="flex items-center justify-end gap-1.5">
|
||||||
@@ -449,11 +472,114 @@ export default function FirmSettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
|
{/* Stage sub-rows — shown when targetSameEquity is OFF */}
|
||||||
|
{!row.targetSameEquity && (
|
||||||
|
<>
|
||||||
|
{row.withdrawalStages.map((stage, idx) => {
|
||||||
|
const isLast = idx === row.withdrawalStages.length - 1;
|
||||||
|
const showPlus = isLast && row.withdrawalStages.length > 1;
|
||||||
|
return (
|
||||||
|
<tr key={`${row.id}-stage-${idx}`} className="bg-slate-50/60 border-b border-slate-100 last:border-0">
|
||||||
|
<td colSpan={9} className="pl-10 pr-4 py-1.5">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xs text-slate-400 w-16 shrink-0">
|
||||||
|
Stage {idx + 2}{showPlus ? '+' : ''}
|
||||||
|
</span>
|
||||||
|
<div className="relative flex items-center">
|
||||||
|
<span className="absolute left-2 text-slate-400 text-xs pointer-events-none">$</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="border border-slate-200 rounded px-1.5 py-1 pl-4 text-xs text-slate-800 focus:outline-none focus:ring-1 focus:ring-blue-400 bg-white w-24"
|
||||||
|
value={stage.profit}
|
||||||
|
min={0}
|
||||||
|
step={100}
|
||||||
|
placeholder="Profit"
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = row.withdrawalStages.map((s, i) =>
|
||||||
|
i === idx ? { ...s, profit: Number(e.target.value) } : s
|
||||||
|
);
|
||||||
|
updateRow(row.id, { withdrawalStages: next });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{row.consistency > 0 && (
|
||||||
|
<div className="relative flex items-center">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="border border-slate-200 rounded px-1.5 py-1 pr-5 text-xs text-slate-800 focus:outline-none focus:ring-1 focus:ring-blue-400 bg-white w-20"
|
||||||
|
value={Math.round(stage.consistency * 100)}
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={1}
|
||||||
|
placeholder="Cons%"
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = row.withdrawalStages.map((s, i) =>
|
||||||
|
i === idx ? { ...s, consistency: Number(e.target.value) / 100 } : s
|
||||||
|
);
|
||||||
|
updateRow(row.id, { withdrawalStages: next });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="absolute right-1.5 text-slate-400 text-xs pointer-events-none">%</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="border border-slate-200 rounded px-1.5 py-1 text-xs text-slate-800 focus:outline-none focus:ring-1 focus:ring-blue-400 bg-white w-12"
|
||||||
|
value={stage.minTradingDays}
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
placeholder="Days"
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = row.withdrawalStages.map((s, i) =>
|
||||||
|
i === idx ? { ...s, minTradingDays: Number(e.target.value) } : s
|
||||||
|
);
|
||||||
|
updateRow(row.id, { withdrawalStages: next });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="text-slate-400 text-[10px] shrink-0">days</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const next = row.withdrawalStages.filter((_, i) => i !== idx);
|
||||||
|
updateRow(row.id, { withdrawalStages: next });
|
||||||
|
}}
|
||||||
|
className="text-slate-300 hover:text-red-400 text-sm leading-none"
|
||||||
|
title="Remove stage"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<tr className="bg-slate-50/60 border-b border-dashed border-slate-200">
|
||||||
|
<td colSpan={9} className="pl-10 pr-4 py-1">
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
updateRow(row.id, {
|
||||||
|
withdrawalStages: [
|
||||||
|
...row.withdrawalStages,
|
||||||
|
{ profit: row.profitTarget, consistency: row.consistency, minTradingDays: row.minTradingDays },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="text-xs text-blue-400 hover:text-blue-600"
|
||||||
|
>
|
||||||
|
+ Add stage
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Add row */}
|
{/* Add row */}
|
||||||
<tr className="border-t border-dashed border-slate-200">
|
<tr className="border-t border-dashed border-slate-200">
|
||||||
<td colSpan={8} className="px-2 py-2">
|
<td colSpan={9} className="px-2 py-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setRows((prev) => [...prev, newRow()])}
|
onClick={() => setRows((prev) => [...prev, newRow()])}
|
||||||
className="w-full py-1.5 text-sm text-slate-400 hover:text-slate-600 hover:bg-slate-50 rounded-lg transition-colors"
|
className="w-full py-1.5 text-sm text-slate-400 hover:text-slate-600 hover:bg-slate-50 rounded-lg transition-colors"
|
||||||
|
|||||||
+20
-7
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
import { getFirms, isSymbolBanned, getInstruments } from './db';
|
import { getFirms, isSymbolBanned, getInstruments } from './db';
|
||||||
import { getClients } from './clients';
|
import { getClients } from './clients';
|
||||||
import { computeDailyTarget, POINT_VALUES } from './trading-logic';
|
import { computeDailyTarget, resolveEffectiveConfig, POINT_VALUES } from './trading-logic';
|
||||||
import { getSetting } from './db';
|
import { getSetting } from './db';
|
||||||
import type { FirmConfig, AccountConfig } from '@/types';
|
import type { FirmConfig, AccountConfig } from '@/types';
|
||||||
import type { FirmWithAccounts } from './db';
|
import type { FirmWithAccounts } from './db';
|
||||||
@@ -42,6 +42,8 @@ function mapFirmConfig(firm: FirmWithAccounts): FirmConfig {
|
|||||||
accountSize: a.account_size,
|
accountSize: a.account_size,
|
||||||
maxLoss: a.max_loss,
|
maxLoss: a.max_loss,
|
||||||
maxPositionSize: a.max_position_size,
|
maxPositionSize: a.max_position_size,
|
||||||
|
targetSameEquity: a.target_same_equity === 1,
|
||||||
|
withdrawalStages: (() => { try { return JSON.parse(a.withdrawal_stages ?? '[]') as { profit: number; consistency: number; minTradingDays: number }[]; } catch { return []; } })(),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -176,13 +178,19 @@ export async function runTrade(action: 'Buy' | 'Sell' | 'Random', symbol: string
|
|||||||
// Only trade accounts that haven't traded yet today
|
// Only trade accounts that haven't traded yet today
|
||||||
if (cash.realizedPnL !== 0) continue;
|
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)
|
// Use the same target formula as the dashboard — skip if $0 (challenge complete)
|
||||||
const target = computeDailyTarget(cfg.profitTarget, cfg.consistency, totalProfit, dailyPnL, cfg.minDayPnL, cfg.minTradingDays);
|
const target = computeDailyTarget(effective.profitTarget, effective.consistency, totalProfit, dailyPnL, cfg.minDayPnL, effective.minTradingDays);
|
||||||
|
|
||||||
// Allow through if it's an MNQ extra-day trade: no min day P&L, profit done, days still needed
|
// 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
|
const isMnqExtraDay = cfg.minDayPnL <= 0
|
||||||
&& cfg.minTradingDays > daysTraded
|
&& effective.minTradingDays > daysTraded
|
||||||
&& totalProfit >= cfg.profitTarget;
|
&& totalProfit >= effective.profitTarget;
|
||||||
|
|
||||||
if (target.amount <= 0 && !isMnqExtraDay) continue;
|
if (target.amount <= 0 && !isMnqExtraDay) continue;
|
||||||
|
|
||||||
@@ -205,12 +213,17 @@ export async function runTrade(action: 'Buy' | 'Sell' | 'Random', symbol: string
|
|||||||
const { client, acc, contract, firmConfig, dailyPnL, daysTraded } = item;
|
const { client, acc, contract, firmConfig, dailyPnL, daysTraded } = item;
|
||||||
const cfg = getAccountConfig(acc.name, firmConfig)!;
|
const cfg = getAccountConfig(acc.name, firmConfig)!;
|
||||||
const totalProfit = dailyPnL.reduce((sum, d) => sum + d.pnl, 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
|
||||||
|
);
|
||||||
|
|
||||||
// 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
|
const isExtraDay = cfg.minDayPnL <= 0
|
||||||
&& cfg.minTradingDays > daysTraded
|
&& effective.minTradingDays > daysTraded
|
||||||
&& totalProfit >= cfg.profitTarget;
|
&& totalProfit >= effective.profitTarget;
|
||||||
|
|
||||||
if (isExtraDay) {
|
if (isExtraDay) {
|
||||||
const mnqContract = await client.findFrontMonthContract('MNQ');
|
const mnqContract = await client.findFrontMonthContract('MNQ');
|
||||||
@@ -237,7 +250,7 @@ export async function runTrade(action: 'Buy' | 'Sell' | 'Random', symbol: string
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const target = computeDailyTarget(cfg.profitTarget, cfg.consistency, totalProfit, dailyPnL, cfg.minDayPnL, cfg.minTradingDays);
|
const target = computeDailyTarget(effective.profitTarget, effective.consistency, totalProfit, dailyPnL, cfg.minDayPnL, effective.minTradingDays);
|
||||||
|
|
||||||
const rawContracts = Math.max(1, Math.ceil(target.amount / 1000));
|
const rawContracts = Math.max(1, Math.ceil(target.amount / 1000));
|
||||||
const contracts = cfg.maxPositionSize > 0 ? Math.min(rawContracts, cfg.maxPositionSize) : rawContracts;
|
const contracts = cfg.maxPositionSize > 0 ? Math.min(rawContracts, cfg.maxPositionSize) : rawContracts;
|
||||||
|
|||||||
@@ -50,6 +50,20 @@ try {
|
|||||||
// Column already exists
|
// Column already exists
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Migration: add target_same_equity flag
|
||||||
|
try {
|
||||||
|
db.exec('ALTER TABLE account_configs ADD COLUMN target_same_equity INTEGER NOT NULL DEFAULT 1');
|
||||||
|
} catch {
|
||||||
|
// Column already exists
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migration: add withdrawal_stages JSON array
|
||||||
|
try {
|
||||||
|
db.exec("ALTER TABLE account_configs ADD COLUMN withdrawal_stages TEXT NOT NULL DEFAULT '[]'");
|
||||||
|
} catch {
|
||||||
|
// Column already exists
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// ── Interfaces ─────────────────────────────────────────────────────────────
|
// ── Interfaces ─────────────────────────────────────────────────────────────
|
||||||
@@ -65,6 +79,8 @@ export interface AccountConfigRow {
|
|||||||
account_size: number;
|
account_size: number;
|
||||||
max_loss: number;
|
max_loss: number;
|
||||||
max_position_size: number;
|
max_position_size: number;
|
||||||
|
target_same_equity: number; // 0 | 1
|
||||||
|
withdrawal_stages: string; // JSON { profit: number; consistency: number; minTradingDays: number }[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FirmRow {
|
export interface FirmRow {
|
||||||
@@ -118,11 +134,13 @@ export function createAccountConfig(firmId: number, data: {
|
|||||||
accountSize: number;
|
accountSize: number;
|
||||||
maxLoss: number;
|
maxLoss: number;
|
||||||
maxPositionSize: number;
|
maxPositionSize: number;
|
||||||
|
targetSameEquity?: boolean;
|
||||||
|
withdrawalStages?: { profit: number; consistency: number; minTradingDays: number }[];
|
||||||
}): AccountConfigRow {
|
}): AccountConfigRow {
|
||||||
const stmt = db.prepare(
|
const stmt = db.prepare(
|
||||||
'INSERT INTO account_configs (firm_id, prefix, profit_target, consistency, min_day_pnl, min_trading_days, account_size, max_loss, max_position_size) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
'INSERT INTO account_configs (firm_id, prefix, profit_target, consistency, min_day_pnl, min_trading_days, account_size, max_loss, max_position_size, target_same_equity, withdrawal_stages) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||||
);
|
);
|
||||||
const result = stmt.run(firmId, data.prefix, data.profitTarget, data.consistency, data.minDayPnL, data.minTradingDays, data.accountSize, data.maxLoss, data.maxPositionSize);
|
const result = stmt.run(firmId, data.prefix, data.profitTarget, data.consistency, data.minDayPnL, data.minTradingDays, data.accountSize, data.maxLoss, data.maxPositionSize, data.targetSameEquity ? 1 : 0, JSON.stringify(data.withdrawalStages ?? []));
|
||||||
return db.prepare('SELECT * FROM account_configs WHERE id = ?').get(result.lastInsertRowid) as AccountConfigRow;
|
return db.prepare('SELECT * FROM account_configs WHERE id = ?').get(result.lastInsertRowid) as AccountConfigRow;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,12 +158,14 @@ export function updateAccountConfig(id: number, data: {
|
|||||||
accountSize: number;
|
accountSize: number;
|
||||||
maxLoss: number;
|
maxLoss: number;
|
||||||
maxPositionSize: number;
|
maxPositionSize: number;
|
||||||
|
targetSameEquity?: boolean;
|
||||||
|
withdrawalStages?: { profit: number; consistency: number; minTradingDays: number }[];
|
||||||
}): boolean {
|
}): boolean {
|
||||||
const result = db.prepare(`
|
const result = db.prepare(`
|
||||||
UPDATE account_configs
|
UPDATE account_configs
|
||||||
SET prefix = ?, profit_target = ?, consistency = ?, min_day_pnl = ?, min_trading_days = ?, account_size = ?, max_loss = ?, max_position_size = ?
|
SET prefix = ?, profit_target = ?, consistency = ?, min_day_pnl = ?, min_trading_days = ?, account_size = ?, max_loss = ?, max_position_size = ?, target_same_equity = ?, withdrawal_stages = ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`).run(data.prefix, data.profitTarget, data.consistency, data.minDayPnL, data.minTradingDays, data.accountSize, data.maxLoss, data.maxPositionSize, id);
|
`).run(data.prefix, data.profitTarget, data.consistency, data.minDayPnL, data.minTradingDays, data.accountSize, data.maxLoss, data.maxPositionSize, data.targetSameEquity ? 1 : 0, JSON.stringify(data.withdrawalStages ?? []), id);
|
||||||
return result.changes > 0;
|
return result.changes > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* Resolve the effective profit target and consistency for an account based on its withdrawal strategy.
|
||||||
|
*
|
||||||
|
* Mode A (targetSameEquity=true): the account must reach the same cumulative equity level.
|
||||||
|
* remainingProfit = priorProfit + totalWithdrawals (profit still in the account after payouts)
|
||||||
|
* effectiveProfitTarget = profitTarget − remainingProfit
|
||||||
|
*
|
||||||
|
* Mode B (withdrawalStages non-empty): Stage 1 (no withdrawals yet) uses base profitTarget/consistency.
|
||||||
|
* After the Nth withdrawal, use withdrawalStages[N-1]; last stage repeats.
|
||||||
|
* Fallback: returns base profitTarget and consistency unchanged.
|
||||||
|
*/
|
||||||
|
export function resolveEffectiveConfig(
|
||||||
|
profitTarget: number,
|
||||||
|
consistency: number,
|
||||||
|
minTradingDays: number,
|
||||||
|
targetSameEquity: boolean,
|
||||||
|
withdrawalStages: { profit: number; consistency: number; minTradingDays: number }[],
|
||||||
|
priorProfit: number,
|
||||||
|
fundTransactions: { date: string; amount: number }[]
|
||||||
|
): { profitTarget: number; consistency: number; minTradingDays: number } {
|
||||||
|
if (targetSameEquity) {
|
||||||
|
// Withdrawals reduce the profit remaining in the account
|
||||||
|
const totalWithdrawals = fundTransactions
|
||||||
|
.filter((f) => f.amount < 0)
|
||||||
|
.reduce((s, f) => s + f.amount, 0); // negative sum
|
||||||
|
const remainingProfit = priorProfit + totalWithdrawals;
|
||||||
|
return { profitTarget: Math.max(0, profitTarget - remainingProfit), consistency, minTradingDays };
|
||||||
|
}
|
||||||
|
if (withdrawalStages.length > 0) {
|
||||||
|
const withdrawalCount = fundTransactions.filter((f) => f.amount < 0).length;
|
||||||
|
if (withdrawalCount === 0) {
|
||||||
|
return { profitTarget, consistency, minTradingDays }; // Stage 1 = base values
|
||||||
|
}
|
||||||
|
const idx = Math.min(withdrawalCount - 1, withdrawalStages.length - 1);
|
||||||
|
const stage = withdrawalStages[idx];
|
||||||
|
return { profitTarget: stage.profit, consistency: stage.consistency, minTradingDays: stage.minTradingDays };
|
||||||
|
}
|
||||||
|
return { profitTarget, consistency, minTradingDays };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated Use resolveEffectiveConfig instead */
|
||||||
|
export function resolveEffectiveProfitTarget(
|
||||||
|
profitTarget: number,
|
||||||
|
targetSameEquity: boolean,
|
||||||
|
withdrawalStages: { profit: number; consistency: number; minTradingDays: number }[],
|
||||||
|
priorProfit: number,
|
||||||
|
fundTransactions: { date: string; amount: number }[]
|
||||||
|
): number {
|
||||||
|
return resolveEffectiveConfig(profitTarget, 0, 0, targetSameEquity, withdrawalStages, priorProfit, fundTransactions).profitTarget;
|
||||||
|
}
|
||||||
|
|
||||||
/** 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,
|
||||||
|
|||||||
+78
-5
@@ -31,10 +31,14 @@ export class TradovateClient {
|
|||||||
|
|
||||||
public daysTraded: { [accountId: number]: number } = {};
|
public daysTraded: { [accountId: number]: number } = {};
|
||||||
public dailyPnL: { [accountId: number]: { date: string; pnl: number }[] } = {};
|
public dailyPnL: { [accountId: number]: { date: string; pnl: number }[] } = {};
|
||||||
|
/** Full P&L history (all cycles) — used for calendar display */
|
||||||
|
public fullDailyPnL: { [accountId: number]: { date: string; pnl: number }[] } = {};
|
||||||
/** Date of the last fund transaction per account — days traded are counted from this date onwards */
|
/** Date of the last fund transaction per account — days traded are counted from this date onwards */
|
||||||
public lastFundDates: { [accountId: number]: string | null } = {};
|
public lastFundDates: { [accountId: number]: string | null } = {};
|
||||||
/** All fund transactions (deposits/withdrawals) per account */
|
/** All fund transactions (deposits/withdrawals) per account */
|
||||||
public fundTransactions: { [accountId: number]: { date: string; amount: number }[] } = {};
|
public fundTransactions: { [accountId: number]: { date: string; amount: number }[] } = {};
|
||||||
|
/** Sum of daily P&L from before the last fund transaction — used for "Target Same Equity" mode */
|
||||||
|
public priorProfit: { [accountId: number]: number } = {};
|
||||||
/** Balance floor set by the prop firm — account is blown when amount <= this value (0 = not set) */
|
/** Balance floor set by the prop firm — account is blown when amount <= this value (0 = not set) */
|
||||||
public autoLiqThresholds: { [accountId: number]: number } = {};
|
public autoLiqThresholds: { [accountId: number]: number } = {};
|
||||||
|
|
||||||
@@ -386,6 +390,43 @@ export class TradovateClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true when a withdrawal happened before the trading session started,
|
||||||
|
* meaning that day's trades belong to the NEW cycle.
|
||||||
|
* Timestamp format from Tradovate: "MM/DD/YYYY HH:MM:SS" in Central Time.
|
||||||
|
* Cutoff: before 9:00 AM CT → "before trading".
|
||||||
|
*/
|
||||||
|
private static isWithdrawalBeforeTrading(timestamp: string | null): boolean {
|
||||||
|
if (!timestamp) return false;
|
||||||
|
const match = timestamp.match(/\d{2}\/\d{2}\/\d{4}\s+(\d{2}):\d{2}:\d{2}/);
|
||||||
|
if (!match) return false;
|
||||||
|
return parseInt(match[1], 10) < 9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters daily PnL entries for the current cycle based on fund date and withdrawal timing.
|
||||||
|
* - Deposit: include the fund date (trading can start same day)
|
||||||
|
* - Withdrawal before trading session: include the fund date (day's trades are new cycle)
|
||||||
|
* - Withdrawal during/after trading: exclude the fund date (day's trades are old cycle)
|
||||||
|
*/
|
||||||
|
private static filterActivePnL(
|
||||||
|
entries: { date: string; pnl: number }[],
|
||||||
|
fundDate: string | null,
|
||||||
|
isWithdrawal: boolean,
|
||||||
|
fundTimestamp: string | null
|
||||||
|
): { active: { date: string; pnl: number }[]; prior: { date: string; pnl: number }[] } {
|
||||||
|
if (!fundDate) return { active: entries, prior: [] };
|
||||||
|
|
||||||
|
// Withdrawal before trading → day belongs to NEW cycle (use >=)
|
||||||
|
// Withdrawal during/after trading → day belongs to OLD cycle (use >)
|
||||||
|
// Deposit → always include the day (use >=)
|
||||||
|
const excludeFundDate = isWithdrawal && !TradovateClient.isWithdrawalBeforeTrading(fundTimestamp);
|
||||||
|
|
||||||
|
const active = entries.filter((d) => excludeFundDate ? d.date > fundDate : d.date >= fundDate);
|
||||||
|
const prior = entries.filter((d) => excludeFundDate ? d.date <= fundDate : d.date < fundDate);
|
||||||
|
return { active, prior };
|
||||||
|
}
|
||||||
|
|
||||||
public async fetchDaysTraded(): Promise<{ failedAccounts: number }> {
|
public async fetchDaysTraded(): Promise<{ failedAccounts: number }> {
|
||||||
if (!this.accessInfo?.accessToken) return { failedAccounts: 0 };
|
if (!this.accessInfo?.accessToken) return { failedAccounts: 0 };
|
||||||
this.fetchDaysComplete = false;
|
this.fetchDaysComplete = false;
|
||||||
@@ -457,8 +498,16 @@ export class TradovateClient {
|
|||||||
|
|
||||||
// Load cache first — serves as both the startup baseline and the fallback if API fails
|
// Load cache first — serves as both the startup baseline and the fallback if API fails
|
||||||
const cached = loadDailyPnL(account.id);
|
const cached = loadDailyPnL(account.id);
|
||||||
|
const storedFundTimestamp = loadAccountMeta(account.id, 'last_fund_timestamp');
|
||||||
if (cached.length > 0) {
|
if (cached.length > 0) {
|
||||||
const active = storedFundDate ? cached.filter((d) => d.date >= storedFundDate) : cached;
|
const storedFundTxns = this.fundTransactions[account.id] ?? [];
|
||||||
|
const storedLastFundAmt = storedFundDate ? (storedFundTxns.find((f) => f.date === storedFundDate)?.amount ?? null) : null;
|
||||||
|
const storedIsWithdrawal = storedLastFundAmt !== null && storedLastFundAmt < 0;
|
||||||
|
const { active, prior: priorEntries } = TradovateClient.filterActivePnL(
|
||||||
|
cached, storedFundDate, storedIsWithdrawal, storedFundTimestamp
|
||||||
|
);
|
||||||
|
this.priorProfit[account.id] = Math.round(priorEntries.reduce((s, d) => s + d.pnl, 0) * 100) / 100;
|
||||||
|
this.fullDailyPnL[account.id] = cached;
|
||||||
this.dailyPnL[account.id] = active;
|
this.dailyPnL[account.id] = active;
|
||||||
this.daysTraded[account.id] = active.filter((d) => d.pnl !== 0).length;
|
this.daysTraded[account.id] = active.filter((d) => d.pnl !== 0).length;
|
||||||
}
|
}
|
||||||
@@ -471,11 +520,15 @@ export class TradovateClient {
|
|||||||
this.lastFetchRaw[account.name] = rows.length > 0 ? JSON.stringify(rows[0]) : '(empty)';
|
this.lastFetchRaw[account.name] = rows.length > 0 ? JSON.stringify(rows[0]) : '(empty)';
|
||||||
|
|
||||||
const fundMap: { [date: string]: number } = {};
|
const fundMap: { [date: string]: number } = {};
|
||||||
|
const fundTimestampMap: { [date: string]: string } = {};
|
||||||
const dailyMap: { [date: string]: number } = {};
|
const dailyMap: { [date: string]: number } = {};
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
if ((row['Cash Change Type'] ?? '').trim() === 'Fund Transaction') {
|
if ((row['Cash Change Type'] ?? '').trim() === 'Fund Transaction') {
|
||||||
const delta = parseFloat((row['Delta'] ?? '0').replace(/,/g, ''));
|
const delta = parseFloat((row['Delta'] ?? '0').replace(/,/g, ''));
|
||||||
if (!isNaN(delta)) fundMap[row['Date']] = (fundMap[row['Date']] ?? 0) + delta;
|
if (!isNaN(delta)) {
|
||||||
|
fundMap[row['Date']] = (fundMap[row['Date']] ?? 0) + delta;
|
||||||
|
if (row['Timestamp']) fundTimestampMap[row['Date']] = row['Timestamp'];
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const delta = parseFloat((row['Delta'] ?? '0').replace(/,/g, ''));
|
const delta = parseFloat((row['Delta'] ?? '0').replace(/,/g, ''));
|
||||||
@@ -497,6 +550,9 @@ export class TradovateClient {
|
|||||||
if (lastFundDate) {
|
if (lastFundDate) {
|
||||||
saveAccountMeta(account.id, 'last_fund_date', lastFundDate);
|
saveAccountMeta(account.id, 'last_fund_date', lastFundDate);
|
||||||
this.lastFundDates[account.id] = lastFundDate;
|
this.lastFundDates[account.id] = lastFundDate;
|
||||||
|
if (fundTimestampMap[lastFundDate]) {
|
||||||
|
saveAccountMeta(account.id, 'last_fund_timestamp', fundTimestampMap[lastFundDate]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const fundDate = this.lastFundDates[account.id];
|
const fundDate = this.lastFundDates[account.id];
|
||||||
|
|
||||||
@@ -505,7 +561,16 @@ export class TradovateClient {
|
|||||||
.sort((a, b) => a.date.localeCompare(b.date));
|
.sort((a, b) => a.date.localeCompare(b.date));
|
||||||
|
|
||||||
const merged = mergePnL(cached, fresh);
|
const merged = mergePnL(cached, fresh);
|
||||||
const active = fundDate ? merged.filter((d) => d.date >= fundDate) : merged;
|
|
||||||
|
const lastFundAmt = fundDate ? (fundMap[fundDate] ?? null) : null;
|
||||||
|
const isWithdrawal = lastFundAmt !== null && lastFundAmt < 0;
|
||||||
|
const lastFundTs = fundDate ? (fundTimestampMap[fundDate] ?? loadAccountMeta(account.id, 'last_fund_timestamp')) : null;
|
||||||
|
const { active, prior: priorEntries } = TradovateClient.filterActivePnL(
|
||||||
|
merged, fundDate, isWithdrawal, lastFundTs
|
||||||
|
);
|
||||||
|
this.priorProfit[account.id] = Math.round(priorEntries.reduce((s, d) => s + d.pnl, 0) * 100) / 100;
|
||||||
|
|
||||||
|
this.fullDailyPnL[account.id] = merged;
|
||||||
this.dailyPnL[account.id] = active;
|
this.dailyPnL[account.id] = active;
|
||||||
this.daysTraded[account.id] = active.filter((d) => d.pnl !== 0).length;
|
this.daysTraded[account.id] = active.filter((d) => d.pnl !== 0).length;
|
||||||
saveDailyPnL(account.id, account.name, fresh); // upsert only fresh entries — preserves older cached rows
|
saveDailyPnL(account.id, account.name, fresh); // upsert only fresh entries — preserves older cached rows
|
||||||
@@ -557,15 +622,23 @@ export class TradovateClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fills report has no fund transaction data — use stored fund date
|
// Fills report has no fund transaction data — use stored fund date and stored fund transactions
|
||||||
const fundDate = this.lastFundDates[account.id];
|
const fundDate = this.lastFundDates[account.id];
|
||||||
|
const storedFundTxns = this.fundTransactions[account.id] ?? [];
|
||||||
|
const lastFundAmt = fundDate ? (storedFundTxns.find((f) => f.date === fundDate)?.amount ?? null) : null;
|
||||||
|
const isWithdrawalFills = lastFundAmt !== null && lastFundAmt < 0;
|
||||||
|
|
||||||
const fresh = Object.entries(dailyMap)
|
const fresh = Object.entries(dailyMap)
|
||||||
.map(([date, pnl]) => ({ date, pnl: Math.round(pnl * 100) / 100 }))
|
.map(([date, pnl]) => ({ date, pnl: Math.round(pnl * 100) / 100 }))
|
||||||
.sort((a, b) => a.date.localeCompare(b.date));
|
.sort((a, b) => a.date.localeCompare(b.date));
|
||||||
|
|
||||||
const merged = mergePnL(cached, fresh);
|
const merged = mergePnL(cached, fresh);
|
||||||
const active = fundDate ? merged.filter((d) => d.date >= fundDate) : merged;
|
const fillsFundTs = loadAccountMeta(account.id, 'last_fund_timestamp');
|
||||||
|
const { active, prior: priorEntries } = TradovateClient.filterActivePnL(
|
||||||
|
merged, fundDate, isWithdrawalFills, fillsFundTs
|
||||||
|
);
|
||||||
|
this.priorProfit[account.id] = Math.round(priorEntries.reduce((s, d) => s + d.pnl, 0) * 100) / 100;
|
||||||
|
this.fullDailyPnL[account.id] = merged;
|
||||||
this.dailyPnL[account.id] = active;
|
this.dailyPnL[account.id] = active;
|
||||||
this.daysTraded[account.id] = active.filter((d) => d.pnl !== 0).length;
|
this.daysTraded[account.id] = active.filter((d) => d.pnl !== 0).length;
|
||||||
saveDailyPnL(account.id, account.name, fresh); // upsert only fresh entries — preserves older cached rows
|
saveDailyPnL(account.id, account.name, fresh); // upsert only fresh entries — preserves older cached rows
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ export interface AccountConfig {
|
|||||||
accountSize: number;
|
accountSize: number;
|
||||||
maxLoss: number; // 0 = no limit; positive = account is "Dead" when loss exceeds this
|
maxLoss: number; // 0 = no limit; positive = account is "Dead" when loss exceeds this
|
||||||
maxPositionSize: number; // 0 = no limit; positive = max contracts per trade
|
maxPositionSize: number; // 0 = no limit; positive = max contracts per trade
|
||||||
|
targetSameEquity: boolean; // if true, reduce profitTarget by priorProfit after withdrawal
|
||||||
|
withdrawalStages: { profit: number; consistency: number; minTradingDays: number }[]; // per-stage fresh profit + consistency (empty = use base values)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FirmConfig {
|
export interface FirmConfig {
|
||||||
@@ -34,8 +36,10 @@ export interface AccountState {
|
|||||||
targetHit: boolean;
|
targetHit: boolean;
|
||||||
/** Next trading day's target, computed server-side. null when account is dead or config is missing. */
|
/** Next trading day's target, computed server-side. null when account is dead or config is missing. */
|
||||||
dailyTarget: { amount: number; path: 'first_day' | 'normal_day' | 'reduced_day' } | null;
|
dailyTarget: { amount: number; path: 'first_day' | 'normal_day' | 'reduced_day' } | null;
|
||||||
/** FIFO daily P&L history — used by the equity curve and calendar. */
|
/** Current cycle daily P&L — used for target calculations. */
|
||||||
dailyPnL: { date: string; pnl: number }[];
|
dailyPnL: { date: string; pnl: number }[];
|
||||||
|
/** Full P&L history across all cycles — used for calendar and equity curve display. */
|
||||||
|
fullDailyPnL: { date: string; pnl: number }[];
|
||||||
/** Fund transactions (deposits/withdrawals) — used by the calendar and equity curve. */
|
/** Fund transactions (deposits/withdrawals) — used by the calendar and equity curve. */
|
||||||
fundTransactions: { date: string; amount: number }[];
|
fundTransactions: { date: string; amount: number }[];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user