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:
co-authored by
Claude Opus 5.5
parent
49c4725e0c
commit
28f465508d
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
+30
-22
@@ -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,26 +96,30 @@ 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);
|
||||
// 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).
|
||||
// If a big day forces the consistency rule, the account must reach maxDay/consistency
|
||||
@@ -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
@@ -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>
|
||||
) : (
|
||||
|
||||
+534
-401
File diff suppressed because it is too large
Load Diff
+45
-16
@@ -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);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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');
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user