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 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-09-11 18:28:21 -05:00
co-authored by Claude Fable 5
parent ae0f93a44f
commit c58e981b1e
7 changed files with 161 additions and 12 deletions
+2
View File
@@ -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,
})),
+98 -12
View File
@@ -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<number, { date: string; count: number }>();
// 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<number, { date: string; count: number; sum: number }>();
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,
+3
View File
@@ -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;
});
+6
View File
@@ -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). */
+4
View File
@@ -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;
+46
View File
@@ -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;
+2
View File
@@ -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)
}