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:
Brandon Li
2026-09-12 16:25:52 -05:00
co-authored by Claude Fable 5
parent 1bca441247
commit 50a5eb4713
21 changed files with 1854 additions and 142 deletions
+14 -7
View File
@@ -12,7 +12,7 @@
*/
import type { CatalogAccountType } from './master-protocol';
import type { TradovateClient } from './tradovate-class';
import type { PlatformClient } from './platform-client';
import {
getAssignment,
setAssignment,
@@ -69,10 +69,14 @@ export function matchAccountType(
const byPrefix = types.filter((t) => accountName.startsWith(t.prefix));
if (byPrefix.length === 0) return { type: null, ambiguous: false };
// No size yet — stay Unassigned and try again next sync.
if (accountSize === null) return { type: null, ambiguous: false };
const candidates = byPrefix.filter((t) => Math.abs(t.accountSize - accountSize) < 0.01);
// Exact size match first. An account type with size 0 means the account's
// balance starts at $0 (TopstepX Express style) — there is no funding
// transaction to infer a size from, so it matches on prefix alone, but
// only once every sized candidate has had its chance.
const sized = accountSize !== null
? byPrefix.filter((t) => t.accountSize > 0 && Math.abs(t.accountSize - accountSize) < 0.01)
: [];
const candidates = sized.length > 0 ? sized : byPrefix.filter((t) => t.accountSize === 0);
if (candidates.length === 0) return { type: null, ambiguous: false };
if (candidates.length === 1) return { type: candidates[0], ambiguous: false };
@@ -117,7 +121,7 @@ export function getAccountConfig(accountId: number): ResolvedAssignment {
/** The size this account resolved to, cached once found so a later reset
* deposit can't move it. */
export function getInferredSize(accountId: number, client: TradovateClient): number | null {
export function getInferredSize(accountId: number, client: PlatformClient): number | null {
const cached = loadAccountMeta(accountId, INFERRED_SIZE_KEY);
if (cached !== null) {
const value = Number(cached);
@@ -130,7 +134,7 @@ export function getInferredSize(accountId: number, client: TradovateClient): num
* Called after each Tradovate sync and after the catalog changes. */
export function resolveFirmAssignments(
firmId: string,
client: TradovateClient,
client: PlatformClient,
): { assigned: number; unassigned: number; manual: number } {
const firm = getCachedFirm(firmId);
const types = firm?.accountTypes ?? [];
@@ -184,6 +188,9 @@ export function resolveFirmAssignments(
}
if (matched.type) {
// A $0-start type matched on prefix alone — whatever number rode
// along (usually the live balance) is not an inferred size.
if (matched.type.accountSize === 0) matchedSize = 0;
if (matchedSize !== null && cached === null) {
saveAccountMeta(account.id, INFERRED_SIZE_KEY, String(matchedSize));
}
+69 -33
View File
@@ -72,6 +72,22 @@ function obfuscationStopDollars(
return stop;
}
/** The instrument a leg actually trades: drain legs go on MNQ, hardcoded
* (like the extra-day path) — tiny targets belong on the micro regardless of
* the batch symbol. */
async function legInstrument(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
client: any,
drain: boolean,
batchContract: { name: string; tickSize: number },
batchPointValue: number,
): Promise<{ contract: { name: string; tickSize: number }; pointValue: number }> {
if (!drain) return { contract: batchContract, pointValue: batchPointValue };
const mnq = await client.findFrontMonthContract('MNQ');
if (!mnq) throw new Error('MNQ contract not found for drain trade');
return { contract: mnq, pointValue: POINT_VALUES['MNQ'] };
}
// ── per-account trade evaluation (shared by runTrade / copyTrade / hasRemaining…) ──
interface AccountEvaluation {
@@ -79,6 +95,8 @@ interface AccountEvaluation {
reason: string;
plan: DayPlan | null;
isExtraDay: boolean;
/** Current stage is a drain stage — the leg trades MNQ regardless of the batch symbol. */
drain: boolean;
cash: { amount: number; realizedPnL: number };
dailyPnL: { date: string; pnl: number }[];
daysTraded: number;
@@ -110,7 +128,7 @@ function evaluateAccountForTrade(client: any, acc: { id: number; name: string; a
const dailyPnL = combined.dailyPnL;
const daysTraded = combined.daysTraded;
const base = { plan: null, isExtraDay: false, cash, dailyPnL, daysTraded };
const base = { plan: null, isExtraDay: false, drain: false, cash, dailyPnL, daysTraded };
if (isAccountDead(cash.amount, autoLiqThreshold)) return { ...base, eligible: false, reason: 'dead' };
if (!acc.active) return { ...base, eligible: false, reason: 'inactive (DLL)' };
if (client.positions[acc.id]) return { ...base, eligible: false, reason: 'already in trade' };
@@ -132,23 +150,25 @@ function evaluateAccountForTrade(client: any, acc: { id: number; name: string; a
const priorProfit = client.priorProfit?.[acc.id] ?? 0;
const allFundTxns = client.fundTransactions?.[acc.id] ?? [];
const effective = resolveEffectiveConfig(
cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity, cfg.withdrawalStages, priorProfit, allFundTxns
cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity, cfg.withdrawalStages, priorProfit, allFundTxns,
cfg.minDayPnL
);
const equityProfit = cash.amount - cfg.accountSize;
const plan = computeDayPlan(
acc.id, combined.tradeDate, effective.profitTarget, effective.consistency,
dailyPnL, cfg.minDayPnL, effective.minTradingDays, equityProfit, combined.realizedPnL,
cfg.splitEnabled
dailyPnL, effective.minDayPnL, effective.minTradingDays, equityProfit, combined.realizedPnL,
cfg.splitEnabled, effective.drain
);
if (!plan) return { ...base, eligible: false, reason: 'invalid balance' };
const totalProfit = dailyPnL.reduce((sum, d) => sum + d.pnl, 0);
const isExtraDay = cfg.minDayPnL <= 0
const isExtraDay = !effective.drain
&& effective.minDayPnL <= 0
&& effective.minTradingDays > daysTraded
&& totalProfit >= effective.profitTarget;
const evaluated = { plan, isExtraDay, cash, dailyPnL, daysTraded };
const evaluated = { plan, isExtraDay, drain: effective.drain, cash, dailyPnL, daysTraded };
if (isExtraDay || !plan.splitAllowed) {
// Legacy one-and-done gate: any realized PnL today means this account is
@@ -157,7 +177,10 @@ function evaluateAccountForTrade(client: any, acc: { id: number; name: string; a
return { ...evaluated, eligible: false, reason: `already traded today (realizedPnL=${combined.realizedPnL})` };
}
if (!isExtraDay && plan.remainingToday <= 0) {
return { ...evaluated, eligible: false, reason: `target=$${plan.dayTarget} (challenge complete)` };
const reason = effective.drain && plan.dayTarget === 0
? 'drain days booked — waiting for withdrawal'
: `target=$${plan.dayTarget} (challenge complete)`;
return { ...evaluated, eligible: false, reason };
}
} else {
// Split-leg mode: keep trading until the day's remaining target is used up.
@@ -315,6 +338,7 @@ export async function runTrade(action: 'Buy' | 'Sell' | 'Auto', symbol: string)
daysTraded: number;
plan: DayPlan;
isExtraDay: boolean;
drain: boolean;
prefix: string;
};
@@ -341,7 +365,7 @@ export async function runTrade(action: 'Buy' | 'Sell' | 'Auto', symbol: string)
allEligible.push({
firmName: firm.name, client, acc, contract,
cash: ev.cash, dailyPnL: ev.dailyPnL, daysTraded: ev.daysTraded,
plan: ev.plan, isExtraDay: ev.isExtraDay,
plan: ev.plan, isExtraDay: ev.isExtraDay, drain: ev.drain,
prefix: getAccountConfig(acc.id).accountType?.prefix ?? '',
});
}
@@ -419,9 +443,13 @@ export async function runTrade(action: 'Buy' | 'Sell' | 'Auto', symbol: string)
: item.plan.remainingToday;
recordLeg(acc.id, tradeDate, legAmount);
const inst = await legInstrument(client, item.drain, contract, pointValue);
const legContract = inst.contract;
const pv = inst.pointValue;
const rawContracts = Math.max(1, Math.ceil(legAmount / 1000));
const contracts = cfg.maxPositionSize > 0 ? Math.min(rawContracts, cfg.maxPositionSize) : rawContracts;
const fill = await client.sendOrder(acc.id, contract.name, contracts, resolvedAction, 'Market');
const fill = await client.sendOrder(acc.id, legContract.name, contracts, resolvedAction, 'Market');
// Wait briefly for the cash balance WebSocket update to reflect entry commission
await new Promise(r => setTimeout(r, 1000));
@@ -433,35 +461,35 @@ export async function runTrade(action: 'Buy' | 'Sell' | 'Auto', symbol: string)
const totalCommission = entryCommission * 2; // entry + exit round-trip
const grossTarget = legAmount + totalCommission;
const targetPoints = grossTarget / (pointValue * contracts);
const ticks = Math.ceil(targetPoints / contract.tickSize);
const targetPoints = grossTarget / (pv * contracts);
const ticks = Math.ceil(targetPoints / legContract.tickSize);
const exitPrice = resolvedAction === 'Buy'
? fill.price + (ticks * contract.tickSize)
: fill.price - (ticks * contract.tickSize);
? fill.price + (ticks * legContract.tickSize)
: fill.price - (ticks * legContract.tickSize);
const exitAction: 'Buy' | 'Sell' = resolvedAction === 'Buy' ? 'Sell' : 'Buy';
const stopDollars = obfuscationStopDollars(cfg, item.dailyPnL, item.plan, tradeDate, legAmount);
let exitOrder: { orderId?: number };
let stopPrice: number | null = null;
if (Number.isFinite(stopDollars) && stopDollars > 0) {
const stopTicks = Math.max(1, Math.ceil(stopDollars / (pointValue * contracts) / contract.tickSize));
const stopTicks = Math.max(1, Math.ceil(stopDollars / (pv * contracts) / legContract.tickSize));
stopPrice = resolvedAction === 'Buy'
? fill.price - (stopTicks * contract.tickSize)
: fill.price + (stopTicks * contract.tickSize);
? fill.price - (stopTicks * legContract.tickSize)
: fill.price + (stopTicks * legContract.tickSize);
try {
exitOrder = await client.placeOcoExit(acc.id, contract.name, contracts, exitAction, exitPrice, stopPrice);
exitOrder = await client.placeOcoExit(acc.id, legContract.name, contracts, exitAction, exitPrice, stopPrice);
} catch (err) {
// The position is already open — never leave it exit-less. Fall
// back to the classic lone limit and shout about it.
console.error(`[auto-trade] ${acc.name}: placeoco REJECTED (${err instanceof Error ? err.message : err}) — falling back to lone limit exit, NO STOP`);
stopPrice = null;
exitOrder = await client.placeOrderNoWait(acc.id, contract.name, contracts, exitAction, 'Limit', exitPrice);
exitOrder = await client.placeOrderNoWait(acc.id, legContract.name, contracts, exitAction, 'Limit', exitPrice);
}
} else {
exitOrder = await client.placeOrderNoWait(acc.id, contract.name, contracts, exitAction, 'Limit', exitPrice);
exitOrder = await client.placeOrderNoWait(acc.id, legContract.name, contracts, exitAction, 'Limit', exitPrice);
}
console.log(`[auto-trade] ${acc.name} (${item.firmName}) ${resolvedAction} ${contracts}x${resolvedSymbol} @ ${fill.price} | leg $${legAmount} of day $${item.plan.dayTarget} (remaining $${item.plan.remainingToday}) [${item.plan.path}] (+$${totalCommission.toFixed(2)} comm) | exit @ ${exitPrice}${stopPrice !== null ? ` / stop @ ${stopPrice}` : ''} (orderId=${exitOrder.orderId})`);
console.log(`[auto-trade] ${acc.name} (${item.firmName}) ${resolvedAction} ${contracts}x${item.drain ? legContract.name : resolvedSymbol} @ ${fill.price} | leg $${legAmount} of day $${item.plan.dayTarget} (remaining $${item.plan.remainingToday}) [${item.plan.path}${item.drain ? ' drain' : ''}] (+$${totalCommission.toFixed(2)} comm) | exit @ ${exitPrice}${stopPrice !== null ? ` / stop @ ${stopPrice}` : ''} (orderId=${exitOrder.orderId})`);
return {
account: acc.name,
@@ -516,7 +544,9 @@ export async function copyTrade() {
// Find all accounts with open positions to determine direction + symbol
let resolvedAction: 'Buy' | 'Sell' | null = null;
let positionContractId: number | null = null;
// number on Tradovate, string on TopstepX — only ever compared against
// contracts from the same client, so the union stays coherent.
let positionContractId: number | string | null = null;
let positionedCount = 0;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let positionedClient: any = null;
@@ -592,6 +622,7 @@ export async function copyTrade() {
cash: { amount: number; realizedPnL: number };
dailyPnL: { date: string; pnl: number }[];
plan: DayPlan;
drain: boolean;
prefix: string;
};
const eligible: CopyItem[] = [];
@@ -622,6 +653,7 @@ export async function copyTrade() {
eligible.push({
firmName: firm.name, client, acc, contract, cash: ev.cash, dailyPnL: ev.dailyPnL, plan: ev.plan,
drain: ev.drain,
prefix: getAccountConfig(acc.id).accountType?.prefix ?? '',
});
}
@@ -654,9 +686,13 @@ export async function copyTrade() {
: item.plan.remainingToday;
recordLeg(acc.id, tradeDate, legAmount);
const inst = await legInstrument(client, item.drain, contract, pointValue);
const legContract = inst.contract;
const pv = inst.pointValue;
const rawContracts = Math.max(1, Math.ceil(legAmount / 1000));
const contracts = cfg.maxPositionSize > 0 ? Math.min(rawContracts, cfg.maxPositionSize) : rawContracts;
const fill = await client.sendOrder(acc.id, contract.name, contracts, resolvedAction!, 'Market');
const fill = await client.sendOrder(acc.id, legContract.name, contracts, resolvedAction!, 'Market');
await new Promise(r => setTimeout(r, 1000));
const updatedCash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 };
@@ -664,33 +700,33 @@ export async function copyTrade() {
const totalCommission = entryCommission * 2;
const grossTarget = legAmount + totalCommission;
const targetPoints = grossTarget / (pointValue * contracts);
const ticks = Math.ceil(targetPoints / contract.tickSize);
const targetPoints = grossTarget / (pv * contracts);
const ticks = Math.ceil(targetPoints / legContract.tickSize);
const exitPrice = resolvedAction === 'Buy'
? fill.price + (ticks * contract.tickSize)
: fill.price - (ticks * contract.tickSize);
? fill.price + (ticks * legContract.tickSize)
: fill.price - (ticks * legContract.tickSize);
const exitAction: 'Buy' | 'Sell' = resolvedAction === 'Buy' ? 'Sell' : 'Buy';
const stopDollars = obfuscationStopDollars(cfg, item.dailyPnL, item.plan, tradeDate, legAmount);
let exitOrder: { orderId?: number };
let stopPrice: number | null = null;
if (Number.isFinite(stopDollars) && stopDollars > 0) {
const stopTicks = Math.max(1, Math.ceil(stopDollars / (pointValue * contracts) / contract.tickSize));
const stopTicks = Math.max(1, Math.ceil(stopDollars / (pv * contracts) / legContract.tickSize));
stopPrice = resolvedAction === 'Buy'
? fill.price - (stopTicks * contract.tickSize)
: fill.price + (stopTicks * contract.tickSize);
? fill.price - (stopTicks * legContract.tickSize)
: fill.price + (stopTicks * legContract.tickSize);
try {
exitOrder = await client.placeOcoExit(acc.id, contract.name, contracts, exitAction, exitPrice, stopPrice);
exitOrder = await client.placeOcoExit(acc.id, legContract.name, contracts, exitAction, exitPrice, stopPrice);
} catch (err) {
console.error(`[copy-trade] ${acc.name}: placeoco REJECTED (${err instanceof Error ? err.message : err}) — falling back to lone limit exit, NO STOP`);
stopPrice = null;
exitOrder = await client.placeOrderNoWait(acc.id, contract.name, contracts, exitAction, 'Limit', exitPrice);
exitOrder = await client.placeOrderNoWait(acc.id, legContract.name, contracts, exitAction, 'Limit', exitPrice);
}
} else {
exitOrder = await client.placeOrderNoWait(acc.id, contract.name, contracts, exitAction, 'Limit', exitPrice);
exitOrder = await client.placeOrderNoWait(acc.id, legContract.name, contracts, exitAction, 'Limit', exitPrice);
}
console.log(`[copy-trade] ${acc.name} (${item.firmName}) ${resolvedAction} ${contracts}x${resolvedSymbol} @ ${fill.price} | leg $${legAmount} of day $${item.plan.dayTarget} (remaining $${item.plan.remainingToday}) [${item.plan.path}] | exit @ ${exitPrice}${stopPrice !== null ? ` / stop @ ${stopPrice}` : ''} (orderId=${exitOrder.orderId})`);
console.log(`[copy-trade] ${acc.name} (${item.firmName}) ${resolvedAction} ${contracts}x${item.drain ? legContract.name : resolvedSymbol} @ ${fill.price} | leg $${legAmount} of day $${item.plan.dayTarget} (remaining $${item.plan.remainingToday}) [${item.plan.path}${item.drain ? ' drain' : ''}] | exit @ ${exitPrice}${stopPrice !== null ? ` / stop @ ${stopPrice}` : ''} (orderId=${exitOrder.orderId})`);
return {
account: acc.name,
BIN
View File
Binary file not shown.
+20 -8
View File
@@ -49,6 +49,15 @@ db.exec(`
// Tradovate credentials, and a silent DROP would be unrecoverable if this
// change had to be rolled back. Safe to drop by hand once it has stuck.
// Migration: TopstepX firms authenticate with an API key instead of a
// password. Kept in the same table so re-keying and reconciliation treat both
// platforms alike; a Tradovate row simply leaves it blank.
try {
db.exec("ALTER TABLE firm_credentials ADD COLUMN api_key TEXT NOT NULL DEFAULT ''");
} catch {
// Column already exists
}
// ── Firm credentials ─────────────────────────────────────────────────────────
export interface FirmCredentialRow {
@@ -56,13 +65,15 @@ export interface FirmCredentialRow {
firm_name: string;
username: string;
password: string;
api_key: string;
updated_at: string;
}
export function getCredentials(firmId: string): { username: string; password: string } | undefined {
const row = db.prepare('SELECT username, password FROM firm_credentials WHERE firm_id = ?').get(firmId) as
{ username: string; password: string } | undefined;
return row;
export function getCredentials(firmId: string): { username: string; password: string; apiKey: string } | undefined {
const row = db.prepare('SELECT username, password, api_key FROM firm_credentials WHERE firm_id = ?').get(firmId) as
{ username: string; password: string; api_key: string } | undefined;
if (!row) return undefined;
return { username: row.username, password: row.password, apiKey: row.api_key };
}
export function listCredentials(): FirmCredentialRow[] {
@@ -72,16 +83,17 @@ export function listCredentials(): FirmCredentialRow[] {
/** firm_name is stored alongside the id so credentials can be re-keyed by name
* if the master's database is ever rebuilt and the firms come back with new
* ids. See rekeyCredentialsByName in lib/firm-catalog.ts. */
export function setCredentials(firmId: string, firmName: string, username: string, password: string): void {
export function setCredentials(firmId: string, firmName: string, username: string, password: string, apiKey = ''): void {
db.prepare(`
INSERT INTO firm_credentials (firm_id, firm_name, username, password, updated_at)
VALUES (?, ?, ?, ?, ?)
INSERT INTO firm_credentials (firm_id, firm_name, username, password, api_key, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(firm_id) DO UPDATE SET
firm_name = excluded.firm_name,
username = excluded.username,
password = excluded.password,
api_key = excluded.api_key,
updated_at = excluded.updated_at
`).run(firmId, firmName, username, password, new Date().toISOString());
`).run(firmId, firmName, username, password, apiKey, new Date().toISOString());
}
export function clearCredentials(firmId: string): boolean {
+12 -9
View File
@@ -99,17 +99,20 @@ export function getCatalogMeta(): {
};
}
/** Firms this machine can actually trade: in the catalog AND holding
* credentials. Everything that starts a Tradovate client goes through here. */
export function getTradableFirms(): (CatalogFirm & { username: string; password: string })[] {
const result: (CatalogFirm & { username: string; password: string })[] = [];
/** Firms this machine can actually trade: in the catalog AND holding the
* credentials their platform needs — a Tradovate username + password, or a
* TopstepX API key. Everything that starts a platform client goes through
* here. */
export function getTradableFirms(): (CatalogFirm & { username: string; password: string; apiKey: string })[] {
const result: (CatalogFirm & { username: string; password: string; apiKey: string })[] = [];
for (const firm of getCachedCatalog()) {
// Only Tradovate has a client. A TopstepX firm in the catalog is
// config-only for now; handing it to the Tradovate login would just
// burn attempts against the wrong platform.
if (firm.platform !== 'tradovate') continue;
const creds = getCredentials(firm.id);
if (creds) result.push({ ...firm, ...creds });
if (!creds) continue;
if (firm.platform === 'topstepx') {
if (creds.apiKey) result.push({ ...firm, ...creds });
} else {
if (creds.username && creds.password) result.push({ ...firm, ...creds });
}
}
return result;
}
+5 -1
View File
@@ -128,7 +128,11 @@ export interface CatalogAccountType {
* firms that follow CME clearing report them (e.g. Apex: Sun/Mon/Tue around
* a Monday holiday is ONE trading day, recorded on the Tuesday). */
combineCmeHolidays: boolean;
withdrawalStages: { profit: number; consistency: number; minTradingDays: number }[];
/** Per-stage config after the Nth withdrawal; the last stage repeats.
* drain=true marks a spam-withdraw stage: no profit target — book
* minTradingDays qualifying days of ~minDayPnL (default $5, randomized
* $5–$15, traded as tiny MNQ legs), then wait for the next withdrawal. */
withdrawalStages: { profit: number; consistency: number; minTradingDays: number; drain?: boolean; minDayPnL?: number }[];
/** ISO. Ties between two matching account types are broken by this. */
updatedAt: string;
}
+84
View File
@@ -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
View File
@@ -91,6 +91,16 @@ function collectFirmStats() {
const combined = cfg ? combineCmeSessions(
cfg.combineCmeHolidays, client.dailyPnL[acc.id] ?? [], currentTradeDateCT(), cash.realizedPnL,
) : null;
// Stage-resolved config, shared by the payout state and the
// day-outcome check below — a drain stage swaps in its own
// min-day and drops the profit target.
const fundTxns = client.fundTransactions?.[acc.id] ?? [];
const effective = cfg ? resolveEffectiveConfig(
cfg.profitTarget, cfg.consistency, cfg.minTradingDays,
cfg.targetSameEquity, cfg.withdrawalStages,
client.priorProfit?.[acc.id] ?? 0, fundTxns, cfg.minDayPnL,
) : null;
if (cfg && combined && acc.active && cash.amount - cfg.accountSize >= 0) {
const hist = (slice.daysHist ??= {});
hist[combined.daysTraded] = (hist[combined.daysTraded] ?? 0) + 1;
@@ -100,12 +110,12 @@ function collectFirmStats() {
// included, since the master sims their real required profit.
// client.dailyPnL is already the active-cycle slice, so a
// withdrawal resets days/maxDay/sum here automatically.
if (cfg && combined) {
if (cfg && combined && effective) {
const r2 = (n: number) => Math.round(n * 100) / 100;
const equityProfit = r2(cash.amount - cfg.accountSize);
const qualifying = cfg.minDayPnL === 0
const qualifying = effective.minDayPnL === 0
? combined.dailyPnL
: combined.dailyPnL.filter((d) => d.pnl >= cfg.minDayPnL);
: combined.dailyPnL.filter((d) => d.pnl >= effective.minDayPnL);
const sum = r2(combined.dailyPnL.reduce((s, d) => s + d.pnl, 0));
// End-of-day equity-profit high-water mark for this cycle,
// floored at the $0 start: walk the P&L from the cycle base.
@@ -115,13 +125,6 @@ function collectFirmStats() {
run = r2(run + d.pnl);
if (run > highWater) highWater = run;
}
// Effective target for this cycle: withdrawal stages applied.
const fundTxns = client.fundTransactions?.[acc.id] ?? [];
const effective = resolveEffectiveConfig(
cfg.profitTarget, cfg.consistency, cfg.minTradingDays,
cfg.targetSameEquity, cfg.withdrawalStages,
client.priorProfit?.[acc.id] ?? 0, fundTxns,
);
const traded = combined.dailyPnL.filter((d) => d.pnl !== 0);
(slice.payoutStates ??= []).push({
days: qualifying.length,
@@ -156,16 +159,17 @@ function collectFirmStats() {
continue;
}
// Check if target was hit today
if (cfg && combined) {
// Check if target was hit today — on the stage-resolved config, so
// a later stage's (or drain's) day counts the way it was traded.
if (cfg && combined && effective) {
const equityProfit = cash.amount - cfg.accountSize;
const plan = computeDayPlan(
acc.id, combined.tradeDate, cfg.profitTarget, cfg.consistency,
combined.dailyPnL, cfg.minDayPnL, cfg.minTradingDays, equityProfit, combined.realizedPnL,
cfg.splitEnabled
acc.id, combined.tradeDate, effective.profitTarget, effective.consistency,
combined.dailyPnL, effective.minDayPnL, effective.minTradingDays, equityProfit, combined.realizedPnL,
cfg.splitEnabled, effective.drain
);
const targetHit = !plan ? false :
((plan.dayTarget === 0 && Math.abs(combined.realizedPnL) > 0 && combined.daysTraded <= cfg.minTradingDays) ||
((plan.dayTarget === 0 && Math.abs(combined.realizedPnL) > 0 && combined.daysTraded <= effective.minTradingDays) ||
(combined.realizedPnL > 0 && plan.remainingToday <= LEG_COMPLETION_EPSILON));
if (targetHit || combined.realizedPnL !== 0) {
+933
View File
@@ -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;
}
}
+208 -11
View File
@@ -1,3 +1,23 @@
export interface WithdrawalStage {
profit: number;
consistency: number;
minTradingDays: number;
/** Spam-withdraw stage: no profit target — book minTradingDays qualifying
* days of ~minDayPnL (tiny MNQ trades), then wait for the withdrawal. */
drain?: boolean;
/** Drain stages only: the per-day booked amount (default $5; randomized
* $5–$15 by drainDayAmount). */
minDayPnL?: number;
}
export interface EffectiveConfig {
profitTarget: number;
consistency: number;
minTradingDays: number;
minDayPnL: number;
drain: boolean;
}
/**
* Resolve the effective profit target and consistency for an account based on its withdrawal strategy.
*
@@ -6,42 +26,53 @@
* effectiveProfitTarget = profitTarget − remainingProfit
*
* Mode B (withdrawalStages non-empty): Stage 1 (no withdrawals yet) uses base profitTarget/consistency.
* After the Nth withdrawal, use withdrawalStages[N-1]; last stage repeats.
* Fallback: returns base profitTarget and consistency unchanged.
* After the Nth withdrawal, use withdrawalStages[N-1]; last stage repeats. A drain stage carries no
* profit target — it resolves to target 0 with drain=true and its own min-day amount.
* Fallback: returns base values unchanged.
*/
export function resolveEffectiveConfig(
profitTarget: number,
consistency: number,
minTradingDays: number,
targetSameEquity: boolean,
withdrawalStages: { profit: number; consistency: number; minTradingDays: number }[],
withdrawalStages: WithdrawalStage[],
priorProfit: number,
fundTransactions: { date: string; amount: number }[]
): { profitTarget: number; consistency: number; minTradingDays: number } {
fundTransactions: { date: string; amount: number }[],
minDayPnL = 0,
): EffectiveConfig {
if (targetSameEquity) {
// Simple Mode A: effective target = profitTarget - equityProfit, computed in computeDailyTarget.
// Since computeDailyTarget already compares (amount - accountSize) against profitTarget,
// no adjustment needed here — just return the base values.
return { profitTarget, consistency, minTradingDays };
return { profitTarget, consistency, minTradingDays, minDayPnL, drain: false };
}
if (withdrawalStages.length > 0) {
const withdrawalCount = fundTransactions.filter((f) => f.amount < 0).length;
if (withdrawalCount === 0) {
return { profitTarget, consistency, minTradingDays }; // Stage 1 = base values
return { profitTarget, consistency, minTradingDays, minDayPnL, drain: false }; // Stage 1 = base values
}
const idx = Math.min(withdrawalCount - 1, withdrawalStages.length - 1);
const stage = withdrawalStages[idx];
if (stage.drain === true) {
return {
profitTarget: 0,
consistency: 0,
minTradingDays: stage.minTradingDays || minTradingDays,
minDayPnL: stage.minDayPnL || 5,
drain: true,
};
}
// Fall back to base minTradingDays when the stage doesn't specify one (0 or missing)
return { profitTarget: stage.profit, consistency: stage.consistency, minTradingDays: stage.minTradingDays || minTradingDays };
return { profitTarget: stage.profit, consistency: stage.consistency, minTradingDays: stage.minTradingDays || minTradingDays, minDayPnL, drain: false };
}
return { profitTarget, consistency, minTradingDays };
return { profitTarget, consistency, minTradingDays, minDayPnL, drain: false };
}
/** @deprecated Use resolveEffectiveConfig instead */
export function resolveEffectiveProfitTarget(
profitTarget: number,
targetSameEquity: boolean,
withdrawalStages: { profit: number; consistency: number; minTradingDays: number }[],
withdrawalStages: WithdrawalStage[],
priorProfit: number,
fundTransactions: { date: string; amount: number }[]
): number {
@@ -96,6 +127,22 @@ export function currentTradeDateCT(now: Date = new Date()): string {
return new Date(date).toISOString().slice(0, 10);
}
/**
* Drain-day target: the stage's min-day amount plus a deterministic bump of
* $0/$5/$10 — so a $5 min day trades as $5, $10 or $15 depending on the
* (account, trade date) pair. Seeded like dailyBump so every consumer of the
* plan computes the same number.
*/
export function drainDayAmount(accountId: number, tradeDate: string, minDay: number): number {
const key = `${accountId}|drain|${tradeDate}`;
let hash = 0x811c9dc5;
for (let i = 0; i < key.length; i++) {
hash ^= key.charCodeAt(i);
hash = Math.imul(hash, 0x01000193) >>> 0;
}
return minDay + (hash % 3) * 5;
}
/**
* Deterministic fraction in [0, 1) for a (prefix group, trade date, leg index) triple.
* Like accounts (same account-type prefix) carving the same leg of the same day draw the
@@ -277,6 +324,137 @@ export function combineCmeSessions(
};
}
// ── perfect-path projection ──────────────────────────────────────────────────
export interface ProjectedDay {
date: string;
/** The day's booked P&L on the perfect path (0 on withdrawal/payout days). */
pnl: number;
/** Stage the account is on while trading this day. */
stage: number;
drain: boolean;
/** A simulated withdrawal (negative) lands on this day — cycle resets after it. */
withdrawal?: number;
/** Non-staged account completed here; the projection stops. */
payoutReady?: boolean;
}
/** Next weekday after d (YYYY-MM-DD). Trade dates never fall on Sat/Sun. */
function nextTradingDate(d: string): string {
const dt = new Date(`${d}T00:00:00Z`);
do { dt.setUTCDate(dt.getUTCDate() + 1); } while (dt.getUTCDay() === 0 || dt.getUTCDay() === 6);
return dt.toISOString().slice(0, 10);
}
/**
* Simulate the account's future assuming every day goes perfectly: each
* trading day books exactly its planned target (deterministic bumps included,
* so the numbers match what the trader would actually aim for), stages
* complete, a 50% withdrawal fires the day a cycle finishes, and drain cycles
* repeat. Built on the same computeDayPlan/resolveEffectiveConfig the live
* trade path uses — the projection can't drift from reality's rules.
*
* An account without withdrawal stages stops at its first completion, marked
* payoutReady. Projection starts on the next trading day (today's cell shows
* live data); today's remaining target is booked silently first.
*/
export function projectPerfectPath(opts: {
accountId: number;
cfg: {
profitTarget: number; consistency: number; minDayPnL: number;
minTradingDays: number; accountSize: number; targetSameEquity: boolean;
withdrawalStages: WithdrawalStage[]; splitEnabled: boolean;
};
/** Current-cycle daily P&L (account.dailyPnL). */
cycleDailyPnL: { date: string; pnl: number }[];
fundTransactions: { date: string; amount: number }[];
balance: number;
realizedPnL: number;
/** Trading days to look ahead. */
horizonDays?: number;
maxWithdrawals?: number;
}): ProjectedDay[] {
const { accountId, cfg } = opts;
const horizon = opts.horizonDays ?? 40;
const maxWithdrawals = opts.maxWithdrawals ?? 6;
const today = currentTradeDateCT();
let sim = opts.cycleDailyPnL.filter((d) => d.date !== today);
if (opts.realizedPnL !== 0) sim = [...sim, { date: today, pnl: opts.realizedPnL }];
else sim = [...sim];
const funds = [...opts.fundTransactions];
let balance = opts.balance;
let withdrawals = 0;
const out: ProjectedDay[] = [];
const effectiveNow = () => resolveEffectiveConfig(
cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity,
cfg.withdrawalStages, 0, funds, cfg.minDayPnL,
);
const qualifyingDays = (minDay: number, excludeDate: string) => (
minDay > 0
? sim.filter((d) => d.date !== excludeDate && d.pnl >= minDay).length
: sim.filter((d) => d.date !== excludeDate && d.pnl !== 0).length
);
// Book the rest of today silently — the perfect path finishes today too.
// Skipped when "today" is a weekend date (Saturday, or Friday evening
// after the roll): there is no session to finish.
const todayDow = new Date(`${today}T00:00:00Z`).getUTCDay();
if (todayDow !== 0 && todayDow !== 6) {
const eff = effectiveNow();
const plan = computeDayPlan(
accountId, today, eff.profitTarget, eff.consistency, sim, eff.minDayPnL,
eff.minTradingDays, balance - cfg.accountSize, opts.realizedPnL, cfg.splitEnabled, eff.drain,
);
if (plan && plan.dayTarget > 0 && plan.remainingToday > 0) {
const idx = sim.findIndex((d) => d.date === today);
const pnl = round2(opts.realizedPnL + plan.remainingToday);
if (idx >= 0) sim[idx] = { date: today, pnl };
else sim.push({ date: today, pnl });
balance = round2(balance + plan.remainingToday);
}
}
let date = today;
for (let step = 0; step < horizon; step++) {
date = nextTradingDate(date);
const eff = effectiveNow();
const stage = 1 + funds.filter((f) => f.amount < 0).length;
const plan = computeDayPlan(
accountId, date, eff.profitTarget, eff.consistency, sim, eff.minDayPnL,
eff.minTradingDays, balance - cfg.accountSize, 0, cfg.splitEnabled, eff.drain,
);
if (!plan) break;
const daysNeeded = eff.minTradingDays > qualifyingDays(eff.minDayPnL, date);
if (plan.dayTarget <= 0 && !daysNeeded) {
// Cycle complete — nothing left to trade.
const withdrawal = -round2(balance / 2);
if (cfg.withdrawalStages.length > 0 && withdrawals < maxWithdrawals && withdrawal < -1) {
funds.push({ date, amount: withdrawal });
balance = round2(balance + withdrawal);
sim = []; // withdrawal excludes its day — fresh cycle
withdrawals++;
out.push({ date, pnl: 0, stage, drain: eff.drain, withdrawal });
continue;
}
out.push({ date, pnl: 0, stage, drain: eff.drain, payoutReady: true });
break;
}
// Extra days (min-day 0, days still owed) book a token dollar so the
// day counts, matching the real in-and-out MNQ trade.
const pnl = plan.dayTarget > 0 ? plan.remainingToday : 1;
sim.push({ date, pnl });
balance = round2(balance + pnl);
out.push({ date, pnl, stage, drain: eff.drain });
}
return out;
}
/** Dollar-per-point value for common futures products. */
export const POINT_VALUES: { [symbol: string]: number } = {
NQ: 20, MNQ: 2, ES: 50, MES: 5,
@@ -438,11 +616,30 @@ export function computeDayPlan(
minTradingDays: number,
equityProfit: number | null | undefined,
realizedPnL: number,
splitEnabled: boolean = true
splitEnabled: boolean = true,
drain: boolean = false
): DayPlan | null {
if (typeof equityProfit !== 'number' || !Number.isFinite(equityProfit)) return null;
const priorDays = dailyPnL.filter((d) => d.date !== tradeDate);
// Drain stage: no profit target at all. Book minTradingDays qualifying
// days at the randomized min-day amount, then go quiet until the next
// withdrawal resets the cycle. One unsplit tiny trade per day.
if (drain) {
const qualifying = minDayPnL > 0
? priorDays.filter((d) => d.pnl >= minDayPnL)
: priorDays.filter((d) => d.pnl !== 0);
const dayTarget = qualifying.length >= minTradingDays
? 0
: drainDayAmount(accountId, tradeDate, minDayPnL || 5);
return {
dayTarget,
remainingToday: round2(dayTarget - realizedPnL),
path: 'reduced_day',
splitAllowed: false,
};
}
const dayStartEquity = equityProfit - realizedPnL;
const target = computeDailyTarget(
profitTarget, consistency, priorDays, minDayPnL, minTradingDays,