From 8747dd6b0849b530a72c984ee344a40627640d65 Mon Sep 17 00:00:00 2001 From: Brandon Li Date: Fri, 2 Oct 2026 13:49:59 -0500 Subject: [PATCH] Exit prices on the tick grid, and a refused take-profit flattens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 4217.6 − 87 × 0.1 is 4208.900000000001 in floating point. TopstepX refused that take-profit ("Invalid price. Price is outside allowed range") on two GC shorts on 2026-10-02, and with no stop on the leg nothing else was working: both rode far past their target with no exit. ES and NQ never hit it because 0.25 is exact in binary; GC and CL ticks aren't. - onTick puts the measured-fee take-profit and every stop on the grid — the take-profit away from the entry, the stop toward it — so an averaged multi-contract fill between ticks is handled the same way. - A leg's lone take-profit (no stop, or the fallback after a refused bracket) that the broker refuses now flattens the position and benches the account for the day, like a refused bracket. An unknown outcome still places nothing more. Co-Authored-By: Claude Opus 5.5 --- lib/auto-trade.ts | 53 +++++++++++++++++++++++++++------ lib/trading-logic.ts | 21 +++++++++++-- scripts/check-trading-logic.mjs | 37 +++++++++++++++++++++-- 3 files changed, 98 insertions(+), 13 deletions(-) diff --git a/lib/auto-trade.ts b/lib/auto-trade.ts index 359531f..10ebdf2 100644 --- a/lib/auto-trade.ts +++ b/lib/auto-trade.ts @@ -12,7 +12,7 @@ import { getCachedFirm, getTradableFirms } from './firm-catalog'; import { getAccountConfig } from './account-type-resolver'; import { combineCmeSessions, computeDayPlan, groupLegFraction, pickLegAmount, resolveEffectiveConfig, currentTradeDateCT, - obfuscationStopDollars, takeProfitPlan, percentFee, dllRoom, dllStopDollars, dllViable, stopTicks, + obfuscationStopDollars, takeProfitPlan, onTick, percentFee, dllRoom, dllStopDollars, dllViable, stopTicks, expectedSwapToWin, planLegSize, maxLossRoom, breachDistance, DEFAULT_TARGET_HOLD_HOURS, isNoTradeTime, isPastSessionClose, @@ -63,9 +63,10 @@ interface TradeRuntime { /** Lane → when the batch holding that lane's trade mutex took it (absent * = no batch in flight there). See laneOf. */ tradeInFlight?: Map; - /** Accounts whose exit bracket the broker refused while a hard daily loss - * limit was on: the position was flattened and the account sits out the - * trade date rather than bleed a round trip per leg. */ + /** Accounts whose exit the broker refused — a bracket while a hard daily + * loss limit was on, or a lone take-profit: the position was flattened + * and the account sits out the trade date rather than bleed a round trip + * per leg. */ exitRefused?: Map; // What getSchedulerHealth reports. lastTickAt: number; @@ -627,7 +628,7 @@ function evaluateAccountForTrade(client: any, acc: { id: number; name: string; a if (cash.pendingRoll) return { ...base, eligible: false, reason: ROLL_PENDING }; if (unknownEntryHolds(client, acc.id, tradeDate)) return { ...base, eligible: false, reason: ENTRY_UNKNOWN }; if (runtime().exitRefused?.get(acc.id) === tradeDate) { - return { ...base, eligible: false, reason: 'exit bracket refused — flattened, benched for the day' }; + return { ...base, eligible: false, reason: 'exit refused — flattened, benched for the day' }; } // Obfuscation stops: a stop-out shows as realized P&L falling short of the @@ -1173,6 +1174,39 @@ async function flattenAndFail( throw new Error(`entry filled @ ${entryPrice}; flattened — ${why}`); } +/** + * The take-profit of a leg with no stop. The position is already open, so a + * refusal must not leave it riding with no exit at all — on 2026-10-02 two GC + * shorts did, far past their target. Refused: flatten and bench the account + * for the day, like a refused bracket. Outcome unknown: it may be working, and + * a second exit on top could flip the account, so nothing more is placed. + */ +async function placeLoneExit( + tag: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + client: any, + acc: { id: number; name: string }, + contractName: string, + contracts: number, + exitAction: 'Buy' | 'Sell', + exitPrice: number, + entryPrice: number, + tradeDate: string, +): Promise<{ orderId?: number }> { + try { + return await client.placeOrderNoWait(acc.id, contractName, contracts, exitAction, 'Limit', exitPrice); + } catch (err) { + if (!isOrderNotPlaced(err)) { + console.error(`${tag} ${acc.name}: take-profit outcome UNKNOWN (${errorMessage(err)}) — NOT placing a second exit; check this position's exits at the broker`); + throw new Error(`entry filled @ ${entryPrice} but the take-profit's outcome is unknown — ${errorMessage(err)}`); + } + (runtime().exitRefused ??= new Map()).set(acc.id, tradeDate); + console.error(`${tag} ${acc.name}: take-profit @ ${exitPrice} REJECTED (${errorMessage(err)}) — flattening`); + return flattenAndFail(tag, client, acc, contractName, contracts, exitAction, entryPrice, + `take-profit @ ${exitPrice} refused (${errorMessage(err)})`); + } +} + /** * The leg an account trades next. Split-allowed big days carve a ≥$500 * piece; everything else trades the full remaining amount in one go. The @@ -1389,9 +1423,10 @@ async function fireLeg( let exitOrder: { orderId?: number }; let stopPrice: number | null = null; if (ticks !== null) { + // On the grid toward the entry: the stop's distance is a hard limit. stopPrice = action === 'Buy' - ? fill.price - (ticks * legContract.tickSize) - : fill.price + (ticks * legContract.tickSize); + ? onTick(fill.price - (ticks * legContract.tickSize), legContract.tickSize, 'up') + : onTick(fill.price + (ticks * legContract.tickSize), legContract.tickSize, 'down'); try { exitOrder = await client.placeOcoExit(acc.id, legContract.name, contracts, exitAction, tp.exitPrice, stopPrice); } catch (err) { @@ -1414,10 +1449,10 @@ async function fireLeg( // No limit: the position is already open — never leave it exit-less. console.error(`${tag} ${acc.name}: placeoco REJECTED (${errorMessage(err)}) — falling back to lone limit exit, NO STOP`); stopPrice = null; - exitOrder = await client.placeOrderNoWait(acc.id, legContract.name, contracts, exitAction, 'Limit', tp.exitPrice); + exitOrder = await placeLoneExit(tag, client, acc, legContract.name, contracts, exitAction, tp.exitPrice, fill.price, tradeDate); } } else { - exitOrder = await client.placeOrderNoWait(acc.id, legContract.name, contracts, exitAction, 'Limit', tp.exitPrice); + exitOrder = await placeLoneExit(tag, client, acc, legContract.name, contracts, exitAction, tp.exitPrice, fill.price, tradeDate); } const instrumentLabel = legContract.stepNotional || item.drain ? legContract.name : symbolLabel; diff --git a/lib/trading-logic.ts b/lib/trading-logic.ts index 373e58d..61d3e5d 100644 --- a/lib/trading-logic.ts +++ b/lib/trading-logic.ts @@ -1085,11 +1085,28 @@ export function percentFee(pct: number, price: number, pointValue: number, contr return Math.ceil(raw * 100 - 1e-9) / 100; } +/** + * A price on the tick grid, free of float fuzz. 4217.6 − 87 × 0.1 is + * 4208.900000000001 in floating point, and TopstepX refuses a price off the + * grid ("Invalid price. Price is outside allowed range") — a GC short rode + * with no take-profit on 2026-10-02. An averaged multi-contract fill can sit + * between ticks too (by 1/contracts of a tick or more), so an off-grid price + * goes `mode`'s way; fuzz within a millionth of a tick is never pushed a + * whole tick. + */ +export function onTick(price: number, tickSize: number, mode: 'up' | 'down'): number { + if (!(tickSize > 0)) return price; + const n = price / tickSize; + const k = mode === 'up' ? Math.ceil(n - 1e-6) : Math.floor(n + 1e-6); + return Math.round(k * tickSize * 1e10) / 1e10; +} + /** * Where to put the take-profit so the leg nets at least `leg` after fees. * * Measured-fee firms (commissionPct 0): the exit fee is taken to equal the - * entry fee — the same formula the fire path always used, reproduced exactly. + * entry fee — the same formula the fire path always used, reproduced exactly, + * then put on the tick grid away from the entry (onTick). * * Percent-fee firms: the exit fee depends on the exit price itself, so solve * for it. With r = pct/100 and k = pointValue × contracts, a long nets @@ -1116,7 +1133,7 @@ export function takeProfitPlan(o: { const grossTarget = o.leg + totalCommission; const ticks = Math.ceil(grossTarget / k / o.tickSize); return { - exitPrice: o.entry + dir * ticks * o.tickSize, + exitPrice: onTick(o.entry + dir * ticks * o.tickSize, o.tickSize, o.side === 'Buy' ? 'up' : 'down'), ticks, entryFee: o.entryCommission, exitFee: o.entryCommission, diff --git a/scripts/check-trading-logic.mjs b/scripts/check-trading-logic.mjs index 787cccc..f614a89 100644 --- a/scripts/check-trading-logic.mjs +++ b/scripts/check-trading-logic.mjs @@ -9,7 +9,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { - percentFee, takeProfitPlan, obfuscationStopDollars, dllRoom, dllStopDollars, dllViable, stopTicks, + percentFee, takeProfitPlan, onTick, obfuscationStopDollars, dllRoom, dllStopDollars, dllViable, stopTicks, botStopsActive, sizeNotional, isNoTradeTime, isPastSessionClose, nextTradingDate, effectiveCombine, DEFAULT_TARGET_HOLD_HOURS, projectPerfectPath, cycleCompletion, DLL_RESERVE_FRACTION, DLL_MIN_STOP, notionalCap, expectedSwapToWin, MARGIN_HEADROOM, dllReserve, DLL_SLIPPAGE_FRACTION, maxLossRoom, lastStand, breachDistance, LAST_STAND_FRACTION, planLegSize, rankForTarget, @@ -28,6 +28,38 @@ function rng(seed) { }; } +// ── prices on the tick grid ───────────────────────────────────────────────── + +test('onTick: 2026-10-02 GC short — 87 ticks below 4217.6 is 4208.9, not 4208.900000000001', () => { + assert.notEqual(4217.6 - 87 * 0.1, 4208.9); // the fuzz TopstepX refused + const tp = takeProfitPlan({ side: 'Sell', entry: 4217.6, leg: 861.4, pointValue: 100, contracts: 1, tickSize: 0.1, commissionPct: 0, entryCommission: 2.8 }); + assert.equal(tp.ticks, 87); + assert.equal(tp.exitPrice, 4208.9); + assert.equal(JSON.stringify(tp.exitPrice), '4208.9'); +}); + +test('onTick: measured-fee take-profits always land on the grid, as the shortest decimal', () => { + const r = rng(11); + for (let i = 0; i < 5000; i++) { + const side = r() < 0.5 ? 'Buy' : 'Sell'; + const [pv, tick] = [[100, 0.1], [1000, 0.01], [10, 0.1], [12.5, 0.0001], [1000, 0.03125], [50, 0.25]][Math.floor(r() * 6)]; + const decimals = String(tick).split('.')[1].length; + const entry = Number((Math.round((50 + r() * 20000) / tick) * tick).toFixed(decimals)); + const tp = takeProfitPlan({ side, entry, leg: 5 + r() * 2500, pointValue: pv, contracts: 1 + Math.floor(r() * 4), tickSize: tick, commissionPct: 0, entryCommission: Math.round(r() * 1000) / 100 }); + assert.equal(tp.exitPrice, Number(tp.exitPrice.toFixed(decimals)), `${tp.exitPrice} has more decimals than tick ${tick}`); + assert.equal(Math.round(Math.abs(tp.exitPrice - entry) / tick), tp.ticks); + } +}); + +test('onTick: an averaged fill between ticks goes the given way; fuzz never moves a tick', () => { + assert.equal(onTick(7749.125, 0.25, 'up'), 7749.25); // 2 ES filled at .00 and .25 + assert.equal(onTick(7749.125, 0.25, 'down'), 7749); + assert.equal(onTick(4208.900000000001, 0.1, 'up'), 4208.9); + assert.equal(onTick(4208.899999999999, 0.1, 'down'), 4208.9); + assert.equal(onTick(62.07, 0.01, 'up'), 62.07); + assert.equal(onTick(123.5, 0, 'up'), 123.5); // no tick size known: untouched +}); + // ── parity with the pre-refactor inline code ──────────────────────────────── /** The take-profit exactly as auto-trade.ts computed it before takeProfitPlan. */ @@ -51,7 +83,8 @@ test('takeProfitPlan reproduces the old formula when fees are measured', () => { const fee = Math.round(r() * 1000) / 100; const got = takeProfitPlan({ side, entry, leg, pointValue: pv, contracts, tickSize: tick, commissionPct: 0, entryCommission: fee }); const want = oldTakeProfit(side, entry, leg, pv, contracts, tick, fee); - assert.equal(got.exitPrice, want.exitPrice); + // The same price, minus the old formula's float fuzz. + assert.equal(got.exitPrice, Math.round(want.exitPrice * 1e10) / 1e10); assert.equal(got.ticks, want.ticks); assert.equal(got.totalCommission, want.totalCommission); assert.equal(got.grossTarget, want.grossTarget);