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 {
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<string, number>;
/** 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<number, string>;
// 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;
+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;
}
/**
* 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,