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:
co-authored by
Claude Opus 5.5
parent
d6bb5a7221
commit
8747dd6b08
+44
-9
@@ -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
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user