Exit prices on the tick grid, and a refused take-profit flattens

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 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-10-02 13:49:59 -05:00
co-authored by Claude Opus 5.5
parent d6bb5a7221
commit 8747dd6b08
3 changed files with 98 additions and 13 deletions
+44 -9
View File
@@ -12,7 +12,7 @@ import { getCachedFirm, getTradableFirms } from './firm-catalog';
import { getAccountConfig } from './account-type-resolver'; import { getAccountConfig } from './account-type-resolver';
import { import {
combineCmeSessions, computeDayPlan, groupLegFraction, pickLegAmount, resolveEffectiveConfig, currentTradeDateCT, 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, expectedSwapToWin, planLegSize, maxLossRoom, breachDistance,
DEFAULT_TARGET_HOLD_HOURS, DEFAULT_TARGET_HOLD_HOURS,
isNoTradeTime, isPastSessionClose, isNoTradeTime, isPastSessionClose,
@@ -63,9 +63,10 @@ interface TradeRuntime {
/** Lane → when the batch holding that lane's trade mutex took it (absent /** Lane → when the batch holding that lane's trade mutex took it (absent
* = no batch in flight there). See laneOf. */ * = no batch in flight there). See laneOf. */
tradeInFlight?: Map<string, number>; tradeInFlight?: Map<string, number>;
/** Accounts whose exit bracket the broker refused while a hard daily loss /** Accounts whose exit the broker refused — a bracket while a hard daily
* limit was on: the position was flattened and the account sits out the * loss limit was on, or a lone take-profit: the position was flattened
* trade date rather than bleed a round trip per leg. */ * and the account sits out the trade date rather than bleed a round trip
* per leg. */
exitRefused?: Map<number, string>; exitRefused?: Map<number, string>;
// What getSchedulerHealth reports. // What getSchedulerHealth reports.
lastTickAt: number; 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 (cash.pendingRoll) return { ...base, eligible: false, reason: ROLL_PENDING };
if (unknownEntryHolds(client, acc.id, tradeDate)) return { ...base, eligible: false, reason: ENTRY_UNKNOWN }; if (unknownEntryHolds(client, acc.id, tradeDate)) return { ...base, eligible: false, reason: ENTRY_UNKNOWN };
if (runtime().exitRefused?.get(acc.id) === tradeDate) { 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 // 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}`); 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 * 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 * piece; everything else trades the full remaining amount in one go. The
@@ -1389,9 +1423,10 @@ async function fireLeg(
let exitOrder: { orderId?: number }; let exitOrder: { orderId?: number };
let stopPrice: number | null = null; let stopPrice: number | null = null;
if (ticks !== null) { if (ticks !== null) {
// On the grid toward the entry: the stop's distance is a hard limit.
stopPrice = action === 'Buy' stopPrice = action === 'Buy'
? fill.price - (ticks * legContract.tickSize) ? onTick(fill.price - (ticks * legContract.tickSize), legContract.tickSize, 'up')
: fill.price + (ticks * legContract.tickSize); : onTick(fill.price + (ticks * legContract.tickSize), legContract.tickSize, 'down');
try { try {
exitOrder = await client.placeOcoExit(acc.id, legContract.name, contracts, exitAction, tp.exitPrice, stopPrice); exitOrder = await client.placeOcoExit(acc.id, legContract.name, contracts, exitAction, tp.exitPrice, stopPrice);
} catch (err) { } catch (err) {
@@ -1414,10 +1449,10 @@ async function fireLeg(
// No limit: the position is already open — never leave it exit-less. // 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`); console.error(`${tag} ${acc.name}: placeoco REJECTED (${errorMessage(err)}) — falling back to lone limit exit, NO STOP`);
stopPrice = null; 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 { } 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; const instrumentLabel = legContract.stepNotional || item.drain ? legContract.name : symbolLabel;
+19 -2
View File
@@ -1085,11 +1085,28 @@ export function percentFee(pct: number, price: number, pointValue: number, contr
return Math.ceil(raw * 100 - 1e-9) / 100; 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. * 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 * 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 * 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 * 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 grossTarget = o.leg + totalCommission;
const ticks = Math.ceil(grossTarget / k / o.tickSize); const ticks = Math.ceil(grossTarget / k / o.tickSize);
return { return {
exitPrice: o.entry + dir * ticks * o.tickSize, exitPrice: onTick(o.entry + dir * ticks * o.tickSize, o.tickSize, o.side === 'Buy' ? 'up' : 'down'),
ticks, ticks,
entryFee: o.entryCommission, entryFee: o.entryCommission,
exitFee: o.entryCommission, exitFee: o.entryCommission,
+35 -2
View File
@@ -9,7 +9,7 @@
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { 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, botStopsActive, sizeNotional, isNoTradeTime, isPastSessionClose, nextTradingDate, effectiveCombine, DEFAULT_TARGET_HOLD_HOURS,
projectPerfectPath, cycleCompletion, DLL_RESERVE_FRACTION, DLL_MIN_STOP, notionalCap, expectedSwapToWin, MARGIN_HEADROOM, projectPerfectPath, cycleCompletion, DLL_RESERVE_FRACTION, DLL_MIN_STOP, notionalCap, expectedSwapToWin, MARGIN_HEADROOM,
dllReserve, DLL_SLIPPAGE_FRACTION, maxLossRoom, lastStand, breachDistance, LAST_STAND_FRACTION, planLegSize, rankForTarget, 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 ──────────────────────────────── // ── parity with the pre-refactor inline code ────────────────────────────────
/** The take-profit exactly as auto-trade.ts computed it before takeProfitPlan. */ /** 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 fee = Math.round(r() * 1000) / 100;
const got = takeProfitPlan({ side, entry, leg, pointValue: pv, contracts, tickSize: tick, commissionPct: 0, entryCommission: fee }); 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); 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.ticks, want.ticks);
assert.equal(got.totalCommission, want.totalCommission); assert.equal(got.totalCommission, want.totalCommission);
assert.equal(got.grossTarget, want.grossTarget); assert.equal(got.grossTarget, want.grossTarget);