diff --git a/.claude/launch.json b/.claude/launch.json index 8528960..5d72c43 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -2,11 +2,11 @@ "version": "0.0.1", "configurations": [ { - "name": "autotrader-next", - "runtimeExecutable": "npx", - "runtimeArgs": ["next", "dev", "--port", "3000"], + "name": "autotrader", + "runtimeExecutable": "C:\\Program Files\\nodejs\\npm.cmd", + "runtimeArgs": ["run", "dev", "--", "--webpack"], "port": 3000, - "cwd": "D:\\Development\\market-dev\\autotrader-firms\\autotrader-next" + "cwd": "D:\\Development\\market-dev\\autotrader-firms\\autotrader" } ] } diff --git a/app/accounts/[id]/page.tsx b/app/accounts/[id]/page.tsx new file mode 100644 index 0000000..e2062be --- /dev/null +++ b/app/accounts/[id]/page.tsx @@ -0,0 +1,493 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useParams } from 'next/navigation'; +import Link from 'next/link'; +import { + ResponsiveContainer, + AreaChart, + Area, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ReferenceLine, + Dot, +} from 'recharts'; +import type { FirmConfig, FirmState, AccountState, AccountConfig } from '@/types'; + +interface DailyPnL { + date: string; + pnl: number; +} + +function getAccountConfig(name: string, firm: FirmConfig): AccountConfig | undefined { + return [...firm.accounts] + .sort((a, b) => b.prefix.length - a.prefix.length) + .find((a) => name.startsWith(a.prefix)); +} + +function fmt(value: number) { + return value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); +} + +function fmtDate(iso: string) { + const [, m, d] = iso.split('-'); + return `${parseInt(m)}/${parseInt(d)}`; +} + +function PerfRow({ label, value, badge, positive }: { label: string; value: string | number; badge?: string; positive?: boolean }) { + return ( +
+ {label} +
+ {badge != null && ( + + {badge} + + )} + {value} +
+
+ ); +} + +function ObjRow({ passed, label, value }: { passed: boolean; label: string; value: string }) { + return ( +
+
+ {passed + ? + : ! + } + {label} +
+ {value} +
+ ); +} + +function EquityTooltip({ active, payload, label }: any) { + if (!active || !payload?.length) return null; + // Always read from the raw data point so fill-only series don't interfere + const equity: number = payload[0].payload.equity; + const daily: number = payload[0].payload.pnl; + return ( +
+

{label}

+

= 0 ? 'text-green-600' : 'text-red-500'}`}> + {equity >= 0 ? '+' : ''}${fmt(equity)} +

+

= 0 ? 'text-green-500' : 'text-red-400'}`}> + {daily >= 0 ? '▲' : '▼'} ${fmt(Math.abs(daily))} day +

+
+ ); +} + +const MONTH_NAMES = [ + 'January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December', +]; +const DOW_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + +function CalendarMonth({ year, month, pnlMap }: { year: number; month: number; pnlMap: Map }) { + const daysInMonth = new Date(year, month, 0).getDate(); + const firstDow = new Date(year, month - 1, 1).getDay(); // 0 = Sunday + + // Monthly total from only the days that have data + let monthTotal = 0; + for (let d = 1; d <= daysInMonth; d++) { + const key = `${year}-${String(month).padStart(2, '0')}-${String(d).padStart(2, '0')}`; + const pnl = pnlMap.get(key); + if (pnl !== undefined) monthTotal += pnl; + } + monthTotal = Math.round(monthTotal * 100) / 100; + + // Build flat cell array: null = empty leading cell, number = day of month + const cells: (number | null)[] = [ + ...Array.from({ length: firstDow }, () => null), + ...Array.from({ length: daysInMonth }, (_, i) => i + 1), + ]; + + return ( +
+ {/* Month header */} +
+ + {MONTH_NAMES[month - 1]} {year} + + {monthTotal !== 0 && ( + = 0 ? 'text-green-600' : 'text-red-500'}`}> + {monthTotal >= 0 ? '+' : '−'}${fmt(Math.abs(monthTotal))} + + )} +
+ + {/* Day-of-week headers */} +
+ {DOW_LABELS.map((d) => ( +
{d}
+ ))} +
+ + {/* Day cells */} +
+ {cells.map((day, i) => { + if (day === null) return
; + const key = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; + const pnl = pnlMap.get(key); + const hasData = pnl !== undefined; + const positive = hasData && pnl! >= 0; + return ( +
+ + {day} + + {hasData && ( + + {positive ? '+' : '−'}${fmt(Math.abs(pnl!))} + + )} +
+ ); + })} +
+
+ ); +} + +export default function AccountPage() { + const { id } = useParams<{ id: string }>(); + const accountId = Number(id); + + const [account, setAccount] = useState(null); + const [cfg, setCfg] = useState(null); + const [firmName, setFirmName] = useState(''); + const [dailyPnL, setDailyPnL] = useState([]); + + useEffect(() => { + async function load() { + const [stateRes, firmsRes, dailyRes] = await Promise.all([ + fetch('/api/state'), + fetch('/api/firms'), + fetch(`/api/accounts/${accountId}/daily-pnl`), + ]); + const states: FirmState[] = await stateRes.json(); + const firms: FirmConfig[] = await firmsRes.json(); + const daily: DailyPnL[] = await dailyRes.json(); + + setDailyPnL(daily); + + for (const firmState of states) { + const acc = firmState.accounts.find((a) => a.id === accountId); + if (acc) { + const firmCfg = firms.find((f) => f.firm === firmState.firm); + setAccount(acc); + setFirmName(firmState.firm); + if (firmCfg) setCfg(getAccountConfig(acc.name, firmCfg) ?? null); + break; + } + } + } + + load(); + const interval = setInterval(load, 5000); + return () => clearInterval(interval); + }, [accountId]); + + if (!account) { + return ( +
+
+
+ ← Back +
+

Loading…

+
+
+ ); + } + + const hasLossLimit = cfg != null && cfg.minDayPnL !== -999; + const daysPassed = cfg != null && account.daysTraded >= cfg.minTradingDays; + // Dead when balance hits Tradovate's auto-liquidation floor + const isDead = account.autoLiqThreshold > 0 && account.amount <= account.autoLiqThreshold; + const liqFloor = account.autoLiqThreshold > 0 ? account.autoLiqThreshold : null; + + // Ground-truth profit = balance minus funded account size. + // Falls back to FIFO total when accountSize is unavailable. + const fifoTotal = dailyPnL.reduce((s, d) => s + d.pnl, 0); + const totalProfit = cfg?.accountSize + ? Math.round((account.amount - cfg.accountSize) * 100) / 100 + : Math.round(fifoTotal * 100) / 100; + const profitPct = cfg?.accountSize ? (totalProfit / cfg.accountSize) * 100 : null; + const profitPassed = cfg != null && totalProfit >= cfg.profitTarget; + const lossPassed = !hasLossLimit || (cfg != null && totalProfit >= cfg.minDayPnL); + + // Build equity curve: FIFO daily increments, origin at $0 + let running = 0; + const equityData = [ + { label: '', equity: 0, pnl: 0, origin: true, pos: 0, neg: 0 }, + ...dailyPnL.map((d) => { + running += d.pnl; + const equity = Math.round(running * 100) / 100; + return { + label: fmtDate(d.date), + equity, + pnl: d.pnl, + pos: Math.max(0, equity), // above-zero portion for green fill + neg: Math.min(0, equity), // below-zero portion for red fill + }; + }), + ]; + const isPositive = totalProfit >= 0; + + // Equity range + const equityValues = equityData.map((d) => d.equity); + // Y-axis domain: include profit target so its reference line stays visible + const rawMin = Math.min(0, ...equityValues); + const rawMax = Math.max(0, ...equityValues, cfg?.profitTarget ?? 0); + + // Stroke gradient split: use only the actual equity range, NOT the profit target. + // The SVG gradient bounding box is the line's bbox, so inflating by profitTarget + // would shift the green→red transition away from y=0. + const gradMax = Math.max(0, ...equityValues); + const gradMin = rawMin; // rawMin never includes profitTarget + const gradRange = gradMax - gradMin; + const zeroFraction = gradRange > 0 ? gradMax / gradRange : 0.5; + const zeroPct = `${(Math.max(0, Math.min(1, zeroFraction)) * 100).toFixed(2)}%`; + + const strokeGradId = 'equityStroke'; + + // Build calendar data + const pnlMap = new Map(dailyPnL.map((d) => [d.date, d.pnl])); + const calendarMonths = [...new Set(dailyPnL.map((d) => d.date.slice(0, 7)))].sort(); + + return ( +
+
+ +
+ + ← Back + + / + {firmName} + / +

{account.name}

+ {isDead && ( + + ☠ DEAD + + )} +
+ + {isDead && ( +
+ +
+

Account Blown

+

+ Balance ${fmt(account.amount)} has breached the Tradovate auto-liquidation floor + {liqFloor != null ? ` of $${fmt(liqFloor)}` : ''}. +

+
+
+ )} + +
+
+

Overall Performance

+ + = 0 ? '+' : ''}$${fmt(totalProfit)}`} + badge={profitPct != null ? `${profitPct >= 0 ? '↑' : '↓'} ${Math.abs(profitPct).toFixed(1)}%` : undefined} + positive={profitPct != null && profitPct >= 0} + /> + + +
+ +
+

Objectives

+ + + {hasLossLimit && ( + + )} +
+
+ + {/* Equity Curve */} +
+
+

Equity Curve

+ {dailyPnL.length > 0 && ( + + {isPositive ? '+' : ''}${fmt(totalProfit)} + + )} +
+ {dailyPnL.length === 0 ? ( +

No trading history available

+ ) : ( + + + + {/* Positive fill: opaque at peak, fades to transparent at zero */} + + + + + {/* Negative fill: transparent at zero, opaque at trough */} + + + + + {/* Stroke: green above zero, red below */} + + + + + + + + + + rawMin, + () => rawMax, + ]} + tickFormatter={(v) => { + const abs = Math.abs(v); + const sign = v < 0 ? '-' : ''; + return abs >= 1000 ? `${sign}$${(abs / 1000).toFixed(1)}k` : `${sign}$${v}`; + }} + tick={{ fontSize: 11, fill: '#94a3b8' }} + axisLine={false} + tickLine={false} + width={60} + /> + } + cursor={{ stroke: '#e2e8f0', strokeWidth: 1 }} + /> + + {cfg?.profitTarget != null && ( + + )} + {/* Green fill: positive equity only, fills down to y=0 */} + + {/* Red fill: negative equity only, fills up to y=0 */} + + {/* Equity line: stroke-only, green above zero / red below */} + { + if (props.payload?.origin) return ; + return ( + = 0 ? '#22c55e' : '#ef4444'} + stroke="white" + strokeWidth={1.5} + /> + ); + }} + activeDot={{ r: 5, fill: '#64748b', stroke: 'white', strokeWidth: 2 }} + /> + + + )} +
+ + {/* Daily P&L Calendar */} + {calendarMonths.length > 0 && ( +
+

Daily P&L

+
+ {calendarMonths.map((ym) => { + const [y, m] = ym.split('-').map(Number); + return ; + })} +
+
+ )} + +
+
+ ); +} diff --git a/app/api/account-configs/[id]/route.ts b/app/api/account-configs/[id]/route.ts new file mode 100644 index 0000000..bd13cbe --- /dev/null +++ b/app/api/account-configs/[id]/route.ts @@ -0,0 +1,81 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { updateAccountConfig, deleteAccountConfig } from '@/lib/db'; + +export async function PUT( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { id: idStr } = await params; + const id = parseInt(idStr, 10); + + if (isNaN(id)) { + return NextResponse.json({ error: 'Invalid id' }, { status: 400 }); + } + + const body = await req.json() as { + prefix?: string; + profitTarget?: number; + consistency?: number; + minDayPnL?: number; + minTradingDays?: number; + accountSize?: number; + maxLoss?: number; + }; + + const { prefix, profitTarget, consistency, minDayPnL, minTradingDays, accountSize, maxLoss } = body; + + if ( + typeof prefix !== 'string' || !prefix.trim() || + typeof profitTarget !== 'number' || + typeof consistency !== 'number' || + typeof minDayPnL !== 'number' || + typeof minTradingDays !== 'number' || + typeof accountSize !== 'number' + ) { + return NextResponse.json({ error: 'Invalid body' }, { status: 400 }); + } + + try { + const updated = updateAccountConfig(id, { + prefix: prefix.trim(), + profitTarget, + consistency, + minDayPnL, + minTradingDays, + accountSize, + maxLoss: maxLoss ?? 0, + }); + + if (!updated) { + return NextResponse.json({ error: 'Account config not found' }, { status: 404 }); + } + + return NextResponse.json({ success: true }); + } catch (err) { + console.error('[PUT /api/account-configs/:id]', err); + return NextResponse.json({ error: 'Failed to update' }, { status: 500 }); + } +} + +export async function DELETE( + _req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { id: idStr } = await params; + const id = parseInt(idStr, 10); + + if (isNaN(id)) { + return NextResponse.json({ error: 'Invalid id' }, { status: 400 }); + } + + try { + const deleted = deleteAccountConfig(id); + if (!deleted) { + return NextResponse.json({ error: 'Account config not found' }, { status: 404 }); + } + return NextResponse.json({ success: true }); + } catch (err) { + console.error('[DELETE /api/account-configs/:id]', err); + return NextResponse.json({ error: 'Failed to delete' }, { status: 500 }); + } +} diff --git a/app/api/accounts/[id]/daily-pnl/route.ts b/app/api/accounts/[id]/daily-pnl/route.ts new file mode 100644 index 0000000..369bdfd --- /dev/null +++ b/app/api/accounts/[id]/daily-pnl/route.ts @@ -0,0 +1,17 @@ +import { NextResponse } from 'next/server'; +import { getClients } from '@/lib/clients'; + +export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const accountId = Number(id); + + const clients = getClients(); + for (const client of clients.values()) { + const daily = client.dailyPnL?.[accountId]; + if (daily !== undefined) { + return NextResponse.json(daily); + } + } + + return NextResponse.json([]); +} diff --git a/app/api/firms/[id]/accounts/route.ts b/app/api/firms/[id]/accounts/route.ts new file mode 100644 index 0000000..07acf41 --- /dev/null +++ b/app/api/firms/[id]/accounts/route.ts @@ -0,0 +1,67 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getFirmById, createAccountConfig } from '@/lib/db'; + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { id: idStr } = await params; + const firmId = parseInt(idStr, 10); + + if (isNaN(firmId)) { + return NextResponse.json({ error: 'Invalid firm id' }, { status: 400 }); + } + + if (!getFirmById(firmId)) { + return NextResponse.json({ error: 'Firm not found' }, { status: 404 }); + } + + const body = await req.json() as { + prefix?: string; + profitTarget?: number; + consistency?: number; + minDayPnL?: number; + minTradingDays?: number; + accountSize?: number; + maxLoss?: number; + }; + + const { prefix, profitTarget, consistency, minDayPnL, minTradingDays, accountSize, maxLoss } = body; + + if ( + typeof prefix !== 'string' || !prefix.trim() || + typeof profitTarget !== 'number' || + typeof consistency !== 'number' || + typeof minDayPnL !== 'number' || + typeof minTradingDays !== 'number' || + typeof accountSize !== 'number' + ) { + return NextResponse.json({ error: 'Invalid body' }, { status: 400 }); + } + + try { + const row = createAccountConfig(firmId, { + prefix: prefix.trim(), + profitTarget, + consistency, + minDayPnL, + minTradingDays, + accountSize, + maxLoss: maxLoss ?? 0, + }); + + return NextResponse.json({ + id: row.id, + prefix: row.prefix, + profitTarget: row.profit_target, + consistency: row.consistency, + minDayPnL: row.min_day_pnl, + minTradingDays: row.min_trading_days, + accountSize: row.account_size, + maxLoss: row.max_loss, + }, { status: 201 }); + } catch (err) { + console.error('[POST /api/firms/:id/accounts]', err); + return NextResponse.json({ error: 'Failed to create account type' }, { status: 500 }); + } +} diff --git a/app/api/firms/[id]/instrument-configs/[instrumentId]/route.ts b/app/api/firms/[id]/instrument-configs/[instrumentId]/route.ts new file mode 100644 index 0000000..fcd686e --- /dev/null +++ b/app/api/firms/[id]/instrument-configs/[instrumentId]/route.ts @@ -0,0 +1,41 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { upsertFirmInstrumentConfig } from '@/lib/db'; + +export async function PUT( + req: NextRequest, + { params }: { params: Promise<{ id: string; instrumentId: string }> } +) { + const { id: idStr, instrumentId: instrIdStr } = await params; + const firmId = parseInt(idStr, 10); + const instrumentId = parseInt(instrIdStr, 10); + + if (isNaN(firmId) || isNaN(instrumentId)) { + return NextResponse.json({ error: 'Invalid id' }, { status: 400 }); + } + + const body = await req.json() as { + allinFee?: number; + roundtripFee?: number; + banned?: boolean; + }; + + if ( + typeof body.allinFee !== 'number' || + typeof body.roundtripFee !== 'number' || + typeof body.banned !== 'boolean' + ) { + return NextResponse.json({ error: 'Invalid body' }, { status: 400 }); + } + + try { + upsertFirmInstrumentConfig(firmId, instrumentId, { + allinFee: body.allinFee, + roundtripFee: body.roundtripFee, + banned: body.banned, + }); + return NextResponse.json({ success: true }); + } catch (err) { + console.error('[PUT /api/firms/:id/instrument-configs/:instrumentId]', err); + return NextResponse.json({ error: 'Failed to save config' }, { status: 500 }); + } +} diff --git a/app/api/firms/[id]/instrument-configs/route.ts b/app/api/firms/[id]/instrument-configs/route.ts new file mode 100644 index 0000000..eedcbaf --- /dev/null +++ b/app/api/firms/[id]/instrument-configs/route.ts @@ -0,0 +1,21 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getFirmFees } from '@/lib/db'; + +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { id: idStr } = await params; + const firmId = parseInt(idStr, 10); + + if (isNaN(firmId)) { + return NextResponse.json({ error: 'Invalid firm id' }, { status: 400 }); + } + + try { + return NextResponse.json(getFirmFees(firmId)); + } catch (err) { + console.error('[GET /api/firms/:id/instrument-configs]', err); + return NextResponse.json({ error: 'Failed to fetch fees' }, { status: 500 }); + } +} diff --git a/app/api/firms/[id]/route.ts b/app/api/firms/[id]/route.ts index f49ed90..dcb6f2d 100644 --- a/app/api/firms/[id]/route.ts +++ b/app/api/firms/[id]/route.ts @@ -1,7 +1,40 @@ import { NextRequest, NextResponse } from 'next/server'; -import { deleteFirm } from '@/lib/db'; +import { getFirmById, deleteFirm } from '@/lib/db'; import { removeClient } from '@/lib/clients'; +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { id: idStr } = await params; + const id = parseInt(idStr, 10); + + if (isNaN(id)) { + return NextResponse.json({ error: 'Invalid id' }, { status: 400 }); + } + + const firm = getFirmById(id); + if (!firm) { + return NextResponse.json({ error: 'Firm not found' }, { status: 404 }); + } + + return NextResponse.json({ + id: firm.id, + firm: firm.name, + username: firm.username, + password: firm.password, + accounts: firm.accounts.map((a) => ({ + id: a.id, + prefix: a.prefix, + profitTarget: a.profit_target, + consistency: a.consistency, + minDayPnL: a.min_day_pnl, + minTradingDays: a.min_trading_days, + accountSize: a.account_size, + })), + }); +} + export async function DELETE( _req: NextRequest, { params }: { params: Promise<{ id: string }> } diff --git a/app/api/firms/route.ts b/app/api/firms/route.ts index c059d74..ba09e29 100644 --- a/app/api/firms/route.ts +++ b/app/api/firms/route.ts @@ -16,6 +16,8 @@ export async function GET() { consistency: a.consistency, minDayPnL: a.min_day_pnl, minTradingDays: a.min_trading_days, + accountSize: a.account_size, + maxLoss: a.max_loss, })), })); return NextResponse.json(result); diff --git a/app/api/instruments/[symbol]/route.ts b/app/api/instruments/[symbol]/route.ts new file mode 100644 index 0000000..21daafe --- /dev/null +++ b/app/api/instruments/[symbol]/route.ts @@ -0,0 +1,13 @@ +import { NextResponse } from 'next/server'; +import { setInstrumentEnabled } from '@/lib/db'; + +export async function PATCH( + request: Request, + { params }: { params: Promise<{ symbol: string }> } +) { + const { symbol } = await params; + const body = await request.json(); + const ok = setInstrumentEnabled(symbol, !!body.enabled); + if (!ok) return NextResponse.json({ error: 'Not found' }, { status: 404 }); + return NextResponse.json({ symbol, enabled: !!body.enabled }); +} diff --git a/app/api/instruments/route.ts b/app/api/instruments/route.ts new file mode 100644 index 0000000..89db4f2 --- /dev/null +++ b/app/api/instruments/route.ts @@ -0,0 +1,6 @@ +import { NextResponse } from 'next/server'; +import { getInstruments } from '@/lib/db'; + +export function GET() { + return NextResponse.json(getInstruments()); +} diff --git a/app/api/state/route.ts b/app/api/state/route.ts index ef6e4c2..4021a04 100644 --- a/app/api/state/route.ts +++ b/app/api/state/route.ts @@ -22,6 +22,7 @@ export async function GET() { realizedPnL: cash.realizedPnL, daysTraded: client.daysTraded[acc.id] ?? 0, hasPosition: !!client.positions[acc.id], + autoLiqThreshold: client.autoLiqThresholds[acc.id] ?? 0, }; }); return { firm: f.name, connected: true, accounts }; diff --git a/app/firms/[id]/settings/page.tsx b/app/firms/[id]/settings/page.tsx new file mode 100644 index 0000000..227c45f --- /dev/null +++ b/app/firms/[id]/settings/page.tsx @@ -0,0 +1,465 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter, useParams } from 'next/navigation'; + +const SIZE_PRESETS = [5_000, 10_000, 25_000, 50_000, 75_000, 100_000, 150_000]; + +interface FirmFee { + firmId: number; + symbol: string; + allinFee: number; + roundtripFee: number; +} + +interface AccountConfig { + id: number; + prefix: string; + profitTarget: number; + consistency: number; + minDayPnL: number; + minTradingDays: number; + accountSize: number; +} + +interface FirmConfig { + id: number; + firm: string; + accounts: AccountConfig[]; +} + +interface RowState extends AccountConfig { + dirty: boolean; + saving: boolean; + error: string; + useCustomSize: boolean; + confirmDelete: boolean; +} + +function initRow(acc: AccountConfig): RowState { + return { + ...acc, + dirty: false, + saving: false, + error: '', + useCustomSize: !SIZE_PRESETS.includes(acc.accountSize), + confirmDelete: false, + }; +} + +function newRow(): RowState { + return { + id: -(Date.now()), + prefix: '', + profitTarget: 3000, + consistency: 0.5, + minDayPnL: -999, + minTradingDays: 5, + accountSize: 50_000, + dirty: true, + saving: false, + error: '', + useCustomSize: false, + confirmDelete: false, + }; +} + +function fmtSize(n: number) { + return n >= 1000 ? `${n / 1000}k` : String(n); +} + +function TrashIcon() { + return ( + + + + + + + ); +} + +const inputCls = + 'border border-slate-200 rounded-md px-2 py-1.5 text-sm text-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-400 bg-white w-full'; + +export default function FirmSettingsPage() { + const router = useRouter(); + const params = useParams(); + const id = params.id as string; + + const [firmName, setFirmName] = useState(''); + const [rows, setRows] = useState([]); + const [fees, setFees] = useState([]); + const [loadError, setLoadError] = useState(''); + + useEffect(() => { + fetch(`/api/firms/${id}`) + .then((r) => { + if (!r.ok) throw new Error('Not found'); + return r.json() as Promise; + }) + .then((firm) => { + setFirmName(firm.firm); + setRows(firm.accounts.map(initRow)); + }) + .catch(() => setLoadError('Failed to load firm')); + + fetch(`/api/firms/${id}/instrument-configs`) + .then((r) => r.json() as Promise) + .then(setFees) + .catch(() => {}); + }, [id]); + + const updateRow = (rowId: number, patch: Partial) => { + setRows((prev) => + prev.map((r) => (r.id === rowId ? { ...r, ...patch, dirty: true } : r)) + ); + }; + + const saveRow = async (row: RowState) => { + setRows((prev) => + prev.map((r) => (r.id === row.id ? { ...r, saving: true, error: '' } : r)) + ); + try { + const body = { + prefix: row.prefix, + profitTarget: row.profitTarget, + consistency: row.consistency, + minDayPnL: row.minDayPnL, + minTradingDays: row.minTradingDays, + accountSize: row.accountSize, + }; + + if (row.id < 0) { + const res = await fetch(`/api/firms/${id}/accounts`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!res.ok) throw new Error('Failed'); + const created = await res.json() as AccountConfig; + setRows((prev) => + prev.map((r) => + r.id === row.id ? { ...initRow(created), dirty: false } : r + ) + ); + } else { + const res = await fetch(`/api/account-configs/${row.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!res.ok) throw new Error('Failed'); + setRows((prev) => + prev.map((r) => + r.id === row.id ? { ...r, saving: false, dirty: false, error: '' } : r + ) + ); + } + } catch { + setRows((prev) => + prev.map((r) => + r.id === row.id ? { ...r, saving: false, error: 'Save failed' } : r + ) + ); + } + }; + + const confirmDeleteRow = (rowId: number) => { + setRows((prev) => + prev.map((r) => (r.id === rowId ? { ...r, confirmDelete: true } : r)) + ); + }; + + const cancelDeleteRow = (rowId: number) => { + setRows((prev) => + prev.map((r) => (r.id === rowId ? { ...r, confirmDelete: false } : r)) + ); + }; + + const deleteRow = async (row: RowState) => { + if (row.id < 0) { + setRows((prev) => prev.filter((r) => r.id !== row.id)); + return; + } + try { + const res = await fetch(`/api/account-configs/${row.id}`, { method: 'DELETE' }); + if (!res.ok) throw new Error('Failed'); + setRows((prev) => prev.filter((r) => r.id !== row.id)); + } catch { + setRows((prev) => + prev.map((r) => + r.id === row.id ? { ...r, confirmDelete: false, error: 'Delete failed' } : r + ) + ); + } + }; + + if (loadError) { + return ( +
+
+

{loadError}

+
+
+ ); + } + + return ( +
+
+ + {/* Header */} +
+ +

{firmName || '…'}

+ / + Settings +
+ + {/* Account Types */} +

+ Account Types +

+
+ + + + + + + + + + + + + {rows.length === 0 && firmName && ( + + + + )} + + {rows.map((row) => ( + + + {/* Prefix */} + + + {/* Account Size */} + + + {/* Profit Target */} + + + {/* Consistency */} + + + {/* Min Day P&L */} + + + {/* Min Trading Days */} + + + {/* Actions */} + + + ))} + + {/* Add row */} + + + + +
PrefixAccount SizeProfit TargetConsistencyMin Day P&LMin Trading Days +
+ No account types yet +
+ updateRow(row.id, { prefix: e.target.value })} + /> + +
+ + {row.useCustomSize && ( + + updateRow(row.id, { accountSize: Number(e.target.value) }) + } + /> + )} +
+
+
+ $ + + updateRow(row.id, { profitTarget: Number(e.target.value) }) + } + /> +
+
+
+ + updateRow(row.id, { consistency: Number(e.target.value) / 100 }) + } + /> + % +
+
+
+ $ + + updateRow(row.id, { + minDayPnL: e.target.value === '' ? -999 : Number(e.target.value), + }) + } + /> +
+
+ + updateRow(row.id, { minTradingDays: Number(e.target.value) }) + } + /> + +
+ {row.error && ( + {row.error} + )} + + {row.confirmDelete ? ( + <> + Delete? + + + + ) : ( + <> + + + + )} +
+
+ +
+
+ + {/* Fees */} +

+ Fees +

+
+ {fees.length === 0 ? ( +
+ No fees loaded yet +
+ ) : ( + + + + + + + + + + {fees.map((fee) => ( + + + + + + ))} + +
SymbolAll-In FeeRoundtrip Fee
{fee.symbol}${fee.allinFee.toFixed(4)}${fee.roundtripFee.toFixed(4)}
+ )} +
+ +
+
+ ); +} diff --git a/app/page.tsx b/app/page.tsx index e67407b..c517148 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; +import Link from 'next/link'; import type { FirmConfig, FirmState, AccountState, AccountConfig } from '@/types'; function getAccountConfig(name: string, firm: FirmConfig): AccountConfig | undefined { @@ -14,6 +15,11 @@ function fmt(value: number) { return value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); } +/** Returns true when an account's balance has breached Tradovate's auto-liquidation floor. */ +function isAccountDead(account: AccountState): boolean { + return account.autoLiqThreshold > 0 && account.amount <= account.autoLiqThreshold; +} + function SettingsIcon() { return ( @@ -31,12 +37,26 @@ function ChevronIcon({ open }: { open: boolean }) { ); } -function AccountRow({ account, firm }: { account: AccountState; firm: FirmConfig }) { +function DetailsIcon() { + return ( + + + + + + ); +} + +function AccountRow({ account, firm, hideDead }: { account: AccountState; firm: FirmConfig; hideDead: boolean }) { const cfg = getAccountConfig(account.name, firm); + const dead = isAccountDead(account); + + if (hideDead && dead) return null; + const pnlColor = account.realizedPnL > 0 ? 'text-green-600 font-medium' : account.realizedPnL < 0 ? 'text-red-600 font-medium' : 'text-slate-400'; return ( -
+
{account.name} @@ -53,22 +73,35 @@ function AccountRow({ account, firm }: { account: AccountState; firm: FirmConfig ${cfg?.profitTarget?.toLocaleString() ?? '—'} - {!account.active - ? Inactive - : account.hasPosition - ? In Trade - : Flat} +
+ {dead + ? Dead + : !account.active + ? Inactive + : account.hasPosition + ? In Trade + : Flat} + e.stopPropagation()} + > + + +
); } -function FirmRows({ state, firm, deleteMode, selected, onToggle }: { +function FirmRows({ state, firm, deleteMode, selected, onToggle, hideDead }: { state: FirmState; firm: FirmConfig; deleteMode: boolean; selected: boolean; onToggle: () => void; + hideDead: boolean; }) { const [open, setOpen] = useState(true); @@ -95,13 +128,14 @@ function FirmRows({ state, firm, deleteMode, selected, onToggle }: {
- +
@@ -116,7 +150,7 @@ function FirmRows({ state, firm, deleteMode, selected, onToggle }: { ) : ( state.accounts.map((acc) => ( - + )) ) )} @@ -130,6 +164,7 @@ export default function Home() { const [firms, setFirms] = useState([]); const [deleteMode, setDeleteMode] = useState(false); const [selected, setSelected] = useState>(new Set()); + const [hideDead, setHideDead] = useState(false); const fetchConfig = async () => { try { @@ -163,6 +198,13 @@ export default function Home() { return () => clearInterval(interval); }, []); + // 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); + if (!firmCfg) return total; + return total + firmState.accounts.filter((acc) => isAccountDead(acc)).length; + }, 0); + const handleConfirmDelete = async () => { await Promise.all( [...selected].map((id) => @@ -208,6 +250,18 @@ export default function Home() { ) : ( <> + {deadCount > 0 && ( + + )} + + + )}
@@ -254,6 +315,7 @@ export default function Home() { deleteMode={deleteMode} selected={selected.has(cfg.id)} onToggle={() => toggleSelected(cfg.id)} + hideDead={hideDead} /> ); }) diff --git a/app/settings/page.tsx b/app/settings/page.tsx new file mode 100644 index 0000000..5036781 --- /dev/null +++ b/app/settings/page.tsx @@ -0,0 +1,89 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import Link from 'next/link'; + +interface Instrument { + symbol: string; + enabled: boolean; +} + +export default function SettingsPage() { + const [instruments, setInstruments] = useState([]); + + useEffect(() => { + fetch('/api/instruments') + .then((r) => r.json()) + .then(setInstruments); + }, []); + + async function toggle(symbol: string, enabled: boolean) { + setInstruments((prev) => + prev.map((i) => (i.symbol === symbol ? { ...i, enabled } : i)) + ); + await fetch(`/api/instruments/${symbol}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled }), + }); + } + + return ( +
+
+ +
+ + ← Back + +

Settings

+
+ +

+ Instruments +

+ +
+ {instruments.length === 0 ? ( +
+ Loading… +
+ ) : ( + + + + + + + + + {instruments.map((instr) => ( + + + + + ))} + +
SymbolEnabled
{instr.symbol} + +
+ )} +
+
+
+ ); +} diff --git a/lib/clients.ts b/lib/clients.ts index e31ceee..9e1bd9d 100644 --- a/lib/clients.ts +++ b/lib/clients.ts @@ -1,5 +1,7 @@ import { TradovateClient } from './tradovate-class'; -import { getFirms } from './db'; +import { getFirms, upsertFirmFee } from './db'; + +const SYMBOLS = ['NQ', 'MNQ', 'ES', 'MES', 'YM', 'MYM', 'RTY', 'M2K', 'GC', 'MGC', 'SI', 'CL', 'MCL', 'NG', 'ZB', 'ZN', 'ZF', '6E', '6J', '6B']; // Use global to persist the client pool across HMR reloads in dev mode const g = global as typeof globalThis & { @@ -16,8 +18,22 @@ function ensureMap(): Map { export function initClient(id: number, username: string, password: string, firmName: string): TradovateClient { const map = ensureMap(); + let feesInitialized = false; const client = new TradovateClient(username, password, async () => { console.log(`[${firmName}] sync complete — ${client.accountList.length} account(s)`); + if (!feesInitialized) { + feesInitialized = true; + try { + const fees = await client.fetchInstrumentFees(SYMBOLS); + for (const [symbol, fee] of Object.entries(fees)) { + upsertFirmFee(id, symbol, fee, parseFloat((fee * 2).toFixed(4))); + } + const count = Object.keys(fees).length; + if (count > 0) console.log(`[${firmName}] Auto-fetched fees for ${count} symbol(s)`); + } catch (err) { + console.error(`[${firmName}] Failed to auto-fetch fees`, err); + } + } }); map.set(id, client); return client; diff --git a/lib/db.ts b/lib/db.ts index ee42fc4..cd3b532 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -22,29 +22,59 @@ db.exec(` min_day_pnl REAL NOT NULL DEFAULT -999, min_trading_days INTEGER NOT NULL DEFAULT 5 ); + + CREATE TABLE IF NOT EXISTS firm_fees ( + firm_id INTEGER NOT NULL REFERENCES firms(id) ON DELETE CASCADE, + symbol TEXT NOT NULL, + allin_fee REAL NOT NULL DEFAULT 0, + roundtrip_fee REAL NOT NULL DEFAULT 0, + PRIMARY KEY (firm_id, symbol) + ); + + CREATE TABLE IF NOT EXISTS instruments ( + symbol TEXT PRIMARY KEY, + enabled INTEGER NOT NULL DEFAULT 1 + ); `); -// Seed default data if empty +// Migration: add account_size column if it doesn't exist yet +try { + db.exec('ALTER TABLE account_configs ADD COLUMN account_size REAL NOT NULL DEFAULT 50000'); +} catch { + // Column already exists +} + +// Migration: add max_loss column (0 = no max loss limit) +try { + db.exec('ALTER TABLE account_configs ADD COLUMN max_loss REAL NOT NULL DEFAULT 0'); +} catch { + // Column already exists +} + +// Seed default firms if empty const firmCount = (db.prepare('SELECT COUNT(*) as count FROM firms').get() as { count: number }).count; if (firmCount === 0) { const insertFirm = db.prepare('INSERT INTO firms (name, username, password) VALUES (?, ?, ?)'); const insertAccount = db.prepare( - 'INSERT INTO account_configs (firm_id, prefix, profit_target, consistency, min_day_pnl, min_trading_days) VALUES (?, ?, ?, ?, ?, ?)' + 'INSERT INTO account_configs (firm_id, prefix, profit_target, consistency, min_day_pnl, min_trading_days, account_size) VALUES (?, ?, ?, ?, ?, ?, ?)' ); const alpha = insertFirm.run('Alpha', 'brandonsenoli72786', '-Z2kPm7nBg'); - insertAccount.run(alpha.lastInsertRowid, 'AFSTDEV', 9000, 0.51, -999, 5); - insertAccount.run(alpha.lastInsertRowid, 'AFSTDQA', 4500, 0.40, -999, 7); - insertAccount.run(alpha.lastInsertRowid, 'AFZEROEV', 3000, 0.50, -999, 5); - insertAccount.run(alpha.lastInsertRowid, 'AFZEROQA', 3000, 0.50, -999, 5); - insertAccount.run(alpha.lastInsertRowid, 'AF', 3000, 0.50, -999, 5); + insertAccount.run(alpha.lastInsertRowid, 'AFSTDEV', 9000, 0.51, -999, 5, 150000); + insertAccount.run(alpha.lastInsertRowid, 'AFSTDQA', 4500, 0.40, -999, 7, 150000); + insertAccount.run(alpha.lastInsertRowid, 'AFZEROEV', 3000, 0.50, -999, 5, 100000); + insertAccount.run(alpha.lastInsertRowid, 'AFZEROQA', 3000, 0.50, -999, 5, 100000); + insertAccount.run(alpha.lastInsertRowid, 'AF', 3000, 0.50, -999, 5, 100000); const tpt = insertFirm.run('TakeProfitTrader', 'BRANDONLI1', 'W4592F5512U2817tv='); - insertAccount.run(tpt.lastInsertRowid, 'TAKEPROFIT', 9000, 0.50, -999, 5); + insertAccount.run(tpt.lastInsertRowid, 'TAKEPROFIT', 9000, 0.50, -999, 5, 150000); console.log('[db] Seeded default firms.'); } + +// ── Interfaces ───────────────────────────────────────────────────────────── + export interface AccountConfigRow { id: number; firm_id: number; @@ -53,6 +83,8 @@ export interface AccountConfigRow { consistency: number; min_day_pnl: number; min_trading_days: number; + account_size: number; + max_loss: number; } export interface FirmRow { @@ -66,6 +98,15 @@ export interface FirmWithAccounts extends FirmRow { accounts: AccountConfigRow[]; } +export interface FirmFee { + firmId: number; + symbol: string; + allinFee: number; + roundtripFee: number; +} + +// ── Firms ─────────────────────────────────────────────────────────────────── + export function getFirms(): FirmWithAccounts[] { const firms = db.prepare('SELECT * FROM firms ORDER BY id').all() as FirmRow[]; const getAccounts = db.prepare('SELECT * FROM account_configs WHERE firm_id = ? ORDER BY id'); @@ -75,6 +116,13 @@ export function getFirms(): FirmWithAccounts[] { })); } +export function getFirmById(id: number): FirmWithAccounts | undefined { + const firm = db.prepare('SELECT * FROM firms WHERE id = ?').get(id) as FirmRow | undefined; + if (!firm) return undefined; + const accounts = db.prepare('SELECT * FROM account_configs WHERE firm_id = ? ORDER BY id').all(id) as AccountConfigRow[]; + return { ...firm, accounts }; +} + export function createFirm(name: string, username: string, password: string): FirmRow { const stmt = db.prepare('INSERT INTO firms (name, username, password) VALUES (?, ?, ?)'); const result = stmt.run(name, username, password); @@ -85,3 +133,95 @@ export function deleteFirm(id: number): boolean { const result = db.prepare('DELETE FROM firms WHERE id = ?').run(id); return result.changes > 0; } + +// ── Account Configs ───────────────────────────────────────────────────────── + +export function createAccountConfig(firmId: number, data: { + prefix: string; + profitTarget: number; + consistency: number; + minDayPnL: number; + minTradingDays: number; + accountSize: number; + maxLoss: number; +}): AccountConfigRow { + const stmt = db.prepare( + 'INSERT INTO account_configs (firm_id, prefix, profit_target, consistency, min_day_pnl, min_trading_days, account_size, max_loss) VALUES (?, ?, ?, ?, ?, ?, ?, ?)' + ); + const result = stmt.run(firmId, data.prefix, data.profitTarget, data.consistency, data.minDayPnL, data.minTradingDays, data.accountSize, data.maxLoss); + return db.prepare('SELECT * FROM account_configs WHERE id = ?').get(result.lastInsertRowid) as AccountConfigRow; +} + +export function deleteAccountConfig(id: number): boolean { + const result = db.prepare('DELETE FROM account_configs WHERE id = ?').run(id); + return result.changes > 0; +} + +export function updateAccountConfig(id: number, data: { + prefix: string; + profitTarget: number; + consistency: number; + minDayPnL: number; + minTradingDays: number; + accountSize: number; + maxLoss: number; +}): boolean { + const result = db.prepare(` + UPDATE account_configs + SET prefix = ?, profit_target = ?, consistency = ?, min_day_pnl = ?, min_trading_days = ?, account_size = ?, max_loss = ? + WHERE id = ? + `).run(data.prefix, data.profitTarget, data.consistency, data.minDayPnL, data.minTradingDays, data.accountSize, data.maxLoss, id); + return result.changes > 0; +} + +// ── Firm Fees ──────────────────────────────────────────────────────────────── + +export function getFirmFees(firmId: number): FirmFee[] { + return (db.prepare('SELECT firm_id, symbol, allin_fee, roundtrip_fee FROM firm_fees WHERE firm_id = ? ORDER BY symbol').all(firmId) as { + firm_id: number; + symbol: string; + allin_fee: number; + roundtrip_fee: number; + }[]).map((r) => ({ + firmId: r.firm_id, + symbol: r.symbol, + allinFee: r.allin_fee, + roundtripFee: r.roundtrip_fee, + })); +} + +export function upsertFirmFee(firmId: number, symbol: string, allinFee: number, roundtripFee: number): void { + db.prepare(` + INSERT INTO firm_fees (firm_id, symbol, allin_fee, roundtrip_fee) + VALUES (?, ?, ?, ?) + ON CONFLICT(firm_id, symbol) DO UPDATE SET + allin_fee = excluded.allin_fee, + roundtrip_fee = excluded.roundtrip_fee + `).run(firmId, symbol, allinFee, roundtripFee); +} + +// ── Instruments ────────────────────────────────────────────────────────────── + +const SYMBOLS = ['NQ','MNQ','ES','MES','YM','MYM','RTY','M2K','GC','MGC','SI','CL','MCL','NG','ZB','ZN','ZF','6E','6J','6B']; + +// Seed instruments table if empty +const instrCount = (db.prepare('SELECT COUNT(*) as count FROM instruments').get() as { count: number }).count; +if (instrCount === 0) { + const ins = db.prepare('INSERT INTO instruments (symbol, enabled) VALUES (?, 1)'); + for (const s of SYMBOLS) ins.run(s); +} + +export interface InstrumentRow { + symbol: string; + enabled: boolean; +} + +export function getInstruments(): InstrumentRow[] { + return (db.prepare('SELECT symbol, enabled FROM instruments ORDER BY symbol').all() as { symbol: string; enabled: number }[]) + .map((r) => ({ symbol: r.symbol, enabled: r.enabled === 1 })); +} + +export function setInstrumentEnabled(symbol: string, enabled: boolean): boolean { + const result = db.prepare('UPDATE instruments SET enabled = ? WHERE symbol = ?').run(enabled ? 1 : 0, symbol); + return result.changes > 0; +} diff --git a/lib/tradovate-class.ts b/lib/tradovate-class.ts index 3049d70..01c1a82 100644 --- a/lib/tradovate-class.ts +++ b/lib/tradovate-class.ts @@ -27,6 +27,11 @@ export class TradovateClient { } = {}; public daysTraded: { [accountId: number]: number } = {}; + public dailyPnL: { [accountId: number]: { date: string; pnl: number }[] } = {}; + /** Balance floor set by the prop firm — account is blown when amount <= this value (0 = not set) */ + public autoLiqThresholds: { [accountId: number]: number } = {}; + + public products: { id: number; name: string }[] = []; private ws: WebSocket; private callbackOnSyncRequest: () => Promise; @@ -52,7 +57,12 @@ export class TradovateClient { callback: (response: any) => void; }[] = []; - constructor(name: string, password: string, callbackOnSyncRequest: () => Promise) { + + constructor( + name: string, + password: string, + callbackOnSyncRequest: () => Promise + ) { this.name = name; this.password = password; this.callbackOnSyncRequest = callbackOnSyncRequest; @@ -85,9 +95,8 @@ export class TradovateClient { console.log('Connected to websocket'); this.ws.send('authorize\n2\n\n' + this.accessInfo.accessToken); this.directEventCallbacks[2] = (response: any) => { - // Once authorize, start syncing every 60 seconds - this.requestAccountUpdates(); - setInterval(() => this.requestAccountUpdates(), 60000); + this.requestSync(); + setInterval(() => this.requestSync(), 60000); // Every 2.5 seconds send a heartbeat setInterval(() => { @@ -156,79 +165,94 @@ export class TradovateClient { }); } - private async requestAccountUpdates(): Promise { + private requestSync(): void { this.directEventCallbacks[3] = (response: any) => { - - // Syncing DLL or MLL hit - const riskStatusById: { [id: number]: { liquidateOnly?: string } } = ( - response.accountRiskStatuses || [] - ).reduce((acc: any, item: any) => { - acc[item.id] = item; - return acc; - }, {}); - - this.accountList = (response.accounts as AccountItem[]).map((account) => { - if (riskStatusById[account.id]?.liquidateOnly) { - return { ...account, active: false }; + try { + if (!response) { + console.error('[requestSync] Received null/undefined response — auth may have failed'); + return; } - return account; - }); - this.accountCashBalances = response.cashBalances.reduce( - ( - acc: { - [accountId: number]: { amount: number; realizedPnL: number }; - }, - item: { accountId: number; amount: number; realizedPnL: number } - ) => { - acc[item.accountId] = { - amount: item.amount, - realizedPnL: item.realizedPnL, - }; + // liquidateOnly flag lives in accountRiskStatuses + const riskStatusById: { [accountId: number]: { liquidateOnly?: string } } = ( + response.accountRiskStatuses || [] + ).reduce((acc: any, item: any) => { + const key = item.accountId ?? item.id; + acc[key] = item; return acc; - }, - {} as { [accountId: number]: { amount: number; realizedPnL: number } } - ); - this.positions = response.positions - .filter((item) => item.netPos !== 0) - .reduce( + }, {}); + + // Auto-liquidation balance floor lives in userAccountAutoLiqs. + // item.id IS the account ID. The floor is: trailingMaxDrawdownLimit - trailingMaxDrawdown. + // Tradovate uses 999999999 as a sentinel for "no limit" — skip those. + for (const item of (response.userAccountAutoLiqs ?? [])) { + const accountId: number = item.id; + const limit: number = item.trailingMaxDrawdownLimit ?? 0; + const drawdown: number = item.trailingMaxDrawdown ?? 0; + const isSentinel = limit >= 999999999; + const floor = (!isSentinel && limit > 0 && drawdown > 0) ? limit - drawdown : 0; + this.autoLiqThresholds[accountId] = floor; + if (floor > 0) { + console.log(`[autoLiq] account ${accountId} → floor $${floor} (hwm=$${limit} drawdown=$${drawdown})`); + } + } + + this.accountList = ((response.accounts ?? []) as AccountItem[]).map((account) => { + if (riskStatusById[account.id]?.liquidateOnly) { + return { ...account, active: false }; + } + return account; + }); + + this.accountCashBalances = (response.cashBalances ?? []).reduce( ( - acc: { - [accountId: number]: { - contractId: number; - netPos: number; - netPrice: number; - timestamp: Date; - }; - }, - item: { - accountId: number; - contractId: number; - netPos: number; - netPrice: number; - timestamp: Date; - } + acc: { [accountId: number]: { amount: number; realizedPnL: number } }, + item: { accountId: number; amount: number; realizedPnL: number } ) => { - acc[item.accountId] = { - contractId: item.contractId, - netPos: item.netPos, - netPrice: item.netPrice, - timestamp: new Date(item.timestamp), - }; + acc[item.accountId] = { amount: item.amount, realizedPnL: item.realizedPnL }; return acc; }, - {} as { - [accountId: number]: { - contractId: number; - netPos: number; - netPrice: number; - timestamp: Date; - }; - } + {} as { [accountId: number]: { amount: number; realizedPnL: number } } ); - this.callbackOnSyncRequest(); - this.fetchDaysTraded(); + this.positions = (response.positions ?? []) + .filter((item: any) => item.netPos !== 0) + .reduce( + ( + acc: { [accountId: number]: { contractId: number; netPos: number; netPrice: number; timestamp: Date } }, + item: { accountId: number; contractId: number; netPos: number; netPrice: number; timestamp: Date } + ) => { + acc[item.accountId] = { + contractId: item.contractId, + netPos: item.netPos, + netPrice: item.netPrice, + timestamp: new Date(item.timestamp), + }; + return acc; + }, + {} as { [accountId: number]: { contractId: number; netPos: number; netPrice: number; timestamp: Date } } + ); + + console.log(`[requestSync] ${this.accountList.length} account(s), ${Object.keys(this.accountCashBalances).length} balance(s)`); + + this.fetchDaysTraded(); + + if (this.products.length > 0) { + this.callbackOnSyncRequest(); + return; + } + + this.directEventCallbacks[30] = (products: any) => { + if (Array.isArray(products) && products.length > 0) { + this.products = products.map((p: any) => ({ id: p.id, name: p.name })); + console.log(`Loaded ${this.products.length} products`); + } + this.callbackOnSyncRequest(); + }; + this.ws.send('product/list\n30\n\n'); + } catch (err) { + console.error('[requestSync] Error processing sync response:', err); + } }; this.ws.send('user/syncrequest\n3\n\n{"splitResponses":false}'); @@ -237,25 +261,111 @@ export class TradovateClient { private async fetchDaysTraded(): Promise { if (!this.accessInfo?.accessToken) return; - const cutoff = new Date(); - cutoff.setDate(cutoff.getDate() - 28); + const now = new Date(); + const start = new Date(); + start.setDate(start.getDate() - 28); + + const fmtDate = (d: Date) => { + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${m}/${day}/${d.getFullYear()}`; + }; for (const account of this.accountList) { try { - const res = await axios.get( - `https://demo.tradovateapi.com/v1/fill/ldeps?masterid=${account.id}`, + const res = await axios.post( + 'https://rpt-demo.tradovateapi.com/v1/reports/requestreport', + { + name: 'Fills', + params: [ + { name: 'startDate', value: fmtDate(start) }, + { name: 'endDate', value: fmtDate(now) }, + { name: 'startTime', value: '00:00:00' }, + { name: 'endTime', value: '00:00:00' }, + { name: 'account', value: account.name }, + ], + representationType: 'json', + timezone: 0, + }, { headers: { Authorization: `Bearer ${this.accessInfo.accessToken}` } } ); - const fills: { timestamp: string }[] = res.data ?? []; - const tradingDays = new Set( - fills - .filter((f) => new Date(f.timestamp) >= cutoff) - .map((f) => new Date(f.timestamp).toDateString()) - ); - this.daysTraded[account.id] = tradingDays.size; + // _tradeDate is unquoted in the response (invalid JSON), but the "Date" field + // ("M/D/YY") is a valid quoted string that already reflects CME trade date. + const raw: string = (res.data?.data ?? '[]') + .replace(/"_tradeDate":\s*(\d{4}-\d{2}-\d{2})/g, '"_tradeDate": "$1"'); + type Fill = { + _tradeDate: string; + _timestamp: string; + _action: number; // 0 = Buy, 1 = Sell + _qty: number; + _price: number; + Product: string; + commission: number; + }; + const fills: Fill[] = JSON.parse(raw); + const uniqueDays = new Set(fills.map(f => f._tradeDate)); + this.daysTraded[account.id] = uniqueDays.size; + + // Dollar-per-point map for common futures products + const POINT_VALUES: { [product: string]: number } = { + NQ: 20, MNQ: 2, ES: 50, MES: 5, + YM: 5, MYM: 0.5, RTY: 50, M2K: 10, + GC: 100, MGC: 10, SI: 50, CL: 1000, + MCL: 100, NG: 10000, ZB: 1000, ZN: 1000, + ZF: 1000, '6E': 125000, '6J': 12500000, '6B': 62500, + }; + + // FIFO P&L computation: match buy/sell fills into round-trips + // Both the opening and closing commissions are deducted on close. + const sorted = [...fills].sort((a, b) => a._timestamp.localeCompare(b._timestamp)); + interface Lot { price: number; qty: number; commPerUnit: number } + const longBook: Lot[] = []; + const shortBook: Lot[] = []; + const dailyMap: { [date: string]: number } = {}; + + for (const fill of sorted) { + const pointValue = POINT_VALUES[fill.Product] ?? 1; + const isBuy = fill._action === 0; + let remaining = fill._qty; + const commPerUnit = fill._qty > 0 ? fill.commission / fill._qty : 0; + + if (isBuy) { + // Close any short lots first (FIFO), then open long + while (remaining > 0 && shortBook.length > 0) { + const lot = shortBook[0]; + const closed = Math.min(lot.qty, remaining); + const pnl = (lot.price - fill._price) * closed * pointValue + - (commPerUnit * closed) // closing fill commission + - (lot.commPerUnit * closed); // opening fill commission + dailyMap[fill._tradeDate] = (dailyMap[fill._tradeDate] ?? 0) + pnl; + lot.qty -= closed; + remaining -= closed; + if (lot.qty === 0) shortBook.shift(); + } + if (remaining > 0) longBook.push({ price: fill._price, qty: remaining, commPerUnit }); + } else { + // Close any long lots first (FIFO), then open short + while (remaining > 0 && longBook.length > 0) { + const lot = longBook[0]; + const closed = Math.min(lot.qty, remaining); + const pnl = (fill._price - lot.price) * closed * pointValue + - (commPerUnit * closed) // closing fill commission + - (lot.commPerUnit * closed); // opening fill commission + dailyMap[fill._tradeDate] = (dailyMap[fill._tradeDate] ?? 0) + pnl; + lot.qty -= closed; + remaining -= closed; + if (lot.qty === 0) longBook.shift(); + } + if (remaining > 0) shortBook.push({ price: fill._price, qty: remaining, commPerUnit }); + } + } + + this.dailyPnL[account.id] = Object.entries(dailyMap) + .map(([date, pnl]) => ({ date, pnl: Math.round(pnl * 100) / 100 })) + .sort((a, b) => a.date.localeCompare(b.date)); } catch (err) { - console.error(`[fetchDaysTraded] account ${account.id}`, err); - this.daysTraded[account.id] = 0; + console.error(`[fetchDaysTraded] ${account.name}`, err); + this.daysTraded[account.id] ??= 0; } } } @@ -321,6 +431,47 @@ export class TradovateClient { return res.data; } + async fetchInstrumentFees(symbols: string[]): Promise<{ [symbol: string]: number }> { + if (!this.accessInfo?.accessToken || this.products.length === 0) return {}; + + const productIds = symbols + .map((sym) => this.products.find((p) => p.name === sym)?.id) + .filter((id): id is number => id !== undefined); + + const res = await axios.post( + 'https://demo.tradovateapi.com/v1/contract/getproductfeeparams', + { productIds }, + { headers: { Authorization: `Bearer ${this.accessInfo.accessToken}` } } + ); + + const result: { [symbol: string]: number } = {}; + for (const param of (res.data?.params ?? [])) { + const product = this.products.find((p) => p.id === param.productId); + if (product && symbols.includes(product.name)) { + const raw = + (param.clearingFee ?? 0) + + (param.exchangeFee ?? 0) + + (param.nfaFee ?? 0) + + (param.brokerageFee ?? 0) + + (param.ipFee ?? 0) + + (param.commission ?? 0) + + (param.orderRoutingFee ?? 0); + result[product.name] = parseFloat(raw.toFixed(4)); + console.log( + `[fees] ${product.name}: clearing=${param.clearingFee ?? 0}` + + ` exchange=${param.exchangeFee ?? 0}` + + ` nfa=${param.nfaFee ?? 0}` + + ` brokerage=${param.brokerageFee ?? 0}` + + ` ip=${param.ipFee ?? 0}` + + ` commission=${param.commission ?? 0}` + + ` routing=${param.orderRoutingFee ?? 0}` + + ` → total=${result[product.name]}` + ); + } + } + return result; + } + async requestContractsFromSocket(names: string[]): Promise<{ [name: string]: Contract; }> { diff --git a/package-lock.json b/package-lock.json index e571429..dd6473e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,8 @@ "better-sqlite3": "^12.6.2", "next": "16.1.6", "react": "19.2.3", - "react-dom": "19.2.3" + "react-dom": "19.2.3", + "recharts": "^3.8.0" }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -1230,6 +1231,42 @@ "node": ">=12.4.0" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.4", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", + "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -1237,6 +1274,18 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -1538,6 +1587,69 @@ "@types/node": "*" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1573,7 +1685,7 @@ "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "dev": true, + "devOptional": true, "license": "MIT", "peer": true, "dependencies": { @@ -1590,6 +1702,12 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.56.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz", @@ -2723,6 +2841,15 @@ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2788,9 +2915,130 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -2870,6 +3118,12 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -3192,6 +3446,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-toolkit": { + "version": "1.45.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz", + "integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -3647,6 +3911,12 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", @@ -4179,6 +4449,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -4233,6 +4513,15 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -5858,8 +6147,32 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true + }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } }, "node_modules/readable-stream": { "version": "3.6.2", @@ -5875,6 +6188,52 @@ "node": ">= 6" } }, + "node_modules/recharts": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.0.tgz", + "integrity": "sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT", + "peer": true + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -5919,6 +6278,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -6610,6 +6975,12 @@ "node": ">=6" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -6961,12 +7332,43 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 87baae1..1d68216 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev", + "dev": "next dev --webpack", "build": "next build", "start": "next start", "lint": "eslint" @@ -13,7 +13,8 @@ "better-sqlite3": "^12.6.2", "next": "16.1.6", "react": "19.2.3", - "react-dom": "19.2.3" + "react-dom": "19.2.3", + "recharts": "^3.8.0" }, "devDependencies": { "@tailwindcss/postcss": "^4", diff --git a/tsconfig.json b/tsconfig.json index 3a13f90..6e5b3fe 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,6 +5,7 @@ "allowJs": true, "skipLibCheck": true, "strict": true, + "noImplicitAny": false, "noEmit": true, "esModuleInterop": true, "module": "esnext", diff --git a/types.ts b/types.ts index 5a5e6b9..214b45d 100644 --- a/types.ts +++ b/types.ts @@ -4,6 +4,8 @@ export interface AccountConfig { consistency: number; minDayPnL: number; minTradingDays: number; + accountSize: number; + maxLoss: number; // 0 = no limit; positive = account is "Dead" when loss exceeds this } export interface FirmConfig { @@ -22,6 +24,8 @@ export interface AccountState { realizedPnL: number; daysTraded: number; hasPosition: boolean; + /** Balance floor from Tradovate's auto-liquidation profile (0 = not set) */ + autoLiqThreshold: number; } export interface FirmState {