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 { 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
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user