From b2a1bdd1c35c334bf9e10b8014fd9a85a1f9e390 Mon Sep 17 00:00:00 2001 From: Senofy <63175905+Senofy@users.noreply.github.com> Date: Mon, 9 Mar 2026 03:31:47 -0500 Subject: [PATCH] Add auto-trade scheduler with batch locking, commission gross-up, and sync gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Auto-trade scheduler fires every 60s; uses Promise.allSettled batch so no new trades fire while any position from the current batch is open - Commission gross-up: read entryCommission from cash.realizedPnL after fill (fallback 2.5×contracts), grossTarget = target + 2×entryCommission - Sync gate: TradovateClient.syncComplete flag; scheduler skips tick until every client finishes initial position/balance sync - Contracts formula changed to Math.ceil so $1500 target = 2 contracts - Removed all fee caching (perContractFees, recentFills, fillFee handler) from tradovate-class.ts - Removed firm_fees table, getFirmFees, upsertFirmFee from db.ts - Deleted instrument-configs API routes; removed Fees UI from firm settings page - /api/instruments returns full {symbol, enabled}[] objects; dashboard filters to enabled-only for trade selector - Added auto-trade, debug, orders, settings, and trade API routes - Instrument selector on dashboard now driven by enabled instruments from DB Co-Authored-By: Claude Sonnet 4.6 --- app/api/auto-trade/route.ts | 13 + app/api/debug/route.ts | 110 +++++++ app/api/firms/[id]/debug-contracts/route.ts | 33 +++ .../[instrumentId]/route.ts | 41 --- .../firms/[id]/instrument-configs/route.ts | 21 -- app/api/firms/[id]/orders/route.ts | 47 +++ app/api/instruments/route.ts | 2 +- app/api/settings/route.ts | 31 ++ app/api/state/route.ts | 31 +- app/api/trade/route.ts | 29 ++ app/firms/[id]/settings/page.tsx | 44 --- app/page.tsx | 206 ++++++++++++- app/settings/page.tsx | 65 +++++ lib/auto-trade.ts | 271 ++++++++++++++++++ lib/clients.ts | 34 ++- lib/db.ts | 53 ++-- lib/trading-logic.ts | 9 + lib/tradovate-class.ts | 224 ++++++++++++--- types.ts | 4 + 19 files changed, 1050 insertions(+), 218 deletions(-) create mode 100644 app/api/auto-trade/route.ts create mode 100644 app/api/debug/route.ts create mode 100644 app/api/firms/[id]/debug-contracts/route.ts delete mode 100644 app/api/firms/[id]/instrument-configs/[instrumentId]/route.ts delete mode 100644 app/api/firms/[id]/instrument-configs/route.ts create mode 100644 app/api/firms/[id]/orders/route.ts create mode 100644 app/api/settings/route.ts create mode 100644 app/api/trade/route.ts create mode 100644 lib/auto-trade.ts diff --git a/app/api/auto-trade/route.ts b/app/api/auto-trade/route.ts new file mode 100644 index 0000000..6de2be9 --- /dev/null +++ b/app/api/auto-trade/route.ts @@ -0,0 +1,13 @@ +import { NextResponse } from 'next/server'; +import { stopScheduler, getSchedulerStatus } from '@/lib/auto-trade'; + +/** GET /api/auto-trade — return current scheduler status */ +export async function GET() { + return NextResponse.json(getSchedulerStatus()); +} + +/** DELETE /api/auto-trade — stop the scheduler */ +export async function DELETE() { + stopScheduler(); + return NextResponse.json({ ok: true }); +} diff --git a/app/api/debug/route.ts b/app/api/debug/route.ts new file mode 100644 index 0000000..0b3565e --- /dev/null +++ b/app/api/debug/route.ts @@ -0,0 +1,110 @@ +import { NextResponse } from 'next/server'; +import { getClients, resetClients } from '@/lib/clients'; +import { getFirms } from '@/lib/db'; + +export async function GET() { + try { + const clients = getClients(); + const firms = getFirms(); + + const debug = firms.map((firm) => { + const client = clients.get(firm.id); + if (!client) return { firmId: firm.id, firmName: firm.name, status: 'no_client' }; + + return { + firmId: firm.id, + firmName: firm.name, + status: 'connected', + accountCount: client.accountList.length, + fetchDaysComplete: (client as any).fetchDaysComplete ?? 'n/a (old instance)', + lastFetchErrors: (client as any).lastFetchErrors ?? {}, + lastFetchRaw: (client as any).lastFetchRaw ?? {}, + accounts: client.accountList.map((acc) => ({ + id: acc.id, + name: acc.name, + cash: client.accountCashBalances[acc.id] ?? null, + daysTraded: client.daysTraded[acc.id] ?? '(not set)', + dailyPnLEntries: (client.dailyPnL[acc.id] ?? []).length, + dailyPnL: client.dailyPnL[acc.id] ?? [], + })), + recentEntityEvents: client.recentEntityEvents.slice(-3).map((e) => ({ + ts: new Date(e.ts).toISOString(), + entityType: e.entityType, + eventType: e.eventType, + })), + }; + }); + + return NextResponse.json({ ok: true, ts: new Date().toISOString(), firms: debug }); + } catch (err) { + console.error('[GET /api/debug]', err); + return NextResponse.json({ error: String(err) }, { status: 500 }); + } +} + +/** POST /api/debug — manually trigger fetchDaysTraded on all clients and return results */ +export async function POST() { + try { + const clients = getClients(); + const firms = getFirms(); + + const results = await Promise.all( + firms.map(async (firm) => { + const client = clients.get(firm.id); + if (!client || client.accountList.length === 0) { + return { firmId: firm.id, firmName: firm.name, status: 'skipped' }; + } + + try { + await (client as any).fetchDaysTraded(); + } catch (err) { + return { firmId: firm.id, firmName: firm.name, status: 'error', error: String(err) }; + } + + return { + firmId: firm.id, + firmName: firm.name, + status: 'done', + fetchDaysComplete: (client as any).fetchDaysComplete ?? 'n/a', + errors: (client as any).lastFetchErrors ?? {}, + rawSamples: (client as any).lastFetchRaw ?? {}, + daysTraded: client.daysTraded, + dailyPnLCounts: Object.fromEntries( + Object.entries(client.dailyPnL).map(([k, v]) => [k, (v as any[]).length]) + ), + }; + }) + ); + + return NextResponse.json({ ok: true, ts: new Date().toISOString(), results }); + } catch (err) { + console.error('[POST /api/debug]', err); + return NextResponse.json({ error: String(err) }, { status: 500 }); + } +} + +/** + * DELETE /api/debug — force-reinitialize all Tradovate clients with fresh instances + * Clears the global pool so getClients() recreates everything from DB on next call. + */ +export async function DELETE() { + try { + const g = global as any; + const oldCount = (g.__tradovateClients as Map | undefined)?.size ?? 0; + + // Disconnect all existing WebSocket connections before clearing + resetClients(); + + // Re-initialize immediately with fresh instances + getClients(); + + return NextResponse.json({ + ok: true, + message: `Disconnected ${oldCount} old client(s) — fresh instances initializing`, + ts: new Date().toISOString(), + }); + } catch (err) { + console.error('[DELETE /api/debug]', err); + return NextResponse.json({ error: String(err) }, { status: 500 }); + } +} diff --git a/app/api/firms/[id]/debug-contracts/route.ts b/app/api/firms/[id]/debug-contracts/route.ts new file mode 100644 index 0000000..90bb91e --- /dev/null +++ b/app/api/firms/[id]/debug-contracts/route.ts @@ -0,0 +1,33 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getClients } from '@/lib/clients'; +import axios from 'axios'; + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const firmId = parseInt(id, 10); + const clients = getClients(); + const client = clients.get(firmId) as any; + if (!client) return NextResponse.json({ error: 'Client not found' }, { status: 404 }); + + const symbol = req.nextUrl.searchParams.get('symbol') ?? 'NQ'; + const accessToken = client.accessInfo?.accessToken; + if (!accessToken) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + + const res = await axios.get( + `https://demo.tradovateapi.com/v1/contract/suggest?t=${encodeURIComponent(symbol)}&l=20`, + { headers: { Authorization: `Bearer ${accessToken}` } } + ); + + return NextResponse.json({ + symbol, + contracts: res.data, + recentEntityEvents: client.recentEntityEvents ?? [], + }); + } catch (err: any) { + return NextResponse.json({ error: err?.message }, { status: 500 }); + } +} diff --git a/app/api/firms/[id]/instrument-configs/[instrumentId]/route.ts b/app/api/firms/[id]/instrument-configs/[instrumentId]/route.ts deleted file mode 100644 index fcd686e..0000000 --- a/app/api/firms/[id]/instrument-configs/[instrumentId]/route.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { upsertFirmInstrumentConfig } from '@/lib/db'; - -export async function PUT( - req: NextRequest, - { params }: { params: Promise<{ id: string; instrumentId: string }> } -) { - const { id: idStr, instrumentId: instrIdStr } = await params; - const firmId = parseInt(idStr, 10); - const instrumentId = parseInt(instrIdStr, 10); - - if (isNaN(firmId) || isNaN(instrumentId)) { - return NextResponse.json({ error: 'Invalid id' }, { status: 400 }); - } - - const body = await req.json() as { - allinFee?: number; - roundtripFee?: number; - banned?: boolean; - }; - - if ( - typeof body.allinFee !== 'number' || - typeof body.roundtripFee !== 'number' || - typeof body.banned !== 'boolean' - ) { - return NextResponse.json({ error: 'Invalid body' }, { status: 400 }); - } - - try { - upsertFirmInstrumentConfig(firmId, instrumentId, { - allinFee: body.allinFee, - roundtripFee: body.roundtripFee, - banned: body.banned, - }); - return NextResponse.json({ success: true }); - } catch (err) { - console.error('[PUT /api/firms/:id/instrument-configs/:instrumentId]', err); - return NextResponse.json({ error: 'Failed to save config' }, { status: 500 }); - } -} diff --git a/app/api/firms/[id]/instrument-configs/route.ts b/app/api/firms/[id]/instrument-configs/route.ts deleted file mode 100644 index eedcbaf..0000000 --- a/app/api/firms/[id]/instrument-configs/route.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { getFirmFees } from '@/lib/db'; - -export async function GET( - _req: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - const { id: idStr } = await params; - const firmId = parseInt(idStr, 10); - - if (isNaN(firmId)) { - return NextResponse.json({ error: 'Invalid firm id' }, { status: 400 }); - } - - try { - return NextResponse.json(getFirmFees(firmId)); - } catch (err) { - console.error('[GET /api/firms/:id/instrument-configs]', err); - return NextResponse.json({ error: 'Failed to fetch fees' }, { status: 500 }); - } -} diff --git a/app/api/firms/[id]/orders/route.ts b/app/api/firms/[id]/orders/route.ts new file mode 100644 index 0000000..379be56 --- /dev/null +++ b/app/api/firms/[id]/orders/route.ts @@ -0,0 +1,47 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getClients } from '@/lib/clients'; + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const firmId = parseInt(id, 10); + if (isNaN(firmId)) return NextResponse.json({ error: 'Invalid firm ID' }, { status: 400 }); + + const body = await req.json() as { + accountId: number; + symbol: string; // product name, e.g. "NQ" + qty: number; + action: 'Buy' | 'Sell'; + orderType?: 'Market' | 'Limit'; + price?: number; + }; + + const { accountId, symbol, qty, action, orderType = 'Market', price } = body; + if (!accountId || !symbol || !qty || !action) { + return NextResponse.json({ error: 'Missing required fields: accountId, symbol, qty, action' }, { status: 400 }); + } + + const clients = getClients(); + const client = clients.get(firmId); + if (!client) return NextResponse.json({ error: 'Client not found for firm' }, { status: 404 }); + + // 1. Find the front-month contract + const contract = await client.findFrontMonthContract(symbol); + if (!contract) { + return NextResponse.json({ error: `Could not find active front-month contract for ${symbol}` }, { status: 404 }); + } + console.log(`[order] front-month: ${contract.name} (id=${contract.id})`); + + // 2. Place the order — resolves with the fill event (includes commission/fees) + const fill = await client.sendOrder(accountId, contract.name, qty, action, orderType, price); + console.log(`[order] fill received: price=${fill.price} qty=${fill.qty} commission=$${fill.commission} perContract=$${fill.perContractFee}`); + + return NextResponse.json({ contract: { id: contract.id, name: contract.name }, fill }); + } catch (err: any) { + console.error('[POST /api/firms/[id]/orders]', err); + return NextResponse.json({ error: err?.message ?? 'Order failed' }, { status: 500 }); + } +} diff --git a/app/api/instruments/route.ts b/app/api/instruments/route.ts index 89db4f2..68936b1 100644 --- a/app/api/instruments/route.ts +++ b/app/api/instruments/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from 'next/server'; import { getInstruments } from '@/lib/db'; -export function GET() { +export async function GET() { return NextResponse.json(getInstruments()); } diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts new file mode 100644 index 0000000..6ff3d54 --- /dev/null +++ b/app/api/settings/route.ts @@ -0,0 +1,31 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getSetting, setSetting } from '@/lib/db'; + +const VALID_KEYS = ['max_concurrent_accounts'] as const; +type SettingKey = typeof VALID_KEYS[number]; + +export async function GET() { + const result: Record = {}; + for (const key of VALID_KEYS) { + result[key] = getSetting(key); + } + return NextResponse.json(result); +} + +export async function PATCH(req: NextRequest) { + try { + const body = await req.json() as Partial>; + + for (const key of VALID_KEYS) { + if (key in body) { + const raw = body[key]; + if (raw === undefined || raw === null) continue; + setSetting(key, String(raw)); + } + } + + return NextResponse.json({ ok: true }); + } catch (err: any) { + return NextResponse.json({ error: err?.message ?? 'Failed to save settings' }, { status: 500 }); + } +} diff --git a/app/api/state/route.ts b/app/api/state/route.ts index 4021a04..6624355 100644 --- a/app/api/state/route.ts +++ b/app/api/state/route.ts @@ -1,6 +1,14 @@ import { NextResponse } from 'next/server'; import { getFirms } from '@/lib/db'; import { getClients } from '@/lib/clients'; +import { computeDailyTarget } from '@/lib/trading-logic'; +import type { AccountConfigRow } from '@/lib/db'; + +function getAccountConfig(name: string, accounts: AccountConfigRow[]): AccountConfigRow | undefined { + return [...accounts] + .sort((a, b) => b.prefix.length - a.prefix.length) + .find((a) => name.startsWith(a.prefix)); +} export async function GET() { try { @@ -14,18 +22,37 @@ export async function GET() { } const accounts = client.accountList.map((acc) => { const cash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 }; + const dailyPnL: { date: string; pnl: number }[] = client.dailyPnL[acc.id] ?? []; + const daysTraded: number = client.daysTraded[acc.id] ?? 0; + const totalProfit = dailyPnL.reduce((sum, d) => sum + d.pnl, 0); + + // Determine if today's daily target was hit + const cfg = getAccountConfig(acc.name, f.accounts); + let targetHit = false; + if (cfg) { + const target = computeDailyTarget(cfg.profit_target, cfg.consistency, totalProfit, dailyPnL); + // Condition 1: profit target already exceeded (target=0), still need days → any activity counts + // Condition 2: target > 0 → must have made at least the computed daily target + targetHit = + // If we are just flipping take any activity as target hit + (target.amount === 0 && Math.abs(cash.realizedPnL) > 0 && client.daysTraded[acc.id] <= cfg.min_trading_days) || + (cash.realizedPnL >= target.amount); + } + return { id: acc.id, name: acc.name, active: acc.active, amount: cash.amount, realizedPnL: cash.realizedPnL, - daysTraded: client.daysTraded[acc.id] ?? 0, + daysTraded, hasPosition: !!client.positions[acc.id], autoLiqThreshold: client.autoLiqThresholds[acc.id] ?? 0, + totalProfit, + targetHit, }; }); - return { firm: f.name, connected: true, accounts }; + return { firm: f.name, connected: true, accounts, perContractFees: client.perContractFees }; }); return NextResponse.json(state); diff --git a/app/api/trade/route.ts b/app/api/trade/route.ts new file mode 100644 index 0000000..56fc66b --- /dev/null +++ b/app/api/trade/route.ts @@ -0,0 +1,29 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { runTrade, startScheduler } from '@/lib/auto-trade'; +import { POINT_VALUES } from '@/lib/trading-logic'; + +export async function POST(req: NextRequest) { + try { + const body = await req.json() as { action: 'Buy' | 'Sell'; symbol: string }; + const { action, symbol } = body; + + if (!action || !symbol) { + return NextResponse.json({ error: 'Missing required fields: action, symbol' }, { status: 400 }); + } + + if (!POINT_VALUES[symbol]) { + return NextResponse.json({ error: `Unknown symbol: ${symbol}` }, { status: 400 }); + } + + // Execute trade immediately for all eligible accounts + const results = await runTrade(action, symbol); + + // (Re)start the 60-second scheduler with this action + symbol + startScheduler(action, symbol); + + return NextResponse.json(results); + } catch (err: any) { + console.error('[POST /api/trade]', err); + return NextResponse.json({ error: err?.message ?? 'Trade failed' }, { status: 500 }); + } +} diff --git a/app/firms/[id]/settings/page.tsx b/app/firms/[id]/settings/page.tsx index 227c45f..767c194 100644 --- a/app/firms/[id]/settings/page.tsx +++ b/app/firms/[id]/settings/page.tsx @@ -5,13 +5,6 @@ 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; @@ -89,7 +82,6 @@ export default function FirmSettingsPage() { const [firmName, setFirmName] = useState(''); const [rows, setRows] = useState([]); - const [fees, setFees] = useState([]); const [loadError, setLoadError] = useState(''); useEffect(() => { @@ -103,11 +95,6 @@ export default function FirmSettingsPage() { setRows(firm.accounts.map(initRow)); }) .catch(() => setLoadError('Failed to load firm')); - - fetch(`/api/firms/${id}/instrument-configs`) - .then((r) => r.json() as Promise) - .then(setFees) - .catch(() => {}); }, [id]); const updateRow = (rowId: number, patch: Partial) => { @@ -428,37 +415,6 @@ export default function FirmSettingsPage() { - {/* Fees */} -

- Fees -

-
- {fees.length === 0 ? ( -
- No fees loaded yet -
- ) : ( - - - - - - - - - - {fees.map((fee) => ( - - - - - - ))} - -
SymbolAll-In FeeRoundtrip Fee
{fee.symbol}${fee.allinFee.toFixed(4)}${fee.roundtripFee.toFixed(4)}
- )} -
- ); diff --git a/app/page.tsx b/app/page.tsx index c517148..f3a7a09 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -5,6 +5,19 @@ import { useRouter } from 'next/navigation'; import Link from 'next/link'; import type { FirmConfig, FirmState, AccountState, AccountConfig } from '@/types'; +type SortKey = 'name' | 'balance' | 'dayPnL' | 'daysTraded' | 'target' | 'status'; +type SortDir = 'asc' | 'desc'; + +function statusRank(account: AccountState): number { + if (isAccountDead(account)) return 0; + if (!account.active) return 1; + if (!account.hasPosition) { + if (account.targetHit) return 4; // Target Hit — most accomplished + return 2; // Flat + } + return 3; // In Trade +} + function getAccountConfig(name: string, firm: FirmConfig): AccountConfig | undefined { return [...firm.accounts] .sort((a, b) => b.prefix.length - a.prefix.length) @@ -47,6 +60,31 @@ function DetailsIcon() { ); } +function SortHeader({ label, col, sortKey, sortDir, onSort }: { + label: string; + col: SortKey; + sortKey: SortKey | null; + sortDir: SortDir; + onSort: (col: SortKey) => void; +}) { + const active = sortKey === col; + return ( + onSort(col)} + > +
+ + {label} + + + {active ? (sortDir === 'asc' ? '↑' : '↓') : '↕'} + +
+ + ); +} + function AccountRow({ account, firm, hideDead }: { account: AccountState; firm: FirmConfig; hideDead: boolean }) { const cfg = getAccountConfig(account.name, firm); const dead = isAccountDead(account); @@ -77,10 +115,12 @@ function AccountRow({ account, firm, hideDead }: { account: AccountState; firm: {dead ? Dead : !account.active - ? Inactive + ? Hit DLL : account.hasPosition ? In Trade - : Flat} + : account.targetHit + ? Target Hit + : Flat} void; hideDead: boolean; + sortKey: SortKey | null; + sortDir: SortDir; }) { const [open, setOpen] = useState(true); + const sortedAccounts = sortKey === null ? state.accounts : [...state.accounts].sort((a, b) => { + const dir = sortDir === 'asc' ? 1 : -1; + switch (sortKey) { + case 'name': return dir * a.name.localeCompare(b.name); + case 'balance': return dir * (a.amount - b.amount); + case 'dayPnL': return dir * (a.realizedPnL - b.realizedPnL); + case 'daysTraded': return dir * (a.daysTraded - b.daysTraded); + case 'target': { + const at = getAccountConfig(a.name, firm)?.profitTarget ?? 0; + const bt = getAccountConfig(b.name, firm)?.profitTarget ?? 0; + return dir * (at - bt); + } + case 'status': return dir * (statusRank(a) - statusRank(b)); + default: return 0; + } + }); + return ( <> ) : ( - state.accounts.map((acc) => ( + sortedAccounts.map((acc) => ( )) ) @@ -158,6 +217,14 @@ function FirmRows({ state, firm, deleteMode, selected, onToggle, hideDead }: { ); } + +interface SchedulerStatus { + running: boolean; + action: 'Buy' | 'Sell'; + symbol: string; + lastRun: string | null; +} + export default function Home() { const router = useRouter(); const [config, setConfig] = useState([]); @@ -165,6 +232,24 @@ export default function Home() { const [deleteMode, setDeleteMode] = useState(false); const [selected, setSelected] = useState>(new Set()); const [hideDead, setHideDead] = useState(false); + const [sortKey, setSortKey] = useState(null); + const [sortDir, setSortDir] = useState('asc'); + + // Trade controls + const [scheduler, setScheduler] = useState({ running: false, action: 'Buy', symbol: 'NQ', lastRun: null }); + const [enabledSymbols, setEnabledSymbols] = useState([]); + const [tradeSymbol, setTradeSymbol] = useState(''); + const [tradeAction, setTradeAction] = useState<'Buy' | 'Sell'>('Buy'); + const [tradeLoading, setTradeLoading] = useState(false); + + const handleSort = (col: SortKey) => { + if (sortKey === col) { + setSortDir((d) => d === 'asc' ? 'desc' : 'asc'); + } else { + setSortKey(col); + setSortDir('asc'); + } + }; const fetchConfig = async () => { try { @@ -178,8 +263,25 @@ export default function Home() { } }; + const fetchScheduler = async () => { + try { + const res = await fetch('/api/auto-trade'); + if (res.ok) setScheduler(await res.json()); + } catch { /* ignore */ } + }; + useEffect(() => { + fetch('/api/instruments') + .then((r) => r.json() as Promise<{ symbol: string; enabled: boolean }[]>) + .then((instruments) => { + const syms = instruments.filter((i) => i.enabled).map((i) => i.symbol); + setEnabledSymbols(syms); + setTradeSymbol((prev) => prev || syms[0] || 'NQ'); + }) + .catch(() => {}); + fetchConfig(); + fetchScheduler(); const fetchState = async () => { try { @@ -194,10 +296,30 @@ export default function Home() { }; fetchState(); - const interval = setInterval(fetchState, 5000); - return () => clearInterval(interval); + const stateInterval = setInterval(fetchState, 5000); + const schedulerInterval = setInterval(fetchScheduler, 5000); + return () => { clearInterval(stateInterval); clearInterval(schedulerInterval); }; }, []); + const handleStart = async () => { + setTradeLoading(true); + try { + await fetch('/api/trade', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: tradeAction, symbol: tradeSymbol }), + }); + await fetchScheduler(); + } finally { + setTradeLoading(false); + } + }; + + const handleStop = async () => { + await fetch('/api/auto-trade', { method: 'DELETE' }); + setScheduler((s) => ({ ...s, running: false, lastRun: null })); + }; + // 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); @@ -285,16 +407,74 @@ export default function Home() { )} + {/* ── Trade Controls ── */} +
+ {scheduler.running ? ( + <> + + + {scheduler.symbol} · {scheduler.action} + + {scheduler.lastRun && ( + + last run {new Date(scheduler.lastRun).toLocaleTimeString()} + + )} + + + ) : ( + <> + + Idle +
+ +
+ + +
+
+ + + )} +
+
- - - - - - + + + + + + @@ -316,6 +496,8 @@ export default function Home() { selected={selected.has(cfg.id)} onToggle={() => toggleSelected(cfg.id)} hideDead={hideDead} + sortKey={sortKey} + sortDir={sortDir} /> ); }) diff --git a/app/settings/page.tsx b/app/settings/page.tsx index 5036781..6eb2fa7 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -8,13 +8,28 @@ interface Instrument { enabled: boolean; } +interface AppSettings { + max_concurrent_accounts: string | null; +} + export default function SettingsPage() { const [instruments, setInstruments] = useState([]); + const [maxConcurrent, setMaxConcurrent] = useState('5'); + const [saving, setSaving] = useState(false); + const [saved, setSaved] = useState(false); useEffect(() => { fetch('/api/instruments') .then((r) => r.json()) .then(setInstruments); + + fetch('/api/settings') + .then((r) => r.json()) + .then((s: AppSettings) => { + if (s.max_concurrent_accounts != null) { + setMaxConcurrent(s.max_concurrent_accounts); + } + }); }, []); async function toggle(symbol: string, enabled: boolean) { @@ -28,6 +43,19 @@ export default function SettingsPage() { }); } + async function saveSettings() { + setSaving(true); + setSaved(false); + await fetch('/api/settings', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ max_concurrent_accounts: maxConcurrent }), + }); + setSaving(false); + setSaved(true); + setTimeout(() => setSaved(false), 2000); + } + return (
@@ -42,6 +70,43 @@ export default function SettingsPage() {

Settings

+ {/* ── Trading ── */} +

+ Trading +

+
+
+
+

Max concurrent accounts

+

+ How many accounts trade in parallel per /api/trade call +

+
+
+ setMaxConcurrent(e.target.value)} + className="w-20 rounded-lg border border-slate-200 bg-slate-50 px-3 py-1.5 text-sm text-right font-mono text-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + +
+
+
+ + {/* ── Instruments ── */}

Instruments

diff --git a/lib/auto-trade.ts b/lib/auto-trade.ts new file mode 100644 index 0000000..3475b48 --- /dev/null +++ b/lib/auto-trade.ts @@ -0,0 +1,271 @@ +/** + * Auto-trade scheduler + * + * When a trade is triggered (POST /api/trade), the scheduler stores the + * action + symbol and fires the same trade logic every 60 seconds to pick + * up accounts that were busy (in a position) at the time of the original + * signal but have since exited and are now eligible. + */ + +import { getFirms } from './db'; +import { getClients } from './clients'; +import { computeDailyTarget, POINT_VALUES } from './trading-logic'; +import { getSetting } from './db'; +import type { FirmConfig, AccountConfig } from '@/types'; +import type { FirmWithAccounts } from './db'; + +// ── helpers ────────────────────────────────────────────────────────────────── + +function isAccountDead(amount: number, autoLiqThreshold: number): boolean { + return autoLiqThreshold > 0 && amount <= autoLiqThreshold; +} + +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)); +} + +/** Map DB row (snake_case) → FirmConfig (camelCase) to fix field-name mismatch. */ +function mapFirmConfig(firm: FirmWithAccounts): FirmConfig { + return { + id: firm.id, + firm: firm.name, + username: firm.username, + password: firm.password, + accounts: firm.accounts.map((a) => ({ + prefix: a.prefix, + profitTarget: a.profit_target, + consistency: a.consistency, + minDayPnL: a.min_day_pnl, + minTradingDays: a.min_trading_days, + accountSize: a.account_size, + maxLoss: a.max_loss, + })), + }; +} + + + +// ── core trade logic ────────────────────────────────────────────────────────── + +export async function runTrade(action: 'Buy' | 'Sell', symbol: string) { + const pointValue = POINT_VALUES[symbol]; + if (!pointValue) throw new Error(`Unknown symbol: ${symbol}`); + + const maxConcurrent = parseInt(getSetting('max_concurrent_accounts') ?? '5', 10); + const firms = getFirms(); + const clients = getClients(); + + // ── Phase 1: collect ALL eligible accounts across ALL firms in parallel ── + type EligibleItem = { + firmName: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + client: any; + acc: { id: number; name: string; active: boolean }; + contract: { name: string; tickSize: number }; + firmConfig: FirmConfig; + cash: { amount: number; realizedPnL: number }; + dailyPnL: { date: string; pnl: number }[]; + daysTraded: number; + }; + + const allEligible: EligibleItem[] = []; + + await Promise.all(firms.map(async (firm) => { + const client = clients.get(firm.id); + if (!client || client.accountList.length === 0) return; + + const firmConfig = mapFirmConfig(firm); + + const contract = await client.findFrontMonthContract(symbol); + if (!contract) return; + + for (const acc of client.accountList) { + const cash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 }; + const autoLiqThreshold = client.autoLiqThresholds[acc.id] ?? 0; + const dailyPnL: { date: string; pnl: number }[] = client.dailyPnL[acc.id] ?? []; + const daysTraded: number = client.daysTraded[acc.id] ?? 0; + + if (isAccountDead(cash.amount, autoLiqThreshold)) continue; + if (!acc.active) continue; + if (client.positions[acc.id]) continue; + + const cfg = getAccountConfig(acc.name, firmConfig); + if (!cfg) continue; + + const totalProfit = dailyPnL.reduce((sum, d) => sum + d.pnl, 0); + + // Only trade accounts that haven't traded yet today + if (cash.realizedPnL !== 0) continue; + + // Use the same target formula as the dashboard — skip if $0 (challenge complete) + const target = computeDailyTarget(cfg.profitTarget, cfg.consistency, totalProfit, dailyPnL); + if (target.amount <= 0) continue; + + allEligible.push({ firmName: firm.name, client, acc, contract, firmConfig, cash, dailyPnL, daysTraded }); + } + })); + + if (allEligible.length === 0) { + console.log('[auto-trade] no eligible accounts found'); + return []; + } + + // Take only the first batch — all fired simultaneously, no rolling pool. + // Remaining accounts wait for the next tick (which only fires once all positions are flat). + const batch = allEligible.slice(0, maxConcurrent); + console.log(`[auto-trade] ${allEligible.length} eligible account(s) — firing batch of ${batch.length}`); + + // ── Phase 2: fire the batch simultaneously ── + const tradeResults = await Promise.allSettled(batch.map(async (item) => { + const { client, acc, contract, firmConfig, cash, dailyPnL, daysTraded } = item; + const cfg = getAccountConfig(acc.name, firmConfig)!; + const totalProfit = dailyPnL.reduce((sum, d) => sum + d.pnl, 0); + const target = computeDailyTarget(cfg.profitTarget, cfg.consistency, totalProfit, dailyPnL); + + const contracts = Math.max(1, Math.ceil(target.amount / 1000)); + const fill = await client.sendOrder(acc.id, contract.name, contracts, action, 'Market'); + + // Wait briefly for the cash balance WebSocket update to reflect entry commission + await new Promise(r => setTimeout(r, 1000)); + const updatedCash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 }; + // After entry, realizedPnL = -entryCommission (was 0 before), so abs = entry fee paid. + // Fall back to $2.50/contract if the WS hasn't updated yet (guarantees at least 1 extra tick). + const entryCommission = Math.abs(updatedCash.realizedPnL) || (2.5 * contracts); + const totalCommission = entryCommission * 2; // entry + exit round-trip + const grossTarget = target.amount + totalCommission; + + const targetPoints = grossTarget / (pointValue * contracts); + const ticks = Math.ceil(targetPoints / contract.tickSize); + const exitPrice = action === 'Buy' + ? fill.price + (ticks * contract.tickSize) + : fill.price - (ticks * contract.tickSize); + + const exitAction: 'Buy' | 'Sell' = action === 'Buy' ? 'Sell' : 'Buy'; + const exitOrder = await client.placeOrderNoWait(acc.id, contract.name, contracts, exitAction, 'Limit', exitPrice); + + console.log(`[auto-trade] ${acc.name} (${item.firmName}) ${action} ${contracts}x${symbol} @ ${fill.price} | target $${target.amount} [${target.path}] (+$${totalCommission.toFixed(2)} comm) | exit @ ${exitPrice} (orderId=${exitOrder.orderId})`); + + return { + account: acc.name, + firm: item.firmName, + status: 'filled', + contracts, + target: target.amount, + grossTarget, + totalCommission, + targetPath: target.path, + entryPrice: fill.price, + exitPrice, + commission: entryCommission, + }; + })); + + // Group results by firm for the response + const firmResultsMap = new Map(); + for (let i = 0; i < batch.length; i++) { + const firmName = batch[i].firmName; + if (!firmResultsMap.has(firmName)) firmResultsMap.set(firmName, []); + const r = tradeResults[i]; + firmResultsMap.get(firmName)!.push( + r.status === 'fulfilled' + ? r.value + : { status: 'error', reason: (r.reason as any)?.message ?? String(r.reason) } + ); + } + + return Array.from(firmResultsMap.entries()).map(([firm, results]) => ({ firm, results })); +} + +// ── scheduler ───────────────────────────────────────────────────────────────── + +interface SchedulerState { + action: 'Buy' | 'Sell'; + symbol: string; + intervalId: ReturnType | null; + lastRun: Date | null; + running: boolean; +} + +// Global singleton (survives HMR in dev via module cache) +const _global = globalThis as typeof globalThis & { __autoTrader?: SchedulerState }; + +function getState(): SchedulerState { + if (!_global.__autoTrader) { + _global.__autoTrader = { action: 'Buy', symbol: 'NQ', intervalId: null, lastRun: null, running: false }; + } + return _global.__autoTrader; +} + +export function startScheduler(action: 'Buy' | 'Sell', symbol: string) { + const state = getState(); + + // Clear any existing interval + if (state.intervalId !== null) { + clearInterval(state.intervalId); + } + + state.action = action; + state.symbol = symbol; + state.running = true; + + const tick = async () => { + if (!state.running) return; + state.lastRun = new Date(); + + // Skip this tick until every client has completed its initial sync (positions are populated) + const clients = getClients(); + const firms = getFirms(); + const notReady = firms.filter(f => { + const c = clients.get(f.id); + return c && !c.syncComplete; + }); + if (notReady.length > 0) { + console.log(`[scheduler] waiting for sync: ${notReady.map(f => f.name).join(', ')}`); + return; + } + + // Skip this tick if any account still has an open position from the previous batch + const openPositions = firms.reduce((count, firm) => { + const client = clients.get(firm.id); + if (!client) return count; + return count + client.accountList.filter(acc => !!client.positions[acc.id]).length; + }, 0); + if (openPositions > 0) { + console.log(`[scheduler] ${openPositions} position(s) still open — skipping tick`); + return; + } + + try { + const results = await runTrade(state.action, state.symbol); + const filled = results.flatMap((r: any) => r.results ?? []).filter((r: any) => r.status === 'filled').length; + if (filled > 0) console.log(`[scheduler] tick: ${filled} account(s) filled`); + } catch (err) { + console.error('[scheduler] tick error:', err); + } + }; + + state.intervalId = setInterval(tick, 60_000); + console.log(`[scheduler] started — ${action} ${symbol} every 60s`); +} + +export function stopScheduler() { + const state = getState(); + if (state.intervalId !== null) { + clearInterval(state.intervalId); + state.intervalId = null; + } + state.running = false; + console.log('[scheduler] stopped'); +} + +export function getSchedulerStatus() { + const state = getState(); + return { + running: state.running, + action: state.action, + symbol: state.symbol, + lastRun: state.lastRun, + }; +} diff --git a/lib/clients.ts b/lib/clients.ts index 9e1bd9d..6aa8a9b 100644 --- a/lib/clients.ts +++ b/lib/clients.ts @@ -1,7 +1,5 @@ import { TradovateClient } from './tradovate-class'; -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']; +import { getFirms } from './db'; // Use global to persist the client pool across HMR reloads in dev mode const g = global as typeof globalThis & { @@ -18,31 +16,31 @@ function ensureMap(): Map { export function initClient(id: number, username: string, password: string, firmName: string): TradovateClient { const map = ensureMap(); - let feesInitialized = false; - const client = new TradovateClient(username, password, async () => { + const client = new TradovateClient(username, password, () => { console.log(`[${firmName}] sync complete — ${client.accountList.length} account(s)`); - if (!feesInitialized) { - feesInitialized = true; - try { - const fees = await client.fetchInstrumentFees(SYMBOLS); - for (const [symbol, fee] of Object.entries(fees)) { - upsertFirmFee(id, symbol, fee, parseFloat((fee * 2).toFixed(4))); - } - const count = Object.keys(fees).length; - if (count > 0) console.log(`[${firmName}] Auto-fetched fees for ${count} symbol(s)`); - } catch (err) { - console.error(`[${firmName}] Failed to auto-fetch fees`, err); - } - } }); map.set(id, client); return client; } export function removeClient(id: number): void { + const client = ensureMap().get(id); + client?.disconnect(); ensureMap().delete(id); } +/** Disconnect all clients and clear the pool so they are recreated on next getClients() call. */ +export function resetClients(): void { + const map = g.__tradovateClients; + if (map) { + for (const client of map.values()) { + try { client.disconnect(); } catch { /* ignore */ } + } + } + g.__tradovateClients = undefined; + g.__tradovateClientsInitialized = false; +} + export function getClients(): Map { const map = ensureMap(); if (!g.__tradovateClientsInitialized) { diff --git a/lib/db.ts b/lib/db.ts index cd3b532..7a02caa 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -23,14 +23,6 @@ db.exec(` 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 @@ -98,13 +90,6 @@ export interface FirmWithAccounts extends FirmRow { accounts: AccountConfigRow[]; } -export interface FirmFee { - firmId: number; - symbol: string; - allinFee: number; - roundtripFee: number; -} - // ── Firms ─────────────────────────────────────────────────────────────────── export function getFirms(): FirmWithAccounts[] { @@ -174,30 +159,26 @@ export function updateAccountConfig(id: number, data: { return result.changes > 0; } -// ── Firm Fees ──────────────────────────────────────────────────────────────── +// ── Settings ───────────────────────────────────────────────────────────────── -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, - })); +db.exec(` + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); +`); + +// Seed defaults if missing +const seedSetting = db.prepare(`INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)`); +seedSetting.run('max_concurrent_accounts', '5'); + +export function getSetting(key: string): string | null { + const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key) as { value: string } | undefined; + return row?.value ?? null; } -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); +export function setSetting(key: string, value: string): void { + db.prepare(`INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`).run(key, value); } // ── Instruments ────────────────────────────────────────────────────────────── diff --git a/lib/trading-logic.ts b/lib/trading-logic.ts index de54d80..2c9a9e6 100644 --- a/lib/trading-logic.ts +++ b/lib/trading-logic.ts @@ -1,3 +1,12 @@ +/** Dollar-per-point value for common futures products. */ +export const POINT_VALUES: { [symbol: 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, +}; + /** * Compute the next trading day's profit target for an account. * diff --git a/lib/tradovate-class.ts b/lib/tradovate-class.ts index 01c1a82..79951be 100644 --- a/lib/tradovate-class.ts +++ b/lib/tradovate-class.ts @@ -3,6 +3,7 @@ import axios from 'axios'; import type { AccountItem, AuthLoginResponse, Contract } from './tradovate-helpers'; import { computeSec, randomUUIDV4 } from './tradovate-helpers'; +import { POINT_VALUES } from './trading-logic'; export class TradovateClient { private name: string; @@ -31,11 +32,30 @@ export class TradovateClient { /** Balance floor set by the prop firm — account is blown when amount <= this value (0 = not set) */ public autoLiqThresholds: { [accountId: number]: number } = {}; + /** True once fetchDaysTraded() has finished its last full run */ + public fetchDaysComplete = false; + /** Last error per account name from fetchDaysTraded() */ + public lastFetchErrors: Record = {}; + /** Raw reports API response data per account (first 200 chars) for debugging */ + public lastFetchRaw: Record = {}; + public products: { id: number; name: string }[] = []; + + /** Rolling buffer of the last 50 raw entity events — useful for debugging */ + public recentEntityEvents: { entityType: string; eventType: string; entity: any; ts: number }[] = []; + + /** True once the first requestSync has completed and positions/balances are populated. */ + public syncComplete = false; + + private ws: WebSocket; private callbackOnSyncRequest: () => Promise; + /** Incrementing ID for outgoing WebSocket messages — ensures concurrent orders don't clobber each other's callbacks. */ + private nextMsgId = 100; + private getMsgId(): number { return this.nextMsgId++; } + // Events that we sent out, and tradovate gives us a response for the id we sent out private directEventCallbacks: { [id: number]: (response: any) => void; @@ -48,7 +68,6 @@ export class TradovateClient { | 'command' | 'commandReport' | 'fill' - | 'fillFee' | 'executionReport' | 'cashBalance'; eventType: 'Created' | 'Updated'; @@ -151,7 +170,39 @@ export class TradovateClient { } } - // console.log('No callback found', response.d); + // Buffer recent entity events (last 50) + if (response.d?.entityType) { + this.recentEntityEvents.push({ entityType: response.d.entityType, eventType: response.d.eventType, entity: response.d.entity, ts: Date.now() }); + if (this.recentEntityEvents.length > 50) this.recentEntityEvents.shift(); + } + + + + // Update positions from WebSocket position events + if (response.d?.entityType === 'position' && response.d?.entity) { + const pos = response.d.entity; + if (pos.netPos !== 0) { + this.positions[pos.accountId] = { + contractId: pos.contractId, + netPos: pos.netPos, + netPrice: pos.netPrice, + timestamp: new Date(pos.timestamp), + }; + } else { + delete this.positions[pos.accountId]; + } + } + + // Update cash balances from WebSocket cashBalance events + if (response.d?.entityType === 'cashBalance' && response.d?.entity) { + const cb = response.d.entity; + if (cb.accountId) { + this.accountCashBalances[cb.accountId] = { + amount: cb.amount, + realizedPnL: cb.realizedPnL, + }; + } + } } } } else if (event.data[0] === 'h') { @@ -238,6 +289,7 @@ export class TradovateClient { this.fetchDaysTraded(); if (this.products.length > 0) { + this.syncComplete = true; this.callbackOnSyncRequest(); return; } @@ -247,6 +299,7 @@ export class TradovateClient { this.products = products.map((p: any) => ({ id: p.id, name: p.name })); console.log(`Loaded ${this.products.length} products`); } + this.syncComplete = true; this.callbackOnSyncRequest(); }; this.ws.send('product/list\n30\n\n'); @@ -260,6 +313,7 @@ export class TradovateClient { private async fetchDaysTraded(): Promise { if (!this.accessInfo?.accessToken) return; + this.fetchDaysComplete = false; const now = new Date(); const start = new Date(); @@ -273,7 +327,10 @@ export class TradovateClient { for (const account of this.accountList) { try { - const res = await axios.post( + const authHeaders = { Authorization: `Bearer ${this.accessInfo.accessToken}` }; + + // Step 1 — request the report + let reportData = (await axios.post( 'https://rpt-demo.tradovateapi.com/v1/reports/requestreport', { name: 'Fills', @@ -287,11 +344,29 @@ export class TradovateClient { representationType: 'json', timezone: 0, }, - { headers: { Authorization: `Bearer ${this.accessInfo.accessToken}` } } - ); + { headers: authHeaders } + )).data; + + // Step 2 — if the report is queued, poll until it's ready + let pollAttempts = 0; + while (reportData?.['p-ticket'] && pollAttempts < 30) { + const pTicket: string = reportData['p-ticket']; + const pTime: number = Math.max(1, reportData['p-time'] ?? 1); + await new Promise((r) => setTimeout(r, pTime * 1000)); + reportData = (await axios.get( + 'https://rpt-demo.tradovateapi.com/v1/reports/getreport', + { params: { 'p-ticket': pTicket }, headers: authHeaders } + )).data; + pollAttempts++; + } + + if (!this.lastFetchRaw) this.lastFetchRaw = {}; + this.lastFetchRaw[account.name] = JSON.stringify(reportData).slice(0, 500); + // _tradeDate is unquoted in the response (invalid JSON), but the "Date" field // ("M/D/YY") is a valid quoted string that already reflects CME trade date. - const raw: string = (res.data?.data ?? '[]') + const rawResponse = reportData?.data ?? '[]'; + const raw: string = String(rawResponse) .replace(/"_tradeDate":\s*(\d{4}-\d{2}-\d{2})/g, '"_tradeDate": "$1"'); type Fill = { _tradeDate: string; @@ -306,14 +381,7 @@ export class TradovateClient { 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, - }; + // POINT_VALUES imported from trading-logic.ts // FIFO P&L computation: match buy/sell fills into round-trips // Both the opening and closing commissions are deducted on close. @@ -364,10 +432,14 @@ export class TradovateClient { .map(([date, pnl]) => ({ date, pnl: Math.round(pnl * 100) / 100 })) .sort((a, b) => a.date.localeCompare(b.date)); } catch (err) { - console.error(`[fetchDaysTraded] ${account.name}`, err); + const msg = err instanceof Error ? `${err.message}` : String(err); + console.error(`[fetchDaysTraded] ${account.name}:`, msg); + if (!this.lastFetchErrors) this.lastFetchErrors = {}; + this.lastFetchErrors[account.name] = msg; this.daysTraded[account.id] ??= 0; } } + this.fetchDaysComplete = true; } private async login(): Promise { @@ -431,6 +503,19 @@ export class TradovateClient { return res.data; } + async findFrontMonthContract(productName: string): Promise<{ id: number; name: string; tickSize: number } | null> { + if (!this.accessInfo?.accessToken) return null; + const res = await axios.get( + `https://demo.tradovateapi.com/v1/contract/suggest?t=${encodeURIComponent(productName)}&l=20`, + { headers: { Authorization: `Bearer ${this.accessInfo.accessToken}` } } + ); + const contracts: Array<{ id: number; name: string; status: string; providerTickSize: number }> = res.data ?? []; + // Front-month = first contract whose name starts with the product symbol (results are ordered front→back) + const match = contracts.find((c) => c.name.startsWith(productName)); + if (!match) return null; + return { id: match.id, name: match.name, tickSize: match.providerTickSize ?? 0.25 }; + } + async fetchInstrumentFees(symbols: string[]): Promise<{ [symbol: string]: number }> { if (!this.accessInfo?.accessToken || this.products.length === 0) return {}; @@ -494,61 +579,114 @@ export class TradovateClient { async sendOrder( accountId: number, - contractId: number, + contractSymbol: string, // e.g. "NQH6" — WebSocket placeorder requires "symbol" quantity: number, action: 'Buy' | 'Sell', orderType: 'Market' | 'Limit', price?: number - ): Promise { - if (!this.ws) { - console.log('Websocket not connected'); - return; - } + ): Promise> { + if (!this.ws) throw new Error('WebSocket not connected'); this.ws.send( `user/registeraudituseraction\n25\n\n${JSON.stringify({ - accountId: accountId, + accountId, actionType: action + orderType, - details: `DOM MESZ5: Buy ${orderType}, Buy ${quantity} ${orderType}${ - price ? ` ${price}` : '' - }, TIF Day`, + details: `${action} ${quantity} ${contractSymbol} ${orderType}${price ? ` @ ${price}` : ''}, TIF Day`, })}` ); return new Promise((resolve, reject) => { - this.directEventCallbacks[26] = (response: any) => { - // console.log('Order id', response?.orderId); - console.log('Order placed, order id: ', response); + const msgId = this.getMsgId(); + this.directEventCallbacks[msgId] = (response: any) => { + console.log(`[sendOrder] raw ack:`, JSON.stringify(response)); + if (typeof response === 'string' || !response) { + reject(new Error(typeof response === 'string' ? response : 'Empty response from order/placeorder')); + return; + } + // Tradovate returns the command object: { id, commandStatus, orderId, ... } + const orderId = response.orderId ?? response.id; + console.log(`[sendOrder] orderId=${orderId} status=${response.commandStatus}`); - // TODO: Implement for limit orders and rejected market orders - - // If we don't get a response within 5 seconds, reject the promise - setTimeout(() => { - reject(new Error('No response from order placement')); - }, 5000); + // Wait for the fill event — it carries the real execution price + const timeout = setTimeout(() => { + reject(new Error(`Order ${orderId} acknowledged but no fill within 30s (market may be closed)`)); + }, 30000); this.indirectEventCallbacks.push({ entityType: 'fill', eventType: 'Created', - validator: (item: any) => item?.orderId === response?.orderId, - callback: (response: any) => { - resolve(response); + validator: (item: any) => item?.orderId === orderId, + callback: (fill: any) => { + clearTimeout(timeout); + console.log(`[sendOrder] fill:`, JSON.stringify(fill)); + resolve(fill); }, }); }; this.ws.send( - `order/placeorder\n26\n\n${JSON.stringify({ - accountId: accountId, - action: action, - symbol: contractId, + `order/placeorder\n${msgId}\n\n${JSON.stringify({ + accountId, + action, + symbol: contractSymbol, orderQty: quantity, - orderType: orderType, - price: price, + orderType, + price, timeInForce: 'Day', text: 'DOM', })}` ); }); } + + /** Place an order and resolve as soon as the command is acknowledged (does not wait for fill). */ + async placeOrderNoWait( + accountId: number, + contractSymbol: string, + quantity: number, + action: 'Buy' | 'Sell', + orderType: 'Market' | 'Limit', + price?: number + ): Promise<{ orderId?: number }> { + if (!this.ws) throw new Error('WebSocket not connected'); + + const msgId = this.getMsgId(); + return new Promise((resolve, reject) => { + this.directEventCallbacks[msgId] = (response: any) => { + if (typeof response === 'string') { + reject(new Error(response)); + return; + } + resolve({ orderId: response?.orderId ?? response?.id }); + }; + + this.ws.send( + `order/placeorder\n${msgId}\n\n${JSON.stringify({ + accountId, + action, + symbol: contractSymbol, + orderQty: quantity, + orderType, + price, + timeInForce: 'Day', + text: 'DOM', + })}` + ); + }); + } + + /** Close the WebSocket connection and stop all intervals. Call before discarding the instance. */ + public disconnect(): void { + try { this.ws?.close(); } catch { /* ignore */ } + } + + /** Register a one-time callback for when a fill arrives for a given orderId. */ + onFill(orderId: number, callback: (fill: any) => void): void { + this.indirectEventCallbacks.push({ + entityType: 'fill', + eventType: 'Created', + validator: (item: any) => item?.orderId === orderId, + callback, + }); + } } diff --git a/types.ts b/types.ts index 214b45d..aa9c38b 100644 --- a/types.ts +++ b/types.ts @@ -26,6 +26,10 @@ export interface AccountState { hasPosition: boolean; /** Balance floor from Tradovate's auto-liquidation profile (0 = not set) */ autoLiqThreshold: number; + /** Sum of all historical daily P&L entries */ + totalProfit: number; + /** True when today's realizedPnL has met or exceeded the computed daily target */ + targetHit: boolean; } export interface FirmState {
AccountBalanceDay P&LDays TradedTargetStatus