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
|
# AutoFirmer
|
||||||
|
|
||||||
Automated futures trading dashboard for prop firm accounts via Tradovate and
|
Automated trading dashboard for prop firm accounts via Tradovate and TopstepX
|
||||||
TopstepX (ProjectX Gateway).
|
(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
|
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
|
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
|
1. On the **master dashboard**, open **Firms**, add the firm, and give it one
|
||||||
account type per prefix + account size combination (profit target,
|
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** on the master asks for an API key instead (generated in the
|
||||||
TopstepX platform under Settings → API); the username is optional to store
|
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
|
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
|
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**
|
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
|
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
|
## The master dashboard
|
||||||
|
|
||||||
Set **Instance Name** and **Dashboard URL** on the Settings page and this
|
Set **Instance Name** and **Dashboard URL** on the Settings page and this
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
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 { setCredentials, clearCredentials } from '@/lib/db';
|
||||||
import { reconcileClients, removeClient } from '@/lib/clients';
|
import { reconcileClients, removeClient } from '@/lib/clients';
|
||||||
import { scheduleBackupPush } from '@/lib/backup-sync';
|
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
|
* (lib/backup-sync.ts); they are still absent from the catalog and the
|
||||||
* control socket.
|
* control socket.
|
||||||
*
|
*
|
||||||
* Tradovate firms need username + password. TopstepX firms need the API key;
|
* Tradovate and DXTrade firms need username + password (DXTrade's server URL
|
||||||
* username is optional for now (the loginKey call still wants it, so the
|
* and login domain are set on the master, per firm). TopstepX firms need the
|
||||||
* client sits idle with a clear error until one is added) and password is
|
* API key; username is optional for now (the loginKey call still wants it,
|
||||||
* unused. */
|
* 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) {
|
export async function PUT(req: NextRequest, { params }: Params) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
|
||||||
@@ -27,12 +28,13 @@ export async function PUT(req: NextRequest, { params }: Params) {
|
|||||||
const password = body.password?.trim() ?? '';
|
const password = body.password?.trim() ?? '';
|
||||||
const apiKey = body.apiKey?.trim() ?? '';
|
const apiKey = body.apiKey?.trim() ?? '';
|
||||||
|
|
||||||
if (firm.platform === 'topstepx') {
|
if (!hasPlatformCredentials(firm.platform, { username, password, apiKey })) {
|
||||||
if (!apiKey) {
|
const error = firm.platform === 'topstepx'
|
||||||
return NextResponse.json({ error: 'apiKey is required for a TopstepX firm' }, { status: 400 });
|
? 'apiKey is required for a TopstepX firm'
|
||||||
}
|
: firm.platform === 'dxtrade'
|
||||||
} else if (!username || !password) {
|
? 'username and password are required for a DXtrade firm'
|
||||||
return NextResponse.json({ error: 'username and password are required' }, { status: 400 });
|
: 'username and password are required';
|
||||||
|
return NextResponse.json({ error }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { getCachedCatalog } from '@/lib/firm-catalog';
|
import { getCachedCatalog, hasPlatformCredentials } from '@/lib/firm-catalog';
|
||||||
import { getCredentials } from '@/lib/db';
|
import { getCredentials } from '@/lib/db';
|
||||||
import type { FirmConfig } from '@/types';
|
import type { FirmConfig } from '@/types';
|
||||||
|
|
||||||
@@ -18,9 +18,7 @@ export async function GET() {
|
|||||||
try {
|
try {
|
||||||
const result: FirmConfig[] = getCachedCatalog().map((firm) => {
|
const result: FirmConfig[] = getCachedCatalog().map((firm) => {
|
||||||
const creds = getCredentials(firm.id);
|
const creds = getCredentials(firm.id);
|
||||||
const hasCredentials = firm.platform === 'topstepx'
|
const hasCredentials = hasPlatformCredentials(firm.platform, creds);
|
||||||
? !!creds?.apiKey
|
|
||||||
: !!(creds?.username && creds?.password);
|
|
||||||
return {
|
return {
|
||||||
id: firm.id,
|
id: firm.id,
|
||||||
firm: firm.name,
|
firm: firm.name,
|
||||||
@@ -46,6 +44,9 @@ export async function GET() {
|
|||||||
minDayStop: a.minDayStop,
|
minDayStop: a.minDayStop,
|
||||||
combineCmeHolidays: a.combineCmeHolidays,
|
combineCmeHolidays: a.combineCmeHolidays,
|
||||||
withdrawalStages: a.withdrawalStages,
|
withdrawalStages: a.withdrawalStages,
|
||||||
|
dailyLossLimit: a.dailyLossLimit ?? 0,
|
||||||
|
maxNotional: a.maxNotional ?? 0,
|
||||||
|
tradesEveryDay: a.tradesEveryDay === true,
|
||||||
})),
|
})),
|
||||||
catalogUpdatedAt: firm.updatedAt,
|
catalogUpdatedAt: firm.updatedAt,
|
||||||
};
|
};
|
||||||
|
|||||||
+30
-22
@@ -6,7 +6,8 @@ import { getAccountConfig, getInferredSize, matchAccountType } from '@/lib/accou
|
|||||||
import { historyFresh } from '@/lib/platform-client';
|
import { historyFresh } from '@/lib/platform-client';
|
||||||
import {
|
import {
|
||||||
combineCmeSessions, computeDayPlan, cycleCompletion, resolveEffectiveConfig, currentTradeDateCT,
|
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';
|
} from '@/lib/trading-logic';
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
@@ -84,6 +85,7 @@ export async function GET() {
|
|||||||
let remainingToday: number | null = null;
|
let remainingToday: number | null = null;
|
||||||
let effectiveProfitTarget: number | null = null;
|
let effectiveProfitTarget: number | null = null;
|
||||||
let nextStop: number | null = null;
|
let nextStop: number | null = null;
|
||||||
|
let dllRoom: number | null = null;
|
||||||
if (cfg && effective && !isDead) {
|
if (cfg && effective && !isDead) {
|
||||||
const equityProfit = cash.amount - cfg.accountSize;
|
const equityProfit = cash.amount - cfg.accountSize;
|
||||||
const plan = computeDayPlan(
|
const plan = computeDayPlan(
|
||||||
@@ -94,26 +96,30 @@ export async function GET() {
|
|||||||
dailyTarget = plan ? { amount: plan.dayTarget, path: plan.path } : null;
|
dailyTarget = plan ? { amount: plan.dayTarget, path: plan.path } : null;
|
||||||
remainingToday = plan?.remainingToday ?? null;
|
remainingToday = plan?.remainingToday ?? null;
|
||||||
|
|
||||||
// The stop the next trade would carry under the obfuscation
|
// The stop the next trade would carry, from the same helpers
|
||||||
// options, mirroring obfuscationStopDollars in auto-trade:
|
// the fire path uses: the obfuscation stop (day 1 keeps the
|
||||||
// day 1 keeps the day's net floor at −$1,000, min days stop
|
// day's net floor at −$1,000, min days stop at 2× the leg,
|
||||||
// at 2× the target, and the day after a loss rides stopless.
|
// the day after a loss rides stopless) against the hard
|
||||||
if (plan && (cfg.firstDayStop || cfg.minDayStop)) {
|
// daily-loss stop, before fees. A min day trades its whole
|
||||||
const prior = dailyPnL
|
// remaining target as one leg.
|
||||||
.filter((d) => d.date < combined.tradeDate && d.pnl !== 0)
|
const dll = cfg.dailyLossLimit ?? 0;
|
||||||
.sort((a, b) => a.date.localeCompare(b.date));
|
if (dll > 0) {
|
||||||
const recoveryDay = prior.length > 0 && prior[prior.length - 1].pnl < 0;
|
const dayStart = client.dayStartBalance?.(acc.id);
|
||||||
if (!recoveryDay) {
|
dllRoom = dayStart === null ? null : computeDllRoom({
|
||||||
let stop = Infinity;
|
dailyLossLimit: dll,
|
||||||
if (cfg.firstDayStop && prior.length === 0) {
|
closedBalance: cash.amount,
|
||||||
stop = Math.max(0, Math.round((FIRST_DAY_STOP + combined.realizedPnL) * 100) / 100);
|
dayStartBalance: dayStart,
|
||||||
}
|
realizedToday: Math.min(cash.realizedPnL, combined.realizedPnL),
|
||||||
if (cfg.minDayStop && plan.dayTarget <= BIG_DAY_THRESHOLD) {
|
});
|
||||||
stop = Math.min(stop, MIN_DAY_STOP_MULT * plan.dayTarget);
|
|
||||||
}
|
}
|
||||||
|
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;
|
if (Number.isFinite(stop) && stop > 0) nextStop = Math.round(stop * 100) / 100;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Effective profit target = max(stage target, consistency realTarget).
|
// Effective profit target = max(stage target, consistency realTarget).
|
||||||
// If a big day forces the consistency rule, the account must reach maxDay/consistency
|
// 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],
|
cycleDailyPnL: client.dailyPnL[acc.id],
|
||||||
rawRealizedPnL: cash.realizedPnL,
|
rawRealizedPnL: cash.realizedPnL,
|
||||||
combineCmeHolidays: cfg.combineCmeHolidays,
|
combineCmeHolidays: cfg.combineCmeHolidays,
|
||||||
|
tradesEveryDay: cfg.tradesEveryDay === true,
|
||||||
effective,
|
effective,
|
||||||
equityProfit: cash.amount - cfg.accountSize,
|
equityProfit: cash.amount - cfg.accountSize,
|
||||||
splitEnabled: cfg.splitEnabled,
|
splitEnabled: cfg.splitEnabled,
|
||||||
@@ -202,10 +209,10 @@ export async function GET() {
|
|||||||
// on the Tuesday after a holiday this includes Sun/Mon.
|
// on the Tuesday after a holiday this includes Sun/Mon.
|
||||||
realizedPnL: combined.realizedPnL,
|
realizedPnL: combined.realizedPnL,
|
||||||
daysTraded,
|
daysTraded,
|
||||||
// A stop-enabled account whose day booked red via its own
|
// An account the bot puts stops on whose day booked red via
|
||||||
// stop — the personal DLL. Firm DLL lockouts show via
|
// its own stop — the personal DLL. Firm DLL lockouts show
|
||||||
// !active instead.
|
// via !active instead.
|
||||||
personalDll: !!cfg && (cfg.firstDayStop || cfg.minDayStop)
|
personalDll: !!cfg && botStopsActive(cfg)
|
||||||
&& acc.active && !client.positions[acc.id]
|
&& acc.active && !client.positions[acc.id]
|
||||||
&& combined.realizedPnL <= -25,
|
&& combined.realizedPnL <= -25,
|
||||||
positionDirection: client.positions[acc.id]
|
positionDirection: client.positions[acc.id]
|
||||||
@@ -220,6 +227,7 @@ export async function GET() {
|
|||||||
stageDrain: effective?.drain ?? false,
|
stageDrain: effective?.drain ?? false,
|
||||||
dailyTarget,
|
dailyTarget,
|
||||||
nextStop,
|
nextStop,
|
||||||
|
dllRoom,
|
||||||
remainingToday,
|
remainingToday,
|
||||||
effectiveProfitTarget,
|
effectiveProfitTarget,
|
||||||
dailyPnL,
|
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.
|
/** Inline credential entry, shown to the right of a firm that has none.
|
||||||
* Tradovate wants username + password; TopstepX wants the API key (username
|
* Tradovate and DXtrade want username + password; TopstepX wants the API key
|
||||||
* optional for now, though login needs it eventually — password unused). */
|
* (username optional for now, though login needs it eventually — password
|
||||||
|
* unused). */
|
||||||
function CredentialForm({ firm, onSaved }: { firm: FirmConfig; onSaved: () => void }) {
|
function CredentialForm({ firm, onSaved }: { firm: FirmConfig; onSaved: () => void }) {
|
||||||
const isTopstepX = firm.platform === 'topstepx';
|
const isTopstepX = firm.platform === 'topstepx';
|
||||||
const [username, setUsername] = useState('');
|
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()}>
|
<div className="flex items-center gap-1.5" onClick={(e) => e.stopPropagation()}>
|
||||||
<input
|
<input
|
||||||
className={cls}
|
className={cls}
|
||||||
placeholder={isTopstepX ? 'Username (optional)' : 'Tradovate username'}
|
placeholder={isTopstepX ? 'Username (optional)' : firm.platform === 'dxtrade' ? 'DXtrade username' : 'Tradovate username'}
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
/>
|
/>
|
||||||
@@ -375,7 +385,10 @@ function AccountRow({ account, firm, hideDead, privacy, onAssigned }: {
|
|||||||
<span className="text-xs text-slate-400">
|
<span className="text-xs text-slate-400">
|
||||||
${fmt(account.dailyTarget.amount)} today
|
${fmt(account.dailyTarget.amount)} today
|
||||||
{account.nextStop !== null && (
|
{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>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -392,7 +405,7 @@ function AccountRow({ account, firm, hideDead, privacy, onAssigned }: {
|
|||||||
: account.positionDirection
|
: 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>
|
? <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
|
: 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
|
: 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-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>}
|
: <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
|
TopstepX
|
||||||
</span>
|
</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 && (
|
{firm.platform === 'topstepx' && hasCredentials && !firm.username && (
|
||||||
<span
|
<span
|
||||||
className="text-xs bg-amber-100 text-amber-700 px-1.5 py-0.5 rounded font-semibold"
|
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">
|
<td colSpan={5} className="px-4 py-2.5">
|
||||||
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
|
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
|
||||||
<span className="text-xs text-slate-500">
|
<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>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={removeCredentials}
|
onClick={removeCredentials}
|
||||||
@@ -532,7 +550,7 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo
|
|||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); setConfirmCreds(true); }}
|
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"
|
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 />
|
<KeyOffIcon />
|
||||||
</button>
|
</button>
|
||||||
@@ -554,7 +572,7 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo
|
|||||||
state.accounts.length === 0 ? (
|
state.accounts.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={7} className="px-4 py-5 pl-12 text-sm text-slate-400 italic">
|
<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>
|
</td>
|
||||||
</tr>
|
</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 { TradovateClient } from './tradovate-class';
|
||||||
import { TopstepXClient } from './topstepx-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 { errorMessage, isClientReady, type PlatformClient } from './platform-client';
|
||||||
import { getInstruments } from './db';
|
import { getInstruments } from './db';
|
||||||
import { getTradableFirms, startCatalogSync } from './firm-catalog';
|
import { getTradableFirms, startCatalogSync } from './firm-catalog';
|
||||||
@@ -43,15 +45,18 @@ function credsMap(): Map<string, string> {
|
|||||||
return g.__tradovateClientCreds;
|
return g.__tradovateClientCreds;
|
||||||
}
|
}
|
||||||
|
|
||||||
function credsSignature(firm: { platform: string; username: string; password: string; apiKey: string }): string {
|
/** What a client was built from — a change to any of it rebuilds the client.
|
||||||
return `${firm.platform} ${firm.username} ${firm.password} ${firm.apiKey}`;
|
* 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(
|
export function initClient(
|
||||||
id: string,
|
id: string,
|
||||||
firmName: string,
|
firmName: string,
|
||||||
platform: 'tradovate' | 'topstepx',
|
platform: FirmPlatform,
|
||||||
creds: { username: string; password: string; apiKey: string },
|
creds: { username: string; password: string; apiKey: string; serverUrl?: string; loginDomain?: string },
|
||||||
): PlatformClient {
|
): PlatformClient {
|
||||||
const map = ensureMap();
|
const map = ensureMap();
|
||||||
const onSyncComplete = async () => {
|
const onSyncComplete = async () => {
|
||||||
@@ -73,9 +78,29 @@ export function initClient(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const client: PlatformClient = platform === 'topstepx'
|
let client: PlatformClient;
|
||||||
? new TopstepXClient(creds.username, creds.apiKey, onSyncComplete)
|
switch (platform) {
|
||||||
: new TradovateClient(creds.username, creds.password, onSyncComplete);
|
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);
|
map.set(id, client);
|
||||||
credsMap().set(id, credsSignature({ platform, ...creds }));
|
credsMap().set(id, credsSignature({ platform, ...creds }));
|
||||||
return client;
|
return client;
|
||||||
@@ -316,11 +341,16 @@ const SUPERVISOR_INTERVAL_MS = 60_000;
|
|||||||
const REBUILD_AFTER_UNREADY_MS = 20 * 60_000;
|
const REBUILD_AFTER_UNREADY_MS = 20 * 60_000;
|
||||||
const REBUILD_COOLDOWN_MS = 20 * 60_000;
|
const REBUILD_COOLDOWN_MS = 20 * 60_000;
|
||||||
|
|
||||||
/** The message TopstepXClient records when a firm has an API key but no
|
/** Clients that can't log in until someone acts — a rebuild changes nothing,
|
||||||
* username. That client never even tries to log in, so a rebuild changes
|
* and fixing the credentials (or the firm's settings) rebuilds them through
|
||||||
* nothing — adding the username changes the credentials, and
|
* reconcileClients anyway. TopstepX with an API key but no username never
|
||||||
* reconcileClients rebuilds it then. */
|
* tries to log in; DXTrade with no server URL can't, and after refused
|
||||||
const TOPSTEPX_USERNAME_MISSING = /username missing/i;
|
* 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 {
|
interface SupervisionEntry {
|
||||||
/** The instance being judged. A different one (rebuilt here, or replaced
|
/** 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)`;
|
+ ` (${health.consecutiveLoginFailures} failure(s) in a row)`;
|
||||||
}
|
}
|
||||||
} catch { /* no health: judge it on the rest */ }
|
} catch { /* no health: judge it on the rest */ }
|
||||||
if (platform === 'topstepx' && Object.keys(client.positions ?? {}).length > 0) {
|
if ((platform === 'topstepx' || platform === 'dxtrade') && Object.keys(client.positions ?? {}).length > 0) {
|
||||||
return 'it holds an open position (TopstepX exit brackets are tracked only inside the client)';
|
return 'it holds an open position (its exit pairs are tracked only inside the client)';
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -429,8 +459,7 @@ function superviseClients(): void {
|
|||||||
firms ??= getTradableFirms();
|
firms ??= getTradableFirms();
|
||||||
const firm = firms.find((f) => f.id === id);
|
const firm = firms.find((f) => f.id === id);
|
||||||
if (!firm) continue; // no longer tradable: reconcileClients drops it
|
if (!firm) continue; // no longer tradable: reconcileClients drops it
|
||||||
if (firm.platform === 'topstepx'
|
if (needsAttention(firm.platform, client)) continue;
|
||||||
&& TOPSTEPX_USERNAME_MISSING.test(client.lastFetchErrors?.['login'] ?? '')) continue;
|
|
||||||
|
|
||||||
const minutes = Math.round((now - entry.unhealthySince) / 60_000);
|
const minutes = Math.round((now - entry.unhealthySince) / 60_000);
|
||||||
const hold = rebuildHold(firm.platform, client, now);
|
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.)
|
* 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 {
|
import {
|
||||||
getSetting,
|
getSetting,
|
||||||
setSetting,
|
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
|
/** Firms this machine can actually trade: in the catalog AND holding the
|
||||||
* credentials their platform needs — a Tradovate username + password, or a
|
* credentials their platform needs. Everything that starts a platform client
|
||||||
* TopstepX API key. Everything that starts a platform client goes through
|
* goes through here. */
|
||||||
* here. */
|
|
||||||
export function getTradableFirms(): (CatalogFirm & { username: string; password: string; apiKey: string })[] {
|
export function getTradableFirms(): (CatalogFirm & { username: string; password: string; apiKey: string })[] {
|
||||||
const result: (CatalogFirm & { username: string; password: string; apiKey: string })[] = [];
|
const result: (CatalogFirm & { username: string; password: string; apiKey: string })[] = [];
|
||||||
for (const firm of getCachedCatalog()) {
|
for (const firm of getCachedCatalog()) {
|
||||||
const creds = getCredentials(firm.id);
|
const creds = getCredentials(firm.id);
|
||||||
if (!creds) continue;
|
if (creds && hasPlatformCredentials(firm.platform, creds)) result.push({ ...firm, ...creds });
|
||||||
if (firm.platform === 'topstepx') {
|
|
||||||
if (creds.apiKey) result.push({ ...firm, ...creds });
|
|
||||||
} else {
|
|
||||||
if (creds.username && creds.password) result.push({ ...firm, ...creds });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Sync ─────────────────────────────────────────────────────────────────────
|
// ── 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 }[] {
|
function isStages(value: unknown): value is { profit: number; consistency: number; minTradingDays: number }[] {
|
||||||
return Array.isArray(value) && value.every((s) =>
|
return Array.isArray(value) && value.every((s) =>
|
||||||
typeof s === 'object' && s !== null
|
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 (typeof body.updatedAt !== 'string') throw new Error('missing updatedAt');
|
||||||
if (!Array.isArray(body.firms)) throw new Error('missing firms');
|
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`);
|
if (typeof f !== 'object' || f === null) throw new Error(`firm ${i} is not an object`);
|
||||||
const firm = f as Record<string, unknown>;
|
const firm = f as Record<string, unknown>;
|
||||||
if (typeof firm.id !== 'string' || typeof firm.name !== 'string') {
|
if (typeof firm.id !== 'string' || typeof firm.name !== 'string') {
|
||||||
throw new Error(`firm ${i} is missing id or name`);
|
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`);
|
if (!Array.isArray(firm.accountTypes)) throw new Error(`firm ${firm.name} is missing accountTypes`);
|
||||||
|
|
||||||
const accountTypes: CatalogAccountType[] = firm.accountTypes.map((t: unknown, j: number) => {
|
const accountTypes: CatalogAccountType[] = firm.accountTypes.map((t: unknown, j: number) => {
|
||||||
@@ -163,26 +222,37 @@ function validatePayload(raw: unknown): FirmCatalogPayload {
|
|||||||
return {
|
return {
|
||||||
...type,
|
...type,
|
||||||
splitEnabled: type.splitEnabled !== false,
|
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".
|
// Lenient: a master from before the min-legs field means "no minimum".
|
||||||
minLegs: typeof type.minLegs === 'number' ? type.minLegs : 0,
|
minLegs: typeof type.minLegs === 'number' ? type.minLegs : 0,
|
||||||
// Lenient: the obfuscation stops are opt-in, so absent means off.
|
// Lenient: the obfuscation stops are opt-in, so absent means off.
|
||||||
firstDayStop: type.firstDayStop === true,
|
firstDayStop: type.firstDayStop === true,
|
||||||
minDayStop: type.minDayStop === 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;
|
} as unknown as CatalogAccountType;
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
firms.push({
|
||||||
id: firm.id,
|
id: firm.id,
|
||||||
name: firm.name,
|
name: firm.name,
|
||||||
// Lenient on purpose: a master from before the platform field (or
|
platform,
|
||||||
// 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',
|
|
||||||
bannedSymbols: Array.isArray(firm.bannedSymbols) ? (firm.bannedSymbols as string[]) : [],
|
bannedSymbols: Array.isArray(firm.bannedSymbols) ? (firm.bannedSymbols as string[]) : [],
|
||||||
accountTypes,
|
accountTypes,
|
||||||
|
serverUrl: typeof firm.serverUrl === 'string' ? firm.serverUrl : '',
|
||||||
|
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(),
|
updatedAt: typeof firm.updatedAt === 'string' ? firm.updatedAt : new Date(0).toISOString(),
|
||||||
};
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return { version: body.version, updatedAt: body.updatedAt, firms };
|
return { version: body.version, updatedAt: body.updatedAt, firms };
|
||||||
|
|||||||
+54
-4
@@ -205,19 +205,69 @@ export interface CatalogAccountType {
|
|||||||
payoutCap?: number;
|
payoutCap?: number;
|
||||||
/** Share of the gross the person actually receives (default 0.9). */
|
/** Share of the gross the person actually receives (default 0.9). */
|
||||||
profitSplit?: number;
|
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. */
|
/** ISO. Ties between two matching account types are broken by this. */
|
||||||
updatedAt: string;
|
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 {
|
export interface CatalogFirm {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
/** Which platform the firm's accounts trade on. Only 'tradovate' firms get
|
/** Which platform the firm's accounts trade on. Older masters omit the
|
||||||
* a client here; anything else is synced but sits idle until its trading
|
* field — treated as 'tradovate'; a value this build doesn't know makes
|
||||||
* logic exists. Older masters omit the field — treated as 'tradovate'. */
|
* the sync skip the firm. */
|
||||||
platform: 'tradovate' | 'topstepx';
|
platform: FirmPlatform;
|
||||||
bannedSymbols: string[];
|
bannedSymbols: string[];
|
||||||
accountTypes: CatalogAccountType[];
|
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;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+39
-7
@@ -4,11 +4,14 @@
|
|||||||
* reporter, state route and resolver can drive either platform without caring
|
* reporter, state route and resolver can drive either platform without caring
|
||||||
* which is behind the map entry.
|
* which is behind the map entry.
|
||||||
*
|
*
|
||||||
* Contract ids are `number` on Tradovate and `string` (e.g.
|
* Contract ids are `number` on Tradovate and `string` on TopstepX (e.g.
|
||||||
* "CON.F.US.ENQ.Z25") on TopstepX — everything that compares them only ever
|
* "CON.F.US.ENQ.Z25") and DXTrade (the symbol, e.g. "BTC/USD") — everything
|
||||||
* compares within one client, so the union is safe.
|
* that compares them only ever compares within one client, so the union is
|
||||||
|
* safe.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { FirmPlatform } from './master-protocol';
|
||||||
|
|
||||||
export interface PlatformAccount {
|
export interface PlatformAccount {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -19,14 +22,33 @@ export interface PlatformContract {
|
|||||||
id: number | string;
|
id: number | string;
|
||||||
name: string;
|
name: string;
|
||||||
tickSize: number;
|
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 {
|
export interface PlatformClient {
|
||||||
accountList: PlatformAccount[];
|
accountList: PlatformAccount[];
|
||||||
/** realizedPnL is today's. pendingRoll (Tradovate): the broker still shows
|
/** 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 ended day's record and today's share of it can't be told apart —
|
||||||
* the account waits for the broker to roll it. */
|
* the account waits for the broker to roll it. equity: balance plus open
|
||||||
accountCashBalances: { [accountId: number]: { amount: number; realizedPnL: number; pendingRoll?: boolean } };
|
* P&L, where the platform reports it. */
|
||||||
|
accountCashBalances: { [accountId: number]: { amount: number; realizedPnL: number; pendingRoll?: boolean; equity?: number } };
|
||||||
positions: {
|
positions: {
|
||||||
[accountId: number]: {
|
[accountId: number]: {
|
||||||
contractId: number | string;
|
contractId: number | string;
|
||||||
@@ -69,6 +91,15 @@ export interface PlatformClient {
|
|||||||
* read as a fresh stage-1 cycle, so such an account must not trade.
|
* read as a fresh stage-1 cycle, so such an account must not trade.
|
||||||
*/
|
*/
|
||||||
historyReady(accountId: number): boolean;
|
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
|
/** Drop the account's history from memory (the wipe routes), so it reads
|
||||||
* unloaded until the next fetch rebuilds it. */
|
* unloaded until the next fetch rebuilds it. */
|
||||||
forgetHistory(accountId: number, accountName: string): void;
|
forgetHistory(accountId: number, accountName: string): void;
|
||||||
@@ -113,10 +144,11 @@ export interface PlatformClient {
|
|||||||
* Timestamps are epoch ms, 0 = never. Never carries tokens or credentials.
|
* Timestamps are epoch ms, 0 = never. Never carries tokens or credentials.
|
||||||
*/
|
*/
|
||||||
export interface ClientHealth {
|
export interface ClientHealth {
|
||||||
platform: 'tradovate' | 'topstepx';
|
platform: FirmPlatform;
|
||||||
/** Holding a session token right now. */
|
/** Holding a session token right now. */
|
||||||
authenticated: boolean;
|
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;
|
transport: string;
|
||||||
syncComplete: boolean;
|
syncComplete: boolean;
|
||||||
lastSyncAt: number;
|
lastSyncAt: number;
|
||||||
|
|||||||
+8
-6
@@ -18,7 +18,7 @@ import { getClients } from './clients';
|
|||||||
import { getTradableFirms } from './firm-catalog';
|
import { getTradableFirms } from './firm-catalog';
|
||||||
import { getAccountConfig } from './account-type-resolver';
|
import { getAccountConfig } from './account-type-resolver';
|
||||||
import { getSchedulerStatus } from './auto-trade';
|
import { getSchedulerStatus } from './auto-trade';
|
||||||
import { 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 type { AccountTypeStats, FirmStats } from './master-protocol';
|
||||||
import { historyFresh } from './platform-client';
|
import { historyFresh } from './platform-client';
|
||||||
|
|
||||||
@@ -288,6 +288,7 @@ function collectFirmStats() {
|
|||||||
cycleDailyPnL: client.dailyPnL[acc.id],
|
cycleDailyPnL: client.dailyPnL[acc.id],
|
||||||
rawRealizedPnL: cash.realizedPnL,
|
rawRealizedPnL: cash.realizedPnL,
|
||||||
combineCmeHolidays: cfg.combineCmeHolidays,
|
combineCmeHolidays: cfg.combineCmeHolidays,
|
||||||
|
tradesEveryDay: cfg.tradesEveryDay === true,
|
||||||
effective,
|
effective,
|
||||||
equityProfit: cash.amount - cfg.accountSize,
|
equityProfit: cash.amount - cfg.accountSize,
|
||||||
splitEnabled: cfg.splitEnabled,
|
splitEnabled: cfg.splitEnabled,
|
||||||
@@ -349,11 +350,12 @@ function collectFirmStats() {
|
|||||||
slice.accountsTraded++;
|
slice.accountsTraded++;
|
||||||
// A grind-day fill (dayTarget 0) can flag targetHit on a
|
// A grind-day fill (dayTarget 0) can flag targetHit on a
|
||||||
// negative day — a loss is still a loss, so red wins.
|
// negative day — a loss is still a loss, so red wins.
|
||||||
// A red day on a stop-enabled account is its own personal
|
// A red day on an account the bot puts stops on (the
|
||||||
// DLL, booked on purpose — not a genuine loss. (A firm DLL
|
// obfuscation stops, or a hard daily loss limit) is its own
|
||||||
// lockout took the !acc.active branch above and stays a
|
// personal DLL, booked on purpose — not a genuine loss. (A
|
||||||
// loss.)
|
// firm DLL lockout took the !acc.active branch above and
|
||||||
if ((cfg.firstDayStop || cfg.minDayStop) && combined.realizedPnL <= -25) {
|
// stays a loss.)
|
||||||
|
if (botStopsActive(cfg) && combined.realizedPnL <= -25) {
|
||||||
personalDllCount++;
|
personalDllCount++;
|
||||||
slice.personalDll = (slice.personalDll ?? 0) + 1;
|
slice.personalDll = (slice.personalDll ?? 0) + 1;
|
||||||
} else if (combined.realizedPnL < 0) {
|
} else if (combined.realizedPnL < 0) {
|
||||||
|
|||||||
+389
-21
@@ -366,13 +366,22 @@ export interface ProjectedDay {
|
|||||||
net?: number;
|
net?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Next weekday after d (YYYY-MM-DD). Trade dates never fall on Sat/Sun. */
|
/** Next trade date after d (YYYY-MM-DD): the next weekday on the CME calendar,
|
||||||
function nextTradingDate(d: string): string {
|
* 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`);
|
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);
|
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
|
* Simulate the account's future assuming every day goes perfectly: each
|
||||||
* trading day books exactly its planned target (deterministic bumps included,
|
* 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
|
* (withdraw, reset, re-target). False = it just PASSES once (an
|
||||||
* eval) and the projection stops. */
|
* eval) and the projection stops. */
|
||||||
payoutable?: boolean;
|
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). */
|
/** Current-cycle daily P&L (account.dailyPnL). */
|
||||||
cycleDailyPnL: { date: string; pnl: number }[];
|
cycleDailyPnL: { date: string; pnl: number }[];
|
||||||
@@ -424,6 +436,8 @@ export function projectPerfectPath(opts: {
|
|||||||
* so today's room is exact and only tightens going forward. */
|
* so today's room is exact and only tightens going forward. */
|
||||||
startThreshold?: number;
|
startThreshold?: number;
|
||||||
};
|
};
|
||||||
|
/** Defaults to the current time. */
|
||||||
|
now?: Date;
|
||||||
}): ProjectedDay[] {
|
}): ProjectedDay[] {
|
||||||
const { accountId, cfg } = opts;
|
const { accountId, cfg } = opts;
|
||||||
const horizon = opts.horizonDays ?? 40;
|
const horizon = opts.horizonDays ?? 40;
|
||||||
@@ -432,30 +446,33 @@ export function projectPerfectPath(opts: {
|
|||||||
const baseCap = opts.payout?.payoutCap ?? 0;
|
const baseCap = opts.payout?.payoutCap ?? 0;
|
||||||
const profitSplit = opts.payout?.profitSplit ?? 0.9;
|
const profitSplit = opts.payout?.profitSplit ?? 0.9;
|
||||||
const dd = Math.max(0, opts.payout?.drawdown ?? 0);
|
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
|
// 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
|
// 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
|
// ONE trading day, exactly as the firm counts min days. Identity view
|
||||||
// when combining is off.
|
// when combining is off.
|
||||||
const combine = cfg.combineCmeHolidays === true;
|
const combine = effectiveCombine(cfg);
|
||||||
const view = combineCmeSessions(combine, opts.cycleDailyPnL, rawToday, opts.realizedPnL);
|
const view = combineCmeSessions(combine, opts.cycleDailyPnL, rawToday, opts.realizedPnL);
|
||||||
// A weekend "today" (Saturday, or Sunday before the evening open) has no
|
// A weekend "today" (Saturday, or Sunday before the evening open) has no
|
||||||
// session — the platform's realizedPnL is the LAST session's stale number,
|
// session — the platform's realizedPnL is the LAST session's stale number,
|
||||||
// already booked in cycleDailyPnL under its own date. Adding it as a
|
// 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 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
|
// 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
|
// date so the projection's first day is Monday, not the day after it
|
||||||
// (combinedTradeDate rolls weekend dates forward).
|
// (combinedTradeDate rolls weekend dates forward).
|
||||||
const today = todayHasSession ? view.tradeDate : rawToday;
|
const today = todayHasSession ? view.tradeDate : rawToday;
|
||||||
let sim = view.dailyPnL.filter((d) => d.date !== today);
|
let sim = view.dailyPnL.filter((d) => d.date !== today);
|
||||||
if (todayHasSession && view.realizedPnL !== 0) sim = [...sim, { date: today, pnl: view.realizedPnL }];
|
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
|
/** Next date the firm settles a session on: weekends skip (except for an
|
||||||
* combining firm's holiday sessions clear into the next full day. */
|
* every-day firm), and a combining firm's holiday sessions clear into
|
||||||
|
* the next full day. */
|
||||||
const nextDay = (d: string): string => {
|
const nextDay = (d: string): string => {
|
||||||
let n = nextTradingDate(d);
|
let n = nextTradingDate(d, everyDay);
|
||||||
while (combine && combinedTradeDate(n) !== n) n = nextTradingDate(n);
|
while (combine && combinedTradeDate(n) !== n) n = nextTradingDate(n);
|
||||||
return n;
|
return n;
|
||||||
};
|
};
|
||||||
@@ -662,12 +679,18 @@ function cycleOwesNothing(
|
|||||||
* weekend (it is the last session's stale number, already booked under its
|
* 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
|
* 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
|
* a weekend date, which would count it twice — and the first minutes after
|
||||||
* the 5 PM roll. */
|
* the 5 PM roll. An every-day firm's weekends are real sessions, so only the
|
||||||
function sessionDoubtful(rawTradeDate: string, cycleDailyPnL: { date: string; pnl: number }[], now: Date): boolean {
|
* 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 day = new Date(`${rawTradeDate}T00:00:00Z`);
|
||||||
const dow = day.getUTCDay();
|
const dow = day.getUTCDay();
|
||||||
if (dow === 0 || dow === 6) return true;
|
if (!everyDay && (dow === 0 || dow === 6)) return true;
|
||||||
if (dow === 1) {
|
if (!everyDay && dow === 1) {
|
||||||
const sunday = fmtUTC(new Date(day.getTime() - 24 * 60 * 60 * 1000));
|
const sunday = fmtUTC(new Date(day.getTime() - 24 * 60 * 60 * 1000));
|
||||||
const saturday = fmtUTC(new Date(day.getTime() - 2 * 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;
|
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 }[];
|
cycleDailyPnL: { date: string; pnl: number }[];
|
||||||
rawRealizedPnL: number;
|
rawRealizedPnL: number;
|
||||||
combineCmeHolidays: boolean;
|
combineCmeHolidays: boolean;
|
||||||
|
/** The firm trades every calendar day — weekends are real sessions. */
|
||||||
|
tradesEveryDay?: boolean;
|
||||||
effective: EffectiveConfig;
|
effective: EffectiveConfig;
|
||||||
/** amount − accountSize — today's P&L included. */
|
/** amount − accountSize — today's P&L included. */
|
||||||
equityProfit: number;
|
equityProfit: number;
|
||||||
@@ -706,14 +731,18 @@ export function cycleCompletion(opts: {
|
|||||||
now?: Date;
|
now?: Date;
|
||||||
}): CycleCompletion {
|
}): CycleCompletion {
|
||||||
const { accountId, rawTradeDate, effective: eff, equityProfit, splitEnabled } = opts;
|
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);
|
const prior = view.dailyPnL.filter((d) => d.date !== view.tradeDate);
|
||||||
|
|
||||||
if (cycleOwesNothing(accountId, eff, prior, view.tradeDate, equityProfit, view.realizedPnL, splitEnabled)) return 'before';
|
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 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';
|
if (view.realizedPnL !== 0 && cycleOwesNothing(accountId, eff, withToday, probe, equityProfit, 0, splitEnabled)) return 'today';
|
||||||
return 'none';
|
return 'none';
|
||||||
}
|
}
|
||||||
@@ -764,15 +793,18 @@ export function endedCycleCompletion(opts: {
|
|||||||
profitTarget: number; consistency: number; minTradingDays: number; minDayPnL: number;
|
profitTarget: number; consistency: number; minTradingDays: number; minDayPnL: number;
|
||||||
targetSameEquity: boolean; withdrawalStages: WithdrawalStage[];
|
targetSameEquity: boolean; withdrawalStages: WithdrawalStage[];
|
||||||
combineCmeHolidays: boolean; splitEnabled: boolean;
|
combineCmeHolidays: boolean; splitEnabled: boolean;
|
||||||
|
tradesEveryDay?: boolean;
|
||||||
};
|
};
|
||||||
/** Defaults to the current time. */
|
/** Defaults to the current time. */
|
||||||
now?: Date;
|
now?: Date;
|
||||||
}): { date: string; stage: number; drain: boolean; cycle: string } | null {
|
}): { date: string; stage: number; drain: boolean; cycle: string } | null {
|
||||||
const { accountId, rawTradeDate, lastFundDate, endedCycleStart: start, fundTransactions, cfg } = opts;
|
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;
|
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);
|
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 (!(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 latest = (a: string, b: string) => (b > a ? b : a);
|
||||||
const earlierFunds = fundTransactions.filter((f) => f.date < lastFundDate);
|
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
|
// Equity the ended cycle finished on, worked back from live equity: less
|
||||||
// everything the current cycle booked (today's live P&L included) and
|
// everything the current cycle booked (today's live P&L included) and
|
||||||
// less the fund transaction between the two.
|
// 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)
|
const curTotal = cur.dailyPnL.filter((d) => d.date !== cur.tradeDate).reduce((s, d) => s + d.pnl, 0)
|
||||||
+ cur.realizedPnL;
|
+ cur.realizedPnL;
|
||||||
const fundAmt = fundTransactions.filter((f) => f.date === lastFundDate).reduce((s, f) => s + f.amount, 0);
|
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.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity,
|
||||||
cfg.withdrawalStages, 0, earlierFunds, cfg.minDayPnL,
|
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.
|
// 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);
|
const traded = rows.filter((d) => d.pnl !== 0);
|
||||||
if (traded.length === 0) return null;
|
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;
|
if (!cycleOwesNothing(accountId, eff, rows, probe, equityBefore, 0, cfg.splitEnabled)) return null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -1040,3 +1072,339 @@ export function computeDayPlan(
|
|||||||
splitAllowed,
|
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",
|
"dev": "next dev --webpack",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint",
|
||||||
|
"check": "node --experimental-strip-types --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --test scripts/check-trading-logic.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@microsoft/signalr": "^10.0.11",
|
"@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 {
|
export interface AccountConfig {
|
||||||
/** Master's account type id — what a manual assignment pins to. */
|
/** Master's account type id — what a manual assignment pins to. */
|
||||||
id: string;
|
id: string;
|
||||||
@@ -27,6 +29,13 @@ export interface AccountConfig {
|
|||||||
payoutCap?: number;
|
payoutCap?: number;
|
||||||
profitSplit?: number;
|
profitSplit?: number;
|
||||||
trailingDrawdown?: 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 {
|
export interface FirmConfig {
|
||||||
@@ -35,12 +44,12 @@ export interface FirmConfig {
|
|||||||
firm: string;
|
firm: string;
|
||||||
/** Which platform the firm's accounts trade on — decides both which client
|
/** Which platform the firm's accounts trade on — decides both which client
|
||||||
* drives it and which credential fields the dashboard asks for. */
|
* 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. */
|
/** Blank when this machine has no credentials for the firm. */
|
||||||
username: string;
|
username: string;
|
||||||
/** True once the platform's credentials are stored locally — Tradovate
|
/** True once the platform's credentials are stored locally — a username +
|
||||||
* username + password, or a TopstepX API key. Firms without them are
|
* password (Tradovate, DXTrade) or a TopstepX API key. Firms without them
|
||||||
* shown greyed out with an inline credential form. */
|
* are shown greyed out with an inline credential form. */
|
||||||
hasCredentials: boolean;
|
hasCredentials: boolean;
|
||||||
bannedSymbols: string[];
|
bannedSymbols: string[];
|
||||||
/** The firm's account types, straight from the catalog. Doubles as the
|
/** The firm's account types, straight from the catalog. Doubles as the
|
||||||
@@ -76,10 +85,15 @@ export interface AccountState {
|
|||||||
stageDrain: boolean;
|
stageDrain: boolean;
|
||||||
/** Next trading day's target, computed server-side. null when account is dead or config is missing. */
|
/** 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;
|
dailyTarget: { amount: number; path: 'first_day' | 'normal_day' | 'reduced_day' } | null;
|
||||||
/** Dollars the next trade may lose before its obfuscation stop fires.
|
/** Dollars of price movement the next trade may lose before its stop
|
||||||
* null = the next trade rides stopless (options off, big non-first day,
|
* fires — the tighter of the obfuscation stop and the hard daily-loss
|
||||||
* or a make-back day after a 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;
|
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). */
|
/** Actual profit target to hit — max(stage profit target, consistency realTarget = maxDay/consistency). */
|
||||||
effectiveProfitTarget: number | null;
|
effectiveProfitTarget: number | null;
|
||||||
/** Current cycle daily P&L — used for target calculations. */
|
/** Current cycle daily P&L — used for target calculations. */
|
||||||
|
|||||||
Reference in New Issue
Block a user