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