diff --git a/README.md b/README.md index 8a9613f..2bdebde 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # AutoFirmer -Automated futures trading dashboard for prop firm accounts via Tradovate. +Automated futures trading dashboard for prop firm accounts via Tradovate and +TopstepX (ProjectX Gateway). --- @@ -81,7 +82,11 @@ stores the Tradovate login for each firm. master. The catalog is pulled at startup and then every 60 seconds 3. On the main page, each firm from the catalog appears. Firms without stored credentials are greyed out — enter the Tradovate username and password in the - fields beside the firm name and click **Save** + fields beside the firm name and click **Save**. A firm whose platform is + **TopstepX** on the master asks for an API key instead (generated in the + TopstepX platform under Settings → API); the username is optional to store + but the ProjectX login needs it before the firm can connect, so add it when + you have it. Password is unused on TopstepX 4. Accounts populate once the firm connects. Each is matched to an account type by its name prefix *and* its size, so a new account shows as **Unassigned** until its opening funding transaction has been fetched. Use the **Account diff --git a/app/accounts/[id]/page.tsx b/app/accounts/[id]/page.tsx index 2d28621..b71eb86 100644 --- a/app/accounts/[id]/page.tsx +++ b/app/accounts/[id]/page.tsx @@ -16,6 +16,7 @@ import { } from 'recharts'; import type { FirmConfig, FirmState, AccountState, AccountConfig } from '@/types'; import { stagePillClasses } from '@/lib/stage-colors'; +import { projectPerfectPath } from '@/lib/trading-logic'; /** Reassign this account to another of its firm's account types, or hand it * back to the auto-resolver. Mirrors the control on the dashboard. */ @@ -203,7 +204,18 @@ const MONTH_NAMES = [ ]; const DOW_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; -function CalendarMonth({ year, month, pnlMap, fundMap, stageMap }: { year: number; month: number; pnlMap: Map; fundMap: Map; stageMap: Map }) { +function CalendarMonth({ year, month, pnlMap, fundMap, stageMap, projPnlMap, projFundMap, projStageMap, payoutDate }: { + year: number; + month: number; + pnlMap: Map; + fundMap: Map; + stageMap: Map; + /** Perfect-path projection — rendered faded and dashed on days with no real data. */ + projPnlMap?: Map; + projFundMap?: Map; + projStageMap?: Map; + payoutDate?: string; +}) { const daysInMonth = new Date(year, month, 0).getDate(); const firstDow = new Date(year, month - 1, 1).getDay(); // 0 = Sunday @@ -250,9 +262,16 @@ function CalendarMonth({ year, month, pnlMap, fundMap, stageMap }: { year: numbe const key = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; const pnl = pnlMap.get(key); const fundAmt = fundMap.get(key); - const stage = stageMap.get(key); const hasData = pnl !== undefined; const positive = hasData && pnl! >= 0; + + // Projection only fills days with no real data. + const projPnl = !hasData && fundAmt === undefined ? projPnlMap?.get(key) : undefined; + const projFund = !hasData && fundAmt === undefined ? projFundMap?.get(key) : undefined; + const isPayout = !hasData && key === payoutDate; + const isProj = projPnl !== undefined || projFund !== undefined || isPayout; + const stage = stageMap.get(key) ?? (isProj ? projStageMap?.get(key) : undefined); + return (
@@ -273,7 +298,7 @@ function CalendarMonth({ year, month, pnlMap, fundMap, stageMap }: { year: numbe {day} {stage !== undefined && ( - + #{stage} )} @@ -290,6 +315,21 @@ function CalendarMonth({ year, month, pnlMap, fundMap, stageMap }: { year: numbe W/D {fundAmt >= 0 ? '+' : '−'}${fmt(Math.abs(fundAmt))} )} + {projPnl !== undefined && ( + + +${fmt(projPnl)} + + )} + {projFund !== undefined && ( + + W/D −${fmt(Math.abs(projFund))} + + )} + {isPayout && ( + + Payout ✓ + + )}
); })} @@ -433,9 +473,30 @@ export default function AccountPage() { const strokeGradId = 'equityStroke'; + // Perfect-path projection: what the future looks like if every trading day + // books exactly its plan, cycles complete, and a 50% withdrawal fires the + // day a cycle finishes. Same planner the trade path runs, so the numbers + // (bumps and drain amounts included) are what would actually be aimed for. + const projection = (cfg && !isDead) ? projectPerfectPath({ + accountId: account.id, + cfg, + cycleDailyPnL: account.dailyPnL ?? [], + fundTransactions, + balance: account.amount, + realizedPnL: account.realizedPnL, + }) : []; + const projPnlMap = new Map(projection.filter((p) => p.withdrawal === undefined && !p.payoutReady).map((p) => [p.date, p.pnl])); + const projFundMap = new Map(projection.filter((p) => p.withdrawal !== undefined).map((p) => [p.date, p.withdrawal!])); + const projStageMap = new Map(projection.map((p) => [p.date, p.stage])); + const payoutDate = projection.find((p) => p.payoutReady)?.date; + // Build calendar data const pnlMap = new Map(displayPnL.map((d) => [d.date, d.pnl])); - const allCalendarDates = [...displayPnL.map((d) => d.date), ...fundTransactions.map((f) => f.date)]; + const allCalendarDates = [ + ...displayPnL.map((d) => d.date), + ...fundTransactions.map((f) => f.date), + ...projection.map((p) => p.date), + ]; const calendarMonths = [...new Set(allCalendarDates.map((d) => d.slice(0, 7)))].sort(); // Build stage map: stage increments after each withdrawal (negative fund txn) @@ -511,7 +572,7 @@ export default function AccountPage() { Profit Target - Stage {account.stage} + Stage {account.stage}{account.stageDrain ? ' – Drain' : ''} } @@ -704,11 +765,25 @@ export default function AccountPage() { {/* Daily P&L Calendar */} {calendarMonths.length > 0 && (
-

Daily P&L

+
+

Daily P&L

+ {projection.length > 0 && ( + + Dashed days: the perfect path — planned targets booked daily, 50% withdrawals when a stage completes + + )} +
{calendarMonths.map((ym) => { const [y, m] = ym.split('-').map(Number); - return ; + return ( + + ); })}
diff --git a/app/api/debug/route.ts b/app/api/debug/route.ts index 185c249..b76a6b4 100644 --- a/app/api/debug/route.ts +++ b/app/api/debug/route.ts @@ -20,9 +20,9 @@ function dayPlanDiag(client: any, acc: { id: number; name: string }, tradeDate: const priorProfit = client.priorProfit?.[acc.id] ?? 0; const allFundTxns = client.fundTransactions?.[acc.id] ?? []; const combined = combineCmeSessions(cfg.combineCmeHolidays === true, client.dailyPnL[acc.id] ?? [], tradeDate, cash.realizedPnL); - const effective = resolveEffectiveConfig(cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity, cfg.withdrawalStages, priorProfit, allFundTxns); + const effective = resolveEffectiveConfig(cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity, cfg.withdrawalStages, priorProfit, allFundTxns, cfg.minDayPnL); const equityProfit = cash.amount - cfg.accountSize; - const plan = computeDayPlan(acc.id, combined.tradeDate, effective.profitTarget, effective.consistency, combined.dailyPnL, cfg.minDayPnL, effective.minTradingDays, equityProfit, combined.realizedPnL, cfg.splitEnabled); + const plan = computeDayPlan(acc.id, combined.tradeDate, effective.profitTarget, effective.consistency, combined.dailyPnL, effective.minDayPnL, effective.minTradingDays, equityProfit, combined.realizedPnL, cfg.splitEnabled, effective.drain); const priorDaysSum = combined.dailyPnL.filter((d) => d.date !== combined.tradeDate).reduce((s, d) => s + d.pnl, 0); const dayStartEquity = Math.round((equityProfit - combined.realizedPnL) * 100) / 100; @@ -40,6 +40,7 @@ function dayPlanDiag(client: any, acc: { id: number; name: string }, tradeDate: dayTarget: plan?.dayTarget ?? null, remainingToday: plan?.remainingToday ?? null, path: plan?.path ?? null, + drain: effective.drain, consistency: effective.consistency, hasTodayRow: combined.dailyPnL.some((d) => d.date === combined.tradeDate), combineCmeHolidays: cfg.combineCmeHolidays === true, diff --git a/app/api/firms/[id]/credentials/route.ts b/app/api/firms/[id]/credentials/route.ts index 19e1f77..60185b6 100644 --- a/app/api/firms/[id]/credentials/route.ts +++ b/app/api/firms/[id]/credentials/route.ts @@ -5,24 +5,35 @@ import { reconcileClients, removeClient } from '@/lib/clients'; type Params = { params: Promise<{ id: string }> }; -/** The Tradovate login for one firm. This is the only firm data this machine +/** The platform login for one firm. This is the only firm data this machine * owns — everything else comes from the master catalog, and these credentials - * never go the other way. */ + * never go the other way. + * + * Tradovate firms need username + password. TopstepX firms need the API key; + * username is optional for now (the loginKey call still wants it, so the + * client sits idle with a clear error until one is added) and password is + * unused. */ export async function PUT(req: NextRequest, { params }: Params) { const { id } = await params; const firm = getCachedFirm(id); if (!firm) return NextResponse.json({ error: 'Firm not found' }, { status: 404 }); - const body = await req.json() as { username?: string; password?: string }; + const body = await req.json() as { username?: string; password?: string; apiKey?: string }; const username = body.username?.trim() ?? ''; const password = body.password?.trim() ?? ''; - if (!username || !password) { + const apiKey = body.apiKey?.trim() ?? ''; + + if (firm.platform === 'topstepx') { + if (!apiKey) { + return NextResponse.json({ error: 'apiKey is required for a TopstepX firm' }, { status: 400 }); + } + } else if (!username || !password) { return NextResponse.json({ error: 'username and password are required' }, { status: 400 }); } try { - setCredentials(id, firm.name, username, password); + setCredentials(id, firm.name, username, password, apiKey); // Picks up the new login immediately rather than at the next restart. reconcileClients(); return NextResponse.json({ ok: true }); diff --git a/app/api/firms/route.ts b/app/api/firms/route.ts index 547129d..c375d04 100644 --- a/app/api/firms/route.ts +++ b/app/api/firms/route.ts @@ -18,11 +18,15 @@ export async function GET() { try { const result: FirmConfig[] = getCachedCatalog().map((firm) => { const creds = getCredentials(firm.id); + const hasCredentials = firm.platform === 'topstepx' + ? !!creds?.apiKey + : !!(creds?.username && creds?.password); return { id: firm.id, firm: firm.name, + platform: firm.platform, username: creds?.username ?? '', - hasCredentials: !!creds, + hasCredentials, bannedSymbols: firm.bannedSymbols, accounts: firm.accountTypes.map((a) => ({ id: a.id, diff --git a/app/api/state/route.ts b/app/api/state/route.ts index 970782b..65b30fb 100644 --- a/app/api/state/route.ts +++ b/app/api/state/route.ts @@ -44,17 +44,29 @@ export async function GET() { ); const dailyPnL = combined.dailyPnL; const totalProfit = dailyPnL.reduce((sum, d) => sum + d.pnl, 0); - // Days traded counts only days that hit minDayPnL (when set) — that's what - // the firm requires toward the min trading day rule. - const daysTraded: number = cfg && cfg.minDayPnL > 0 - ? dailyPnL.filter((d) => d.pnl >= cfg.minDayPnL).length - : combined.daysTraded; const autoLiqThreshold = client.autoLiqThresholds[acc.id] ?? 0; const isDead = autoLiqThreshold > 0 && cash.amount <= autoLiqThreshold; const allFundTxns = client.fundTransactions?.[acc.id] ?? []; const priorProfit = client.priorProfit?.[acc.id] ?? 0; // Stage = 1 + number of withdrawals (negative fund transactions) const stage = 1 + allFundTxns.filter((f) => f.amount < 0).length; + // Stage-resolved config: a drain stage swaps in its own min-day + // (default $5) and no profit target. + const effective = cfg ? resolveEffectiveConfig( + cfg.profitTarget, + cfg.consistency, + cfg.minTradingDays, + cfg.targetSameEquity, + cfg.withdrawalStages, + priorProfit, + allFundTxns, + cfg.minDayPnL + ) : null; + // Days traded counts only days that hit minDayPnL (when set) — that's what + // the firm requires toward the min trading day rule. + const daysTraded: number = effective && effective.minDayPnL > 0 + ? dailyPnL.filter((d) => d.pnl >= effective.minDayPnL).length + : combined.daysTraded; // Hide initial funding (amount === accountSize) from display const fundingAmount = cfg?.accountSize ?? inferredAccountSize; const displayFundTxns = fundingAmount !== null @@ -70,21 +82,12 @@ export async function GET() { let remainingToday: number | null = null; let effectiveProfitTarget: number | null = null; let nextStop: number | null = null; - if (cfg && !isDead) { - const effective = resolveEffectiveConfig( - cfg.profitTarget, - cfg.consistency, - cfg.minTradingDays, - cfg.targetSameEquity, - cfg.withdrawalStages, - priorProfit, - allFundTxns - ); + if (cfg && effective && !isDead) { const equityProfit = cash.amount - cfg.accountSize; const plan = computeDayPlan( acc.id, combined.tradeDate, effective.profitTarget, effective.consistency, - dailyPnL, cfg.minDayPnL, effective.minTradingDays, equityProfit, combined.realizedPnL, - cfg.splitEnabled + dailyPnL, effective.minDayPnL, effective.minTradingDays, equityProfit, combined.realizedPnL, + cfg.splitEnabled, effective.drain ); dailyTarget = plan ? { amount: plan.dayTarget, path: plan.path } : null; remainingToday = plan?.remainingToday ?? null; @@ -113,7 +116,7 @@ export async function GET() { // Effective profit target = max(stage target, consistency realTarget). // If a big day forces the consistency rule, the account must reach maxDay/consistency // in total trading profit for the stage, not just profitTarget. - const qualifying = cfg.minDayPnL === 0 ? dailyPnL : dailyPnL.filter((d) => d.pnl >= cfg.minDayPnL); + const qualifying = effective.minDayPnL === 0 ? dailyPnL : dailyPnL.filter((d) => d.pnl >= effective.minDayPnL); const maxDay = qualifying.length > 0 ? Math.max(...qualifying.map((d) => d.pnl)) : 0; const consistencyReal = (effective.consistency > 0 && effective.consistency < 1 && maxDay > 0) ? maxDay / effective.consistency : 0; @@ -192,6 +195,8 @@ export async function GET() { totalProfit, targetHit, stage, + // Current stage is a drain stage — dashboards label it "Stage N – Drain". + stageDrain: effective?.drain ?? false, dailyTarget, nextStop, remainingToday, diff --git a/app/page.tsx b/app/page.tsx index 3499b30..fbab7e9 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -186,26 +186,35 @@ function AccountTypeCell({ account, firm, onAssigned }: { ); } -/** Inline credential entry, shown to the right of a firm that has none. */ +/** Inline credential entry, shown to the right of a firm that has none. + * Tradovate wants username + password; TopstepX wants the API key (username + * optional for now, though login needs it eventually — password unused). */ function CredentialForm({ firm, onSaved }: { firm: FirmConfig; onSaved: () => void }) { + const isTopstepX = firm.platform === 'topstepx'; const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); + const [apiKey, setApiKey] = useState(''); const [saving, setSaving] = useState(false); const [error, setError] = useState(''); + const canSave = isTopstepX + ? apiKey.trim().length > 0 + : username.trim().length > 0 && password.trim().length > 0; + async function save() { - if (!username.trim() || !password.trim()) return; + if (!canSave) return; setSaving(true); setError(''); try { const res = await fetch(`/api/firms/${firm.id}/credentials`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ username, password }), + body: JSON.stringify(isTopstepX ? { username, apiKey } : { username, password }), }); if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error ?? 'Failed'); setUsername(''); setPassword(''); + setApiKey(''); onSaved(); } catch (err) { setError(err instanceof Error ? err.message : 'Failed'); @@ -220,21 +229,32 @@ function CredentialForm({ firm, onSaved }: { firm: FirmConfig; onSaved: () => vo
e.stopPropagation()}> setUsername(e.target.value)} /> - setPassword(e.target.value)} - onKeyDown={(e) => { if (e.key === 'Enter') void save(); }} - /> + {isTopstepX ? ( + setApiKey(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') void save(); }} + /> + ) : ( + setPassword(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') void save(); }} + /> + )} diff --git a/lib/account-type-resolver.ts b/lib/account-type-resolver.ts index 734130a..e244020 100644 --- a/lib/account-type-resolver.ts +++ b/lib/account-type-resolver.ts @@ -12,7 +12,7 @@ */ import type { CatalogAccountType } from './master-protocol'; -import type { TradovateClient } from './tradovate-class'; +import type { PlatformClient } from './platform-client'; import { getAssignment, setAssignment, @@ -69,10 +69,14 @@ export function matchAccountType( const byPrefix = types.filter((t) => accountName.startsWith(t.prefix)); if (byPrefix.length === 0) return { type: null, ambiguous: false }; - // No size yet — stay Unassigned and try again next sync. - if (accountSize === null) return { type: null, ambiguous: false }; - - const candidates = byPrefix.filter((t) => Math.abs(t.accountSize - accountSize) < 0.01); + // Exact size match first. An account type with size 0 means the account's + // balance starts at $0 (TopstepX Express style) — there is no funding + // transaction to infer a size from, so it matches on prefix alone, but + // only once every sized candidate has had its chance. + const sized = accountSize !== null + ? byPrefix.filter((t) => t.accountSize > 0 && Math.abs(t.accountSize - accountSize) < 0.01) + : []; + const candidates = sized.length > 0 ? sized : byPrefix.filter((t) => t.accountSize === 0); if (candidates.length === 0) return { type: null, ambiguous: false }; if (candidates.length === 1) return { type: candidates[0], ambiguous: false }; @@ -117,7 +121,7 @@ export function getAccountConfig(accountId: number): ResolvedAssignment { /** The size this account resolved to, cached once found so a later reset * deposit can't move it. */ -export function getInferredSize(accountId: number, client: TradovateClient): number | null { +export function getInferredSize(accountId: number, client: PlatformClient): number | null { const cached = loadAccountMeta(accountId, INFERRED_SIZE_KEY); if (cached !== null) { const value = Number(cached); @@ -130,7 +134,7 @@ export function getInferredSize(accountId: number, client: TradovateClient): num * Called after each Tradovate sync and after the catalog changes. */ export function resolveFirmAssignments( firmId: string, - client: TradovateClient, + client: PlatformClient, ): { assigned: number; unassigned: number; manual: number } { const firm = getCachedFirm(firmId); const types = firm?.accountTypes ?? []; @@ -184,6 +188,9 @@ export function resolveFirmAssignments( } if (matched.type) { + // A $0-start type matched on prefix alone — whatever number rode + // along (usually the live balance) is not an inferred size. + if (matched.type.accountSize === 0) matchedSize = 0; if (matchedSize !== null && cached === null) { saveAccountMeta(account.id, INFERRED_SIZE_KEY, String(matchedSize)); } diff --git a/lib/auto-trade.ts b/lib/auto-trade.ts index 69a4699..a9fbbb0 100644 --- a/lib/auto-trade.ts +++ b/lib/auto-trade.ts @@ -72,6 +72,22 @@ function obfuscationStopDollars( return stop; } +/** The instrument a leg actually trades: drain legs go on MNQ, hardcoded + * (like the extra-day path) — tiny targets belong on the micro regardless of + * the batch symbol. */ +async function legInstrument( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + client: any, + drain: boolean, + batchContract: { name: string; tickSize: number }, + batchPointValue: number, +): Promise<{ contract: { name: string; tickSize: number }; pointValue: number }> { + if (!drain) return { contract: batchContract, pointValue: batchPointValue }; + const mnq = await client.findFrontMonthContract('MNQ'); + if (!mnq) throw new Error('MNQ contract not found for drain trade'); + return { contract: mnq, pointValue: POINT_VALUES['MNQ'] }; +} + // ── per-account trade evaluation (shared by runTrade / copyTrade / hasRemaining…) ── interface AccountEvaluation { @@ -79,6 +95,8 @@ interface AccountEvaluation { reason: string; plan: DayPlan | null; isExtraDay: boolean; + /** Current stage is a drain stage — the leg trades MNQ regardless of the batch symbol. */ + drain: boolean; cash: { amount: number; realizedPnL: number }; dailyPnL: { date: string; pnl: number }[]; daysTraded: number; @@ -110,7 +128,7 @@ function evaluateAccountForTrade(client: any, acc: { id: number; name: string; a const dailyPnL = combined.dailyPnL; const daysTraded = combined.daysTraded; - const base = { plan: null, isExtraDay: false, cash, dailyPnL, daysTraded }; + const base = { plan: null, isExtraDay: false, drain: false, cash, dailyPnL, daysTraded }; if (isAccountDead(cash.amount, autoLiqThreshold)) return { ...base, eligible: false, reason: 'dead' }; if (!acc.active) return { ...base, eligible: false, reason: 'inactive (DLL)' }; if (client.positions[acc.id]) return { ...base, eligible: false, reason: 'already in trade' }; @@ -132,23 +150,25 @@ function evaluateAccountForTrade(client: any, acc: { id: number; name: string; a const priorProfit = client.priorProfit?.[acc.id] ?? 0; const allFundTxns = client.fundTransactions?.[acc.id] ?? []; const effective = resolveEffectiveConfig( - cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity, cfg.withdrawalStages, priorProfit, allFundTxns + cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity, cfg.withdrawalStages, priorProfit, allFundTxns, + cfg.minDayPnL ); const equityProfit = cash.amount - cfg.accountSize; const plan = computeDayPlan( acc.id, combined.tradeDate, effective.profitTarget, effective.consistency, - dailyPnL, cfg.minDayPnL, effective.minTradingDays, equityProfit, combined.realizedPnL, - cfg.splitEnabled + dailyPnL, effective.minDayPnL, effective.minTradingDays, equityProfit, combined.realizedPnL, + cfg.splitEnabled, effective.drain ); if (!plan) return { ...base, eligible: false, reason: 'invalid balance' }; const totalProfit = dailyPnL.reduce((sum, d) => sum + d.pnl, 0); - const isExtraDay = cfg.minDayPnL <= 0 + const isExtraDay = !effective.drain + && effective.minDayPnL <= 0 && effective.minTradingDays > daysTraded && totalProfit >= effective.profitTarget; - const evaluated = { plan, isExtraDay, cash, dailyPnL, daysTraded }; + const evaluated = { plan, isExtraDay, drain: effective.drain, cash, dailyPnL, daysTraded }; if (isExtraDay || !plan.splitAllowed) { // Legacy one-and-done gate: any realized PnL today means this account is @@ -157,7 +177,10 @@ function evaluateAccountForTrade(client: any, acc: { id: number; name: string; a return { ...evaluated, eligible: false, reason: `already traded today (realizedPnL=${combined.realizedPnL})` }; } if (!isExtraDay && plan.remainingToday <= 0) { - return { ...evaluated, eligible: false, reason: `target=$${plan.dayTarget} (challenge complete)` }; + const reason = effective.drain && plan.dayTarget === 0 + ? 'drain days booked — waiting for withdrawal' + : `target=$${plan.dayTarget} (challenge complete)`; + return { ...evaluated, eligible: false, reason }; } } else { // Split-leg mode: keep trading until the day's remaining target is used up. @@ -315,6 +338,7 @@ export async function runTrade(action: 'Buy' | 'Sell' | 'Auto', symbol: string) daysTraded: number; plan: DayPlan; isExtraDay: boolean; + drain: boolean; prefix: string; }; @@ -341,7 +365,7 @@ export async function runTrade(action: 'Buy' | 'Sell' | 'Auto', symbol: string) allEligible.push({ firmName: firm.name, client, acc, contract, cash: ev.cash, dailyPnL: ev.dailyPnL, daysTraded: ev.daysTraded, - plan: ev.plan, isExtraDay: ev.isExtraDay, + plan: ev.plan, isExtraDay: ev.isExtraDay, drain: ev.drain, prefix: getAccountConfig(acc.id).accountType?.prefix ?? '', }); } @@ -419,9 +443,13 @@ export async function runTrade(action: 'Buy' | 'Sell' | 'Auto', symbol: string) : item.plan.remainingToday; recordLeg(acc.id, tradeDate, legAmount); + const inst = await legInstrument(client, item.drain, contract, pointValue); + const legContract = inst.contract; + const pv = inst.pointValue; + const rawContracts = Math.max(1, Math.ceil(legAmount / 1000)); const contracts = cfg.maxPositionSize > 0 ? Math.min(rawContracts, cfg.maxPositionSize) : rawContracts; - const fill = await client.sendOrder(acc.id, contract.name, contracts, resolvedAction, 'Market'); + const fill = await client.sendOrder(acc.id, legContract.name, contracts, resolvedAction, 'Market'); // Wait briefly for the cash balance WebSocket update to reflect entry commission await new Promise(r => setTimeout(r, 1000)); @@ -433,35 +461,35 @@ export async function runTrade(action: 'Buy' | 'Sell' | 'Auto', symbol: string) const totalCommission = entryCommission * 2; // entry + exit round-trip const grossTarget = legAmount + totalCommission; - const targetPoints = grossTarget / (pointValue * contracts); - const ticks = Math.ceil(targetPoints / contract.tickSize); + const targetPoints = grossTarget / (pv * contracts); + const ticks = Math.ceil(targetPoints / legContract.tickSize); const exitPrice = resolvedAction === 'Buy' - ? fill.price + (ticks * contract.tickSize) - : fill.price - (ticks * contract.tickSize); + ? fill.price + (ticks * legContract.tickSize) + : fill.price - (ticks * legContract.tickSize); const exitAction: 'Buy' | 'Sell' = resolvedAction === 'Buy' ? 'Sell' : 'Buy'; const stopDollars = obfuscationStopDollars(cfg, item.dailyPnL, item.plan, tradeDate, legAmount); let exitOrder: { orderId?: number }; let stopPrice: number | null = null; if (Number.isFinite(stopDollars) && stopDollars > 0) { - const stopTicks = Math.max(1, Math.ceil(stopDollars / (pointValue * contracts) / contract.tickSize)); + const stopTicks = Math.max(1, Math.ceil(stopDollars / (pv * contracts) / legContract.tickSize)); stopPrice = resolvedAction === 'Buy' - ? fill.price - (stopTicks * contract.tickSize) - : fill.price + (stopTicks * contract.tickSize); + ? fill.price - (stopTicks * legContract.tickSize) + : fill.price + (stopTicks * legContract.tickSize); try { - exitOrder = await client.placeOcoExit(acc.id, contract.name, contracts, exitAction, exitPrice, stopPrice); + exitOrder = await client.placeOcoExit(acc.id, legContract.name, contracts, exitAction, exitPrice, stopPrice); } catch (err) { // The position is already open — never leave it exit-less. Fall // back to the classic lone limit and shout about it. console.error(`[auto-trade] ${acc.name}: placeoco REJECTED (${err instanceof Error ? err.message : err}) — falling back to lone limit exit, NO STOP`); stopPrice = null; - exitOrder = await client.placeOrderNoWait(acc.id, contract.name, contracts, exitAction, 'Limit', exitPrice); + exitOrder = await client.placeOrderNoWait(acc.id, legContract.name, contracts, exitAction, 'Limit', exitPrice); } } else { - exitOrder = await client.placeOrderNoWait(acc.id, contract.name, contracts, exitAction, 'Limit', exitPrice); + exitOrder = await client.placeOrderNoWait(acc.id, legContract.name, contracts, exitAction, 'Limit', exitPrice); } - console.log(`[auto-trade] ${acc.name} (${item.firmName}) ${resolvedAction} ${contracts}x${resolvedSymbol} @ ${fill.price} | leg $${legAmount} of day $${item.plan.dayTarget} (remaining $${item.plan.remainingToday}) [${item.plan.path}] (+$${totalCommission.toFixed(2)} comm) | exit @ ${exitPrice}${stopPrice !== null ? ` / stop @ ${stopPrice}` : ''} (orderId=${exitOrder.orderId})`); + console.log(`[auto-trade] ${acc.name} (${item.firmName}) ${resolvedAction} ${contracts}x${item.drain ? legContract.name : resolvedSymbol} @ ${fill.price} | leg $${legAmount} of day $${item.plan.dayTarget} (remaining $${item.plan.remainingToday}) [${item.plan.path}${item.drain ? ' drain' : ''}] (+$${totalCommission.toFixed(2)} comm) | exit @ ${exitPrice}${stopPrice !== null ? ` / stop @ ${stopPrice}` : ''} (orderId=${exitOrder.orderId})`); return { account: acc.name, @@ -516,7 +544,9 @@ export async function copyTrade() { // Find all accounts with open positions to determine direction + symbol let resolvedAction: 'Buy' | 'Sell' | null = null; - let positionContractId: number | null = null; + // number on Tradovate, string on TopstepX — only ever compared against + // contracts from the same client, so the union stays coherent. + let positionContractId: number | string | null = null; let positionedCount = 0; // eslint-disable-next-line @typescript-eslint/no-explicit-any let positionedClient: any = null; @@ -592,6 +622,7 @@ export async function copyTrade() { cash: { amount: number; realizedPnL: number }; dailyPnL: { date: string; pnl: number }[]; plan: DayPlan; + drain: boolean; prefix: string; }; const eligible: CopyItem[] = []; @@ -622,6 +653,7 @@ export async function copyTrade() { eligible.push({ firmName: firm.name, client, acc, contract, cash: ev.cash, dailyPnL: ev.dailyPnL, plan: ev.plan, + drain: ev.drain, prefix: getAccountConfig(acc.id).accountType?.prefix ?? '', }); } @@ -654,9 +686,13 @@ export async function copyTrade() { : item.plan.remainingToday; recordLeg(acc.id, tradeDate, legAmount); + const inst = await legInstrument(client, item.drain, contract, pointValue); + const legContract = inst.contract; + const pv = inst.pointValue; + const rawContracts = Math.max(1, Math.ceil(legAmount / 1000)); const contracts = cfg.maxPositionSize > 0 ? Math.min(rawContracts, cfg.maxPositionSize) : rawContracts; - const fill = await client.sendOrder(acc.id, contract.name, contracts, resolvedAction!, 'Market'); + const fill = await client.sendOrder(acc.id, legContract.name, contracts, resolvedAction!, 'Market'); await new Promise(r => setTimeout(r, 1000)); const updatedCash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 }; @@ -664,33 +700,33 @@ export async function copyTrade() { const totalCommission = entryCommission * 2; const grossTarget = legAmount + totalCommission; - const targetPoints = grossTarget / (pointValue * contracts); - const ticks = Math.ceil(targetPoints / contract.tickSize); + const targetPoints = grossTarget / (pv * contracts); + const ticks = Math.ceil(targetPoints / legContract.tickSize); const exitPrice = resolvedAction === 'Buy' - ? fill.price + (ticks * contract.tickSize) - : fill.price - (ticks * contract.tickSize); + ? fill.price + (ticks * legContract.tickSize) + : fill.price - (ticks * legContract.tickSize); const exitAction: 'Buy' | 'Sell' = resolvedAction === 'Buy' ? 'Sell' : 'Buy'; const stopDollars = obfuscationStopDollars(cfg, item.dailyPnL, item.plan, tradeDate, legAmount); let exitOrder: { orderId?: number }; let stopPrice: number | null = null; if (Number.isFinite(stopDollars) && stopDollars > 0) { - const stopTicks = Math.max(1, Math.ceil(stopDollars / (pointValue * contracts) / contract.tickSize)); + const stopTicks = Math.max(1, Math.ceil(stopDollars / (pv * contracts) / legContract.tickSize)); stopPrice = resolvedAction === 'Buy' - ? fill.price - (stopTicks * contract.tickSize) - : fill.price + (stopTicks * contract.tickSize); + ? fill.price - (stopTicks * legContract.tickSize) + : fill.price + (stopTicks * legContract.tickSize); try { - exitOrder = await client.placeOcoExit(acc.id, contract.name, contracts, exitAction, exitPrice, stopPrice); + exitOrder = await client.placeOcoExit(acc.id, legContract.name, contracts, exitAction, exitPrice, stopPrice); } catch (err) { console.error(`[copy-trade] ${acc.name}: placeoco REJECTED (${err instanceof Error ? err.message : err}) — falling back to lone limit exit, NO STOP`); stopPrice = null; - exitOrder = await client.placeOrderNoWait(acc.id, contract.name, contracts, exitAction, 'Limit', exitPrice); + exitOrder = await client.placeOrderNoWait(acc.id, legContract.name, contracts, exitAction, 'Limit', exitPrice); } } else { - exitOrder = await client.placeOrderNoWait(acc.id, contract.name, contracts, exitAction, 'Limit', exitPrice); + exitOrder = await client.placeOrderNoWait(acc.id, legContract.name, contracts, exitAction, 'Limit', exitPrice); } - console.log(`[copy-trade] ${acc.name} (${item.firmName}) ${resolvedAction} ${contracts}x${resolvedSymbol} @ ${fill.price} | leg $${legAmount} of day $${item.plan.dayTarget} (remaining $${item.plan.remainingToday}) [${item.plan.path}] | exit @ ${exitPrice}${stopPrice !== null ? ` / stop @ ${stopPrice}` : ''} (orderId=${exitOrder.orderId})`); + console.log(`[copy-trade] ${acc.name} (${item.firmName}) ${resolvedAction} ${contracts}x${item.drain ? legContract.name : resolvedSymbol} @ ${fill.price} | leg $${legAmount} of day $${item.plan.dayTarget} (remaining $${item.plan.remainingToday}) [${item.plan.path}${item.drain ? ' drain' : ''}] | exit @ ${exitPrice}${stopPrice !== null ? ` / stop @ ${stopPrice}` : ''} (orderId=${exitOrder.orderId})`); return { account: acc.name, diff --git a/lib/clients.ts b/lib/clients.ts index ec7864a..9c9da48 100644 Binary files a/lib/clients.ts and b/lib/clients.ts differ diff --git a/lib/db.ts b/lib/db.ts index eea6ecf..46eaea6 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -49,6 +49,15 @@ db.exec(` // Tradovate credentials, and a silent DROP would be unrecoverable if this // change had to be rolled back. Safe to drop by hand once it has stuck. +// Migration: TopstepX firms authenticate with an API key instead of a +// password. Kept in the same table so re-keying and reconciliation treat both +// platforms alike; a Tradovate row simply leaves it blank. +try { + db.exec("ALTER TABLE firm_credentials ADD COLUMN api_key TEXT NOT NULL DEFAULT ''"); +} catch { + // Column already exists +} + // ── Firm credentials ───────────────────────────────────────────────────────── export interface FirmCredentialRow { @@ -56,13 +65,15 @@ export interface FirmCredentialRow { firm_name: string; username: string; password: string; + api_key: string; updated_at: string; } -export function getCredentials(firmId: string): { username: string; password: string } | undefined { - const row = db.prepare('SELECT username, password FROM firm_credentials WHERE firm_id = ?').get(firmId) as - { username: string; password: string } | undefined; - return row; +export function getCredentials(firmId: string): { username: string; password: string; apiKey: string } | undefined { + const row = db.prepare('SELECT username, password, api_key FROM firm_credentials WHERE firm_id = ?').get(firmId) as + { username: string; password: string; api_key: string } | undefined; + if (!row) return undefined; + return { username: row.username, password: row.password, apiKey: row.api_key }; } export function listCredentials(): FirmCredentialRow[] { @@ -72,16 +83,17 @@ export function listCredentials(): FirmCredentialRow[] { /** firm_name is stored alongside the id so credentials can be re-keyed by name * if the master's database is ever rebuilt and the firms come back with new * ids. See rekeyCredentialsByName in lib/firm-catalog.ts. */ -export function setCredentials(firmId: string, firmName: string, username: string, password: string): void { +export function setCredentials(firmId: string, firmName: string, username: string, password: string, apiKey = ''): void { db.prepare(` - INSERT INTO firm_credentials (firm_id, firm_name, username, password, updated_at) - VALUES (?, ?, ?, ?, ?) + INSERT INTO firm_credentials (firm_id, firm_name, username, password, api_key, updated_at) + VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(firm_id) DO UPDATE SET firm_name = excluded.firm_name, username = excluded.username, password = excluded.password, + api_key = excluded.api_key, updated_at = excluded.updated_at - `).run(firmId, firmName, username, password, new Date().toISOString()); + `).run(firmId, firmName, username, password, apiKey, new Date().toISOString()); } export function clearCredentials(firmId: string): boolean { diff --git a/lib/firm-catalog.ts b/lib/firm-catalog.ts index 9a7dc3a..bd95723 100644 --- a/lib/firm-catalog.ts +++ b/lib/firm-catalog.ts @@ -99,17 +99,20 @@ export function getCatalogMeta(): { }; } -/** Firms this machine can actually trade: in the catalog AND holding - * credentials. Everything that starts a Tradovate client goes through here. */ -export function getTradableFirms(): (CatalogFirm & { username: string; password: string })[] { - const result: (CatalogFirm & { username: string; password: string })[] = []; +/** Firms this machine can actually trade: in the catalog AND holding the + * credentials their platform needs — a Tradovate username + password, or a + * TopstepX API key. Everything that starts a platform client goes through + * here. */ +export function getTradableFirms(): (CatalogFirm & { username: string; password: string; apiKey: string })[] { + const result: (CatalogFirm & { username: string; password: string; apiKey: string })[] = []; for (const firm of getCachedCatalog()) { - // Only Tradovate has a client. A TopstepX firm in the catalog is - // config-only for now; handing it to the Tradovate login would just - // burn attempts against the wrong platform. - if (firm.platform !== 'tradovate') continue; const creds = getCredentials(firm.id); - if (creds) result.push({ ...firm, ...creds }); + if (!creds) continue; + if (firm.platform === 'topstepx') { + if (creds.apiKey) result.push({ ...firm, ...creds }); + } else { + if (creds.username && creds.password) result.push({ ...firm, ...creds }); + } } return result; } diff --git a/lib/master-protocol.ts b/lib/master-protocol.ts index 8a8ee31..7729ab9 100644 --- a/lib/master-protocol.ts +++ b/lib/master-protocol.ts @@ -128,7 +128,11 @@ export interface CatalogAccountType { * firms that follow CME clearing report them (e.g. Apex: Sun/Mon/Tue around * a Monday holiday is ONE trading day, recorded on the Tuesday). */ combineCmeHolidays: boolean; - withdrawalStages: { profit: number; consistency: number; minTradingDays: number }[]; + /** Per-stage config after the Nth withdrawal; the last stage repeats. + * drain=true marks a spam-withdraw stage: no profit target — book + * minTradingDays qualifying days of ~minDayPnL (default $5, randomized + * $5–$15, traded as tiny MNQ legs), then wait for the next withdrawal. */ + withdrawalStages: { profit: number; consistency: number; minTradingDays: number; drain?: boolean; minDayPnL?: number }[]; /** ISO. Ties between two matching account types are broken by this. */ updatedAt: string; } diff --git a/lib/platform-client.ts b/lib/platform-client.ts new file mode 100644 index 0000000..2364770 --- /dev/null +++ b/lib/platform-client.ts @@ -0,0 +1,84 @@ +/** + * The surface a platform trading client must expose. TradovateClient grew this + * shape organically; TopstepXClient implements the same one so the scheduler, + * reporter, state route and resolver can drive either platform without caring + * which is behind the map entry. + * + * Contract ids are `number` on Tradovate and `string` (e.g. + * "CON.F.US.ENQ.Z25") on TopstepX — everything that compares them only ever + * compares within one client, so the union is safe. + */ + +export interface PlatformAccount { + id: number; + name: string; + active: boolean; +} + +export interface PlatformContract { + id: number | string; + name: string; + tickSize: number; +} + +export interface PlatformClient { + accountList: PlatformAccount[]; + accountCashBalances: { [accountId: number]: { amount: number; realizedPnL: number } }; + positions: { + [accountId: number]: { + contractId: number | string; + netPos: number; + netPrice: number; + timestamp: Date; + }; + }; + + daysTraded: { [accountId: number]: number }; + dailyPnL: { [accountId: number]: { date: string; pnl: number }[] }; + fullDailyPnL: { [accountId: number]: { date: string; pnl: number }[] }; + lastFundDates: { [accountId: number]: string | null }; + fundTransactions: { [accountId: number]: { date: string; amount: number }[] }; + priorProfit: { [accountId: number]: number }; + autoLiqThresholds: { [accountId: number]: number }; + + fetchDaysComplete: boolean; + syncComplete: boolean; + lastFetchErrors: Record; + lastFetchRaw: Record; + recentEntityEvents: { entityType: string; eventType: string; entity: unknown; ts: number }[]; + products: { id: number; name: string }[]; + + fetchDaysTraded(onlyAccountIds?: number[]): Promise<{ failedAccounts: number }>; + findFrontMonthContract(productName: string): Promise; + fetchInstrumentFees(symbols: string[]): Promise<{ [symbol: string]: number }>; + + sendOrder( + accountId: number, + contractSymbol: string, + quantity: number, + action: 'Buy' | 'Sell', + orderType: 'Market' | 'Limit', + price?: number + ): Promise>; + + placeOrderNoWait( + accountId: number, + contractSymbol: string, + quantity: number, + action: 'Buy' | 'Sell', + orderType: 'Market' | 'Limit', + price?: number + ): Promise<{ orderId?: number }>; + + placeOcoExit( + accountId: number, + contractSymbol: string, + quantity: number, + exitAction: 'Buy' | 'Sell', + limitPrice: number, + stopPrice: number, + ): Promise<{ orderId?: number }>; + + disconnect(): void; + onFill(orderId: number, callback: (fill: Record) => void): void; +} diff --git a/lib/reporter.ts b/lib/reporter.ts index 03ca1be..4626cf4 100644 --- a/lib/reporter.ts +++ b/lib/reporter.ts @@ -91,6 +91,16 @@ function collectFirmStats() { const combined = cfg ? combineCmeSessions( cfg.combineCmeHolidays, client.dailyPnL[acc.id] ?? [], currentTradeDateCT(), cash.realizedPnL, ) : null; + + // Stage-resolved config, shared by the payout state and the + // day-outcome check below — a drain stage swaps in its own + // min-day and drops the profit target. + const fundTxns = client.fundTransactions?.[acc.id] ?? []; + const effective = cfg ? resolveEffectiveConfig( + cfg.profitTarget, cfg.consistency, cfg.minTradingDays, + cfg.targetSameEquity, cfg.withdrawalStages, + client.priorProfit?.[acc.id] ?? 0, fundTxns, cfg.minDayPnL, + ) : null; if (cfg && combined && acc.active && cash.amount - cfg.accountSize >= 0) { const hist = (slice.daysHist ??= {}); hist[combined.daysTraded] = (hist[combined.daysTraded] ?? 0) + 1; @@ -100,12 +110,12 @@ function collectFirmStats() { // included, since the master sims their real required profit. // client.dailyPnL is already the active-cycle slice, so a // withdrawal resets days/maxDay/sum here automatically. - if (cfg && combined) { + if (cfg && combined && effective) { const r2 = (n: number) => Math.round(n * 100) / 100; const equityProfit = r2(cash.amount - cfg.accountSize); - const qualifying = cfg.minDayPnL === 0 + const qualifying = effective.minDayPnL === 0 ? combined.dailyPnL - : combined.dailyPnL.filter((d) => d.pnl >= cfg.minDayPnL); + : combined.dailyPnL.filter((d) => d.pnl >= effective.minDayPnL); const sum = r2(combined.dailyPnL.reduce((s, d) => s + d.pnl, 0)); // End-of-day equity-profit high-water mark for this cycle, // floored at the $0 start: walk the P&L from the cycle base. @@ -115,13 +125,6 @@ function collectFirmStats() { run = r2(run + d.pnl); if (run > highWater) highWater = run; } - // Effective target for this cycle: withdrawal stages applied. - const fundTxns = client.fundTransactions?.[acc.id] ?? []; - const effective = resolveEffectiveConfig( - cfg.profitTarget, cfg.consistency, cfg.minTradingDays, - cfg.targetSameEquity, cfg.withdrawalStages, - client.priorProfit?.[acc.id] ?? 0, fundTxns, - ); const traded = combined.dailyPnL.filter((d) => d.pnl !== 0); (slice.payoutStates ??= []).push({ days: qualifying.length, @@ -156,16 +159,17 @@ function collectFirmStats() { continue; } - // Check if target was hit today - if (cfg && combined) { + // Check if target was hit today — on the stage-resolved config, so + // a later stage's (or drain's) day counts the way it was traded. + if (cfg && combined && effective) { const equityProfit = cash.amount - cfg.accountSize; const plan = computeDayPlan( - acc.id, combined.tradeDate, cfg.profitTarget, cfg.consistency, - combined.dailyPnL, cfg.minDayPnL, cfg.minTradingDays, equityProfit, combined.realizedPnL, - cfg.splitEnabled + acc.id, combined.tradeDate, effective.profitTarget, effective.consistency, + combined.dailyPnL, effective.minDayPnL, effective.minTradingDays, equityProfit, combined.realizedPnL, + cfg.splitEnabled, effective.drain ); const targetHit = !plan ? false : - ((plan.dayTarget === 0 && Math.abs(combined.realizedPnL) > 0 && combined.daysTraded <= cfg.minTradingDays) || + ((plan.dayTarget === 0 && Math.abs(combined.realizedPnL) > 0 && combined.daysTraded <= effective.minTradingDays) || (combined.realizedPnL > 0 && plan.remainingToday <= LEG_COMPLETION_EPSILON)); if (targetHit || combined.realizedPnL !== 0) { diff --git a/lib/topstepx-class.ts b/lib/topstepx-class.ts new file mode 100644 index 0000000..0102e82 --- /dev/null +++ b/lib/topstepx-class.ts @@ -0,0 +1,933 @@ +/** + * TopstepX (ProjectX Gateway) client — the TopstepX twin of TradovateClient. + * + * REST: https://api.topstepx.com (auth via API key at POST /api/Auth/loginKey, + * 24h session token, refreshed through POST /api/Auth/validate). + * Realtime: SignalR user hub at https://rtc.topstepx.com/hubs/user, which + * pushes account balances, orders, positions and trades — the analogue of + * Tradovate's user-sync WebSocket. + * + * Differences from Tradovate that shape this file: + * - There is no cash-history / fund-transaction report. Daily P&L comes from + * POST /api/Trade/search (each half-turn carries fees; closing halves carry + * profitAndLoss), grouped into Central-time trade dates with the same 5 PM + * roll Tradovate uses. Deposits/withdrawals are invisible to the API, so + * fund transactions only ever come from whatever the local DB already holds. + * - There is no OCO order. placeOcoExit places the limit and stop separately + * and cancels the sibling the moment one fills (order events, plus a safety + * poll) — see the `ocoPairs` machinery. + * - There is no auto-liquidation profile to read, so autoLiqThresholds stays + * all zeros; a blown account shows up as canTrade=false → active=false. + */ + +import axios from 'axios'; +import * as signalR from '@microsoft/signalr'; +import { currentTradeDateCT } from './trading-logic'; +import { getCachedContract } from './contract-resolver'; +import { saveDailyPnL, loadDailyPnL, loadAccountMeta, saveAccountMeta, loadFundTransactions, saveFundTransactions } from './db'; +import type { PlatformClient, PlatformAccount, PlatformContract } from './platform-client'; + +const API_BASE = 'https://api.topstepx.com'; +const USER_HUB = 'https://rtc.topstepx.com/hubs/user'; + +interface TsxContract { + id: string; + name: string; + description: string; + tickSize: number; + tickValue: number; + activeContract: boolean; + symbolId: string; +} + +interface TsxTrade { + id: number; + accountId: number; + contractId: string; + creationTimestamp: string; + price: number; + profitAndLoss: number | null; // null = half-turn (entry only) + fees: number; + side: number; // 0 = Bid/Buy, 1 = Ask/Sell + size: number; + voided: boolean; + orderId: number; +} + +/** OrderStatus: 1 Open, 2 Filled, 3 Cancelled, 4 Expired, 5 Rejected, 6 Pending */ +const ORDER_FILLED = 2; +const ORDER_GONE = new Set([3, 4, 5]); + +const SIDE_BUY = 0; +const SIDE_SELL = 1; +const TYPE_LIMIT = 1; +const TYPE_MARKET = 2; +const TYPE_STOP = 4; + +export class TopstepXClient implements PlatformClient { + private name: string; + private apiKey: string; + private token: string | null = null; + + public accountList: PlatformAccount[] = []; + public accountCashBalances: { [accountId: number]: { amount: number; realizedPnL: number } } = {}; + public positions: { + [accountId: number]: { contractId: string; netPos: number; netPrice: number; timestamp: Date }; + } = {}; + + public daysTraded: { [accountId: number]: number } = {}; + public dailyPnL: { [accountId: number]: { date: string; pnl: number }[] } = {}; + public fullDailyPnL: { [accountId: number]: { date: string; pnl: number }[] } = {}; + public lastFundDates: { [accountId: number]: string | null } = {}; + public fundTransactions: { [accountId: number]: { date: string; amount: number }[] } = {}; + public priorProfit: { [accountId: number]: number } = {}; + public autoLiqThresholds: { [accountId: number]: number } = {}; + + public fetchDaysComplete = false; + public syncComplete = false; + public lastFetchErrors: Record = {}; + public lastFetchRaw: Record = {}; + public recentEntityEvents: { entityType: string; eventType: string; entity: unknown; ts: number }[] = []; + /** Tradovate-only concept; empty keeps fee lookups a clean no-op. */ + public products: { id: number; name: string }[] = []; + + private callbackOnSyncRequest: () => Promise; + private hub: signalR.HubConnection | null = null; + private subscribedAccountIds = new Set(); + + private syncInterval: ReturnType | null = null; + private tokenRenewalInterval: ReturnType | null = null; + private ocoPollInterval: ReturnType | null = null; + private loginRetryTimer: ReturnType | null = null; + private intentionalDisconnect = false; + private syncInFlight = false; + + /** Same hourly-with-retry throttle the Tradovate client uses for reports. */ + private daysFetchInFlight = false; + private lastDaysFetchStartedAt = 0; + private lastDaysFetchSucceededAt = 0; + private nextDaysFetchRetryAt = 0; + + /** Trades seen for the current CT trade date, per account — realizedPnL is + * recomputed from these on every REST sync and every live trade event. */ + private todayTrades: { [accountId: number]: Map } = {}; + + /** Front-month contract per product symbol, plus name→contract for order + * placement (sendOrder receives the contract NAME, e.g. "NQZ5"). */ + private contractByProduct = new Map(); + private contractByName = new Map(); + private static CONTRACT_TTL_MS = 30 * 60 * 1000; + + /** Fill waiters keyed by orderId — resolved by GatewayUserTrade events. */ + private fillWaiters: { orderId: number; callback: (trade: TsxTrade) => void }[] = []; + + /** Emulated OCO exit pairs: when one leg fills, cancel the other. */ + private ocoPairs: { accountId: number; limitId: number; stopId: number; done: boolean; placedAt: number }[] = []; + + constructor(name: string, apiKey: string, callbackOnSyncRequest: () => Promise) { + this.name = name; + this.apiKey = apiKey; + this.callbackOnSyncRequest = callbackOnSyncRequest; + this.connectAndAuth(); + } + + // ── auth ───────────────────────────────────────────────────────────────── + + private authHeaders() { + return { Authorization: `Bearer ${this.token}` }; + } + + private async post(path: string, body: unknown): Promise { + const res = await axios.post(`${API_BASE}${path}`, body, { headers: this.authHeaders() }); + const data = res.data as T & { success?: boolean; errorMessage?: string | null; errorCode?: number }; + if (data && data.success === false) { + throw new Error(`${path} failed: ${data.errorMessage ?? `errorCode=${data.errorCode}`}`); + } + return data; + } + + private connectAndAuth(): void { + if (this.intentionalDisconnect) return; + + if (!this.name) { + // loginKey requires the platform username the key was generated for. + // The key alone is storable (per the credential form), but nothing + // can be traded until the username arrives — say so loudly, once. + this.lastFetchErrors['login'] = + 'TopstepX username missing — the API key alone cannot log in. Add the username on the dashboard.'; + console.error(`[topstepx] ${this.lastFetchErrors['login']}`); + return; + } + + this.login() + .then(async (token) => { + this.token = token; + console.log(`[topstepx] logged in as ${this.name}`); + delete this.lastFetchErrors['login']; + + // Session tokens live 24h; re-validate every hour to stay fresh. + if (this.tokenRenewalInterval) clearInterval(this.tokenRenewalInterval); + this.tokenRenewalInterval = setInterval(() => void this.renewToken(), 60 * 60 * 1000); + + if (this.syncInterval) { clearInterval(this.syncInterval); this.syncInterval = null; } + this.syncComplete = false; + + await this.startHub(); + + await this.requestSync(); + this.syncInterval = setInterval(() => void this.requestSync(), 60_000); + }) + .catch((err) => { + const msg = err instanceof Error ? err.message : String(err); + this.lastFetchErrors['login'] = msg; + console.error(`[topstepx] login failed for ${this.name}: ${msg} — retrying in 60s`); + if (this.loginRetryTimer) clearTimeout(this.loginRetryTimer); + this.loginRetryTimer = setTimeout(() => this.connectAndAuth(), 60_000); + }); + } + + /** LoginErrorCode names from the gateway swagger — the API itself sends + * only the number with a null message. */ + private static LOGIN_ERRORS: Record = { + 1: 'UserNotFound', + 2: 'PasswordVerificationFailed', + 3: 'InvalidCredentials — check that the username is the TopstepX platform username the API key was generated for (not an email)', + 4: 'AppNotFound', + 5: 'AppVerificationFailed', + 6: 'InvalidDevice', + 7: 'AgreementsNotSigned — sign the API agreements in the TopstepX platform', + 8: 'UnknownError', + 9: 'ApiSubscriptionNotFound — the account has no API subscription', + 10: 'ApiKeyAuthenticationDisabled', + }; + + private async login(): Promise { + const res = await axios.post(`${API_BASE}/api/Auth/loginKey`, { + userName: this.name, + apiKey: this.apiKey, + }); + const data = res.data as { token?: string; success: boolean; errorCode: number; errorMessage: string | null }; + if (!data.success || !data.token) { + const detail = TopstepXClient.LOGIN_ERRORS[data.errorCode] ?? `errorCode=${data.errorCode}`; + throw new Error(data.errorMessage ?? `loginKey failed: ${detail}`); + } + return data.token; + } + + private async renewToken(): Promise { + try { + const res = await axios.post(`${API_BASE}/api/Auth/validate`, {}, { headers: this.authHeaders() }); + const data = res.data as { success: boolean; newToken?: string }; + if (data.success && data.newToken) { + this.token = data.newToken; + console.log('[topstepx] session token renewed'); + } else if (!data.success) { + console.warn('[topstepx] token validate failed — re-authenticating'); + this.connectAndAuth(); + } + } catch (err) { + console.error('[topstepx] token renewal error — re-authenticating', err); + this.connectAndAuth(); + } + } + + // ── realtime hub ───────────────────────────────────────────────────────── + + private async startHub(): Promise { + if (this.hub) { + try { await this.hub.stop(); } catch { /* ignore */ } + this.hub = null; + } + this.subscribedAccountIds.clear(); + + const hub = new signalR.HubConnectionBuilder() + .withUrl(USER_HUB, { + skipNegotiation: true, + transport: signalR.HttpTransportType.WebSockets, + accessTokenFactory: () => this.token ?? '', + }) + .withAutomaticReconnect() + .configureLogging(signalR.LogLevel.Warning) + .build(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const unwrap = (raw: unknown): Record => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const r = raw as Record; + return (r && typeof r === 'object' && 'data' in r && typeof r.data === 'object') ? r.data : r; + }; + + hub.on('GatewayUserAccount', (raw: unknown) => { + const acc = unwrap(raw); + if (typeof acc?.id !== 'number') return; + this.bufferEvent('account', acc); + const existing = this.accountCashBalances[acc.id]; + if (typeof acc.balance === 'number') { + this.accountCashBalances[acc.id] = { + amount: acc.balance, + realizedPnL: existing?.realizedPnL ?? 0, + }; + } + if (typeof acc.canTrade === 'boolean') { + this.accountList = this.accountList.map((a) => + a.id === acc.id ? { ...a, active: acc.canTrade } : a); + } + }); + + hub.on('GatewayUserPosition', (raw: unknown) => { + const pos = unwrap(raw); + if (typeof pos?.accountId !== 'number') return; + this.bufferEvent('position', pos); + this.applyPosition(pos as { accountId: number; contractId: string; type: number; size: number; averagePrice: number; creationTimestamp?: string }); + }); + + hub.on('GatewayUserTrade', (raw: unknown) => { + const trade = unwrap(raw) as unknown as TsxTrade; + if (typeof trade?.accountId !== 'number') return; + this.bufferEvent('trade', trade); + this.recordTodayTrade(trade); + + // Wake anything waiting on this order's fill. + const waiters = this.fillWaiters.filter((w) => w.orderId === trade.orderId); + this.fillWaiters = this.fillWaiters.filter((w) => w.orderId !== trade.orderId); + for (const w of waiters) { + try { w.callback(trade); } catch (err) { console.error('[topstepx] fill callback error', err); } + } + }); + + hub.on('GatewayUserOrder', (raw: unknown) => { + const order = unwrap(raw); + if (typeof order?.id !== 'number') return; + this.bufferEvent('order', order); + this.handleOcoOrderEvent(order.id, order.status); + }); + + hub.onreconnected(() => { + console.log('[topstepx] hub reconnected — resubscribing'); + this.subscribedAccountIds.clear(); + void this.subscribeAll(); + void this.requestSync(); + }); + + hub.onclose((err) => { + if (this.intentionalDisconnect) return; + console.warn('[topstepx] hub closed', err?.message ?? ''); + }); + + this.hub = hub; + try { + await hub.start(); + await this.subscribeAll(); + console.log('[topstepx] user hub connected'); + } catch (err) { + // REST polling still works without the hub; automatic reconnect + // only runs after a successful start, so retry via the next sync. + console.error('[topstepx] hub connect failed — falling back to REST polling', err); + } + } + + private async subscribeAll(): Promise { + if (!this.hub) return; + // A hub whose initial start failed never auto-reconnects — each sync + // tick gets another chance to bring it up. + if (this.hub.state === signalR.HubConnectionState.Disconnected) { + try { + await this.hub.start(); + this.subscribedAccountIds.clear(); + console.log('[topstepx] user hub connected'); + } catch { + return; // REST polling carries on; retry next sync + } + } + if (this.hub.state !== signalR.HubConnectionState.Connected) return; + try { + await this.hub.invoke('SubscribeAccounts'); + for (const acc of this.accountList) { + if (this.subscribedAccountIds.has(acc.id)) continue; + await this.hub.invoke('SubscribeOrders', acc.id); + await this.hub.invoke('SubscribePositions', acc.id); + await this.hub.invoke('SubscribeTrades', acc.id); + this.subscribedAccountIds.add(acc.id); + } + } catch (err) { + console.error('[topstepx] hub subscribe failed', err); + } + } + + private bufferEvent(entityType: string, entity: unknown): void { + this.recentEntityEvents.push({ entityType, eventType: 'Updated', entity, ts: Date.now() }); + if (this.recentEntityEvents.length > 50) this.recentEntityEvents.shift(); + } + + private applyPosition(pos: { accountId: number; contractId: string; type: number; size: number; averagePrice: number; creationTimestamp?: string }): void { + // PositionType: 1 Long, 2 Short. Size 0 (or type 0) means flat. + if (pos.size > 0 && (pos.type === 1 || pos.type === 2)) { + this.positions[pos.accountId] = { + contractId: pos.contractId, + netPos: pos.type === 1 ? pos.size : -pos.size, + netPrice: pos.averagePrice, + timestamp: pos.creationTimestamp ? new Date(pos.creationTimestamp) : new Date(), + }; + } else { + delete this.positions[pos.accountId]; + // Flat → any surviving emulated-OCO exit legs must go. + void this.cancelOcoLegsForAccount(pos.accountId); + } + } + + private recordTodayTrade(trade: TsxTrade): void { + if (trade.voided) return; + const tradeDate = currentTradeDateCT(new Date(trade.creationTimestamp)); + if (tradeDate !== currentTradeDateCT()) return; + const map = (this.todayTrades[trade.accountId] ??= new Map()); + map.set(trade.id, trade); + this.recomputeRealized(trade.accountId); + } + + private recomputeRealized(accountId: number): void { + const today = currentTradeDateCT(); + const map = this.todayTrades[accountId]; + let realized = 0; + if (map) { + for (const t of map.values()) { + if (t.voided) continue; + if (currentTradeDateCT(new Date(t.creationTimestamp)) !== today) continue; + realized += (t.profitAndLoss ?? 0) - (t.fees ?? 0); + } + } + const existing = this.accountCashBalances[accountId]; + this.accountCashBalances[accountId] = { + amount: existing?.amount ?? 0, + realizedPnL: Math.round(realized * 100) / 100, + }; + } + + // ── sync ───────────────────────────────────────────────────────────────── + + private async requestSync(): Promise { + if (!this.token || this.syncInFlight) return; + this.syncInFlight = true; + try { + const accRes = await this.post<{ accounts: { id: number; name: string; balance: number; canTrade: boolean; isVisible: boolean }[] }>( + '/api/Account/search', { onlyActiveAccounts: false }); + + this.accountList = (accRes.accounts ?? []) + .filter((a) => a.isVisible) + .map((a) => ({ id: a.id, name: a.name, active: a.canTrade })); + + // Trades since yesterday cover the whole current CT trade date + // (which opens at 5 PM the prior evening) with margin. + const start = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(); + const end = new Date(Date.now() + 60 * 60 * 1000).toISOString(); + const today = currentTradeDateCT(); + + for (const acc of this.accountList) { + const balance = (accRes.accounts ?? []).find((a) => a.id === acc.id)?.balance ?? 0; + + try { + const posRes = await this.post<{ positions: { accountId: number; contractId: string; type: number; size: number; averagePrice: number; creationTimestamp: string }[] }>( + '/api/Position/searchOpen', { accountId: acc.id }); + const open = (posRes.positions ?? []).filter((p) => p.size > 0); + if (open.length > 0) { + this.applyPosition(open[0]); + } else { + delete this.positions[acc.id]; + } + } catch (err) { + console.error(`[topstepx] position sync failed for ${acc.name}`, err instanceof Error ? err.message : err); + } + + try { + const tradeRes = await this.post<{ trades: TsxTrade[] }>( + '/api/Trade/search', { accountId: acc.id, startTimestamp: start, endTimestamp: end }); + const map = new Map(); + for (const t of tradeRes.trades ?? []) { + if (t.voided) continue; + if (currentTradeDateCT(new Date(t.creationTimestamp)) !== today) continue; + map.set(t.id, t); + } + this.todayTrades[acc.id] = map; + } catch (err) { + console.error(`[topstepx] trade sync failed for ${acc.name}`, err instanceof Error ? err.message : err); + } + + this.accountCashBalances[acc.id] = { amount: balance, realizedPnL: 0 }; + this.recomputeRealized(acc.id); + this.autoLiqThresholds[acc.id] = 0; + + // Withdrawal/deposit detection — the books must explain the balance. + try { + this.reconcileFunds(acc); + } catch (err) { + console.error(`[topstepx] fund reconcile failed for ${acc.name}`, err instanceof Error ? err.message : err); + } + } + + console.log(`[topstepx] sync — ${this.accountList.length} account(s), ${Object.keys(this.positions).length} open position(s)`); + + await this.subscribeAll(); + this.maybeFetchDaysTraded(); + + this.syncComplete = true; + await this.callbackOnSyncRequest(); + } catch (err) { + console.error('[topstepx] sync failed', err instanceof Error ? err.message : err); + } finally { + this.syncInFlight = false; + } + } + + /** Re-slice the active cycle out of the full history using the last fund + * transaction. A withdrawal's day belongs to the OLD cycle (its min-days + * were booked before the payout) so it is excluded; a deposit's day is + * included. */ + private applyCycleSlice(accountId: number): void { + const merged = this.fullDailyPnL[accountId] ?? []; + const fundDate = this.lastFundDates[accountId]; + const txns = this.fundTransactions[accountId] ?? []; + const lastAmt = fundDate ? (txns.find((f) => f.date === fundDate)?.amount ?? null) : null; + const isWithdrawal = lastAmt !== null && lastAmt < 0; + const active = fundDate + ? merged.filter((d) => (isWithdrawal ? d.date > fundDate : d.date >= fundDate)) + : merged; + const prior = fundDate + ? merged.filter((d) => (isWithdrawal ? d.date <= fundDate : d.date < fundDate)) + : []; + this.priorProfit[accountId] = Math.round(prior.reduce((s, d) => s + d.pnl, 0) * 100) / 100; + this.dailyPnL[accountId] = active; + this.daysTraded[accountId] = active.filter((d) => d.pnl !== 0).length; + } + + /** + * Books invariant: for a $0-start account, balance must equal all trade + * P&L plus all recorded fund transactions. TopstepX has no funds API, so a + * divergence IS the fund event — a withdrawal (or deposit) the platform + * never told us about. Recording it advances the withdrawal-stage counter + * and starts a new cycle; the very first check also books any historical + * net withdrawal in one lump (fine — the last stage repeats). + * + * $200 keeps fee noise and rounding out; the smallest real withdrawal in + * the drain flow is well above it. Skipped while a position is open (fills + * in flight) or while the trade history is known-stale. + */ + private reconcileFunds(account: PlatformAccount): void { + if (this.positions[account.id]) return; + if (this.lastFetchErrors[account.name]) return; + const history = this.fullDailyPnL[account.id]; + if (!history) return; // first daily-P&L fetch hasn't landed yet + + const cash = this.accountCashBalances[account.id]; + if (!cash) return; + const today = currentTradeDateCT(); + const tradePnL = history + .filter((d) => d.date !== today) + .reduce((s, d) => s + d.pnl, 0) + cash.realizedPnL; + const txns = this.fundTransactions[account.id] ?? []; + const funds = txns.reduce((s, f) => s + f.amount, 0); + const delta = Math.round((cash.amount - tradePnL - funds) * 100) / 100; + if (Math.abs(delta) < 200) return; + + console.log(`[topstepx] ${account.name}: balance ${cash.amount} vs books ${Math.round((tradePnL + funds) * 100) / 100} — recording ${delta < 0 ? 'withdrawal' : 'deposit'} of ${delta}`); + // Same-day events merge into one row (the table keys on account+date), + // so add today's delta onto whatever is already booked for today. + const existingToday = txns.find((f) => f.date === today)?.amount ?? 0; + saveFundTransactions(account.id, account.name, [ + { date: today, amount: Math.round((existingToday + delta) * 100) / 100 }, + ]); + saveAccountMeta(account.id, 'last_fund_date', today); + this.fundTransactions[account.id] = loadFundTransactions(account.id); + this.lastFundDates[account.id] = today; + this.applyCycleSlice(account.id); + } + + private maybeFetchDaysTraded(): void { + if (this.daysFetchInFlight) return; + + const now = Date.now(); + const initialFetchDue = this.lastDaysFetchStartedAt === 0; + const retryPending = this.nextDaysFetchRetryAt > 0; + const retryDue = retryPending && now >= this.nextDaysFetchRetryAt; + const hourlyRefreshDue = + !retryPending && + this.lastDaysFetchSucceededAt > 0 && + now - this.lastDaysFetchSucceededAt >= 60 * 60 * 1_000; + + if (!initialFetchDue && !retryDue && !hourlyRefreshDue) return; + + this.daysFetchInFlight = true; + this.lastDaysFetchStartedAt = now; + if (retryDue) this.nextDaysFetchRetryAt = 0; + + this.fetchDaysTraded() + .then(({ failedAccounts }) => { + if (failedAccounts > 0) { + this.nextDaysFetchRetryAt = Date.now() + 5 * 60 * 1_000; + console.warn(`[topstepx] ${failedAccounts} account(s) failed daily P&L fetch — retrying in 5 minutes`); + return; + } + this.lastDaysFetchSucceededAt = Date.now(); + }) + .catch((err) => { + this.nextDaysFetchRetryAt = Date.now() + 5 * 60 * 1_000; + console.error('[topstepx] daily P&L fetch failed — retrying in 5 minutes', err); + }) + .finally(() => { + this.daysFetchInFlight = false; + }); + } + + /** Daily P&L from the trades endpoint: net of fees per CT trade date, like + * Tradovate's Cash History. Merged over the DB cache so history older than + * the fetch window survives. */ + public async fetchDaysTraded(onlyAccountIds?: number[]): Promise<{ failedAccounts: number }> { + if (!this.token) return { failedAccounts: 0 }; + this.fetchDaysComplete = false; + let failedAccounts = 0; + + const start = new Date(Date.now() - 27 * 24 * 60 * 60 * 1000).toISOString(); + const end = new Date(Date.now() + 60 * 60 * 1000).toISOString(); + + const mergePnL = ( + cached: { date: string; pnl: number }[], + fresh: { date: string; pnl: number }[] + ): { date: string; pnl: number }[] => { + const map = new Map(); + for (const e of cached) map.set(e.date, e.pnl); + for (const e of fresh) map.set(e.date, e.pnl); + return Array.from(map.entries()) + .map(([date, pnl]) => ({ date, pnl })) + .sort((a, b) => a.date.localeCompare(b.date)); + }; + + for (const account of this.accountList) { + if (onlyAccountIds && !onlyAccountIds.includes(account.id)) continue; + + // Fund transactions never come from the TopstepX API — only what the + // DB already holds (imports, manual rows). Usually empty. + const storedFundDate = loadAccountMeta(account.id, 'last_fund_date'); + this.lastFundDates[account.id] = storedFundDate; + this.fundTransactions[account.id] = loadFundTransactions(account.id); + + const cached = loadDailyPnL(account.id); + + try { + const res = await this.post<{ trades: TsxTrade[] }>( + '/api/Trade/search', { accountId: account.id, startTimestamp: start, endTimestamp: end }); + const trades = (res.trades ?? []).filter((t) => !t.voided); + this.lastFetchRaw[account.name] = trades.length > 0 ? JSON.stringify(trades[0]) : '(no trades)'; + + const dailyMap: { [date: string]: number } = {}; + for (const t of trades) { + const date = currentTradeDateCT(new Date(t.creationTimestamp)); + dailyMap[date] = (dailyMap[date] ?? 0) + (t.profitAndLoss ?? 0) - (t.fees ?? 0); + } + + const fresh = Object.entries(dailyMap) + .map(([date, pnl]) => ({ date, pnl: Math.round(pnl * 100) / 100 })) + .sort((a, b) => a.date.localeCompare(b.date)); + + const merged = mergePnL(cached, fresh); + + // The last fund transaction (usually one this client detected + // itself — see reconcileFunds) splits cycles: withdrawals + // exclude their day, deposits include it. + this.fullDailyPnL[account.id] = merged; + this.applyCycleSlice(account.id); + saveDailyPnL(account.id, account.name, fresh); + delete this.lastFetchErrors[account.name]; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(`[topstepx] trade history failed for ${account.name}:`, msg); + failedAccounts++; + if (cached.length > 0) { + this.fullDailyPnL[account.id] = cached; + this.applyCycleSlice(account.id); + this.lastFetchErrors[account.name] = `${msg} (using ${cached.length} cached entries from database)`; + } else { + this.dailyPnL[account.id] ??= []; + this.daysTraded[account.id] ??= 0; + this.lastFetchErrors[account.name] = msg; + } + } + } + this.fetchDaysComplete = true; + return { failedAccounts }; + } + + // ── contracts ──────────────────────────────────────────────────────────── + + public async findFrontMonthContract(productName: string): Promise { + if (!this.token) return null; + + const cached = this.contractByProduct.get(productName); + if (cached && Date.now() - cached.resolvedAt < TopstepXClient.CONTRACT_TTL_MS) { + const c = cached.contract; + return { id: c.id, name: c.name, tickSize: c.tickSize }; + } + + try { + const res = await this.post<{ contracts: TsxContract[] }>( + '/api/Contract/search', { searchText: productName, live: false }); + const candidates = (res.contracts ?? []).filter((c) => + c.name.startsWith(productName) && /^[A-Z]\d/.test(c.name.slice(productName.length))); + if (candidates.length === 0) { + console.log(`[topstepx] no contract found for ${productName}`); + return null; + } + + // Prefer the volume-resolved front month (shared with the Tradovate + // path — names match across platforms, e.g. "NQZ5"), then TopstepX's + // own activeContract flag. + const resolvedName = getCachedContract(productName)?.name; + const match = + (resolvedName ? candidates.find((c) => c.name === resolvedName) : undefined) + ?? candidates.find((c) => c.activeContract) + ?? candidates[0]; + + this.contractByProduct.set(productName, { contract: match, resolvedAt: Date.now() }); + this.contractByName.set(match.name, match); + return { id: match.id, name: match.name, tickSize: match.tickSize }; + } catch (err) { + console.error(`[topstepx] contract search failed for ${productName}`, err instanceof Error ? err.message : err); + return null; + } + } + + /** Resolve a contract NAME (e.g. "NQZ5") to its TopstepX contract id. */ + private async resolveContractId(contractSymbol: string): Promise { + const known = this.contractByName.get(contractSymbol); + if (known) return known; + + const res = await this.post<{ contracts: TsxContract[] }>( + '/api/Contract/search', { searchText: contractSymbol, live: false }); + const match = (res.contracts ?? []).find((c) => c.name === contractSymbol) + ?? (res.contracts ?? []).find((c) => contractSymbol.startsWith(c.name)); + if (!match) throw new Error(`TopstepX contract not found for symbol ${contractSymbol}`); + this.contractByName.set(match.name, match); + return match; + } + + public async fetchInstrumentFees(): Promise<{ [symbol: string]: number }> { + // No fee-parameter endpoint on the gateway; per-trade fees arrive with + // each fill instead, which is what the commission delta reads. + return {}; + } + + public async getAccountList(): Promise { + return this.accountList; + } + + // ── orders ─────────────────────────────────────────────────────────────── + + private async placeOrder( + accountId: number, + contractId: string, + size: number, + action: 'Buy' | 'Sell', + type: number, + limitPrice?: number, + stopPrice?: number, + ): Promise { + const res = await this.post<{ orderId: number }>('/api/Order/place', { + accountId, + contractId, + type, + side: action === 'Buy' ? SIDE_BUY : SIDE_SELL, + size, + limitPrice: limitPrice ?? null, + stopPrice: stopPrice ?? null, + trailPrice: null, + customTag: null, + }); + return res.orderId; + } + + /** Place an order and wait for its fill (trade event, with a REST poll as + * backstop). Resolves with an object exposing the fill `price`. */ + public async sendOrder( + accountId: number, + contractSymbol: string, + quantity: number, + action: 'Buy' | 'Sell', + orderType: 'Market' | 'Limit', + price?: number + ): Promise> { + const contract = await this.resolveContractId(contractSymbol); + const orderId = await this.placeOrder( + accountId, contract.id, quantity, action, + orderType === 'Market' ? TYPE_MARKET : TYPE_LIMIT, + orderType === 'Limit' ? price : undefined, + ); + console.log(`[topstepx] order ${orderId}: ${action} ${quantity}x${contractSymbol} ${orderType}${price ? ` @ ${price}` : ''}`); + + return new Promise((resolve, reject) => { + let settled = false; + const timeout = setTimeout(() => { + if (settled) return; + settled = true; + clearInterval(poll); + this.fillWaiters = this.fillWaiters.filter((w) => w.orderId !== orderId); + reject(new Error(`Order ${orderId} placed but no fill within 30s (market may be closed)`)); + }, 30_000); + + const settle = (fillPrice: number) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + clearInterval(poll); + console.log(`[topstepx] order ${orderId} filled @ ${fillPrice}`); + resolve({ orderId, price: fillPrice, accountId, contractId: contract.id }); + }; + + this.fillWaiters.push({ orderId, callback: (trade) => settle(trade.price) }); + + // REST backstop for when the hub is down or the event is missed. + const poll = setInterval(() => { + void this.post<{ order?: { status: number; filledPrice?: number } }>( + '/api/Order/searchById', { accountId, orderId }) + .then((res) => { + const order = res.order; + if (!order) return; + if (order.status === ORDER_FILLED && typeof order.filledPrice === 'number') { + this.fillWaiters = this.fillWaiters.filter((w) => w.orderId !== orderId); + settle(order.filledPrice); + } else if (ORDER_GONE.has(order.status) && !settled) { + settled = true; + clearTimeout(timeout); + clearInterval(poll); + this.fillWaiters = this.fillWaiters.filter((w) => w.orderId !== orderId); + reject(new Error(`Order ${orderId} ended without fill (status=${order.status})`)); + } + }) + .catch(() => { /* transient — the next poll tries again */ }); + }, 2_000); + }); + } + + /** Place an order and resolve on placement (does not wait for fill). */ + public async placeOrderNoWait( + accountId: number, + contractSymbol: string, + quantity: number, + action: 'Buy' | 'Sell', + orderType: 'Market' | 'Limit', + price?: number + ): Promise<{ orderId?: number }> { + const contract = await this.resolveContractId(contractSymbol); + const orderId = await this.placeOrder( + accountId, contract.id, quantity, action, + orderType === 'Market' ? TYPE_MARKET : TYPE_LIMIT, + orderType === 'Limit' ? price : undefined, + ); + return { orderId }; + } + + /** + * Emulated OCO exit: the gateway has no linked-order type, so place the + * take-profit limit and the stop separately and cancel the survivor when + * either fills (order events; a poll and the flat-position hook back it + * up). If the stop can't be placed the limit is cancelled and the error + * rethrown, so the caller's lone-limit fallback starts from a clean book. + */ + public async placeOcoExit( + accountId: number, + contractSymbol: string, + quantity: number, + exitAction: 'Buy' | 'Sell', + limitPrice: number, + stopPrice: number, + ): Promise<{ orderId?: number }> { + const contract = await this.resolveContractId(contractSymbol); + const limitId = await this.placeOrder(accountId, contract.id, quantity, exitAction, TYPE_LIMIT, limitPrice); + let stopId: number; + try { + stopId = await this.placeOrder(accountId, contract.id, quantity, exitAction, TYPE_STOP, undefined, stopPrice); + } catch (err) { + await this.cancelOrder(accountId, limitId).catch(() => { /* best effort */ }); + throw err; + } + + this.ocoPairs.push({ accountId, limitId, stopId, done: false, placedAt: Date.now() }); + this.ensureOcoPoll(); + console.log(`[topstepx] OCO exit pair placed — limit ${limitId} @ ${limitPrice}, stop ${stopId} @ ${stopPrice}`); + return { orderId: limitId }; + } + + private async cancelOrder(accountId: number, orderId: number): Promise { + await this.post('/api/Order/cancel', { accountId, orderId }); + } + + private handleOcoOrderEvent(orderId: number, status: number): void { + const pair = this.ocoPairs.find((p) => !p.done && (p.limitId === orderId || p.stopId === orderId)); + if (!pair) return; + if (status === ORDER_FILLED) { + pair.done = true; + const sibling = pair.limitId === orderId ? pair.stopId : pair.limitId; + console.log(`[topstepx] OCO leg ${orderId} filled — cancelling sibling ${sibling}`); + void this.cancelOrder(pair.accountId, sibling).catch((err) => + console.error(`[topstepx] failed to cancel OCO sibling ${sibling}`, err instanceof Error ? err.message : err)); + } + } + + private async cancelOcoLegsForAccount(accountId: number): Promise { + const pairs = this.ocoPairs.filter((p) => !p.done && p.accountId === accountId); + for (const pair of pairs) { + pair.done = true; + for (const orderId of [pair.limitId, pair.stopId]) { + await this.cancelOrder(accountId, orderId).catch(() => { /* already gone */ }); + } + } + } + + /** Safety net under the event-driven OCO: while pairs are live, poll open + * orders and reap legs whose sibling is gone or whose position is flat. */ + private ensureOcoPoll(): void { + if (this.ocoPollInterval) return; + this.ocoPollInterval = setInterval(() => { + void (async () => { + const live = this.ocoPairs.filter((p) => !p.done); + if (live.length === 0) { + if (this.ocoPollInterval) { clearInterval(this.ocoPollInterval); this.ocoPollInterval = null; } + this.ocoPairs = []; + return; + } + for (const pair of live) { + try { + const res = await this.post<{ orders: { id: number }[] }>( + '/api/Order/searchOpen', { accountId: pair.accountId }); + const openIds = new Set((res.orders ?? []).map((o) => o.id)); + const limitOpen = openIds.has(pair.limitId); + const stopOpen = openIds.has(pair.stopId); + if (!limitOpen && !stopOpen) { + pair.done = true; + } else if (limitOpen !== stopOpen && !this.positions[pair.accountId]) { + // One leg gone, position flat — the survivor is stale. + pair.done = true; + const survivor = limitOpen ? pair.limitId : pair.stopId; + console.log(`[topstepx] reaping stale OCO leg ${survivor} (position flat)`); + await this.cancelOrder(pair.accountId, survivor).catch(() => { /* already gone */ }); + } + } catch { /* transient — retry next poll */ } + } + })(); + }, 15_000); + } + + public onFill(orderId: number, callback: (fill: Record) => void): void { + this.fillWaiters.push({ orderId, callback: (trade) => callback(trade as unknown as Record) }); + } + + // ── lifecycle ──────────────────────────────────────────────────────────── + + public disconnect(): void { + this.intentionalDisconnect = true; + if (this.syncInterval) { clearInterval(this.syncInterval); this.syncInterval = null; } + if (this.tokenRenewalInterval) { clearInterval(this.tokenRenewalInterval); this.tokenRenewalInterval = null; } + if (this.ocoPollInterval) { clearInterval(this.ocoPollInterval); this.ocoPollInterval = null; } + if (this.loginRetryTimer) { clearTimeout(this.loginRetryTimer); this.loginRetryTimer = null; } + this.daysFetchInFlight = false; + this.nextDaysFetchRetryAt = 0; + void this.hub?.stop().catch(() => { /* ignore */ }); + this.hub = null; + } +} diff --git a/lib/trading-logic.ts b/lib/trading-logic.ts index 2221ae1..b847af3 100644 --- a/lib/trading-logic.ts +++ b/lib/trading-logic.ts @@ -1,3 +1,23 @@ +export interface WithdrawalStage { + profit: number; + consistency: number; + minTradingDays: number; + /** Spam-withdraw stage: no profit target — book minTradingDays qualifying + * days of ~minDayPnL (tiny MNQ trades), then wait for the withdrawal. */ + drain?: boolean; + /** Drain stages only: the per-day booked amount (default $5; randomized + * $5–$15 by drainDayAmount). */ + minDayPnL?: number; +} + +export interface EffectiveConfig { + profitTarget: number; + consistency: number; + minTradingDays: number; + minDayPnL: number; + drain: boolean; +} + /** * Resolve the effective profit target and consistency for an account based on its withdrawal strategy. * @@ -6,42 +26,53 @@ * effectiveProfitTarget = profitTarget − remainingProfit * * Mode B (withdrawalStages non-empty): Stage 1 (no withdrawals yet) uses base profitTarget/consistency. - * After the Nth withdrawal, use withdrawalStages[N-1]; last stage repeats. - * Fallback: returns base profitTarget and consistency unchanged. + * After the Nth withdrawal, use withdrawalStages[N-1]; last stage repeats. A drain stage carries no + * profit target — it resolves to target 0 with drain=true and its own min-day amount. + * Fallback: returns base values unchanged. */ export function resolveEffectiveConfig( profitTarget: number, consistency: number, minTradingDays: number, targetSameEquity: boolean, - withdrawalStages: { profit: number; consistency: number; minTradingDays: number }[], + withdrawalStages: WithdrawalStage[], priorProfit: number, - fundTransactions: { date: string; amount: number }[] -): { profitTarget: number; consistency: number; minTradingDays: number } { + fundTransactions: { date: string; amount: number }[], + minDayPnL = 0, +): EffectiveConfig { if (targetSameEquity) { // Simple Mode A: effective target = profitTarget - equityProfit, computed in computeDailyTarget. // Since computeDailyTarget already compares (amount - accountSize) against profitTarget, // no adjustment needed here — just return the base values. - return { profitTarget, consistency, minTradingDays }; + return { profitTarget, consistency, minTradingDays, minDayPnL, drain: false }; } if (withdrawalStages.length > 0) { const withdrawalCount = fundTransactions.filter((f) => f.amount < 0).length; if (withdrawalCount === 0) { - return { profitTarget, consistency, minTradingDays }; // Stage 1 = base values + return { profitTarget, consistency, minTradingDays, minDayPnL, drain: false }; // Stage 1 = base values } const idx = Math.min(withdrawalCount - 1, withdrawalStages.length - 1); const stage = withdrawalStages[idx]; + if (stage.drain === true) { + return { + profitTarget: 0, + consistency: 0, + minTradingDays: stage.minTradingDays || minTradingDays, + minDayPnL: stage.minDayPnL || 5, + drain: true, + }; + } // Fall back to base minTradingDays when the stage doesn't specify one (0 or missing) - return { profitTarget: stage.profit, consistency: stage.consistency, minTradingDays: stage.minTradingDays || minTradingDays }; + return { profitTarget: stage.profit, consistency: stage.consistency, minTradingDays: stage.minTradingDays || minTradingDays, minDayPnL, drain: false }; } - return { profitTarget, consistency, minTradingDays }; + return { profitTarget, consistency, minTradingDays, minDayPnL, drain: false }; } /** @deprecated Use resolveEffectiveConfig instead */ export function resolveEffectiveProfitTarget( profitTarget: number, targetSameEquity: boolean, - withdrawalStages: { profit: number; consistency: number; minTradingDays: number }[], + withdrawalStages: WithdrawalStage[], priorProfit: number, fundTransactions: { date: string; amount: number }[] ): number { @@ -96,6 +127,22 @@ export function currentTradeDateCT(now: Date = new Date()): string { return new Date(date).toISOString().slice(0, 10); } +/** + * Drain-day target: the stage's min-day amount plus a deterministic bump of + * $0/$5/$10 — so a $5 min day trades as $5, $10 or $15 depending on the + * (account, trade date) pair. Seeded like dailyBump so every consumer of the + * plan computes the same number. + */ +export function drainDayAmount(accountId: number, tradeDate: string, minDay: number): number { + const key = `${accountId}|drain|${tradeDate}`; + let hash = 0x811c9dc5; + for (let i = 0; i < key.length; i++) { + hash ^= key.charCodeAt(i); + hash = Math.imul(hash, 0x01000193) >>> 0; + } + return minDay + (hash % 3) * 5; +} + /** * Deterministic fraction in [0, 1) for a (prefix group, trade date, leg index) triple. * Like accounts (same account-type prefix) carving the same leg of the same day draw the @@ -277,6 +324,137 @@ export function combineCmeSessions( }; } +// ── perfect-path projection ────────────────────────────────────────────────── + +export interface ProjectedDay { + date: string; + /** The day's booked P&L on the perfect path (0 on withdrawal/payout days). */ + pnl: number; + /** Stage the account is on while trading this day. */ + stage: number; + drain: boolean; + /** A simulated withdrawal (negative) lands on this day — cycle resets after it. */ + withdrawal?: number; + /** Non-staged account completed here; the projection stops. */ + payoutReady?: boolean; +} + +/** Next weekday after d (YYYY-MM-DD). Trade dates never fall on Sat/Sun. */ +function nextTradingDate(d: string): string { + const dt = new Date(`${d}T00:00:00Z`); + do { dt.setUTCDate(dt.getUTCDate() + 1); } while (dt.getUTCDay() === 0 || dt.getUTCDay() === 6); + return dt.toISOString().slice(0, 10); +} + +/** + * Simulate the account's future assuming every day goes perfectly: each + * trading day books exactly its planned target (deterministic bumps included, + * so the numbers match what the trader would actually aim for), stages + * complete, a 50% withdrawal fires the day a cycle finishes, and drain cycles + * repeat. Built on the same computeDayPlan/resolveEffectiveConfig the live + * trade path uses — the projection can't drift from reality's rules. + * + * An account without withdrawal stages stops at its first completion, marked + * payoutReady. Projection starts on the next trading day (today's cell shows + * live data); today's remaining target is booked silently first. + */ +export function projectPerfectPath(opts: { + accountId: number; + cfg: { + profitTarget: number; consistency: number; minDayPnL: number; + minTradingDays: number; accountSize: number; targetSameEquity: boolean; + withdrawalStages: WithdrawalStage[]; splitEnabled: boolean; + }; + /** Current-cycle daily P&L (account.dailyPnL). */ + cycleDailyPnL: { date: string; pnl: number }[]; + fundTransactions: { date: string; amount: number }[]; + balance: number; + realizedPnL: number; + /** Trading days to look ahead. */ + horizonDays?: number; + maxWithdrawals?: number; +}): ProjectedDay[] { + const { accountId, cfg } = opts; + const horizon = opts.horizonDays ?? 40; + const maxWithdrawals = opts.maxWithdrawals ?? 6; + + const today = currentTradeDateCT(); + let sim = opts.cycleDailyPnL.filter((d) => d.date !== today); + if (opts.realizedPnL !== 0) sim = [...sim, { date: today, pnl: opts.realizedPnL }]; + else sim = [...sim]; + const funds = [...opts.fundTransactions]; + let balance = opts.balance; + let withdrawals = 0; + const out: ProjectedDay[] = []; + + const effectiveNow = () => resolveEffectiveConfig( + cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity, + cfg.withdrawalStages, 0, funds, cfg.minDayPnL, + ); + const qualifyingDays = (minDay: number, excludeDate: string) => ( + minDay > 0 + ? sim.filter((d) => d.date !== excludeDate && d.pnl >= minDay).length + : sim.filter((d) => d.date !== excludeDate && d.pnl !== 0).length + ); + + // Book the rest of today silently — the perfect path finishes today too. + // Skipped when "today" is a weekend date (Saturday, or Friday evening + // after the roll): there is no session to finish. + const todayDow = new Date(`${today}T00:00:00Z`).getUTCDay(); + if (todayDow !== 0 && todayDow !== 6) { + const eff = effectiveNow(); + const plan = computeDayPlan( + accountId, today, eff.profitTarget, eff.consistency, sim, eff.minDayPnL, + eff.minTradingDays, balance - cfg.accountSize, opts.realizedPnL, cfg.splitEnabled, eff.drain, + ); + if (plan && plan.dayTarget > 0 && plan.remainingToday > 0) { + const idx = sim.findIndex((d) => d.date === today); + const pnl = round2(opts.realizedPnL + plan.remainingToday); + if (idx >= 0) sim[idx] = { date: today, pnl }; + else sim.push({ date: today, pnl }); + balance = round2(balance + plan.remainingToday); + } + } + + let date = today; + for (let step = 0; step < horizon; step++) { + date = nextTradingDate(date); + const eff = effectiveNow(); + const stage = 1 + funds.filter((f) => f.amount < 0).length; + const plan = computeDayPlan( + accountId, date, eff.profitTarget, eff.consistency, sim, eff.minDayPnL, + eff.minTradingDays, balance - cfg.accountSize, 0, cfg.splitEnabled, eff.drain, + ); + if (!plan) break; + + const daysNeeded = eff.minTradingDays > qualifyingDays(eff.minDayPnL, date); + + if (plan.dayTarget <= 0 && !daysNeeded) { + // Cycle complete — nothing left to trade. + const withdrawal = -round2(balance / 2); + if (cfg.withdrawalStages.length > 0 && withdrawals < maxWithdrawals && withdrawal < -1) { + funds.push({ date, amount: withdrawal }); + balance = round2(balance + withdrawal); + sim = []; // withdrawal excludes its day — fresh cycle + withdrawals++; + out.push({ date, pnl: 0, stage, drain: eff.drain, withdrawal }); + continue; + } + out.push({ date, pnl: 0, stage, drain: eff.drain, payoutReady: true }); + break; + } + + // Extra days (min-day 0, days still owed) book a token dollar so the + // day counts, matching the real in-and-out MNQ trade. + const pnl = plan.dayTarget > 0 ? plan.remainingToday : 1; + sim.push({ date, pnl }); + balance = round2(balance + pnl); + out.push({ date, pnl, stage, drain: eff.drain }); + } + + return out; +} + /** Dollar-per-point value for common futures products. */ export const POINT_VALUES: { [symbol: string]: number } = { NQ: 20, MNQ: 2, ES: 50, MES: 5, @@ -438,11 +616,30 @@ export function computeDayPlan( minTradingDays: number, equityProfit: number | null | undefined, realizedPnL: number, - splitEnabled: boolean = true + splitEnabled: boolean = true, + drain: boolean = false ): DayPlan | null { if (typeof equityProfit !== 'number' || !Number.isFinite(equityProfit)) return null; const priorDays = dailyPnL.filter((d) => d.date !== tradeDate); + + // Drain stage: no profit target at all. Book minTradingDays qualifying + // days at the randomized min-day amount, then go quiet until the next + // withdrawal resets the cycle. One unsplit tiny trade per day. + if (drain) { + const qualifying = minDayPnL > 0 + ? priorDays.filter((d) => d.pnl >= minDayPnL) + : priorDays.filter((d) => d.pnl !== 0); + const dayTarget = qualifying.length >= minTradingDays + ? 0 + : drainDayAmount(accountId, tradeDate, minDayPnL || 5); + return { + dayTarget, + remainingToday: round2(dayTarget - realizedPnL), + path: 'reduced_day', + splitAllowed: false, + }; + } const dayStartEquity = equityProfit - realizedPnL; const target = computeDailyTarget( profitTarget, consistency, priorDays, minDayPnL, minTradingDays, diff --git a/package-lock.json b/package-lock.json index ee9fe73..ce026ae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "autotrader-next", "version": "0.1.0", "dependencies": { + "@microsoft/signalr": "^10.0.11", "axios": "^1.13.6", "better-sqlite3": "^12.6.2", "next": "16.1.6", @@ -1025,6 +1026,19 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@microsoft/signalr": { + "version": "10.0.11", + "resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-10.0.11.tgz", + "integrity": "sha512-FulOJ2EEtKvLQcswe/U7v8pzyXyk7Jua5xgWJPPwyQU/2Z9ORvKcVjyO75VvLsem+CJ1ORRexJ+7Bz1FCei2aw==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "eventsource": "^2.0.2", + "fetch-cookie": "^2.0.3", + "node-fetch": "^2.6.7", + "ws": "^7.5.10" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -2270,6 +2284,18 @@ "win32" ] }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -3904,12 +3930,30 @@ "node": ">=0.10.0" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "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/eventsource": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", + "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", @@ -3980,6 +4024,16 @@ "reusify": "^1.0.4" } }, + "node_modules/fetch-cookie": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-2.2.0.tgz", + "integrity": "sha512-h9AgfjURuCgA2+2ISl8GbavpUdR+WGAM2McW/ovn4tVccegp8ZqCKWSBR8uRdM8dDNlx5WdKRWxBYUwteLDCNQ==", + "license": "Unlicense", + "dependencies": { + "set-cookie-parser": "^2.4.8", + "tough-cookie": "^4.0.0" + } + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -5688,6 +5742,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/node-releases": { "version": "2.0.36", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", @@ -6048,6 +6122,18 @@ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "license": "MIT" }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, "node_modules/pump": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", @@ -6062,12 +6148,17 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" } }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "license": "MIT" + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -6266,6 +6357,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, "node_modules/reselect": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", @@ -6439,6 +6536,12 @@ "semver": "bin/semver.js" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -7030,6 +7133,27 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/ts-api-utils": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", @@ -7242,6 +7366,15 @@ "dev": true, "license": "MIT" }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/unrs-resolver": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", @@ -7318,6 +7451,16 @@ "punycode": "^2.1.0" } }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.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", @@ -7355,6 +7498,22 @@ "d3-timer": "^3.0.1" } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -7476,6 +7635,27 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/ws": { + "version": "7.5.13", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz", + "integrity": "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index 1d68216..77458f3 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "lint": "eslint" }, "dependencies": { + "@microsoft/signalr": "^10.0.11", "axios": "^1.13.6", "better-sqlite3": "^12.6.2", "next": "16.1.6", diff --git a/types.ts b/types.ts index 0049e6a..59de823 100644 --- a/types.ts +++ b/types.ts @@ -16,17 +16,21 @@ export interface AccountConfig { firstDayStop: boolean; // first cycle day carries a $1,000 account stop - books red and ends minDayStop: boolean; // min-day trades carry a 2x-target stop; next day after a loss rides stopless combineCmeHolidays: boolean; // true = CME holiday sessions count with the next full trading day (Apex-style) - withdrawalStages: { profit: number; consistency: number; minTradingDays: number }[]; // per-stage fresh profit + consistency (empty = use base values) + withdrawalStages: { profit: number; consistency: number; minTradingDays: number; drain?: boolean; minDayPnL?: number }[]; // per-stage fresh profit + consistency (empty = use base values); drain = spam-withdraw stage } export interface FirmConfig { /** Master's firm id (uuid). */ id: string; firm: string; + /** Which platform the firm's accounts trade on — decides both which client + * drives it and which credential fields the dashboard asks for. */ + platform: 'tradovate' | 'topstepx'; /** Blank when this machine has no credentials for the firm. */ username: string; - /** True once a Tradovate username and password are stored locally. Firms - * without them are shown greyed out with an inline credential form. */ + /** True once the platform's credentials are stored locally — Tradovate + * username + password, or a TopstepX API key. Firms without them are + * shown greyed out with an inline credential form. */ hasCredentials: boolean; bannedSymbols: string[]; /** The firm's account types, straight from the catalog. Doubles as the @@ -54,6 +58,8 @@ export interface AccountState { personalDll: boolean; /** Current stage = 1 + number of withdrawals (negative fund transactions). Stage 1 = fresh account. */ stage: number; + /** True when the current stage is a drain stage (spam-withdraw mode). */ + stageDrain: boolean; /** Next trading day's target, computed server-side. null when account is dead or config is missing. */ dailyTarget: { amount: number; path: 'first_day' | 'normal_day' | 'reduced_day' } | null; /** Dollars the next trade may lose before its obfuscation stop fires. diff --git a/yarn.lock b/yarn.lock index bc5e421..8719efb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -310,6 +310,17 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" +"@microsoft/signalr@^10.0.11": + version "10.0.11" + resolved "https://registry.npmjs.org/@microsoft/signalr/-/signalr-10.0.11.tgz" + integrity sha512-FulOJ2EEtKvLQcswe/U7v8pzyXyk7Jua5xgWJPPwyQU/2Z9ORvKcVjyO75VvLsem+CJ1ORRexJ+7Bz1FCei2aw== + dependencies: + abort-controller "^3.0.0" + eventsource "^2.0.2" + fetch-cookie "^2.0.3" + node-fetch "^2.6.7" + ws "^7.5.10" + "@next/env@16.1.6": version "16.1.6" resolved "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz" @@ -639,6 +650,13 @@ resolved "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz" integrity sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g== +abort-controller@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz" + integrity sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg== + dependencies: + event-target-shim "^5.0.0" + acorn-jsx@^5.3.2: version "5.3.2" resolved "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz" @@ -1564,11 +1582,21 @@ esutils@^2.0.2: resolved "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz" integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== +event-target-shim@^5.0.0: + version "5.0.1" + resolved "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz" + integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ== + eventemitter3@^5.0.1: version "5.0.4" resolved "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz" integrity sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw== +eventsource@^2.0.2: + version "2.0.2" + resolved "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz" + integrity sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA== + expand-template@^2.0.3: version "2.0.3" resolved "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz" @@ -1612,6 +1640,14 @@ fdir@^6.5.0: resolved "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz" integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg== +fetch-cookie@^2.0.3: + version "2.2.0" + resolved "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-2.2.0.tgz" + integrity sha512-h9AgfjURuCgA2+2ISl8GbavpUdR+WGAM2McW/ovn4tVccegp8ZqCKWSBR8uRdM8dDNlx5WdKRWxBYUwteLDCNQ== + dependencies: + set-cookie-parser "^2.4.8" + tough-cookie "^4.0.0" + file-entry-cache@^8.0.0: version "8.0.0" resolved "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz" @@ -2394,6 +2430,13 @@ node-exports-info@^1.6.0: object.entries "^1.1.9" semver "^6.3.1" +node-fetch@^2.6.7: + version "2.7.0" + resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz" + integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== + dependencies: + whatwg-url "^5.0.0" + node-releases@^2.0.27: version "2.0.36" resolved "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz" @@ -2604,6 +2647,13 @@ proxy-from-env@^1.1.0: resolved "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz" integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg== +psl@^1.1.33: + version "1.15.0" + resolved "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz" + integrity sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w== + dependencies: + punycode "^2.3.1" + pump@^3.0.0: version "3.0.4" resolved "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz" @@ -2612,11 +2662,16 @@ pump@^3.0.0: end-of-stream "^1.1.0" once "^1.3.1" -punycode@^2.1.0: +punycode@^2.1.0, punycode@^2.1.1, punycode@^2.3.1: version "2.3.1" resolved "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz" integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== +querystringify@^2.1.1: + version "2.2.0" + resolved "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz" + integrity sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ== + queue-microtask@^1.2.2: version "1.2.3" resolved "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz" @@ -2719,6 +2774,11 @@ regexp.prototype.flags@^1.5.3, regexp.prototype.flags@^1.5.4: gopd "^1.2.0" set-function-name "^2.0.2" +requires-port@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz" + integrity sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ== + reselect@^5.1.0, reselect@5.1.1: version "5.1.1" resolved "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz" @@ -2825,6 +2885,11 @@ semver@^7.7.3: resolved "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz" integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA== +set-cookie-parser@^2.4.8: + version "2.7.2" + resolved "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz" + integrity sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw== + set-function-length@^1.2.2: version "1.2.2" resolved "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz" @@ -3134,6 +3199,21 @@ to-regex-range@^5.0.1: dependencies: is-number "^7.0.0" +tough-cookie@^4.0.0: + version "4.1.4" + resolved "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz" + integrity sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag== + dependencies: + psl "^1.1.33" + punycode "^2.1.1" + universalify "^0.2.0" + url-parse "^1.5.3" + +tr46@~0.0.3: + version "0.0.3" + resolved "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz" + integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== + ts-api-utils@^2.4.0: version "2.4.0" resolved "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz" @@ -3243,6 +3323,11 @@ undici-types@~6.21.0: resolved "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz" integrity sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ== +universalify@^0.2.0: + version "0.2.0" + resolved "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz" + integrity sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg== + unrs-resolver@^1.6.2: version "1.11.1" resolved "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz" @@ -3285,6 +3370,14 @@ uri-js@^4.2.2: dependencies: punycode "^2.1.0" +url-parse@^1.5.3: + version "1.5.10" + resolved "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz" + integrity sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ== + dependencies: + querystringify "^2.1.1" + requires-port "^1.0.0" + use-sync-external-store@^1.2.2, use-sync-external-store@^1.4.0: version "1.6.0" resolved "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz" @@ -3315,6 +3408,19 @@ victory-vendor@^37.0.2: d3-time "^3.0.0" d3-timer "^3.0.1" +webidl-conversions@^3.0.0: + version "3.0.1" + resolved "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz" + integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== + +whatwg-url@^5.0.0: + version "5.0.0" + resolved "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz" + integrity sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw== + dependencies: + tr46 "~0.0.3" + webidl-conversions "^3.0.0" + which-boxed-primitive@^1.1.0, which-boxed-primitive@^1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz" @@ -3385,6 +3491,11 @@ wrappy@1: resolved "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz" integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== +ws@^7.5.10: + version "7.5.13" + resolved "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz" + integrity sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA== + yallist@^3.0.2: version "3.1.1" resolved "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz"