Fix consistency bug, rename Random to Auto, add stop-after-all, direction pills, copy-trade

Bug fixes:
- Fix computeDailyTarget when consistency is 0% or 100%: treat as no constraint,
  letting min-day reservation or full remaining profit drive the target
- Rename 'Random' to 'Auto' across entire codebase (types, API, UI, scheduler)

Features:
- Add "Stop after all eligible" checkbox: auto-stops scheduler when all
  configured accounts are dead, inactive, already traded, or challenge complete
- Show position direction in status pill: "Long" (green) / "Short" (red)
  instead of generic "In Trade" (blue)
- Add "Copy to Max" button: copies current trade direction to remaining
  eligible accounts up to max_concurrent_accounts limit

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-03-26 02:50:31 -05:00
co-authored by Claude Opus 4.6
parent 8d9a9a5ea9
commit df793bfd70
7 changed files with 342 additions and 39 deletions
+12
View File
@@ -0,0 +1,12 @@
import { NextResponse } from 'next/server';
import { copyTrade } from '@/lib/auto-trade';
export async function POST() {
try {
const results = await copyTrade();
return NextResponse.json(results);
} catch (err: any) {
console.error('[POST /api/copy-trade]', err);
return NextResponse.json({ error: err?.message ?? 'Copy trade failed' }, { status: 500 });
}
}
+3 -1
View File
@@ -66,7 +66,9 @@ export async function GET() {
amount: cash.amount,
realizedPnL: cash.realizedPnL,
daysTraded,
hasPosition: !!client.positions[acc.id],
positionDirection: client.positions[acc.id]
? (client.positions[acc.id].netPos > 0 ? 'long' as const : 'short' as const)
: null,
autoLiqThreshold,
totalProfit,
targetHit,
+4 -4
View File
@@ -4,14 +4,14 @@ import { POINT_VALUES } from '@/lib/trading-logic';
export async function POST(req: NextRequest) {
try {
const body = await req.json() as { action: 'Buy' | 'Sell' | 'Random'; symbol: string };
const { action, symbol } = body;
const body = await req.json() as { action: 'Buy' | 'Sell' | 'Auto'; symbol: string; stopAfterAll?: boolean };
const { action, symbol, stopAfterAll } = body;
if (!action || !symbol) {
return NextResponse.json({ error: 'Missing required fields: action, symbol' }, { status: 400 });
}
if (symbol !== 'Random' && !POINT_VALUES[symbol]) {
if (symbol !== 'Auto' && !POINT_VALUES[symbol]) {
return NextResponse.json({ error: `Unknown symbol: ${symbol}` }, { status: 400 });
}
@@ -19,7 +19,7 @@ export async function POST(req: NextRequest) {
const results = await runTrade(action, symbol);
// (Re)start the scheduler with this action + symbol
startScheduler(action, symbol);
startScheduler(action, symbol, stopAfterAll ?? false);
return NextResponse.json(results);
} catch (err: any) {
+56 -12
View File
@@ -11,7 +11,7 @@ type SortDir = 'asc' | 'desc';
function statusRank(account: AccountState): number {
if (isAccountDead(account)) return 0;
if (!account.active) return 1;
if (!account.hasPosition) {
if (!account.positionDirection) {
if (account.targetHit) return 4; // Target Hit — most accomplished
return 2; // Flat
}
@@ -147,8 +147,8 @@ function AccountRow({ account, firm, hideDead, privacy }: { account: AccountStat
? <span className="inline-block px-2.5 py-0.5 rounded-full text-xs font-semibold bg-slate-800 text-white">Dead</span>
: !account.active
? <span className="inline-block px-2.5 py-0.5 rounded-full text-xs font-semibold bg-red-100 text-red-700">Hit DLL</span>
: account.hasPosition
? <span className="inline-block px-2.5 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-700">In Trade</span>
: account.positionDirection
? <span className={`inline-block px-2.5 py-0.5 rounded-full text-xs font-semibold ${account.positionDirection === 'long' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>{account.positionDirection === 'long' ? 'Long' : 'Short'}</span>
: account.targetHit
? <span className="inline-block px-2.5 py-0.5 rounded-full text-xs font-semibold bg-green-100 text-green-700">Target Hit</span>
: <span className="inline-block px-2.5 py-0.5 rounded-full text-xs font-semibold bg-slate-100 text-slate-500">Flat</span>}
@@ -258,10 +258,11 @@ function FirmRows({ state, firm, deleteMode, selected, onToggle, hideDead, priva
interface SchedulerStatus {
running: boolean;
action: 'Buy' | 'Sell' | 'Random';
action: 'Buy' | 'Sell' | 'Auto';
symbol: string;
lastRun: string | null;
intervalSeconds: number;
stopAfterAll: boolean;
}
export default function Home() {
@@ -276,12 +277,13 @@ export default function Home() {
const [sortDir, setSortDir] = useState<SortDir>('asc');
// Trade controls
const [scheduler, setScheduler] = useState<SchedulerStatus>({ running: false, action: 'Buy', symbol: 'NQ', lastRun: null, intervalSeconds: 60 });
const [scheduler, setScheduler] = useState<SchedulerStatus>({ running: false, action: 'Buy', symbol: 'NQ', lastRun: null, intervalSeconds: 60, stopAfterAll: false });
const [enabledSymbols, setEnabledSymbols] = useState<string[]>([]);
const [tradeSymbol, setTradeSymbol] = useState('');
const [tradeAction, setTradeAction] = useState<'Buy' | 'Sell' | 'Random'>('Buy');
const [tradeAction, setTradeAction] = useState<'Buy' | 'Sell' | 'Auto'>('Buy');
const [tickInterval, setTickInterval] = useState('60');
const [tradeLoading, setTradeLoading] = useState(false);
const [stopAfterAll, setStopAfterAll] = useState(false);
const handleSort = (col: SortKey) => {
if (sortKey === col) {
@@ -355,7 +357,7 @@ export default function Home() {
await fetch('/api/trade', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: tradeAction, symbol: tradeSymbol }),
body: JSON.stringify({ action: tradeAction, symbol: tradeSymbol, stopAfterAll }),
});
await fetchScheduler();
} finally {
@@ -368,6 +370,17 @@ export default function Home() {
setScheduler((s) => ({ ...s, running: false, lastRun: null }));
};
const hasAnyPosition = firms.some(f => f.accounts.some(a => a.positionDirection !== null));
const [copyLoading, setCopyLoading] = useState(false);
const handleCopy = async () => {
setCopyLoading(true);
try {
await fetch('/api/copy-trade', { method: 'POST' });
} finally {
setCopyLoading(false);
}
};
// Count dead accounts across all firms for the toggle button label
const deadCount = firms.reduce((total, firmState) => {
const firmCfg = config.find((c) => c.firm === firmState.firm);
@@ -476,12 +489,23 @@ export default function Home() {
· last run {new Date(scheduler.lastRun).toLocaleTimeString()}
</span>
)}
<div className="ml-auto flex items-center gap-2">
{hasAnyPosition && (
<button
onClick={handleCopy}
disabled={copyLoading}
className="px-4 py-1.5 bg-amber-100 hover:bg-amber-200 disabled:opacity-50 text-amber-700 text-sm font-semibold rounded-lg transition-colors"
>
{copyLoading ? 'Copying…' : 'Copy to Max'}
</button>
)}
<button
onClick={handleStop}
className="ml-auto px-4 py-1.5 bg-red-100 hover:bg-red-200 text-red-700 text-sm font-semibold rounded-lg transition-colors"
className="px-4 py-1.5 bg-red-100 hover:bg-red-200 text-red-700 text-sm font-semibold rounded-lg transition-colors"
>
Stop
</button>
</div>
</>
) : (
<>
@@ -494,7 +518,7 @@ export default function Home() {
className="rounded-lg border border-slate-200 bg-slate-50 px-2.5 py-1.5 text-sm font-mono text-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{enabledSymbols.map((s) => <option key={s} value={s}>{s}</option>)}
<option value="Random">Auto</option>
<option value="Auto">Auto</option>
</select>
<div className="flex rounded-lg border border-slate-200 overflow-hidden text-sm font-semibold">
<button
@@ -510,8 +534,8 @@ export default function Home() {
Sell
</button>
<button
onClick={() => setTradeAction('Random')}
className={`px-3 py-1.5 transition-colors border-l border-slate-200 ${tradeAction === 'Random' ? 'bg-purple-500 text-white' : 'text-slate-500 hover:bg-slate-50'}`}
onClick={() => setTradeAction('Auto')}
className={`px-3 py-1.5 transition-colors border-l border-slate-200 ${tradeAction === 'Auto' ? 'bg-purple-500 text-white' : 'text-slate-500 hover:bg-slate-50'}`}
>
Auto
</button>
@@ -538,13 +562,33 @@ export default function Home() {
<span className="text-xs text-slate-400">s</span>
</div>
</div>
<label className="flex items-center gap-1.5 ml-2 cursor-pointer select-none">
<input
type="checkbox"
checked={stopAfterAll}
onChange={(e) => setStopAfterAll(e.target.checked)}
className="rounded border-slate-300 text-blue-500 focus:ring-blue-500"
/>
<span className="text-xs text-slate-400 whitespace-nowrap">Stop after all eligible</span>
</label>
<div className="ml-auto flex items-center gap-2">
{hasAnyPosition && (
<button
onClick={handleCopy}
disabled={copyLoading}
className="px-4 py-1.5 bg-amber-100 hover:bg-amber-200 disabled:opacity-50 text-amber-700 text-sm font-semibold rounded-lg transition-colors"
>
{copyLoading ? 'Copying…' : 'Copy to Max'}
</button>
)}
<button
onClick={handleStart}
disabled={tradeLoading}
className="ml-auto px-4 py-1.5 bg-blue-500 hover:bg-blue-600 disabled:opacity-50 text-white text-sm font-semibold rounded-lg transition-colors"
className="px-4 py-1.5 bg-blue-500 hover:bg-blue-600 disabled:opacity-50 text-white text-sm font-semibold rounded-lg transition-colors"
>
{tradeLoading ? 'Starting…' : '▶ Start'}
</button>
</div>
</>
)}
</div>
+246 -7
View File
@@ -107,20 +107,20 @@ function isInNoTradeWindow(): boolean {
// ── core trade logic ──────────────────────────────────────────────────────────
export async function runTrade(action: 'Buy' | 'Sell' | 'Random', symbol: string) {
export async function runTrade(action: 'Buy' | 'Sell' | 'Auto', symbol: string) {
if (isInNoTradeWindow()) {
console.log('[auto-trade] CME market closed — skipping');
return [];
}
// Resolve 'Random' symbol once per batch so all accounts trade the same symbol
// Resolve 'Auto' symbol once per batch so all accounts trade the same symbol
let resolvedSymbol = symbol;
if (symbol === 'Random') {
if (symbol === 'Auto') {
const enabled = getInstruments().filter((i) => i.enabled).map((i) => i.symbol);
resolvedSymbol = enabled.length > 0 ? enabled[Math.floor(Math.random() * enabled.length)] : 'NQ';
console.log(`[auto-trade] random symbol resolved to: ${resolvedSymbol}`);
}
// Resolve Random action once per batch so all accounts trade the same direction
const resolvedAction: 'Buy' | 'Sell' = action === 'Random'
const resolvedAction: 'Buy' | 'Sell' = action === 'Auto'
? (Math.random() < 0.5 ? 'Buy' : 'Sell')
: action;
const pointValue = POINT_VALUES[resolvedSymbol];
@@ -307,14 +307,245 @@ export async function runTrade(action: 'Buy' | 'Sell' | 'Random', symbol: string
return Array.from(firmResultsMap.entries()).map(([firm, results]) => ({ firm, results }));
}
// ── copy trade ───────────────────────────────────────────────────────────────
/**
* Copy the current trade direction to up to maxConcurrent accounts.
* Finds accounts with open positions, determines direction, then fires
* orders for eligible accounts that haven't traded yet.
*/
export async function copyTrade() {
if (isInNoTradeWindow()) {
console.log('[copy-trade] outside trading hours — skipping');
return [];
}
const firms = getFirms();
const clients = getClients();
const maxConcurrent = Math.max(1, parseInt(getSetting('max_concurrent_accounts') ?? '5', 10));
// Find all accounts with open positions to determine direction + symbol
let resolvedAction: 'Buy' | 'Sell' | null = null;
let resolvedSymbol: string | null = null;
let positionedCount = 0;
for (const firm of firms) {
const client = clients.get(firm.id);
if (!client) continue;
for (const acc of client.accountList) {
const pos = client.positions[acc.id];
if (!pos) continue;
positionedCount++;
if (!resolvedAction) {
resolvedAction = pos.netPos > 0 ? 'Buy' : 'Sell';
}
// Determine the symbol from the scheduler state (positions only have contractId)
if (!resolvedSymbol) {
const state = getState();
resolvedSymbol = state.symbol === 'Auto' ? null : state.symbol;
}
}
}
if (!resolvedAction || positionedCount === 0) {
console.log('[copy-trade] no open positions to copy from');
return [];
}
// Fall back to enabled instruments if symbol unknown
if (!resolvedSymbol) {
const instruments = getInstruments();
const enabled = instruments.filter(i => i.enabled).map(i => i.symbol);
resolvedSymbol = enabled[0] ?? 'NQ';
}
const pointValue = POINT_VALUES[resolvedSymbol];
if (!pointValue) {
console.log(`[copy-trade] unknown symbol ${resolvedSymbol}`);
return [];
}
const slotsAvailable = maxConcurrent - positionedCount;
if (slotsAvailable <= 0) {
console.log(`[copy-trade] already at max concurrent (${positionedCount}/${maxConcurrent})`);
return [];
}
// Collect eligible accounts (same logic as Phase 1 of runTrade)
type CopyItem = {
firmName: string;
client: any;
acc: { id: number; name: string; active: boolean };
contract: { name: string; tickSize: number };
firmConfig: FirmConfig;
dailyPnL: { date: string; pnl: number }[];
daysTraded: number;
};
const eligible: CopyItem[] = [];
await Promise.all(firms.map(async (firm) => {
const client = clients.get(firm.id);
if (!client || client.accountList.length === 0) return;
if (isSymbolBanned(firm.id, resolvedSymbol!)) return;
const firmConfig = mapFirmConfig(firm);
const contract = await client.findFrontMonthContract(resolvedSymbol!);
if (!contract) return;
for (const acc of client.accountList) {
if (client.positions[acc.id]) continue; // already in a trade
const cash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 };
const autoLiqThreshold = client.autoLiqThresholds[acc.id] ?? 0;
if (isAccountDead(cash.amount, autoLiqThreshold)) continue;
if (!acc.active) continue;
const cfg = getAccountConfig(acc.name, firmConfig);
if (!cfg) 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 target = computeDailyTarget(effective.profitTarget, effective.consistency, totalProfit, dailyPnL, cfg.minDayPnL, effective.minTradingDays);
if (target.amount <= 0) continue;
eligible.push({ firmName: firm.name, client, acc, contract, firmConfig, dailyPnL, daysTraded });
}
}));
const batch = eligible.slice(0, slotsAvailable);
if (batch.length === 0) {
console.log('[copy-trade] no eligible accounts to copy to');
return [];
}
console.log(`[copy-trade] copying ${resolvedAction} ${resolvedSymbol} to ${batch.length} account(s)`);
// Fire orders (same as Phase 2 of runTrade)
const tradeResults = await Promise.allSettled(batch.map(async (item) => {
const { client, acc, contract, firmConfig, dailyPnL } = item;
const cfg = getAccountConfig(acc.name, firmConfig)!;
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 target = computeDailyTarget(effective.profitTarget, effective.consistency, totalProfit, dailyPnL, cfg.minDayPnL, effective.minTradingDays);
const rawContracts = Math.max(1, Math.ceil(target.amount / 1000));
const contracts = cfg.maxPositionSize > 0 ? Math.min(rawContracts, cfg.maxPositionSize) : rawContracts;
const fill = await client.sendOrder(acc.id, contract.name, contracts, resolvedAction!, 'Market');
await new Promise(r => setTimeout(r, 1000));
const updatedCash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 };
const entryCommission = Math.abs(updatedCash.realizedPnL) || (2.5 * contracts);
const totalCommission = entryCommission * 2;
const grossTarget = target.amount + totalCommission;
const targetPoints = grossTarget / (pointValue * contracts);
const ticks = Math.ceil(targetPoints / contract.tickSize);
const exitPrice = resolvedAction === 'Buy'
? fill.price + (ticks * contract.tickSize)
: fill.price - (ticks * contract.tickSize);
const exitAction: 'Buy' | 'Sell' = resolvedAction === 'Buy' ? 'Sell' : 'Buy';
const exitOrder = await client.placeOrderNoWait(acc.id, contract.name, contracts, exitAction, 'Limit', exitPrice);
console.log(`[copy-trade] ${acc.name} (${item.firmName}) ${resolvedAction} ${contracts}x${resolvedSymbol} @ ${fill.price} | target $${target.amount} [${target.path}] | exit @ ${exitPrice} (orderId=${exitOrder.orderId})`);
return {
account: acc.name,
firm: item.firmName,
status: 'filled',
contracts,
target: target.amount,
grossTarget,
totalCommission,
targetPath: target.path,
entryPrice: fill.price,
exitPrice,
commission: entryCommission,
};
}));
const results: unknown[] = [];
for (let i = 0; i < batch.length; i++) {
const r = tradeResults[i];
results.push(
r.status === 'fulfilled'
? r.value
: { status: 'error', reason: (r.reason as any)?.message ?? String(r.reason) }
);
}
return results;
}
// ── 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). */
function hasRemainingConfiguredAccounts(): boolean {
const firms = getFirms();
const clients = getClients();
for (const firm of firms) {
const client = clients.get(firm.id);
if (!client || client.accountList.length === 0) continue;
const firmConfig = mapFirmConfig(firm);
for (const acc of client.accountList) {
const cfg = getAccountConfig(acc.name, firmConfig);
if (!cfg) continue; // no config = not our account
// Account with open position = still in play
if (client.positions[acc.id]) return true;
const cash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 };
const autoLiqThreshold = client.autoLiqThresholds[acc.id] ?? 0;
if (isAccountDead(cash.amount, autoLiqThreshold)) continue;
if (!acc.active) continue;
if (cash.realizedPnL !== 0) continue; // already traded today
const dailyPnL: { date: string; pnl: number }[] = client.dailyPnL[acc.id] ?? [];
const daysTraded: number = client.daysTraded[acc.id] ?? 0;
const totalProfit = dailyPnL.reduce((sum, d) => sum + d.pnl, 0);
const priorProfit = client.priorProfit?.[acc.id] ?? 0;
const allFundTxns = client.fundTransactions?.[acc.id] ?? [];
const effective = resolveEffectiveConfig(
cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity, cfg.withdrawalStages, priorProfit, allFundTxns
);
const target = computeDailyTarget(effective.profitTarget, effective.consistency, totalProfit, dailyPnL, cfg.minDayPnL, effective.minTradingDays);
const isMnqExtraDay = cfg.minDayPnL <= 0
&& effective.minTradingDays > daysTraded
&& totalProfit >= effective.profitTarget;
if (target.amount > 0 || isMnqExtraDay) return true;
}
}
return false;
}
// ── scheduler ─────────────────────────────────────────────────────────────────
interface SchedulerState {
action: 'Buy' | 'Sell' | 'Random';
action: 'Buy' | 'Sell' | 'Auto';
symbol: string;
intervalId: ReturnType<typeof setInterval> | null;
lastRun: Date | null;
running: boolean;
stopAfterAll: boolean;
}
// Global singleton (survives HMR in dev via module cache)
@@ -322,12 +553,12 @@ const _global = globalThis as typeof globalThis & { __autoTrader?: SchedulerStat
function getState(): SchedulerState {
if (!_global.__autoTrader) {
_global.__autoTrader = { action: 'Buy', symbol: 'NQ', intervalId: null, lastRun: null, running: false };
_global.__autoTrader = { action: 'Buy', symbol: 'NQ', intervalId: null, lastRun: null, running: false, stopAfterAll: false };
}
return _global.__autoTrader;
}
export function startScheduler(action: 'Buy' | 'Sell' | 'Random', symbol: string) {
export function startScheduler(action: 'Buy' | 'Sell' | 'Auto', symbol: string, stopAfterAll: boolean = false) {
const state = getState();
// Clear any existing interval
@@ -338,6 +569,7 @@ export function startScheduler(action: 'Buy' | 'Sell' | 'Random', symbol: string
state.action = action;
state.symbol = symbol;
state.running = true;
state.stopAfterAll = stopAfterAll;
const tick = async () => {
if (!state.running) return;
@@ -370,6 +602,12 @@ export function startScheduler(action: 'Buy' | 'Sell' | 'Random', symbol: string
const results = await runTrade(state.action, state.symbol);
const filled = results.flatMap((r: any) => r.results ?? []).filter((r: any) => r.status === 'filled').length;
if (filled > 0) console.log(`[scheduler] tick: ${filled} account(s) filled`);
// Auto-stop if user opted in and no configured accounts can trade anymore
if (state.stopAfterAll && !hasRemainingConfiguredAccounts()) {
console.log('[scheduler] all configured accounts done for today — stopping');
stopScheduler();
}
} catch (err) {
console.error('[scheduler] tick error:', err);
}
@@ -398,5 +636,6 @@ export function getSchedulerStatus() {
symbol: state.symbol,
lastRun: state.lastRun,
intervalSeconds: parseInt(getSetting('tick_interval_seconds') ?? '60', 10),
stopAfterAll: state.stopAfterAll,
};
}
+9 -3
View File
@@ -94,10 +94,11 @@ export function computeDailyTarget(
let path: 'first_day' | 'normal_day' | 'reduced_day';
if (daysTraded === 0) {
baseAmount = profitTarget * consistency;
// 0% or 100% consistency = no constraint; let min-day reservation drive the target
baseAmount = (consistency === 0 || consistency >= 1) ? 0 : profitTarget * consistency;
path = 'first_day';
} else if (consistency === 0) {
// 0% consistency means no consistency rule to satisfy — base amount is always $0.
} else if (consistency === 0 || consistency >= 1) {
// No consistency rule to satisfy — base amount is $0.
// The min-day reservation block below handles any mandatory-day targeting.
baseAmount = 0;
path = 'reduced_day';
@@ -140,5 +141,10 @@ export function computeDailyTarget(
return { amount: Math.round(amount * 100) / 100, path };
}
// When no consistency constraint and no min-day reservation applied, target the full remaining profit
if (baseAmount <= 0 && (consistency === 0 || consistency >= 1)) {
baseAmount = Math.max(0, profitTarget - totalProfit);
}
return { amount: Math.round(baseAmount * 100) / 100, path };
}
+1 -1
View File
@@ -27,7 +27,7 @@ export interface AccountState {
amount: number;
realizedPnL: number;
daysTraded: number;
hasPosition: boolean;
positionDirection: 'long' | 'short' | null;
/** Balance floor from Tradovate's auto-liquidation profile (0 = not set) */
autoLiqThreshold: number;
/** Sum of all historical daily P&L entries */