From c58e981b1e70e2520868aae856a02cb393b3eca7 Mon Sep 17 00:00:00 2001 From: Brandon Li Date: Fri, 11 Sep 2026 18:28:21 -0500 Subject: [PATCH] Place OCO stop brackets for the catalog's obfuscation options When an account type opts in, legs are placed as an OCO pair via order/placeoco - the usual limit plus a stop: the first cycle day's stop keeps the day's net floor at -$1,000, min-day trades stop at 2x their target, and the day after any red day rides stopless to make it back. A stop-out books the day: eligibility refuses the account once realized P&L falls short of the legs fired (or is simply negative on an opted-in account, which survives restarts). If placeoco is rejected the leg falls back to the classic lone limit with a loud log rather than leave a position without an exit. Co-Authored-By: Claude Fable 5 --- app/api/firms/route.ts | 2 + lib/auto-trade.ts | 110 ++++++++++++++++++++++++++++++++++++----- lib/firm-catalog.ts | 3 ++ lib/master-protocol.ts | 6 +++ lib/trading-logic.ts | 4 ++ lib/tradovate-class.ts | 46 +++++++++++++++++ types.ts | 2 + 7 files changed, 161 insertions(+), 12 deletions(-) diff --git a/app/api/firms/route.ts b/app/api/firms/route.ts index 10f0805..547129d 100644 --- a/app/api/firms/route.ts +++ b/app/api/firms/route.ts @@ -38,6 +38,8 @@ export async function GET() { targetSameEquity: a.targetSameEquity, splitEnabled: a.splitEnabled, minLegs: a.minLegs, + firstDayStop: a.firstDayStop, + minDayStop: a.minDayStop, combineCmeHolidays: a.combineCmeHolidays, withdrawalStages: a.withdrawalStages, })), diff --git a/lib/auto-trade.ts b/lib/auto-trade.ts index 1143cd6..69a4699 100644 --- a/lib/auto-trade.ts +++ b/lib/auto-trade.ts @@ -14,6 +14,7 @@ import { getAccountConfig } from './account-type-resolver'; import { combineCmeSessions, computeDayPlan, groupLegFraction, pickLegAmount, resolveEffectiveConfig, currentTradeDateCT, BIG_DAY_THRESHOLD, LEG_COMPLETION_EPSILON, MAX_LEGS_PER_DAY, POINT_VALUES, + FIRST_DAY_STOP, MIN_DAY_STOP_MULT, type DayPlan, } from './trading-logic'; import { getSetting, setSetting } from './db'; @@ -24,17 +25,51 @@ function isAccountDead(amount: number, autoLiqThreshold: number): boolean { return autoLiqThreshold > 0 && amount <= autoLiqThreshold; } -// In-memory runaway guard: legs fired per account per trade date. Resets on restart, -// which is fine — day completion is derived from realized PnL, not from this counter. -const _legCounts = new Map(); +// In-memory runaway guard: legs fired per account per trade date, plus the sum +// of their targets — realized P&L falling short of that sum is how a stop-out +// is detected. Resets on restart, which is fine — day completion is derived +// from realized PnL, not from this counter. +const _legCounts = new Map(); function legsToday(accountId: number, tradeDate: string): number { const entry = _legCounts.get(accountId); return entry && entry.date === tradeDate ? entry.count : 0; } -function recordLeg(accountId: number, tradeDate: string): void { - _legCounts.set(accountId, { date: tradeDate, count: legsToday(accountId, tradeDate) + 1 }); +function legSumToday(accountId: number, tradeDate: string): number { + const entry = _legCounts.get(accountId); + return entry && entry.date === tradeDate ? entry.sum : 0; +} + +function recordLeg(accountId: number, tradeDate: string, legAmount: number): void { + _legCounts.set(accountId, { + date: tradeDate, + count: legsToday(accountId, tradeDate) + 1, + sum: Math.round((legSumToday(accountId, tradeDate) + legAmount) * 100) / 100, + }); +} + +/** Obfuscation stop for the current leg, in dollars. Infinity = no stop. + * Day 1 of a cycle carries the $1,000 account stop (firstDayStop), min-day + * (small) trades a 2×-target stop (minDayStop) — and the day after a losing + * day always rides stopless, the make-back day. */ +function obfuscationStopDollars( + cfg: { firstDayStop: boolean; minDayStop: boolean }, + dailyPnL: { date: string; pnl: number }[], + plan: DayPlan, + tradeDate: string, + legAmount: number, +): number { + if (!cfg.firstDayStop && !cfg.minDayStop) return Infinity; + const prior = dailyPnL + .filter((d) => d.date < tradeDate && d.pnl !== 0) + .sort((a, b) => a.date.localeCompare(b.date)); + if (prior.length > 0 && prior[prior.length - 1].pnl < 0) return Infinity; + let stop = Infinity; + const realizedToday = Math.round((plan.dayTarget - plan.remainingToday) * 100) / 100; + if (cfg.firstDayStop && prior.length === 0) stop = Math.max(0, FIRST_DAY_STOP + realizedToday); + if (cfg.minDayStop && plan.dayTarget <= BIG_DAY_THRESHOLD) stop = Math.min(stop, MIN_DAY_STOP_MULT * legAmount); + return stop; } // ── per-account trade evaluation (shared by runTrade / copyTrade / hasRemaining…) ── @@ -80,6 +115,20 @@ function evaluateAccountForTrade(client: any, acc: { id: number; name: string; a if (!acc.active) return { ...base, eligible: false, reason: 'inactive (DLL)' }; if (client.positions[acc.id]) return { ...base, eligible: false, reason: 'already in trade' }; + // Obfuscation stops: a stop-out shows as realized P&L falling short of the + // legs fired today — the day is booked; the make-back ride is tomorrow. + // The plain negative-realized check is what survives a restart, when the + // in-memory leg counter has forgotten the morning's stop-out. + if (cfg.firstDayStop || cfg.minDayStop) { + if (combined.realizedPnL <= -25) { + return { ...base, eligible: false, reason: 'red day booked — make-back rides tomorrow' }; + } + if (legsToday(acc.id, tradeDate) > 0 + && combined.realizedPnL < legSumToday(acc.id, tradeDate) - 25) { + return { ...base, eligible: false, reason: 'stopped out — day booked' }; + } + } + const priorProfit = client.priorProfit?.[acc.id] ?? 0; const allFundTxns = client.fundTransactions?.[acc.id] ?? []; const effective = resolveEffectiveConfig( @@ -368,7 +417,7 @@ export async function runTrade(action: 'Buy' | 'Sell' | 'Auto', symbol: string) minLegs - legsToday(acc.id, tradeDate), ) : item.plan.remainingToday; - recordLeg(acc.id, tradeDate); + recordLeg(acc.id, tradeDate, legAmount); const rawContracts = Math.max(1, Math.ceil(legAmount / 1000)); const contracts = cfg.maxPositionSize > 0 ? Math.min(rawContracts, cfg.maxPositionSize) : rawContracts; @@ -391,9 +440,28 @@ export async function runTrade(action: 'Buy' | 'Sell' | 'Auto', symbol: string) : fill.price - (ticks * contract.tickSize); const exitAction: 'Buy' | 'Sell' = resolvedAction === 'Buy' ? 'Sell' : 'Buy'; - const exitOrder = await client.placeOrderNoWait(acc.id, contract.name, contracts, exitAction, 'Limit', exitPrice); + 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)); + stopPrice = resolvedAction === 'Buy' + ? fill.price - (stopTicks * contract.tickSize) + : fill.price + (stopTicks * contract.tickSize); + try { + exitOrder = await client.placeOcoExit(acc.id, contract.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); + } + } else { + exitOrder = await client.placeOrderNoWait(acc.id, contract.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} (orderId=${exitOrder.orderId})`); + 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})`); return { account: acc.name, @@ -522,6 +590,7 @@ export async function copyTrade() { acc: { id: number; name: string; active: boolean }; contract: { name: string; tickSize: number }; cash: { amount: number; realizedPnL: number }; + dailyPnL: { date: string; pnl: number }[]; plan: DayPlan; prefix: string; }; @@ -552,7 +621,7 @@ export async function copyTrade() { if (ev.isExtraDay) { console.log(`[copy-trade] ${acc.name}: extra-day account — skip`); continue; } eligible.push({ - firmName: firm.name, client, acc, contract, cash: ev.cash, plan: ev.plan, + firmName: firm.name, client, acc, contract, cash: ev.cash, dailyPnL: ev.dailyPnL, plan: ev.plan, prefix: getAccountConfig(acc.id).accountType?.prefix ?? '', }); } @@ -583,7 +652,7 @@ export async function copyTrade() { minLegs - legsToday(acc.id, tradeDate), ) : item.plan.remainingToday; - recordLeg(acc.id, tradeDate); + recordLeg(acc.id, tradeDate, legAmount); const rawContracts = Math.max(1, Math.ceil(legAmount / 1000)); const contracts = cfg.maxPositionSize > 0 ? Math.min(rawContracts, cfg.maxPositionSize) : rawContracts; @@ -602,9 +671,26 @@ export async function copyTrade() { : fill.price - (ticks * contract.tickSize); const exitAction: 'Buy' | 'Sell' = resolvedAction === 'Buy' ? 'Sell' : 'Buy'; - const exitOrder = await client.placeOrderNoWait(acc.id, contract.name, contracts, exitAction, 'Limit', exitPrice); + 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)); + stopPrice = resolvedAction === 'Buy' + ? fill.price - (stopTicks * contract.tickSize) + : fill.price + (stopTicks * contract.tickSize); + try { + exitOrder = await client.placeOcoExit(acc.id, contract.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); + } + } else { + exitOrder = await client.placeOrderNoWait(acc.id, contract.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} (orderId=${exitOrder.orderId})`); + 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})`); return { account: acc.name, diff --git a/lib/firm-catalog.ts b/lib/firm-catalog.ts index a0ae08f..9a7dc3a 100644 --- a/lib/firm-catalog.ts +++ b/lib/firm-catalog.ts @@ -163,6 +163,9 @@ function validatePayload(raw: unknown): FirmCatalogPayload { combineCmeHolidays: type.combineCmeHolidays === true, // Lenient: a master from before the min-legs field means "no minimum". minLegs: typeof type.minLegs === 'number' ? type.minLegs : 0, + // Lenient: the obfuscation stops are opt-in, so absent means off. + firstDayStop: type.firstDayStop === true, + minDayStop: type.minDayStop === true, } as unknown as CatalogAccountType; }); diff --git a/lib/master-protocol.ts b/lib/master-protocol.ts index 7574cfe..572900a 100644 --- a/lib/master-protocol.ts +++ b/lib/master-protocol.ts @@ -114,6 +114,12 @@ export interface CatalogAccountType { * legs (0 = however the carve falls). Smaller days always trade as one * leg; clamped to what ≥$500 pieces make possible. */ minLegs: number; + /** Obfuscation, part 1: the first trading day of a cycle carries a $1,000 + * account stop — the day books red and ends instead of riding. */ + firstDayStop: boolean; + /** Obfuscation, part 2: min-day (small) trades carry a 2×-target stop; a + * stopped-out day books, and the next day rides stopless. */ + minDayStop: boolean; /** True = CME holiday sessions count with the next full trading day, the way * 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). */ diff --git a/lib/trading-logic.ts b/lib/trading-logic.ts index 7104b46..2221ae1 100644 --- a/lib/trading-logic.ts +++ b/lib/trading-logic.ts @@ -56,6 +56,10 @@ export const BIG_DAY_THRESHOLD = 1700; export const MIN_LEG = 500; /** After at least one trade, a residual at or below this counts as "day done". */ export const LEG_COMPLETION_EPSILON = 25; +/** firstDayStop: the first cycle day books at this loss and stops. */ +export const FIRST_DAY_STOP = 1000; +/** minDayStop: a min-day trade's stop sits at this multiple of its target. */ +export const MIN_DAY_STOP_MULT = 2; /** Safety valve: never fire more than this many legs per account per trade date. */ export const MAX_LEGS_PER_DAY = 8; diff --git a/lib/tradovate-class.ts b/lib/tradovate-class.ts index 7bc36a5..f87b08c 100644 --- a/lib/tradovate-class.ts +++ b/lib/tradovate-class.ts @@ -951,6 +951,52 @@ export class TradovateClient { }); } + /** Place an OCO exit pair after an entry fill: a limit take-profit linked + * to a stop, via order/placeoco — Tradovate cancels the sibling when one + * fills. Resolves on command ack (does not wait for a fill), like + * placeOrderNoWait. */ + async placeOcoExit( + accountId: number, + contractSymbol: string, + quantity: number, + exitAction: 'Buy' | 'Sell', + limitPrice: number, + stopPrice: number, + ): Promise<{ orderId?: number }> { + if (!this.ws) throw new Error('WebSocket not connected'); + + const msgId = this.getMsgId(); + return new Promise((resolve, reject) => { + this.directEventCallbacks[msgId] = (response: any) => { + if (typeof response === 'string') { + reject(new Error(response)); + return; + } + resolve({ orderId: response?.orderId ?? response?.id }); + }; + + this.ws.send( + `order/placeoco\n${msgId}\n\n${JSON.stringify({ + accountId, + action: exitAction, + symbol: contractSymbol, + orderQty: quantity, + orderType: 'Limit', + price: limitPrice, + timeInForce: 'Day', + text: 'DOM', + other: { + action: exitAction, + orderType: 'Stop', + stopPrice, + timeInForce: 'Day', + text: 'DOM', + }, + })}` + ); + }); + } + /** Close the WebSocket connection and stop all intervals. Call before discarding the instance. */ public disconnect(): void { this.intentionalDisconnect = true; diff --git a/types.ts b/types.ts index d3cb294..c0de70d 100644 --- a/types.ts +++ b/types.ts @@ -13,6 +13,8 @@ export interface AccountConfig { targetSameEquity: boolean; // if true, reduce profitTarget by priorProfit after withdrawal splitEnabled: boolean; // false = big days trade as one unsplit leg instead of random ≥$500 pieces minLegs: number; // big days (> $1,700) must carve at least this many legs (0 = however the carve falls) + firstDayStop: boolean; // first cycle day carries a $1,000 account stop - books red and ends + minDayStop: boolean; // min-day trades carry a 2x-target stop; next day after a loss rides stopless combineCmeHolidays: boolean; // true = CME holiday sessions count with the next full trading day (Apex-style) withdrawalStages: { profit: number; consistency: number; minTradingDays: number }[]; // per-stage fresh profit + consistency (empty = use base values) }