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:
co-authored by
Claude Fable 5
parent
ae0f93a44f
commit
c58e981b1e
@@ -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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user