Instruments: each firm's list from the master, and a session-aware futures draw
- lib/instrument-specs.ts (pure, tested): the CME specs — point value, tick size, one-tick spread, Yahoo ticker — replacing POINT_VALUES; the draw's maths; the 12-hour hold cap; the micro for drain and extra-day legs; reading a firm's list (the master's, or the old rules against an older master: this machine's enabled set less bans, the symbol map). - lib/instruments.ts: the one module the trading paths ask. Measures every future's daily move from Yahoo's hourly candles once per trade date, and shows entry cost (a tick of spread plus Tradovate's fee parameters or TopstepX's last measured fill) without ever choosing by it. - Futures Auto draws one root per batch from the batch firm's list: movement per dollar of spread early in the session with a 10% floor each, sliding to the fastest movers by the last entry; nothing expected to hold 12h or more; micros only if nothing else is listed. Every draw logs its odds. - The batch is chosen before its instrument, and a group that can't trade anything yields to the next. Copy trade matches the held contract against the listed roots and won't copy an instrument it can't identify. Manual legs, manual orders and drain/extra-day legs follow the lists. - DXtrade firms rank their own list whatever root was drawn. - Capabilities, the dashboard's trade bar and contract resolves follow the lists; the trade bar and scheduler default to Auto. The master link reconnects when master_dashboard_url changes, and the catalog drops ?since after a switch so two masters' versions can't collide. - Reports carry each futures firm's instrumentStats for the master's Firms page; GET /api/instruments shows them (?simulate=N runs the draw, no orders); the Settings page shows the lists read-only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
302e9b4911
commit
02544fbdf5
+201
-97
@@ -7,7 +7,6 @@
|
||||
* signal but have since exited and are now eligible.
|
||||
*/
|
||||
|
||||
import { getInstruments } from './db';
|
||||
import { getClients } from './clients';
|
||||
import { getCachedFirm, getTradableFirms } from './firm-catalog';
|
||||
import { getAccountConfig } from './account-type-resolver';
|
||||
@@ -17,11 +16,13 @@ import {
|
||||
expectedSwapToWin, planLegSize, maxLossRoom, breachDistance,
|
||||
DEFAULT_TARGET_HOLD_HOURS,
|
||||
isNoTradeTime, isPastSessionClose,
|
||||
BIG_DAY_THRESHOLD, LEG_COMPLETION_EPSILON, MAX_LEGS_PER_DAY, POINT_VALUES,
|
||||
BIG_DAY_THRESHOLD, LEG_COMPLETION_EPSILON, MAX_LEGS_PER_DAY,
|
||||
FALLBACK_COMMISSION_PER_CONTRACT, MAX_COMMISSION_PER_CONTRACT, DLL_MIN_STOP,
|
||||
type DayPlan,
|
||||
} from './trading-logic';
|
||||
import { getSetting, setSetting } from './db';
|
||||
import { POINT_VALUES, housekeepingRoot } from './instrument-specs';
|
||||
import { firmInstruments, drawFuturesInstrument, recordMeasuredFee, type LegShape } from './instruments';
|
||||
import { errorMessage, isClientReady, isOrderNotPlaced, type PlatformClient, type PlatformContract, type PickContext } from './platform-client';
|
||||
|
||||
// ── process-wide state ───────────────────────────────────────────────────────
|
||||
@@ -422,20 +423,64 @@ function releaseTradeMutex(lease: number, lane: string): void {
|
||||
if (holdsTradeMutex(lease, lane)) runtime().tradeInFlight?.delete(lane);
|
||||
}
|
||||
|
||||
/** The instrument a leg actually trades: drain legs go on MNQ (whatever the
|
||||
* platform maps MNQ to), hardcoded like the extra-day path — tiny targets
|
||||
* belong on the micro regardless of the batch symbol. */
|
||||
/** What a firm's tiny legs (drain, extra-day) trade: a futures firm the
|
||||
* micro of what it lists (MNQ when NQ is listed, as always — see
|
||||
* housekeepingRoot); a DXtrade firm its current pick. */
|
||||
function housekeepingFor(firm: { platform: string; instruments?: string[]; bannedSymbols?: string[]; symbolMap?: Record<string, { symbol: string }[]> }): string {
|
||||
if (firm.platform === 'dxtrade') return '*';
|
||||
return housekeepingRoot(firmInstruments(firm)) ?? 'MNQ';
|
||||
}
|
||||
|
||||
/** The instrument a leg actually trades: drain legs go on the firm's
|
||||
* housekeeping micro — tiny targets belong on a micro whatever the batch
|
||||
* symbol. */
|
||||
async function legInstrument(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
client: any,
|
||||
firm: TradableFirm,
|
||||
drain: boolean,
|
||||
batchContract: LegContract,
|
||||
batchPointValue: number,
|
||||
): Promise<{ contract: LegContract; pointValue: number }> {
|
||||
if (!drain) return { contract: batchContract, pointValue: batchPointValue };
|
||||
const mnq: LegContract | null = await client.findFrontMonthContract('MNQ');
|
||||
if (!mnq) throw new Error('MNQ contract not found for drain trade');
|
||||
return { contract: mnq, pointValue: mnq.pointValue ?? POINT_VALUES['MNQ'] };
|
||||
const root = housekeepingFor(firm);
|
||||
const micro: LegContract | null = await client.findFrontMonthContract(root);
|
||||
if (!micro) throw new Error(`${root} contract not found for drain trade`);
|
||||
const pointValue = micro.pointValue ?? POINT_VALUES[root];
|
||||
if (!pointValue) throw new Error(`no point value for ${root}`);
|
||||
return { contract: micro, pointValue };
|
||||
}
|
||||
|
||||
/** Which listed root a held contract is — each candidate looked up on the
|
||||
* client holding it. null when none matches. */
|
||||
async function heldRoot(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
client: any,
|
||||
contractId: number | string,
|
||||
roots: readonly string[],
|
||||
): Promise<string | null> {
|
||||
for (const root of roots) {
|
||||
try {
|
||||
const contract = await client.findFrontMonthContract(root);
|
||||
if (contract && String(contract.id) === String(contractId)) return root;
|
||||
} catch (err) {
|
||||
console.error(`[auto-trade] ${root}: contract lookup failed — ${errorMessage(err)}`);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** A leg's shape for the draw's hold estimate: its dollars, its stop and
|
||||
* the account type's max contracts. */
|
||||
function legShapeFor(item: SizingItem, tradeDate: string): LegShape {
|
||||
const cfg = getAccountConfig(item.acc.id).accountType!;
|
||||
const leg = legAmountFor(item, tradeDate);
|
||||
const obf = obfuscationStopDollars(cfg, item.dailyPnL, item.plan, tradeDate, leg);
|
||||
const dll = cfg.dailyLossLimit ?? 0;
|
||||
const hard = Number.isFinite(item.dllRoom) && dll > 0
|
||||
? dllStopDollars({ room: item.dllRoom, dailyLossLimit: dll, entryFee: FALLBACK_COMMISSION_PER_CONTRACT, commissionPct: 0 })
|
||||
: Infinity;
|
||||
return { leg, stopDollars: Math.min(Number.isFinite(obf) && obf > 0 ? obf : Infinity, hard), maxPositionSize: cfg.maxPositionSize };
|
||||
}
|
||||
|
||||
/** What the fire path needs of a contract — PlatformContract minus the id. */
|
||||
@@ -783,49 +828,43 @@ async function runTradeLocked(
|
||||
}
|
||||
const open = laneReady.filter(({ firm }) => !isInNoTradeWindow(firm));
|
||||
|
||||
// Resolve 'Auto' symbol once per batch so all accounts trade the same symbol
|
||||
let resolvedSymbol = symbol;
|
||||
if (symbol === 'Auto') {
|
||||
const enabled = getInstruments().filter((i) => i.enabled).map((i) => i.symbol);
|
||||
resolvedSymbol = enabled.length > 0 ? enabled[Math.floor(Math.random() * enabled.length)] : 'NQ';
|
||||
console.log(`[auto-trade] ${laneName(lane)}: random symbol resolved to: ${resolvedSymbol}`);
|
||||
}
|
||||
// Resolve Auto action once per batch so all accounts trade the same direction
|
||||
// One direction per batch: every account trades the same side.
|
||||
const resolvedAction: 'Buy' | 'Sell' = action === 'Auto'
|
||||
? (Math.random() < 0.5 ? 'Buy' : 'Sell')
|
||||
: action;
|
||||
// The CME product's point value. A platform that maps the root to its own
|
||||
// instrument (DXTrade) supplies the contract's point value instead.
|
||||
const basePointValue: number | undefined = POINT_VALUES[resolvedSymbol];
|
||||
const futuresLane = lane === FUTURES_LANE;
|
||||
// An explicit symbol (the scheduler's, or a manual Trade) is the
|
||||
// operator's pick; 'Auto' is drawn per batch from the batch firm's list.
|
||||
const explicit = futuresLane && symbol !== 'Auto' ? symbol : null;
|
||||
|
||||
const tradeDate = currentTradeDateCT();
|
||||
const maxConcurrent = maxConcurrentAccounts();
|
||||
|
||||
// ── Phase 1: collect ALL eligible accounts across the lane's firms in parallel ──
|
||||
// ── Phase 1: the eligible accounts across the lane's firms ──
|
||||
// No instrument yet: it's chosen for the batch's firm, from that firm's
|
||||
// list, once the batch is known.
|
||||
type Candidate = SizingItem & { daysTraded: number; isExtraDay: boolean };
|
||||
type EligibleItem = LegItem & { daysTraded: number; isExtraDay: boolean };
|
||||
const allEligible: Candidate[] = [];
|
||||
|
||||
const allEligible: EligibleItem[] = [];
|
||||
|
||||
await Promise.all(open.map(async ({ firm, client }) => {
|
||||
if (client.accountList.length === 0) return;
|
||||
|
||||
// Skip this firm entirely if the symbol is banned for it
|
||||
if (firm.bannedSymbols.includes(resolvedSymbol)) {
|
||||
console.log(`[auto-trade] ${firm.name}: ${resolvedSymbol} is banned — skipping firm`);
|
||||
return;
|
||||
for (const { firm, client } of open) {
|
||||
if (client.accountList.length === 0) continue;
|
||||
const listed = firmInstruments(firm);
|
||||
if (listed.length === 0) {
|
||||
console.log(`[auto-trade] ${firm.name}: no instruments listed — skipping firm`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// One firm's trouble (a contract lookup that throws, a malformed
|
||||
// snapshot) skips that firm, never the batch — a rejection here would
|
||||
// fail the Promise.all and every other firm would sit the tick out.
|
||||
if (explicit && !listed.includes(explicit)) {
|
||||
console.log(`[auto-trade] ${firm.name}: ${explicit} isn't on its instrument list — skipping firm`);
|
||||
continue;
|
||||
}
|
||||
// One firm's trouble (a malformed snapshot) skips that firm, never
|
||||
// the batch.
|
||||
try {
|
||||
// The accounts first: a platform that chooses between
|
||||
// instruments ranks them by how the batch's leg would size.
|
||||
const evaluated: (SizingItem & { daysTraded: number; isExtraDay: boolean })[] = [];
|
||||
for (const acc of client.accountList) {
|
||||
const ev = evaluateAccountForTrade(client, acc, tradeDate);
|
||||
if (!ev || !ev.eligible || !ev.plan) continue;
|
||||
evaluated.push({
|
||||
allEligible.push({
|
||||
firmName: firm.name, firm, client, acc,
|
||||
cash: ev.cash, dailyPnL: ev.dailyPnL, daysTraded: ev.daysTraded,
|
||||
plan: ev.plan, isExtraDay: ev.isExtraDay, drain: ev.drain,
|
||||
@@ -833,53 +872,112 @@ async function runTradeLocked(
|
||||
dllRoom: ev.dllRoom,
|
||||
});
|
||||
}
|
||||
if (evaluated.length === 0) return;
|
||||
|
||||
const contract = await client.findFrontMonthContract(
|
||||
resolvedSymbol, pickContextFor(evaluated.filter((it) => !it.isExtraDay && !it.drain), tradeDate));
|
||||
if (!contract) return;
|
||||
const pv = contract.pointValue ?? basePointValue;
|
||||
if (!pv) {
|
||||
console.log(`[auto-trade] ${firm.name}: no point value for ${resolvedSymbol} — skipping firm`);
|
||||
return;
|
||||
}
|
||||
const found: EligibleItem[] = evaluated.map((it) => ({ ...it, contract, pv }));
|
||||
allEligible.push(...found);
|
||||
} catch (err) {
|
||||
console.error(`[auto-trade] ${firm.name}: skipped — ${errorMessage(err)}`);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
if (allEligible.length === 0) {
|
||||
console.log(`[auto-trade] ${laneName(lane)}: no eligible accounts found`);
|
||||
return { outcome: 'no-eligible', results: [] };
|
||||
}
|
||||
|
||||
// Collecting took so long that another batch presumed this one wedged and
|
||||
// took the mutex. That batch traded on fresher numbers than these, so
|
||||
// firing now could trade an account twice.
|
||||
if (!holdsTradeMutex(lease, lane)) {
|
||||
console.warn('[auto-trade] superseded while collecting accounts — abandoning this batch');
|
||||
return { outcome: 'skipped-in-flight', results: [] };
|
||||
}
|
||||
|
||||
// Take only the first batch — all fired simultaneously, no rolling pool.
|
||||
// Remaining accounts wait for the next tick (which only fires once all positions are flat).
|
||||
// Like accounts trade together: a batch is drawn from a single prefix group
|
||||
// (largest group first), never mixed across prefixes — a group's remainder
|
||||
// fires as a smaller batch on a later tick instead of being padded from
|
||||
// another prefix. E.g. 4×FTDFY, 5×MFFUEV, 7×MFFUSF at limit 5 fires as
|
||||
// batches of 5, 5, 4, 2.
|
||||
const groups = new Map<string, EligibleItem[]>();
|
||||
// batches of 5, 5, 4, 2. A group that can't trade anything right now
|
||||
// (nothing listed fits, no contract resolves) yields to the next.
|
||||
const groups = new Map<string, Candidate[]>();
|
||||
for (const item of allEligible) {
|
||||
const group = groups.get(item.prefix);
|
||||
if (group) group.push(item);
|
||||
else groups.set(item.prefix, [item]);
|
||||
}
|
||||
const [batchPrefix, batchGroup] = [...groups.entries()]
|
||||
.sort((a, b) => b[1].length - a[1].length || a[0].localeCompare(b[0]))[0];
|
||||
const batch = batchGroup.slice(0, maxConcurrent);
|
||||
console.log(`[auto-trade] ${laneName(lane)}: ${allEligible.length} eligible account(s) across ${groups.size} prefix group(s) — firing batch of ${batch.length} ${batchPrefix || '(no prefix)'}`);
|
||||
const ordered = [...groups.entries()].sort((a, b) => b[1].length - a[1].length || a[0].localeCompare(b[0]));
|
||||
|
||||
let batch: EligibleItem[] = [];
|
||||
let batchPrefix = '';
|
||||
let resolvedSymbol = '';
|
||||
for (const [prefix, members] of ordered) {
|
||||
const candidates = members.slice(0, maxConcurrent);
|
||||
const lead = candidates.find((it) => !it.isExtraDay && !it.drain);
|
||||
const firms = [...new Map(candidates.map((it) => [it.firm.id, it])).values()];
|
||||
|
||||
if (futuresLane) {
|
||||
// The group's firms must all list the root: their lists' overlap,
|
||||
// or — if that's empty — the lead firm's accounts alone.
|
||||
let pool = candidates;
|
||||
let allowed = firms.map((it) => firmInstruments(it.firm)).reduce((a, b) => a.filter((r) => b.includes(r)));
|
||||
if (allowed.length === 0) {
|
||||
const leadFirm = (lead ?? candidates[0]).firm.id;
|
||||
pool = candidates.filter((it) => it.firm.id === leadFirm);
|
||||
allowed = firmInstruments(pool[0].firm);
|
||||
}
|
||||
const shape = lead ? legShapeFor(lead, tradeDate) : null;
|
||||
const tried: string[] = [];
|
||||
for (let attempt = 0; attempt < 3 && batch.length === 0; attempt++) {
|
||||
const root = explicit ?? await drawFuturesInstrument(allowed.filter((r) => !tried.includes(r)), shape);
|
||||
if (!root) break;
|
||||
tried.push(root);
|
||||
const resolved: EligibleItem[] = [];
|
||||
for (const { firm, client } of [...new Map(pool.map((it) => [it.firm.id, it])).values()]) {
|
||||
try {
|
||||
const contract = await client.findFrontMonthContract(root);
|
||||
const pv = contract?.pointValue ?? POINT_VALUES[root];
|
||||
if (!contract || !pv) {
|
||||
console.log(`[auto-trade] ${firm.name}: no ${root} contract — leaving its accounts out`);
|
||||
continue;
|
||||
}
|
||||
resolved.push(...pool.filter((it) => it.firm.id === firm.id).map((it) => ({ ...it, contract, pv })));
|
||||
} catch (err) {
|
||||
console.error(`[auto-trade] ${firm.name}: ${root} contract lookup failed — ${errorMessage(err)}`);
|
||||
}
|
||||
}
|
||||
if (resolved.length > 0) {
|
||||
batch = resolved;
|
||||
resolvedSymbol = root;
|
||||
}
|
||||
if (explicit) break;
|
||||
}
|
||||
} else {
|
||||
// A DXtrade lane: one firm, its list ranked for the group's leg.
|
||||
const { client, firm } = candidates[0];
|
||||
try {
|
||||
const contract = await client.findFrontMonthContract('*', pickContextFor(candidates.filter((it) => !it.isExtraDay && !it.drain), tradeDate));
|
||||
const pv = contract?.pointValue;
|
||||
if (contract && pv) {
|
||||
batch = candidates.map((it) => ({ ...it, contract, pv }));
|
||||
resolvedSymbol = String(contract.id);
|
||||
} else {
|
||||
console.log(`[auto-trade] ${firm.name}: nothing on its list can trade right now`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[auto-trade] ${firm.name}: instrument pick failed — ${errorMessage(err)}`);
|
||||
}
|
||||
}
|
||||
if (batch.length > 0) {
|
||||
batchPrefix = prefix;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (batch.length === 0) {
|
||||
console.log(`[auto-trade] ${laneName(lane)}: nothing tradable for any eligible group`);
|
||||
return { outcome: 'no-eligible', results: [] };
|
||||
}
|
||||
|
||||
// Choosing took so long that another batch presumed this one wedged and
|
||||
// took the mutex. That batch traded on fresher numbers than these, so
|
||||
// firing now could trade an account twice.
|
||||
if (!holdsTradeMutex(lease, lane)) {
|
||||
console.warn('[auto-trade] superseded while collecting accounts — abandoning this batch');
|
||||
return { outcome: 'skipped-in-flight', results: [] };
|
||||
}
|
||||
|
||||
console.log(`[auto-trade] ${laneName(lane)}: ${allEligible.length} eligible account(s) across ${groups.size} prefix group(s) — firing batch of ${batch.length} ${batchPrefix || '(no prefix)'} on ${resolvedSymbol}`);
|
||||
runtime().lastFireAt = Date.now();
|
||||
|
||||
// ── Phase 2: fire the batch simultaneously ──
|
||||
@@ -889,10 +987,12 @@ async function runTradeLocked(
|
||||
const { client, acc } = item;
|
||||
|
||||
// Extra-day mode: profit target already met, no min day P&L, days still needed.
|
||||
// Just trade 1 MNQ in and out at market immediately — P&L doesn't matter.
|
||||
// Just trade 1 micro (MNQ for a firm listing NQ) in and out at market
|
||||
// immediately — P&L doesn't matter.
|
||||
if (item.isExtraDay) {
|
||||
const mnqContract = await client.findFrontMonthContract('MNQ');
|
||||
if (!mnqContract) throw new Error('MNQ contract not found for extra-day trade');
|
||||
const microRoot = housekeepingFor(item.firm);
|
||||
const mnqContract = await client.findFrontMonthContract(microRoot);
|
||||
if (!mnqContract) throw new Error(`${microRoot} contract not found for extra-day trade`);
|
||||
|
||||
const fill = await client.sendOrder(acc.id, mnqContract.name, 1, resolvedAction, 'Market');
|
||||
const exitAction: 'Buy' | 'Sell' = resolvedAction === 'Buy' ? 'Sell' : 'Buy';
|
||||
@@ -1082,7 +1182,7 @@ async function fireLeg(
|
||||
let contracts: number;
|
||||
let plan: ReturnType<typeof sizeLeg>;
|
||||
try {
|
||||
const inst = await legInstrument(client, item.drain, item.contract, item.pv);
|
||||
const inst = await legInstrument(client, item.firm, item.drain, item.contract, item.pv);
|
||||
legContract = inst.contract;
|
||||
pv = inst.pointValue;
|
||||
plan = sizeLeg(item, legContract, legAmount, tradeDate);
|
||||
@@ -1130,6 +1230,7 @@ async function fireLeg(
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
const updatedCash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 };
|
||||
measured = entryCommissionMeasured(item.cash.realizedPnL, updatedCash.realizedPnL, contracts);
|
||||
if (measured !== null && !item.drain) recordMeasuredFee(item.firm.id, symbolLabel, measured / contracts);
|
||||
entryFee = measured ?? FALLBACK_COMMISSION_PER_CONTRACT * contracts;
|
||||
}
|
||||
// The stop. On a measured-fee firm whose fee didn't show, the daily-loss
|
||||
@@ -1297,6 +1398,7 @@ async function copyTradeLocked(lease: number, lane: string): Promise<unknown[]>
|
||||
}
|
||||
}
|
||||
}
|
||||
const futuresLane = lane === FUTURES_LANE;
|
||||
|
||||
if (!resolvedAction || positionedCount === 0) {
|
||||
console.log('[copy-trade] no open positions to copy from');
|
||||
@@ -1304,33 +1406,23 @@ async function copyTradeLocked(lease: number, lane: string): Promise<unknown[]>
|
||||
}
|
||||
console.log(`[copy-trade] found ${positionedCount} open position(s), action=${resolvedAction}, contractId=${positionContractId}`);
|
||||
|
||||
// Resolve symbol from the positioned contract's contractId — only check the client that
|
||||
// actually has the position. Iterating every firm × every symbol can take 30+ seconds.
|
||||
// Which instrument is held — looked up only on the client that holds it
|
||||
// (iterating every firm × every symbol can take 30+ seconds), among the
|
||||
// roots the lane's firms list plus their drain micros. A DXtrade client
|
||||
// answers '*' with the symbol it holds. Unidentified means no copy: a
|
||||
// guess could put the direction on a different instrument.
|
||||
let resolvedSymbol: string | null = null;
|
||||
const instruments = getInstruments();
|
||||
const enabledSymbols = instruments.filter(i => i.enabled).map(i => i.symbol);
|
||||
|
||||
if (positionedClient) {
|
||||
for (const sym of enabledSymbols) {
|
||||
// A lookup that throws rules out that one symbol, not the copy.
|
||||
try {
|
||||
const contract = await positionedClient.findFrontMonthContract(sym);
|
||||
if (contract && contract.id === positionContractId) {
|
||||
resolvedSymbol = sym;
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[copy-trade] ${sym}: contract lookup failed — ${errorMessage(err)}`);
|
||||
}
|
||||
}
|
||||
if (positionedClient && positionContractId !== null) {
|
||||
const roots = futuresLane
|
||||
? [...new Set(ready.flatMap(({ firm }) => [...firmInstruments(firm), housekeepingFor(firm)]))]
|
||||
: ['*'];
|
||||
resolvedSymbol = await heldRoot(positionedClient, positionContractId, roots);
|
||||
}
|
||||
console.log(`[copy-trade] resolved symbol: ${resolvedSymbol ?? '(none, using fallback)'}`);
|
||||
|
||||
// Fall back to scheduler symbol or first enabled
|
||||
if (!resolvedSymbol) {
|
||||
const state = getState();
|
||||
resolvedSymbol = state.symbol !== 'Auto' ? state.symbol : (enabledSymbols[0] ?? 'NQ');
|
||||
console.log(`[copy-trade] can't tell which instrument contract ${positionContractId} is — not copying`);
|
||||
return [];
|
||||
}
|
||||
console.log(`[copy-trade] resolved symbol: ${resolvedSymbol}`);
|
||||
const basePointValue: number | undefined = POINT_VALUES[resolvedSymbol];
|
||||
|
||||
const slotsAvailable = maxConcurrent - positionedCount;
|
||||
@@ -1349,8 +1441,8 @@ async function copyTradeLocked(lease: number, lane: string): Promise<unknown[]>
|
||||
console.log(`[copy-trade] ${firm.name}: empty account list — skip firm`);
|
||||
return;
|
||||
}
|
||||
if (firm.bannedSymbols.includes(resolvedSymbol!)) {
|
||||
console.log(`[copy-trade] ${firm.name}: ${resolvedSymbol} is banned — skip firm`);
|
||||
if (futuresLane && !firmInstruments(firm).includes(resolvedSymbol!)) {
|
||||
console.log(`[copy-trade] ${firm.name}: ${resolvedSymbol} isn't on its instrument list — skip firm`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1478,14 +1570,26 @@ export async function fireManualLeg(accountId: number, action: 'Buy' | 'Sell' |
|
||||
if (heldSide && action !== 'Auto' && action !== heldSide) {
|
||||
throw new Error(`the ${laneName(lane)} lane is ${heldSide === 'Buy' ? 'long' : 'short'} — a ${action} would hedge it`);
|
||||
}
|
||||
const state = getState();
|
||||
const symbol = state.symbol !== 'Auto' ? state.symbol : 'NQ';
|
||||
const resolvedAction: 'Buy' | 'Sell' = heldSide ?? (action === 'Auto' ? (Math.random() < 0.5 ? 'Buy' : 'Sell') : action);
|
||||
const sizingItem: SizingItem = {
|
||||
firmName: firm.name, firm, client, acc,
|
||||
cash: ev.cash, dailyPnL: ev.dailyPnL, plan: ev.plan, drain: ev.drain,
|
||||
prefix: cfg.prefix, dllRoom: room,
|
||||
};
|
||||
// The instrument: a DXtrade firm ranks its list; a futures firm joins the
|
||||
// held root, else trades an explicit scheduler symbol it lists, else
|
||||
// draws from its list.
|
||||
let symbol = '*';
|
||||
if (firm.platform !== 'dxtrade') {
|
||||
const listed = firmInstruments(firm);
|
||||
const state = getState();
|
||||
const found = held.length > 0 ? await heldRoot(client, held[0].contractId, [...listed, housekeepingFor(firm)]) : null;
|
||||
const drawn = found ?? (state.symbol !== 'Auto' && listed.includes(state.symbol)
|
||||
? state.symbol
|
||||
: await drawFuturesInstrument(listed, legShapeFor(sizingItem, tradeDate)));
|
||||
if (!drawn) throw new Error(held.length > 0 ? `can't tell which listed instrument the lane holds` : 'nothing on the firm\'s list fits right now');
|
||||
symbol = drawn;
|
||||
}
|
||||
const contract = await client.findFrontMonthContract(symbol, ev.drain ? undefined : pickContextFor([sizingItem], tradeDate));
|
||||
if (!contract) throw new Error(`no instrument for ${symbol}`);
|
||||
if (held.length > 0 && String(contract.id) !== [...heldContracts][0]) {
|
||||
@@ -1574,7 +1678,7 @@ const _global = globalThis as typeof globalThis & { __autoTrader?: SchedulerStat
|
||||
|
||||
function getState(): SchedulerState {
|
||||
if (!_global.__autoTrader) {
|
||||
_global.__autoTrader = { action: 'Buy', symbol: 'NQ', intervalId: null, lastRun: null, running: false, stopAfterAll: false };
|
||||
_global.__autoTrader = { action: 'Buy', symbol: 'Auto', intervalId: null, lastRun: null, running: false, stopAfterAll: false };
|
||||
}
|
||||
return _global.__autoTrader;
|
||||
}
|
||||
@@ -1723,7 +1827,7 @@ export function resumeSchedulerIfPersisted(): void {
|
||||
if (getSetting('scheduler_running') !== '1') return;
|
||||
|
||||
const action = (getSetting('scheduler_action') ?? 'Buy') as 'Buy' | 'Sell' | 'Auto';
|
||||
const symbol = getSetting('scheduler_symbol') ?? 'NQ';
|
||||
const symbol = getSetting('scheduler_symbol') ?? 'Auto';
|
||||
const stopAfterAll = getSetting('scheduler_stop_after_all') === '1';
|
||||
|
||||
console.log(`[scheduler] resuming persisted schedule — ${action} ${symbol}`);
|
||||
|
||||
+23
-3
@@ -3,8 +3,9 @@ import { TopstepXClient } from './topstepx-class';
|
||||
import { DXTradeClient } from './dxtrade-class';
|
||||
import type { FirmPlatform } from './master-protocol';
|
||||
import { errorMessage, isClientReady, type PlatformClient } from './platform-client';
|
||||
import { getInstruments } from './db';
|
||||
import { getTradableFirms, startCatalogSync } from './firm-catalog';
|
||||
import { firmInstruments, isFuturesFirm, prewarmMarket } from './instruments';
|
||||
import { housekeepingRoot } from './instrument-specs';
|
||||
import { resolveFirmAssignments } from './account-type-resolver';
|
||||
import { resolveContracts } from './contract-resolver';
|
||||
import { startReporter, sendReport } from './reporter';
|
||||
@@ -27,6 +28,7 @@ const g = global as typeof globalThis & {
|
||||
__contractResolverTimer?: ReturnType<typeof setInterval>;
|
||||
__contractResolveRetryTimer?: ReturnType<typeof setTimeout>;
|
||||
__contractResolveRetries?: number;
|
||||
__contractResolvedRoots?: Set<string>;
|
||||
__clientSupervisorTimer?: ReturnType<typeof setInterval>;
|
||||
__clientSupervision?: Map<string, SupervisionEntry>;
|
||||
};
|
||||
@@ -191,6 +193,8 @@ export function getClients(): Map<string, PlatformClient> {
|
||||
// sitting Unassigned without waiting for a Tradovate sync.
|
||||
startCatalogSync(() => {
|
||||
reconcileClients();
|
||||
// A newly listed root needs its front-month contract.
|
||||
if (resolveRootsHere().some((r) => !g.__contractResolvedRoots?.has(r))) triggerContractResolve();
|
||||
for (const [firmId, client] of ensureMap()) {
|
||||
try {
|
||||
resolveFirmAssignments(firmId, client);
|
||||
@@ -203,8 +207,10 @@ export function getClients(): Map<string, PlatformClient> {
|
||||
reconcileClients();
|
||||
console.log(`[clients] Initialized ${map.size} platform client(s)`);
|
||||
|
||||
// Auto-resolve contracts after clients have time to authenticate
|
||||
// Auto-resolve contracts after clients have time to authenticate,
|
||||
// and measure every future's movement for the Auto draw.
|
||||
setTimeout(() => triggerContractResolve(), 15_000);
|
||||
prewarmMarket();
|
||||
|
||||
// Schedule daily resolve at midnight
|
||||
scheduleDailyResolve();
|
||||
@@ -242,6 +248,18 @@ export function getClients(): Map<string, PlatformClient> {
|
||||
const CONTRACT_RESOLVE_RETRY_MS = 60_000;
|
||||
const CONTRACT_RESOLVE_MAX_RETRIES = 30;
|
||||
|
||||
/** Every root a futures firm here lists, plus each one's drain micro. */
|
||||
function resolveRootsHere(): string[] {
|
||||
const roots = new Set<string>();
|
||||
for (const firm of getTradableFirms().filter(isFuturesFirm)) {
|
||||
const listed = firmInstruments(firm);
|
||||
for (const r of listed) roots.add(r);
|
||||
const micro = housekeepingRoot(listed);
|
||||
if (micro) roots.add(micro);
|
||||
}
|
||||
return [...roots];
|
||||
}
|
||||
|
||||
function triggerContractResolve(): void {
|
||||
// A midnight run supersedes a pending retry; either way only one chain.
|
||||
if (g.__contractResolveRetryTimer) clearTimeout(g.__contractResolveRetryTimer);
|
||||
@@ -284,7 +302,8 @@ function triggerContractResolve(): void {
|
||||
// Runs from timers: a throw here (the instruments read) would escape as
|
||||
// an uncaught exception.
|
||||
try {
|
||||
const symbols = getInstruments().filter((i) => i.enabled).map((i) => i.symbol);
|
||||
const symbols = resolveRootsHere();
|
||||
g.__contractResolvedRoots = new Set(symbols);
|
||||
console.log(`[contract-resolver] Resolving ${symbols.length} instruments...`);
|
||||
resolveContracts(symbols, accessToken)
|
||||
.then((results) => {
|
||||
@@ -309,6 +328,7 @@ function scheduleDailyResolve(): void {
|
||||
// Its own half hour of retries, not what's left of a chain it cuts off.
|
||||
g.__contractResolveRetries = 0;
|
||||
triggerContractResolve();
|
||||
prewarmMarket();
|
||||
}
|
||||
}, 60_000);
|
||||
}
|
||||
|
||||
+16
-15
@@ -14,9 +14,9 @@
|
||||
* Differences from the futures platforms that shape this file:
|
||||
* - Account codes are strings ("clearing:account"); ids are a stable hash of
|
||||
* them (dxAccountId) so every numeric account table keeps working.
|
||||
* - There are no CME products. The firm's symbol map (master Firms page) says
|
||||
* which DXtrade symbols a CME root may trade as, and findFrontMonthContract
|
||||
* picks the cheapest per unit of volatility right now (scoreCandidate).
|
||||
* - There are no CME products. The firm's instrument list (master Firms page)
|
||||
* says which DXtrade symbols it trades, whatever root a batch drew, and
|
||||
* findFrontMonthContract picks among them (rankForTarget / scoreCandidate).
|
||||
* One engine "contract" is one quantity step of the symbol, and the step
|
||||
* rides in the contract name, so the engine's target/stop math is unchanged.
|
||||
* - Quantities are units, and positions are closed by positionCode
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
} from './db';
|
||||
import { getAccountConfig } from './account-type-resolver';
|
||||
import { getCachedFirm } from './firm-catalog';
|
||||
import { firmInstruments } from './instruments';
|
||||
import type { SymbolMapCandidate } from './master-protocol';
|
||||
import type { PlatformClient, PlatformAccount, PlatformContract, ClientHealth, PickContext } from './platform-client';
|
||||
import { OrderNotPlacedError, isOrderNotPlaced, errorMessage } from './platform-client';
|
||||
@@ -1162,24 +1163,26 @@ export class DXTradeClient implements PlatformClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* The contract a CME root trades as on this firm right now, from the
|
||||
* symbol map. With a pick context (the batch's leg), each candidate is
|
||||
* The contract this firm trades right now, from its instrument list —
|
||||
* the root is ignored. With a pick context (the batch's leg), each candidate is
|
||||
* sized as the leg would be — at its own leverage — and the cheapest one
|
||||
* that reaches the target hold wins (else the quickest); without one, the
|
||||
* cheapest per unit of volatility (see scoreCandidate). Sticky while an
|
||||
* account holds one of the candidates; a batch's pick is reused briefly
|
||||
* so its other lookups (drain legs) agree.
|
||||
*/
|
||||
public async findFrontMonthContract(root: string, ctx?: PickContext): Promise<PlatformContract | null> {
|
||||
public async findFrontMonthContract(_root: string, ctx?: PickContext): Promise<PlatformContract | null> {
|
||||
if (!this.token) return null;
|
||||
// The firm's own list: whatever root the batch drew, a DXtrade firm
|
||||
// chooses among its instruments. One cache key for every root.
|
||||
const root = '*';
|
||||
const firm = getCachedFirm(this.cfg.firmId);
|
||||
const map = firm?.symbolMap ?? {};
|
||||
const candidates = map[root.toUpperCase()] ?? map['*'] ?? [];
|
||||
const candidates: SymbolMapCandidate[] = firm ? firmInstruments(firm).map((symbol) => ({ symbol })) : [];
|
||||
const now = Date.now();
|
||||
if (candidates.length === 0) {
|
||||
if (now - (this.unmappedLoggedAt.get(root) ?? 0) > 10 * 60_000) {
|
||||
this.unmappedLoggedAt.set(root, now);
|
||||
console.log(`${this.tag} no symbol mapped for ${root} (nor '*') — skipping; set the symbol map on the master Firms page`);
|
||||
console.log(`${this.tag} no instruments listed — skipping; add them on the master Firms page`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1194,14 +1197,12 @@ export class DXTradeClient implements PlatformClient {
|
||||
return pick;
|
||||
}
|
||||
|
||||
/** Every mapped symbol, looked at afresh whatever the accounts hold: the
|
||||
/** Every listed symbol, looked at afresh whatever the accounts hold: the
|
||||
* day's volatility, and the candidates the dashboard ranks. */
|
||||
private async surveyCandidates(): Promise<void> {
|
||||
const all = new Map<string, SymbolMapCandidate>();
|
||||
for (const list of Object.values(getCachedFirm(this.cfg.firmId)?.symbolMap ?? {})) {
|
||||
for (const c of list) if (!all.has(c.symbol)) all.set(c.symbol, c);
|
||||
}
|
||||
if (all.size > 0) await this.pickCandidate('survey', [...all.values()], false);
|
||||
const firm = getCachedFirm(this.cfg.firmId);
|
||||
const list = firm ? firmInstruments(firm) : [];
|
||||
if (list.length > 0) await this.pickCandidate('survey', list.map((symbol) => ({ symbol })), false);
|
||||
}
|
||||
|
||||
private async pickCandidate(root: string, candidates: SymbolMapCandidate[], sticky: boolean, ctx?: PickContext): Promise<PlatformContract | null> {
|
||||
|
||||
+11
-1
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
|
||||
import { isFirmPlatform } from './master-protocol';
|
||||
import { sanitizeInstruments } from './instrument-specs';
|
||||
import type {
|
||||
CatalogAccountType, CatalogFirm, FirmCatalogPayload, FirmPlatform, SymbolMap, SymbolMapCandidate,
|
||||
} from './master-protocol';
|
||||
@@ -241,6 +242,9 @@ function validatePayload(raw: unknown): FirmCatalogPayload {
|
||||
id: firm.id,
|
||||
name: firm.name,
|
||||
platform,
|
||||
// The master's list when it sends one; absent from an older
|
||||
// master, whose bannedSymbols / symbolMap then apply.
|
||||
...(Array.isArray(firm.instruments) ? { instruments: sanitizeInstruments(platform, firm.instruments) } : {}),
|
||||
bannedSymbols: Array.isArray(firm.bannedSymbols) ? (firm.bannedSymbols as string[]) : [],
|
||||
accountTypes,
|
||||
serverUrl: typeof firm.serverUrl === 'string' ? firm.serverUrl : '',
|
||||
@@ -302,8 +306,12 @@ export async function syncCatalog(): Promise<{ ok: boolean; changed: boolean; ve
|
||||
// Only the URL is required — deliberately not instance_name, unlike the
|
||||
// reporter. A freshly imaged machine has to be able to pull its catalog
|
||||
// before anyone has got round to naming it.
|
||||
// `since` only against the master the cache came from: two masters'
|
||||
// version numbers can collide, and an 'unchanged' from the wrong one
|
||||
// would keep the other's catalog.
|
||||
const hasCache = getCachedCatalog().length > 0;
|
||||
if (hasCache) url.searchParams.set('since', String(version));
|
||||
const source = url.origin;
|
||||
if (hasCache && getSetting('catalog_source_url') === source) url.searchParams.set('since', String(version));
|
||||
|
||||
try {
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
||||
@@ -311,6 +319,7 @@ export async function syncCatalog(): Promise<{ ok: boolean; changed: boolean; ve
|
||||
const body = await res.json();
|
||||
|
||||
if (body && typeof body === 'object' && (body as Record<string, unknown>).unchanged === true) {
|
||||
setSetting('catalog_source_url', source);
|
||||
setSetting('catalog_last_sync_at', new Date().toISOString());
|
||||
setSetting('catalog_last_error', '');
|
||||
return { ok: true, changed: false, version };
|
||||
@@ -342,6 +351,7 @@ export async function syncCatalog(): Promise<{ ok: boolean; changed: boolean; ve
|
||||
|
||||
setSetting('catalog_payload', JSON.stringify(payload));
|
||||
setSetting('catalog_version', String(payload.version));
|
||||
setSetting('catalog_source_url', source);
|
||||
setSetting('catalog_updated_at', payload.updatedAt);
|
||||
setSetting('catalog_last_sync_at', new Date().toISOString());
|
||||
setSetting('catalog_last_error', suspicious.length > 0
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* Contract specs for the CME futures the bot trades, and the pure maths of
|
||||
* choosing between them — the session-aware Auto draw, the 12-hour hold cap,
|
||||
* the legs that always trade a micro — plus reading a firm's instrument list
|
||||
* off the catalog.
|
||||
*
|
||||
* Specs live here, not on the master: point value and tick size size real
|
||||
* orders, so they aren't editable remotely. The master only knows the names.
|
||||
*
|
||||
* No imports: scripts/check-instruments.mjs loads this file directly.
|
||||
*/
|
||||
|
||||
export interface CmeSpec {
|
||||
name: string;
|
||||
/** Dollars per 1.0 of price, per contract. */
|
||||
pointValue: number;
|
||||
tickSize: number;
|
||||
/** Typical bid/ask spread in ticks. There are no live futures quotes, and
|
||||
* a liquid front month is one tick wide. */
|
||||
spreadTicks: number;
|
||||
/** Yahoo's continuous-contract ticker, for hourly candles. */
|
||||
yahoo: string;
|
||||
micro?: boolean;
|
||||
}
|
||||
|
||||
const spec = (name: string, pointValue: number, tickSize: number, yahoo: string, micro = false): CmeSpec =>
|
||||
({ name, pointValue, tickSize, spreadTicks: 1, yahoo, ...(micro ? { micro } : {}) });
|
||||
|
||||
export const CME_SPECS: Readonly<Record<string, CmeSpec>> = {
|
||||
NQ: spec('Nasdaq-100', 20, 0.25, 'NQ=F'),
|
||||
MNQ: spec('Micro Nasdaq-100', 2, 0.25, 'MNQ=F', true),
|
||||
ES: spec('S&P 500', 50, 0.25, 'ES=F'),
|
||||
MES: spec('Micro S&P 500', 5, 0.25, 'MES=F', true),
|
||||
YM: spec('Dow', 5, 1, 'YM=F'),
|
||||
MYM: spec('Micro Dow', 0.5, 1, 'MYM=F', true),
|
||||
RTY: spec('Russell 2000', 50, 0.1, 'RTY=F'),
|
||||
M2K: spec('Micro Russell 2000', 5, 0.1, 'M2K=F', true),
|
||||
GC: spec('Gold', 100, 0.1, 'GC=F'),
|
||||
MGC: spec('Micro Gold', 10, 0.1, 'MGC=F', true),
|
||||
SI: spec('Silver', 5000, 0.005, 'SI=F'),
|
||||
CL: spec('Crude Oil', 1000, 0.01, 'CL=F'),
|
||||
MCL: spec('Micro Crude Oil', 100, 0.01, 'MCL=F', true),
|
||||
NG: spec('Natural Gas', 10000, 0.001, 'NG=F'),
|
||||
ZB: spec('30-Year T-Bond', 1000, 1 / 32, 'ZB=F'),
|
||||
ZN: spec('10-Year T-Note', 1000, 1 / 64, 'ZN=F'),
|
||||
ZF: spec('5-Year T-Note', 1000, 1 / 128, 'ZF=F'),
|
||||
'6E': spec('Euro FX', 125000, 0.00005, '6E=F'),
|
||||
'6J': spec('Japanese Yen', 12500000, 0.0000005, '6J=F'),
|
||||
'6B': spec('British Pound', 62500, 0.0001, '6B=F'),
|
||||
};
|
||||
|
||||
export const CME_ROOTS: readonly string[] = Object.keys(CME_SPECS);
|
||||
|
||||
/** $ per point, per contract — the table the engine has always used. */
|
||||
export const POINT_VALUES: Readonly<Record<string, number>> = Object.fromEntries(
|
||||
CME_ROOTS.map((root) => [root, CME_SPECS[root].pointValue]),
|
||||
);
|
||||
|
||||
/** Each full-size contract's micro. */
|
||||
export const MICRO_OF: Readonly<Record<string, string>> = { NQ: 'MNQ', ES: 'MES', YM: 'MYM', RTY: 'M2K', GC: 'MGC', CL: 'MCL' };
|
||||
|
||||
export function tickValue(root: string): number {
|
||||
const s = CME_SPECS[root];
|
||||
return s ? s.tickSize * s.pointValue : NaN;
|
||||
}
|
||||
|
||||
/** One contract crossing the typical spread, in dollars. */
|
||||
export function spreadDollars(root: string): number {
|
||||
const s = CME_SPECS[root];
|
||||
return s ? s.spreadTicks * s.tickSize * s.pointValue : NaN;
|
||||
}
|
||||
|
||||
/** Hold cap: an instrument whose next leg is expected to take this long or
|
||||
* longer is out of the draw. */
|
||||
export const MAX_HOLD_HOURS = 12;
|
||||
/** Every drawable instrument's minimum share at the session's open; it fades
|
||||
* to nothing by the last entry. */
|
||||
export const DRAW_FLOOR = 0.10;
|
||||
/** How hard the end of the session leans on the fastest instruments. */
|
||||
export const LATE_SHARPNESS = 8;
|
||||
/** Hourly bars in a CME trading day (23 of 24 hours trade). */
|
||||
export const BARS_PER_DAY = 23;
|
||||
const MIN_CLOSES = 25;
|
||||
|
||||
/**
|
||||
* One contract's typical daily move (1σ), in dollars, from hourly closes
|
||||
* oldest first: σ of the hourly log returns × √(bars per day) × the last
|
||||
* price × the point value. null on too few closes.
|
||||
*/
|
||||
export function dailyMoveDollars(closes: number[], pointValue: number, barsPerDay = BARS_PER_DAY): number | null {
|
||||
const clean = closes.filter((c) => Number.isFinite(c) && c > 0);
|
||||
if (clean.length < MIN_CLOSES || !(pointValue > 0)) return null;
|
||||
const r: number[] = [];
|
||||
for (let i = 1; i < clean.length; i++) r.push(Math.log(clean[i] / clean[i - 1]));
|
||||
const mean = r.reduce((s, x) => s + x, 0) / r.length;
|
||||
const sd = Math.sqrt(r.reduce((s, x) => s + (x - mean) ** 2, 0) / (r.length - 1));
|
||||
return sd * Math.sqrt(barsPerDay) * clean[clean.length - 1] * pointValue;
|
||||
}
|
||||
|
||||
/** Contracts a futures leg trades: one per $1,000, capped by the account
|
||||
* type's max contracts (0 = no cap) — the rule fireLeg sizes by. */
|
||||
export function futuresContracts(leg: number, maxPositionSize: number): number {
|
||||
const raw = Math.max(1, Math.ceil(leg / 1000));
|
||||
return maxPositionSize > 0 ? Math.min(raw, maxPositionSize) : raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expected hours for a leg to reach its take-profit or stop, as a fair walk:
|
||||
* (A / move)(B / move) days, with the move scaled by the contracts. A
|
||||
* stopless leg is taken as B = A. null without a move.
|
||||
*/
|
||||
export function expectedHoldHours(o: { leg: number; stopDollars: number; contracts: number; dailyMove: number | null }): number | null {
|
||||
if (o.dailyMove === null || !(o.dailyMove > 0) || !(o.leg > 0)) return null;
|
||||
const move = o.dailyMove * Math.max(1, o.contracts);
|
||||
const b = Number.isFinite(o.stopDollars) && o.stopDollars > 0 ? o.stopDollars : o.leg;
|
||||
return (o.leg / move) * (b / move) * 24;
|
||||
}
|
||||
|
||||
function centralMinutes(now: Date): { day: string; minutes: number } {
|
||||
const parts = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: 'America/Chicago', weekday: 'short', hour: '2-digit', minute: '2-digit', hourCycle: 'h23',
|
||||
}).formatToParts(now);
|
||||
const get = (type: string) => parts.find((p) => p.type === type)?.value ?? '';
|
||||
return { day: get('weekday'), minutes: (parseInt(get('hour'), 10) % 24) * 60 + parseInt(get('minute'), 10) };
|
||||
}
|
||||
|
||||
/**
|
||||
* How far through the trading session `now` is: 0 at the open, 1 at the last
|
||||
* entry. full_cme runs 17:05 → 14:55 CT the next day; equity_hours 8:35 →
|
||||
* 14:55. null outside the session.
|
||||
*/
|
||||
export function sessionProgress(now: Date, mode: string): number | null {
|
||||
const { day, minutes } = centralMinutes(now);
|
||||
if (mode === 'equity_hours') {
|
||||
if (day === 'Sat' || day === 'Sun' || minutes < 515 || minutes > 895) return null;
|
||||
return (minutes - 515) / (895 - 515);
|
||||
}
|
||||
const open = 17 * 60 + 5;
|
||||
const length = 24 * 60 - open + 14 * 60 + 55; // 21h50m
|
||||
const since = (minutes - open + 1440) % 1440;
|
||||
if (since > length) return null;
|
||||
if (day === 'Sat' || (day === 'Fri' && minutes > 895) || (day === 'Sun' && minutes < open)) return null;
|
||||
return since / length;
|
||||
}
|
||||
|
||||
export interface DrawCandidate {
|
||||
symbol: string;
|
||||
spreadDollars: number;
|
||||
/** One contract's daily move in dollars; null = not measured. */
|
||||
dailyMove: number | null;
|
||||
/** The next leg's expected hold on it; null = unknown. */
|
||||
holdHours: number | null;
|
||||
micro?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Each instrument's share of the Auto draw at session progress `p`.
|
||||
*
|
||||
* Efficiency = daily move ÷ spread: movement per dollar paid to cross the
|
||||
* spread (commissions don't count). Speed ∝ move², since a fair walk's hold
|
||||
* time goes with 1/move². With q = p², the weight slides from efficiency
|
||||
* early to a sharp preference for the fastest late:
|
||||
* weight = efficiency^(1−q) × move^(sharpness·q)
|
||||
* and every drawable instrument gets a floor of DRAW_FLOOR × (1−q).
|
||||
*
|
||||
* Out of the draw: anything whose expected hold is MAX_HOLD_HOURS or more
|
||||
* (`disabled`), and micros unless nothing else is left. An unmeasured
|
||||
* instrument gets the median weight; if none are measured, the draw is even.
|
||||
*/
|
||||
export function drawChances(
|
||||
candidates: DrawCandidate[],
|
||||
p: number | null,
|
||||
opts: { floor?: number; sharpness?: number; maxHoldHours?: number } = {},
|
||||
): { chances: { symbol: string; chance: number }[]; disabled: string[] } {
|
||||
const floorBase = opts.floor ?? DRAW_FLOOR;
|
||||
const sharpness = opts.sharpness ?? LATE_SHARPNESS;
|
||||
const maxHold = opts.maxHoldHours ?? MAX_HOLD_HOURS;
|
||||
const progress = p === null ? 0 : Math.min(1, Math.max(0, p));
|
||||
const q = progress * progress;
|
||||
|
||||
const disabled = candidates.filter((c) => c.holdHours !== null && c.holdHours >= maxHold).map((c) => c.symbol);
|
||||
let pool = candidates.filter((c) => !disabled.includes(c.symbol));
|
||||
if (pool.some((c) => !c.micro)) pool = pool.filter((c) => !c.micro);
|
||||
if (pool.length === 0) return { chances: [], disabled };
|
||||
|
||||
const logWeight = (c: DrawCandidate): number | null => (c.dailyMove !== null && c.dailyMove > 0 && c.spreadDollars > 0
|
||||
? (1 - q) * Math.log(c.dailyMove / c.spreadDollars) + sharpness * q * Math.log(c.dailyMove)
|
||||
: null);
|
||||
const measured = pool.map(logWeight).filter((w): w is number => w !== null).sort((a, b) => a - b);
|
||||
const median = measured.length === 0 ? 0 : measured.length % 2
|
||||
? measured[(measured.length - 1) / 2]
|
||||
: (measured[measured.length / 2 - 1] + measured[measured.length / 2]) / 2;
|
||||
const logs = pool.map((c) => logWeight(c) ?? median);
|
||||
const top = Math.max(...logs);
|
||||
const weights = logs.map((l) => Math.exp(l - top));
|
||||
const total = weights.reduce((s, w) => s + w, 0);
|
||||
const n = pool.length;
|
||||
const floor = Math.min(floorBase * (1 - q), 1 / n);
|
||||
return {
|
||||
chances: pool.map((c, i) => ({ symbol: c.symbol, chance: floor + (1 - n * floor) * (weights[i] / total) })),
|
||||
disabled,
|
||||
};
|
||||
}
|
||||
|
||||
/** The instrument a uniform random number in [0, 1) lands on. */
|
||||
export function pickByChance(chances: { symbol: string; chance: number }[], rand: number): string | null {
|
||||
let acc = 0;
|
||||
for (const c of chances) {
|
||||
acc += c.chance;
|
||||
if (rand < acc) return c.symbol;
|
||||
}
|
||||
return chances.length > 0 ? chances[chances.length - 1].symbol : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a firm's drain and extra-day legs trade — tiny, so a micro: MNQ when
|
||||
* NQ or MNQ is listed (as always), else the micro of a listed full-size
|
||||
* contract, else a listed micro, else the listed root with the smallest tick
|
||||
* value. null for an empty list.
|
||||
*/
|
||||
export function housekeepingRoot(listed: readonly string[]): string | null {
|
||||
const roots = listed.filter((r) => CME_SPECS[r]);
|
||||
if (roots.length === 0) return null;
|
||||
if (roots.includes('NQ') || roots.includes('MNQ')) return 'MNQ';
|
||||
const twin = roots.find((r) => MICRO_OF[r]);
|
||||
if (twin) return MICRO_OF[twin];
|
||||
const micro = roots.find((r) => CME_SPECS[r].micro);
|
||||
if (micro) return micro;
|
||||
return [...roots].sort((a, b) => tickValue(a) - tickValue(b))[0];
|
||||
}
|
||||
|
||||
const DXTRADE_SYMBOL = /^[A-Za-z0-9./_:-]{1,32}$/;
|
||||
|
||||
/** A catalog list, cleaned for the firm's family: CME roots this build has
|
||||
* specs for, or valid DXtrade symbols. Deduplicated, in order. */
|
||||
export function sanitizeInstruments(platform: string, raw: unknown): string[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const futures = platform !== 'dxtrade';
|
||||
const out: string[] = [];
|
||||
for (const value of raw) {
|
||||
if (typeof value !== 'string') continue;
|
||||
const symbol = futures ? value.trim().toUpperCase() : value.trim();
|
||||
const ok = futures ? Boolean(CME_SPECS[symbol]) : DXTRADE_SYMBOL.test(symbol);
|
||||
if (ok && !out.includes(symbol)) out.push(symbol);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The instruments a firm trades. The master's list when the catalog carries
|
||||
* one; otherwise (a master from before the lists) the old rules: a futures
|
||||
* firm trades this machine's enabled instruments less its banned symbols; a
|
||||
* DXtrade firm every symbol in its map, the '*' row first.
|
||||
*/
|
||||
export function resolveAllowlist(
|
||||
firm: { platform: string; instruments?: string[]; bannedSymbols?: string[]; symbolMap?: Record<string, { symbol: string }[]> },
|
||||
localEnabled: readonly string[],
|
||||
): { roots: string[]; source: 'master' | 'legacy' } {
|
||||
if (Array.isArray(firm.instruments)) return { roots: sanitizeInstruments(firm.platform, firm.instruments), source: 'master' };
|
||||
if (firm.platform === 'dxtrade') {
|
||||
const map = firm.symbolMap ?? {};
|
||||
const keys = Object.keys(map).sort((a, b) => (a === '*' ? -1 : b === '*' ? 1 : a.localeCompare(b)));
|
||||
return { roots: sanitizeInstruments('dxtrade', keys.flatMap((k) => (map[k] ?? []).map((c) => c.symbol))), source: 'legacy' };
|
||||
}
|
||||
const banned = new Set(firm.bannedSymbols ?? []);
|
||||
return { roots: sanitizeInstruments(firm.platform, localEnabled.filter((r) => !banned.has(r))), source: 'legacy' };
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* The instruments each firm trades, and the futures Auto draw — the one
|
||||
* module every trading path asks.
|
||||
*
|
||||
* A firm's list comes from the master's catalog (`instruments`); against a
|
||||
* master from before the lists, from the old rules (this machine's enabled
|
||||
* instruments less the firm's bans; a DXtrade firm's symbol map). Futures
|
||||
* "Auto" draws one listed root per batch, weighted by lib/instrument-specs'
|
||||
* drawChances: cheap-per-move with a floor early in the session, the fastest
|
||||
* near the last entry, nothing expected to hold 12 hours or more.
|
||||
*
|
||||
* Movement comes from Yahoo's hourly candles (7 days, measured once per trade
|
||||
* date); the spread is one tick. Fees are shown, never used to choose:
|
||||
* Tradovate's fee parameters, or TopstepX's last measured fill.
|
||||
*/
|
||||
|
||||
import axios from 'axios';
|
||||
import { getInstruments, getSetting } from './db';
|
||||
import { getTradableFirms } from './firm-catalog';
|
||||
import { getCachedContract } from './contract-resolver';
|
||||
import { currentTradeDateCT } from './trading-logic';
|
||||
import { errorMessage } from './platform-client';
|
||||
import type { InstrumentStats } from './master-protocol';
|
||||
import {
|
||||
CME_SPECS, CME_ROOTS, spreadDollars, dailyMoveDollars, futuresContracts, expectedHoldHours, sessionProgress,
|
||||
drawChances, pickByChance, resolveAllowlist, MAX_HOLD_HOURS, type DrawCandidate,
|
||||
} from './instrument-specs';
|
||||
|
||||
export interface FirmLike {
|
||||
id?: string;
|
||||
platform: string;
|
||||
instruments?: string[];
|
||||
bannedSymbols?: string[];
|
||||
symbolMap?: Record<string, { symbol: string }[]>;
|
||||
}
|
||||
|
||||
function localEnabled(): string[] {
|
||||
return getInstruments().filter((i) => i.enabled).map((i) => i.symbol);
|
||||
}
|
||||
|
||||
/** What the firm trades (see resolveAllowlist). */
|
||||
export function firmInstruments(firm: FirmLike): string[] {
|
||||
return resolveAllowlist(firm, localEnabled()).roots;
|
||||
}
|
||||
|
||||
/** Whether the list came from the master or the pre-list fallback. */
|
||||
export function instrumentsSource(firm: FirmLike): 'master' | 'legacy' {
|
||||
return resolveAllowlist(firm, localEnabled()).source;
|
||||
}
|
||||
|
||||
export function isFuturesFirm(firm: { platform: string }): boolean {
|
||||
return firm.platform !== 'dxtrade';
|
||||
}
|
||||
|
||||
/** Every root a futures firm on this machine lists, in CME order — what the
|
||||
* master's trade bar may send here. */
|
||||
export function futuresRootsHere(): string[] {
|
||||
const listed = new Set(getTradableFirms().filter(isFuturesFirm).flatMap((f) => firmInstruments(f)));
|
||||
return CME_ROOTS.filter((root) => listed.has(root));
|
||||
}
|
||||
|
||||
// ── market data (Yahoo hourly candles) ─────────────────────────────────────
|
||||
|
||||
interface Market {
|
||||
tradeDate: string;
|
||||
price: number | null;
|
||||
dailyMove: number | null;
|
||||
at: number;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const RETRY_MS = 15 * 60_000;
|
||||
const FETCH_TIMEOUT_MS = 10_000;
|
||||
const CONCURRENCY = 4;
|
||||
|
||||
const g = globalThis as typeof globalThis & {
|
||||
__futuresMarket?: Map<string, Market>;
|
||||
__futuresMarketInflight?: Map<string, Promise<void>>;
|
||||
__futuresFees?: Map<string, { tradeDate: string; fees: Record<string, number>; at: number }>;
|
||||
__futuresMeasuredFees?: Map<string, number>;
|
||||
};
|
||||
const market = (g.__futuresMarket ??= new Map());
|
||||
const inflight = (g.__futuresMarketInflight ??= new Map());
|
||||
|
||||
function isFresh(m: Market | undefined): boolean {
|
||||
if (!m || m.tradeDate !== currentTradeDateCT()) return false;
|
||||
return m.error === null || Date.now() - m.at < RETRY_MS;
|
||||
}
|
||||
|
||||
async function fetchMarket(root: string): Promise<void> {
|
||||
const spec = CME_SPECS[root];
|
||||
const tradeDate = currentTradeDateCT();
|
||||
try {
|
||||
const res = await axios.get(`https://query1.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(spec.yahoo)}`, {
|
||||
params: { interval: '60m', range: '7d' },
|
||||
headers: { 'User-Agent': 'Mozilla/5.0' },
|
||||
timeout: FETCH_TIMEOUT_MS,
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS + 5_000),
|
||||
});
|
||||
const raw: unknown[] = res.data?.chart?.result?.[0]?.indicators?.quote?.[0]?.close ?? [];
|
||||
const closes = raw.filter((c): c is number => typeof c === 'number' && Number.isFinite(c) && c > 0);
|
||||
const dailyMove = dailyMoveDollars(closes, spec.pointValue);
|
||||
market.set(root, {
|
||||
tradeDate, price: closes.at(-1) ?? null, dailyMove, at: Date.now(),
|
||||
error: dailyMove === null ? `only ${closes.length} hourly closes` : null,
|
||||
});
|
||||
} catch (err) {
|
||||
market.set(root, { tradeDate, price: null, dailyMove: null, at: Date.now(), error: errorMessage(err) });
|
||||
}
|
||||
}
|
||||
|
||||
/** Refresh whichever roots aren't measured for today, waiting at most
|
||||
* `deadlineMs` — a draw never stalls on Yahoo. */
|
||||
export async function ensureMarket(roots: readonly string[], deadlineMs = 4_000): Promise<void> {
|
||||
const stale = [...new Set(roots)].filter((r) => CME_SPECS[r] && !isFresh(market.get(r)));
|
||||
if (stale.length === 0) return;
|
||||
const run = (async () => {
|
||||
for (let i = 0; i < stale.length; i += CONCURRENCY) {
|
||||
await Promise.all(stale.slice(i, i + CONCURRENCY).map((root) => {
|
||||
let p = inflight.get(root);
|
||||
if (!p) {
|
||||
p = fetchMarket(root).finally(() => inflight.delete(root));
|
||||
inflight.set(root, p);
|
||||
}
|
||||
return p;
|
||||
}));
|
||||
}
|
||||
})();
|
||||
await Promise.race([run, new Promise((r) => setTimeout(r, deadlineMs))]);
|
||||
}
|
||||
|
||||
/** Measure every root in the background — at boot and at the midnight
|
||||
* contract resolve — so the stats cover unlisted instruments too. */
|
||||
export function prewarmMarket(): void {
|
||||
void ensureMarket(CME_ROOTS, 120_000).then(() => {
|
||||
const measured = CME_ROOTS.filter((r) => market.get(r)?.dailyMove != null).length;
|
||||
console.log(`[instruments] measured ${measured}/${CME_ROOTS.length} futures from hourly closes`);
|
||||
});
|
||||
}
|
||||
|
||||
export function marketOf(root: string): { price: number | null; dailyMove: number | null; error: string | null } | null {
|
||||
const m = market.get(root);
|
||||
return m ? { price: m.price, dailyMove: m.dailyMove, error: m.error } : null;
|
||||
}
|
||||
|
||||
// ── fees (shown, never used to choose) ─────────────────────────────────────
|
||||
|
||||
const feeCache = (g.__futuresFees ??= new Map());
|
||||
const measuredFees = (g.__futuresMeasuredFees ??= new Map());
|
||||
|
||||
/** A fill's commission per contract per side, for platforms with no fee
|
||||
* lookup (TopstepX). */
|
||||
export function recordMeasuredFee(firmId: string, root: string, perContract: number): void {
|
||||
if (Number.isFinite(perContract) && perContract >= 0) measuredFees.set(`${firmId}|${root}`, Math.round(perContract * 100) / 100);
|
||||
}
|
||||
|
||||
/** Tradovate's fee parameters, fetched once per trade date per firm. */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function refreshFees(firm: { id: string; platform: string }, client: any): void {
|
||||
if (firm.platform !== 'tradovate' || typeof client?.fetchInstrumentFees !== 'function') return;
|
||||
const cached = feeCache.get(firm.id);
|
||||
if (cached && cached.tradeDate === currentTradeDateCT() && (Object.keys(cached.fees).length > 0 || Date.now() - cached.at < RETRY_MS)) return;
|
||||
feeCache.set(firm.id, { tradeDate: currentTradeDateCT(), fees: cached?.fees ?? {}, at: Date.now() });
|
||||
void Promise.resolve(client.fetchInstrumentFees([...CME_ROOTS]))
|
||||
.then((fees: Record<string, number>) => feeCache.set(firm.id, { tradeDate: currentTradeDateCT(), fees: fees ?? {}, at: Date.now() }))
|
||||
.catch((err: unknown) => console.warn(`[instruments] fee lookup failed: ${errorMessage(err)}`));
|
||||
}
|
||||
|
||||
export function feesPerSide(firmId: string, root: string): number | null {
|
||||
return feeCache.get(firmId)?.fees[root] ?? measuredFees.get(`${firmId}|${root}`) ?? null;
|
||||
}
|
||||
|
||||
// ── the draw ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** The batch's leg, for the hold estimate: its dollars, its stop (Infinity =
|
||||
* none) and the account type's max contracts. */
|
||||
export interface LegShape {
|
||||
leg: number;
|
||||
stopDollars: number;
|
||||
maxPositionSize: number;
|
||||
}
|
||||
|
||||
function progressNow(now: Date): number | null {
|
||||
return sessionProgress(now, getSetting('trading_hours') ?? 'full_cme');
|
||||
}
|
||||
|
||||
/** Every root sized for the leg, and the draw's chances. */
|
||||
export function futuresBoard(roots: readonly string[], leg: LegShape | null, now = new Date()): {
|
||||
progress: number | null;
|
||||
candidates: DrawCandidate[];
|
||||
chances: { symbol: string; chance: number }[];
|
||||
disabled: string[];
|
||||
} {
|
||||
const contracts = leg ? futuresContracts(leg.leg, leg.maxPositionSize) : 1;
|
||||
const candidates = roots.filter((r) => CME_SPECS[r]).map((root): DrawCandidate => {
|
||||
const dailyMove = market.get(root)?.dailyMove ?? null;
|
||||
return {
|
||||
symbol: root,
|
||||
spreadDollars: spreadDollars(root),
|
||||
dailyMove,
|
||||
holdHours: leg ? expectedHoldHours({ leg: leg.leg, stopDollars: leg.stopDollars, contracts, dailyMove }) : null,
|
||||
micro: CME_SPECS[root].micro === true,
|
||||
};
|
||||
});
|
||||
const progress = progressNow(now);
|
||||
const { chances, disabled } = drawChances(candidates, progress);
|
||||
return { progress, candidates, chances, disabled };
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw one of `roots` for a batch whose leg is `leg`. null when nothing
|
||||
* listed fits (all over the hold cap) — the batch sits this tick out.
|
||||
*/
|
||||
export async function drawFuturesInstrument(roots: readonly string[], leg: LegShape | null, rand: () => number = Math.random): Promise<string | null> {
|
||||
await ensureMarket(roots);
|
||||
const board = futuresBoard(roots, leg);
|
||||
const root = pickByChance(board.chances, rand());
|
||||
const hold = (s: string) => board.candidates.find((c) => c.symbol === s)?.holdHours;
|
||||
const parts = board.chances.map((c) => `${c.symbol} ${Math.round(c.chance * 100)}%`).join(' · ');
|
||||
const off = board.disabled.map((s) => `${s} (~${Math.round(hold(s) ?? 0)}h)`).join(', ');
|
||||
console.log(`[instruments] futures: ${root ? `drew ${root}` : 'nothing fits'} — ${parts || 'none drawable'}`
|
||||
+ `${off ? `; over ${MAX_HOLD_HOURS}h: ${off}` : ''}`
|
||||
+ `${board.progress !== null ? ` (session ${Math.round(board.progress * 100)}%)` : ''}`);
|
||||
return root;
|
||||
}
|
||||
|
||||
/** The draw's odds over `n` simulated batches — the real draw code, no
|
||||
* orders — for GET /api/instruments?simulate=. */
|
||||
export function simulateDraws(roots: readonly string[], leg: LegShape | null, n: number): Record<string, number> {
|
||||
const board = futuresBoard(roots, leg);
|
||||
const counts: Record<string, number> = {};
|
||||
for (let i = 0; i < n; i++) {
|
||||
const s = pickByChance(board.chances, Math.random());
|
||||
if (s) counts[s] = (counts[s] ?? 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
// ── stats for the master and the Settings page ─────────────────────────────
|
||||
|
||||
/**
|
||||
* A futures firm's instruments as the draw sees them right now: every CME
|
||||
* root with its entry cost, movement and contract, and — sized for `lead`'s
|
||||
* next leg — the listed ones' holds, the ones over the cap, and each one's
|
||||
* chance.
|
||||
*/
|
||||
export function instrumentStatsFor(
|
||||
firm: FirmLike & { id: string },
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
client: any,
|
||||
lead: (LegShape & { account: string }) | null,
|
||||
): InstrumentStats {
|
||||
refreshFees({ id: firm.id, platform: firm.platform }, client);
|
||||
const listed = firmInstruments(firm);
|
||||
const board = futuresBoard(listed, lead);
|
||||
const disabled = new Set(board.disabled);
|
||||
const leg = lead ? { leg: lead.leg, stopDollars: lead.stopDollars, contracts: futuresContracts(lead.leg, lead.maxPositionSize) } : null;
|
||||
return {
|
||||
at: new Date().toISOString(),
|
||||
account: lead?.account ?? null,
|
||||
leg: lead?.leg ?? null,
|
||||
sessionProgress: board.progress,
|
||||
maxHoldHours: MAX_HOLD_HOURS,
|
||||
instruments: CME_ROOTS.map((root) => {
|
||||
const dailyMove = market.get(root)?.dailyMove ?? null;
|
||||
const holdHours = leg ? expectedHoldHours({ ...leg, dailyMove }) : null;
|
||||
return {
|
||||
symbol: root,
|
||||
listed: listed.includes(root),
|
||||
contract: getCachedContract(root)?.name ?? null,
|
||||
spreadDollars: spreadDollars(root),
|
||||
feesPerSide: feesPerSide(firm.id, root),
|
||||
dailyMoveDollars: dailyMove === null ? null : Math.round(dailyMove),
|
||||
holdHours: holdHours === null ? null : Math.round(holdHours * 10) / 10,
|
||||
disabled: listed.includes(root) ? disabled.has(root) : holdHours !== null && holdHours >= MAX_HOLD_HOURS,
|
||||
chance: board.chances.find((c) => c.symbol === root)?.chance ?? null,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
+51
-16
@@ -11,7 +11,8 @@ import type { PlatformContract } from './platform-client';
|
||||
import type { SymbolStats } from './master-protocol';
|
||||
import {
|
||||
combineCmeSessions, computeDayPlan, resolveEffectiveConfig, obfuscationStopDollars, dllRoom as computeDllRoom,
|
||||
planLegSize, rankForTarget, maxLossRoom, lastStand, DEFAULT_TARGET_HOLD_HOURS,
|
||||
planLegSize, rankForTarget, maxLossRoom, lastStand, dllStopDollars, DEFAULT_TARGET_HOLD_HOURS,
|
||||
FALLBACK_COMMISSION_PER_CONTRACT,
|
||||
} from './trading-logic';
|
||||
|
||||
export type CandidateLeg = SymbolStats['symbols'][number] & {
|
||||
@@ -86,18 +87,14 @@ export function rankNextLeg(o: {
|
||||
return rankForTarget(sized, target).map((i) => sized[i]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The same for an account worked out from scratch — its day plan, daily-loss
|
||||
* room and stops as the dashboard computes them. null when it has no
|
||||
* account type, no plan, or nothing left to trade today.
|
||||
*/
|
||||
export function accountNextLeg(
|
||||
/** An account's day plan and daily-loss room, worked out from scratch the
|
||||
* way the dashboard does. null without an account type or a plan. */
|
||||
function accountPlan(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
client: any,
|
||||
acc: { id: number },
|
||||
firm: FirmSizing,
|
||||
tradeDate: string,
|
||||
): { leg: number; lastStand: boolean; candidates: CandidateLeg[] } | null {
|
||||
) {
|
||||
const cfg = getAccountConfig(acc.id).accountType;
|
||||
if (!cfg) return null;
|
||||
const cash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 };
|
||||
@@ -111,7 +108,7 @@ export function accountNextLeg(
|
||||
combined.dailyPnL, effective.minDayPnL, effective.minTradingDays, cash.amount - cfg.accountSize, combined.realizedPnL,
|
||||
cfg.splitEnabled, effective.drain,
|
||||
);
|
||||
if (!plan || !(plan.remainingToday > 0)) return null;
|
||||
if (!plan) return null;
|
||||
const dll = cfg.dailyLossLimit ?? 0;
|
||||
let room: number | null = null;
|
||||
if (dll > 0) {
|
||||
@@ -121,17 +118,55 @@ export function accountNextLeg(
|
||||
realizedToday: Math.min(cash.realizedPnL, combined.realizedPnL),
|
||||
});
|
||||
}
|
||||
const obfuscation = obfuscationStopDollars(cfg, combined.dailyPnL, plan, combined.tradeDate, plan.remainingToday);
|
||||
return { cfg, cash, plan, dll, room, obfuscation };
|
||||
}
|
||||
|
||||
/**
|
||||
* The same for an account worked out from scratch — its day plan, daily-loss
|
||||
* room and stops as the dashboard computes them. null when it has no
|
||||
* account type, no plan, or nothing left to trade today.
|
||||
*/
|
||||
export function accountNextLeg(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
client: any,
|
||||
acc: { id: number },
|
||||
firm: FirmSizing,
|
||||
tradeDate: string,
|
||||
): { leg: number; lastStand: boolean; candidates: CandidateLeg[] } | null {
|
||||
const p = accountPlan(client, acc, tradeDate);
|
||||
if (!p || !(p.plan.remainingToday > 0)) return null;
|
||||
const floorRoom = maxLossRoom({
|
||||
balance: cash.amount, accountSize: cfg.accountSize, maxLoss: cfg.maxLoss ?? 0,
|
||||
balance: p.cash.amount, accountSize: p.cfg.accountSize, maxLoss: p.cfg.maxLoss ?? 0,
|
||||
autoLiqThreshold: client.autoLiqThresholds?.[acc.id],
|
||||
});
|
||||
return {
|
||||
leg: plan.remainingToday,
|
||||
lastStand: room !== null && lastStand({ dllRoom: room, dailyLossLimit: dll, maxLossRoom: floorRoom }),
|
||||
leg: p.plan.remainingToday,
|
||||
lastStand: p.room !== null && lastStand({ dllRoom: p.room, dailyLossLimit: p.dll, maxLossRoom: floorRoom }),
|
||||
candidates: rankNextLeg({
|
||||
client, accountId: acc.id, cfg, firm, leg: plan.remainingToday, dllRoom: room,
|
||||
obfuscationDollars: obfuscationStopDollars(cfg, combined.dailyPnL, plan, combined.tradeDate, plan.remainingToday),
|
||||
cash,
|
||||
client, accountId: acc.id, cfg: p.cfg, firm, leg: p.plan.remainingToday, dllRoom: p.room,
|
||||
obfuscationDollars: p.obfuscation, cash: p.cash,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A futures account's next leg as the Auto draw sizes its hold: the day's
|
||||
* remaining target, its stop (the tighter of the obfuscation and daily-loss
|
||||
* stops; Infinity = none) and the account type's max contracts. null with
|
||||
* nothing left to trade today.
|
||||
*/
|
||||
export function accountLegShape(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
client: any,
|
||||
acc: { id: number },
|
||||
tradeDate: string,
|
||||
): { leg: number; stopDollars: number; maxPositionSize: number } | null {
|
||||
const p = accountPlan(client, acc, tradeDate);
|
||||
if (!p || !(p.plan.remainingToday > 0)) return null;
|
||||
const obf = Number.isFinite(p.obfuscation) && p.obfuscation > 0 ? p.obfuscation : Infinity;
|
||||
const hard = p.room !== null && p.dll > 0
|
||||
? dllStopDollars({ room: p.room, dailyLossLimit: p.dll, entryFee: FALLBACK_COMMISSION_PER_CONTRACT, commissionPct: 0 })
|
||||
: Infinity;
|
||||
return { leg: p.plan.remainingToday, stopDollars: Math.min(obf, hard), maxPositionSize: p.cfg.maxPositionSize };
|
||||
}
|
||||
|
||||
+23
-8
@@ -15,10 +15,11 @@
|
||||
* master you control, and prefer https so it cannot be redirected in transit.
|
||||
*/
|
||||
|
||||
import { getSetting, setSetting, getInstruments, getRunnerHeartbeat, RUNNER_TIMEOUT_MS } from './db';
|
||||
import { getSetting, setSetting, getRunnerHeartbeat, RUNNER_TIMEOUT_MS } from './db';
|
||||
import { runTrade, startScheduler, stopScheduler, getSchedulerStatus } from './auto-trade';
|
||||
import { FIRMS, automationKey } from './automations';
|
||||
import { POINT_VALUES } from './trading-logic';
|
||||
import { CME_ROOTS, CME_SPECS } from './instrument-specs';
|
||||
import { futuresRootsHere } from './instruments';
|
||||
import { queueRun } from './queue-run';
|
||||
import { pushBackup } from './backup-sync';
|
||||
import { getBuildVersion } from './version';
|
||||
@@ -35,6 +36,8 @@ const RECONNECT_MAX_MS = 60_000;
|
||||
|
||||
interface LinkState {
|
||||
socket: WebSocket | null;
|
||||
/** The master_dashboard_url the socket was opened for. */
|
||||
url: string | null;
|
||||
/** Only true once the master has answered the hello. */
|
||||
ready: boolean;
|
||||
attempt: number;
|
||||
@@ -47,7 +50,7 @@ const _g = globalThis as typeof globalThis & { __masterLink?: LinkState };
|
||||
|
||||
function state(): LinkState {
|
||||
if (!_g.__masterLink) {
|
||||
_g.__masterLink = { socket: null, ready: false, attempt: 0, reconnectTimer: null, stopped: false };
|
||||
_g.__masterLink = { socket: null, url: null, ready: false, attempt: 0, reconnectTimer: null, stopped: false };
|
||||
}
|
||||
return _g.__masterLink;
|
||||
}
|
||||
@@ -56,16 +59,16 @@ function state(): LinkState {
|
||||
|
||||
/** What this instance can be asked to do, as of right now. */
|
||||
export function collectCapabilities(): InstanceCapabilities {
|
||||
// Only symbols the operator has enabled here. An empty instruments table
|
||||
// means nothing has been configured yet, so offer everything we can price.
|
||||
const enabled = getInstruments().filter((i) => i.enabled).map((i) => i.symbol);
|
||||
// The roots this machine's futures firms list (the master's per-firm
|
||||
// instrument lists); none listed yet means offer everything we can price.
|
||||
const listed = futuresRootsHere();
|
||||
|
||||
// Same test the AutoBuyer page uses: `at` is epoch ms from the last ping.
|
||||
const heartbeat = getRunnerHeartbeat();
|
||||
const runnerOnline = heartbeat !== null && Date.now() - heartbeat.at < RUNNER_TIMEOUT_MS;
|
||||
|
||||
return {
|
||||
symbols: enabled.length > 0 ? enabled : Object.keys(POINT_VALUES),
|
||||
symbols: listed.length > 0 ? listed : [...CME_ROOTS],
|
||||
automations: FIRMS.flatMap((firm) =>
|
||||
firm.automations
|
||||
.filter((a) => a.steps.length > 0)
|
||||
@@ -96,7 +99,7 @@ export function collectCapabilities(): InstanceCapabilities {
|
||||
async function execute(command: Command): Promise<unknown> {
|
||||
switch (command.kind) {
|
||||
case 'trade': {
|
||||
if (command.symbol !== 'Auto' && !POINT_VALUES[command.symbol]) {
|
||||
if (command.symbol !== 'Auto' && !CME_SPECS[command.symbol]) {
|
||||
throw new Error(`Unknown symbol: ${command.symbol}`);
|
||||
}
|
||||
// Same order as POST /api/trade: (re)start the scheduler first,
|
||||
@@ -163,8 +166,19 @@ function send(message: InstanceMessage): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/** A changed master_dashboard_url drops the socket, and the reconnect dials
|
||||
* the new master — otherwise reports and commands would stay with the old
|
||||
* one until a restart, while the catalog followed the setting. */
|
||||
function followMasterUrl(): void {
|
||||
const s = state();
|
||||
if (!s.socket || s.url === getSetting('master_dashboard_url')) return;
|
||||
console.log('[master-link] master_dashboard_url changed — reconnecting');
|
||||
try { s.socket.close(1000, 'master changed'); } catch { /* the close handler reconnects */ }
|
||||
}
|
||||
|
||||
/** True when the report went up the socket, so the caller can skip the POST. */
|
||||
export function pushReport(firms: unknown[], scheduler: unknown): boolean {
|
||||
followMasterUrl();
|
||||
return send({
|
||||
type: 'report',
|
||||
firms: firms as never,
|
||||
@@ -224,6 +238,7 @@ function connect() {
|
||||
return;
|
||||
}
|
||||
s.socket = socket;
|
||||
s.url = masterUrl;
|
||||
s.ready = false;
|
||||
|
||||
socket.addEventListener('open', () => {
|
||||
|
||||
+39
-1
@@ -116,6 +116,37 @@ export interface FirmStats {
|
||||
* candidate, best first — what the master's Firms page shows beside each
|
||||
* symbol. Optional. */
|
||||
symbolStats?: SymbolStats;
|
||||
/** Futures firms: each CME root's entry cost, movement, expected hold and
|
||||
* chance in the Auto draw right now. Optional. */
|
||||
instrumentStats?: InstrumentStats;
|
||||
}
|
||||
|
||||
/** A futures firm's instruments as the Auto draw sees them. */
|
||||
export interface InstrumentStats {
|
||||
/** ISO time computed. */
|
||||
at: string;
|
||||
/** Whose next leg the holds are sized for; null when no account has one. */
|
||||
account: string | null;
|
||||
leg: number | null;
|
||||
/** 0 at the session's open, 1 at its last entry; null outside it. */
|
||||
sessionProgress: number | null;
|
||||
/** Holds at or over this are disabled. */
|
||||
maxHoldHours: number;
|
||||
instruments: {
|
||||
symbol: string;
|
||||
listed: boolean;
|
||||
contract: string | null;
|
||||
/** One tick of spread on one contract, in dollars. */
|
||||
spreadDollars: number;
|
||||
/** The firm's fees per contract per side; null when unknown. */
|
||||
feesPerSide: number | null;
|
||||
/** One contract's typical daily move (1σ), in dollars. */
|
||||
dailyMoveDollars: number | null;
|
||||
holdHours: number | null;
|
||||
disabled: boolean;
|
||||
/** Share of Auto draws right now (0–1); null when not in the draw. */
|
||||
chance: number | null;
|
||||
}[];
|
||||
}
|
||||
|
||||
/** How one account's next leg sizes on each candidate (see planLegSize). */
|
||||
@@ -278,6 +309,12 @@ export interface CatalogFirm {
|
||||
* field — treated as 'tradovate'; a value this build doesn't know makes
|
||||
* the sync skip the firm. */
|
||||
platform: FirmPlatform;
|
||||
/** What the firm trades, in order: CME roots for futures firms, the firm's
|
||||
* own symbols for DXtrade (see lib/instruments.ts). Absent from a master
|
||||
* from before the lists — then bannedSymbols and symbolMap apply. */
|
||||
instruments?: string[];
|
||||
/** Older masters' way to exclude roots; newer ones derive it from
|
||||
* `instruments`. */
|
||||
bannedSymbols: string[];
|
||||
accountTypes: CatalogAccountType[];
|
||||
/** DXTrade REST base, e.g. https://dx.tradeify247.co/dxsca-web ('' otherwise). */
|
||||
@@ -289,7 +326,8 @@ export interface CatalogFirm {
|
||||
commissionPct?: number;
|
||||
/** Trades every calendar day, weekends and CME holidays included. */
|
||||
tradesEveryDay?: boolean;
|
||||
/** DXTrade: which symbols each CME root may trade as. */
|
||||
/** DXTrade, older masters: which symbols each CME root may trade as.
|
||||
* Newer ones derive it from `instruments`. */
|
||||
symbolMap?: SymbolMap;
|
||||
/** DXTrade sizing: aim each leg's expected hold at this many hours
|
||||
* (0 = DEFAULT_TARGET_HOLD_HOURS, 12). */
|
||||
|
||||
+39
-2
@@ -19,8 +19,9 @@ import { getTradableFirms } from './firm-catalog';
|
||||
import { getAccountConfig } from './account-type-resolver';
|
||||
import { getSchedulerStatus } from './auto-trade';
|
||||
import { botStopsActive, combineCmeSessions, computeDayPlan, currentTradeDateCT, cycleCompletion, endedCycleCompletion, LEG_COMPLETION_EPSILON, projectPerfectPath, resolveEffectiveConfig, DEFAULT_TARGET_HOLD_HOURS } from './trading-logic';
|
||||
import type { AccountTypeStats, FirmStats, SymbolStats } from './master-protocol';
|
||||
import { accountNextLeg } from './leg-estimate';
|
||||
import type { AccountTypeStats, FirmStats, InstrumentStats, SymbolStats } from './master-protocol';
|
||||
import { accountNextLeg, accountLegShape } from './leg-estimate';
|
||||
import { instrumentStatsFor, isFuturesFirm } from './instruments';
|
||||
import { historyFresh } from './platform-client';
|
||||
|
||||
function collectFirmStats() {
|
||||
@@ -389,10 +390,46 @@ function collectFirmStats() {
|
||||
...(client.syncComplete ? { accounts } : {}),
|
||||
accountTypes,
|
||||
...symbolStatsFor(f, client, rawToday),
|
||||
...instrumentStatsBlock(f, client, rawToday),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** The live accounts of a firm's largest prefix group first — the group a
|
||||
* batch fires — each with an account type. */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function leadCandidates(client: any): { id: number; name: string }[] {
|
||||
const groups = new Map<string, { id: number; name: string }[]>();
|
||||
for (const acc of client.accountList as { id: number; name: string; active: boolean }[]) {
|
||||
const cfg = getAccountConfig(acc.id).accountType;
|
||||
const floor = client.autoLiqThresholds?.[acc.id] ?? 0;
|
||||
const cash = client.accountCashBalances?.[acc.id];
|
||||
if (!cfg || !acc.active || !cash || (floor !== 0 && cash.amount <= floor)) continue;
|
||||
groups.set(cfg.prefix, [...(groups.get(cfg.prefix) ?? []), acc]);
|
||||
}
|
||||
return [...groups.values()].sort((a, b) => b.length - a.length).flat();
|
||||
}
|
||||
|
||||
/**
|
||||
* A futures firm's instruments as the Auto draw sees them, for the master's
|
||||
* Firms page: entry cost, movement and chance for each, with holds sized for
|
||||
* the next leg of the first live account (largest prefix group) that has one.
|
||||
*/
|
||||
function instrumentStatsBlock(
|
||||
f: { id: string; name: string; platform: string; instruments?: string[]; bannedSymbols?: string[] },
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
client: any,
|
||||
tradeDate: string,
|
||||
): { instrumentStats?: InstrumentStats } {
|
||||
if (!isFuturesFirm(f)) return {};
|
||||
let lead: { account: string; leg: number; stopDollars: number; maxPositionSize: number } | null = null;
|
||||
for (const acc of leadCandidates(client)) {
|
||||
const shape = accountLegShape(client, acc, tradeDate);
|
||||
if (shape) { lead = { ...shape, account: acc.name }; break; }
|
||||
}
|
||||
return { instrumentStats: instrumentStatsFor(f, client, lead) };
|
||||
}
|
||||
|
||||
/**
|
||||
* A DXtrade firm's next leg on each symbol-map candidate, for the master's
|
||||
* Firms page: one live account from the firm's largest prefix group — the
|
||||
|
||||
@@ -847,15 +847,6 @@ export function endedCycleCompletion(opts: {
|
||||
};
|
||||
}
|
||||
|
||||
/** Dollar-per-point value for common futures products. */
|
||||
export const POINT_VALUES: { [symbol: string]: number } = {
|
||||
NQ: 20, MNQ: 2, ES: 50, MES: 5,
|
||||
YM: 5, MYM: 0.5, RTY: 50, M2K: 5,
|
||||
GC: 100, MGC: 10, SI: 5000, CL: 1000,
|
||||
MCL: 100, NG: 10000, ZB: 1000, ZN: 1000,
|
||||
ZF: 1000, '6E': 125000, '6J': 12500000, '6B': 62500,
|
||||
};
|
||||
|
||||
/**
|
||||
* Compute the next trading day's profit target for an account.
|
||||
*
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import axios from 'axios';
|
||||
import type { AccountItem, AuthLoginResponse, Contract } from './tradovate-helpers';
|
||||
import { computeSec, randomUUIDV4 } from './tradovate-helpers';
|
||||
import { POINT_VALUES, currentTradeDateCT } from './trading-logic';
|
||||
import { currentTradeDateCT } from './trading-logic';
|
||||
import { POINT_VALUES } from './instrument-specs';
|
||||
import { getCachedContract, resolveContracts } from './contract-resolver';
|
||||
import { saveDailyPnL, loadDailyPnL, saveAccountMeta, loadAccountMeta, saveFundTransactions, loadFundTransactions, saveEndedCycleStart } from './db';
|
||||
import { brokerWebSocket } from './outbound-proxy';
|
||||
|
||||
Reference in New Issue
Block a user