DXtrade platform for Tradeify247: percent fees, hard daily-loss stops, volatility sizing, every-day lanes

- DXtrade client (lib/dxtrade-class.ts): REST session login with lockout-safe
  backoff, 60s sync of accounts/metrics/positions/orders/transfers, history and
  fund transactions from cash transactions, day-start balance at the 22:00 UTC
  reset, CLOSE-by-positionCode exits (native OCO group, else a stop-first
  emulated pair), a cross-account hedge guard, stray-order reaper.
- Instrument selector: the firm's symbol map lists candidate symbols per CME
  root; the cheapest tradable one per unit of recent hourly volatility wins
  (spread + 2x commission [+ financing] / sigma).
- Fees: a firm's commission % of notional prices every fee off the fill; the
  take-profit is solved in closed form so the leg nets its target.
- Hard daily loss limit per account type: every leg carries a stop keeping the
  firm's day inside the limit less a 10% reserve and both fees (rounded in);
  a green day never widens it; no room or a refused bracket flattens.
- Notional sizing where the platform prices positions: target-hold volatility
  sizing or the symbol map's fixed notional, under a per-type notional cap.
- Every-day firms trade weekends/holidays; the scheduler runs lanes (futures,
  one per DXtrade firm) with their own hours, flat gate, mutex and batches.
- The catalog sync skips platforms this build doesn't know instead of treating
  them as Tradovate. One shared fireLeg replaces the duplicated batch/copy code.
- npm run check: fee/stop/sizing/hours/selector checks; scripts/dxtrade-smoke.mjs
  confirms the API against a real login before trading.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-09-26 16:04:15 -05:00
co-authored by Claude Opus 5.5
parent 49c4725e0c
commit 28f465508d
18 changed files with 3962 additions and 534 deletions
+47 -4
View File
@@ -1,7 +1,8 @@
# AutoFirmer
Automated futures trading dashboard for prop firm accounts via Tradovate and
TopstepX (ProjectX Gateway).
Automated trading dashboard for prop firm accounts via Tradovate and TopstepX
(ProjectX Gateway) for CME futures, and DXtrade for 24/7 crypto/CFD firms
(Tradeify247).
---
@@ -73,7 +74,7 @@ On first run, the SQLite database (`autotrader.sqlite`) is created automatically
Firms and their account types live on the master dashboard, not here — every
machine pulls the same catalog so they cannot drift apart. This machine only
stores the Tradovate login for each firm.
stores the platform login for each firm.
1. On the **master dashboard**, open **Firms**, add the firm, and give it one
account type per prefix + account size combination (profit target,
@@ -86,7 +87,10 @@ stores the Tradovate login for each firm.
**TopstepX** on the master asks for an API key instead (generated in the
TopstepX platform under Settings → API); the username is optional to store
but the ProjectX login needs it before the firm can connect, so add it when
you have it. Password is unused on TopstepX
you have it. Password is unused on TopstepX. A **DXtrade** firm asks for
its DXtrade username and password (the ones behind the key icon on the
firm's dashboard, not the dashboard login); the server URL and login domain
come from the master
4. Accounts populate once the firm connects. Each is matched to an account type
by its name prefix *and* its size, so a new account shows as **Unassigned**
until its opening funding transaction has been fetched. Use the **Account
@@ -94,6 +98,45 @@ stores the Tradovate login for each firm.
---
## DXtrade firms (Tradeify247)
A DXtrade firm is set up on the master's **Firms** page like any other, plus:
- **Server URL** (`https://dx.tradeify247.co/dxsca-web`) and **login domain**
(usually `default`). Changing the URL re-keys the firm's accounts.
- **Commission %** of notional per side (Tradeify: `0.04`). The engine prices
every fee off the fill with it.
- **Trades every day** — weekends and CME holidays included. Such a firm runs
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
multi-hour crypto position never holds up the futures open.
- **Symbol map** — which DXtrade symbols each CME root may trade as. A `*` row
covers every root (drain and extra-day legs trade `MNQ`, so map it or use
`*`). When a root lists several, the cheapest tradable one right now wins:
spread plus both commissions, divided by its recent hourly volatility.
- **Target hold hours** — size each leg's notional so its expected hold is
about this long (0 = the symbol map's fixed notional per $1,000 of leg), and
**financing %/day** if the firm charges swap.
Per account type: **Daily loss limit** (Tradeify: 3% of size — every leg then
carries a stop that keeps the firm's day inside it, less a 10% reserve and
fees) and **Max notional**.
Before the scheduler trades a DXtrade firm, run the smoke test against a real
login (with this instance's client for the firm disconnected):
```bash
DX_BASE=https://dx.tradeify247.co/dxsca-web DX_USER=… DX_PASS=… DX_SYMBOLS=BTC/USD,ETH/USD node scripts/dxtrade-smoke.mjs
```
It is read-only unless `DX_CONFIRM=yes DX_ACCOUNT=<code>` are set too, which
adds one minimum-size live round trip.
`npm run check` runs the pure-logic checks (fees, stops, sizing, trading
hours, the instrument selector).
---
## The master dashboard
Set **Instance Name** and **Dashboard URL** on the Settings page and this
+13 -11
View File
@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server';
import { getCachedFirm } from '@/lib/firm-catalog';
import { getCachedFirm, hasPlatformCredentials } from '@/lib/firm-catalog';
import { setCredentials, clearCredentials } from '@/lib/db';
import { reconcileClients, removeClient } from '@/lib/clients';
import { scheduleBackupPush } from '@/lib/backup-sync';
@@ -12,10 +12,11 @@ type Params = { params: Promise<{ id: string }> };
* (lib/backup-sync.ts); they are still absent from the catalog and the
* control socket.
*
* Tradovate firms need username + password. TopstepX firms need the API key;
* username is optional for now (the loginKey call still wants it, so the
* client sits idle with a clear error until one is added) and password is
* unused. */
* Tradovate and DXTrade firms need username + password (DXTrade's server URL
* and login domain are set on the master, per firm). TopstepX firms need the
* API key; username is optional for now (the loginKey call still wants it,
* so the client sits idle with a clear error until one is added) and
* password is unused. */
export async function PUT(req: NextRequest, { params }: Params) {
const { id } = await params;
@@ -27,12 +28,13 @@ export async function PUT(req: NextRequest, { params }: Params) {
const password = body.password?.trim() ?? '';
const apiKey = body.apiKey?.trim() ?? '';
if (firm.platform === 'topstepx') {
if (!apiKey) {
return NextResponse.json({ error: 'apiKey is required for a TopstepX firm' }, { status: 400 });
}
} else if (!username || !password) {
return NextResponse.json({ error: 'username and password are required' }, { status: 400 });
if (!hasPlatformCredentials(firm.platform, { username, password, apiKey })) {
const error = firm.platform === 'topstepx'
? 'apiKey is required for a TopstepX firm'
: firm.platform === 'dxtrade'
? 'username and password are required for a DXtrade firm'
: 'username and password are required';
return NextResponse.json({ error }, { status: 400 });
}
try {
+5 -4
View File
@@ -1,5 +1,5 @@
import { NextResponse } from 'next/server';
import { getCachedCatalog } from '@/lib/firm-catalog';
import { getCachedCatalog, hasPlatformCredentials } from '@/lib/firm-catalog';
import { getCredentials } from '@/lib/db';
import type { FirmConfig } from '@/types';
@@ -18,9 +18,7 @@ export async function GET() {
try {
const result: FirmConfig[] = getCachedCatalog().map((firm) => {
const creds = getCredentials(firm.id);
const hasCredentials = firm.platform === 'topstepx'
? !!creds?.apiKey
: !!(creds?.username && creds?.password);
const hasCredentials = hasPlatformCredentials(firm.platform, creds);
return {
id: firm.id,
firm: firm.name,
@@ -46,6 +44,9 @@ export async function GET() {
minDayStop: a.minDayStop,
combineCmeHolidays: a.combineCmeHolidays,
withdrawalStages: a.withdrawalStages,
dailyLossLimit: a.dailyLossLimit ?? 0,
maxNotional: a.maxNotional ?? 0,
tradesEveryDay: a.tradesEveryDay === true,
})),
catalogUpdatedAt: firm.updatedAt,
};
+32 -24
View File
@@ -6,7 +6,8 @@ import { getAccountConfig, getInferredSize, matchAccountType } from '@/lib/accou
import { historyFresh } from '@/lib/platform-client';
import {
combineCmeSessions, computeDayPlan, cycleCompletion, resolveEffectiveConfig, currentTradeDateCT,
BIG_DAY_THRESHOLD, FIRST_DAY_STOP, LEG_COMPLETION_EPSILON, MIN_DAY_STOP_MULT,
obfuscationStopDollars, dllRoom as computeDllRoom, dllStopDollars, botStopsActive,
LEG_COMPLETION_EPSILON,
} from '@/lib/trading-logic';
export async function GET() {
@@ -84,6 +85,7 @@ export async function GET() {
let remainingToday: number | null = null;
let effectiveProfitTarget: number | null = null;
let nextStop: number | null = null;
let dllRoom: number | null = null;
if (cfg && effective && !isDead) {
const equityProfit = cash.amount - cfg.accountSize;
const plan = computeDayPlan(
@@ -94,25 +96,29 @@ export async function GET() {
dailyTarget = plan ? { amount: plan.dayTarget, path: plan.path } : null;
remainingToday = plan?.remainingToday ?? null;
// The stop the next trade would carry under the obfuscation
// options, mirroring obfuscationStopDollars in auto-trade:
// day 1 keeps the day's net floor at −$1,000, min days stop
// at 2× the target, and the day after a loss rides stopless.
if (plan && (cfg.firstDayStop || cfg.minDayStop)) {
const prior = dailyPnL
.filter((d) => d.date < combined.tradeDate && d.pnl !== 0)
.sort((a, b) => a.date.localeCompare(b.date));
const recoveryDay = prior.length > 0 && prior[prior.length - 1].pnl < 0;
if (!recoveryDay) {
let stop = Infinity;
if (cfg.firstDayStop && prior.length === 0) {
stop = Math.max(0, Math.round((FIRST_DAY_STOP + combined.realizedPnL) * 100) / 100);
}
if (cfg.minDayStop && plan.dayTarget <= BIG_DAY_THRESHOLD) {
stop = Math.min(stop, MIN_DAY_STOP_MULT * plan.dayTarget);
}
if (Number.isFinite(stop) && stop > 0) nextStop = Math.round(stop * 100) / 100;
}
// The stop the next trade would carry, from the same helpers
// the fire path uses: the obfuscation stop (day 1 keeps the
// day's net floor at −$1,000, min days stop at 2× the leg,
// the day after a loss rides stopless) against the hard
// daily-loss stop, before fees. A min day trades its whole
// remaining target as one leg.
const dll = cfg.dailyLossLimit ?? 0;
if (dll > 0) {
const dayStart = client.dayStartBalance?.(acc.id);
dllRoom = dayStart === null ? null : computeDllRoom({
dailyLossLimit: dll,
closedBalance: cash.amount,
dayStartBalance: dayStart,
realizedToday: Math.min(cash.realizedPnL, combined.realizedPnL),
});
}
if (plan && (botStopsActive(cfg))) {
const obf = obfuscationStopDollars(cfg, dailyPnL, plan, combined.tradeDate, plan.remainingToday);
const hard = dllRoom !== null
? dllStopDollars({ room: dllRoom, dailyLossLimit: dll, entryFee: 0, commissionPct: 0 })
: Infinity;
const stop = Math.min(Number.isFinite(obf) && obf > 0 ? obf : Infinity, hard);
if (Number.isFinite(stop) && stop > 0) nextStop = Math.round(stop * 100) / 100;
}
// Effective profit target = max(stage target, consistency realTarget).
@@ -187,6 +193,7 @@ export async function GET() {
cycleDailyPnL: client.dailyPnL[acc.id],
rawRealizedPnL: cash.realizedPnL,
combineCmeHolidays: cfg.combineCmeHolidays,
tradesEveryDay: cfg.tradesEveryDay === true,
effective,
equityProfit: cash.amount - cfg.accountSize,
splitEnabled: cfg.splitEnabled,
@@ -202,10 +209,10 @@ export async function GET() {
// on the Tuesday after a holiday this includes Sun/Mon.
realizedPnL: combined.realizedPnL,
daysTraded,
// A stop-enabled account whose day booked red via its own
// stop — the personal DLL. Firm DLL lockouts show via
// !active instead.
personalDll: !!cfg && (cfg.firstDayStop || cfg.minDayStop)
// An account the bot puts stops on whose day booked red via
// its own stop — the personal DLL. Firm DLL lockouts show
// via !active instead.
personalDll: !!cfg && botStopsActive(cfg)
&& acc.active && !client.positions[acc.id]
&& combined.realizedPnL <= -25,
positionDirection: client.positions[acc.id]
@@ -220,6 +227,7 @@ export async function GET() {
stageDrain: effective?.drain ?? false,
dailyTarget,
nextStop,
dllRoom,
remainingToday,
effectiveProfitTarget,
dailyPnL,
+26 -8
View File
@@ -188,9 +188,19 @@ function AccountTypeCell({ account, firm, onAssigned }: {
);
}
/** What a firm's saved login is called in the dashboard's prompts. */
function credentialNoun(platform: FirmConfig['platform']): string {
switch (platform) {
case 'topstepx': return 'TopstepX API key';
case 'dxtrade': return 'DXtrade login';
case 'tradovate': return 'Tradovate login';
}
}
/** Inline credential entry, shown to the right of a firm that has none.
* Tradovate wants username + password; TopstepX wants the API key (username
* optional for now, though login needs it eventually — password unused). */
* Tradovate and DXtrade want username + password; TopstepX wants the API key
* (username optional for now, though login needs it eventually — password
* unused). */
function CredentialForm({ firm, onSaved }: { firm: FirmConfig; onSaved: () => void }) {
const isTopstepX = firm.platform === 'topstepx';
const [username, setUsername] = useState('');
@@ -231,7 +241,7 @@ function CredentialForm({ firm, onSaved }: { firm: FirmConfig; onSaved: () => vo
<div className="flex items-center gap-1.5" onClick={(e) => e.stopPropagation()}>
<input
className={cls}
placeholder={isTopstepX ? 'Username (optional)' : 'Tradovate username'}
placeholder={isTopstepX ? 'Username (optional)' : firm.platform === 'dxtrade' ? 'DXtrade username' : 'Tradovate username'}
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
@@ -375,7 +385,10 @@ function AccountRow({ account, firm, hideDead, privacy, onAssigned }: {
<span className="text-xs text-slate-400">
${fmt(account.dailyTarget.amount)} today
{account.nextStop !== null && (
<span className="text-amber-600" title="The next trade's stop — dollars it may lose before booking the day"> · stop ${fmt(account.nextStop)}</span>
<span className="text-amber-600" title="The next trade's stop — dollars of price movement it may lose before booking the day (fees come on top; the daily-loss stop already reserves for them)"> · stop ${fmt(account.nextStop)}</span>
)}
{typeof account.dllRoom === 'number' && (
<span className="text-slate-500" title="Hard daily loss limit: what the firm's day may still lose before a breach. 10% of the limit is never spent."> · DLL room ${fmt(account.dllRoom)}</span>
)}
</span>
)}
@@ -392,7 +405,7 @@ function AccountRow({ account, firm, hideDead, privacy, onAssigned }: {
: account.positionDirection
? <span className={`inline-block px-2.5 py-0.5 rounded-full text-xs font-semibold ${account.positionDirection === 'long' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>{account.positionDirection === 'long' ? 'Long' : 'Short'}</span>
: account.personalDll
? <span className="inline-block px-2.5 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-700" title="Booked the day red via its own stop — make-back rides next session">Personal DLL</span>
? <span className="inline-block px-2.5 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-700" title="Booked the day red via its own stop (obfuscation or daily-loss) — trading resumes next session">Personal DLL</span>
: account.targetHit
? <span className="inline-block px-2.5 py-0.5 rounded-full text-xs font-semibold bg-green-100 text-green-700">Target Hit</span>
: <span className="inline-block px-2.5 py-0.5 rounded-full text-xs font-semibold bg-slate-100 text-slate-500">Flat</span>}
@@ -478,6 +491,11 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo
TopstepX
</span>
)}
{firm.platform === 'dxtrade' && (
<span className="text-xs bg-emerald-100 text-emerald-700 px-1.5 py-0.5 rounded font-semibold">
DXtrade
</span>
)}
{firm.platform === 'topstepx' && hasCredentials && !firm.username && (
<span
className="text-xs bg-amber-100 text-amber-700 px-1.5 py-0.5 rounded font-semibold"
@@ -506,7 +524,7 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo
<td colSpan={5} className="px-4 py-2.5">
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
<span className="text-xs text-slate-500">
Remove the saved {firm.platform === 'topstepx' ? 'TopstepX API key' : 'Tradovate login'} for {firm.firm}? Its accounts disappear until you enter one again.
Remove the saved {credentialNoun(firm.platform)} for {firm.firm}? Its accounts disappear until you enter one again.
</span>
<button
onClick={removeCredentials}
@@ -532,7 +550,7 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo
<button
onClick={(e) => { e.stopPropagation(); setConfirmCreds(true); }}
className="text-slate-400 hover:text-red-500 hover:bg-red-50 p-1 rounded-md transition-colors inline-flex"
title={firm.platform === 'topstepx' ? 'Remove saved TopstepX API key' : 'Remove saved Tradovate login'}
title={`Remove saved ${credentialNoun(firm.platform)}`}
>
<KeyOffIcon />
</button>
@@ -554,7 +572,7 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo
state.accounts.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 py-5 pl-12 text-sm text-slate-400 italic">
{state.connected ? 'No accounts at this firm' : 'Connecting to Tradovate…'}
{state.connected ? 'No accounts at this firm' : 'Connecting…'}
</td>
</tr>
) : (
+536 -403
View File
File diff suppressed because it is too large Load Diff
+45 -16
View File
@@ -1,5 +1,7 @@
import { TradovateClient } from './tradovate-class';
import { TopstepXClient } from './topstepx-class';
import { DXTradeClient } from './dxtrade-class';
import type { FirmPlatform } from './master-protocol';
import { errorMessage, isClientReady, type PlatformClient } from './platform-client';
import { getInstruments } from './db';
import { getTradableFirms, startCatalogSync } from './firm-catalog';
@@ -43,15 +45,18 @@ function credsMap(): Map<string, string> {
return g.__tradovateClientCreds;
}
function credsSignature(firm: { platform: string; username: string; password: string; apiKey: string }): string {
return `${firm.platform} ${firm.username} ${firm.password} ${firm.apiKey}`;
/** What a client was built from — a change to any of it rebuilds the client.
* DXTrade's server URL and login domain are the firm's (master), not the
* machine's, but they decide the login just as much. */
function credsSignature(firm: { platform: string; username: string; password: string; apiKey: string; serverUrl?: string; loginDomain?: string }): string {
return `${firm.platform} ${firm.username} ${firm.password} ${firm.apiKey} ${firm.serverUrl ?? ''} ${firm.loginDomain ?? ''}`;
}
export function initClient(
id: string,
firmName: string,
platform: 'tradovate' | 'topstepx',
creds: { username: string; password: string; apiKey: string },
platform: FirmPlatform,
creds: { username: string; password: string; apiKey: string; serverUrl?: string; loginDomain?: string },
): PlatformClient {
const map = ensureMap();
const onSyncComplete = async () => {
@@ -73,9 +78,29 @@ export function initClient(
}
};
const client: PlatformClient = platform === 'topstepx'
? new TopstepXClient(creds.username, creds.apiKey, onSyncComplete)
: new TradovateClient(creds.username, creds.password, onSyncComplete);
let client: PlatformClient;
switch (platform) {
case 'topstepx':
client = new TopstepXClient(creds.username, creds.apiKey, onSyncComplete);
break;
case 'dxtrade':
client = new DXTradeClient({
firmId: id,
firmName,
serverUrl: creds.serverUrl ?? '',
loginDomain: creds.loginDomain ?? '',
username: creds.username,
password: creds.password,
}, onSyncComplete);
break;
case 'tradovate':
client = new TradovateClient(creds.username, creds.password, onSyncComplete);
break;
default: {
const unknown: never = platform;
throw new Error(`no client for platform ${String(unknown)}`);
}
}
map.set(id, client);
credsMap().set(id, credsSignature({ platform, ...creds }));
return client;
@@ -316,11 +341,16 @@ const SUPERVISOR_INTERVAL_MS = 60_000;
const REBUILD_AFTER_UNREADY_MS = 20 * 60_000;
const REBUILD_COOLDOWN_MS = 20 * 60_000;
/** The message TopstepXClient records when a firm has an API key but no
* username. That client never even tries to log in, so a rebuild changes
* nothing — adding the username changes the credentials, and
* reconcileClients rebuilds it then. */
const TOPSTEPX_USERNAME_MISSING = /username missing/i;
/** Clients that can't log in until someone acts — a rebuild changes nothing,
* and fixing the credentials (or the firm's settings) rebuilds them through
* reconcileClients anyway. TopstepX with an API key but no username never
* tries to log in; DXTrade with no server URL can't, and after refused
* credentials it stops trying so as not to lock the account. */
function needsAttention(platform: FirmPlatform, client: PlatformClient): boolean {
if (platform === 'topstepx') return /username missing/i.test(client.lastFetchErrors?.['login'] ?? '');
if (platform === 'dxtrade') return (client as DXTradeClient).needsAttention === true;
return false;
}
interface SupervisionEntry {
/** The instance being judged. A different one (rebuilt here, or replaced
@@ -383,8 +413,8 @@ function rebuildHold(platform: string, client: PlatformClient, now: number): str
+ ` (${health.consecutiveLoginFailures} failure(s) in a row)`;
}
} catch { /* no health: judge it on the rest */ }
if (platform === 'topstepx' && Object.keys(client.positions ?? {}).length > 0) {
return 'it holds an open position (TopstepX exit brackets are tracked only inside the client)';
if ((platform === 'topstepx' || platform === 'dxtrade') && Object.keys(client.positions ?? {}).length > 0) {
return 'it holds an open position (its exit pairs are tracked only inside the client)';
}
return null;
}
@@ -429,8 +459,7 @@ function superviseClients(): void {
firms ??= getTradableFirms();
const firm = firms.find((f) => f.id === id);
if (!firm) continue; // no longer tradable: reconcileClients drops it
if (firm.platform === 'topstepx'
&& TOPSTEPX_USERNAME_MISSING.test(client.lastFetchErrors?.['login'] ?? '')) continue;
if (needsAttention(firm.platform, client)) continue;
const minutes = Math.round((now - entry.unhealthySince) / 60_000);
const hold = rebuildHold(firm.platform, client, now);
+1687
View File
File diff suppressed because it is too large Load Diff
+270
View File
@@ -0,0 +1,270 @@
/**
* Pure helpers for the DXTrade client (lib/dxtrade-class.ts): account ids,
* contract names, the firm-day clock, parsing the API's loosely typed
* answers, and the instrument selector's volatility and cost scoring.
*
* No imports beyond node built-ins, so scripts/check-trading-logic.mjs can
* test them directly.
*/
import { createHash } from 'crypto';
// ── account ids ──────────────────────────────────────────────────────────────
/**
* A stable numeric id for a DXTrade account code ("clearing:account"). Every
* account table, route and backup keys on a number, and DXTrade codes are
* strings, so the code is hashed — deterministically, which is what lets a
* backup restored onto a new machine find its manual assignments again (a
* per-machine id table would renumber them).
*
* 52 bits of SHA-256 folded into [2^50, 2^53): safe integers, far above any
* Tradovate or TopstepX id. The host is part of the key, so the same code on
* two DXTrade servers never collides — and changing a firm's server URL
* re-keys its accounts.
*/
export function dxAccountId(host: string, code: string): number {
const digest = createHash('sha256').update(`dxtrade|${host.toLowerCase()}|${code}`).digest();
return 2 ** 50 + digest.readUIntBE(0, 6) * 16 + (digest[6] >> 4);
}
/** "default:TFY-50K-123" → "TFY-50K-123": what the dashboard shows and what
* account-type prefixes match against. */
export function accountDisplayName(code: string): string {
const i = code.indexOf(':');
return i >= 0 && i < code.length - 1 ? code.slice(i + 1) : code;
}
/** DXTrade answers carry account codes either as "clearing:account" or as an
* {clearing, account} object, depending on the endpoint's serializer. */
export function accountCodeOf(raw: unknown): string | null {
if (typeof raw === 'string' && raw) return raw;
if (raw && typeof raw === 'object') {
const r = raw as { clearing?: unknown; account?: unknown };
if (typeof r.account === 'string') return typeof r.clearing === 'string' ? `${r.clearing}:${r.account}` : r.account;
}
return null;
}
// ── contract names ───────────────────────────────────────────────────────────
/** An engine "contract" on DXTrade is one quantity step of a symbol; the
* step rides in the name the engine hands back to sendOrder, which keeps
* order placement stateless. Symbols never contain '|' (the master refuses
* them). */
export function encodeContractName(symbol: string, step: number): string {
return `${symbol}|${step}`;
}
export function decodeContractName(name: string): { symbol: string; step: number } | null {
const i = name.lastIndexOf('|');
if (i <= 0) return null;
const step = Number(name.slice(i + 1));
if (!Number.isFinite(step) || step <= 0) return null;
return { symbol: name.slice(0, i), step };
}
/** Decimal places of a step like 0.001 (or 1e-7), for exact rounding. */
export function decimalsOf(step: number): number {
if (!Number.isFinite(step) || step <= 0) return 0;
const s = step.toString();
const exp = s.match(/e-(\d+)$/);
if (exp) return Number(exp[1]) + (s.split('e')[0].split('.')[1]?.length ?? 0);
return s.split('.')[1]?.length ?? 0;
}
/** A multiple of `step` without float fuzz (0.1 + 0.2 style). */
export function roundToStep(value: number, step: number, mode: 'nearest' | 'down' | 'up' = 'nearest'): number {
if (!(step > 0)) return value;
const n = value / step;
const k = mode === 'down' ? Math.floor(n + 1e-9) : mode === 'up' ? Math.ceil(n - 1e-9) : Math.round(n);
return Number((k * step).toFixed(decimalsOf(step)));
}
// ── clocks ───────────────────────────────────────────────────────────────────
/** The most recent 22:00 UTC at or before `now` — when Tradeify (and DXtrade
* brokers generally) snapshot the closed balance and start a new day. */
export function lastUtcReset(now: Date, hourUtc = 22): Date {
const reset = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), hourUtc));
if (reset.getTime() > now.getTime()) reset.setUTCDate(reset.getUTCDate() - 1);
return reset;
}
/** When the current Central trade date began (5 PM CT) — the window today's
* realized P&L covers, matching currentTradeDateCT. */
export function ctTradeDayStart(now: Date): Date {
const parts = new Intl.DateTimeFormat('en-US', {
hour: 'numeric', minute: 'numeric', second: 'numeric', hour12: false, timeZone: 'America/Chicago',
}).formatToParts(now);
const get = (t: string) => Number(parts.find((p) => p.type === t)?.value ?? '0');
const secondsIntoDay = (get('hour') % 24) * 3600 + get('minute') * 60 + get('second');
const sinceRoll = (secondsIntoDay - 17 * 3600 + 86_400) % 86_400;
return new Date(Math.floor(now.getTime() / 1000) * 1000 - sinceRoll * 1000);
}
/** DXTrade timestamps: ISO strings on the wire (the schema calls them
* objects); epoch ms tolerated. NaN when unreadable. */
export function timeOf(raw: unknown): number {
if (typeof raw === 'number') return raw;
if (typeof raw === 'string') return Date.parse(raw);
return NaN;
}
/** The login answer's inactivity timeout, in ms: "PT30M", "01:05:08", a
* {seconds} object or a number of seconds. Falls back to 30 minutes. */
export function parseDurationMs(raw: unknown): number {
const fallback = 30 * 60_000;
if (typeof raw === 'number' && raw > 0) return raw * 1000;
if (raw && typeof raw === 'object') {
const s = (raw as { seconds?: unknown }).seconds;
if (typeof s === 'number' && s > 0) return s * 1000;
}
if (typeof raw === 'string') {
const iso = raw.match(/^P(?:T)?(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?$/i);
if (iso && (iso[1] || iso[2] || iso[3])) {
return ((Number(iso[1] ?? 0) * 60 + Number(iso[2] ?? 0)) * 60 + Number(iso[3] ?? 0)) * 1000;
}
const hms = raw.match(/^(\d+):(\d{2}):(\d{2})$/);
if (hms) return ((Number(hms[1]) * 60 + Number(hms[2])) * 60 + Number(hms[3])) * 1000;
}
return fallback;
}
// ── loose parsing ────────────────────────────────────────────────────────────
/** A number field read case-insensitively — the live server says openPL
* where the docs say openPl. */
export function ciNum(obj: unknown, key: string): number | undefined {
if (!obj || typeof obj !== 'object') return undefined;
const want = key.toLowerCase();
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
if (k.toLowerCase() === want && typeof v === 'number' && Number.isFinite(v)) return v;
}
return undefined;
}
/** Deposits and withdrawals move money in or out; every other cash
* transaction (SETTLEMENT, COMMISSION, FINANCING, ADJUSTMENT…) is trading. */
export function isFundType(type: unknown): boolean {
return type === 'DEPOSIT' || type === 'WITHDRAWAL';
}
/** A withdrawal always takes money out, whatever sign the server files it with. */
export function signedFundValue(type: string, value: number): number {
return type === 'WITHDRAWAL' ? -Math.abs(value) : Math.abs(value);
}
/** A client order code: unique, ≤ 64 chars, and recognisably ours. */
export function newOrderCode(tag: string): string {
const rand = Math.random().toString(36).slice(2, 8);
return `af-${Date.now().toString(36)}-${rand}-${tag}`.slice(0, 64);
}
// ── trading hours ────────────────────────────────────────────────────────────
const WEEKDAYS = ['SUNDAY', 'MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY'];
/**
* Whether an instrument's sessions have it open at `now`. Sessions are
* SESSION_OPEN / SESSION_CLOSE events on a UTC week; the latest event at or
* before now (wrapping around the week) decides. No sessions, or any it
* can't read, means always open — 24/7 crypto lists none.
*/
export function inTradingHours(sessions: unknown, now: Date): boolean {
if (!Array.isArray(sessions) || sessions.length === 0) return true;
const events: { at: number; open: boolean }[] = [];
for (const s of sessions) {
const e = s as { eventType?: unknown; weekDay?: { dayOfWeek?: unknown; time?: { hour?: unknown; minute?: unknown } } };
const day = WEEKDAYS.indexOf(String(e.weekDay?.dayOfWeek ?? ''));
const hour = Number(e.weekDay?.time?.hour ?? NaN);
const minute = Number(e.weekDay?.time?.minute ?? 0);
if (day < 0 || !Number.isFinite(hour) || (e.eventType !== 'SESSION_OPEN' && e.eventType !== 'SESSION_CLOSE')) return true;
events.push({ at: day * 1440 + hour * 60 + minute, open: e.eventType === 'SESSION_OPEN' });
}
events.sort((a, b) => a.at - b.at);
const t = now.getUTCDay() * 1440 + now.getUTCHours() * 60 + now.getUTCMinutes();
const past = events.filter((e) => e.at <= t);
const latest = past.length > 0 ? past[past.length - 1] : events[events.length - 1];
return latest.open;
}
// ── instrument selector ──────────────────────────────────────────────────────
/** Fewest hourly candles a volatility estimate is trusted on. */
export const MIN_VOL_CANDLES = 24;
/**
* σ of hourly log returns (per √hour) from hourly closes, oldest first, in
* wall-clock time — flat hours count, so an instrument that sits still all
* weekend really does read as quiet on a weekend. null on too few candles.
*/
export function realizedVolPerHour(closes: number[]): number | null {
const clean = closes.filter((c) => Number.isFinite(c) && c > 0);
if (clean.length < MIN_VOL_CANDLES + 1) return null;
const returns: number[] = [];
for (let i = 1; i < clean.length; i++) returns.push(Math.log(clean[i] / clean[i - 1]));
const mean = returns.reduce((s, r) => s + r, 0) / returns.length;
const variance = returns.reduce((s, r) => s + (r - mean) ** 2, 0) / (returns.length - 1);
return Math.sqrt(variance);
}
/**
* A candidate's cost score — lower is better.
*
* Volatility sizing (targetHoldHours > 0): a leg sized for a hold of T hours
* needs notional ∝ 1/σ, so its cost — spread plus both commissions plus
* financing over the hold, all proportional to notional — is proportional to
* (spread + 2c + f·T/24) / σ. A volatile instrument reaches the take-profit
* with less notional, which is what makes a tight spread alone not enough.
*
* Fixed sizing: the notional is set by the symbol map, so only the costs per
* dollar of notional matter.
*
* null = can't be scored (volatility sizing without a σ).
*/
export function scoreCandidate(o: {
spreadFraction: number;
commissionPct: number;
financingPctPerDay: number;
targetHoldHours: number;
volatilityPerHour: number | null;
}): number | null {
const perNotional = o.spreadFraction + 2 * (o.commissionPct / 100);
if (o.targetHoldHours > 0) {
if (!o.volatilityPerHour || !(o.volatilityPerHour > 0)) return null;
const financing = (o.financingPctPerDay / 100) * (o.targetHoldHours / 24);
return (perNotional + financing) / o.volatilityPerHour;
}
return perNotional;
}
/** Most a spread may be, as a share of the mid price, for a candidate to trade. */
export const MAX_SPREAD_FRACTION = 0.005;
/** A quote older than this doesn't count as live. */
export const MAX_QUOTE_AGE_MS = 5 * 60_000;
/** Why a candidate can't trade right now, or null when it can. */
export function candidateRejection(o: {
tradingStatus: string | undefined;
bid: number | undefined;
ask: number | undefined;
quoteAgeMs: number | null;
inHours: boolean;
candles: number;
needVolatility: boolean;
minContractsOk: boolean;
currency: string | undefined;
accountCurrency: string;
}): string | null {
if (o.tradingStatus !== undefined && o.tradingStatus !== 'FULL') return `trading status ${o.tradingStatus}`;
if (!o.inHours) return 'outside trading hours';
if (!(typeof o.bid === 'number' && o.bid > 0 && typeof o.ask === 'number' && o.ask >= o.bid)) return 'no live quote';
if (o.quoteAgeMs !== null && o.quoteAgeMs > MAX_QUOTE_AGE_MS) return `quote ${Math.round(o.quoteAgeMs / 60_000)}m old`;
const mid = (o.bid + o.ask) / 2;
if ((o.ask - o.bid) / mid > MAX_SPREAD_FRACTION) return `spread ${(((o.ask - o.bid) / mid) * 100).toFixed(2)}%`;
if (o.needVolatility && o.candles < MIN_VOL_CANDLES + 1) return `only ${o.candles} hourly candles`;
if (!o.minContractsOk) return 'minimum order size out of reach';
if (o.currency && o.currency !== o.accountCurrency) return `priced in ${o.currency}`;
return null;
}
+88 -18
View File
@@ -17,7 +17,10 @@
* the two are not linked yet.)
*/
import type { CatalogAccountType, CatalogFirm, FirmCatalogPayload } from './master-protocol';
import { isFirmPlatform } from './master-protocol';
import type {
CatalogAccountType, CatalogFirm, FirmCatalogPayload, FirmPlatform, SymbolMap, SymbolMapCandidate,
} from './master-protocol';
import {
getSetting,
setSetting,
@@ -99,26 +102,70 @@ export function getCatalogMeta(): {
};
}
/** Whether stored credentials are what the firm's platform logs in with — a
* TopstepX API key, or a username + password (Tradovate, DXTrade). */
export function hasPlatformCredentials(
platform: FirmPlatform,
creds: { username: string; password: string; apiKey: string } | null | undefined,
): boolean {
if (!creds) return false;
switch (platform) {
case 'topstepx': return !!creds.apiKey;
case 'tradovate':
case 'dxtrade': return !!creds.username && !!creds.password;
}
}
/** Firms this machine can actually trade: in the catalog AND holding the
* credentials their platform needs — a Tradovate username + password, or a
* TopstepX API key. Everything that starts a platform client goes through
* here. */
* credentials their platform needs. Everything that starts a platform client
* goes through here. */
export function getTradableFirms(): (CatalogFirm & { username: string; password: string; apiKey: string })[] {
const result: (CatalogFirm & { username: string; password: string; apiKey: string })[] = [];
for (const firm of getCachedCatalog()) {
const creds = getCredentials(firm.id);
if (!creds) continue;
if (firm.platform === 'topstepx') {
if (creds.apiKey) result.push({ ...firm, ...creds });
} else {
if (creds.username && creds.password) result.push({ ...firm, ...creds });
}
if (creds && hasPlatformCredentials(firm.platform, creds)) result.push({ ...firm, ...creds });
}
return result;
}
// ── Sync ─────────────────────────────────────────────────────────────────────
const g2 = global as typeof globalThis & { __catalogUnknownPlatformWarned?: Set<string> };
/** Once per firm per process — the catalog is re-validated every 60s. */
function warnUnknownPlatform(id: string, name: string, platform: unknown): void {
const warned = (g2.__catalogUnknownPlatformWarned ??= new Set());
const key = `${id}|${String(platform)}`;
if (warned.has(key)) return;
warned.add(key);
console.warn(`[catalog] skipping "${name}": platform ${JSON.stringify(platform)} is unknown to this build — update this instance`);
}
function nonNegative(value: unknown): number {
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0;
}
/** Drops anything malformed rather than failing the sync: a bad symbol-map
* entry only means that candidate isn't traded. */
function sanitizeSymbolMap(raw: unknown): SymbolMap {
const map: SymbolMap = {};
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return map;
for (const [root, list] of Object.entries(raw as Record<string, unknown>)) {
if (!Array.isArray(list)) continue;
const candidates = list.flatMap((c): SymbolMapCandidate[] => {
if (typeof c !== 'object' || c === null) return [];
const cand = c as Record<string, unknown>;
if (typeof cand.symbol !== 'string' || !cand.symbol || cand.symbol.includes('|')) return [];
const notional = nonNegative(cand.notionalPerContract);
const pct = typeof cand.commissionPct === 'number' && Number.isFinite(cand.commissionPct) && cand.commissionPct >= 0
? cand.commissionPct : undefined;
return [{ symbol: cand.symbol, notionalPerContract: notional, ...(pct !== undefined ? { commissionPct: pct } : {}) }];
});
if (candidates.length > 0) map[root.toUpperCase()] = candidates;
}
return map;
}
function isStages(value: unknown): value is { profit: number; consistency: number; minTradingDays: number }[] {
return Array.isArray(value) && value.every((s) =>
typeof s === 'object' && s !== null
@@ -136,12 +183,24 @@ function validatePayload(raw: unknown): FirmCatalogPayload {
if (typeof body.updatedAt !== 'string') throw new Error('missing updatedAt');
if (!Array.isArray(body.firms)) throw new Error('missing firms');
const firms: CatalogFirm[] = body.firms.map((f: unknown, i: number) => {
const firms: CatalogFirm[] = [];
body.firms.forEach((f: unknown, i: number) => {
if (typeof f !== 'object' || f === null) throw new Error(`firm ${i} is not an object`);
const firm = f as Record<string, unknown>;
if (typeof firm.id !== 'string' || typeof firm.name !== 'string') {
throw new Error(`firm ${i} is missing id or name`);
}
// An old master sends no platform: that is Tradovate. A platform this
// build doesn't know is NOT — coercing it would start the wrong client
// with another platform's credentials. Skip the firm (before its
// account types can fail the whole sync); it comes back once this
// build knows the platform.
const platform = firm.platform === undefined ? 'tradovate' : firm.platform;
if (!isFirmPlatform(platform)) {
warnUnknownPlatform(firm.id, firm.name, platform);
return;
}
const tradesEveryDay = firm.tradesEveryDay === true;
if (!Array.isArray(firm.accountTypes)) throw new Error(`firm ${firm.name} is missing accountTypes`);
const accountTypes: CatalogAccountType[] = firm.accountTypes.map((t: unknown, j: number) => {
@@ -163,26 +222,37 @@ function validatePayload(raw: unknown): FirmCatalogPayload {
return {
...type,
splitEnabled: type.splitEnabled !== false,
combineCmeHolidays: type.combineCmeHolidays === true,
// An every-day firm has no CME calendar to fold holidays by.
combineCmeHolidays: type.combineCmeHolidays === true && !tradesEveryDay,
// Lenient: a master from before the min-legs field means "no minimum".
minLegs: typeof type.minLegs === 'number' ? type.minLegs : 0,
// Lenient: the obfuscation stops are opt-in, so absent means off.
firstDayStop: type.firstDayStop === true,
minDayStop: type.minDayStop === true,
// Lenient: no hard daily limit, no notional cap.
dailyLossLimit: nonNegative(type.dailyLossLimit),
maxNotional: nonNegative(type.maxNotional),
// The firm's calendar, stamped on every type so the cfg-based
// paths (projections, completion, the dashboard) all see it.
tradesEveryDay,
} as unknown as CatalogAccountType;
});
return {
firms.push({
id: firm.id,
name: firm.name,
// Lenient on purpose: a master from before the platform field (or
// with a platform this build doesn't know) must not fail the whole
// sync, and 'tradovate' is what every firm was until then.
platform: firm.platform === 'topstepx' ? 'topstepx' : 'tradovate',
platform,
bannedSymbols: Array.isArray(firm.bannedSymbols) ? (firm.bannedSymbols as string[]) : [],
accountTypes,
serverUrl: typeof firm.serverUrl === 'string' ? firm.serverUrl : '',
loginDomain: typeof firm.loginDomain === 'string' ? firm.loginDomain : '',
commissionPct: nonNegative(firm.commissionPct),
tradesEveryDay,
symbolMap: sanitizeSymbolMap(firm.symbolMap),
targetHoldHours: nonNegative(firm.targetHoldHours),
financingPctPerDay: nonNegative(firm.financingPctPerDay),
updatedAt: typeof firm.updatedAt === 'string' ? firm.updatedAt : new Date(0).toISOString(),
};
});
});
return { version: body.version, updatedAt: body.updatedAt, firms };
+54 -4
View File
@@ -205,19 +205,69 @@ export interface CatalogAccountType {
payoutCap?: number;
/** Share of the gross the person actually receives (default 0.9). */
profitSplit?: number;
/** Hard daily loss limit in dollars (0 = none). Every leg then carries a
* stop that keeps the firm's day from losing more than this, less a 10%
* reserve and fees. Optional: an older master doesn't send it. */
dailyLossLimit?: number;
/** Cap on one position's notional in dollars (0 = none), where the
* platform prices positions (DXTrade). */
maxNotional?: number;
/** Copied from the firm by the catalog sync, so every cfg-based path sees
* it: the firm trades every calendar day (24/7). */
tradesEveryDay?: boolean;
/** ISO. Ties between two matching account types are broken by this. */
updatedAt: string;
}
/** Where a firm's accounts trade. A build only knows the platforms listed
* here; the catalog sync skips a firm on any other rather than guess. */
export const FIRM_PLATFORMS = ['tradovate', 'topstepx', 'dxtrade'] as const;
export type FirmPlatform = (typeof FIRM_PLATFORMS)[number];
export function isFirmPlatform(value: unknown): value is FirmPlatform {
return typeof value === 'string' && (FIRM_PLATFORMS as readonly string[]).includes(value);
}
/** One symbol a DXTrade firm may trade for a CME root. */
export interface SymbolMapCandidate {
/** The platform's symbol, e.g. "BTC/USD". */
symbol: string;
/** Fixed sizing: notional per $1,000 of leg (like futures' one contract
* per $1,000). Used when volatility sizing is off or can't run. */
notionalPerContract: number;
/** Per-side commission % for this symbol; absent = the firm's. */
commissionPct?: number;
}
/** CME root the batch drew (NQ, MNQ, GC…) or '*' for any root not listed →
* the candidates to choose from, cheapest per unit of volatility wins. */
export type SymbolMap = Record<string, SymbolMapCandidate[]>;
export interface CatalogFirm {
id: string;
name: string;
/** Which platform the firm's accounts trade on. Only 'tradovate' firms get
* a client here; anything else is synced but sits idle until its trading
* logic exists. Older masters omit the field — treated as 'tradovate'. */
platform: 'tradovate' | 'topstepx';
/** Which platform the firm's accounts trade on. Older masters omit the
* field — treated as 'tradovate'; a value this build doesn't know makes
* the sync skip the firm. */
platform: FirmPlatform;
bannedSymbols: string[];
accountTypes: CatalogAccountType[];
/** DXTrade REST base, e.g. https://dx.tradeify247.co/dxsca-web ('' otherwise). */
serverUrl?: string;
/** DXTrade login domain ('' = 'default'). */
loginDomain?: string;
/** Commission as % of notional per side (e.g. 0.04). 0 = the platform's
* per-contract fees, measured off each fill. */
commissionPct?: number;
/** Trades every calendar day, weekends and CME holidays included. */
tradesEveryDay?: boolean;
/** DXTrade: which symbols each CME root may trade as. */
symbolMap?: SymbolMap;
/** DXTrade volatility sizing: aim each leg's expected hold at this many
* hours (0 = fixed sizing from the symbol map). */
targetHoldHours?: number;
/** Overnight financing the firm charges, % of notional per day (0 = none). */
financingPctPerDay?: number;
updatedAt: string;
}
+39 -7
View File
@@ -4,11 +4,14 @@
* reporter, state route and resolver can drive either platform without caring
* which is behind the map entry.
*
* Contract ids are `number` on Tradovate and `string` (e.g.
* "CON.F.US.ENQ.Z25") on TopstepX — everything that compares them only ever
* compares within one client, so the union is safe.
* Contract ids are `number` on Tradovate and `string` on TopstepX (e.g.
* "CON.F.US.ENQ.Z25") and DXTrade (the symbol, e.g. "BTC/USD") — everything
* that compares them only ever compares within one client, so the union is
* safe.
*/
import type { FirmPlatform } from './master-protocol';
export interface PlatformAccount {
id: number;
name: string;
@@ -19,14 +22,33 @@ export interface PlatformContract {
id: number | string;
name: string;
tickSize: number;
/** Dollars per 1.0 of price per engine contract. Absent = the CME
* product's POINT_VALUES entry (futures). */
pointValue?: number;
/** Commission as % of notional per side, where the firm charges that way. */
commissionPct?: number;
/** Notional of one engine contract at the current mid — present where the
* platform prices positions, which is what enables notional sizing. */
stepNotional?: number;
/** Fixed sizing: notional per $1,000 of leg (the symbol map's setting). */
fixedNotional?: number;
/** σ of log returns per √hour, from recent candles. */
volatilityPerHour?: number;
/** (ask − bid) / mid when the contract was picked. */
spreadFraction?: number;
/** Largest order the broker takes, in engine contracts. */
maxContracts?: number;
/** Smallest order the broker takes, in engine contracts. */
minContracts?: number;
}
export interface PlatformClient {
accountList: PlatformAccount[];
/** realizedPnL is today's. pendingRoll (Tradovate): the broker still shows
* the ended day's record and today's share of it can't be told apart —
* the account waits for the broker to roll it. */
accountCashBalances: { [accountId: number]: { amount: number; realizedPnL: number; pendingRoll?: boolean } };
* the account waits for the broker to roll it. equity: balance plus open
* P&L, where the platform reports it. */
accountCashBalances: { [accountId: number]: { amount: number; realizedPnL: number; pendingRoll?: boolean; equity?: number } };
positions: {
[accountId: number]: {
contractId: number | string;
@@ -69,6 +91,15 @@ export interface PlatformClient {
* read as a fresh stage-1 cycle, so such an account must not trade.
*/
historyReady(accountId: number): boolean;
/**
* The account's closed balance at the firm's last daily reset — what a
* hard daily loss limit is measured from. Only platforms that know the
* firm's own day implement it (DXTrade: 22:00 UTC); the rest leave it out
* and the engine falls back to the session's realized P&L. null = the
* platform knows it but can't tell yet (history not fetched): hold the
* account rather than size a stop on a guess.
*/
dayStartBalance?(accountId: number): number | null;
/** Drop the account's history from memory (the wipe routes), so it reads
* unloaded until the next fetch rebuilds it. */
forgetHistory(accountId: number, accountName: string): void;
@@ -113,10 +144,11 @@ export interface PlatformClient {
* Timestamps are epoch ms, 0 = never. Never carries tokens or credentials.
*/
export interface ClientHealth {
platform: 'tradovate' | 'topstepx';
platform: FirmPlatform;
/** Holding a session token right now. */
authenticated: boolean;
/** Tradovate: socket readyState name; TopstepX: hub state. 'none' = no transport. */
/** Tradovate: socket readyState name; TopstepX: hub state; DXTrade: 'rest'
* or its push socket's state. 'none' = no transport. */
transport: string;
syncComplete: boolean;
lastSyncAt: number;
+8 -6
View File
@@ -18,7 +18,7 @@ import { getClients } from './clients';
import { getTradableFirms } from './firm-catalog';
import { getAccountConfig } from './account-type-resolver';
import { getSchedulerStatus } from './auto-trade';
import { combineCmeSessions, computeDayPlan, currentTradeDateCT, cycleCompletion, endedCycleCompletion, LEG_COMPLETION_EPSILON, projectPerfectPath, resolveEffectiveConfig } from './trading-logic';
import { botStopsActive, combineCmeSessions, computeDayPlan, currentTradeDateCT, cycleCompletion, endedCycleCompletion, LEG_COMPLETION_EPSILON, projectPerfectPath, resolveEffectiveConfig } from './trading-logic';
import type { AccountTypeStats, FirmStats } from './master-protocol';
import { historyFresh } from './platform-client';
@@ -288,6 +288,7 @@ function collectFirmStats() {
cycleDailyPnL: client.dailyPnL[acc.id],
rawRealizedPnL: cash.realizedPnL,
combineCmeHolidays: cfg.combineCmeHolidays,
tradesEveryDay: cfg.tradesEveryDay === true,
effective,
equityProfit: cash.amount - cfg.accountSize,
splitEnabled: cfg.splitEnabled,
@@ -349,11 +350,12 @@ function collectFirmStats() {
slice.accountsTraded++;
// A grind-day fill (dayTarget 0) can flag targetHit on a
// negative day — a loss is still a loss, so red wins.
// A red day on a stop-enabled account is its own personal
// DLL, booked on purpose — not a genuine loss. (A firm DLL
// lockout took the !acc.active branch above and stays a
// loss.)
if ((cfg.firstDayStop || cfg.minDayStop) && combined.realizedPnL <= -25) {
// A red day on an account the bot puts stops on (the
// obfuscation stops, or a hard daily loss limit) is its own
// personal DLL, booked on purpose — not a genuine loss. (A
// firm DLL lockout took the !acc.active branch above and
// stays a loss.)
if (botStopsActive(cfg) && combined.realizedPnL <= -25) {
personalDllCount++;
slice.personalDll = (slice.personalDll ?? 0) + 1;
} else if (combined.realizedPnL < 0) {
+389 -21
View File
@@ -366,13 +366,22 @@ export interface ProjectedDay {
net?: number;
}
/** Next weekday after d (YYYY-MM-DD). Trade dates never fall on Sat/Sun. */
function nextTradingDate(d: string): string {
/** Next trade date after d (YYYY-MM-DD): the next weekday on the CME calendar,
* where trade dates never fall on Sat/Sun — or simply the next calendar day
* for a firm that trades every day (24/7 crypto/CFD, e.g. Tradeify247). */
export function nextTradingDate(d: string, everyDay = false): string {
const dt = new Date(`${d}T00:00:00Z`);
do { dt.setUTCDate(dt.getUTCDate() + 1); } while (dt.getUTCDay() === 0 || dt.getUTCDay() === 6);
do { dt.setUTCDate(dt.getUTCDate() + 1); } while (!everyDay && (dt.getUTCDay() === 0 || dt.getUTCDay() === 6));
return dt.toISOString().slice(0, 10);
}
/** Whether CME holiday sessions fold into the next day for this account type.
* Never for an every-day firm: it has no CME calendar to fold by. The catalog
* sync already clears the flag for those firms; this is the backstop. */
export function effectiveCombine(cfg: { combineCmeHolidays?: boolean; tradesEveryDay?: boolean }): boolean {
return cfg.combineCmeHolidays === true && cfg.tradesEveryDay !== true;
}
/**
* Simulate the account's future assuming every day goes perfectly: each
* trading day books exactly its planned target (deterministic bumps included,
@@ -399,6 +408,9 @@ export function projectPerfectPath(opts: {
* (withdraw, reset, re-target). False = it just PASSES once (an
* eval) and the projection stops. */
payoutable?: boolean;
/** The firm trades every calendar day (24/7): weekends and CME
* holidays are ordinary projected days, and nothing combines. */
tradesEveryDay?: boolean;
};
/** Current-cycle daily P&L (account.dailyPnL). */
cycleDailyPnL: { date: string; pnl: number }[];
@@ -424,6 +436,8 @@ export function projectPerfectPath(opts: {
* so today's room is exact and only tightens going forward. */
startThreshold?: number;
};
/** Defaults to the current time. */
now?: Date;
}): ProjectedDay[] {
const { accountId, cfg } = opts;
const horizon = opts.horizonDays ?? 40;
@@ -432,30 +446,33 @@ export function projectPerfectPath(opts: {
const baseCap = opts.payout?.payoutCap ?? 0;
const profitSplit = opts.payout?.profitSplit ?? 0.9;
const dd = Math.max(0, opts.payout?.drawdown ?? 0);
const everyDay = cfg.tradesEveryDay === true;
const rawToday = currentTradeDateCT();
const rawToday = currentTradeDateCT(opts.now);
// The firm's own day view: a combining firm folds holiday-session P&L
// into its clearing date — a Labor-Day trade and the Tuesday after it are
// ONE trading day, exactly as the firm counts min days. Identity view
// when combining is off.
const combine = cfg.combineCmeHolidays === true;
const combine = effectiveCombine(cfg);
const view = combineCmeSessions(combine, opts.cycleDailyPnL, rawToday, opts.realizedPnL);
// A weekend "today" (Saturday, or Sunday before the evening open) has no
// session — the platform's realizedPnL is the LAST session's stale number,
// already booked in cycleDailyPnL under its own date. Adding it as a
// today row would fabricate an extra qualifying day.
// today row would fabricate an extra qualifying day. An every-day firm
// trades weekends, so its weekend today is a live session like any other.
const todayDow = new Date(`${rawToday}T00:00:00Z`).getUTCDay();
const todayHasSession = todayDow !== 0 && todayDow !== 6;
const todayHasSession = everyDay || (todayDow !== 0 && todayDow !== 6);
// Today's clearing date when a session exists; on weekends keep the raw
// date so the projection's first day is Monday, not the day after it
// (combinedTradeDate rolls weekend dates forward).
const today = todayHasSession ? view.tradeDate : rawToday;
let sim = view.dailyPnL.filter((d) => d.date !== today);
if (todayHasSession && view.realizedPnL !== 0) sim = [...sim, { date: today, pnl: view.realizedPnL }];
/** Next date the firm settles a session on: weekends always skip, and a
* combining firm's holiday sessions clear into the next full day. */
/** Next date the firm settles a session on: weekends skip (except for an
* every-day firm), and a combining firm's holiday sessions clear into
* the next full day. */
const nextDay = (d: string): string => {
let n = nextTradingDate(d);
let n = nextTradingDate(d, everyDay);
while (combine && combinedTradeDate(n) !== n) n = nextTradingDate(n);
return n;
};
@@ -662,12 +679,18 @@ function cycleOwesNothing(
* weekend (it is the last session's stale number, already booked under its
* own date), a Monday with a weekend-dated row — the live session filed under
* a weekend date, which would count it twice — and the first minutes after
* the 5 PM roll. */
function sessionDoubtful(rawTradeDate: string, cycleDailyPnL: { date: string; pnl: number }[], now: Date): boolean {
* the 5 PM roll. An every-day firm's weekends are real sessions, so only the
* roll window applies to it. */
function sessionDoubtful(
rawTradeDate: string,
cycleDailyPnL: { date: string; pnl: number }[],
now: Date,
everyDay = false,
): boolean {
const day = new Date(`${rawTradeDate}T00:00:00Z`);
const dow = day.getUTCDay();
if (dow === 0 || dow === 6) return true;
if (dow === 1) {
if (!everyDay && (dow === 0 || dow === 6)) return true;
if (!everyDay && dow === 1) {
const sunday = fmtUTC(new Date(day.getTime() - 24 * 60 * 60 * 1000));
const saturday = fmtUTC(new Date(day.getTime() - 2 * 24 * 60 * 60 * 1000));
if (cycleDailyPnL.some((d) => d.date === saturday || d.date === sunday)) return true;
@@ -698,6 +721,8 @@ export function cycleCompletion(opts: {
cycleDailyPnL: { date: string; pnl: number }[];
rawRealizedPnL: number;
combineCmeHolidays: boolean;
/** The firm trades every calendar day — weekends are real sessions. */
tradesEveryDay?: boolean;
effective: EffectiveConfig;
/** amount − accountSize — today's P&L included. */
equityProfit: number;
@@ -706,14 +731,18 @@ export function cycleCompletion(opts: {
now?: Date;
}): CycleCompletion {
const { accountId, rawTradeDate, effective: eff, equityProfit, splitEnabled } = opts;
const view = combineCmeSessions(opts.combineCmeHolidays, opts.cycleDailyPnL, rawTradeDate, opts.rawRealizedPnL);
const everyDay = opts.tradesEveryDay === true;
const view = combineCmeSessions(
effectiveCombine({ combineCmeHolidays: opts.combineCmeHolidays, tradesEveryDay: everyDay }),
opts.cycleDailyPnL, rawTradeDate, opts.rawRealizedPnL,
);
const prior = view.dailyPnL.filter((d) => d.date !== view.tradeDate);
if (cycleOwesNothing(accountId, eff, prior, view.tradeDate, equityProfit, view.realizedPnL, splitEnabled)) return 'before';
if (sessionDoubtful(rawTradeDate, opts.cycleDailyPnL, opts.now ?? new Date())) return 'none';
if (sessionDoubtful(rawTradeDate, opts.cycleDailyPnL, opts.now ?? new Date(), everyDay)) return 'none';
const withToday = [...prior, { date: view.tradeDate, pnl: view.realizedPnL }];
const probe = nextTradingDate(view.tradeDate);
const probe = nextTradingDate(view.tradeDate, everyDay);
if (view.realizedPnL !== 0 && cycleOwesNothing(accountId, eff, withToday, probe, equityProfit, 0, splitEnabled)) return 'today';
return 'none';
}
@@ -764,15 +793,18 @@ export function endedCycleCompletion(opts: {
profitTarget: number; consistency: number; minTradingDays: number; minDayPnL: number;
targetSameEquity: boolean; withdrawalStages: WithdrawalStage[];
combineCmeHolidays: boolean; splitEnabled: boolean;
tradesEveryDay?: boolean;
};
/** Defaults to the current time. */
now?: Date;
}): { date: string; stage: number; drain: boolean; cycle: string } | null {
const { accountId, rawTradeDate, lastFundDate, endedCycleStart: start, fundTransactions, cfg } = opts;
const everyDay = cfg.tradesEveryDay === true;
const combine = effectiveCombine(cfg);
if (lastFundDate === null || start === null) return null;
const ageDays = (Date.parse(`${rawTradeDate}T00:00:00Z`) - Date.parse(`${lastFundDate}T00:00:00Z`)) / (24 * 60 * 60 * 1000);
if (!(ageDays <= ENDED_CYCLE_WINDOW_DAYS)) return null; // NaN included: an unparseable date
if (sessionDoubtful(rawTradeDate, opts.cycleDailyPnL, opts.now ?? new Date())) return null;
if (sessionDoubtful(rawTradeDate, opts.cycleDailyPnL, opts.now ?? new Date(), everyDay)) return null;
const latest = (a: string, b: string) => (b > a ? b : a);
const earlierFunds = fundTransactions.filter((f) => f.date < lastFundDate);
@@ -789,7 +821,7 @@ export function endedCycleCompletion(opts: {
// Equity the ended cycle finished on, worked back from live equity: less
// everything the current cycle booked (today's live P&L included) and
// less the fund transaction between the two.
const cur = combineCmeSessions(cfg.combineCmeHolidays, opts.cycleDailyPnL, rawTradeDate, opts.rawRealizedPnL);
const cur = combineCmeSessions(combine, opts.cycleDailyPnL, rawTradeDate, opts.rawRealizedPnL);
const curTotal = cur.dailyPnL.filter((d) => d.date !== cur.tradeDate).reduce((s, d) => s + d.pnl, 0)
+ cur.realizedPnL;
const fundAmt = fundTransactions.filter((f) => f.date === lastFundDate).reduce((s, f) => s + f.amount, 0);
@@ -800,11 +832,11 @@ export function endedCycleCompletion(opts: {
cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity,
cfg.withdrawalStages, 0, earlierFunds, cfg.minDayPnL,
);
const rows = combineCmeSessions(cfg.combineCmeHolidays, endedRaw, lastFundDate, 0).dailyPnL;
const rows = combineCmeSessions(combine, endedRaw, lastFundDate, 0).dailyPnL;
// A combining firm's folded day can net to 0 — nothing traded, as the firm counts it.
const traded = rows.filter((d) => d.pnl !== 0);
if (traded.length === 0) return null;
const probe = nextTradingDate(rows.map((d) => d.date).reduce(latest));
const probe = nextTradingDate(rows.map((d) => d.date).reduce(latest), everyDay);
if (!cycleOwesNothing(accountId, eff, rows, probe, equityBefore, 0, cfg.splitEnabled)) return null;
return {
@@ -1040,3 +1072,339 @@ export function computeDayPlan(
splitAllowed,
};
}
// ── fees ─────────────────────────────────────────────────────────────────────
/** An entry's fee when the balance update hasn't shown it yet — at least one
* extra tick on the exit. */
export const FALLBACK_COMMISSION_PER_CONTRACT = 2.5;
/** No real per-contract entry fee comes near this. Also what a daily-loss
* stop reserves per contract and side when the fee couldn't be measured —
* the $2.50 fallback is fine for a take-profit, but it would under-reserve
* a hard stop on NQ-class fees. */
export const MAX_COMMISSION_PER_CONTRACT = 10;
/** One side's commission on a percent-of-notional firm (e.g. Tradeify247's
* 0.04%), rounded UP to the cent so every consumer errs toward paying more.
* pointValue × contracts is the position's dollars per 1.0 of price, so
* price × that is its notional. */
export function percentFee(pct: number, price: number, pointValue: number, contracts: number): number {
if (!(pct > 0)) return 0;
const raw = (pct / 100) * Math.abs(price) * pointValue * contracts;
return Math.ceil(raw * 100 - 1e-9) / 100;
}
/**
* Where to put the take-profit so the leg nets at least `leg` after fees.
*
* Measured-fee firms (commissionPct 0): the exit fee is taken to equal the
* entry fee — the same formula the fire path always used, reproduced exactly.
*
* Percent-fee firms: the exit fee depends on the exit price itself, so solve
* for it. With r = pct/100 and k = pointValue × contracts, a long nets
* (X − entry)·k − entryFee − r·X·k at exit price X, which reaches `leg` at
* X = ((leg + entryFee)/k + entry) / (1 − r); a short mirrors it at
* X = (entry − (leg + entryFee)/k) / (1 + r). Rounded out to whole ticks and
* re-checked with the cent-rounded exit fee.
*/
export function takeProfitPlan(o: {
side: 'Buy' | 'Sell';
entry: number;
leg: number;
pointValue: number;
contracts: number;
tickSize: number;
commissionPct: number;
/** The entry fee: percentFee() on percent firms, the measured one otherwise. */
entryCommission: number;
}): { exitPrice: number; ticks: number; entryFee: number; exitFee: number; totalCommission: number; grossTarget: number } {
const k = o.pointValue * o.contracts;
const dir = o.side === 'Buy' ? 1 : -1;
if (!(o.commissionPct > 0)) {
const totalCommission = o.entryCommission * 2; // entry + exit round-trip
const grossTarget = o.leg + totalCommission;
const ticks = Math.ceil(grossTarget / k / o.tickSize);
return {
exitPrice: o.entry + dir * ticks * o.tickSize,
ticks,
entryFee: o.entryCommission,
exitFee: o.entryCommission,
totalCommission,
grossTarget,
};
}
const r = o.commissionPct / 100;
const need = (o.leg + o.entryCommission) / k;
const target = o.side === 'Buy' ? (need + o.entry) / (1 - r) : (o.entry - need) / (1 + r);
let ticks = Math.max(1, Math.ceil(Math.abs(target - o.entry) / o.tickSize - 1e-9));
const priceAt = (t: number) => Math.round((o.entry + dir * t * o.tickSize) * 1e10) / 1e10;
const netAt = (t: number) => t * o.tickSize * k - o.entryCommission - percentFee(o.commissionPct, priceAt(t), o.pointValue, o.contracts);
// Cent rounding on the fees can leave the closed form short — by several
// ticks when a tick is worth a fraction of a cent. Step out by the shortfall.
for (let i = 0; i < 10; i++) {
const short = o.leg - netAt(ticks);
if (short <= 1e-9) break;
ticks += Math.max(1, Math.ceil(short / (o.tickSize * k * (1 - r)) - 1e-9));
}
const exitPrice = priceAt(ticks);
const exitFee = percentFee(o.commissionPct, exitPrice, o.pointValue, o.contracts);
const totalCommission = Math.round((o.entryCommission + exitFee) * 100) / 100;
return { exitPrice, ticks, entryFee: o.entryCommission, exitFee, totalCommission, grossTarget: round2(o.leg + totalCommission) };
}
// ── stops ────────────────────────────────────────────────────────────────────
/** Obfuscation stop for the current leg, in dollars. Infinity = no stop.
* Day 1 of a cycle carries the $1,000 account stop (firstDayStop), min-day
* (small) trades a 2×-target stop (minDayStop) — and the day after a losing
* day always rides stopless, the make-back day. A hard daily loss limit is
* separate (dllStopDollars) and never rides stopless. */
export function obfuscationStopDollars(
cfg: { firstDayStop: boolean; minDayStop: boolean },
dailyPnL: { date: string; pnl: number }[],
plan: DayPlan,
tradeDate: string,
legAmount: number,
): number {
if (!cfg.firstDayStop && !cfg.minDayStop) return Infinity;
const prior = dailyPnL
.filter((d) => d.date < tradeDate && d.pnl !== 0)
.sort((a, b) => a.date.localeCompare(b.date));
if (prior.length > 0 && prior[prior.length - 1].pnl < 0) return Infinity;
let stop = Infinity;
const realizedToday = Math.round((plan.dayTarget - plan.remainingToday) * 100) / 100;
if (cfg.firstDayStop && prior.length === 0) stop = Math.max(0, FIRST_DAY_STOP + realizedToday);
if (cfg.minDayStop && plan.dayTarget <= BIG_DAY_THRESHOLD) stop = Math.min(stop, MIN_DAY_STOP_MULT * legAmount);
return stop;
}
/** Share of a hard daily loss limit the bot never spends — room for a stop
* that fills worse than its price (gaps, thin weekend books). */
export const DLL_RESERVE_FRACTION = 0.10;
/** Smallest daily-loss stop worth trading. Under a fair walk a leg of L with a
* stop of S wins S/(S + L) of the time — about 9% for $50 against $500 — so
* anything tighter just spends the day's room on fees. */
export const DLL_MIN_STOP = 50;
/**
* How much the firm's day may still lose before a hard daily-loss breach.
* Infinity when the account type has no limit.
*
* The day's P&L is the closed balance against the platform's snapshot at the
* firm's daily reset when the client knows it (DXTrade: 22:00 UTC), else the
* broker's realized P&L for the session. Only a red day counts: a green day
* does NOT widen the room past the limit, because a position still open at
* the next reset has its whole move counted against the NEW day — room
* borrowed from today's profit would be a breach tomorrow.
*/
export function dllRoom(o: {
dailyLossLimit: number;
closedBalance: number;
/** Platform snapshot at the firm's daily reset; undefined/null = unknown. */
dayStartBalance?: number | null;
/** Fallback when there's no snapshot: the day's realized P&L (pass the
* worse of the raw and combined figures). */
realizedToday: number;
}): number {
if (!(o.dailyLossLimit > 0)) return Infinity;
const dayPnL = typeof o.dayStartBalance === 'number' ? o.closedBalance - o.dayStartBalance : o.realizedToday;
return round2(o.dailyLossLimit + Math.min(0, dayPnL));
}
/**
* The widest stop, in dollars of price movement, that keeps a loss inside
* room − reserve with both fees paid. The exit fee at the stop is at most
* entryFee + r × (stop distance) — a short's stop sits above its entry — so
* the worst loss is d(1 + r) + 2·entryFee, plus a cent of fee rounding.
* Infinity when there is no limit. Can go negative: no room at all.
*/
export function dllStopDollars(o: { room: number; dailyLossLimit: number; entryFee: number; commissionPct: number }): number {
if (!Number.isFinite(o.room)) return Infinity;
const r = Math.max(0, o.commissionPct) / 100;
return (o.room - DLL_RESERVE_FRACTION * o.dailyLossLimit - 2 * o.entryFee - 0.01) / (1 + r);
}
/** Is there enough daily-loss room left to trade at all? */
export function dllViable(room: number, dailyLossLimit: number, roundTripFeeEstimate: number): boolean {
if (!Number.isFinite(room)) return true;
return room - DLL_RESERVE_FRACTION * dailyLossLimit >= DLL_MIN_STOP + roundTripFeeEstimate;
}
/**
* The stop distance in ticks: the tighter of the obfuscation stop (rounded
* out, as it always was — it is a soft target) and the daily-loss stop
* (rounded IN — it is a hard limit). null = no stop at all (lone take-profit);
* 0 = the daily-loss room doesn't cover a single tick, so the position must
* not stay open. An obfuscation value of 0 or Infinity means no obfuscation stop.
*/
export function stopTicks(o: {
obfuscationDollars: number;
dllDollars: number;
pointValue: number;
contracts: number;
tickSize: number;
}): number | null {
const perTick = o.pointValue * o.contracts * o.tickSize;
let ticks: number | null = null;
if (Number.isFinite(o.obfuscationDollars) && o.obfuscationDollars > 0) {
ticks = Math.max(1, Math.ceil(o.obfuscationDollars / (o.pointValue * o.contracts) / o.tickSize));
}
if (Number.isFinite(o.dllDollars)) {
const dllTicks = Math.max(0, Math.floor(o.dllDollars / perTick + 1e-9));
ticks = ticks === null ? dllTicks : Math.min(ticks, dllTicks);
}
return ticks;
}
/** Whether the bot places its own stops for this account type — the
* obfuscation options or a hard daily loss limit. A red day on such an
* account is the bot's own stop (the personal DLL), not a firm lockout. */
export function botStopsActive(cfg: { firstDayStop: boolean; minDayStop: boolean; dailyLossLimit?: number }): boolean {
return cfg.firstDayStop || cfg.minDayStop || (cfg.dailyLossLimit ?? 0) > 0;
}
// ── position size ────────────────────────────────────────────────────────────
/**
* How much notional a leg should carry, on platforms that price it (DXTrade).
*
* Model price as a driftless random walk with σ per √hour. With the
* take-profit A dollars away (leg + fees) and the stop B dollars away, a
* position of notional N wins B/(A+B) of the time whatever N is, its expected
* hold is (A/N)(B/N)/σ² hours, and its expected P&L is minus fees and spread
* — proportional to N. Smaller is cheaper, bigger is faster, so the knob is
* the target hold T: N = √(A·B/(σ²·T)). A firm that charges overnight
* financing adds a floor: below √(f·A·B/(σ²·(2c + s))), a bigger position is
* both cheaper and faster.
*
* Fixed mode (no target hold, no σ, or a stopless leg): the symbol map's
* notional per $1,000 of leg, like futures' one contract per $1,000.
* Returns null when neither mode can size the leg.
*/
export function sizeNotional(o: {
leg: number;
/** Stop distance in dollars at a given notional (fees move with it). */
stopDollarsAt: (notional: number) => number;
volatilityPerHour?: number;
targetHoldHours: number;
/** Per side, percent. */
commissionPct: number;
spreadFraction?: number;
financingPctPerDay?: number;
/** Notional per $1,000 of leg for fixed sizing; 0 = none. */
fixedNotional: number;
/** Hard cap on the position; 0 = none. */
maxNotional: number;
}): { notional: number; mode: 'volatility' | 'fixed'; predictedHoldHours: number | null; winProbability: number | null } | null {
const c = Math.max(0, o.commissionPct) / 100;
const s = Math.max(0, o.spreadFraction ?? 0);
const sigma = o.volatilityPerHour ?? 0;
const fixed = o.fixedNotional > 0 ? Math.max(1, Math.ceil(o.leg / 1000)) * o.fixedNotional : 0;
const cap = (n: number) => (o.maxNotional > 0 ? Math.min(n, o.maxNotional) : n);
const describe = (n: number) => {
const a = o.leg + 2 * c * n;
const b = o.stopDollarsAt(n);
if (!(sigma > 0) || !Number.isFinite(b) || b <= 0 || n <= 0) {
return { predictedHoldHours: null, winProbability: Number.isFinite(b) && b > 0 ? b / (a + b) : null };
}
return { predictedHoldHours: (a / n) * (b / n) / (sigma * sigma), winProbability: b / (a + b) };
};
if (o.targetHoldHours > 0 && sigma > 0) {
let n = fixed > 0 ? fixed : o.leg * 20;
let ok = true;
// Fees depend on N and the daily-loss stop depends on fees: a couple of
// fixed-point passes settle it (the correction is second order).
for (let pass = 0; pass < 3; pass++) {
const a = o.leg + 2 * c * n;
const b = o.stopDollarsAt(n);
if (!Number.isFinite(b) || b <= 0) { ok = false; break; }
const nTime = Math.sqrt((a * b) / (sigma * sigma * o.targetHoldHours));
const fh = Math.max(0, o.financingPctPerDay ?? 0) / 100 / 24;
const nCost = fh > 0 ? Math.sqrt((fh * a * b) / (sigma * sigma * (2 * c + s))) : 0;
n = Math.max(nTime, nCost);
}
if (ok && Number.isFinite(n) && n > 0) {
const notional = cap(n);
return { notional, mode: 'volatility', ...describe(notional) };
}
}
if (fixed > 0) {
const notional = cap(fixed);
return { notional, mode: 'fixed', ...describe(notional) };
}
return null;
}
// ── trading hours ────────────────────────────────────────────────────────────
/** Tradeify247 (and DXtrade brokers generally) roll the trading day at 22:00
* UTC — 5 PM Central in summer, 4 PM in winter. */
export const FIRM_RESET_UTC_HOUR = 22;
/** Every-day firms stop opening new trades this long before the earlier of
* their reset and the 5 PM CT roll. */
const ROLL_GUARD_MIN = 5;
function centralClock(now: Date): { day: string; minutes: number; resetMinutes: number } {
const parts = new Intl.DateTimeFormat('en-US', {
weekday: 'short', hour: 'numeric', minute: 'numeric', hour12: false, timeZone: 'America/Chicago',
}).formatToParts(now);
const get = (type: string) => parts.find((p) => p.type === type)?.value ?? '';
const minutes = (parseInt(get('hour'), 10) % 24) * 60 + parseInt(get('minute'), 10);
// Central's offset from UTC right now (−300 in CDT, −360 in CST) places
// the firm's 22:00 UTC reset on the Central clock.
const utcMinutes = now.getUTCHours() * 60 + now.getUTCMinutes();
const offset = ((minutes - utcMinutes + 720 + 1440) % 1440) - 720;
const resetMinutes = (FIRM_RESET_UTC_HOUR * 60 + offset + 1440) % 1440;
return { day: get('weekday'), minutes, resetMinutes };
}
/**
* Whether no new trade may open right now.
*
* CME firms follow the trading_hours setting:
* "full_cme" — Sun 5:05 PM – Fri 2:55 PM Central (5 min buffer on each side)
* "equity_hours" — 8:35 AM – 2:55 PM Central, Mon–Fri (5 min buffer on each side)
* with the open pushed back by the day's 1–20 minute jitter.
*
* Every-day firms ignore trading_hours and close only around the day's roll:
* from 5 minutes before the earlier of their 22:00 UTC reset and 5 PM CT, to
* 5:05 PM CT + jitter. In CST that is 3:55 → 5:05 PM, which keeps new entries
* out of the hour where the firm's day (reset at 4 PM CT) and our trade date
* (5 PM CT) disagree.
*/
export function isNoTradeTime(now: Date, o: { everyDay: boolean; mode: string; jitterMin: number }): boolean {
const { day, minutes, resetMinutes } = centralClock(now);
const cmeOpen = 17 * 60 + 5 + o.jitterMin;
if (o.everyDay) {
const guardStart = Math.min(resetMinutes, 17 * 60) - ROLL_GUARD_MIN;
return minutes >= guardStart && minutes < cmeOpen;
}
if (o.mode === 'equity_hours') {
if (day === 'Sat' || day === 'Sun') return true;
const open = 8 * 60 + 35 + o.jitterMin;
const close = 14 * 60 + 55;
return minutes < open || minutes >= close;
}
// Full CME: Sun 5:00 PM – Fri 4:00 PM Central with 5 min buffer.
if (day === 'Sat') return true;
if (day === 'Sun') return minutes < cmeOpen;
const cmeClose = 14 * 60 + 55;
if (day === 'Fri' && minutes >= cmeClose) return true;
// Mon–Thu: block 2:55 PM – 5:05 PM Central (early stop + daily halt + buffer).
return minutes >= cmeClose && minutes < cmeOpen;
}
/** The trade date's session has closed and the 5 PM roll hasn't opened the
* next one: weekdays 2:55–5:00 PM CT on the CME calendar, the pre-roll guard
* for an every-day firm. */
export function isPastSessionClose(now: Date, everyDay: boolean): boolean {
const { day, minutes, resetMinutes } = centralClock(now);
if (everyDay) return minutes >= Math.min(resetMinutes, 17 * 60) - ROLL_GUARD_MIN && minutes < 17 * 60;
if (day === 'Sat' || day === 'Sun') return false;
return minutes >= 14 * 60 + 55 && minutes < 17 * 60;
}
+2 -1
View File
@@ -6,7 +6,8 @@
"dev": "next dev --webpack",
"build": "next build",
"start": "next start",
"lint": "eslint"
"lint": "eslint",
"check": "node --experimental-strip-types --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --test scripts/check-trading-logic.mjs"
},
"dependencies": {
"@microsoft/signalr": "^10.0.11",
+483
View File
@@ -0,0 +1,483 @@
// Checks for the pure trading math in lib/trading-logic.ts.
//
// npm run check
//
// .mjs on purpose: Next's tsconfig includes every .ts file, and a .ts test
// importing another .ts by extension would trip the build's type check. Node
// strips the types from lib/trading-logic.ts at import time (it has no
// imports of its own).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
percentFee, takeProfitPlan, obfuscationStopDollars, dllRoom, dllStopDollars, dllViable, stopTicks,
botStopsActive, sizeNotional, isNoTradeTime, isPastSessionClose, nextTradingDate, effectiveCombine,
projectPerfectPath, cycleCompletion, DLL_RESERVE_FRACTION, DLL_MIN_STOP,
} from '../lib/trading-logic.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;
};
}
// ── parity with the pre-refactor inline code ────────────────────────────────
/** The take-profit exactly as auto-trade.ts computed it before takeProfitPlan. */
function oldTakeProfit(side, entry, leg, pv, contracts, tickSize, entryCommission) {
const totalCommission = entryCommission * 2;
const grossTarget = leg + totalCommission;
const targetPoints = grossTarget / (pv * contracts);
const ticks = Math.ceil(targetPoints / tickSize);
const exitPrice = side === 'Buy' ? entry + ticks * tickSize : entry - ticks * tickSize;
return { exitPrice, ticks, totalCommission, grossTarget };
}
test('takeProfitPlan reproduces the old formula when fees are measured', () => {
const r = rng(1);
for (let i = 0; i < 2000; i++) {
const side = r() < 0.5 ? 'Buy' : 'Sell';
const [pv, tick] = [[20, 0.25], [2, 0.25], [100, 0.1], [1000, 0.01], [50, 0.25]][Math.floor(r() * 5)];
const entry = Math.round((1000 + r() * 20000) / tick) * tick;
const leg = Math.round(5 + r() * 2500);
const contracts = 1 + Math.floor(r() * 4);
const fee = Math.round(r() * 1000) / 100;
const got = takeProfitPlan({ side, entry, leg, pointValue: pv, contracts, tickSize: tick, commissionPct: 0, entryCommission: fee });
const want = oldTakeProfit(side, entry, leg, pv, contracts, tick, fee);
assert.equal(got.exitPrice, want.exitPrice);
assert.equal(got.ticks, want.ticks);
assert.equal(got.totalCommission, want.totalCommission);
assert.equal(got.grossTarget, want.grossTarget);
}
});
/** obfuscationStopDollars exactly as it lived in auto-trade.ts. */
function oldObfuscation(cfg, dailyPnL, plan, tradeDate, legAmount) {
if (!cfg.firstDayStop && !cfg.minDayStop) return Infinity;
const prior = dailyPnL.filter((d) => d.date < tradeDate && d.pnl !== 0).sort((a, b) => a.date.localeCompare(b.date));
if (prior.length > 0 && prior[prior.length - 1].pnl < 0) return Infinity;
let stop = Infinity;
const realizedToday = Math.round((plan.dayTarget - plan.remainingToday) * 100) / 100;
if (cfg.firstDayStop && prior.length === 0) stop = Math.max(0, 1000 + realizedToday);
if (cfg.minDayStop && plan.dayTarget <= 1700) stop = Math.min(stop, 2 * legAmount);
return stop;
}
test('obfuscationStopDollars is unchanged by the move', () => {
const r = rng(2);
for (let i = 0; i < 1000; i++) {
const cfg = { firstDayStop: r() < 0.5, minDayStop: r() < 0.5 };
const days = Math.floor(r() * 4);
const dailyPnL = Array.from({ length: days }, (_, d) => ({ date: `2026-09-0${d + 1}`, pnl: Math.round((r() - 0.4) * 2000) }));
const dayTarget = Math.round(r() * 3000);
const plan = { dayTarget, remainingToday: Math.round(dayTarget * r()), path: 'normal_day', splitAllowed: true };
const leg = Math.round(100 + r() * 1500);
assert.equal(obfuscationStopDollars(cfg, dailyPnL, plan, '2026-09-08', leg), oldObfuscation(cfg, dailyPnL, plan, '2026-09-08', leg));
}
});
/** isInNoTradeWindow as it was, with the clock and settings passed in. */
function oldNoTrade(now, mode, jitter) {
const centralParts = new Intl.DateTimeFormat('en-US', { hour: 'numeric', minute: 'numeric', hour12: false, timeZone: 'America/Chicago' }).formatToParts(now);
const hour = parseInt(centralParts.find((p) => p.type === 'hour').value, 10);
const minute = parseInt(centralParts.find((p) => p.type === 'minute').value, 10);
const day = new Intl.DateTimeFormat('en-US', { weekday: 'short', timeZone: 'America/Chicago' }).format(now);
const timeMinutes = hour * 60 + minute;
if (mode === 'equity_hours') {
if (day === 'Sat' || day === 'Sun') return true;
const open = 8 * 60 + 35 + jitter;
const close = 14 * 60 + 55;
return timeMinutes < open || timeMinutes >= close;
}
if (day === 'Sat') return true;
const cmeOpen = 17 * 60 + 5 + jitter;
if (day === 'Sun') return timeMinutes < cmeOpen;
const cmeClose = 14 * 60 + 55;
if (day === 'Fri' && timeMinutes >= cmeClose) return true;
if (timeMinutes >= cmeClose && timeMinutes < cmeOpen) return true;
return false;
}
function oldAfterClose(now) {
const parts = new Intl.DateTimeFormat('en-US', { weekday: 'short', hour: 'numeric', minute: 'numeric', hour12: false, timeZone: 'America/Chicago' }).formatToParts(now);
const get = (type) => parts.find((p) => p.type === type)?.value ?? '';
if (get('weekday') === 'Sat' || get('weekday') === 'Sun') return false;
const minutes = (parseInt(get('hour'), 10) % 24) * 60 + parseInt(get('minute'), 10);
return minutes >= 14 * 60 + 55 && minutes < 17 * 60;
}
test('CME trading hours are unchanged, across both DST regimes', () => {
for (const start of [Date.UTC(2026, 8, 20), Date.UTC(2026, 0, 11)]) { // a September and a January week
for (let m = 0; m < 7 * 24 * 60; m += 7) {
const now = new Date(start + m * 60_000);
for (const mode of ['full_cme', 'equity_hours']) {
for (const jitter of [1, 13, 20]) {
assert.equal(isNoTradeTime(now, { everyDay: false, mode, jitterMin: jitter }), oldNoTrade(now, mode, jitter), `${now.toISOString()} ${mode} ${jitter}`);
}
}
assert.equal(isPastSessionClose(now, false), oldAfterClose(now), now.toISOString());
}
}
});
// ── every-day hours ──────────────────────────────────────────────────────────
const ct = (iso, offsetHours) => new Date(Date.parse(`${iso}Z`) + offsetHours * 3600_000);
const cdt = (iso) => ct(iso, 5); // Central daylight: UTC−5
const cst = (iso) => ct(iso, 6); // Central standard: UTC−6
test('every-day firms close only around the roll (CDT)', () => {
const o = { everyDay: true, mode: 'full_cme', jitterMin: 7 };
assert.equal(isNoTradeTime(cdt('2026-09-26T12:00:00'), o), false); // Saturday noon
assert.equal(isNoTradeTime(cdt('2026-09-27T09:00:00'), o), false); // Sunday morning
assert.equal(isNoTradeTime(cdt('2026-09-28T16:54:00'), o), false);
assert.equal(isNoTradeTime(cdt('2026-09-28T16:55:00'), o), true);
assert.equal(isNoTradeTime(cdt('2026-09-28T17:11:00'), o), true);
assert.equal(isNoTradeTime(cdt('2026-09-28T17:12:00'), o), false);
assert.equal(isPastSessionClose(cdt('2026-09-26T16:56:00'), true), true);
assert.equal(isPastSessionClose(cdt('2026-09-26T17:01:00'), true), false);
});
test('every-day firms close from 3:55 PM in CST, covering the 4 PM firm reset', () => {
const o = { everyDay: true, mode: 'equity_hours', jitterMin: 1 };
assert.equal(isNoTradeTime(cst('2027-01-12T15:54:00'), o), false);
assert.equal(isNoTradeTime(cst('2027-01-12T15:55:00'), o), true);
assert.equal(isNoTradeTime(cst('2027-01-12T16:30:00'), o), true);
assert.equal(isNoTradeTime(cst('2027-01-12T17:05:00'), o), true);
assert.equal(isNoTradeTime(cst('2027-01-12T17:06:00'), o), false);
assert.equal(isNoTradeTime(cst('2027-01-16T12:00:00'), o), false); // Saturday
assert.equal(isPastSessionClose(cst('2027-01-12T16:10:00'), true), true);
});
// ── fees ─────────────────────────────────────────────────────────────────────
test('percentFee rounds each side up to the cent', () => {
assert.equal(percentFee(0.04, 60000, 1, 1), 24);
assert.equal(percentFee(0.04, 100000, 0.2, 1), 8);
assert.equal(percentFee(0.04, 123.45, 0.001, 7), 0.01); // 0.00035 → a cent
assert.equal(percentFee(0, 60000, 1, 1), 0);
});
test('percent-fee take-profits net at least the leg, and one tick less does not', () => {
const r = rng(3);
for (let i = 0; i < 3000; i++) {
const side = r() < 0.5 ? 'Buy' : 'Sell';
const tick = [0.01, 0.1, 0.5, 1][Math.floor(r() * 4)];
const entry = Math.round((50 + r() * 100000) / tick) * tick;
const step = [0.001, 0.01, 1][Math.floor(r() * 3)];
const contracts = 1 + Math.floor(r() * 400);
const pct = [0.04, 0.02, 0.1][Math.floor(r() * 3)];
const leg = Math.round(5 + r() * 2000);
const entryFee = percentFee(pct, entry, step, contracts);
const tp = takeProfitPlan({ side, entry, leg, pointValue: step, contracts, tickSize: tick, commissionPct: pct, entryCommission: entryFee });
const k = step * contracts;
const dir = side === 'Buy' ? 1 : -1;
const net = (ticks) => {
const x = entry + dir * ticks * tick;
return ticks * tick * k - entryFee - percentFee(pct, x, step, contracts);
};
assert.ok(net(tp.ticks) >= leg - 1e-6, `short of the leg: ${JSON.stringify({ side, entry, tick, step, contracts, pct, leg, tp })}`);
if (tp.ticks > 1) assert.ok(net(tp.ticks - 1) < leg + 0.02, `a tick further than needed: ${JSON.stringify(tp)}`);
assert.equal(tp.totalCommission, Math.round((entryFee + tp.exitFee) * 100) / 100);
}
});
// ── daily loss limit ─────────────────────────────────────────────────────────
test('dllRoom: snapshot, fallback, green-day cap, no limit', () => {
assert.equal(dllRoom({ dailyLossLimit: 0, closedBalance: 50000, realizedToday: -900 }), Infinity);
assert.equal(dllRoom({ dailyLossLimit: 1500, closedBalance: 49600, dayStartBalance: 50000, realizedToday: 0 }), 1100);
assert.equal(dllRoom({ dailyLossLimit: 1500, closedBalance: 50600, dayStartBalance: 50000, realizedToday: 0 }), 1500);
assert.equal(dllRoom({ dailyLossLimit: 1000, closedBalance: 50000, dayStartBalance: null, realizedToday: -200 }), 800);
assert.equal(dllRoom({ dailyLossLimit: 1000, closedBalance: 50000, realizedToday: 350 }), 1000);
});
test('the worked futures example: $1,000 limit, down $200, NQ at $2.14/side', () => {
const room = dllRoom({ dailyLossLimit: 1000, closedBalance: 49800, realizedToday: -200 });
const d = dllStopDollars({ room, dailyLossLimit: 1000, entryFee: 2.14, commissionPct: 0 });
const ticks = stopTicks({ obfuscationDollars: Infinity, dllDollars: d, pointValue: 20, contracts: 1, tickSize: 0.25 });
assert.equal(ticks, 139);
const loss = ticks * 0.25 * 20 + 2 * 2.14;
assert.ok(-200 - loss > -1000 + 100 - 1e-9);
});
test('stopTicks: obfuscation rounds out, the daily-loss stop rounds in, the tighter wins', () => {
const base = { pointValue: 20, contracts: 1, tickSize: 0.25 };
assert.equal(stopTicks({ ...base, obfuscationDollars: Infinity, dllDollars: Infinity }), null);
assert.equal(stopTicks({ ...base, obfuscationDollars: 0, dllDollars: Infinity }), null);
assert.equal(stopTicks({ ...base, obfuscationDollars: 1001, dllDollars: Infinity }), 201);
assert.equal(stopTicks({ ...base, obfuscationDollars: Infinity, dllDollars: 1000 }), 200);
assert.equal(stopTicks({ ...base, obfuscationDollars: Infinity, dllDollars: 999.99 }), 199);
assert.equal(stopTicks({ ...base, obfuscationDollars: 600, dllDollars: 1000 }), 120);
assert.equal(stopTicks({ ...base, obfuscationDollars: 1600, dllDollars: 1000 }), 200);
assert.equal(stopTicks({ ...base, obfuscationDollars: Infinity, dllDollars: 3 }), 0);
assert.equal(stopTicks({ ...base, obfuscationDollars: Infinity, dllDollars: -40 }), 0);
});
test('worst case at the stop, both fees paid, never spends past room − reserve', () => {
const r = rng(4);
for (let i = 0; i < 20000; i++) {
const side = r() < 0.5 ? 'Buy' : 'Sell';
const pctFirm = r() < 0.6;
const pct = pctFirm ? [0.04, 0.1][Math.floor(r() * 2)] : 0;
const tick = pctFirm ? [0.01, 0.1, 1][Math.floor(r() * 3)] : 0.25;
const pv = pctFirm ? [0.001, 0.01, 1][Math.floor(r() * 3)] : [2, 20, 50][Math.floor(r() * 3)];
const contracts = pctFirm ? 1 + Math.floor(r() * 500) : 1 + Math.floor(r() * 3);
const entry = Math.round((100 + r() * 90000) / tick) * tick;
const dll = [300, 750, 1500, 3000][Math.floor(r() * 4)];
const room = dllRoom({ dailyLossLimit: dll, closedBalance: 50000, dayStartBalance: 50000 + Math.round((r() - 0.3) * dll), realizedToday: 0 });
const entryFee = pctFirm ? percentFee(pct, entry, pv, contracts) : Math.round(r() * 500) / 100 * contracts;
const d = dllStopDollars({ room, dailyLossLimit: dll, entryFee, commissionPct: pct });
const ticks = stopTicks({ obfuscationDollars: Infinity, dllDollars: d, pointValue: pv, contracts, tickSize: tick });
if (ticks === 0) continue; // flattened instead
const stopPrice = side === 'Buy' ? entry - ticks * tick : entry + ticks * tick;
const exitFee = pctFirm ? percentFee(pct, stopPrice, pv, contracts) : entryFee;
const loss = ticks * tick * pv * contracts + entryFee + exitFee;
assert.ok(loss <= room - DLL_RESERVE_FRACTION * dll + 1e-6, JSON.stringify({ side, pct, tick, pv, contracts, entry, dll, room, ticks, loss }));
}
});
test('dllViable and botStopsActive', () => {
assert.equal(dllViable(Infinity, 0, 0), true);
assert.equal(dllViable(1500, 1500, 20), true);
assert.equal(dllViable(150 + DLL_MIN_STOP + 20, 1500, 20), true);
assert.equal(dllViable(150 + DLL_MIN_STOP + 19.99, 1500, 20), false);
assert.equal(botStopsActive({ firstDayStop: false, minDayStop: false }), false);
assert.equal(botStopsActive({ firstDayStop: false, minDayStop: false, dailyLossLimit: 1500 }), true);
assert.equal(botStopsActive({ firstDayStop: true, minDayStop: false, dailyLossLimit: 0 }), true);
});
// ── sizing ───────────────────────────────────────────────────────────────────
const btcSigma = 0.03 / Math.sqrt(24); // 3%/day
test('volatility sizing hits the target hold, and the example table', () => {
const stop = () => 1350;
const at = (hours) => sizeNotional({ leg: 600, stopDollarsAt: stop, volatilityPerHour: btcSigma, targetHoldHours: hours, commissionPct: 0, fixedNotional: 0, maxNotional: 0 });
for (const [hours, notional] of [[54, 20000], [24, 30000], [6, 60000]]) {
const s = at(hours);
assert.equal(s.mode, 'volatility');
assert.ok(Math.abs(s.notional - notional) / notional < 0.001, `${hours}h → ${s.notional}`);
assert.ok(Math.abs(s.predictedHoldHours - hours) / hours < 0.001);
assert.ok(Math.abs(s.winProbability - 1350 / 1950) < 1e-9);
}
});
test('sizing: fees feed back, the cap binds, financing only raises N, fixed fallback', () => {
const base = { leg: 600, volatilityPerHour: btcSigma, targetHoldHours: 6, commissionPct: 0.04, spreadFraction: 0.0002, fixedNotional: 20000, maxNotional: 0 };
// Daily-loss stop shrinks as fees grow with N.
const stopDollarsAt = (n) => dllStopDollars({ room: 1500, dailyLossLimit: 1500, entryFee: 0.0004 * n, commissionPct: 0.04 });
const s = sizeNotional({ ...base, stopDollarsAt });
const a = 600 + 2 * 0.0004 * s.notional;
const b = stopDollarsAt(s.notional);
assert.ok(Math.abs(s.predictedHoldHours - 6) < 0.05, `hold ${s.predictedHoldHours}`);
assert.ok(Math.abs(s.winProbability - b / (a + b)) < 1e-9);
const capped = sizeNotional({ ...base, stopDollarsAt, maxNotional: 25000 });
assert.equal(capped.notional, 25000);
assert.ok(capped.predictedHoldHours > 6);
const financed = sizeNotional({ ...base, stopDollarsAt, targetHoldHours: 200, financingPctPerDay: 0.033 });
const unfinanced = sizeNotional({ ...base, stopDollarsAt, targetHoldHours: 200 });
assert.ok(financed.notional > unfinanced.notional);
const fixed = sizeNotional({ ...base, stopDollarsAt: () => Infinity, leg: 1700 });
assert.equal(fixed.mode, 'fixed');
assert.equal(fixed.notional, 40000);
assert.equal(sizeNotional({ ...base, stopDollarsAt: () => Infinity, fixedNotional: 0 }), null);
assert.equal(sizeNotional({ ...base, targetHoldHours: 0, stopDollarsAt }).mode, 'fixed');
});
test('random-walk simulation agrees with the predicted hold and win rate', () => {
const r = rng(5);
const notional = 60000;
const A = 600;
const B = 1350;
const sigma = btcSigma;
const dtHours = 1 / 60; // one-minute steps
const stepDollars = notional * sigma * Math.sqrt(dtHours);
const predicted = (A / notional) * (B / notional) / (sigma * sigma);
let wins = 0;
let totalHours = 0;
const paths = 3000;
for (let p = 0; p < paths; p++) {
let x = 0;
let steps = 0;
while (x < A && x > -B) {
x += r() < 0.5 ? stepDollars : -stepDollars;
steps++;
}
if (x >= A) wins++;
totalHours += steps * dtHours;
}
const meanHold = totalHours / paths;
assert.ok(Math.abs(meanHold - predicted) / predicted < 0.1, `mean hold ${meanHold} vs predicted ${predicted}`);
assert.ok(Math.abs(wins / paths - B / (A + B)) < 0.03, `win rate ${wins / paths}`);
});
// ── every-day calendar ───────────────────────────────────────────────────────
test('nextTradingDate and effectiveCombine', () => {
assert.equal(nextTradingDate('2026-09-25'), '2026-09-28');
assert.equal(nextTradingDate('2026-09-25', true), '2026-09-26');
assert.equal(nextTradingDate('2026-12-24', true), '2026-12-25');
assert.equal(effectiveCombine({ combineCmeHolidays: true }), true);
assert.equal(effectiveCombine({ combineCmeHolidays: true, tradesEveryDay: true }), false);
});
const projCfg = {
profitTarget: 3000, consistency: 0, minDayPnL: 0, minTradingDays: 5, accountSize: 50000,
targetSameEquity: false, withdrawalStages: [], splitEnabled: true,
};
test('every-day projections use consecutive dates; CME ones still skip weekends', () => {
const now = cdt('2026-09-26T12:00:00'); // Saturday noon → trade date Saturday
const common = { accountId: 7, cycleDailyPnL: [], fundTransactions: [], balance: 50000, realizedPnL: 0, now };
const every = projectPerfectPath({ ...common, cfg: { ...projCfg, tradesEveryDay: true } });
const cme = projectPerfectPath({ ...common, cfg: projCfg });
assert.ok(every.length > 1);
for (let i = 1; i < every.length; i++) assert.equal(every[i].date, nextTradingDate(every[i - 1].date, true));
for (const d of cme) {
const dow = new Date(`${d.date}T00:00:00Z`).getUTCDay();
assert.ok(dow !== 0 && dow !== 6, `CME projection landed on ${d.date}`);
}
// Saturday itself is booked (silently) for the every-day firm: its first
// projected day is Sunday, the CME one's is Monday.
assert.equal(every[0].date, '2026-09-27');
assert.equal(cme[0].date, '2026-09-28');
});
test('an every-day firm can finish its cycle on a Saturday', () => {
const now = cdt('2026-09-26T12:00:00');
const cycleDailyPnL = [
{ date: '2026-09-21', pnl: 700 }, { date: '2026-09-22', pnl: 700 },
{ date: '2026-09-23', pnl: 700 }, { date: '2026-09-24', pnl: 700 },
];
const opts = {
accountId: 7, rawTradeDate: '2026-09-26', cycleDailyPnL, rawRealizedPnL: 400, combineCmeHolidays: false,
effective: { profitTarget: 3000, consistency: 0, minTradingDays: 5, minDayPnL: 0, drain: false },
equityProfit: 3200, splitEnabled: true, now,
};
assert.equal(cycleCompletion({ ...opts, tradesEveryDay: true }), 'today');
assert.equal(cycleCompletion(opts), 'none');
});
// ── DXTrade helpers (lib/dxtrade-helpers.ts) ─────────────────────────────────
import {
dxAccountId, accountDisplayName, accountCodeOf, encodeContractName, decodeContractName, roundToStep,
lastUtcReset, ctTradeDayStart, parseDurationMs, ciNum, signedFundValue, inTradingHours,
realizedVolPerHour, scoreCandidate, candidateRejection, MIN_VOL_CANDLES,
} from '../lib/dxtrade-helpers.ts';
test('dxAccountId is stable, safe, far above broker ids, and host-scoped', () => {
const a = dxAccountId('dx.tradeify247.co', 'default:TFY-1');
assert.equal(a, dxAccountId('DX.Tradeify247.co', 'default:TFY-1'));
assert.ok(Number.isSafeInteger(a) && a >= 2 ** 50 && a < 2 ** 53);
assert.notEqual(a, dxAccountId('dx.other.co', 'default:TFY-1'));
const seen = new Set();
for (let i = 0; i < 20000; i++) seen.add(dxAccountId('dx.tradeify247.co', `default:${i}`));
assert.equal(seen.size, 20000);
});
test('account codes and contract names', () => {
assert.equal(accountDisplayName('default:TFY-50K-12'), 'TFY-50K-12');
assert.equal(accountDisplayName('plain'), 'plain');
assert.equal(accountCodeOf({ clearing: 'default', account: 'x1' }), 'default:x1');
assert.equal(accountCodeOf('default:x1'), 'default:x1');
assert.deepEqual(decodeContractName(encodeContractName('BTC/USD', 0.001)), { symbol: 'BTC/USD', step: 0.001 });
assert.equal(decodeContractName('NQZ5'), null);
assert.equal(roundToStep(0.1 + 0.2, 0.1), 0.3);
assert.equal(roundToStep(12 * 0.001, 0.001), 0.012);
assert.equal(roundToStep(101.234, 0.5, 'up'), 101.5);
assert.equal(roundToStep(101.234, 0.5, 'down'), 101);
});
test('the firm reset and the CT trade-date start', () => {
assert.equal(lastUtcReset(new Date('2026-09-26T21:59:00Z')).toISOString(), '2026-09-25T22:00:00.000Z');
assert.equal(lastUtcReset(new Date('2026-09-26T22:00:00Z')).toISOString(), '2026-09-26T22:00:00.000Z');
assert.equal(ctTradeDayStart(cdt('2026-09-26T18:30:00')).toISOString(), '2026-09-26T22:00:00.000Z');
assert.equal(ctTradeDayStart(cdt('2026-09-27T09:00:00')).toISOString(), '2026-09-26T22:00:00.000Z');
assert.equal(ctTradeDayStart(cst('2027-01-12T10:00:00')).toISOString(), '2027-01-11T23:00:00.000Z');
});
test('loose parsing', () => {
assert.equal(parseDurationMs('PT30M'), 30 * 60_000);
assert.equal(parseDurationMs('01:05:08'), (3600 + 300 + 8) * 1000);
assert.equal(parseDurationMs({ seconds: 900 }), 900_000);
assert.equal(parseDurationMs(undefined), 30 * 60_000);
assert.equal(ciNum({ openPL: 12.5 }, 'openPl'), 12.5);
assert.equal(ciNum({ openPL: '12' }, 'openPl'), undefined);
assert.equal(signedFundValue('WITHDRAWAL', 500), -500);
assert.equal(signedFundValue('WITHDRAWAL', -500), -500);
assert.equal(signedFundValue('DEPOSIT', 50000), 50000);
});
test('trading hours from UTC week sessions', () => {
const at = (dayOfWeek, hour, eventType) => ({ eventType, weekDay: { dayOfWeek, time: { hour, minute: 30 } } });
const weekdays = [at('MONDAY', 13, 'SESSION_OPEN'), at('MONDAY', 20, 'SESSION_CLOSE'), at('TUESDAY', 13, 'SESSION_OPEN'), at('TUESDAY', 20, 'SESSION_CLOSE')];
assert.equal(inTradingHours(weekdays, new Date('2026-09-28T15:00:00Z')), true); // Monday
assert.equal(inTradingHours(weekdays, new Date('2026-09-28T21:00:00Z')), false);
assert.equal(inTradingHours(weekdays, new Date('2026-09-27T15:00:00Z')), false); // Sunday wraps to Tuesday's close
assert.equal(inTradingHours([], new Date()), true);
assert.equal(inTradingHours([{ garbage: true }], new Date()), true);
});
test('realized volatility of hourly closes', () => {
assert.equal(realizedVolPerHour([100, 101, 102]), null);
const r = rng(6);
const sigma = 0.004;
const closes = [30000];
for (let i = 0; i < 2000; i++) {
// Box–Muller for a normal step
const z = Math.sqrt(-2 * Math.log(1 - r())) * Math.cos(2 * Math.PI * r());
closes.push(closes[closes.length - 1] * Math.exp(sigma * z));
}
const v = realizedVolPerHour(closes);
assert.ok(Math.abs(v - sigma) / sigma < 0.08, `σ ${v}`);
const flat = Array.from({ length: MIN_VOL_CANDLES + 1 }, () => 200);
assert.equal(realizedVolPerHour(flat), 0);
});
test('the selector ranks the plan example: ETH, BTC, XYZ100, AAPL', () => {
const perHour = (daily) => daily / Math.sqrt(24);
const score = (spread, daily) => scoreCandidate({
spreadFraction: spread, commissionPct: 0.04, financingPctPerDay: 0, targetHoldHours: 6, volatilityPerHour: perHour(daily),
});
const board = [
['BTC/USD', score(0.0002, 0.03)],
['XYZ100', score(0.0001, 0.012)],
['AAPL/USD (Sat)', score(0.0005, 0.004)],
['ETH/USD', score(0.0003, 0.04)],
['AAPL/USD (Tue)', score(0.0005, 0.015)],
].sort((a, b) => a[1] - b[1]).map((b) => b[0]);
assert.deepEqual(board, ['ETH/USD', 'BTC/USD', 'XYZ100', 'AAPL/USD (Tue)', 'AAPL/USD (Sat)']);
// Cost per leg = score × √(A·B/T): the example's $50 and $60.
const k = Math.sqrt((600 * 1350) / 6);
assert.ok(Math.abs(score(0.0003, 0.04) * k - 49.6) < 1);
assert.ok(Math.abs(score(0.0002, 0.03) * k - 60) < 1);
// Fixed sizing ignores volatility; volatility sizing needs it.
assert.equal(scoreCandidate({ spreadFraction: 0.0002, commissionPct: 0.04, financingPctPerDay: 0, targetHoldHours: 0, volatilityPerHour: null }), 0.001);
assert.equal(scoreCandidate({ spreadFraction: 0.0002, commissionPct: 0.04, financingPctPerDay: 0, targetHoldHours: 6, volatilityPerHour: null }), null);
});
test('the selector drops what cannot trade right now', () => {
const ok = {
tradingStatus: 'FULL', bid: 100, ask: 100.02, quoteAgeMs: 1000, inHours: true, candles: 49,
needVolatility: true, minContractsOk: true, currency: 'USD', accountCurrency: 'USD',
};
assert.equal(candidateRejection(ok), null);
assert.match(candidateRejection({ ...ok, tradingStatus: 'CLOSE_ONLY' }), /CLOSE_ONLY/);
assert.match(candidateRejection({ ...ok, inHours: false }), /hours/);
assert.match(candidateRejection({ ...ok, bid: undefined }), /quote/);
assert.match(candidateRejection({ ...ok, quoteAgeMs: 10 * 60_000 }), /old/);
assert.match(candidateRejection({ ...ok, ask: 101 }), /spread/);
assert.match(candidateRejection({ ...ok, candles: 10 }), /candles/);
assert.equal(candidateRejection({ ...ok, candles: 10, needVolatility: false }), null);
assert.match(candidateRejection({ ...ok, currency: 'EUR' }), /EUR/);
});
+217
View File
@@ -0,0 +1,217 @@
#!/usr/bin/env node
// DXtrade smoke test — run against a real login before the scheduler trades a
// DXtrade firm. Confirms the API details lib/dxtrade-class.ts assumes.
//
// DX_BASE=https://dx.tradeify247.co/dxsca-web DX_USER=... DX_PASS=... \
// DX_DOMAIN=default DX_SYMBOLS=BTC/USD,ETH/USD,XYZ100 node scripts/dxtrade-smoke.mjs
//
// Read-only unless DX_CONFIRM=yes AND DX_ACCOUNT=<account code> are set, which
// adds a minimum-size round trip on that account (a real order: it pays two
// commissions). Credentials come from the environment only; the password and
// session token are never printed.
//
// Run it while the instance's client for the firm is disconnected (remove the
// credentials there, or stop the instance): two sessions on one login can
// collide, and logins are rate-limited per IP.
const BASE = (process.env.DX_BASE ?? '').replace(/\/+$/, '');
const USER = process.env.DX_USER ?? '';
const PASS = process.env.DX_PASS ?? '';
const DOMAIN = process.env.DX_DOMAIN || 'default';
const SYMBOLS = (process.env.DX_SYMBOLS ?? 'BTC/USD').split(',').map((s) => s.trim()).filter(Boolean);
const ORDER_ACCOUNT = process.env.DX_ACCOUNT ?? '';
const CONFIRM = process.env.DX_CONFIRM === 'yes';
if (!BASE.startsWith('https://') || !USER || !PASS) {
console.error('Set DX_BASE (https://…/dxsca-web), DX_USER and DX_PASS.');
process.exit(1);
}
let token = null;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const section = (title) => console.log(`\n── ${title} ${'─'.repeat(Math.max(0, 70 - title.length))}`);
const show = (label, value) => console.log(`${label}:`, typeof value === 'string' ? value : JSON.stringify(value, null, 2)?.slice(0, 4000));
async function call(method, path, { params, body, headers } = {}) {
const url = new URL(`${BASE}${path}`);
for (const [k, v] of Object.entries(params ?? {})) url.searchParams.set(k, String(v));
const res = await fetch(url, {
method,
headers: {
Accept: 'application/json',
...(token ? { Authorization: `DXAPI ${token}` } : {}),
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
...headers,
},
body: body !== undefined ? JSON.stringify(body) : undefined,
signal: AbortSignal.timeout(30_000),
});
const text = await res.text();
let data = null;
try { data = text ? JSON.parse(text) : null; } catch { data = text.slice(0, 500); }
return { status: res.status, headers: Object.fromEntries(res.headers), data };
}
async function step(label, fn) {
try {
await fn();
} catch (err) {
console.log(`${label} FAILED: ${err instanceof Error ? err.message : String(err)}`);
}
}
const codeOf = (a) => (typeof a === 'string' ? a : a && typeof a === 'object' ? `${a.clearing}:${a.account}` : String(a));
section('login');
{
const res = await call('POST', '/login', { body: { username: USER, domain: DOMAIN, password: PASS } });
if (res.status !== 200 || !res.data?.sessionToken) {
show(`login refused (HTTP ${res.status})`, res.data);
console.log('Check DX_DOMAIN (try "default", then the broker name) — and stop after a couple of tries: repeated failures lock the account.');
process.exit(1);
}
token = res.data.sessionToken;
show('session timeout field (format check)', res.data.timeout);
}
let accounts = [];
section('accounts (/users)');
for (const path of [`${USER}@${DOMAIN}`, USER]) {
const res = await call('GET', `/users/${encodeURIComponent(path)}`);
console.log(`/users/${path} → HTTP ${res.status}`);
if (res.status === 200) {
accounts = (res.data?.userDetails ?? []).flatMap((u) => u.accounts ?? []);
for (const a of accounts) {
console.log(` ${codeOf(a.account)} status=${a.accountStatus} positionBased=${a.positionBased} currency=${a.baseCurrency} registered=${a.registrationTime}`);
}
break;
}
}
const codes = accounts.map((a) => codeOf(a.account));
const ref = codes[0];
if (!ref) {
console.log('No accounts found — nothing more to check.');
process.exit(0);
}
section('metrics (key casing: openPL vs openPl)');
await step('metrics', async () => {
const res = await call('GET', '/accounts/metrics', { params: { accounts: codes.join(',') } });
show(`HTTP ${res.status}`, res.data?.metrics?.[0] ?? res.data);
});
section('positions / open orders');
await step('positions', async () => show('positions', (await call('GET', '/accounts/positions', { params: { accounts: codes.join(',') } })).data));
await step('orders', async () => show('open orders', (await call('GET', '/accounts/orders', { params: { accounts: codes.join(',') } })).data));
section('transfers — types, signs, paging, the opening DEPOSIT, FINANCING');
await step('transfers', async () => {
const from = new Date(Date.now() - 180 * 86_400_000).toISOString();
const res = await call('GET', '/accounts/transfers', { params: { accounts: codes.join(','), 'time-from': from, limit: 50 } });
const txns = (res.data?.cashTransfers ?? []).flatMap((t) => (t.cashTransactions ?? []).map((x) => ({ ...x, transferTime: t.transactionTime })));
const byType = {};
for (const t of txns) {
const e = (byType[t.type] ??= { count: 0, min: Infinity, max: -Infinity });
e.count++; e.min = Math.min(e.min, t.value); e.max = Math.max(e.max, t.value);
}
show('by type (value range → sign convention)', byType);
show('first / last transaction (ordering)', [txns[0], txns[txns.length - 1]]);
show('nextPageTransactionTime', res.data?.nextPageTransactionTime ?? '(none)');
});
section('instruments');
await step('instruments', async () => {
const res = await call('GET', '/instruments/query', { params: { symbols: SYMBOLS.join(',') } });
for (const i of res.data?.instruments ?? []) {
console.log(` ${i.symbol}: type=${i.type} priceIncrement=${i.priceIncrement} multiplier=${i.multiplier} lotSize=${i.lotSize} quantityIncrement=${i.quantityIncrement} currency=${i.currency} sessions=${(i.tradingHours ?? []).length}`);
}
show('first instrument, raw', res.data?.instruments?.[0]);
});
await step('account instrument details', async () => {
const res = await call('GET', `/accounts/${encodeURIComponent(ref)}/instruments/query`, { params: { symbols: SYMBOLS.join(',') } });
show('details', res.data?.instrumentDetails ?? res.data);
});
section('quotes and hourly candles (σ)');
await step('quotes', async () => {
const res = await call('POST', '/marketdata', { body: { account: ref, symbols: SYMBOLS, eventTypes: [{ type: 'Quote', format: 'COMPACT' }] } });
show(`Quote events (HTTP ${res.status})`, res.data?.events ?? res.data);
});
await step('candles', async () => {
const now = Date.now();
const res = await call('POST', '/marketdata', {
body: {
account: ref, symbols: SYMBOLS,
eventTypes: [{ type: 'Candle', format: 'COMPACT', candleType: '1h', fromTime: new Date(now - 49 * 3_600_000).toISOString(), toTime: new Date(now).toISOString() }],
},
});
const events = res.data?.events ?? [];
console.log(`Candle events: HTTP ${res.status}, ${events.length} event(s); first: ${JSON.stringify(events[0])}`);
for (const symbol of SYMBOLS) {
const closes = events.filter((e) => e.symbol === symbol && typeof e.close === 'number').map((e) => e.close);
const rets = closes.slice(1).map((c, i) => Math.log(c / closes[i]));
const mean = rets.reduce((s, r) => s + r, 0) / Math.max(1, rets.length);
const sd = Math.sqrt(rets.reduce((s, r) => s + (r - mean) ** 2, 0) / Math.max(1, rets.length - 1));
console.log(` ${symbol}: ${closes.length} closes, σ ≈ ${(sd * 100).toFixed(3)}%/h ≈ ${(sd * Math.sqrt(24) * 100).toFixed(2)}%/day`);
}
});
section('end-of-day metrics vs the 22:00 UTC reset');
await step('eodmetrics', async () => {
const d = new Date(Date.now() - 86_400_000).toISOString().slice(0, 10);
const res = await call('GET', `/accounts/eodmetrics/${d}`, { params: { accounts: ref } });
show(`eodmetrics ${d} (HTTP ${res.status}) — compare balance with the instance's dayStart debug value`, res.data);
});
section('rate limits (two history calls back to back)');
await step('rate', async () => {
const a = await call('GET', '/accounts/transfers', { params: { accounts: ref, limit: 1 } });
const b = await call('GET', '/accounts/transfers', { params: { accounts: ref, limit: 1 } });
console.log(`HTTP ${a.status}, ${b.status}${b.status === 429 ? ' — history calls are limited to 1/s' : ''}`);
});
section('ping');
console.log(`POST /ping → HTTP ${(await call('POST', '/ping')).status}`);
if (CONFIRM && ORDER_ACCOUNT) {
section(`LIVE ORDERS on ${ORDER_ACCOUNT}`);
const acct = encodeURIComponent(ORDER_ACCOUNT);
const symbol = SYMBOLS[0];
const inst = (await call('GET', '/instruments/query', { params: { symbols: symbol } })).data?.instruments?.[0] ?? {};
const det = (await call('GET', `/accounts/${acct}/instruments/query`, { params: { symbols: symbol } })).data?.instrumentDetails?.[0] ?? {};
const qty = Math.max(det.minOrderSize ?? 0, inst.quantityIncrement ?? 0, det.minOrderSizeIncrement ?? 0) || 1;
const code = `af-smoke-${Date.now().toString(36)}`;
const open = { account: ORDER_ACCOUNT, orderCode: code, type: 'MARKET', instrument: symbol, quantity: qty, positionEffect: 'OPEN', side: 'BUY', tif: 'GTC' };
const placed = await call('POST', `/accounts/${acct}/orders`, { body: open });
show(`MARKET BUY ${qty} ${symbol} (HTTP ${placed.status})`, placed.data);
show('duplicate orderCode (expect 409 / code 100)', (await call('POST', `/accounts/${acct}/orders`, { body: open })));
await sleep(3_000);
const pos = ((await call('GET', `/accounts/${acct}/positions`)).data?.positions ?? []).find((p) => p.symbol === symbol);
show('position (positionCode should equal the orderId on position-based accounts)', pos);
show('order history row (commission, averagePrice)', (await call('GET', `/accounts/${acct}/orders/history`, { params: { 'with-client-id': code, limit: 5 } })).data);
if (pos) {
const px = pos.openPrice;
const tick = inst.priceIncrement || 0.01;
const far = (p) => Math.round(p / tick) * tick;
const leg = (c, type, price) => ({
account: ORDER_ACCOUNT, orderCode: c, type, instrument: symbol, quantity: pos.quantity, positionEffect: 'CLOSE',
positionCode: pos.positionCode, side: 'SELL', tif: 'GTC', ...(type === 'LIMIT' ? { limitPrice: price } : { stopPrice: price }),
});
const group = await call('POST', `/accounts/${acct}/orders`, {
body: { orders: [leg(`${code}-tp`, 'LIMIT', far(px * 1.05)), leg(`${code}-sl`, 'STOP', far(px * 0.95))], contingencyType: 'OCO' },
});
show(`native OCO group of CLOSE legs (HTTP ${group.status})`, group.data);
show('open orders now', (await call('GET', `/accounts/${acct}/orders`)).data);
const del = await call('DELETE', `/accounts/${acct}/orders/${encodeURIComponent(`${code}-tp`)}`);
show(`cancel without If-Match (HTTP ${del.status}; 403/412/428 means it wants one)`, del.data);
const flatten = await call('POST', `/accounts/${acct}/close`, { body: { account: ORDER_ACCOUNT, closePositions: true, cancelOrders: true } });
show(`flatten via /close (HTTP ${flatten.status})`, flatten.data);
}
await sleep(3_000);
const recent = await call('GET', `/accounts/${acct}/transfers`, { params: { 'time-from': new Date(Date.now() - 10 * 60_000).toISOString() } });
show('transactions of the last 10 minutes (commission = 0.04% × notional per side?)', recent.data);
} else {
console.log('\n(read-only: set DX_CONFIRM=yes and DX_ACCOUNT=<code> to also test a live minimum-size round trip)');
}
await call('POST', '/logout');
+21 -7
View File
@@ -1,3 +1,5 @@
import type { FirmPlatform } from './lib/master-protocol';
export interface AccountConfig {
/** Master's account type id — what a manual assignment pins to. */
id: string;
@@ -27,6 +29,13 @@ export interface AccountConfig {
payoutCap?: number;
profitSplit?: number;
trailingDrawdown?: number;
/** Hard daily loss limit in dollars (0/absent = none): every leg carries a
* stop that keeps the firm's day inside it, less a 10% reserve and fees. */
dailyLossLimit?: number;
/** Cap on one position's notional in dollars (0/absent = none). */
maxNotional?: number;
/** The firm trades every calendar day, weekends and CME holidays included. */
tradesEveryDay?: boolean;
}
export interface FirmConfig {
@@ -35,12 +44,12 @@ export interface FirmConfig {
firm: string;
/** Which platform the firm's accounts trade on — decides both which client
* drives it and which credential fields the dashboard asks for. */
platform: 'tradovate' | 'topstepx';
platform: FirmPlatform;
/** Blank when this machine has no credentials for the firm. */
username: string;
/** True once the platform's credentials are stored locally — Tradovate
* username + password, or a TopstepX API key. Firms without them are
* shown greyed out with an inline credential form. */
/** True once the platform's credentials are stored locally — a username +
* password (Tradovate, DXTrade) or a TopstepX API key. Firms without them
* are shown greyed out with an inline credential form. */
hasCredentials: boolean;
bannedSymbols: string[];
/** The firm's account types, straight from the catalog. Doubles as the
@@ -76,10 +85,15 @@ export interface AccountState {
stageDrain: boolean;
/** Next trading day's target, computed server-side. null when account is dead or config is missing. */
dailyTarget: { amount: number; path: 'first_day' | 'normal_day' | 'reduced_day' } | null;
/** Dollars the next trade may lose before its obfuscation stop fires.
* null = the next trade rides stopless (options off, big non-first day,
* or a make-back day after a loss). */
/** Dollars of price movement the next trade may lose before its stop
* fires — the tighter of the obfuscation stop and the hard daily-loss
* stop, before fees. null = the next trade rides stopless (options off,
* big non-first day, or a make-back day after a loss, with no daily
* loss limit). */
nextStop: number | null;
/** What the firm's day may still lose before a hard daily-loss breach.
* null = no daily loss limit (or its day-start balance isn't known yet). */
dllRoom: number | null;
/** Actual profit target to hit — max(stage profit target, consistency realTarget = maxDay/consistency). */
effectiveProfitTarget: number | null;
/** Current cycle daily P&L — used for target calculations. */