Add full Next.js autotrader app with SQLite persistence and live Tradovate data
- SQLite DB (better-sqlite3) with firms, account_configs, firm_fees, instruments tables - REST API routes: firms CRUD, account configs CRUD, state, accounts, instruments - Live Tradovate WebSocket client: login, sync, positions, auto-liq thresholds - Dashboard (app/page.tsx): per-firm account list with balance, day P&L, days traded, target progress, and Dead/Inactive/Flat status based on Tradovate auto-liq floors - Account detail page: objectives progress, daily P&L chart, consistency tracking - Per-firm settings page: account configs and instrument fee management - Dead detection uses trailingMaxDrawdownLimit - trailingMaxDrawdown from userAccountAutoLiqs; filters Tradovate sentinel value (999999999 = no limit) - FIFO P&L engine with commission accounting for daily P&L history - Removed manual maxLoss fallback in favour of live Tradovate auto-liq data Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
a9b6acd479
commit
dd18f91584
+4
-4
@@ -2,11 +2,11 @@
|
|||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"configurations": [
|
"configurations": [
|
||||||
{
|
{
|
||||||
"name": "autotrader-next",
|
"name": "autotrader",
|
||||||
"runtimeExecutable": "npx",
|
"runtimeExecutable": "C:\\Program Files\\nodejs\\npm.cmd",
|
||||||
"runtimeArgs": ["next", "dev", "--port", "3000"],
|
"runtimeArgs": ["run", "dev", "--", "--webpack"],
|
||||||
"port": 3000,
|
"port": 3000,
|
||||||
"cwd": "D:\\Development\\market-dev\\autotrader-firms\\autotrader-next"
|
"cwd": "D:\\Development\\market-dev\\autotrader-firms\\autotrader"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="flex items-center justify-between py-3 border-b border-slate-100 last:border-0">
|
||||||
|
<span className="text-slate-500 text-sm">{label}</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{badge != null && (
|
||||||
|
<span className={`text-xs font-semibold px-2 py-0.5 rounded ${positive ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>
|
||||||
|
{badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-slate-800 font-bold tabular-nums">{value}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ObjRow({ passed, label, value }: { passed: boolean; label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between py-3 border-b border-slate-100 last:border-0">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
{passed
|
||||||
|
? <span className="w-5 h-5 rounded-full bg-green-500 flex items-center justify-center flex-shrink-0 text-white text-[11px] font-bold">✓</span>
|
||||||
|
: <span className="w-5 h-5 rounded-full bg-orange-500 flex items-center justify-center flex-shrink-0 text-white text-[11px] font-bold">!</span>
|
||||||
|
}
|
||||||
|
<span className="text-slate-700 text-sm">{label}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-slate-800 font-bold tabular-nums text-sm">{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="bg-white border border-slate-200 rounded-lg shadow-sm px-3 py-2.5 text-sm min-w-[120px]">
|
||||||
|
<p className="text-slate-400 text-xs mb-1">{label}</p>
|
||||||
|
<p className={`font-bold tabular-nums ${equity >= 0 ? 'text-green-600' : 'text-red-500'}`}>
|
||||||
|
{equity >= 0 ? '+' : ''}${fmt(equity)}
|
||||||
|
</p>
|
||||||
|
<p className={`text-xs tabular-nums mt-0.5 ${daily >= 0 ? 'text-green-500' : 'text-red-400'}`}>
|
||||||
|
{daily >= 0 ? '▲' : '▼'} ${fmt(Math.abs(daily))} day
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, number> }) {
|
||||||
|
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 (
|
||||||
|
<div>
|
||||||
|
{/* Month header */}
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-slate-700 font-semibold text-sm">
|
||||||
|
{MONTH_NAMES[month - 1]} {year}
|
||||||
|
</span>
|
||||||
|
{monthTotal !== 0 && (
|
||||||
|
<span className={`text-sm font-bold tabular-nums ${monthTotal >= 0 ? 'text-green-600' : 'text-red-500'}`}>
|
||||||
|
{monthTotal >= 0 ? '+' : '−'}${fmt(Math.abs(monthTotal))}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Day-of-week headers */}
|
||||||
|
<div className="grid grid-cols-7 mb-1">
|
||||||
|
{DOW_LABELS.map((d) => (
|
||||||
|
<div key={d} className="text-center text-[11px] font-medium text-slate-400 py-1">{d}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Day cells */}
|
||||||
|
<div className="grid grid-cols-7 gap-1">
|
||||||
|
{cells.map((day, i) => {
|
||||||
|
if (day === null) return <div key={`empty-${i}`} />;
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
className={`rounded-lg px-1.5 pt-1.5 pb-2 min-h-14 flex flex-col ${
|
||||||
|
hasData
|
||||||
|
? positive
|
||||||
|
? 'bg-green-50 border border-green-100'
|
||||||
|
: 'bg-red-50 border border-red-100'
|
||||||
|
: 'bg-slate-50 border border-transparent'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={`text-[11px] font-medium leading-none mb-1.5 ${
|
||||||
|
hasData ? (positive ? 'text-green-700' : 'text-red-600') : 'text-slate-400'
|
||||||
|
}`}>
|
||||||
|
{day}
|
||||||
|
</span>
|
||||||
|
{hasData && (
|
||||||
|
<span className={`text-[11px] font-bold tabular-nums leading-tight ${
|
||||||
|
positive ? 'text-green-700' : 'text-red-600'
|
||||||
|
}`}>
|
||||||
|
{positive ? '+' : '−'}${fmt(Math.abs(pnl!))}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AccountPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const accountId = Number(id);
|
||||||
|
|
||||||
|
const [account, setAccount] = useState<AccountState | null>(null);
|
||||||
|
const [cfg, setCfg] = useState<AccountConfig | null>(null);
|
||||||
|
const [firmName, setFirmName] = useState('');
|
||||||
|
const [dailyPnL, setDailyPnL] = useState<DailyPnL[]>([]);
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="min-h-screen bg-slate-50 p-8">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<Link href="/" className="text-slate-400 hover:text-slate-600 text-sm transition-colors">← Back</Link>
|
||||||
|
</div>
|
||||||
|
<p className="text-slate-400 italic text-sm">Loading…</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="min-h-screen bg-slate-50 p-8">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<Link href="/" className="text-slate-400 hover:text-slate-600 text-sm transition-colors">
|
||||||
|
← Back
|
||||||
|
</Link>
|
||||||
|
<span className="text-slate-300">/</span>
|
||||||
|
<span className="text-slate-400 text-sm">{firmName}</span>
|
||||||
|
<span className="text-slate-300">/</span>
|
||||||
|
<h1 className="text-slate-900 font-bold text-sm">{account.name}</h1>
|
||||||
|
{isDead && (
|
||||||
|
<span className="ml-2 inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-bold bg-slate-900 text-white tracking-wide">
|
||||||
|
☠ DEAD
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isDead && (
|
||||||
|
<div className="mb-5 flex items-center gap-3 bg-slate-900 text-white rounded-xl px-5 py-4">
|
||||||
|
<span className="text-2xl leading-none">☠</span>
|
||||||
|
<div>
|
||||||
|
<p className="font-bold text-sm">Account Blown</p>
|
||||||
|
<p className="text-slate-300 text-xs mt-0.5">
|
||||||
|
Balance ${fmt(account.amount)} has breached the Tradovate auto-liquidation floor
|
||||||
|
{liqFloor != null ? ` of $${fmt(liqFloor)}` : ''}.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-5 mb-5">
|
||||||
|
<div className="flex flex-col w-1/2 bg-white border border-slate-200 rounded-xl shadow-sm p-6">
|
||||||
|
<h2 className="text-slate-900 font-bold text-base mb-1">Overall Performance</h2>
|
||||||
|
<PerfRow label="Account Balance" value={`$${fmt(account.amount)}`} />
|
||||||
|
<PerfRow
|
||||||
|
label="Total Profit"
|
||||||
|
value={`${totalProfit >= 0 ? '+' : ''}$${fmt(totalProfit)}`}
|
||||||
|
badge={profitPct != null ? `${profitPct >= 0 ? '↑' : '↓'} ${Math.abs(profitPct).toFixed(1)}%` : undefined}
|
||||||
|
positive={profitPct != null && profitPct >= 0}
|
||||||
|
/>
|
||||||
|
<PerfRow label="Trading Days" value={account.daysTraded} />
|
||||||
|
<PerfRow label="Daily Loss Limit" value={hasLossLimit ? `$${fmt(cfg!.minDayPnL)}` : 'None'} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col w-1/2 bg-white border border-slate-200 rounded-xl shadow-sm p-6">
|
||||||
|
<h2 className="text-slate-900 font-bold text-base mb-1">Objectives</h2>
|
||||||
|
<ObjRow
|
||||||
|
passed={profitPassed}
|
||||||
|
label="Profit Target"
|
||||||
|
value={cfg ? `$${fmt(totalProfit)} of $${fmt(cfg.profitTarget)}` : '—'}
|
||||||
|
/>
|
||||||
|
<ObjRow
|
||||||
|
passed={daysPassed}
|
||||||
|
label="Trading Days"
|
||||||
|
value={cfg ? `${account.daysTraded} of ${cfg.minTradingDays}` : '—'}
|
||||||
|
/>
|
||||||
|
{hasLossLimit && (
|
||||||
|
<ObjRow
|
||||||
|
passed={lossPassed}
|
||||||
|
label="Daily Loss Limit"
|
||||||
|
value={`$${fmt(cfg!.minDayPnL)}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Equity Curve */}
|
||||||
|
<div className="bg-white border border-slate-200 rounded-xl shadow-sm p-6 mb-5">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-slate-900 font-bold text-base">Equity Curve</h2>
|
||||||
|
{dailyPnL.length > 0 && (
|
||||||
|
<span className={`text-sm font-bold tabular-nums ${isPositive ? 'text-green-600' : 'text-red-500'}`}>
|
||||||
|
{isPositive ? '+' : ''}${fmt(totalProfit)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{dailyPnL.length === 0 ? (
|
||||||
|
<p className="text-slate-400 italic text-sm text-center py-8">No trading history available</p>
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height={260}>
|
||||||
|
<AreaChart data={equityData} margin={{ top: 8, right: 8, left: 8, bottom: 0 }}>
|
||||||
|
<defs>
|
||||||
|
{/* Positive fill: opaque at peak, fades to transparent at zero */}
|
||||||
|
<linearGradient id="greenFill" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="#22c55e" stopOpacity={0.20} />
|
||||||
|
<stop offset="100%" stopColor="#22c55e" stopOpacity={0.02} />
|
||||||
|
</linearGradient>
|
||||||
|
{/* Negative fill: transparent at zero, opaque at trough */}
|
||||||
|
<linearGradient id="redFill" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="#ef4444" stopOpacity={0.02} />
|
||||||
|
<stop offset="100%" stopColor="#ef4444" stopOpacity={0.20} />
|
||||||
|
</linearGradient>
|
||||||
|
{/* Stroke: green above zero, red below */}
|
||||||
|
<linearGradient id={strokeGradId} x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="#22c55e" />
|
||||||
|
<stop offset={zeroPct} stopColor="#22c55e" />
|
||||||
|
<stop offset={zeroPct} stopColor="#ef4444" />
|
||||||
|
<stop offset="100%" stopColor="#ef4444" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f1f5f9" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="label"
|
||||||
|
tick={{ fontSize: 11, fill: '#94a3b8' }}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
domain={[
|
||||||
|
() => 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}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
content={<EquityTooltip />}
|
||||||
|
cursor={{ stroke: '#e2e8f0', strokeWidth: 1 }}
|
||||||
|
/>
|
||||||
|
<ReferenceLine y={0} stroke="#e2e8f0" strokeWidth={1.5} />
|
||||||
|
{cfg?.profitTarget != null && (
|
||||||
|
<ReferenceLine
|
||||||
|
y={cfg.profitTarget}
|
||||||
|
stroke="#f59e0b"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
strokeDasharray="6 3"
|
||||||
|
label={{
|
||||||
|
value: `$${fmt(cfg.profitTarget)} target`,
|
||||||
|
position: 'insideTopRight',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 600,
|
||||||
|
fill: '#d97706',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* Green fill: positive equity only, fills down to y=0 */}
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="pos"
|
||||||
|
baseValue={0}
|
||||||
|
stroke="none"
|
||||||
|
fill="url(#greenFill)"
|
||||||
|
dot={false}
|
||||||
|
activeDot={false}
|
||||||
|
legendType="none"
|
||||||
|
tooltipType="none"
|
||||||
|
/>
|
||||||
|
{/* Red fill: negative equity only, fills up to y=0 */}
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="neg"
|
||||||
|
baseValue={0}
|
||||||
|
stroke="none"
|
||||||
|
fill="url(#redFill)"
|
||||||
|
dot={false}
|
||||||
|
activeDot={false}
|
||||||
|
legendType="none"
|
||||||
|
tooltipType="none"
|
||||||
|
/>
|
||||||
|
{/* Equity line: stroke-only, green above zero / red below */}
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="equity"
|
||||||
|
baseValue={0}
|
||||||
|
stroke={`url(#${strokeGradId})`}
|
||||||
|
strokeWidth={2.5}
|
||||||
|
fill="none"
|
||||||
|
dot={(props: any) => {
|
||||||
|
if (props.payload?.origin) return <g key={props.key} />;
|
||||||
|
return (
|
||||||
|
<Dot
|
||||||
|
key={props.key}
|
||||||
|
cx={props.cx}
|
||||||
|
cy={props.cy}
|
||||||
|
r={3.5}
|
||||||
|
fill={props.payload.pnl >= 0 ? '#22c55e' : '#ef4444'}
|
||||||
|
stroke="white"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
activeDot={{ r: 5, fill: '#64748b', stroke: 'white', strokeWidth: 2 }}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Daily P&L Calendar */}
|
||||||
|
{calendarMonths.length > 0 && (
|
||||||
|
<div className="bg-white border border-slate-200 rounded-xl shadow-sm p-6">
|
||||||
|
<h2 className="text-slate-900 font-bold text-base mb-6">Daily P&L</h2>
|
||||||
|
<div className="grid grid-cols-1 gap-8" style={{ gridTemplateColumns: `repeat(${Math.min(calendarMonths.length, 3)}, minmax(0, 1fr))` }}>
|
||||||
|
{calendarMonths.map((ym) => {
|
||||||
|
const [y, m] = ym.split('-').map(Number);
|
||||||
|
return <CalendarMonth key={ym} year={y} month={m} pnlMap={pnlMap} />;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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([]);
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,40 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { deleteFirm } from '@/lib/db';
|
import { getFirmById, deleteFirm } from '@/lib/db';
|
||||||
import { removeClient } from '@/lib/clients';
|
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(
|
export async function DELETE(
|
||||||
_req: NextRequest,
|
_req: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ export async function GET() {
|
|||||||
consistency: a.consistency,
|
consistency: a.consistency,
|
||||||
minDayPnL: a.min_day_pnl,
|
minDayPnL: a.min_day_pnl,
|
||||||
minTradingDays: a.min_trading_days,
|
minTradingDays: a.min_trading_days,
|
||||||
|
accountSize: a.account_size,
|
||||||
|
maxLoss: a.max_loss,
|
||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
return NextResponse.json(result);
|
return NextResponse.json(result);
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getInstruments } from '@/lib/db';
|
||||||
|
|
||||||
|
export function GET() {
|
||||||
|
return NextResponse.json(getInstruments());
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ export async function GET() {
|
|||||||
realizedPnL: cash.realizedPnL,
|
realizedPnL: cash.realizedPnL,
|
||||||
daysTraded: client.daysTraded[acc.id] ?? 0,
|
daysTraded: client.daysTraded[acc.id] ?? 0,
|
||||||
hasPosition: !!client.positions[acc.id],
|
hasPosition: !!client.positions[acc.id],
|
||||||
|
autoLiqThreshold: client.autoLiqThresholds[acc.id] ?? 0,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
return { firm: f.name, connected: true, accounts };
|
return { firm: f.name, connected: true, accounts };
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polyline points="3 6 5 6 21 6" />
|
||||||
|
<path d="M19 6l-1 14H6L5 6" />
|
||||||
|
<path d="M10 11v6M14 11v6" />
|
||||||
|
<path d="M9 6V4h6v2" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<RowState[]>([]);
|
||||||
|
const [fees, setFees] = useState<FirmFee[]>([]);
|
||||||
|
const [loadError, setLoadError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`/api/firms/${id}`)
|
||||||
|
.then((r) => {
|
||||||
|
if (!r.ok) throw new Error('Not found');
|
||||||
|
return r.json() as Promise<FirmConfig>;
|
||||||
|
})
|
||||||
|
.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<FirmFee[]>)
|
||||||
|
.then(setFees)
|
||||||
|
.catch(() => {});
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const updateRow = (rowId: number, patch: Partial<RowState>) => {
|
||||||
|
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 (
|
||||||
|
<div className="min-h-screen bg-slate-50 p-8">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
<p className="text-sm text-red-600">{loadError}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-50 p-8">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/')}
|
||||||
|
className="text-slate-400 hover:text-slate-600 text-sm transition-colors"
|
||||||
|
>
|
||||||
|
← Back
|
||||||
|
</button>
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900">{firmName || '…'}</h1>
|
||||||
|
<span className="text-slate-300 text-2xl font-light">/</span>
|
||||||
|
<span className="text-2xl font-bold text-slate-400">Settings</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Account Types */}
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mb-3">
|
||||||
|
Account Types
|
||||||
|
</h2>
|
||||||
|
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden shadow-sm">
|
||||||
|
<table className="w-full text-sm border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-slate-200 bg-slate-50">
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">Prefix</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">Account Size</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">Profit Target</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">Consistency</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">Min Day P&L</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">Min Trading Days</th>
|
||||||
|
<th className="px-3 py-3 w-32" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.length === 0 && firmName && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="px-4 py-6 text-center text-sm text-slate-400 italic">
|
||||||
|
No account types yet
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{rows.map((row) => (
|
||||||
|
<tr key={row.id} className="border-b border-slate-100 last:border-0">
|
||||||
|
|
||||||
|
{/* Prefix */}
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
<input
|
||||||
|
className={`${inputCls} font-mono w-32`}
|
||||||
|
value={row.prefix}
|
||||||
|
placeholder="e.g. MYACCT"
|
||||||
|
onChange={(e) => updateRow(row.id, { prefix: e.target.value })}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Account Size */}
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
className={`${inputCls} w-24`}
|
||||||
|
value={row.useCustomSize ? 'custom' : row.accountSize}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.value === 'custom') {
|
||||||
|
updateRow(row.id, { useCustomSize: true });
|
||||||
|
} else {
|
||||||
|
updateRow(row.id, {
|
||||||
|
accountSize: Number(e.target.value),
|
||||||
|
useCustomSize: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{SIZE_PRESETS.map((s) => (
|
||||||
|
<option key={s} value={s}>{fmtSize(s)}</option>
|
||||||
|
))}
|
||||||
|
<option value="custom">Custom</option>
|
||||||
|
</select>
|
||||||
|
{row.useCustomSize && (
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className={`${inputCls} w-28`}
|
||||||
|
value={row.accountSize || ''}
|
||||||
|
min={0}
|
||||||
|
step={1000}
|
||||||
|
placeholder="e.g. 35000"
|
||||||
|
onChange={(e) =>
|
||||||
|
updateRow(row.id, { accountSize: Number(e.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Profit Target */}
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
<div className="relative flex items-center">
|
||||||
|
<span className="absolute left-2.5 text-slate-400 text-sm pointer-events-none">$</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className={`${inputCls} pl-5 w-28`}
|
||||||
|
value={row.profitTarget}
|
||||||
|
min={0}
|
||||||
|
step={100}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateRow(row.id, { profitTarget: Number(e.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Consistency */}
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
<div className="relative flex items-center">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className={`${inputCls} pr-6 w-24`}
|
||||||
|
value={Math.round(row.consistency * 100)}
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={1}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateRow(row.id, { consistency: Number(e.target.value) / 100 })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="absolute right-2.5 text-slate-400 text-sm pointer-events-none">%</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Min Day P&L */}
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
<div className="relative flex items-center">
|
||||||
|
<span className="absolute left-2.5 text-slate-400 text-sm pointer-events-none">$</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className={`${inputCls} pl-5 w-28`}
|
||||||
|
value={row.minDayPnL <= -999 ? '' : row.minDayPnL}
|
||||||
|
placeholder="None"
|
||||||
|
step={100}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateRow(row.id, {
|
||||||
|
minDayPnL: e.target.value === '' ? -999 : Number(e.target.value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Min Trading Days */}
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className={`${inputCls} w-20`}
|
||||||
|
value={row.minTradingDays}
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateRow(row.id, { minTradingDays: Number(e.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<td className="px-3 py-2.5">
|
||||||
|
<div className="flex items-center justify-end gap-1.5">
|
||||||
|
{row.error && (
|
||||||
|
<span className="text-xs text-red-500 font-medium mr-1">{row.error}</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{row.confirmDelete ? (
|
||||||
|
<>
|
||||||
|
<span className="text-xs text-slate-500 mr-0.5">Delete?</span>
|
||||||
|
<button
|
||||||
|
onClick={() => deleteRow(row)}
|
||||||
|
className="px-2 py-1 bg-red-100 hover:bg-red-200 text-red-700 text-xs font-semibold rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
Yes
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => cancelDeleteRow(row.id)}
|
||||||
|
className="px-2 py-1 bg-slate-100 hover:bg-slate-200 text-slate-600 text-xs font-semibold rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
No
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => saveRow(row)}
|
||||||
|
disabled={row.saving || !row.dirty}
|
||||||
|
className={`px-3 py-1 bg-blue-100 hover:bg-blue-200 disabled:opacity-50 text-blue-700 text-xs font-semibold rounded-md transition-colors ${!row.dirty ? 'invisible' : ''}`}
|
||||||
|
>
|
||||||
|
{row.saving ? '…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => confirmDeleteRow(row.id)}
|
||||||
|
className="p-1 text-slate-300 hover:text-red-400 hover:bg-red-50 rounded-md transition-colors"
|
||||||
|
title="Delete account type"
|
||||||
|
>
|
||||||
|
<TrashIcon />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Add row */}
|
||||||
|
<tr className="border-t border-dashed border-slate-200">
|
||||||
|
<td colSpan={7} className="px-2 py-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setRows((prev) => [...prev, newRow()])}
|
||||||
|
className="w-full py-1.5 text-sm text-slate-400 hover:text-slate-600 hover:bg-slate-50 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
+ Add account type
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fees */}
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mt-8 mb-3">
|
||||||
|
Fees
|
||||||
|
</h2>
|
||||||
|
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden shadow-sm">
|
||||||
|
{fees.length === 0 ? (
|
||||||
|
<div className="px-4 py-8 text-center text-sm text-slate-400 italic">
|
||||||
|
No fees loaded yet
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-sm border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-slate-200 bg-slate-50">
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">Symbol</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">All-In Fee</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">Roundtrip Fee</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{fees.map((fee) => (
|
||||||
|
<tr key={fee.symbol} className="border-b border-slate-100 last:border-0">
|
||||||
|
<td className="px-4 py-2.5 font-mono font-semibold text-slate-800">{fee.symbol}</td>
|
||||||
|
<td className="px-4 py-2.5 font-mono text-slate-600">${fee.allinFee.toFixed(4)}</td>
|
||||||
|
<td className="px-4 py-2.5 font-mono text-slate-600">${fee.roundtripFee.toFixed(4)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+73
-11
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
import type { FirmConfig, FirmState, AccountState, AccountConfig } from '@/types';
|
import type { FirmConfig, FirmState, AccountState, AccountConfig } from '@/types';
|
||||||
|
|
||||||
function getAccountConfig(name: string, firm: FirmConfig): AccountConfig | undefined {
|
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 });
|
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() {
|
function SettingsIcon() {
|
||||||
return (
|
return (
|
||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
@@ -31,12 +37,26 @@ function ChevronIcon({ open }: { open: boolean }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AccountRow({ account, firm }: { account: AccountState; firm: FirmConfig }) {
|
function DetailsIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
||||||
|
<polyline points="15 3 21 3 21 9" />
|
||||||
|
<line x1="10" y1="14" x2="21" y2="3" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccountRow({ account, firm, hideDead }: { account: AccountState; firm: FirmConfig; hideDead: boolean }) {
|
||||||
const cfg = getAccountConfig(account.name, firm);
|
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';
|
const pnlColor = account.realizedPnL > 0 ? 'text-green-600 font-medium' : account.realizedPnL < 0 ? 'text-red-600 font-medium' : 'text-slate-400';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr className={`border-b border-slate-100 hover:bg-slate-50 transition-colors${!account.active ? ' opacity-40' : ''}`}>
|
<tr className={`border-b border-slate-100 hover:bg-slate-50 transition-colors${!account.active || dead ? ' opacity-50' : ''}`}>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="flex items-center gap-2 font-medium text-slate-800 pl-4">
|
<div className="flex items-center gap-2 font-medium text-slate-800 pl-4">
|
||||||
{account.name}
|
{account.name}
|
||||||
@@ -53,22 +73,35 @@ function AccountRow({ account, firm }: { account: AccountState; firm: FirmConfig
|
|||||||
${cfg?.profitTarget?.toLocaleString() ?? '—'}
|
${cfg?.profitTarget?.toLocaleString() ?? '—'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
{!account.active
|
<div className="flex items-center justify-between">
|
||||||
? <span className="inline-block px-2.5 py-0.5 rounded-full text-xs font-semibold bg-red-100 text-red-700">Inactive</span>
|
{dead
|
||||||
: account.hasPosition
|
? <span className="inline-block px-2.5 py-0.5 rounded-full text-xs font-semibold bg-slate-800 text-white">Dead</span>
|
||||||
? <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.active
|
||||||
: <span className="inline-block px-2.5 py-0.5 rounded-full text-xs font-semibold bg-slate-100 text-slate-500">Flat</span>}
|
? <span className="inline-block px-2.5 py-0.5 rounded-full text-xs font-semibold bg-red-100 text-red-700">Inactive</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>
|
||||||
|
: <span className="inline-block px-2.5 py-0.5 rounded-full text-xs font-semibold bg-slate-100 text-slate-500">Flat</span>}
|
||||||
|
<Link
|
||||||
|
href={`/accounts/${account.id}`}
|
||||||
|
className="text-slate-400 hover:text-slate-600 hover:bg-slate-200 p-1 rounded-md transition-colors inline-flex"
|
||||||
|
title="View details"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<DetailsIcon />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FirmRows({ state, firm, deleteMode, selected, onToggle }: {
|
function FirmRows({ state, firm, deleteMode, selected, onToggle, hideDead }: {
|
||||||
state: FirmState;
|
state: FirmState;
|
||||||
firm: FirmConfig;
|
firm: FirmConfig;
|
||||||
deleteMode: boolean;
|
deleteMode: boolean;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
|
hideDead: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(true);
|
const [open, setOpen] = useState(true);
|
||||||
|
|
||||||
@@ -95,13 +128,14 @@ function FirmRows({ state, firm, deleteMode, selected, onToggle }: {
|
|||||||
<td /><td /><td /><td />
|
<td /><td /><td /><td />
|
||||||
<td className="px-4 py-3.5">
|
<td className="px-4 py-3.5">
|
||||||
<div className="flex items-center justify-end gap-1">
|
<div className="flex items-center justify-end gap-1">
|
||||||
<button
|
<Link
|
||||||
|
href={`/firms/${firm.id}/settings`}
|
||||||
className="text-slate-400 hover:text-slate-600 hover:bg-slate-200 p-1 rounded-md transition-colors inline-flex"
|
className="text-slate-400 hover:text-slate-600 hover:bg-slate-200 p-1 rounded-md transition-colors inline-flex"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
title="Settings"
|
title="Settings"
|
||||||
>
|
>
|
||||||
<SettingsIcon />
|
<SettingsIcon />
|
||||||
</button>
|
</Link>
|
||||||
<ChevronIcon open={open} />
|
<ChevronIcon open={open} />
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -116,7 +150,7 @@ function FirmRows({ state, firm, deleteMode, selected, onToggle }: {
|
|||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
state.accounts.map((acc) => (
|
state.accounts.map((acc) => (
|
||||||
<AccountRow key={acc.id} account={acc} firm={firm} />
|
<AccountRow key={acc.id} account={acc} firm={firm} hideDead={hideDead} />
|
||||||
))
|
))
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
@@ -130,6 +164,7 @@ export default function Home() {
|
|||||||
const [firms, setFirms] = useState<FirmState[]>([]);
|
const [firms, setFirms] = useState<FirmState[]>([]);
|
||||||
const [deleteMode, setDeleteMode] = useState(false);
|
const [deleteMode, setDeleteMode] = useState(false);
|
||||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||||
|
const [hideDead, setHideDead] = useState(false);
|
||||||
|
|
||||||
const fetchConfig = async () => {
|
const fetchConfig = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -163,6 +198,13 @@ export default function Home() {
|
|||||||
return () => clearInterval(interval);
|
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 () => {
|
const handleConfirmDelete = async () => {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
[...selected].map((id) =>
|
[...selected].map((id) =>
|
||||||
@@ -208,6 +250,18 @@ export default function Home() {
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
{deadCount > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setHideDead((h) => !h)}
|
||||||
|
className={`px-4 py-2 text-sm font-semibold rounded-lg transition-colors border ${
|
||||||
|
hideDead
|
||||||
|
? 'bg-slate-800 text-white border-slate-800 hover:bg-slate-700'
|
||||||
|
: 'bg-white text-slate-600 border-slate-200 hover:bg-slate-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{hideDead ? `Show Dead (${deadCount})` : `Hide Dead (${deadCount})`}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push('/add')}
|
onClick={() => router.push('/add')}
|
||||||
className="px-4 py-2 bg-green-100 hover:bg-green-200 text-green-700 text-sm font-semibold rounded-lg transition-colors"
|
className="px-4 py-2 bg-green-100 hover:bg-green-200 text-green-700 text-sm font-semibold rounded-lg transition-colors"
|
||||||
@@ -220,6 +274,13 @@ export default function Home() {
|
|||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
|
<Link
|
||||||
|
href="/settings"
|
||||||
|
className="px-3 py-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-lg transition-colors inline-flex items-center"
|
||||||
|
title="Global Settings"
|
||||||
|
>
|
||||||
|
<SettingsIcon />
|
||||||
|
</Link>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -254,6 +315,7 @@ export default function Home() {
|
|||||||
deleteMode={deleteMode}
|
deleteMode={deleteMode}
|
||||||
selected={selected.has(cfg.id)}
|
selected={selected.has(cfg.id)}
|
||||||
onToggle={() => toggleSelected(cfg.id)}
|
onToggle={() => toggleSelected(cfg.id)}
|
||||||
|
hideDead={hideDead}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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<Instrument[]>([]);
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="min-h-screen bg-slate-50 p-8">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="text-slate-400 hover:text-slate-600 text-sm transition-colors"
|
||||||
|
>
|
||||||
|
← Back
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900">Settings</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mb-3">
|
||||||
|
Instruments
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden shadow-sm">
|
||||||
|
{instruments.length === 0 ? (
|
||||||
|
<div className="px-4 py-8 text-center text-sm text-slate-400 italic">
|
||||||
|
Loading…
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-sm border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-slate-200 bg-slate-50">
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">Symbol</th>
|
||||||
|
<th className="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-slate-400">Enabled</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{instruments.map((instr) => (
|
||||||
|
<tr key={instr.symbol} className="border-b border-slate-100 last:border-0">
|
||||||
|
<td className="px-4 py-2.5 font-mono font-semibold text-slate-800">{instr.symbol}</td>
|
||||||
|
<td className="px-4 py-2.5 text-right">
|
||||||
|
<button
|
||||||
|
onClick={() => toggle(instr.symbol, !instr.enabled)}
|
||||||
|
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none ${
|
||||||
|
instr.enabled ? 'bg-blue-500' : 'bg-slate-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-white shadow transition-transform ${
|
||||||
|
instr.enabled ? 'translate-x-4' : 'translate-x-1'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+17
-1
@@ -1,5 +1,7 @@
|
|||||||
import { TradovateClient } from './tradovate-class';
|
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
|
// Use global to persist the client pool across HMR reloads in dev mode
|
||||||
const g = global as typeof globalThis & {
|
const g = global as typeof globalThis & {
|
||||||
@@ -16,8 +18,22 @@ function ensureMap(): Map<number, TradovateClient> {
|
|||||||
|
|
||||||
export function initClient(id: number, username: string, password: string, firmName: string): TradovateClient {
|
export function initClient(id: number, username: string, password: string, firmName: string): TradovateClient {
|
||||||
const map = ensureMap();
|
const map = ensureMap();
|
||||||
|
let feesInitialized = false;
|
||||||
const client = new TradovateClient(username, password, async () => {
|
const client = new TradovateClient(username, password, async () => {
|
||||||
console.log(`[${firmName}] sync complete — ${client.accountList.length} account(s)`);
|
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);
|
map.set(id, client);
|
||||||
return client;
|
return client;
|
||||||
|
|||||||
@@ -22,29 +22,59 @@ db.exec(`
|
|||||||
min_day_pnl REAL NOT NULL DEFAULT -999,
|
min_day_pnl REAL NOT NULL DEFAULT -999,
|
||||||
min_trading_days INTEGER NOT NULL DEFAULT 5
|
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;
|
const firmCount = (db.prepare('SELECT COUNT(*) as count FROM firms').get() as { count: number }).count;
|
||||||
if (firmCount === 0) {
|
if (firmCount === 0) {
|
||||||
const insertFirm = db.prepare('INSERT INTO firms (name, username, password) VALUES (?, ?, ?)');
|
const insertFirm = db.prepare('INSERT INTO firms (name, username, password) VALUES (?, ?, ?)');
|
||||||
const insertAccount = db.prepare(
|
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');
|
const alpha = insertFirm.run('Alpha', 'brandonsenoli72786', '-Z2kPm7nBg');
|
||||||
insertAccount.run(alpha.lastInsertRowid, 'AFSTDEV', 9000, 0.51, -999, 5);
|
insertAccount.run(alpha.lastInsertRowid, 'AFSTDEV', 9000, 0.51, -999, 5, 150000);
|
||||||
insertAccount.run(alpha.lastInsertRowid, 'AFSTDQA', 4500, 0.40, -999, 7);
|
insertAccount.run(alpha.lastInsertRowid, 'AFSTDQA', 4500, 0.40, -999, 7, 150000);
|
||||||
insertAccount.run(alpha.lastInsertRowid, 'AFZEROEV', 3000, 0.50, -999, 5);
|
insertAccount.run(alpha.lastInsertRowid, 'AFZEROEV', 3000, 0.50, -999, 5, 100000);
|
||||||
insertAccount.run(alpha.lastInsertRowid, 'AFZEROQA', 3000, 0.50, -999, 5);
|
insertAccount.run(alpha.lastInsertRowid, 'AFZEROQA', 3000, 0.50, -999, 5, 100000);
|
||||||
insertAccount.run(alpha.lastInsertRowid, 'AF', 3000, 0.50, -999, 5);
|
insertAccount.run(alpha.lastInsertRowid, 'AF', 3000, 0.50, -999, 5, 100000);
|
||||||
|
|
||||||
const tpt = insertFirm.run('TakeProfitTrader', 'BRANDONLI1', 'W4592F5512U2817tv=');
|
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.');
|
console.log('[db] Seeded default firms.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ── Interfaces ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface AccountConfigRow {
|
export interface AccountConfigRow {
|
||||||
id: number;
|
id: number;
|
||||||
firm_id: number;
|
firm_id: number;
|
||||||
@@ -53,6 +83,8 @@ export interface AccountConfigRow {
|
|||||||
consistency: number;
|
consistency: number;
|
||||||
min_day_pnl: number;
|
min_day_pnl: number;
|
||||||
min_trading_days: number;
|
min_trading_days: number;
|
||||||
|
account_size: number;
|
||||||
|
max_loss: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FirmRow {
|
export interface FirmRow {
|
||||||
@@ -66,6 +98,15 @@ export interface FirmWithAccounts extends FirmRow {
|
|||||||
accounts: AccountConfigRow[];
|
accounts: AccountConfigRow[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FirmFee {
|
||||||
|
firmId: number;
|
||||||
|
symbol: string;
|
||||||
|
allinFee: number;
|
||||||
|
roundtripFee: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Firms ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function getFirms(): FirmWithAccounts[] {
|
export function getFirms(): FirmWithAccounts[] {
|
||||||
const firms = db.prepare('SELECT * FROM firms ORDER BY id').all() as FirmRow[];
|
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');
|
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 {
|
export function createFirm(name: string, username: string, password: string): FirmRow {
|
||||||
const stmt = db.prepare('INSERT INTO firms (name, username, password) VALUES (?, ?, ?)');
|
const stmt = db.prepare('INSERT INTO firms (name, username, password) VALUES (?, ?, ?)');
|
||||||
const result = stmt.run(name, username, password);
|
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);
|
const result = db.prepare('DELETE FROM firms WHERE id = ?').run(id);
|
||||||
return result.changes > 0;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
+231
-80
@@ -27,6 +27,11 @@ export class TradovateClient {
|
|||||||
} = {};
|
} = {};
|
||||||
|
|
||||||
public daysTraded: { [accountId: number]: number } = {};
|
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 ws: WebSocket;
|
||||||
private callbackOnSyncRequest: () => Promise<void>;
|
private callbackOnSyncRequest: () => Promise<void>;
|
||||||
@@ -52,7 +57,12 @@ export class TradovateClient {
|
|||||||
callback: (response: any) => void;
|
callback: (response: any) => void;
|
||||||
}[] = [];
|
}[] = [];
|
||||||
|
|
||||||
constructor(name: string, password: string, callbackOnSyncRequest: () => Promise<void>) {
|
|
||||||
|
constructor(
|
||||||
|
name: string,
|
||||||
|
password: string,
|
||||||
|
callbackOnSyncRequest: () => Promise<void>
|
||||||
|
) {
|
||||||
this.name = name;
|
this.name = name;
|
||||||
this.password = password;
|
this.password = password;
|
||||||
this.callbackOnSyncRequest = callbackOnSyncRequest;
|
this.callbackOnSyncRequest = callbackOnSyncRequest;
|
||||||
@@ -85,9 +95,8 @@ export class TradovateClient {
|
|||||||
console.log('Connected to websocket');
|
console.log('Connected to websocket');
|
||||||
this.ws.send('authorize\n2\n\n' + this.accessInfo.accessToken);
|
this.ws.send('authorize\n2\n\n' + this.accessInfo.accessToken);
|
||||||
this.directEventCallbacks[2] = (response: any) => {
|
this.directEventCallbacks[2] = (response: any) => {
|
||||||
// Once authorize, start syncing every 60 seconds
|
this.requestSync();
|
||||||
this.requestAccountUpdates();
|
setInterval(() => this.requestSync(), 60000);
|
||||||
setInterval(() => this.requestAccountUpdates(), 60000);
|
|
||||||
|
|
||||||
// Every 2.5 seconds send a heartbeat
|
// Every 2.5 seconds send a heartbeat
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
@@ -156,79 +165,94 @@ export class TradovateClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async requestAccountUpdates(): Promise<void> {
|
private requestSync(): void {
|
||||||
this.directEventCallbacks[3] = (response: any) => {
|
this.directEventCallbacks[3] = (response: any) => {
|
||||||
|
try {
|
||||||
// Syncing DLL or MLL hit
|
if (!response) {
|
||||||
const riskStatusById: { [id: number]: { liquidateOnly?: string } } = (
|
console.error('[requestSync] Received null/undefined response — auth may have failed');
|
||||||
response.accountRiskStatuses || []
|
return;
|
||||||
).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 };
|
|
||||||
}
|
}
|
||||||
return account;
|
|
||||||
});
|
|
||||||
|
|
||||||
this.accountCashBalances = response.cashBalances.reduce(
|
// liquidateOnly flag lives in accountRiskStatuses
|
||||||
(
|
const riskStatusById: { [accountId: number]: { liquidateOnly?: string } } = (
|
||||||
acc: {
|
response.accountRiskStatuses || []
|
||||||
[accountId: number]: { amount: number; realizedPnL: number };
|
).reduce((acc: any, item: any) => {
|
||||||
},
|
const key = item.accountId ?? item.id;
|
||||||
item: { accountId: number; amount: number; realizedPnL: number }
|
acc[key] = item;
|
||||||
) => {
|
|
||||||
acc[item.accountId] = {
|
|
||||||
amount: item.amount,
|
|
||||||
realizedPnL: item.realizedPnL,
|
|
||||||
};
|
|
||||||
return acc;
|
return acc;
|
||||||
},
|
}, {});
|
||||||
{} as { [accountId: number]: { amount: number; realizedPnL: number } }
|
|
||||||
);
|
// Auto-liquidation balance floor lives in userAccountAutoLiqs.
|
||||||
this.positions = response.positions
|
// item.id IS the account ID. The floor is: trailingMaxDrawdownLimit - trailingMaxDrawdown.
|
||||||
.filter((item) => item.netPos !== 0)
|
// Tradovate uses 999999999 as a sentinel for "no limit" — skip those.
|
||||||
.reduce(
|
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: {
|
acc: { [accountId: number]: { amount: number; realizedPnL: number } },
|
||||||
[accountId: number]: {
|
item: { accountId: number; amount: number; realizedPnL: number }
|
||||||
contractId: number;
|
|
||||||
netPos: number;
|
|
||||||
netPrice: number;
|
|
||||||
timestamp: Date;
|
|
||||||
};
|
|
||||||
},
|
|
||||||
item: {
|
|
||||||
accountId: number;
|
|
||||||
contractId: number;
|
|
||||||
netPos: number;
|
|
||||||
netPrice: number;
|
|
||||||
timestamp: Date;
|
|
||||||
}
|
|
||||||
) => {
|
) => {
|
||||||
acc[item.accountId] = {
|
acc[item.accountId] = { amount: item.amount, realizedPnL: item.realizedPnL };
|
||||||
contractId: item.contractId,
|
|
||||||
netPos: item.netPos,
|
|
||||||
netPrice: item.netPrice,
|
|
||||||
timestamp: new Date(item.timestamp),
|
|
||||||
};
|
|
||||||
return acc;
|
return acc;
|
||||||
},
|
},
|
||||||
{} as {
|
{} as { [accountId: number]: { amount: number; realizedPnL: number } }
|
||||||
[accountId: number]: {
|
|
||||||
contractId: number;
|
|
||||||
netPos: number;
|
|
||||||
netPrice: number;
|
|
||||||
timestamp: Date;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
this.callbackOnSyncRequest();
|
this.positions = (response.positions ?? [])
|
||||||
this.fetchDaysTraded();
|
.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}');
|
this.ws.send('user/syncrequest\n3\n\n{"splitResponses":false}');
|
||||||
@@ -237,25 +261,111 @@ export class TradovateClient {
|
|||||||
private async fetchDaysTraded(): Promise<void> {
|
private async fetchDaysTraded(): Promise<void> {
|
||||||
if (!this.accessInfo?.accessToken) return;
|
if (!this.accessInfo?.accessToken) return;
|
||||||
|
|
||||||
const cutoff = new Date();
|
const now = new Date();
|
||||||
cutoff.setDate(cutoff.getDate() - 28);
|
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) {
|
for (const account of this.accountList) {
|
||||||
try {
|
try {
|
||||||
const res = await axios.get(
|
const res = await axios.post(
|
||||||
`https://demo.tradovateapi.com/v1/fill/ldeps?masterid=${account.id}`,
|
'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}` } }
|
{ headers: { Authorization: `Bearer ${this.accessInfo.accessToken}` } }
|
||||||
);
|
);
|
||||||
const fills: { timestamp: string }[] = res.data ?? [];
|
// _tradeDate is unquoted in the response (invalid JSON), but the "Date" field
|
||||||
const tradingDays = new Set(
|
// ("M/D/YY") is a valid quoted string that already reflects CME trade date.
|
||||||
fills
|
const raw: string = (res.data?.data ?? '[]')
|
||||||
.filter((f) => new Date(f.timestamp) >= cutoff)
|
.replace(/"_tradeDate":\s*(\d{4}-\d{2}-\d{2})/g, '"_tradeDate": "$1"');
|
||||||
.map((f) => new Date(f.timestamp).toDateString())
|
type Fill = {
|
||||||
);
|
_tradeDate: string;
|
||||||
this.daysTraded[account.id] = tradingDays.size;
|
_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) {
|
} catch (err) {
|
||||||
console.error(`[fetchDaysTraded] account ${account.id}`, err);
|
console.error(`[fetchDaysTraded] ${account.name}`, err);
|
||||||
this.daysTraded[account.id] = 0;
|
this.daysTraded[account.id] ??= 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -321,6 +431,47 @@ export class TradovateClient {
|
|||||||
return res.data;
|
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<{
|
async requestContractsFromSocket(names: string[]): Promise<{
|
||||||
[name: string]: Contract;
|
[name: string]: Contract;
|
||||||
}> {
|
}> {
|
||||||
|
|||||||
Generated
+407
-5
@@ -12,7 +12,8 @@
|
|||||||
"better-sqlite3": "^12.6.2",
|
"better-sqlite3": "^12.6.2",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3"
|
"react-dom": "19.2.3",
|
||||||
|
"recharts": "^3.8.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
@@ -1230,6 +1231,42 @@
|
|||||||
"node": ">=12.4.0"
|
"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": {
|
"node_modules/@rtsao/scc": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
||||||
@@ -1237,6 +1274,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@swc/helpers": {
|
||||||
"version": "0.5.15",
|
"version": "0.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||||
@@ -1538,6 +1587,69 @@
|
|||||||
"@types/node": "*"
|
"@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": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
@@ -1573,7 +1685,7 @@
|
|||||||
"version": "19.2.14",
|
"version": "19.2.14",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -1590,6 +1702,12 @@
|
|||||||
"@types/react": "^19.2.0"
|
"@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": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "8.56.1",
|
"version": "8.56.1",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz",
|
||||||
@@ -2723,6 +2841,15 @@
|
|||||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
@@ -2788,9 +2915,130 @@
|
|||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/damerau-levenshtein": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
"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": {
|
"node_modules/decompress-response": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
||||||
@@ -3192,6 +3446,16 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/escalade": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||||
@@ -3647,6 +3911,12 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/expand-template": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
|
||||||
@@ -4179,6 +4449,16 @@
|
|||||||
"node": ">= 4"
|
"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": {
|
"node_modules/import-fresh": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||||
@@ -4233,6 +4513,15 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/is-array-buffer": {
|
||||||
"version": "3.0.5",
|
"version": "3.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
|
||||||
@@ -5858,8 +6147,32 @@
|
|||||||
"version": "16.13.1",
|
"version": "16.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
"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": {
|
"node_modules/readable-stream": {
|
||||||
"version": "3.6.2",
|
"version": "3.6.2",
|
||||||
@@ -5875,6 +6188,52 @@
|
|||||||
"node": ">= 6"
|
"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": {
|
"node_modules/reflect.getprototypeof": {
|
||||||
"version": "1.0.10",
|
"version": "1.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||||
@@ -5919,6 +6278,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.11",
|
"version": "1.22.11",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||||
@@ -6610,6 +6975,12 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.15",
|
"version": "0.2.15",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||||
@@ -6961,12 +7332,43 @@
|
|||||||
"punycode": "^2.1.0"
|
"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": {
|
"node_modules/util-deprecate": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
|||||||
+3
-2
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev --webpack",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
@@ -13,7 +13,8 @@
|
|||||||
"better-sqlite3": "^12.6.2",
|
"better-sqlite3": "^12.6.2",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3"
|
"react-dom": "19.2.3",
|
||||||
|
"recharts": "^3.8.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
"noImplicitAny": false,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"module": "esnext",
|
"module": "esnext",
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ export interface AccountConfig {
|
|||||||
consistency: number;
|
consistency: number;
|
||||||
minDayPnL: number;
|
minDayPnL: number;
|
||||||
minTradingDays: number;
|
minTradingDays: number;
|
||||||
|
accountSize: number;
|
||||||
|
maxLoss: number; // 0 = no limit; positive = account is "Dead" when loss exceeds this
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FirmConfig {
|
export interface FirmConfig {
|
||||||
@@ -22,6 +24,8 @@ export interface AccountState {
|
|||||||
realizedPnL: number;
|
realizedPnL: number;
|
||||||
daysTraded: number;
|
daysTraded: number;
|
||||||
hasPosition: boolean;
|
hasPosition: boolean;
|
||||||
|
/** Balance floor from Tradovate's auto-liquidation profile (0 = not set) */
|
||||||
|
autoLiqThreshold: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FirmState {
|
export interface FirmState {
|
||||||
|
|||||||
Reference in New Issue
Block a user