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
@@ -98,6 +98,44 @@ stores the platform login for each firm.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Instruments: what each firm trades
|
||||||
|
|
||||||
|
Each firm's **Instruments** list on the master's Firms page is the one place
|
||||||
|
that says what it trades — there are no per-machine toggles or banned symbols
|
||||||
|
any more (the instance's Settings page shows the lists read-only).
|
||||||
|
|
||||||
|
- **Futures firms** (Tradovate, TopstepX) list CME roots — the same names on
|
||||||
|
both platforms; each client turns a root into its own front-month contract.
|
||||||
|
- **DXtrade firms** list their own symbols (`ETH/USD`), whatever root a batch
|
||||||
|
drew.
|
||||||
|
|
||||||
|
With the scheduler on **Auto**, each futures batch draws one instrument from
|
||||||
|
the batch firm's list, at random but weighted:
|
||||||
|
|
||||||
|
- **Efficiency** — one contract's typical daily move ÷ one tick of spread
|
||||||
|
(from Yahoo's hourly candles over the past week, measured once a day).
|
||||||
|
Commissions don't count; they're shown, never used to choose.
|
||||||
|
- **Session timing** — at the open every instrument gets a 10% floor and the
|
||||||
|
weights follow efficiency; toward the last entry (14:55 CT) the floor fades
|
||||||
|
and the weight moves sharply to the fastest movers.
|
||||||
|
- **12-hour cap** — sizing is unchanged (a contract per $1,000 of leg, up to
|
||||||
|
the type's max contracts); an instrument whose expected hold for the
|
||||||
|
batch's leg is 12 h or more is out of the draw (e.g. 6E at a $1,000 leg,
|
||||||
|
about a week).
|
||||||
|
- Micros stay out of the draw; drain and extra-day legs trade the micro of a
|
||||||
|
listed product (MNQ when NQ is listed).
|
||||||
|
|
||||||
|
Every draw logs its odds. `GET /api/instruments` shows each firm's list with
|
||||||
|
entry cost, daily move, hold and chance (`?simulate=10000` runs the real draw
|
||||||
|
with no orders), and the master's Firms page shows the same next to each
|
||||||
|
instrument.
|
||||||
|
|
||||||
|
Against a master from before the lists, an instance falls back to the old
|
||||||
|
rules: this machine's enabled instruments less each firm's banned symbols,
|
||||||
|
and a DXtrade firm's symbol map.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## DXtrade firms (Tradeify247)
|
## DXtrade firms (Tradeify247)
|
||||||
|
|
||||||
A DXtrade firm is set up on the master's **Firms** page like any other, plus:
|
A DXtrade firm is set up on the master's **Firms** page like any other, plus:
|
||||||
@@ -113,11 +151,9 @@ per-contract fees and the CME calendar.
|
|||||||
in its own scheduler *lane*: its own trading hours (closed only around the
|
in its own scheduler *lane*: its own trading hours (closed only around the
|
||||||
daily roll), its own wait-until-flat gate and its own batches, so a
|
daily roll), its own wait-until-flat gate and its own batches, so a
|
||||||
multi-hour crypto position never holds up the futures open.
|
multi-hour crypto position never holds up the futures open.
|
||||||
- **Symbol map** — which DXtrade symbols each CME root may trade as. A `*` row
|
- **Instruments** — the firm's own symbols (see above). The cheapest
|
||||||
covers every root (drain and extra-day legs trade `MNQ`, so map it or use
|
tradable one right now wins: spread plus both commissions, per unit of its
|
||||||
`*`). When a root lists several, the cheapest tradable one right now wins:
|
volatility (hourly closes over the past week, measured once a day).
|
||||||
spread plus both commissions, divided by its volatility (hourly closes over
|
|
||||||
the past week, measured once a day).
|
|
||||||
- **Target hold hours** — each leg's notional is sized from that volatility so
|
- **Target hold hours** — each leg's notional is sized from that volatility so
|
||||||
its expected hold is about this long (blank = 12 h). The position never
|
its expected hold is about this long (blank = 12 h). The position never
|
||||||
exceeds what the account's available funds can margin at the broker's
|
exceeds what the account's available funds can margin at the broker's
|
||||||
@@ -148,7 +184,7 @@ It is read-only unless `DX_CONFIRM=yes DX_ACCOUNT=<code>` are set too, which
|
|||||||
adds one minimum-size live round trip.
|
adds one minimum-size live round trip.
|
||||||
|
|
||||||
`npm run check` runs the pure-logic checks (fees, stops, sizing, trading
|
`npm run check` runs the pure-logic checks (fees, stops, sizing, trading
|
||||||
hours, the instrument selector).
|
hours, the DXtrade selector, the futures draw and instrument lists).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getCachedFirm } from '@/lib/firm-catalog';
|
||||||
|
import { firmInstruments, isFuturesFirm } from '@/lib/instruments';
|
||||||
import { getClients } from '@/lib/clients';
|
import { getClients } from '@/lib/clients';
|
||||||
|
|
||||||
export async function POST(
|
export async function POST(
|
||||||
@@ -26,6 +28,13 @@ export async function POST(
|
|||||||
const client = clients.get(firmId);
|
const client = clients.get(firmId);
|
||||||
if (!client) return NextResponse.json({ error: 'Client not found for firm' }, { status: 404 });
|
if (!client) return NextResponse.json({ error: 'Client not found for firm' }, { status: 404 });
|
||||||
|
|
||||||
|
// A futures firm trades only what its instrument list (master Firms
|
||||||
|
// page) names.
|
||||||
|
const firm = getCachedFirm(firmId);
|
||||||
|
if (firm && isFuturesFirm(firm) && !firmInstruments(firm).includes(symbol)) {
|
||||||
|
return NextResponse.json({ error: `${symbol} isn't on ${firm.name}'s instrument list` }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Find the active contract chosen by the shared resolver
|
// 1. Find the active contract chosen by the shared resolver
|
||||||
const contract = await client.findFrontMonthContract(symbol);
|
const contract = await client.findFrontMonthContract(symbol);
|
||||||
if (!contract) {
|
if (!contract) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { getCachedFirm } from '@/lib/firm-catalog';
|
import { getCachedFirm } from '@/lib/firm-catalog';
|
||||||
|
import { firmInstruments, instrumentsSource } from '@/lib/instruments';
|
||||||
import { getCredentials } from '@/lib/db';
|
import { getCredentials } from '@/lib/db';
|
||||||
|
|
||||||
/** One firm from the cached catalog. Read-only: firms and their account types
|
/** One firm from the cached catalog. Read-only: firms and their account types
|
||||||
@@ -22,7 +23,8 @@ export async function GET(
|
|||||||
firm: firm.name,
|
firm: firm.name,
|
||||||
username: creds?.username ?? '',
|
username: creds?.username ?? '',
|
||||||
hasCredentials: !!creds,
|
hasCredentials: !!creds,
|
||||||
bannedSymbols: firm.bannedSymbols,
|
instruments: firmInstruments(firm),
|
||||||
|
instrumentsSource: instrumentsSource(firm),
|
||||||
accounts: firm.accountTypes,
|
accounts: firm.accountTypes,
|
||||||
catalogUpdatedAt: firm.updatedAt,
|
catalogUpdatedAt: firm.updatedAt,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
import { firmInstruments, instrumentsSource } from '@/lib/instruments';
|
||||||
import { getCachedCatalog, hasPlatformCredentials } from '@/lib/firm-catalog';
|
import { getCachedCatalog, hasPlatformCredentials } from '@/lib/firm-catalog';
|
||||||
import { getCredentials } from '@/lib/db';
|
import { getCredentials } from '@/lib/db';
|
||||||
import type { FirmConfig } from '@/types';
|
import type { FirmConfig } from '@/types';
|
||||||
@@ -25,7 +26,8 @@ export async function GET() {
|
|||||||
platform: firm.platform,
|
platform: firm.platform,
|
||||||
username: creds?.username ?? '',
|
username: creds?.username ?? '',
|
||||||
hasCredentials,
|
hasCredentials,
|
||||||
bannedSymbols: firm.bannedSymbols,
|
instruments: firmInstruments(firm),
|
||||||
|
instrumentsSource: instrumentsSource(firm),
|
||||||
accounts: firm.accountTypes.map((a) => ({
|
accounts: firm.accountTypes.map((a) => ({
|
||||||
id: a.id,
|
id: a.id,
|
||||||
label: a.label,
|
label: a.label,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { getClients } from '@/lib/clients';
|
import { getClients } from '@/lib/clients';
|
||||||
import { getInstruments } from '@/lib/db';
|
import { futuresRootsHere } from '@/lib/instruments';
|
||||||
import { resolveContracts, getAllCachedContracts } from '@/lib/contract-resolver';
|
import { resolveContracts, getAllCachedContracts } from '@/lib/contract-resolver';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -15,7 +15,7 @@ export async function GET() {
|
|||||||
* POST /api/instruments/contracts
|
* POST /api/instruments/contracts
|
||||||
* Triggers a fresh resolve via Tradovate + Yahoo Finance.
|
* Triggers a fresh resolve via Tradovate + Yahoo Finance.
|
||||||
* Body (optional): { symbols: string[] } — when provided, resolves only those symbols.
|
* Body (optional): { symbols: string[] } — when provided, resolves only those symbols.
|
||||||
* Otherwise resolves all enabled instruments.
|
* Otherwise resolves every root this machine's futures firms list.
|
||||||
*/
|
*/
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -35,7 +35,7 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
const symbols = requestedSymbols?.length
|
const symbols = requestedSymbols?.length
|
||||||
? requestedSymbols
|
? requestedSymbols
|
||||||
: getInstruments().filter((i) => i.enabled).map((i) => i.symbol);
|
: futuresRootsHere();
|
||||||
|
|
||||||
const resolved = await resolveContracts(symbols, accessToken);
|
const resolved = await resolveContracts(symbols, accessToken);
|
||||||
return NextResponse.json(resolved);
|
return NextResponse.json(resolved);
|
||||||
|
|||||||
@@ -1,6 +1,38 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { getInstruments } from '@/lib/db';
|
import { getClients } from '@/lib/clients';
|
||||||
|
import { getCachedCatalog } from '@/lib/firm-catalog';
|
||||||
|
import {
|
||||||
|
ensureMarket, firmInstruments, futuresRootsHere, instrumentStatsFor, instrumentsSource, isFuturesFirm, simulateDraws,
|
||||||
|
} from '@/lib/instruments';
|
||||||
|
import { CME_ROOTS } from '@/lib/instrument-specs';
|
||||||
|
|
||||||
export async function GET() {
|
export const dynamic = 'force-dynamic';
|
||||||
return NextResponse.json(getInstruments());
|
|
||||||
|
/** The leg the overview sizes holds for: $1,000, stopless, one contract. */
|
||||||
|
const EXAMPLE_LEG = { leg: 1000, stopDollars: Infinity, maxPositionSize: 0 };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/instruments — what each firm trades (the master's lists) and, for
|
||||||
|
* futures firms, how the Auto draw sees every instrument right now: entry
|
||||||
|
* cost, one contract's daily move, the hold of a $1,000 leg, the chance.
|
||||||
|
* ?simulate=N adds N draws from the real draw code — no orders.
|
||||||
|
*/
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
await ensureMarket(CME_ROOTS, 8_000);
|
||||||
|
const clients = getClients();
|
||||||
|
const n = Math.min(100_000, Math.max(0, Math.floor(Number(req.nextUrl.searchParams.get('simulate')) || 0)));
|
||||||
|
const firms = getCachedCatalog().map((firm) => {
|
||||||
|
const instruments = firmInstruments(firm);
|
||||||
|
const futures = isFuturesFirm(firm);
|
||||||
|
return {
|
||||||
|
id: firm.id,
|
||||||
|
name: firm.name,
|
||||||
|
platform: firm.platform,
|
||||||
|
source: instrumentsSource(firm),
|
||||||
|
instruments,
|
||||||
|
...(futures ? { stats: instrumentStatsFor(firm, clients.get(firm.id), { ...EXAMPLE_LEG, account: 'a $1,000 leg' }) } : {}),
|
||||||
|
...(futures && n > 0 ? { simulated: simulateDraws(instruments, EXAMPLE_LEG, n) } : {}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return NextResponse.json({ roots: futuresRootsHere(), firms });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { runTrade, startScheduler } from '@/lib/auto-trade';
|
import { runTrade, startScheduler } from '@/lib/auto-trade';
|
||||||
import { errorMessage } from '@/lib/platform-client';
|
import { errorMessage } from '@/lib/platform-client';
|
||||||
import { POINT_VALUES } from '@/lib/trading-logic';
|
import { CME_SPECS } from '@/lib/instrument-specs';
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -12,7 +12,7 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Missing required fields: action, symbol' }, { status: 400 });
|
return NextResponse.json({ error: 'Missing required fields: action, symbol' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (symbol !== 'Auto' && !POINT_VALUES[symbol]) {
|
if (symbol !== 'Auto' && !CME_SPECS[symbol]) {
|
||||||
return NextResponse.json({ error: `Unknown symbol: ${symbol}` }, { status: 400 });
|
return NextResponse.json({ error: `Unknown symbol: ${symbol}` }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -137,20 +137,25 @@ export default function FirmSettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mt-10 mb-3">
|
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mt-10 mb-3">
|
||||||
Banned Symbols
|
Instruments
|
||||||
</h2>
|
</h2>
|
||||||
<div className="bg-white border border-slate-200 rounded-xl p-4 shadow-sm">
|
<div className="bg-white border border-slate-200 rounded-xl p-4 shadow-sm">
|
||||||
{!firm || firm.bannedSymbols.length === 0 ? (
|
{!firm || firm.instruments.length === 0 ? (
|
||||||
<span className="text-sm text-slate-400 italic">None</span>
|
<span className="text-sm text-slate-400 italic">None — this firm won't trade</span>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{firm.bannedSymbols.map((sym) => (
|
{firm.instruments.map((sym) => (
|
||||||
<span key={sym} className="px-2 py-1 rounded-md text-xs font-mono font-semibold bg-red-100 text-red-700">
|
<span key={sym} className="px-2 py-1 rounded-md text-xs font-mono font-semibold bg-slate-100 text-slate-700">
|
||||||
{sym}
|
{sym}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<p className="mt-2 text-xs text-slate-400">
|
||||||
|
{firm?.instrumentsSource === 'legacy'
|
||||||
|
? 'The master doesn\'t send instrument lists yet: this machine\'s enabled instruments, less the firm\'s banned symbols.'
|
||||||
|
: 'Edited on the master\'s Firms page.'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+11
-10
@@ -498,9 +498,9 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo
|
|||||||
<td className="px-4 py-3.5">
|
<td className="px-4 py-3.5">
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<span className="font-bold text-sm text-slate-800">{firm.firm}</span>
|
<span className="font-bold text-sm text-slate-800">{firm.firm}</span>
|
||||||
{firm.bannedSymbols?.includes(tradeSymbol) && (
|
{firm.platform !== 'dxtrade' && tradeSymbol !== 'Auto' && !firm.instruments?.includes(tradeSymbol) && (
|
||||||
<span className="text-xs bg-red-100 text-red-700 px-1.5 py-0.5 rounded font-semibold">
|
<span className="text-xs bg-red-100 text-red-700 px-1.5 py-0.5 rounded font-semibold" title="Not on this firm's instrument list (master Firms page) — it sits this symbol out">
|
||||||
{tradeSymbol} BANNED
|
{tradeSymbol} not listed
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{firm.platform === 'topstepx' && (
|
{firm.platform === 'topstepx' && (
|
||||||
@@ -629,7 +629,7 @@ export default function Home() {
|
|||||||
const [sortDir, setSortDir] = useState<SortDir>('asc');
|
const [sortDir, setSortDir] = useState<SortDir>('asc');
|
||||||
|
|
||||||
// Trade controls
|
// Trade controls
|
||||||
const [scheduler, setScheduler] = useState<SchedulerStatus>({ running: false, action: 'Buy', symbol: 'NQ', lastRun: null, intervalSeconds: 60, stopAfterAll: false });
|
const [scheduler, setScheduler] = useState<SchedulerStatus>({ running: false, action: 'Buy', symbol: 'Auto', lastRun: null, intervalSeconds: 60, stopAfterAll: false });
|
||||||
const [enabledSymbols, setEnabledSymbols] = useState<string[]>([]);
|
const [enabledSymbols, setEnabledSymbols] = useState<string[]>([]);
|
||||||
const [tradeSymbol, setTradeSymbol] = useState('');
|
const [tradeSymbol, setTradeSymbol] = useState('');
|
||||||
const [tradeAction, setTradeAction] = useState<'Buy' | 'Sell' | 'Auto'>('Buy');
|
const [tradeAction, setTradeAction] = useState<'Buy' | 'Sell' | 'Auto'>('Buy');
|
||||||
@@ -681,12 +681,13 @@ export default function Home() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// The roots this machine's futures firms list; 'Auto' draws among
|
||||||
|
// each batch firm's list and leads the dropdown.
|
||||||
fetch('/api/instruments')
|
fetch('/api/instruments')
|
||||||
.then((r) => r.json() as Promise<{ symbol: string; enabled: boolean }[]>)
|
.then((r) => r.json() as Promise<{ roots: string[] }>)
|
||||||
.then((instruments) => {
|
.then(({ roots }) => {
|
||||||
const syms = instruments.filter((i) => i.enabled).map((i) => i.symbol);
|
setEnabledSymbols(roots ?? []);
|
||||||
setEnabledSymbols(syms);
|
setTradeSymbol((prev) => prev || 'Auto');
|
||||||
setTradeSymbol((prev) => prev || syms[0] || 'NQ');
|
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
|
||||||
@@ -839,8 +840,8 @@ export default function Home() {
|
|||||||
onChange={(e) => setTradeSymbol(e.target.value)}
|
onChange={(e) => setTradeSymbol(e.target.value)}
|
||||||
className="rounded-lg border border-slate-200 bg-slate-50 px-2.5 py-1.5 text-sm font-mono text-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
className="rounded-lg border border-slate-200 bg-slate-50 px-2.5 py-1.5 text-sm font-mono text-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
>
|
>
|
||||||
{enabledSymbols.map((s) => <option key={s} value={s}>{s}</option>)}
|
|
||||||
<option value="Auto">Auto</option>
|
<option value="Auto">Auto</option>
|
||||||
|
{enabledSymbols.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||||
</select>
|
</select>
|
||||||
<div className="flex rounded-lg border border-slate-200 overflow-hidden text-sm font-semibold">
|
<div className="flex rounded-lg border border-slate-200 overflow-hidden text-sm font-semibold">
|
||||||
<button
|
<button
|
||||||
|
|||||||
+81
-73
@@ -3,9 +3,31 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
interface Instrument {
|
/** GET /api/instruments: each firm's list (from the master) and, for
|
||||||
|
* futures firms, how the Auto draw sees each instrument. */
|
||||||
|
interface InstrumentsOverview {
|
||||||
|
roots: string[];
|
||||||
|
firms: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
platform: string;
|
||||||
|
source: 'master' | 'legacy';
|
||||||
|
instruments: string[];
|
||||||
|
stats?: {
|
||||||
|
maxHoldHours: number;
|
||||||
|
sessionProgress: number | null;
|
||||||
|
instruments: {
|
||||||
symbol: string;
|
symbol: string;
|
||||||
enabled: boolean;
|
listed: boolean;
|
||||||
|
spreadDollars: number;
|
||||||
|
feesPerSide: number | null;
|
||||||
|
dailyMoveDollars: number | null;
|
||||||
|
holdHours: number | null;
|
||||||
|
disabled: boolean;
|
||||||
|
chance: number | null;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ResolvedContract {
|
interface ResolvedContract {
|
||||||
@@ -33,7 +55,7 @@ interface BackupMeta {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const [instruments, setInstruments] = useState<Instrument[]>([]);
|
const [overview, setOverview] = useState<InstrumentsOverview | null>(null);
|
||||||
const [contracts, setContracts] = useState<Record<string, ResolvedContract | null>>({});
|
const [contracts, setContracts] = useState<Record<string, ResolvedContract | null>>({});
|
||||||
const [maxConcurrent, setMaxConcurrent] = useState<string>('5');
|
const [maxConcurrent, setMaxConcurrent] = useState<string>('5');
|
||||||
const [tradingHours, setTradingHours] = useState<string>('full_cme');
|
const [tradingHours, setTradingHours] = useState<string>('full_cme');
|
||||||
@@ -75,7 +97,8 @@ export default function SettingsPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/api/instruments')
|
fetch('/api/instruments')
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then(setInstruments);
|
.then(setOverview)
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
fetchCatalog();
|
fetchCatalog();
|
||||||
|
|
||||||
@@ -125,33 +148,6 @@ export default function SettingsPage() {
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function toggle(symbol: string, enabled: boolean) {
|
|
||||||
setInstruments((prev) =>
|
|
||||||
prev.map((i) => (i.symbol === symbol ? { ...i, enabled } : i))
|
|
||||||
);
|
|
||||||
await fetch(`/api/instruments/${symbol}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ enabled }),
|
|
||||||
});
|
|
||||||
|
|
||||||
// When enabling a symbol, auto-resolve its active contract
|
|
||||||
if (enabled) {
|
|
||||||
fetch('/api/instruments/contracts', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ symbols: [symbol] }),
|
|
||||||
})
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then((fresh: Record<string, ResolvedContract | null>) => {
|
|
||||||
if (!fresh.error) {
|
|
||||||
setContracts((prev) => ({ ...prev, ...fresh }));
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveSettings() {
|
async function saveSettings() {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setSaved(false);
|
setSaved(false);
|
||||||
@@ -443,68 +439,80 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Instruments ── */}
|
{/* ── Instruments ── */}
|
||||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mb-3">
|
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mb-1">
|
||||||
Instruments
|
Instruments
|
||||||
</h2>
|
</h2>
|
||||||
|
<p className="text-xs text-slate-400 mb-3">
|
||||||
|
What each firm trades is set on the master's{' '}
|
||||||
|
{masterUrl ? <a className="text-blue-500 hover:underline" href={`${masterUrl.replace(/\/+$/, '')}/firms`} target="_blank" rel="noreferrer">Firms page</a> : 'Firms page'}.
|
||||||
|
Futures “Auto” draws one per batch: cheaper per move early in the session (every instrument gets a floor),
|
||||||
|
the fastest near the last entry, never one expected to hold 12h or more. Holds below are for a $1,000 leg.
|
||||||
|
</p>
|
||||||
|
|
||||||
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden shadow-sm">
|
{!overview ? (
|
||||||
{instruments.length === 0 ? (
|
<div className="bg-white border border-slate-200 rounded-xl px-4 py-8 text-center text-sm text-slate-400 italic shadow-sm">Loading…</div>
|
||||||
<div className="px-4 py-8 text-center text-sm text-slate-400 italic">
|
) : overview.firms.map((firm) => (
|
||||||
Loading…
|
<div key={firm.id} className="bg-white border border-slate-200 rounded-xl overflow-hidden shadow-sm mb-3">
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2.5 bg-slate-50 border-b border-slate-200">
|
||||||
|
<span className="font-semibold text-sm text-slate-800">{firm.name}</span>
|
||||||
|
<span className="text-xs text-slate-400">{firm.platform}</span>
|
||||||
|
{firm.source === 'legacy' && (
|
||||||
|
<span className="text-xs bg-amber-50 text-amber-700 border border-amber-200 rounded px-1.5 py-0.5" title="The master doesn't send instrument lists yet: this machine's enabled instruments, less the firm's banned symbols">
|
||||||
|
from this machine (older master)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{firm.instruments.length === 0 && <span className="text-xs text-amber-700">no instruments — won't trade</span>}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
{firm.stats ? (
|
||||||
<table className="w-full text-sm border-collapse">
|
<table className="w-full text-sm border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-slate-200 bg-slate-50">
|
<tr className="border-b border-slate-100 text-xs uppercase tracking-wider text-slate-400">
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">Symbol</th>
|
<th className="px-4 py-2 text-left font-semibold">Symbol</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">Active Contract</th>
|
<th className="px-4 py-2 text-left font-semibold">Contract</th>
|
||||||
<th className="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-slate-400">Enabled</th>
|
<th className="px-4 py-2 text-right font-semibold">Entry</th>
|
||||||
|
<th className="px-4 py-2 text-right font-semibold">Daily move</th>
|
||||||
|
<th className="px-4 py-2 text-right font-semibold">Hold</th>
|
||||||
|
<th className="px-4 py-2 text-right font-semibold">Chance now</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{instruments.map((instr) => {
|
{firm.instruments.map((sym) => {
|
||||||
const c = contracts[instr.symbol];
|
const x = firm.stats!.instruments.find((i) => i.symbol === sym);
|
||||||
const wasRolled = !!c?.alternative;
|
const c = contracts[sym];
|
||||||
return (
|
return (
|
||||||
<tr key={instr.symbol} className="border-b border-slate-100 last:border-0">
|
<tr key={sym} className="border-b border-slate-100 last:border-0">
|
||||||
<td className="px-4 py-2.5 font-mono font-semibold text-slate-800">
|
<td className="px-4 py-2 font-mono font-semibold text-slate-800">{sym}</td>
|
||||||
{instr.symbol}
|
<td className="px-4 py-2 font-mono text-slate-600">
|
||||||
|
{c?.name ?? '—'}
|
||||||
|
{c?.alternative && <span className="ml-2 text-xs bg-amber-100 text-amber-700 px-1.5 py-0.5 rounded font-sans">rolled from {c.alternative}</span>}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2.5">
|
<td className="px-4 py-2 text-right tabular-nums text-slate-600" title="Per contract: one tick of spread, plus the firm's fees per side when known">
|
||||||
{instr.enabled && c ? (
|
{x ? `$${x.spreadDollars.toFixed(2)}${x.feesPerSide !== null ? ` + $${x.feesPerSide.toFixed(2)}` : ''}` : '—'}
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className={`font-mono text-sm ${wasRolled ? 'text-amber-600 font-semibold' : 'text-slate-600'}`}>
|
|
||||||
{c.name}
|
|
||||||
</span>
|
|
||||||
{wasRolled && (
|
|
||||||
<span className="text-xs bg-amber-100 text-amber-700 px-1.5 py-0.5 rounded font-medium">
|
|
||||||
rolled from {c.alternative}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2.5 text-right">
|
<td className="px-4 py-2 text-right tabular-nums text-slate-600">
|
||||||
<button
|
{x?.dailyMoveDollars != null ? `$${x.dailyMoveDollars.toLocaleString()}` : '—'}
|
||||||
onClick={() => toggle(instr.symbol, !instr.enabled)}
|
</td>
|
||||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none ${
|
<td className={`px-4 py-2 text-right tabular-nums ${x?.disabled ? 'text-amber-600' : 'text-slate-600'}`}>
|
||||||
instr.enabled ? 'bg-blue-500' : 'bg-slate-200'
|
{x?.holdHours != null ? (x.holdHours < 48 ? `${x.holdHours.toFixed(1)}h` : `${(x.holdHours / 24).toFixed(1)}d`) : '—'}
|
||||||
}`}
|
{x?.disabled && ' · disabled'}
|
||||||
>
|
</td>
|
||||||
<span
|
<td className="px-4 py-2 text-right tabular-nums font-semibold text-slate-700">
|
||||||
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-white shadow transition-transform ${
|
{x?.chance != null ? `${Math.round(x.chance * 100)}%` : '—'}
|
||||||
instr.enabled ? 'translate-x-4' : 'translate-x-1'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap gap-1.5 px-4 py-3">
|
||||||
|
{firm.instruments.map((sym) => (
|
||||||
|
<span key={sym} className="px-2 py-1 rounded-md text-xs font-mono font-semibold bg-slate-100 text-slate-700">{sym}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+201
-97
@@ -7,7 +7,6 @@
|
|||||||
* signal but have since exited and are now eligible.
|
* signal but have since exited and are now eligible.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { getInstruments } from './db';
|
|
||||||
import { getClients } from './clients';
|
import { getClients } from './clients';
|
||||||
import { getCachedFirm, getTradableFirms } from './firm-catalog';
|
import { getCachedFirm, getTradableFirms } from './firm-catalog';
|
||||||
import { getAccountConfig } from './account-type-resolver';
|
import { getAccountConfig } from './account-type-resolver';
|
||||||
@@ -17,11 +16,13 @@ import {
|
|||||||
expectedSwapToWin, planLegSize, maxLossRoom, breachDistance,
|
expectedSwapToWin, planLegSize, maxLossRoom, breachDistance,
|
||||||
DEFAULT_TARGET_HOLD_HOURS,
|
DEFAULT_TARGET_HOLD_HOURS,
|
||||||
isNoTradeTime, isPastSessionClose,
|
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,
|
FALLBACK_COMMISSION_PER_CONTRACT, MAX_COMMISSION_PER_CONTRACT, DLL_MIN_STOP,
|
||||||
type DayPlan,
|
type DayPlan,
|
||||||
} from './trading-logic';
|
} from './trading-logic';
|
||||||
import { getSetting, setSetting } from './db';
|
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';
|
import { errorMessage, isClientReady, isOrderNotPlaced, type PlatformClient, type PlatformContract, type PickContext } from './platform-client';
|
||||||
|
|
||||||
// ── process-wide state ───────────────────────────────────────────────────────
|
// ── process-wide state ───────────────────────────────────────────────────────
|
||||||
@@ -422,20 +423,64 @@ function releaseTradeMutex(lease: number, lane: string): void {
|
|||||||
if (holdsTradeMutex(lease, lane)) runtime().tradeInFlight?.delete(lane);
|
if (holdsTradeMutex(lease, lane)) runtime().tradeInFlight?.delete(lane);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The instrument a leg actually trades: drain legs go on MNQ (whatever the
|
/** What a firm's tiny legs (drain, extra-day) trade: a futures firm the
|
||||||
* platform maps MNQ to), hardcoded like the extra-day path — tiny targets
|
* micro of what it lists (MNQ when NQ is listed, as always — see
|
||||||
* belong on the micro regardless of the batch symbol. */
|
* 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(
|
async function legInstrument(
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
client: any,
|
client: any,
|
||||||
|
firm: TradableFirm,
|
||||||
drain: boolean,
|
drain: boolean,
|
||||||
batchContract: LegContract,
|
batchContract: LegContract,
|
||||||
batchPointValue: number,
|
batchPointValue: number,
|
||||||
): Promise<{ contract: LegContract; pointValue: number }> {
|
): Promise<{ contract: LegContract; pointValue: number }> {
|
||||||
if (!drain) return { contract: batchContract, pointValue: batchPointValue };
|
if (!drain) return { contract: batchContract, pointValue: batchPointValue };
|
||||||
const mnq: LegContract | null = await client.findFrontMonthContract('MNQ');
|
const root = housekeepingFor(firm);
|
||||||
if (!mnq) throw new Error('MNQ contract not found for drain trade');
|
const micro: LegContract | null = await client.findFrontMonthContract(root);
|
||||||
return { contract: mnq, pointValue: mnq.pointValue ?? POINT_VALUES['MNQ'] };
|
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. */
|
/** 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));
|
const open = laneReady.filter(({ firm }) => !isInNoTradeWindow(firm));
|
||||||
|
|
||||||
// Resolve 'Auto' symbol once per batch so all accounts trade the same symbol
|
// One direction per batch: every account trades the same side.
|
||||||
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
|
|
||||||
const resolvedAction: 'Buy' | 'Sell' = action === 'Auto'
|
const resolvedAction: 'Buy' | 'Sell' = action === 'Auto'
|
||||||
? (Math.random() < 0.5 ? 'Buy' : 'Sell')
|
? (Math.random() < 0.5 ? 'Buy' : 'Sell')
|
||||||
: action;
|
: action;
|
||||||
// The CME product's point value. A platform that maps the root to its own
|
const futuresLane = lane === FUTURES_LANE;
|
||||||
// instrument (DXTrade) supplies the contract's point value instead.
|
// An explicit symbol (the scheduler's, or a manual Trade) is the
|
||||||
const basePointValue: number | undefined = POINT_VALUES[resolvedSymbol];
|
// 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 tradeDate = currentTradeDateCT();
|
||||||
const maxConcurrent = maxConcurrentAccounts();
|
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 };
|
type EligibleItem = LegItem & { daysTraded: number; isExtraDay: boolean };
|
||||||
|
const allEligible: Candidate[] = [];
|
||||||
|
|
||||||
const allEligible: EligibleItem[] = [];
|
for (const { firm, client } of open) {
|
||||||
|
if (client.accountList.length === 0) continue;
|
||||||
await Promise.all(open.map(async ({ firm, client }) => {
|
const listed = firmInstruments(firm);
|
||||||
if (client.accountList.length === 0) return;
|
if (listed.length === 0) {
|
||||||
|
console.log(`[auto-trade] ${firm.name}: no instruments listed — skipping firm`);
|
||||||
// Skip this firm entirely if the symbol is banned for it
|
continue;
|
||||||
if (firm.bannedSymbols.includes(resolvedSymbol)) {
|
|
||||||
console.log(`[auto-trade] ${firm.name}: ${resolvedSymbol} is banned — skipping firm`);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
if (explicit && !listed.includes(explicit)) {
|
||||||
// One firm's trouble (a contract lookup that throws, a malformed
|
console.log(`[auto-trade] ${firm.name}: ${explicit} isn't on its instrument list — skipping firm`);
|
||||||
// snapshot) skips that firm, never the batch — a rejection here would
|
continue;
|
||||||
// fail the Promise.all and every other firm would sit the tick out.
|
}
|
||||||
|
// One firm's trouble (a malformed snapshot) skips that firm, never
|
||||||
|
// the batch.
|
||||||
try {
|
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) {
|
for (const acc of client.accountList) {
|
||||||
const ev = evaluateAccountForTrade(client, acc, tradeDate);
|
const ev = evaluateAccountForTrade(client, acc, tradeDate);
|
||||||
if (!ev || !ev.eligible || !ev.plan) continue;
|
if (!ev || !ev.eligible || !ev.plan) continue;
|
||||||
evaluated.push({
|
allEligible.push({
|
||||||
firmName: firm.name, firm, client, acc,
|
firmName: firm.name, firm, client, acc,
|
||||||
cash: ev.cash, dailyPnL: ev.dailyPnL, daysTraded: ev.daysTraded,
|
cash: ev.cash, dailyPnL: ev.dailyPnL, daysTraded: ev.daysTraded,
|
||||||
plan: ev.plan, isExtraDay: ev.isExtraDay, drain: ev.drain,
|
plan: ev.plan, isExtraDay: ev.isExtraDay, drain: ev.drain,
|
||||||
@@ -833,53 +872,112 @@ async function runTradeLocked(
|
|||||||
dllRoom: ev.dllRoom,
|
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) {
|
} catch (err) {
|
||||||
console.error(`[auto-trade] ${firm.name}: skipped — ${errorMessage(err)}`);
|
console.error(`[auto-trade] ${firm.name}: skipped — ${errorMessage(err)}`);
|
||||||
}
|
}
|
||||||
}));
|
}
|
||||||
|
|
||||||
if (allEligible.length === 0) {
|
if (allEligible.length === 0) {
|
||||||
console.log(`[auto-trade] ${laneName(lane)}: no eligible accounts found`);
|
console.log(`[auto-trade] ${laneName(lane)}: no eligible accounts found`);
|
||||||
return { outcome: 'no-eligible', results: [] };
|
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.
|
// 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).
|
// 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
|
// Like accounts trade together: a batch is drawn from a single prefix group
|
||||||
// (largest group first), never mixed across prefixes — a group's remainder
|
// (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
|
// 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
|
// another prefix. E.g. 4×FTDFY, 5×MFFUEV, 7×MFFUSF at limit 5 fires as
|
||||||
// batches of 5, 5, 4, 2.
|
// batches of 5, 5, 4, 2. A group that can't trade anything right now
|
||||||
const groups = new Map<string, EligibleItem[]>();
|
// (nothing listed fits, no contract resolves) yields to the next.
|
||||||
|
const groups = new Map<string, Candidate[]>();
|
||||||
for (const item of allEligible) {
|
for (const item of allEligible) {
|
||||||
const group = groups.get(item.prefix);
|
const group = groups.get(item.prefix);
|
||||||
if (group) group.push(item);
|
if (group) group.push(item);
|
||||||
else groups.set(item.prefix, [item]);
|
else groups.set(item.prefix, [item]);
|
||||||
}
|
}
|
||||||
const [batchPrefix, batchGroup] = [...groups.entries()]
|
const ordered = [...groups.entries()].sort((a, b) => b[1].length - a[1].length || a[0].localeCompare(b[0]));
|
||||||
.sort((a, b) => b[1].length - a[1].length || a[0].localeCompare(b[0]))[0];
|
|
||||||
const batch = batchGroup.slice(0, maxConcurrent);
|
let batch: EligibleItem[] = [];
|
||||||
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)'}`);
|
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();
|
runtime().lastFireAt = Date.now();
|
||||||
|
|
||||||
// ── Phase 2: fire the batch simultaneously ──
|
// ── Phase 2: fire the batch simultaneously ──
|
||||||
@@ -889,10 +987,12 @@ async function runTradeLocked(
|
|||||||
const { client, acc } = item;
|
const { client, acc } = item;
|
||||||
|
|
||||||
// Extra-day mode: profit target already met, no min day P&L, days still needed.
|
// 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) {
|
if (item.isExtraDay) {
|
||||||
const mnqContract = await client.findFrontMonthContract('MNQ');
|
const microRoot = housekeepingFor(item.firm);
|
||||||
if (!mnqContract) throw new Error('MNQ contract not found for extra-day trade');
|
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 fill = await client.sendOrder(acc.id, mnqContract.name, 1, resolvedAction, 'Market');
|
||||||
const exitAction: 'Buy' | 'Sell' = resolvedAction === 'Buy' ? 'Sell' : 'Buy';
|
const exitAction: 'Buy' | 'Sell' = resolvedAction === 'Buy' ? 'Sell' : 'Buy';
|
||||||
@@ -1082,7 +1182,7 @@ async function fireLeg(
|
|||||||
let contracts: number;
|
let contracts: number;
|
||||||
let plan: ReturnType<typeof sizeLeg>;
|
let plan: ReturnType<typeof sizeLeg>;
|
||||||
try {
|
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;
|
legContract = inst.contract;
|
||||||
pv = inst.pointValue;
|
pv = inst.pointValue;
|
||||||
plan = sizeLeg(item, legContract, legAmount, tradeDate);
|
plan = sizeLeg(item, legContract, legAmount, tradeDate);
|
||||||
@@ -1130,6 +1230,7 @@ async function fireLeg(
|
|||||||
await new Promise(r => setTimeout(r, 1000));
|
await new Promise(r => setTimeout(r, 1000));
|
||||||
const updatedCash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 };
|
const updatedCash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 };
|
||||||
measured = entryCommissionMeasured(item.cash.realizedPnL, updatedCash.realizedPnL, contracts);
|
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;
|
entryFee = measured ?? FALLBACK_COMMISSION_PER_CONTRACT * contracts;
|
||||||
}
|
}
|
||||||
// The stop. On a measured-fee firm whose fee didn't show, the daily-loss
|
// 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) {
|
if (!resolvedAction || positionedCount === 0) {
|
||||||
console.log('[copy-trade] no open positions to copy from');
|
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}`);
|
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
|
// Which instrument is held — looked up only on the client that holds it
|
||||||
// actually has the position. Iterating every firm × every symbol can take 30+ seconds.
|
// (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;
|
let resolvedSymbol: string | null = null;
|
||||||
const instruments = getInstruments();
|
if (positionedClient && positionContractId !== null) {
|
||||||
const enabledSymbols = instruments.filter(i => i.enabled).map(i => i.symbol);
|
const roots = futuresLane
|
||||||
|
? [...new Set(ready.flatMap(({ firm }) => [...firmInstruments(firm), housekeepingFor(firm)]))]
|
||||||
if (positionedClient) {
|
: ['*'];
|
||||||
for (const sym of enabledSymbols) {
|
resolvedSymbol = await heldRoot(positionedClient, positionContractId, roots);
|
||||||
// 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)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log(`[copy-trade] resolved symbol: ${resolvedSymbol ?? '(none, using fallback)'}`);
|
|
||||||
|
|
||||||
// Fall back to scheduler symbol or first enabled
|
|
||||||
if (!resolvedSymbol) {
|
if (!resolvedSymbol) {
|
||||||
const state = getState();
|
console.log(`[copy-trade] can't tell which instrument contract ${positionContractId} is — not copying`);
|
||||||
resolvedSymbol = state.symbol !== 'Auto' ? state.symbol : (enabledSymbols[0] ?? 'NQ');
|
return [];
|
||||||
}
|
}
|
||||||
|
console.log(`[copy-trade] resolved symbol: ${resolvedSymbol}`);
|
||||||
const basePointValue: number | undefined = POINT_VALUES[resolvedSymbol];
|
const basePointValue: number | undefined = POINT_VALUES[resolvedSymbol];
|
||||||
|
|
||||||
const slotsAvailable = maxConcurrent - positionedCount;
|
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`);
|
console.log(`[copy-trade] ${firm.name}: empty account list — skip firm`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (firm.bannedSymbols.includes(resolvedSymbol!)) {
|
if (futuresLane && !firmInstruments(firm).includes(resolvedSymbol!)) {
|
||||||
console.log(`[copy-trade] ${firm.name}: ${resolvedSymbol} is banned — skip firm`);
|
console.log(`[copy-trade] ${firm.name}: ${resolvedSymbol} isn't on its instrument list — skip firm`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1478,14 +1570,26 @@ export async function fireManualLeg(accountId: number, action: 'Buy' | 'Sell' |
|
|||||||
if (heldSide && action !== 'Auto' && action !== heldSide) {
|
if (heldSide && action !== 'Auto' && action !== heldSide) {
|
||||||
throw new Error(`the ${laneName(lane)} lane is ${heldSide === 'Buy' ? 'long' : 'short'} — a ${action} would hedge it`);
|
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 resolvedAction: 'Buy' | 'Sell' = heldSide ?? (action === 'Auto' ? (Math.random() < 0.5 ? 'Buy' : 'Sell') : action);
|
||||||
const sizingItem: SizingItem = {
|
const sizingItem: SizingItem = {
|
||||||
firmName: firm.name, firm, client, acc,
|
firmName: firm.name, firm, client, acc,
|
||||||
cash: ev.cash, dailyPnL: ev.dailyPnL, plan: ev.plan, drain: ev.drain,
|
cash: ev.cash, dailyPnL: ev.dailyPnL, plan: ev.plan, drain: ev.drain,
|
||||||
prefix: cfg.prefix, dllRoom: room,
|
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));
|
const contract = await client.findFrontMonthContract(symbol, ev.drain ? undefined : pickContextFor([sizingItem], tradeDate));
|
||||||
if (!contract) throw new Error(`no instrument for ${symbol}`);
|
if (!contract) throw new Error(`no instrument for ${symbol}`);
|
||||||
if (held.length > 0 && String(contract.id) !== [...heldContracts][0]) {
|
if (held.length > 0 && String(contract.id) !== [...heldContracts][0]) {
|
||||||
@@ -1574,7 +1678,7 @@ const _global = globalThis as typeof globalThis & { __autoTrader?: SchedulerStat
|
|||||||
|
|
||||||
function getState(): SchedulerState {
|
function getState(): SchedulerState {
|
||||||
if (!_global.__autoTrader) {
|
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;
|
return _global.__autoTrader;
|
||||||
}
|
}
|
||||||
@@ -1723,7 +1827,7 @@ export function resumeSchedulerIfPersisted(): void {
|
|||||||
if (getSetting('scheduler_running') !== '1') return;
|
if (getSetting('scheduler_running') !== '1') return;
|
||||||
|
|
||||||
const action = (getSetting('scheduler_action') ?? 'Buy') as 'Buy' | 'Sell' | 'Auto';
|
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';
|
const stopAfterAll = getSetting('scheduler_stop_after_all') === '1';
|
||||||
|
|
||||||
console.log(`[scheduler] resuming persisted schedule — ${action} ${symbol}`);
|
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 { DXTradeClient } from './dxtrade-class';
|
||||||
import type { FirmPlatform } from './master-protocol';
|
import type { FirmPlatform } from './master-protocol';
|
||||||
import { errorMessage, isClientReady, type PlatformClient } from './platform-client';
|
import { errorMessage, isClientReady, type PlatformClient } from './platform-client';
|
||||||
import { getInstruments } from './db';
|
|
||||||
import { getTradableFirms, startCatalogSync } from './firm-catalog';
|
import { getTradableFirms, startCatalogSync } from './firm-catalog';
|
||||||
|
import { firmInstruments, isFuturesFirm, prewarmMarket } from './instruments';
|
||||||
|
import { housekeepingRoot } from './instrument-specs';
|
||||||
import { resolveFirmAssignments } from './account-type-resolver';
|
import { resolveFirmAssignments } from './account-type-resolver';
|
||||||
import { resolveContracts } from './contract-resolver';
|
import { resolveContracts } from './contract-resolver';
|
||||||
import { startReporter, sendReport } from './reporter';
|
import { startReporter, sendReport } from './reporter';
|
||||||
@@ -27,6 +28,7 @@ const g = global as typeof globalThis & {
|
|||||||
__contractResolverTimer?: ReturnType<typeof setInterval>;
|
__contractResolverTimer?: ReturnType<typeof setInterval>;
|
||||||
__contractResolveRetryTimer?: ReturnType<typeof setTimeout>;
|
__contractResolveRetryTimer?: ReturnType<typeof setTimeout>;
|
||||||
__contractResolveRetries?: number;
|
__contractResolveRetries?: number;
|
||||||
|
__contractResolvedRoots?: Set<string>;
|
||||||
__clientSupervisorTimer?: ReturnType<typeof setInterval>;
|
__clientSupervisorTimer?: ReturnType<typeof setInterval>;
|
||||||
__clientSupervision?: Map<string, SupervisionEntry>;
|
__clientSupervision?: Map<string, SupervisionEntry>;
|
||||||
};
|
};
|
||||||
@@ -191,6 +193,8 @@ export function getClients(): Map<string, PlatformClient> {
|
|||||||
// sitting Unassigned without waiting for a Tradovate sync.
|
// sitting Unassigned without waiting for a Tradovate sync.
|
||||||
startCatalogSync(() => {
|
startCatalogSync(() => {
|
||||||
reconcileClients();
|
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()) {
|
for (const [firmId, client] of ensureMap()) {
|
||||||
try {
|
try {
|
||||||
resolveFirmAssignments(firmId, client);
|
resolveFirmAssignments(firmId, client);
|
||||||
@@ -203,8 +207,10 @@ export function getClients(): Map<string, PlatformClient> {
|
|||||||
reconcileClients();
|
reconcileClients();
|
||||||
console.log(`[clients] Initialized ${map.size} platform client(s)`);
|
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);
|
setTimeout(() => triggerContractResolve(), 15_000);
|
||||||
|
prewarmMarket();
|
||||||
|
|
||||||
// Schedule daily resolve at midnight
|
// Schedule daily resolve at midnight
|
||||||
scheduleDailyResolve();
|
scheduleDailyResolve();
|
||||||
@@ -242,6 +248,18 @@ export function getClients(): Map<string, PlatformClient> {
|
|||||||
const CONTRACT_RESOLVE_RETRY_MS = 60_000;
|
const CONTRACT_RESOLVE_RETRY_MS = 60_000;
|
||||||
const CONTRACT_RESOLVE_MAX_RETRIES = 30;
|
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 {
|
function triggerContractResolve(): void {
|
||||||
// A midnight run supersedes a pending retry; either way only one chain.
|
// A midnight run supersedes a pending retry; either way only one chain.
|
||||||
if (g.__contractResolveRetryTimer) clearTimeout(g.__contractResolveRetryTimer);
|
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
|
// Runs from timers: a throw here (the instruments read) would escape as
|
||||||
// an uncaught exception.
|
// an uncaught exception.
|
||||||
try {
|
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...`);
|
console.log(`[contract-resolver] Resolving ${symbols.length} instruments...`);
|
||||||
resolveContracts(symbols, accessToken)
|
resolveContracts(symbols, accessToken)
|
||||||
.then((results) => {
|
.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.
|
// Its own half hour of retries, not what's left of a chain it cuts off.
|
||||||
g.__contractResolveRetries = 0;
|
g.__contractResolveRetries = 0;
|
||||||
triggerContractResolve();
|
triggerContractResolve();
|
||||||
|
prewarmMarket();
|
||||||
}
|
}
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-15
@@ -14,9 +14,9 @@
|
|||||||
* Differences from the futures platforms that shape this file:
|
* Differences from the futures platforms that shape this file:
|
||||||
* - Account codes are strings ("clearing:account"); ids are a stable hash of
|
* - Account codes are strings ("clearing:account"); ids are a stable hash of
|
||||||
* them (dxAccountId) so every numeric account table keeps working.
|
* them (dxAccountId) so every numeric account table keeps working.
|
||||||
* - There are no CME products. The firm's symbol map (master Firms page) says
|
* - There are no CME products. The firm's instrument list (master Firms page)
|
||||||
* which DXtrade symbols a CME root may trade as, and findFrontMonthContract
|
* says which DXtrade symbols it trades, whatever root a batch drew, and
|
||||||
* picks the cheapest per unit of volatility right now (scoreCandidate).
|
* findFrontMonthContract picks among them (rankForTarget / scoreCandidate).
|
||||||
* One engine "contract" is one quantity step of the symbol, and the step
|
* 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.
|
* rides in the contract name, so the engine's target/stop math is unchanged.
|
||||||
* - Quantities are units, and positions are closed by positionCode
|
* - Quantities are units, and positions are closed by positionCode
|
||||||
@@ -38,6 +38,7 @@ import {
|
|||||||
} from './db';
|
} from './db';
|
||||||
import { getAccountConfig } from './account-type-resolver';
|
import { getAccountConfig } from './account-type-resolver';
|
||||||
import { getCachedFirm } from './firm-catalog';
|
import { getCachedFirm } from './firm-catalog';
|
||||||
|
import { firmInstruments } from './instruments';
|
||||||
import type { SymbolMapCandidate } from './master-protocol';
|
import type { SymbolMapCandidate } from './master-protocol';
|
||||||
import type { PlatformClient, PlatformAccount, PlatformContract, ClientHealth, PickContext } from './platform-client';
|
import type { PlatformClient, PlatformAccount, PlatformContract, ClientHealth, PickContext } from './platform-client';
|
||||||
import { OrderNotPlacedError, isOrderNotPlaced, errorMessage } 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
|
* The contract this firm trades right now, from its instrument list —
|
||||||
* symbol map. With a pick context (the batch's leg), each candidate is
|
* 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
|
* 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
|
* that reaches the target hold wins (else the quickest); without one, the
|
||||||
* cheapest per unit of volatility (see scoreCandidate). Sticky while an
|
* cheapest per unit of volatility (see scoreCandidate). Sticky while an
|
||||||
* account holds one of the candidates; a batch's pick is reused briefly
|
* account holds one of the candidates; a batch's pick is reused briefly
|
||||||
* so its other lookups (drain legs) agree.
|
* 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;
|
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 firm = getCachedFirm(this.cfg.firmId);
|
||||||
const map = firm?.symbolMap ?? {};
|
const candidates: SymbolMapCandidate[] = firm ? firmInstruments(firm).map((symbol) => ({ symbol })) : [];
|
||||||
const candidates = map[root.toUpperCase()] ?? map['*'] ?? [];
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (candidates.length === 0) {
|
if (candidates.length === 0) {
|
||||||
if (now - (this.unmappedLoggedAt.get(root) ?? 0) > 10 * 60_000) {
|
if (now - (this.unmappedLoggedAt.get(root) ?? 0) > 10 * 60_000) {
|
||||||
this.unmappedLoggedAt.set(root, now);
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -1194,14 +1197,12 @@ export class DXTradeClient implements PlatformClient {
|
|||||||
return pick;
|
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. */
|
* day's volatility, and the candidates the dashboard ranks. */
|
||||||
private async surveyCandidates(): Promise<void> {
|
private async surveyCandidates(): Promise<void> {
|
||||||
const all = new Map<string, SymbolMapCandidate>();
|
const firm = getCachedFirm(this.cfg.firmId);
|
||||||
for (const list of Object.values(getCachedFirm(this.cfg.firmId)?.symbolMap ?? {})) {
|
const list = firm ? firmInstruments(firm) : [];
|
||||||
for (const c of list) if (!all.has(c.symbol)) all.set(c.symbol, c);
|
if (list.length > 0) await this.pickCandidate('survey', list.map((symbol) => ({ symbol })), false);
|
||||||
}
|
|
||||||
if (all.size > 0) await this.pickCandidate('survey', [...all.values()], false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async pickCandidate(root: string, candidates: SymbolMapCandidate[], sticky: boolean, ctx?: PickContext): Promise<PlatformContract | null> {
|
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 { isFirmPlatform } from './master-protocol';
|
||||||
|
import { sanitizeInstruments } from './instrument-specs';
|
||||||
import type {
|
import type {
|
||||||
CatalogAccountType, CatalogFirm, FirmCatalogPayload, FirmPlatform, SymbolMap, SymbolMapCandidate,
|
CatalogAccountType, CatalogFirm, FirmCatalogPayload, FirmPlatform, SymbolMap, SymbolMapCandidate,
|
||||||
} from './master-protocol';
|
} from './master-protocol';
|
||||||
@@ -241,6 +242,9 @@ function validatePayload(raw: unknown): FirmCatalogPayload {
|
|||||||
id: firm.id,
|
id: firm.id,
|
||||||
name: firm.name,
|
name: firm.name,
|
||||||
platform,
|
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[]) : [],
|
bannedSymbols: Array.isArray(firm.bannedSymbols) ? (firm.bannedSymbols as string[]) : [],
|
||||||
accountTypes,
|
accountTypes,
|
||||||
serverUrl: typeof firm.serverUrl === 'string' ? firm.serverUrl : '',
|
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
|
// Only the URL is required — deliberately not instance_name, unlike the
|
||||||
// reporter. A freshly imaged machine has to be able to pull its catalog
|
// reporter. A freshly imaged machine has to be able to pull its catalog
|
||||||
// before anyone has got round to naming it.
|
// 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;
|
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 {
|
try {
|
||||||
const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
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();
|
const body = await res.json();
|
||||||
|
|
||||||
if (body && typeof body === 'object' && (body as Record<string, unknown>).unchanged === true) {
|
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_sync_at', new Date().toISOString());
|
||||||
setSetting('catalog_last_error', '');
|
setSetting('catalog_last_error', '');
|
||||||
return { ok: true, changed: false, version };
|
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_payload', JSON.stringify(payload));
|
||||||
setSetting('catalog_version', String(payload.version));
|
setSetting('catalog_version', String(payload.version));
|
||||||
|
setSetting('catalog_source_url', source);
|
||||||
setSetting('catalog_updated_at', payload.updatedAt);
|
setSetting('catalog_updated_at', payload.updatedAt);
|
||||||
setSetting('catalog_last_sync_at', new Date().toISOString());
|
setSetting('catalog_last_sync_at', new Date().toISOString());
|
||||||
setSetting('catalog_last_error', suspicious.length > 0
|
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 type { SymbolStats } from './master-protocol';
|
||||||
import {
|
import {
|
||||||
combineCmeSessions, computeDayPlan, resolveEffectiveConfig, obfuscationStopDollars, dllRoom as computeDllRoom,
|
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';
|
} from './trading-logic';
|
||||||
|
|
||||||
export type CandidateLeg = SymbolStats['symbols'][number] & {
|
export type CandidateLeg = SymbolStats['symbols'][number] & {
|
||||||
@@ -86,18 +87,14 @@ export function rankNextLeg(o: {
|
|||||||
return rankForTarget(sized, target).map((i) => sized[i]);
|
return rankForTarget(sized, target).map((i) => sized[i]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** An account's day plan and daily-loss room, worked out from scratch the
|
||||||
* The same for an account worked out from scratch — its day plan, daily-loss
|
* way the dashboard does. null without an account type or a plan. */
|
||||||
* room and stops as the dashboard computes them. null when it has no
|
function accountPlan(
|
||||||
* account type, no plan, or nothing left to trade today.
|
|
||||||
*/
|
|
||||||
export function accountNextLeg(
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
client: any,
|
client: any,
|
||||||
acc: { id: number },
|
acc: { id: number },
|
||||||
firm: FirmSizing,
|
|
||||||
tradeDate: string,
|
tradeDate: string,
|
||||||
): { leg: number; lastStand: boolean; candidates: CandidateLeg[] } | null {
|
) {
|
||||||
const cfg = getAccountConfig(acc.id).accountType;
|
const cfg = getAccountConfig(acc.id).accountType;
|
||||||
if (!cfg) return null;
|
if (!cfg) return null;
|
||||||
const cash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 };
|
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,
|
combined.dailyPnL, effective.minDayPnL, effective.minTradingDays, cash.amount - cfg.accountSize, combined.realizedPnL,
|
||||||
cfg.splitEnabled, effective.drain,
|
cfg.splitEnabled, effective.drain,
|
||||||
);
|
);
|
||||||
if (!plan || !(plan.remainingToday > 0)) return null;
|
if (!plan) return null;
|
||||||
const dll = cfg.dailyLossLimit ?? 0;
|
const dll = cfg.dailyLossLimit ?? 0;
|
||||||
let room: number | null = null;
|
let room: number | null = null;
|
||||||
if (dll > 0) {
|
if (dll > 0) {
|
||||||
@@ -121,17 +118,55 @@ export function accountNextLeg(
|
|||||||
realizedToday: Math.min(cash.realizedPnL, combined.realizedPnL),
|
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({
|
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],
|
autoLiqThreshold: client.autoLiqThresholds?.[acc.id],
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
leg: plan.remainingToday,
|
leg: p.plan.remainingToday,
|
||||||
lastStand: room !== null && lastStand({ dllRoom: room, dailyLossLimit: dll, maxLossRoom: floorRoom }),
|
lastStand: p.room !== null && lastStand({ dllRoom: p.room, dailyLossLimit: p.dll, maxLossRoom: floorRoom }),
|
||||||
candidates: rankNextLeg({
|
candidates: rankNextLeg({
|
||||||
client, accountId: acc.id, cfg, firm, leg: plan.remainingToday, dllRoom: room,
|
client, accountId: acc.id, cfg: p.cfg, firm, leg: p.plan.remainingToday, dllRoom: p.room,
|
||||||
obfuscationDollars: obfuscationStopDollars(cfg, combined.dailyPnL, plan, combined.tradeDate, plan.remainingToday),
|
obfuscationDollars: p.obfuscation, cash: p.cash,
|
||||||
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.
|
* 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 { runTrade, startScheduler, stopScheduler, getSchedulerStatus } from './auto-trade';
|
||||||
import { FIRMS, automationKey } from './automations';
|
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 { queueRun } from './queue-run';
|
||||||
import { pushBackup } from './backup-sync';
|
import { pushBackup } from './backup-sync';
|
||||||
import { getBuildVersion } from './version';
|
import { getBuildVersion } from './version';
|
||||||
@@ -35,6 +36,8 @@ const RECONNECT_MAX_MS = 60_000;
|
|||||||
|
|
||||||
interface LinkState {
|
interface LinkState {
|
||||||
socket: WebSocket | null;
|
socket: WebSocket | null;
|
||||||
|
/** The master_dashboard_url the socket was opened for. */
|
||||||
|
url: string | null;
|
||||||
/** Only true once the master has answered the hello. */
|
/** Only true once the master has answered the hello. */
|
||||||
ready: boolean;
|
ready: boolean;
|
||||||
attempt: number;
|
attempt: number;
|
||||||
@@ -47,7 +50,7 @@ const _g = globalThis as typeof globalThis & { __masterLink?: LinkState };
|
|||||||
|
|
||||||
function state(): LinkState {
|
function state(): LinkState {
|
||||||
if (!_g.__masterLink) {
|
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;
|
return _g.__masterLink;
|
||||||
}
|
}
|
||||||
@@ -56,16 +59,16 @@ function state(): LinkState {
|
|||||||
|
|
||||||
/** What this instance can be asked to do, as of right now. */
|
/** What this instance can be asked to do, as of right now. */
|
||||||
export function collectCapabilities(): InstanceCapabilities {
|
export function collectCapabilities(): InstanceCapabilities {
|
||||||
// Only symbols the operator has enabled here. An empty instruments table
|
// The roots this machine's futures firms list (the master's per-firm
|
||||||
// means nothing has been configured yet, so offer everything we can price.
|
// instrument lists); none listed yet means offer everything we can price.
|
||||||
const enabled = getInstruments().filter((i) => i.enabled).map((i) => i.symbol);
|
const listed = futuresRootsHere();
|
||||||
|
|
||||||
// Same test the AutoBuyer page uses: `at` is epoch ms from the last ping.
|
// Same test the AutoBuyer page uses: `at` is epoch ms from the last ping.
|
||||||
const heartbeat = getRunnerHeartbeat();
|
const heartbeat = getRunnerHeartbeat();
|
||||||
const runnerOnline = heartbeat !== null && Date.now() - heartbeat.at < RUNNER_TIMEOUT_MS;
|
const runnerOnline = heartbeat !== null && Date.now() - heartbeat.at < RUNNER_TIMEOUT_MS;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
symbols: enabled.length > 0 ? enabled : Object.keys(POINT_VALUES),
|
symbols: listed.length > 0 ? listed : [...CME_ROOTS],
|
||||||
automations: FIRMS.flatMap((firm) =>
|
automations: FIRMS.flatMap((firm) =>
|
||||||
firm.automations
|
firm.automations
|
||||||
.filter((a) => a.steps.length > 0)
|
.filter((a) => a.steps.length > 0)
|
||||||
@@ -96,7 +99,7 @@ export function collectCapabilities(): InstanceCapabilities {
|
|||||||
async function execute(command: Command): Promise<unknown> {
|
async function execute(command: Command): Promise<unknown> {
|
||||||
switch (command.kind) {
|
switch (command.kind) {
|
||||||
case 'trade': {
|
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}`);
|
throw new Error(`Unknown symbol: ${command.symbol}`);
|
||||||
}
|
}
|
||||||
// Same order as POST /api/trade: (re)start the scheduler first,
|
// 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. */
|
/** True when the report went up the socket, so the caller can skip the POST. */
|
||||||
export function pushReport(firms: unknown[], scheduler: unknown): boolean {
|
export function pushReport(firms: unknown[], scheduler: unknown): boolean {
|
||||||
|
followMasterUrl();
|
||||||
return send({
|
return send({
|
||||||
type: 'report',
|
type: 'report',
|
||||||
firms: firms as never,
|
firms: firms as never,
|
||||||
@@ -224,6 +238,7 @@ function connect() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
s.socket = socket;
|
s.socket = socket;
|
||||||
|
s.url = masterUrl;
|
||||||
s.ready = false;
|
s.ready = false;
|
||||||
|
|
||||||
socket.addEventListener('open', () => {
|
socket.addEventListener('open', () => {
|
||||||
|
|||||||
+39
-1
@@ -116,6 +116,37 @@ export interface FirmStats {
|
|||||||
* candidate, best first — what the master's Firms page shows beside each
|
* candidate, best first — what the master's Firms page shows beside each
|
||||||
* symbol. Optional. */
|
* symbol. Optional. */
|
||||||
symbolStats?: SymbolStats;
|
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). */
|
/** 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
|
* field — treated as 'tradovate'; a value this build doesn't know makes
|
||||||
* the sync skip the firm. */
|
* the sync skip the firm. */
|
||||||
platform: FirmPlatform;
|
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[];
|
bannedSymbols: string[];
|
||||||
accountTypes: CatalogAccountType[];
|
accountTypes: CatalogAccountType[];
|
||||||
/** DXTrade REST base, e.g. https://dx.tradeify247.co/dxsca-web ('' otherwise). */
|
/** DXTrade REST base, e.g. https://dx.tradeify247.co/dxsca-web ('' otherwise). */
|
||||||
@@ -289,7 +326,8 @@ export interface CatalogFirm {
|
|||||||
commissionPct?: number;
|
commissionPct?: number;
|
||||||
/** Trades every calendar day, weekends and CME holidays included. */
|
/** Trades every calendar day, weekends and CME holidays included. */
|
||||||
tradesEveryDay?: boolean;
|
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;
|
symbolMap?: SymbolMap;
|
||||||
/** DXTrade sizing: aim each leg's expected hold at this many hours
|
/** DXTrade sizing: aim each leg's expected hold at this many hours
|
||||||
* (0 = DEFAULT_TARGET_HOLD_HOURS, 12). */
|
* (0 = DEFAULT_TARGET_HOLD_HOURS, 12). */
|
||||||
|
|||||||
+39
-2
@@ -19,8 +19,9 @@ import { getTradableFirms } from './firm-catalog';
|
|||||||
import { getAccountConfig } from './account-type-resolver';
|
import { getAccountConfig } from './account-type-resolver';
|
||||||
import { getSchedulerStatus } from './auto-trade';
|
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 { 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 type { AccountTypeStats, FirmStats, InstrumentStats, SymbolStats } from './master-protocol';
|
||||||
import { accountNextLeg } from './leg-estimate';
|
import { accountNextLeg, accountLegShape } from './leg-estimate';
|
||||||
|
import { instrumentStatsFor, isFuturesFirm } from './instruments';
|
||||||
import { historyFresh } from './platform-client';
|
import { historyFresh } from './platform-client';
|
||||||
|
|
||||||
function collectFirmStats() {
|
function collectFirmStats() {
|
||||||
@@ -389,10 +390,46 @@ function collectFirmStats() {
|
|||||||
...(client.syncComplete ? { accounts } : {}),
|
...(client.syncComplete ? { accounts } : {}),
|
||||||
accountTypes,
|
accountTypes,
|
||||||
...symbolStatsFor(f, client, rawToday),
|
...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
|
* 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
|
* 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.
|
* Compute the next trading day's profit target for an account.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import type { AccountItem, AuthLoginResponse, Contract } from './tradovate-helpers';
|
import type { AccountItem, AuthLoginResponse, Contract } from './tradovate-helpers';
|
||||||
import { computeSec, randomUUIDV4 } 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 { getCachedContract, resolveContracts } from './contract-resolver';
|
||||||
import { saveDailyPnL, loadDailyPnL, saveAccountMeta, loadAccountMeta, saveFundTransactions, loadFundTransactions, saveEndedCycleStart } from './db';
|
import { saveDailyPnL, loadDailyPnL, saveAccountMeta, loadAccountMeta, saveFundTransactions, loadFundTransactions, saveEndedCycleStart } from './db';
|
||||||
import { brokerWebSocket } from './outbound-proxy';
|
import { brokerWebSocket } from './outbound-proxy';
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@
|
|||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
"check": "node --experimental-strip-types --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --test scripts/check-trading-logic.mjs"
|
"check": "node --experimental-strip-types --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --test scripts/check-trading-logic.mjs scripts/check-instruments.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@microsoft/signalr": "^10.0.11",
|
"@microsoft/signalr": "^10.0.11",
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
// Checks for the futures instrument specs and the Auto draw
|
||||||
|
// (lib/instrument-specs.ts).
|
||||||
|
//
|
||||||
|
// npm run check
|
||||||
|
//
|
||||||
|
// lib/instrument-specs.ts has no imports, so Node strips its types and loads
|
||||||
|
// it directly; .mjs keeps this file out of Next's type check.
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
CME_SPECS, CME_ROOTS, POINT_VALUES, MICRO_OF, tickValue, spreadDollars, dailyMoveDollars, futuresContracts,
|
||||||
|
expectedHoldHours, sessionProgress, drawChances, pickByChance, housekeepingRoot, sanitizeInstruments,
|
||||||
|
resolveAllowlist, MAX_HOLD_HOURS, DRAW_FLOOR,
|
||||||
|
} from '../lib/instrument-specs.ts';
|
||||||
|
|
||||||
|
// Deterministic PRNG so a failure reproduces.
|
||||||
|
function rng(seed) {
|
||||||
|
let s = seed >>> 0;
|
||||||
|
return () => {
|
||||||
|
s = (s + 0x6d2b79f5) >>> 0;
|
||||||
|
let t = s;
|
||||||
|
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||||
|
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('specs: the old point-value table exactly, and the tick values', () => {
|
||||||
|
// The table lib/trading-logic.ts carried until the specs replaced it.
|
||||||
|
const old = {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
assert.deepEqual({ ...POINT_VALUES }, old);
|
||||||
|
assert.equal(CME_ROOTS.length, 20);
|
||||||
|
const ticks = { NQ: 5, MNQ: 0.5, ES: 12.5, GC: 10, MGC: 1, CL: 10, SI: 25, NG: 10, ZB: 31.25, ZN: 15.625, ZF: 7.8125, '6E': 6.25, '6J': 6.25, '6B': 6.25, YM: 5, RTY: 5 };
|
||||||
|
for (const [root, value] of Object.entries(ticks)) assert.ok(Math.abs(tickValue(root) - value) < 1e-9, `${root} ${tickValue(root)}`);
|
||||||
|
assert.equal(spreadDollars('NQ'), 5);
|
||||||
|
for (const [full, micro] of Object.entries(MICRO_OF)) {
|
||||||
|
assert.ok(CME_SPECS[micro].micro && !CME_SPECS[full].micro);
|
||||||
|
assert.equal(CME_SPECS[full].pointValue / CME_SPECS[micro].pointValue, 10);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('daily move from hourly closes; contracts; expected hold', () => {
|
||||||
|
// A constant 0.2% hourly swing: σ = 0.002, × √23 × price × pv.
|
||||||
|
const closes = Array.from({ length: 60 }, (_, i) => (i % 2 ? 20000 * 1.002 : 20000));
|
||||||
|
const move = dailyMoveDollars(closes, 20);
|
||||||
|
const r = Math.log(1.002);
|
||||||
|
const sd = Math.sqrt((r * r * 59) / 58); // mean ≈ 0 over alternating ±r
|
||||||
|
assert.ok(Math.abs(move - sd * Math.sqrt(23) * closes[59] * 20) / move < 0.01, `move ${move}`);
|
||||||
|
assert.equal(dailyMoveDollars(closes.slice(0, 10), 20), null);
|
||||||
|
assert.equal(futuresContracts(1000, 0), 1);
|
||||||
|
assert.equal(futuresContracts(2400, 0), 3);
|
||||||
|
assert.equal(futuresContracts(2400, 2), 2);
|
||||||
|
// $1,000 leg, stopless, 1 contract: (1000/5227)² days ≈ 0.88 h on NQ; ≈ 8 days on 6E.
|
||||||
|
assert.ok(Math.abs(expectedHoldHours({ leg: 1000, stopDollars: Infinity, contracts: 1, dailyMove: 5227 }) - 0.878) < 0.01);
|
||||||
|
assert.ok(expectedHoldHours({ leg: 1000, stopDollars: Infinity, contracts: 1, dailyMove: 351 }) > 190);
|
||||||
|
// More contracts, bigger move, shorter hold; a stop shortens it too.
|
||||||
|
assert.ok(Math.abs(expectedHoldHours({ leg: 2000, stopDollars: Infinity, contracts: 2, dailyMove: 5227 }) - 0.878) < 0.01);
|
||||||
|
assert.ok(expectedHoldHours({ leg: 1000, stopDollars: 500, contracts: 1, dailyMove: 5227 }) < 0.5);
|
||||||
|
assert.equal(expectedHoldHours({ leg: 1000, stopDollars: Infinity, contracts: 1, dailyMove: null }), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('session progress: full CME 17:05 → 14:55 CT, equity hours 8:35 → 14:55', () => {
|
||||||
|
// 2026-09-28 is a Monday; CDT is UTC−5.
|
||||||
|
const ct = (iso) => new Date(`${iso}-05:00`);
|
||||||
|
assert.equal(sessionProgress(ct('2026-09-27T17:05'), 'full_cme'), 0);
|
||||||
|
assert.ok(Math.abs(sessionProgress(ct('2026-09-28T04:00'), 'full_cme') - 655 / 1310) < 1e-9);
|
||||||
|
assert.equal(sessionProgress(ct('2026-09-28T14:55'), 'full_cme'), 1);
|
||||||
|
assert.equal(sessionProgress(ct('2026-09-28T15:30'), 'full_cme'), null); // maintenance
|
||||||
|
assert.equal(sessionProgress(ct('2026-09-26T12:00'), 'full_cme'), null); // Saturday
|
||||||
|
assert.equal(sessionProgress(ct('2026-09-27T12:00'), 'full_cme'), null); // Sunday before the open
|
||||||
|
assert.equal(sessionProgress(ct('2026-10-02T15:10'), 'full_cme'), null); // Friday after the close
|
||||||
|
assert.equal(sessionProgress(ct('2026-09-28T08:35'), 'equity_hours'), 0);
|
||||||
|
assert.equal(sessionProgress(ct('2026-09-28T14:55'), 'equity_hours'), 1);
|
||||||
|
assert.equal(sessionProgress(ct('2026-09-28T07:00'), 'equity_hours'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Measured 2026-09-26 from Yahoo hourly closes.
|
||||||
|
const MEASURED = [
|
||||||
|
{ symbol: 'NQ', spreadDollars: 5, dailyMove: 5227, holdHours: null },
|
||||||
|
{ symbol: 'GC', spreadDollars: 10, dailyMove: 4843, holdHours: null },
|
||||||
|
{ symbol: 'CL', spreadDollars: 10, dailyMove: 3550, holdHours: null },
|
||||||
|
{ symbol: '6E', spreadDollars: 6.25, dailyMove: 351, holdHours: null },
|
||||||
|
];
|
||||||
|
const pct = (chances) => Object.fromEntries(chances.map((c) => [c.symbol, Math.round(c.chance * 1000) / 10]));
|
||||||
|
|
||||||
|
test('the draw reproduces the plan\'s table: a floor early, the fastest late', () => {
|
||||||
|
const rows = [
|
||||||
|
[0, { NQ: 42.3, GC: 25.0, CL: 21.0, '6E': 11.7 }],
|
||||||
|
[0.5, { NQ: 49.0, GC: 27.5, CL: 16.0, '6E': 7.5 }],
|
||||||
|
[0.9, { NQ: 59.9, GC: 32.5, CL: 5.7, '6E': 1.9 }],
|
||||||
|
[1, { NQ: 63.0, GC: 34.2, CL: 2.9, '6E': 0.0 }],
|
||||||
|
];
|
||||||
|
for (const [p, expected] of rows) {
|
||||||
|
const { chances } = drawChances(MEASURED, p);
|
||||||
|
const got = pct(chances);
|
||||||
|
for (const [sym, v] of Object.entries(expected)) assert.ok(Math.abs(got[sym] - v) <= 0.15, `p=${p} ${sym} ${got[sym]} vs ${v}`);
|
||||||
|
assert.ok(Math.abs(chances.reduce((s, c) => s + c.chance, 0) - 1) < 1e-9);
|
||||||
|
}
|
||||||
|
// At the open every instrument has at least the floor.
|
||||||
|
for (const c of drawChances(MEASURED, 0).chances) assert.ok(c.chance >= DRAW_FLOOR - 1e-12);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the 12-hour cap disables slow instruments; nothing fitting means no draw', () => {
|
||||||
|
const withHolds = MEASURED.map((c) => ({ ...c, holdHours: c.symbol === '6E' ? 195 : 1 }));
|
||||||
|
const { chances, disabled } = drawChances(withHolds, 0);
|
||||||
|
assert.deepEqual(disabled, ['6E']);
|
||||||
|
assert.deepEqual(chances.map((c) => c.symbol), ['NQ', 'GC', 'CL']);
|
||||||
|
const got = pct(chances);
|
||||||
|
assert.ok(Math.abs(got.NQ - 48.9) < 0.2 && Math.abs(got.GC - 28.0) < 0.2 && Math.abs(got.CL - 23.2) < 0.2, JSON.stringify(got));
|
||||||
|
assert.equal(drawChances([{ ...MEASURED[0], holdHours: MAX_HOLD_HOURS }], 0).chances.length, 0);
|
||||||
|
assert.equal(drawChances([{ ...MEASURED[0], holdHours: MAX_HOLD_HOURS - 0.01 }], 0).chances.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fallbacks: median weight when unmeasured, even when nothing is; micros only as a last resort', () => {
|
||||||
|
const one = drawChances([...MEASURED.slice(0, 3), { symbol: 'ES', spreadDollars: 12.5, dailyMove: null, holdHours: null }], 0);
|
||||||
|
const es = one.chances.find((c) => c.symbol === 'ES').chance;
|
||||||
|
const sorted = one.chances.filter((c) => c.symbol !== 'ES').map((c) => c.chance).sort();
|
||||||
|
assert.ok(es >= sorted[0] && es <= sorted[2], `ES ${es}`);
|
||||||
|
const none = drawChances(MEASURED.map((c) => ({ ...c, dailyMove: null })), 0.7);
|
||||||
|
for (const c of none.chances) assert.ok(Math.abs(c.chance - 0.25) < 1e-9);
|
||||||
|
const micros = [{ symbol: 'MNQ', spreadDollars: 0.5, dailyMove: 522, holdHours: 5, micro: true }];
|
||||||
|
assert.deepEqual(drawChances([...micros, MEASURED[0]], 0).chances.map((c) => c.symbol), ['NQ']);
|
||||||
|
assert.deepEqual(drawChances(micros, 0).chances.map((c) => c.symbol), ['MNQ']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('10k seeded draws land on the chances', () => {
|
||||||
|
const { chances } = drawChances(MEASURED, 0.5);
|
||||||
|
const r = rng(7);
|
||||||
|
const counts = {};
|
||||||
|
for (let i = 0; i < 10000; i++) {
|
||||||
|
const s = pickByChance(chances, r());
|
||||||
|
counts[s] = (counts[s] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
for (const c of chances) assert.ok(Math.abs(counts[c.symbol] / 10000 - c.chance) < 0.02, `${c.symbol} ${counts[c.symbol]} vs ${c.chance}`);
|
||||||
|
assert.equal(pickByChance([], 0.5), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('drain and extra-day legs: a micro of what is listed', () => {
|
||||||
|
assert.equal(housekeepingRoot(['NQ', 'GC', 'CL', '6E']), 'MNQ');
|
||||||
|
assert.equal(housekeepingRoot(['MNQ']), 'MNQ');
|
||||||
|
assert.equal(housekeepingRoot(['GC', 'CL']), 'MGC');
|
||||||
|
assert.equal(housekeepingRoot(['6E', 'ZN']), '6E'); // no micro: the smallest tick value
|
||||||
|
assert.equal(housekeepingRoot(['MCL', 'ZB']), 'MCL');
|
||||||
|
assert.equal(housekeepingRoot([]), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reading lists: sanitize per family; master list first, the old rules otherwise', () => {
|
||||||
|
assert.deepEqual(sanitizeInstruments('tradovate', ['nq', 'GC', 'XX', 'NQ', 5]), ['NQ', 'GC']);
|
||||||
|
assert.deepEqual(sanitizeInstruments('dxtrade', ['ETH/USD', 'BAD|X', 'ETH/USD', 'btc/usd']), ['ETH/USD', 'btc/usd']);
|
||||||
|
const local = ['CL', 'GC', 'NQ', '6E'];
|
||||||
|
assert.deepEqual(resolveAllowlist({ platform: 'tradovate', instruments: ['NQ', 'ES'], bannedSymbols: ['NQ'] }, local), { roots: ['NQ', 'ES'], source: 'master' });
|
||||||
|
assert.deepEqual(resolveAllowlist({ platform: 'tradovate', bannedSymbols: ['GC'] }, local), { roots: ['CL', 'NQ', '6E'], source: 'legacy' });
|
||||||
|
assert.deepEqual(resolveAllowlist({ platform: 'topstepx', instruments: [] }, local), { roots: [], source: 'master' });
|
||||||
|
assert.deepEqual(
|
||||||
|
resolveAllowlist({ platform: 'dxtrade', symbolMap: { NQ: [{ symbol: 'XYZ100/USD' }], '*': [{ symbol: 'ETH/USD' }, { symbol: 'XYZ100/USD' }] } }, local),
|
||||||
|
{ roots: ['ETH/USD', 'XYZ100/USD'], source: 'legacy' },
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -49,7 +49,12 @@ export interface FirmConfig {
|
|||||||
* password (Tradovate, DXTrade) or a TopstepX API key. Firms without them
|
* password (Tradovate, DXTrade) or a TopstepX API key. Firms without them
|
||||||
* are shown greyed out with an inline credential form. */
|
* are shown greyed out with an inline credential form. */
|
||||||
hasCredentials: boolean;
|
hasCredentials: boolean;
|
||||||
bannedSymbols: string[];
|
/** What the firm trades (lib/instruments.ts): CME roots for futures
|
||||||
|
* firms, its own symbols for DXtrade. */
|
||||||
|
instruments: string[];
|
||||||
|
/** 'master' = the catalog's list; 'legacy' = a master from before the
|
||||||
|
* lists, read the old way (this machine's enabled set less bans). */
|
||||||
|
instrumentsSource: 'master' | 'legacy';
|
||||||
/** The firm's account types, straight from the catalog. Doubles as the
|
/** The firm's account types, straight from the catalog. Doubles as the
|
||||||
* option list for a manual account-type override. */
|
* option list for a manual account-type override. */
|
||||||
accounts: AccountConfig[];
|
accounts: AccountConfig[];
|
||||||
|
|||||||
Reference in New Issue
Block a user