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
+69
-33
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user