Add TopstepX platform support, drain withdrawal stages, and perfect-path calendar projection
- TopstepXClient (ProjectX Gateway): API-key auth, SignalR user hub, Trade/search daily P&L, emulated OCO exits, books-invariant withdrawal detection for $0-start accounts - Platform-aware client pool, credentials (api_key column), and credential form; $0-size account types match by prefix alone - Drain stages: min-day ($5-$15 randomized) MNQ legs for the configured days, then wait for the 50% withdrawal; stage pills show 'Stage N – Drain' - Account calendar projects the perfect path: future days book their planned targets, simulated 50% withdrawals, payout marker Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
1bca441247
commit
50a5eb4713
@@ -1,6 +1,7 @@
|
|||||||
# AutoFirmer
|
# 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
|
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
|
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
|
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
|
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**
|
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
|
until its opening funding transaction has been fetched. Use the **Account
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
import type { FirmConfig, FirmState, AccountState, AccountConfig } from '@/types';
|
import type { FirmConfig, FirmState, AccountState, AccountConfig } from '@/types';
|
||||||
import { stagePillClasses } from '@/lib/stage-colors';
|
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
|
/** 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. */
|
* 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'];
|
const DOW_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||||
|
|
||||||
function CalendarMonth({ year, month, pnlMap, fundMap, stageMap }: { year: number; month: number; pnlMap: Map<string, number>; fundMap: Map<string, number>; stageMap: Map<string, number> }) {
|
function CalendarMonth({ year, month, pnlMap, fundMap, stageMap, projPnlMap, projFundMap, projStageMap, payoutDate }: {
|
||||||
|
year: number;
|
||||||
|
month: number;
|
||||||
|
pnlMap: Map<string, number>;
|
||||||
|
fundMap: Map<string, number>;
|
||||||
|
stageMap: Map<string, number>;
|
||||||
|
/** Perfect-path projection — rendered faded and dashed on days with no real data. */
|
||||||
|
projPnlMap?: Map<string, number>;
|
||||||
|
projFundMap?: Map<string, number>;
|
||||||
|
projStageMap?: Map<string, number>;
|
||||||
|
payoutDate?: string;
|
||||||
|
}) {
|
||||||
const daysInMonth = new Date(year, month, 0).getDate();
|
const daysInMonth = new Date(year, month, 0).getDate();
|
||||||
const firstDow = new Date(year, month - 1, 1).getDay(); // 0 = Sunday
|
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 key = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||||
const pnl = pnlMap.get(key);
|
const pnl = pnlMap.get(key);
|
||||||
const fundAmt = fundMap.get(key);
|
const fundAmt = fundMap.get(key);
|
||||||
const stage = stageMap.get(key);
|
|
||||||
const hasData = pnl !== undefined;
|
const hasData = pnl !== undefined;
|
||||||
const positive = hasData && pnl! >= 0;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={key}
|
key={key}
|
||||||
@@ -263,6 +282,12 @@ function CalendarMonth({ year, month, pnlMap, fundMap, stageMap }: { year: numbe
|
|||||||
: 'bg-red-50 border border-red-100'
|
: 'bg-red-50 border border-red-100'
|
||||||
: fundAmt !== undefined
|
: fundAmt !== undefined
|
||||||
? 'bg-amber-50 border border-amber-100'
|
? 'bg-amber-50 border border-amber-100'
|
||||||
|
: isPayout
|
||||||
|
? 'bg-blue-50/60 border border-dashed border-blue-200'
|
||||||
|
: projFund !== undefined
|
||||||
|
? 'bg-amber-50/50 border border-dashed border-amber-200'
|
||||||
|
: projPnl !== undefined
|
||||||
|
? 'bg-green-50/50 border border-dashed border-green-200'
|
||||||
: 'bg-slate-50 border border-transparent'
|
: 'bg-slate-50 border border-transparent'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -273,7 +298,7 @@ function CalendarMonth({ year, month, pnlMap, fundMap, stageMap }: { year: numbe
|
|||||||
{day}
|
{day}
|
||||||
</span>
|
</span>
|
||||||
{stage !== undefined && (
|
{stage !== undefined && (
|
||||||
<span className="text-[8px] font-semibold text-slate-400 leading-none">
|
<span className={`text-[8px] font-semibold leading-none ${isProj ? 'text-slate-300' : 'text-slate-400'}`}>
|
||||||
#{stage}
|
#{stage}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -290,6 +315,21 @@ function CalendarMonth({ year, month, pnlMap, fundMap, stageMap }: { year: numbe
|
|||||||
W/D {fundAmt >= 0 ? '+' : '−'}${fmt(Math.abs(fundAmt))}
|
W/D {fundAmt >= 0 ? '+' : '−'}${fmt(Math.abs(fundAmt))}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{projPnl !== undefined && (
|
||||||
|
<span className="text-[11px] font-semibold tabular-nums leading-tight text-green-600/60">
|
||||||
|
+${fmt(projPnl)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{projFund !== undefined && (
|
||||||
|
<span className="text-[10px] font-semibold tabular-nums leading-tight mt-0.5 text-amber-500/70">
|
||||||
|
W/D −${fmt(Math.abs(projFund))}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isPayout && (
|
||||||
|
<span className="text-[10px] font-bold leading-tight mt-0.5 text-blue-600/80">
|
||||||
|
Payout ✓
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -433,9 +473,30 @@ export default function AccountPage() {
|
|||||||
|
|
||||||
const strokeGradId = 'equityStroke';
|
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
|
// Build calendar data
|
||||||
const pnlMap = new Map(displayPnL.map((d) => [d.date, d.pnl]));
|
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();
|
const calendarMonths = [...new Set(allCalendarDates.map((d) => d.slice(0, 7)))].sort();
|
||||||
|
|
||||||
// Build stage map: stage increments after each withdrawal (negative fund txn)
|
// Build stage map: stage increments after each withdrawal (negative fund txn)
|
||||||
@@ -511,7 +572,7 @@ export default function AccountPage() {
|
|||||||
<span className="inline-flex items-center gap-1.5">
|
<span className="inline-flex items-center gap-1.5">
|
||||||
Profit Target
|
Profit Target
|
||||||
<span className={`inline-block px-1.5 py-0.5 rounded text-[10px] font-semibold ${stagePillClasses(account.stage)}`}>
|
<span className={`inline-block px-1.5 py-0.5 rounded text-[10px] font-semibold ${stagePillClasses(account.stage)}`}>
|
||||||
Stage {account.stage}
|
Stage {account.stage}{account.stageDrain ? ' – Drain' : ''}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
@@ -704,11 +765,25 @@ export default function AccountPage() {
|
|||||||
{/* Daily P&L Calendar */}
|
{/* Daily P&L Calendar */}
|
||||||
{calendarMonths.length > 0 && (
|
{calendarMonths.length > 0 && (
|
||||||
<div className="bg-white border border-slate-200 rounded-xl shadow-sm p-6">
|
<div className="bg-white border border-slate-200 rounded-xl shadow-sm p-6">
|
||||||
<h2 className="text-slate-900 font-bold text-base mb-6">Daily P&L</h2>
|
<div className="flex items-baseline justify-between mb-6">
|
||||||
|
<h2 className="text-slate-900 font-bold text-base">Daily P&L</h2>
|
||||||
|
{projection.length > 0 && (
|
||||||
|
<span className="text-xs text-slate-400">
|
||||||
|
Dashed days: the perfect path — planned targets booked daily, 50% withdrawals when a stage completes
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="grid grid-cols-1 gap-8" style={{ gridTemplateColumns: `repeat(${Math.min(calendarMonths.length, 3)}, minmax(0, 1fr))` }}>
|
<div className="grid grid-cols-1 gap-8" style={{ gridTemplateColumns: `repeat(${Math.min(calendarMonths.length, 3)}, minmax(0, 1fr))` }}>
|
||||||
{calendarMonths.map((ym) => {
|
{calendarMonths.map((ym) => {
|
||||||
const [y, m] = ym.split('-').map(Number);
|
const [y, m] = ym.split('-').map(Number);
|
||||||
return <CalendarMonth key={ym} year={y} month={m} pnlMap={pnlMap} fundMap={fundMap} stageMap={stageMap} />;
|
return (
|
||||||
|
<CalendarMonth
|
||||||
|
key={ym} year={y} month={m}
|
||||||
|
pnlMap={pnlMap} fundMap={fundMap} stageMap={stageMap}
|
||||||
|
projPnlMap={projPnlMap} projFundMap={projFundMap}
|
||||||
|
projStageMap={projStageMap} payoutDate={payoutDate}
|
||||||
|
/>
|
||||||
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,9 +20,9 @@ function dayPlanDiag(client: any, acc: { id: number; name: string }, tradeDate:
|
|||||||
const priorProfit = client.priorProfit?.[acc.id] ?? 0;
|
const priorProfit = client.priorProfit?.[acc.id] ?? 0;
|
||||||
const allFundTxns = client.fundTransactions?.[acc.id] ?? [];
|
const allFundTxns = client.fundTransactions?.[acc.id] ?? [];
|
||||||
const combined = combineCmeSessions(cfg.combineCmeHolidays === true, client.dailyPnL[acc.id] ?? [], tradeDate, cash.realizedPnL);
|
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 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 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;
|
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,
|
dayTarget: plan?.dayTarget ?? null,
|
||||||
remainingToday: plan?.remainingToday ?? null,
|
remainingToday: plan?.remainingToday ?? null,
|
||||||
path: plan?.path ?? null,
|
path: plan?.path ?? null,
|
||||||
|
drain: effective.drain,
|
||||||
consistency: effective.consistency,
|
consistency: effective.consistency,
|
||||||
hasTodayRow: combined.dailyPnL.some((d) => d.date === combined.tradeDate),
|
hasTodayRow: combined.dailyPnL.some((d) => d.date === combined.tradeDate),
|
||||||
combineCmeHolidays: cfg.combineCmeHolidays === true,
|
combineCmeHolidays: cfg.combineCmeHolidays === true,
|
||||||
|
|||||||
@@ -5,24 +5,35 @@ import { reconcileClients, removeClient } from '@/lib/clients';
|
|||||||
|
|
||||||
type Params = { params: Promise<{ id: string }> };
|
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
|
* 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) {
|
export async function PUT(req: NextRequest, { params }: Params) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
|
||||||
const firm = getCachedFirm(id);
|
const firm = getCachedFirm(id);
|
||||||
if (!firm) return NextResponse.json({ error: 'Firm not found' }, { status: 404 });
|
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 username = body.username?.trim() ?? '';
|
||||||
const password = body.password?.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 });
|
return NextResponse.json({ error: 'username and password are required' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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.
|
// Picks up the new login immediately rather than at the next restart.
|
||||||
reconcileClients();
|
reconcileClients();
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
|
|||||||
@@ -18,11 +18,15 @@ export async function GET() {
|
|||||||
try {
|
try {
|
||||||
const result: FirmConfig[] = getCachedCatalog().map((firm) => {
|
const result: FirmConfig[] = getCachedCatalog().map((firm) => {
|
||||||
const creds = getCredentials(firm.id);
|
const creds = getCredentials(firm.id);
|
||||||
|
const hasCredentials = firm.platform === 'topstepx'
|
||||||
|
? !!creds?.apiKey
|
||||||
|
: !!(creds?.username && creds?.password);
|
||||||
return {
|
return {
|
||||||
id: firm.id,
|
id: firm.id,
|
||||||
firm: firm.name,
|
firm: firm.name,
|
||||||
|
platform: firm.platform,
|
||||||
username: creds?.username ?? '',
|
username: creds?.username ?? '',
|
||||||
hasCredentials: !!creds,
|
hasCredentials,
|
||||||
bannedSymbols: firm.bannedSymbols,
|
bannedSymbols: firm.bannedSymbols,
|
||||||
accounts: firm.accountTypes.map((a) => ({
|
accounts: firm.accountTypes.map((a) => ({
|
||||||
id: a.id,
|
id: a.id,
|
||||||
|
|||||||
+23
-18
@@ -44,17 +44,29 @@ export async function GET() {
|
|||||||
);
|
);
|
||||||
const dailyPnL = combined.dailyPnL;
|
const dailyPnL = combined.dailyPnL;
|
||||||
const totalProfit = dailyPnL.reduce((sum, d) => sum + d.pnl, 0);
|
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 autoLiqThreshold = client.autoLiqThresholds[acc.id] ?? 0;
|
||||||
const isDead = autoLiqThreshold > 0 && cash.amount <= autoLiqThreshold;
|
const isDead = autoLiqThreshold > 0 && cash.amount <= autoLiqThreshold;
|
||||||
const allFundTxns = client.fundTransactions?.[acc.id] ?? [];
|
const allFundTxns = client.fundTransactions?.[acc.id] ?? [];
|
||||||
const priorProfit = client.priorProfit?.[acc.id] ?? 0;
|
const priorProfit = client.priorProfit?.[acc.id] ?? 0;
|
||||||
// Stage = 1 + number of withdrawals (negative fund transactions)
|
// Stage = 1 + number of withdrawals (negative fund transactions)
|
||||||
const stage = 1 + allFundTxns.filter((f) => f.amount < 0).length;
|
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
|
// Hide initial funding (amount === accountSize) from display
|
||||||
const fundingAmount = cfg?.accountSize ?? inferredAccountSize;
|
const fundingAmount = cfg?.accountSize ?? inferredAccountSize;
|
||||||
const displayFundTxns = fundingAmount !== null
|
const displayFundTxns = fundingAmount !== null
|
||||||
@@ -70,21 +82,12 @@ export async function GET() {
|
|||||||
let remainingToday: number | null = null;
|
let remainingToday: number | null = null;
|
||||||
let effectiveProfitTarget: number | null = null;
|
let effectiveProfitTarget: number | null = null;
|
||||||
let nextStop: number | null = null;
|
let nextStop: number | null = null;
|
||||||
if (cfg && !isDead) {
|
if (cfg && effective && !isDead) {
|
||||||
const effective = resolveEffectiveConfig(
|
|
||||||
cfg.profitTarget,
|
|
||||||
cfg.consistency,
|
|
||||||
cfg.minTradingDays,
|
|
||||||
cfg.targetSameEquity,
|
|
||||||
cfg.withdrawalStages,
|
|
||||||
priorProfit,
|
|
||||||
allFundTxns
|
|
||||||
);
|
|
||||||
const equityProfit = cash.amount - cfg.accountSize;
|
const equityProfit = cash.amount - cfg.accountSize;
|
||||||
const plan = computeDayPlan(
|
const plan = computeDayPlan(
|
||||||
acc.id, combined.tradeDate, effective.profitTarget, effective.consistency,
|
acc.id, combined.tradeDate, effective.profitTarget, effective.consistency,
|
||||||
dailyPnL, cfg.minDayPnL, effective.minTradingDays, equityProfit, combined.realizedPnL,
|
dailyPnL, effective.minDayPnL, effective.minTradingDays, equityProfit, combined.realizedPnL,
|
||||||
cfg.splitEnabled
|
cfg.splitEnabled, effective.drain
|
||||||
);
|
);
|
||||||
dailyTarget = plan ? { amount: plan.dayTarget, path: plan.path } : null;
|
dailyTarget = plan ? { amount: plan.dayTarget, path: plan.path } : null;
|
||||||
remainingToday = plan?.remainingToday ?? null;
|
remainingToday = plan?.remainingToday ?? null;
|
||||||
@@ -113,7 +116,7 @@ export async function GET() {
|
|||||||
// Effective profit target = max(stage target, consistency realTarget).
|
// Effective profit target = max(stage target, consistency realTarget).
|
||||||
// If a big day forces the consistency rule, the account must reach maxDay/consistency
|
// If a big day forces the consistency rule, the account must reach maxDay/consistency
|
||||||
// in total trading profit for the stage, not just profitTarget.
|
// 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 maxDay = qualifying.length > 0 ? Math.max(...qualifying.map((d) => d.pnl)) : 0;
|
||||||
const consistencyReal = (effective.consistency > 0 && effective.consistency < 1 && maxDay > 0)
|
const consistencyReal = (effective.consistency > 0 && effective.consistency < 1 && maxDay > 0)
|
||||||
? maxDay / effective.consistency : 0;
|
? maxDay / effective.consistency : 0;
|
||||||
@@ -192,6 +195,8 @@ export async function GET() {
|
|||||||
totalProfit,
|
totalProfit,
|
||||||
targetHit,
|
targetHit,
|
||||||
stage,
|
stage,
|
||||||
|
// Current stage is a drain stage — dashboards label it "Stage N – Drain".
|
||||||
|
stageDrain: effective?.drain ?? false,
|
||||||
dailyTarget,
|
dailyTarget,
|
||||||
nextStop,
|
nextStop,
|
||||||
remainingToday,
|
remainingToday,
|
||||||
|
|||||||
+41
-8
@@ -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 }) {
|
function CredentialForm({ firm, onSaved }: { firm: FirmConfig; onSaved: () => void }) {
|
||||||
|
const isTopstepX = firm.platform === 'topstepx';
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
|
const [apiKey, setApiKey] = useState('');
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const canSave = isTopstepX
|
||||||
|
? apiKey.trim().length > 0
|
||||||
|
: username.trim().length > 0 && password.trim().length > 0;
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
if (!username.trim() || !password.trim()) return;
|
if (!canSave) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/firms/${firm.id}/credentials`, {
|
const res = await fetch(`/api/firms/${firm.id}/credentials`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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');
|
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error ?? 'Failed');
|
||||||
setUsername('');
|
setUsername('');
|
||||||
setPassword('');
|
setPassword('');
|
||||||
|
setApiKey('');
|
||||||
onSaved();
|
onSaved();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed');
|
setError(err instanceof Error ? err.message : 'Failed');
|
||||||
@@ -220,10 +229,20 @@ function CredentialForm({ firm, onSaved }: { firm: FirmConfig; onSaved: () => vo
|
|||||||
<div className="flex items-center gap-1.5" onClick={(e) => e.stopPropagation()}>
|
<div className="flex items-center gap-1.5" onClick={(e) => e.stopPropagation()}>
|
||||||
<input
|
<input
|
||||||
className={cls}
|
className={cls}
|
||||||
placeholder="Tradovate username"
|
placeholder={isTopstepX ? 'Username (optional)' : 'Tradovate username'}
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
{isTopstepX ? (
|
||||||
|
<input
|
||||||
|
className={`${cls} w-52`}
|
||||||
|
type="password"
|
||||||
|
placeholder="TopstepX API key"
|
||||||
|
value={apiKey}
|
||||||
|
onChange={(e) => setApiKey(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') void save(); }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<input
|
<input
|
||||||
className={cls}
|
className={cls}
|
||||||
type="password"
|
type="password"
|
||||||
@@ -232,9 +251,10 @@ function CredentialForm({ firm, onSaved }: { firm: FirmConfig; onSaved: () => vo
|
|||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') void save(); }}
|
onKeyDown={(e) => { if (e.key === 'Enter') void save(); }}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={save}
|
onClick={save}
|
||||||
disabled={saving || !username.trim() || !password.trim()}
|
disabled={saving || !canSave}
|
||||||
className="px-2.5 py-1 bg-blue-100 hover:bg-blue-200 disabled:opacity-50 text-blue-700 text-xs font-semibold rounded-md transition-colors"
|
className="px-2.5 py-1 bg-blue-100 hover:bg-blue-200 disabled:opacity-50 text-blue-700 text-xs font-semibold rounded-md transition-colors"
|
||||||
>
|
>
|
||||||
{saving ? '…' : 'Save'}
|
{saving ? '…' : 'Save'}
|
||||||
@@ -346,7 +366,7 @@ function AccountRow({ account, firm, hideDead, privacy, onAssigned }: {
|
|||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span>${(account.effectiveProfitTarget ?? cfg?.profitTarget ?? 0).toLocaleString(undefined, { maximumFractionDigits: 2 })}</span>
|
<span>${(account.effectiveProfitTarget ?? cfg?.profitTarget ?? 0).toLocaleString(undefined, { maximumFractionDigits: 2 })}</span>
|
||||||
<span className={`inline-block px-1.5 py-0.5 rounded text-[10px] font-semibold ${stagePillClasses(account.stage)}`}>
|
<span className={`inline-block px-1.5 py-0.5 rounded text-[10px] font-semibold ${stagePillClasses(account.stage)}`}>
|
||||||
Stage {account.stage}
|
Stage {account.stage}{account.stageDrain ? ' – Drain' : ''}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{account.dailyTarget && (
|
{account.dailyTarget && (
|
||||||
@@ -449,6 +469,19 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo
|
|||||||
{tradeSymbol} BANNED
|
{tradeSymbol} BANNED
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{firm.platform === 'topstepx' && (
|
||||||
|
<span className="text-xs bg-indigo-100 text-indigo-700 px-1.5 py-0.5 rounded font-semibold">
|
||||||
|
TopstepX
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{firm.platform === 'topstepx' && hasCredentials && !firm.username && (
|
||||||
|
<span
|
||||||
|
className="text-xs bg-amber-100 text-amber-700 px-1.5 py-0.5 rounded font-semibold"
|
||||||
|
title="TopstepX login needs the platform username the API key was generated for — remove and re-save the credentials with it"
|
||||||
|
>
|
||||||
|
Username missing
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{hasCredentials && !state.connected && (
|
{hasCredentials && !state.connected && (
|
||||||
<span className="text-xs bg-slate-200 text-slate-600 px-1.5 py-0.5 rounded font-semibold">
|
<span className="text-xs bg-slate-200 text-slate-600 px-1.5 py-0.5 rounded font-semibold">
|
||||||
Connecting…
|
Connecting…
|
||||||
@@ -469,7 +502,7 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo
|
|||||||
<td colSpan={5} className="px-4 py-2.5">
|
<td colSpan={5} className="px-4 py-2.5">
|
||||||
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
|
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
|
||||||
<span className="text-xs text-slate-500">
|
<span className="text-xs text-slate-500">
|
||||||
Remove the saved Tradovate login for {firm.firm}? Its accounts disappear until you enter one again.
|
Remove the saved {firm.platform === 'topstepx' ? 'TopstepX API key' : 'Tradovate login'} for {firm.firm}? Its accounts disappear until you enter one again.
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={removeCredentials}
|
onClick={removeCredentials}
|
||||||
@@ -495,7 +528,7 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo
|
|||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); setConfirmCreds(true); }}
|
onClick={(e) => { e.stopPropagation(); setConfirmCreds(true); }}
|
||||||
className="text-slate-400 hover:text-red-500 hover:bg-red-50 p-1 rounded-md transition-colors inline-flex"
|
className="text-slate-400 hover:text-red-500 hover:bg-red-50 p-1 rounded-md transition-colors inline-flex"
|
||||||
title="Remove saved Tradovate login"
|
title={firm.platform === 'topstepx' ? 'Remove saved TopstepX API key' : 'Remove saved Tradovate login'}
|
||||||
>
|
>
|
||||||
<KeyOffIcon />
|
<KeyOffIcon />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { CatalogAccountType } from './master-protocol';
|
import type { CatalogAccountType } from './master-protocol';
|
||||||
import type { TradovateClient } from './tradovate-class';
|
import type { PlatformClient } from './platform-client';
|
||||||
import {
|
import {
|
||||||
getAssignment,
|
getAssignment,
|
||||||
setAssignment,
|
setAssignment,
|
||||||
@@ -69,10 +69,14 @@ export function matchAccountType(
|
|||||||
const byPrefix = types.filter((t) => accountName.startsWith(t.prefix));
|
const byPrefix = types.filter((t) => accountName.startsWith(t.prefix));
|
||||||
if (byPrefix.length === 0) return { type: null, ambiguous: false };
|
if (byPrefix.length === 0) return { type: null, ambiguous: false };
|
||||||
|
|
||||||
// No size yet — stay Unassigned and try again next sync.
|
// Exact size match first. An account type with size 0 means the account's
|
||||||
if (accountSize === null) return { type: null, ambiguous: false };
|
// balance starts at $0 (TopstepX Express style) — there is no funding
|
||||||
|
// transaction to infer a size from, so it matches on prefix alone, but
|
||||||
const candidates = byPrefix.filter((t) => Math.abs(t.accountSize - accountSize) < 0.01);
|
// 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 === 0) return { type: null, ambiguous: false };
|
||||||
if (candidates.length === 1) return { type: candidates[0], 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
|
/** The size this account resolved to, cached once found so a later reset
|
||||||
* deposit can't move it. */
|
* 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);
|
const cached = loadAccountMeta(accountId, INFERRED_SIZE_KEY);
|
||||||
if (cached !== null) {
|
if (cached !== null) {
|
||||||
const value = Number(cached);
|
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. */
|
* Called after each Tradovate sync and after the catalog changes. */
|
||||||
export function resolveFirmAssignments(
|
export function resolveFirmAssignments(
|
||||||
firmId: string,
|
firmId: string,
|
||||||
client: TradovateClient,
|
client: PlatformClient,
|
||||||
): { assigned: number; unassigned: number; manual: number } {
|
): { assigned: number; unassigned: number; manual: number } {
|
||||||
const firm = getCachedFirm(firmId);
|
const firm = getCachedFirm(firmId);
|
||||||
const types = firm?.accountTypes ?? [];
|
const types = firm?.accountTypes ?? [];
|
||||||
@@ -184,6 +188,9 @@ export function resolveFirmAssignments(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (matched.type) {
|
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) {
|
if (matchedSize !== null && cached === null) {
|
||||||
saveAccountMeta(account.id, INFERRED_SIZE_KEY, String(matchedSize));
|
saveAccountMeta(account.id, INFERRED_SIZE_KEY, String(matchedSize));
|
||||||
}
|
}
|
||||||
|
|||||||
+69
-33
@@ -72,6 +72,22 @@ function obfuscationStopDollars(
|
|||||||
return stop;
|
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…) ──
|
// ── per-account trade evaluation (shared by runTrade / copyTrade / hasRemaining…) ──
|
||||||
|
|
||||||
interface AccountEvaluation {
|
interface AccountEvaluation {
|
||||||
@@ -79,6 +95,8 @@ interface AccountEvaluation {
|
|||||||
reason: string;
|
reason: string;
|
||||||
plan: DayPlan | null;
|
plan: DayPlan | null;
|
||||||
isExtraDay: boolean;
|
isExtraDay: boolean;
|
||||||
|
/** Current stage is a drain stage — the leg trades MNQ regardless of the batch symbol. */
|
||||||
|
drain: boolean;
|
||||||
cash: { amount: number; realizedPnL: number };
|
cash: { amount: number; realizedPnL: number };
|
||||||
dailyPnL: { date: string; pnl: number }[];
|
dailyPnL: { date: string; pnl: number }[];
|
||||||
daysTraded: number;
|
daysTraded: number;
|
||||||
@@ -110,7 +128,7 @@ function evaluateAccountForTrade(client: any, acc: { id: number; name: string; a
|
|||||||
const dailyPnL = combined.dailyPnL;
|
const dailyPnL = combined.dailyPnL;
|
||||||
const daysTraded = combined.daysTraded;
|
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 (isAccountDead(cash.amount, autoLiqThreshold)) return { ...base, eligible: false, reason: 'dead' };
|
||||||
if (!acc.active) return { ...base, eligible: false, reason: 'inactive (DLL)' };
|
if (!acc.active) return { ...base, eligible: false, reason: 'inactive (DLL)' };
|
||||||
if (client.positions[acc.id]) return { ...base, eligible: false, reason: 'already in trade' };
|
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 priorProfit = client.priorProfit?.[acc.id] ?? 0;
|
||||||
const allFundTxns = client.fundTransactions?.[acc.id] ?? [];
|
const allFundTxns = client.fundTransactions?.[acc.id] ?? [];
|
||||||
const effective = resolveEffectiveConfig(
|
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 equityProfit = cash.amount - cfg.accountSize;
|
||||||
const plan = computeDayPlan(
|
const plan = computeDayPlan(
|
||||||
acc.id, combined.tradeDate, effective.profitTarget, effective.consistency,
|
acc.id, combined.tradeDate, effective.profitTarget, effective.consistency,
|
||||||
dailyPnL, cfg.minDayPnL, effective.minTradingDays, equityProfit, combined.realizedPnL,
|
dailyPnL, effective.minDayPnL, effective.minTradingDays, equityProfit, combined.realizedPnL,
|
||||||
cfg.splitEnabled
|
cfg.splitEnabled, effective.drain
|
||||||
);
|
);
|
||||||
if (!plan) return { ...base, eligible: false, reason: 'invalid balance' };
|
if (!plan) return { ...base, eligible: false, reason: 'invalid balance' };
|
||||||
|
|
||||||
const totalProfit = dailyPnL.reduce((sum, d) => sum + d.pnl, 0);
|
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
|
&& effective.minTradingDays > daysTraded
|
||||||
&& totalProfit >= effective.profitTarget;
|
&& totalProfit >= effective.profitTarget;
|
||||||
|
|
||||||
const evaluated = { plan, isExtraDay, cash, dailyPnL, daysTraded };
|
const evaluated = { plan, isExtraDay, drain: effective.drain, cash, dailyPnL, daysTraded };
|
||||||
|
|
||||||
if (isExtraDay || !plan.splitAllowed) {
|
if (isExtraDay || !plan.splitAllowed) {
|
||||||
// Legacy one-and-done gate: any realized PnL today means this account is
|
// 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})` };
|
return { ...evaluated, eligible: false, reason: `already traded today (realizedPnL=${combined.realizedPnL})` };
|
||||||
}
|
}
|
||||||
if (!isExtraDay && plan.remainingToday <= 0) {
|
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 {
|
} else {
|
||||||
// Split-leg mode: keep trading until the day's remaining target is used up.
|
// 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;
|
daysTraded: number;
|
||||||
plan: DayPlan;
|
plan: DayPlan;
|
||||||
isExtraDay: boolean;
|
isExtraDay: boolean;
|
||||||
|
drain: boolean;
|
||||||
prefix: string;
|
prefix: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -341,7 +365,7 @@ export async function runTrade(action: 'Buy' | 'Sell' | 'Auto', symbol: string)
|
|||||||
allEligible.push({
|
allEligible.push({
|
||||||
firmName: firm.name, client, acc, contract,
|
firmName: firm.name, client, acc, contract,
|
||||||
cash: ev.cash, dailyPnL: ev.dailyPnL, daysTraded: ev.daysTraded,
|
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 ?? '',
|
prefix: getAccountConfig(acc.id).accountType?.prefix ?? '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -419,9 +443,13 @@ export async function runTrade(action: 'Buy' | 'Sell' | 'Auto', symbol: string)
|
|||||||
: item.plan.remainingToday;
|
: item.plan.remainingToday;
|
||||||
recordLeg(acc.id, tradeDate, legAmount);
|
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 rawContracts = Math.max(1, Math.ceil(legAmount / 1000));
|
||||||
const contracts = cfg.maxPositionSize > 0 ? Math.min(rawContracts, cfg.maxPositionSize) : rawContracts;
|
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
|
// Wait briefly for the cash balance WebSocket update to reflect entry commission
|
||||||
await new Promise(r => setTimeout(r, 1000));
|
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 totalCommission = entryCommission * 2; // entry + exit round-trip
|
||||||
const grossTarget = legAmount + totalCommission;
|
const grossTarget = legAmount + totalCommission;
|
||||||
|
|
||||||
const targetPoints = grossTarget / (pointValue * contracts);
|
const targetPoints = grossTarget / (pv * contracts);
|
||||||
const ticks = Math.ceil(targetPoints / contract.tickSize);
|
const ticks = Math.ceil(targetPoints / legContract.tickSize);
|
||||||
const exitPrice = resolvedAction === 'Buy'
|
const exitPrice = resolvedAction === 'Buy'
|
||||||
? fill.price + (ticks * contract.tickSize)
|
? fill.price + (ticks * legContract.tickSize)
|
||||||
: fill.price - (ticks * contract.tickSize);
|
: fill.price - (ticks * legContract.tickSize);
|
||||||
|
|
||||||
const exitAction: 'Buy' | 'Sell' = resolvedAction === 'Buy' ? 'Sell' : 'Buy';
|
const exitAction: 'Buy' | 'Sell' = resolvedAction === 'Buy' ? 'Sell' : 'Buy';
|
||||||
const stopDollars = obfuscationStopDollars(cfg, item.dailyPnL, item.plan, tradeDate, legAmount);
|
const stopDollars = obfuscationStopDollars(cfg, item.dailyPnL, item.plan, tradeDate, legAmount);
|
||||||
let exitOrder: { orderId?: number };
|
let exitOrder: { orderId?: number };
|
||||||
let stopPrice: number | null = null;
|
let stopPrice: number | null = null;
|
||||||
if (Number.isFinite(stopDollars) && stopDollars > 0) {
|
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'
|
stopPrice = resolvedAction === 'Buy'
|
||||||
? fill.price - (stopTicks * contract.tickSize)
|
? fill.price - (stopTicks * legContract.tickSize)
|
||||||
: fill.price + (stopTicks * contract.tickSize);
|
: fill.price + (stopTicks * legContract.tickSize);
|
||||||
try {
|
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) {
|
} catch (err) {
|
||||||
// The position is already open — never leave it exit-less. Fall
|
// The position is already open — never leave it exit-less. Fall
|
||||||
// back to the classic lone limit and shout about it.
|
// 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`);
|
console.error(`[auto-trade] ${acc.name}: placeoco REJECTED (${err instanceof Error ? err.message : err}) — falling back to lone limit exit, NO STOP`);
|
||||||
stopPrice = null;
|
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 {
|
} 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 {
|
return {
|
||||||
account: acc.name,
|
account: acc.name,
|
||||||
@@ -516,7 +544,9 @@ export async function copyTrade() {
|
|||||||
|
|
||||||
// Find all accounts with open positions to determine direction + symbol
|
// Find all accounts with open positions to determine direction + symbol
|
||||||
let resolvedAction: 'Buy' | 'Sell' | null = null;
|
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;
|
let positionedCount = 0;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
let positionedClient: any = null;
|
let positionedClient: any = null;
|
||||||
@@ -592,6 +622,7 @@ export async function copyTrade() {
|
|||||||
cash: { amount: number; realizedPnL: number };
|
cash: { amount: number; realizedPnL: number };
|
||||||
dailyPnL: { date: string; pnl: number }[];
|
dailyPnL: { date: string; pnl: number }[];
|
||||||
plan: DayPlan;
|
plan: DayPlan;
|
||||||
|
drain: boolean;
|
||||||
prefix: string;
|
prefix: string;
|
||||||
};
|
};
|
||||||
const eligible: CopyItem[] = [];
|
const eligible: CopyItem[] = [];
|
||||||
@@ -622,6 +653,7 @@ export async function copyTrade() {
|
|||||||
|
|
||||||
eligible.push({
|
eligible.push({
|
||||||
firmName: firm.name, client, acc, contract, cash: ev.cash, dailyPnL: ev.dailyPnL, plan: ev.plan,
|
firmName: firm.name, client, acc, contract, cash: ev.cash, dailyPnL: ev.dailyPnL, plan: ev.plan,
|
||||||
|
drain: ev.drain,
|
||||||
prefix: getAccountConfig(acc.id).accountType?.prefix ?? '',
|
prefix: getAccountConfig(acc.id).accountType?.prefix ?? '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -654,9 +686,13 @@ export async function copyTrade() {
|
|||||||
: item.plan.remainingToday;
|
: item.plan.remainingToday;
|
||||||
recordLeg(acc.id, tradeDate, legAmount);
|
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 rawContracts = Math.max(1, Math.ceil(legAmount / 1000));
|
||||||
const contracts = cfg.maxPositionSize > 0 ? Math.min(rawContracts, cfg.maxPositionSize) : rawContracts;
|
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));
|
await new Promise(r => setTimeout(r, 1000));
|
||||||
const updatedCash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 };
|
const updatedCash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 };
|
||||||
@@ -664,33 +700,33 @@ export async function copyTrade() {
|
|||||||
const totalCommission = entryCommission * 2;
|
const totalCommission = entryCommission * 2;
|
||||||
const grossTarget = legAmount + totalCommission;
|
const grossTarget = legAmount + totalCommission;
|
||||||
|
|
||||||
const targetPoints = grossTarget / (pointValue * contracts);
|
const targetPoints = grossTarget / (pv * contracts);
|
||||||
const ticks = Math.ceil(targetPoints / contract.tickSize);
|
const ticks = Math.ceil(targetPoints / legContract.tickSize);
|
||||||
const exitPrice = resolvedAction === 'Buy'
|
const exitPrice = resolvedAction === 'Buy'
|
||||||
? fill.price + (ticks * contract.tickSize)
|
? fill.price + (ticks * legContract.tickSize)
|
||||||
: fill.price - (ticks * contract.tickSize);
|
: fill.price - (ticks * legContract.tickSize);
|
||||||
|
|
||||||
const exitAction: 'Buy' | 'Sell' = resolvedAction === 'Buy' ? 'Sell' : 'Buy';
|
const exitAction: 'Buy' | 'Sell' = resolvedAction === 'Buy' ? 'Sell' : 'Buy';
|
||||||
const stopDollars = obfuscationStopDollars(cfg, item.dailyPnL, item.plan, tradeDate, legAmount);
|
const stopDollars = obfuscationStopDollars(cfg, item.dailyPnL, item.plan, tradeDate, legAmount);
|
||||||
let exitOrder: { orderId?: number };
|
let exitOrder: { orderId?: number };
|
||||||
let stopPrice: number | null = null;
|
let stopPrice: number | null = null;
|
||||||
if (Number.isFinite(stopDollars) && stopDollars > 0) {
|
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'
|
stopPrice = resolvedAction === 'Buy'
|
||||||
? fill.price - (stopTicks * contract.tickSize)
|
? fill.price - (stopTicks * legContract.tickSize)
|
||||||
: fill.price + (stopTicks * contract.tickSize);
|
: fill.price + (stopTicks * legContract.tickSize);
|
||||||
try {
|
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) {
|
} catch (err) {
|
||||||
console.error(`[copy-trade] ${acc.name}: placeoco REJECTED (${err instanceof Error ? err.message : err}) — falling back to lone limit exit, NO STOP`);
|
console.error(`[copy-trade] ${acc.name}: placeoco REJECTED (${err instanceof Error ? err.message : err}) — falling back to lone limit exit, NO STOP`);
|
||||||
stopPrice = null;
|
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 {
|
} 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 {
|
return {
|
||||||
account: acc.name,
|
account: acc.name,
|
||||||
|
|||||||
Binary file not shown.
@@ -49,6 +49,15 @@ db.exec(`
|
|||||||
// Tradovate credentials, and a silent DROP would be unrecoverable if this
|
// 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.
|
// 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 ─────────────────────────────────────────────────────────
|
// ── Firm credentials ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface FirmCredentialRow {
|
export interface FirmCredentialRow {
|
||||||
@@ -56,13 +65,15 @@ export interface FirmCredentialRow {
|
|||||||
firm_name: string;
|
firm_name: string;
|
||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
|
api_key: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCredentials(firmId: string): { username: string; password: string } | undefined {
|
export function getCredentials(firmId: string): { username: string; password: string; apiKey: string } | undefined {
|
||||||
const row = db.prepare('SELECT username, password FROM firm_credentials WHERE firm_id = ?').get(firmId) as
|
const row = db.prepare('SELECT username, password, api_key FROM firm_credentials WHERE firm_id = ?').get(firmId) as
|
||||||
{ username: string; password: string } | undefined;
|
{ username: string; password: string; api_key: string } | undefined;
|
||||||
return row;
|
if (!row) return undefined;
|
||||||
|
return { username: row.username, password: row.password, apiKey: row.api_key };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listCredentials(): FirmCredentialRow[] {
|
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
|
/** 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
|
* if the master's database is ever rebuilt and the firms come back with new
|
||||||
* ids. See rekeyCredentialsByName in lib/firm-catalog.ts. */
|
* 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(`
|
db.prepare(`
|
||||||
INSERT INTO firm_credentials (firm_id, firm_name, username, password, updated_at)
|
INSERT INTO firm_credentials (firm_id, firm_name, username, password, api_key, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(firm_id) DO UPDATE SET
|
ON CONFLICT(firm_id) DO UPDATE SET
|
||||||
firm_name = excluded.firm_name,
|
firm_name = excluded.firm_name,
|
||||||
username = excluded.username,
|
username = excluded.username,
|
||||||
password = excluded.password,
|
password = excluded.password,
|
||||||
|
api_key = excluded.api_key,
|
||||||
updated_at = excluded.updated_at
|
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 {
|
export function clearCredentials(firmId: string): boolean {
|
||||||
|
|||||||
+12
-9
@@ -99,17 +99,20 @@ export function getCatalogMeta(): {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Firms this machine can actually trade: in the catalog AND holding
|
/** Firms this machine can actually trade: in the catalog AND holding the
|
||||||
* credentials. Everything that starts a Tradovate client goes through here. */
|
* credentials their platform needs — a Tradovate username + password, or a
|
||||||
export function getTradableFirms(): (CatalogFirm & { username: string; password: string })[] {
|
* TopstepX API key. Everything that starts a platform client goes through
|
||||||
const result: (CatalogFirm & { username: string; password: string })[] = [];
|
* 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()) {
|
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);
|
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;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,7 +128,11 @@ export interface CatalogAccountType {
|
|||||||
* firms that follow CME clearing report them (e.g. Apex: Sun/Mon/Tue around
|
* 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). */
|
* a Monday holiday is ONE trading day, recorded on the Tuesday). */
|
||||||
combineCmeHolidays: boolean;
|
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. */
|
/** ISO. Ties between two matching account types are broken by this. */
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<string, string>;
|
||||||
|
lastFetchRaw: Record<string, string>;
|
||||||
|
recentEntityEvents: { entityType: string; eventType: string; entity: unknown; ts: number }[];
|
||||||
|
products: { id: number; name: string }[];
|
||||||
|
|
||||||
|
fetchDaysTraded(onlyAccountIds?: number[]): Promise<{ failedAccounts: number }>;
|
||||||
|
findFrontMonthContract(productName: string): Promise<PlatformContract | null>;
|
||||||
|
fetchInstrumentFees(symbols: string[]): Promise<{ [symbol: string]: number }>;
|
||||||
|
|
||||||
|
sendOrder(
|
||||||
|
accountId: number,
|
||||||
|
contractSymbol: string,
|
||||||
|
quantity: number,
|
||||||
|
action: 'Buy' | 'Sell',
|
||||||
|
orderType: 'Market' | 'Limit',
|
||||||
|
price?: number
|
||||||
|
): Promise<Record<string, unknown>>;
|
||||||
|
|
||||||
|
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<string, unknown>) => void): void;
|
||||||
|
}
|
||||||
+20
-16
@@ -91,6 +91,16 @@ function collectFirmStats() {
|
|||||||
const combined = cfg ? combineCmeSessions(
|
const combined = cfg ? combineCmeSessions(
|
||||||
cfg.combineCmeHolidays, client.dailyPnL[acc.id] ?? [], currentTradeDateCT(), cash.realizedPnL,
|
cfg.combineCmeHolidays, client.dailyPnL[acc.id] ?? [], currentTradeDateCT(), cash.realizedPnL,
|
||||||
) : null;
|
) : 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) {
|
if (cfg && combined && acc.active && cash.amount - cfg.accountSize >= 0) {
|
||||||
const hist = (slice.daysHist ??= {});
|
const hist = (slice.daysHist ??= {});
|
||||||
hist[combined.daysTraded] = (hist[combined.daysTraded] ?? 0) + 1;
|
hist[combined.daysTraded] = (hist[combined.daysTraded] ?? 0) + 1;
|
||||||
@@ -100,12 +110,12 @@ function collectFirmStats() {
|
|||||||
// included, since the master sims their real required profit.
|
// included, since the master sims their real required profit.
|
||||||
// client.dailyPnL is already the active-cycle slice, so a
|
// client.dailyPnL is already the active-cycle slice, so a
|
||||||
// withdrawal resets days/maxDay/sum here automatically.
|
// withdrawal resets days/maxDay/sum here automatically.
|
||||||
if (cfg && combined) {
|
if (cfg && combined && effective) {
|
||||||
const r2 = (n: number) => Math.round(n * 100) / 100;
|
const r2 = (n: number) => Math.round(n * 100) / 100;
|
||||||
const equityProfit = r2(cash.amount - cfg.accountSize);
|
const equityProfit = r2(cash.amount - cfg.accountSize);
|
||||||
const qualifying = cfg.minDayPnL === 0
|
const qualifying = effective.minDayPnL === 0
|
||||||
? combined.dailyPnL
|
? 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));
|
const sum = r2(combined.dailyPnL.reduce((s, d) => s + d.pnl, 0));
|
||||||
// End-of-day equity-profit high-water mark for this cycle,
|
// End-of-day equity-profit high-water mark for this cycle,
|
||||||
// floored at the $0 start: walk the P&L from the cycle base.
|
// floored at the $0 start: walk the P&L from the cycle base.
|
||||||
@@ -115,13 +125,6 @@ function collectFirmStats() {
|
|||||||
run = r2(run + d.pnl);
|
run = r2(run + d.pnl);
|
||||||
if (run > highWater) highWater = run;
|
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);
|
const traded = combined.dailyPnL.filter((d) => d.pnl !== 0);
|
||||||
(slice.payoutStates ??= []).push({
|
(slice.payoutStates ??= []).push({
|
||||||
days: qualifying.length,
|
days: qualifying.length,
|
||||||
@@ -156,16 +159,17 @@ function collectFirmStats() {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if target was hit today
|
// Check if target was hit today — on the stage-resolved config, so
|
||||||
if (cfg && combined) {
|
// 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 equityProfit = cash.amount - cfg.accountSize;
|
||||||
const plan = computeDayPlan(
|
const plan = computeDayPlan(
|
||||||
acc.id, combined.tradeDate, cfg.profitTarget, cfg.consistency,
|
acc.id, combined.tradeDate, effective.profitTarget, effective.consistency,
|
||||||
combined.dailyPnL, cfg.minDayPnL, cfg.minTradingDays, equityProfit, combined.realizedPnL,
|
combined.dailyPnL, effective.minDayPnL, effective.minTradingDays, equityProfit, combined.realizedPnL,
|
||||||
cfg.splitEnabled
|
cfg.splitEnabled, effective.drain
|
||||||
);
|
);
|
||||||
const targetHit = !plan ? false :
|
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));
|
(combined.realizedPnL > 0 && plan.remainingToday <= LEG_COMPLETION_EPSILON));
|
||||||
|
|
||||||
if (targetHit || combined.realizedPnL !== 0) {
|
if (targetHit || combined.realizedPnL !== 0) {
|
||||||
|
|||||||
@@ -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<string, string> = {};
|
||||||
|
public lastFetchRaw: Record<string, string> = {};
|
||||||
|
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<void>;
|
||||||
|
private hub: signalR.HubConnection | null = null;
|
||||||
|
private subscribedAccountIds = new Set<number>();
|
||||||
|
|
||||||
|
private syncInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
private tokenRenewalInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
private ocoPollInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
private loginRetryTimer: ReturnType<typeof setTimeout> | 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<number, TsxTrade> } = {};
|
||||||
|
|
||||||
|
/** Front-month contract per product symbol, plus name→contract for order
|
||||||
|
* placement (sendOrder receives the contract NAME, e.g. "NQZ5"). */
|
||||||
|
private contractByProduct = new Map<string, { contract: TsxContract; resolvedAt: number }>();
|
||||||
|
private contractByName = new Map<string, TsxContract>();
|
||||||
|
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<void>) {
|
||||||
|
this.name = name;
|
||||||
|
this.apiKey = apiKey;
|
||||||
|
this.callbackOnSyncRequest = callbackOnSyncRequest;
|
||||||
|
this.connectAndAuth();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── auth ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private authHeaders() {
|
||||||
|
return { Authorization: `Bearer ${this.token}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async post<T>(path: string, body: unknown): Promise<T> {
|
||||||
|
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<number, string> = {
|
||||||
|
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<string> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<string, any> => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const r = raw as Record<string, any>;
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<number, TsxTrade>();
|
||||||
|
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<string, number>();
|
||||||
|
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<PlatformContract | null> {
|
||||||
|
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<TsxContract> {
|
||||||
|
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<PlatformAccount[]> {
|
||||||
|
return this.accountList;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── orders ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private async placeOrder(
|
||||||
|
accountId: number,
|
||||||
|
contractId: string,
|
||||||
|
size: number,
|
||||||
|
action: 'Buy' | 'Sell',
|
||||||
|
type: number,
|
||||||
|
limitPrice?: number,
|
||||||
|
stopPrice?: number,
|
||||||
|
): Promise<number> {
|
||||||
|
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<Record<string, unknown>> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<string, unknown>) => void): void {
|
||||||
|
this.fillWaiters.push({ orderId, callback: (trade) => callback(trade as unknown as Record<string, unknown>) });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+209
-12
@@ -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.
|
* Resolve the effective profit target and consistency for an account based on its withdrawal strategy.
|
||||||
*
|
*
|
||||||
@@ -6,42 +26,53 @@
|
|||||||
* effectiveProfitTarget = profitTarget − remainingProfit
|
* effectiveProfitTarget = profitTarget − remainingProfit
|
||||||
*
|
*
|
||||||
* Mode B (withdrawalStages non-empty): Stage 1 (no withdrawals yet) uses base profitTarget/consistency.
|
* 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.
|
* After the Nth withdrawal, use withdrawalStages[N-1]; last stage repeats. A drain stage carries no
|
||||||
* Fallback: returns base profitTarget and consistency unchanged.
|
* profit target — it resolves to target 0 with drain=true and its own min-day amount.
|
||||||
|
* Fallback: returns base values unchanged.
|
||||||
*/
|
*/
|
||||||
export function resolveEffectiveConfig(
|
export function resolveEffectiveConfig(
|
||||||
profitTarget: number,
|
profitTarget: number,
|
||||||
consistency: number,
|
consistency: number,
|
||||||
minTradingDays: number,
|
minTradingDays: number,
|
||||||
targetSameEquity: boolean,
|
targetSameEquity: boolean,
|
||||||
withdrawalStages: { profit: number; consistency: number; minTradingDays: number }[],
|
withdrawalStages: WithdrawalStage[],
|
||||||
priorProfit: number,
|
priorProfit: number,
|
||||||
fundTransactions: { date: string; amount: number }[]
|
fundTransactions: { date: string; amount: number }[],
|
||||||
): { profitTarget: number; consistency: number; minTradingDays: number } {
|
minDayPnL = 0,
|
||||||
|
): EffectiveConfig {
|
||||||
if (targetSameEquity) {
|
if (targetSameEquity) {
|
||||||
// Simple Mode A: effective target = profitTarget - equityProfit, computed in computeDailyTarget.
|
// Simple Mode A: effective target = profitTarget - equityProfit, computed in computeDailyTarget.
|
||||||
// Since computeDailyTarget already compares (amount - accountSize) against profitTarget,
|
// Since computeDailyTarget already compares (amount - accountSize) against profitTarget,
|
||||||
// no adjustment needed here — just return the base values.
|
// no adjustment needed here — just return the base values.
|
||||||
return { profitTarget, consistency, minTradingDays };
|
return { profitTarget, consistency, minTradingDays, minDayPnL, drain: false };
|
||||||
}
|
}
|
||||||
if (withdrawalStages.length > 0) {
|
if (withdrawalStages.length > 0) {
|
||||||
const withdrawalCount = fundTransactions.filter((f) => f.amount < 0).length;
|
const withdrawalCount = fundTransactions.filter((f) => f.amount < 0).length;
|
||||||
if (withdrawalCount === 0) {
|
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 idx = Math.min(withdrawalCount - 1, withdrawalStages.length - 1);
|
||||||
const stage = withdrawalStages[idx];
|
const stage = withdrawalStages[idx];
|
||||||
// Fall back to base minTradingDays when the stage doesn't specify one (0 or missing)
|
if (stage.drain === true) {
|
||||||
return { profitTarget: stage.profit, consistency: stage.consistency, minTradingDays: stage.minTradingDays || minTradingDays };
|
return {
|
||||||
|
profitTarget: 0,
|
||||||
|
consistency: 0,
|
||||||
|
minTradingDays: stage.minTradingDays || minTradingDays,
|
||||||
|
minDayPnL: stage.minDayPnL || 5,
|
||||||
|
drain: true,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return { profitTarget, consistency, minTradingDays };
|
// 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, minDayPnL, drain: false };
|
||||||
|
}
|
||||||
|
return { profitTarget, consistency, minTradingDays, minDayPnL, drain: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @deprecated Use resolveEffectiveConfig instead */
|
/** @deprecated Use resolveEffectiveConfig instead */
|
||||||
export function resolveEffectiveProfitTarget(
|
export function resolveEffectiveProfitTarget(
|
||||||
profitTarget: number,
|
profitTarget: number,
|
||||||
targetSameEquity: boolean,
|
targetSameEquity: boolean,
|
||||||
withdrawalStages: { profit: number; consistency: number; minTradingDays: number }[],
|
withdrawalStages: WithdrawalStage[],
|
||||||
priorProfit: number,
|
priorProfit: number,
|
||||||
fundTransactions: { date: string; amount: number }[]
|
fundTransactions: { date: string; amount: number }[]
|
||||||
): number {
|
): number {
|
||||||
@@ -96,6 +127,22 @@ export function currentTradeDateCT(now: Date = new Date()): string {
|
|||||||
return new Date(date).toISOString().slice(0, 10);
|
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.
|
* 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
|
* 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. */
|
/** Dollar-per-point value for common futures products. */
|
||||||
export const POINT_VALUES: { [symbol: string]: number } = {
|
export const POINT_VALUES: { [symbol: string]: number } = {
|
||||||
NQ: 20, MNQ: 2, ES: 50, MES: 5,
|
NQ: 20, MNQ: 2, ES: 50, MES: 5,
|
||||||
@@ -438,11 +616,30 @@ export function computeDayPlan(
|
|||||||
minTradingDays: number,
|
minTradingDays: number,
|
||||||
equityProfit: number | null | undefined,
|
equityProfit: number | null | undefined,
|
||||||
realizedPnL: number,
|
realizedPnL: number,
|
||||||
splitEnabled: boolean = true
|
splitEnabled: boolean = true,
|
||||||
|
drain: boolean = false
|
||||||
): DayPlan | null {
|
): DayPlan | null {
|
||||||
if (typeof equityProfit !== 'number' || !Number.isFinite(equityProfit)) return null;
|
if (typeof equityProfit !== 'number' || !Number.isFinite(equityProfit)) return null;
|
||||||
|
|
||||||
const priorDays = dailyPnL.filter((d) => d.date !== tradeDate);
|
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 dayStartEquity = equityProfit - realizedPnL;
|
||||||
const target = computeDailyTarget(
|
const target = computeDailyTarget(
|
||||||
profitTarget, consistency, priorDays, minDayPnL, minTradingDays,
|
profitTarget, consistency, priorDays, minDayPnL, minTradingDays,
|
||||||
|
|||||||
Generated
+181
-1
@@ -8,6 +8,7 @@
|
|||||||
"name": "autotrader-next",
|
"name": "autotrader-next",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@microsoft/signalr": "^10.0.11",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
"better-sqlite3": "^12.6.2",
|
"better-sqlite3": "^12.6.2",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
@@ -1025,6 +1026,19 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@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": {
|
"node_modules/@napi-rs/wasm-runtime": {
|
||||||
"version": "0.2.12",
|
"version": "0.2.12",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
|
||||||
@@ -2270,6 +2284,18 @@
|
|||||||
"win32"
|
"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": {
|
"node_modules/acorn": {
|
||||||
"version": "8.16.0",
|
"version": "8.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||||
@@ -3904,12 +3930,30 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/eventemitter3": {
|
||||||
"version": "5.0.4",
|
"version": "5.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||||
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/expand-template": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
|
||||||
@@ -3980,6 +4024,16 @@
|
|||||||
"reusify": "^1.0.4"
|
"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": {
|
"node_modules/file-entry-cache": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
"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"
|
"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": {
|
"node_modules/node-releases": {
|
||||||
"version": "2.0.36",
|
"version": "2.0.36",
|
||||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz",
|
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz",
|
||||||
@@ -6048,6 +6122,18 @@
|
|||||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/pump": {
|
||||||
"version": "3.0.4",
|
"version": "3.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
|
||||||
@@ -6062,12 +6148,17 @@
|
|||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"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": {
|
"node_modules/queue-microtask": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||||
@@ -6266,6 +6357,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/reselect": {
|
||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||||
@@ -6439,6 +6536,12 @@
|
|||||||
"semver": "bin/semver.js"
|
"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": {
|
"node_modules/set-function-length": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
||||||
@@ -7030,6 +7133,27 @@
|
|||||||
"node": ">=8.0"
|
"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": {
|
"node_modules/ts-api-utils": {
|
||||||
"version": "2.4.0",
|
"version": "2.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz",
|
||||||
@@ -7242,6 +7366,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/unrs-resolver": {
|
||||||
"version": "1.11.1",
|
"version": "1.11.1",
|
||||||
"resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz",
|
"resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz",
|
||||||
@@ -7318,6 +7451,16 @@
|
|||||||
"punycode": "^2.1.0"
|
"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": {
|
"node_modules/use-sync-external-store": {
|
||||||
"version": "1.6.0",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
"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"
|
"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": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
@@ -7476,6 +7635,27 @@
|
|||||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@microsoft/signalr": "^10.0.11",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
"better-sqlite3": "^12.6.2",
|
"better-sqlite3": "^12.6.2",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
|
|||||||
@@ -16,17 +16,21 @@ export interface AccountConfig {
|
|||||||
firstDayStop: boolean; // first cycle day carries a $1,000 account stop - books red and ends
|
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
|
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)
|
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 {
|
export interface FirmConfig {
|
||||||
/** Master's firm id (uuid). */
|
/** Master's firm id (uuid). */
|
||||||
id: string;
|
id: string;
|
||||||
firm: 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. */
|
/** Blank when this machine has no credentials for the firm. */
|
||||||
username: string;
|
username: string;
|
||||||
/** True once a Tradovate username and password are stored locally. Firms
|
/** True once the platform's credentials are stored locally — Tradovate
|
||||||
* without them are shown greyed out with an inline credential form. */
|
* username + password, or a TopstepX API key. Firms without them are
|
||||||
|
* shown greyed out with an inline credential form. */
|
||||||
hasCredentials: boolean;
|
hasCredentials: boolean;
|
||||||
bannedSymbols: string[];
|
bannedSymbols: string[];
|
||||||
/** The firm's account types, straight from the catalog. Doubles as the
|
/** The firm's account types, straight from the catalog. Doubles as the
|
||||||
@@ -54,6 +58,8 @@ export interface AccountState {
|
|||||||
personalDll: boolean;
|
personalDll: boolean;
|
||||||
/** Current stage = 1 + number of withdrawals (negative fund transactions). Stage 1 = fresh account. */
|
/** Current stage = 1 + number of withdrawals (negative fund transactions). Stage 1 = fresh account. */
|
||||||
stage: number;
|
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. */
|
/** 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;
|
dailyTarget: { amount: number; path: 'first_day' | 'normal_day' | 'reduced_day' } | null;
|
||||||
/** Dollars the next trade may lose before its obfuscation stop fires.
|
/** Dollars the next trade may lose before its obfuscation stop fires.
|
||||||
|
|||||||
@@ -310,6 +310,17 @@
|
|||||||
"@jridgewell/resolve-uri" "^3.1.0"
|
"@jridgewell/resolve-uri" "^3.1.0"
|
||||||
"@jridgewell/sourcemap-codec" "^1.4.14"
|
"@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":
|
"@next/env@16.1.6":
|
||||||
version "16.1.6"
|
version "16.1.6"
|
||||||
resolved "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz"
|
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"
|
resolved "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz"
|
||||||
integrity sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==
|
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:
|
acorn-jsx@^5.3.2:
|
||||||
version "5.3.2"
|
version "5.3.2"
|
||||||
resolved "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz"
|
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"
|
resolved "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz"
|
||||||
integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==
|
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:
|
eventemitter3@^5.0.1:
|
||||||
version "5.0.4"
|
version "5.0.4"
|
||||||
resolved "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz"
|
resolved "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz"
|
||||||
integrity sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==
|
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:
|
expand-template@^2.0.3:
|
||||||
version "2.0.3"
|
version "2.0.3"
|
||||||
resolved "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz"
|
resolved "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz"
|
||||||
@@ -1612,6 +1640,14 @@ fdir@^6.5.0:
|
|||||||
resolved "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz"
|
resolved "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz"
|
||||||
integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==
|
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:
|
file-entry-cache@^8.0.0:
|
||||||
version "8.0.0"
|
version "8.0.0"
|
||||||
resolved "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz"
|
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"
|
object.entries "^1.1.9"
|
||||||
semver "^6.3.1"
|
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:
|
node-releases@^2.0.27:
|
||||||
version "2.0.36"
|
version "2.0.36"
|
||||||
resolved "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz"
|
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"
|
resolved "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz"
|
||||||
integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==
|
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:
|
pump@^3.0.0:
|
||||||
version "3.0.4"
|
version "3.0.4"
|
||||||
resolved "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz"
|
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"
|
end-of-stream "^1.1.0"
|
||||||
once "^1.3.1"
|
once "^1.3.1"
|
||||||
|
|
||||||
punycode@^2.1.0:
|
punycode@^2.1.0, punycode@^2.1.1, punycode@^2.3.1:
|
||||||
version "2.3.1"
|
version "2.3.1"
|
||||||
resolved "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz"
|
resolved "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz"
|
||||||
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
|
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:
|
queue-microtask@^1.2.2:
|
||||||
version "1.2.3"
|
version "1.2.3"
|
||||||
resolved "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz"
|
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"
|
gopd "^1.2.0"
|
||||||
set-function-name "^2.0.2"
|
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:
|
reselect@^5.1.0, reselect@5.1.1:
|
||||||
version "5.1.1"
|
version "5.1.1"
|
||||||
resolved "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz"
|
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"
|
resolved "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz"
|
||||||
integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==
|
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:
|
set-function-length@^1.2.2:
|
||||||
version "1.2.2"
|
version "1.2.2"
|
||||||
resolved "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz"
|
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:
|
dependencies:
|
||||||
is-number "^7.0.0"
|
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:
|
ts-api-utils@^2.4.0:
|
||||||
version "2.4.0"
|
version "2.4.0"
|
||||||
resolved "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz"
|
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"
|
resolved "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz"
|
||||||
integrity sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==
|
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:
|
unrs-resolver@^1.6.2:
|
||||||
version "1.11.1"
|
version "1.11.1"
|
||||||
resolved "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz"
|
resolved "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz"
|
||||||
@@ -3285,6 +3370,14 @@ uri-js@^4.2.2:
|
|||||||
dependencies:
|
dependencies:
|
||||||
punycode "^2.1.0"
|
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:
|
use-sync-external-store@^1.2.2, use-sync-external-store@^1.4.0:
|
||||||
version "1.6.0"
|
version "1.6.0"
|
||||||
resolved "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz"
|
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-time "^3.0.0"
|
||||||
d3-timer "^3.0.1"
|
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:
|
which-boxed-primitive@^1.1.0, which-boxed-primitive@^1.1.1:
|
||||||
version "1.1.1"
|
version "1.1.1"
|
||||||
resolved "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz"
|
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"
|
resolved "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz"
|
||||||
integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==
|
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:
|
yallist@^3.0.2:
|
||||||
version "3.1.1"
|
version "3.1.1"
|
||||||
resolved "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz"
|
resolved "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz"
|
||||||
|
|||||||
Reference in New Issue
Block a user