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,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getCachedFirm } from '@/lib/firm-catalog';
|
||||
import { getCachedFirm, hasPlatformCredentials } from '@/lib/firm-catalog';
|
||||
import { setCredentials, clearCredentials } from '@/lib/db';
|
||||
import { reconcileClients, removeClient } from '@/lib/clients';
|
||||
import { scheduleBackupPush } from '@/lib/backup-sync';
|
||||
@@ -12,10 +12,11 @@ type Params = { params: Promise<{ id: string }> };
|
||||
* (lib/backup-sync.ts); they are still absent from the catalog and the
|
||||
* control socket.
|
||||
*
|
||||
* Tradovate firms need username + password. TopstepX firms need the API key;
|
||||
* username is optional for now (the loginKey call still wants it, so the
|
||||
* client sits idle with a clear error until one is added) and password is
|
||||
* unused. */
|
||||
* Tradovate and DXTrade firms need username + password (DXTrade's server URL
|
||||
* and login domain are set on the master, per firm). TopstepX firms need the
|
||||
* API key; username is optional for now (the loginKey call still wants it,
|
||||
* so the client sits idle with a clear error until one is added) and
|
||||
* password is unused. */
|
||||
export async function PUT(req: NextRequest, { params }: Params) {
|
||||
const { id } = await params;
|
||||
|
||||
@@ -27,12 +28,13 @@ export async function PUT(req: NextRequest, { params }: Params) {
|
||||
const password = body.password?.trim() ?? '';
|
||||
const apiKey = body.apiKey?.trim() ?? '';
|
||||
|
||||
if (firm.platform === 'topstepx') {
|
||||
if (!apiKey) {
|
||||
return NextResponse.json({ error: 'apiKey is required for a TopstepX firm' }, { status: 400 });
|
||||
}
|
||||
} else if (!username || !password) {
|
||||
return NextResponse.json({ error: 'username and password are required' }, { status: 400 });
|
||||
if (!hasPlatformCredentials(firm.platform, { username, password, apiKey })) {
|
||||
const error = firm.platform === 'topstepx'
|
||||
? 'apiKey is required for a TopstepX firm'
|
||||
: firm.platform === 'dxtrade'
|
||||
? 'username and password are required for a DXtrade firm'
|
||||
: 'username and password are required';
|
||||
return NextResponse.json({ error }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getCachedCatalog } from '@/lib/firm-catalog';
|
||||
import { getCachedCatalog, hasPlatformCredentials } from '@/lib/firm-catalog';
|
||||
import { getCredentials } from '@/lib/db';
|
||||
import type { FirmConfig } from '@/types';
|
||||
|
||||
@@ -18,9 +18,7 @@ export async function GET() {
|
||||
try {
|
||||
const result: FirmConfig[] = getCachedCatalog().map((firm) => {
|
||||
const creds = getCredentials(firm.id);
|
||||
const hasCredentials = firm.platform === 'topstepx'
|
||||
? !!creds?.apiKey
|
||||
: !!(creds?.username && creds?.password);
|
||||
const hasCredentials = hasPlatformCredentials(firm.platform, creds);
|
||||
return {
|
||||
id: firm.id,
|
||||
firm: firm.name,
|
||||
@@ -46,6 +44,9 @@ export async function GET() {
|
||||
minDayStop: a.minDayStop,
|
||||
combineCmeHolidays: a.combineCmeHolidays,
|
||||
withdrawalStages: a.withdrawalStages,
|
||||
dailyLossLimit: a.dailyLossLimit ?? 0,
|
||||
maxNotional: a.maxNotional ?? 0,
|
||||
tradesEveryDay: a.tradesEveryDay === true,
|
||||
})),
|
||||
catalogUpdatedAt: firm.updatedAt,
|
||||
};
|
||||
|
||||
+32
-24
@@ -6,7 +6,8 @@ import { getAccountConfig, getInferredSize, matchAccountType } from '@/lib/accou
|
||||
import { historyFresh } from '@/lib/platform-client';
|
||||
import {
|
||||
combineCmeSessions, computeDayPlan, cycleCompletion, resolveEffectiveConfig, currentTradeDateCT,
|
||||
BIG_DAY_THRESHOLD, FIRST_DAY_STOP, LEG_COMPLETION_EPSILON, MIN_DAY_STOP_MULT,
|
||||
obfuscationStopDollars, dllRoom as computeDllRoom, dllStopDollars, botStopsActive,
|
||||
LEG_COMPLETION_EPSILON,
|
||||
} from '@/lib/trading-logic';
|
||||
|
||||
export async function GET() {
|
||||
@@ -84,6 +85,7 @@ export async function GET() {
|
||||
let remainingToday: number | null = null;
|
||||
let effectiveProfitTarget: number | null = null;
|
||||
let nextStop: number | null = null;
|
||||
let dllRoom: number | null = null;
|
||||
if (cfg && effective && !isDead) {
|
||||
const equityProfit = cash.amount - cfg.accountSize;
|
||||
const plan = computeDayPlan(
|
||||
@@ -94,25 +96,29 @@ export async function GET() {
|
||||
dailyTarget = plan ? { amount: plan.dayTarget, path: plan.path } : null;
|
||||
remainingToday = plan?.remainingToday ?? null;
|
||||
|
||||
// The stop the next trade would carry under the obfuscation
|
||||
// options, mirroring obfuscationStopDollars in auto-trade:
|
||||
// day 1 keeps the day's net floor at −$1,000, min days stop
|
||||
// at 2× the target, and the day after a loss rides stopless.
|
||||
if (plan && (cfg.firstDayStop || cfg.minDayStop)) {
|
||||
const prior = dailyPnL
|
||||
.filter((d) => d.date < combined.tradeDate && d.pnl !== 0)
|
||||
.sort((a, b) => a.date.localeCompare(b.date));
|
||||
const recoveryDay = prior.length > 0 && prior[prior.length - 1].pnl < 0;
|
||||
if (!recoveryDay) {
|
||||
let stop = Infinity;
|
||||
if (cfg.firstDayStop && prior.length === 0) {
|
||||
stop = Math.max(0, Math.round((FIRST_DAY_STOP + combined.realizedPnL) * 100) / 100);
|
||||
}
|
||||
if (cfg.minDayStop && plan.dayTarget <= BIG_DAY_THRESHOLD) {
|
||||
stop = Math.min(stop, MIN_DAY_STOP_MULT * plan.dayTarget);
|
||||
}
|
||||
if (Number.isFinite(stop) && stop > 0) nextStop = Math.round(stop * 100) / 100;
|
||||
}
|
||||
// The stop the next trade would carry, from the same helpers
|
||||
// the fire path uses: the obfuscation stop (day 1 keeps the
|
||||
// day's net floor at −$1,000, min days stop at 2× the leg,
|
||||
// the day after a loss rides stopless) against the hard
|
||||
// daily-loss stop, before fees. A min day trades its whole
|
||||
// remaining target as one leg.
|
||||
const dll = cfg.dailyLossLimit ?? 0;
|
||||
if (dll > 0) {
|
||||
const dayStart = client.dayStartBalance?.(acc.id);
|
||||
dllRoom = dayStart === null ? null : computeDllRoom({
|
||||
dailyLossLimit: dll,
|
||||
closedBalance: cash.amount,
|
||||
dayStartBalance: dayStart,
|
||||
realizedToday: Math.min(cash.realizedPnL, combined.realizedPnL),
|
||||
});
|
||||
}
|
||||
if (plan && (botStopsActive(cfg))) {
|
||||
const obf = obfuscationStopDollars(cfg, dailyPnL, plan, combined.tradeDate, plan.remainingToday);
|
||||
const hard = dllRoom !== null
|
||||
? dllStopDollars({ room: dllRoom, dailyLossLimit: dll, entryFee: 0, commissionPct: 0 })
|
||||
: Infinity;
|
||||
const stop = Math.min(Number.isFinite(obf) && obf > 0 ? obf : Infinity, hard);
|
||||
if (Number.isFinite(stop) && stop > 0) nextStop = Math.round(stop * 100) / 100;
|
||||
}
|
||||
|
||||
// Effective profit target = max(stage target, consistency realTarget).
|
||||
@@ -187,6 +193,7 @@ export async function GET() {
|
||||
cycleDailyPnL: client.dailyPnL[acc.id],
|
||||
rawRealizedPnL: cash.realizedPnL,
|
||||
combineCmeHolidays: cfg.combineCmeHolidays,
|
||||
tradesEveryDay: cfg.tradesEveryDay === true,
|
||||
effective,
|
||||
equityProfit: cash.amount - cfg.accountSize,
|
||||
splitEnabled: cfg.splitEnabled,
|
||||
@@ -202,10 +209,10 @@ export async function GET() {
|
||||
// on the Tuesday after a holiday this includes Sun/Mon.
|
||||
realizedPnL: combined.realizedPnL,
|
||||
daysTraded,
|
||||
// A stop-enabled account whose day booked red via its own
|
||||
// stop — the personal DLL. Firm DLL lockouts show via
|
||||
// !active instead.
|
||||
personalDll: !!cfg && (cfg.firstDayStop || cfg.minDayStop)
|
||||
// An account the bot puts stops on whose day booked red via
|
||||
// its own stop — the personal DLL. Firm DLL lockouts show
|
||||
// via !active instead.
|
||||
personalDll: !!cfg && botStopsActive(cfg)
|
||||
&& acc.active && !client.positions[acc.id]
|
||||
&& combined.realizedPnL <= -25,
|
||||
positionDirection: client.positions[acc.id]
|
||||
@@ -220,6 +227,7 @@ export async function GET() {
|
||||
stageDrain: effective?.drain ?? false,
|
||||
dailyTarget,
|
||||
nextStop,
|
||||
dllRoom,
|
||||
remainingToday,
|
||||
effectiveProfitTarget,
|
||||
dailyPnL,
|
||||
|
||||
+26
-8
@@ -188,9 +188,19 @@ function AccountTypeCell({ account, firm, onAssigned }: {
|
||||
);
|
||||
}
|
||||
|
||||
/** What a firm's saved login is called in the dashboard's prompts. */
|
||||
function credentialNoun(platform: FirmConfig['platform']): string {
|
||||
switch (platform) {
|
||||
case 'topstepx': return 'TopstepX API key';
|
||||
case 'dxtrade': return 'DXtrade login';
|
||||
case 'tradovate': return 'Tradovate login';
|
||||
}
|
||||
}
|
||||
|
||||
/** Inline credential entry, shown to the right of a firm that has none.
|
||||
* Tradovate wants username + password; TopstepX wants the API key (username
|
||||
* optional for now, though login needs it eventually — password unused). */
|
||||
* Tradovate and DXtrade want username + password; TopstepX wants the API key
|
||||
* (username optional for now, though login needs it eventually — password
|
||||
* unused). */
|
||||
function CredentialForm({ firm, onSaved }: { firm: FirmConfig; onSaved: () => void }) {
|
||||
const isTopstepX = firm.platform === 'topstepx';
|
||||
const [username, setUsername] = useState('');
|
||||
@@ -231,7 +241,7 @@ function CredentialForm({ firm, onSaved }: { firm: FirmConfig; onSaved: () => vo
|
||||
<div className="flex items-center gap-1.5" onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
className={cls}
|
||||
placeholder={isTopstepX ? 'Username (optional)' : 'Tradovate username'}
|
||||
placeholder={isTopstepX ? 'Username (optional)' : firm.platform === 'dxtrade' ? 'DXtrade username' : 'Tradovate username'}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
@@ -375,7 +385,10 @@ function AccountRow({ account, firm, hideDead, privacy, onAssigned }: {
|
||||
<span className="text-xs text-slate-400">
|
||||
${fmt(account.dailyTarget.amount)} today
|
||||
{account.nextStop !== null && (
|
||||
<span className="text-amber-600" title="The next trade's stop — dollars it may lose before booking the day"> · stop ${fmt(account.nextStop)}</span>
|
||||
<span className="text-amber-600" title="The next trade's stop — dollars of price movement it may lose before booking the day (fees come on top; the daily-loss stop already reserves for them)"> · stop ${fmt(account.nextStop)}</span>
|
||||
)}
|
||||
{typeof account.dllRoom === 'number' && (
|
||||
<span className="text-slate-500" title="Hard daily loss limit: what the firm's day may still lose before a breach. 10% of the limit is never spent."> · DLL room ${fmt(account.dllRoom)}</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
@@ -392,7 +405,7 @@ function AccountRow({ account, firm, hideDead, privacy, onAssigned }: {
|
||||
: account.positionDirection
|
||||
? <span className={`inline-block px-2.5 py-0.5 rounded-full text-xs font-semibold ${account.positionDirection === 'long' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>{account.positionDirection === 'long' ? 'Long' : 'Short'}</span>
|
||||
: account.personalDll
|
||||
? <span className="inline-block px-2.5 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-700" title="Booked the day red via its own stop — make-back rides next session">Personal DLL</span>
|
||||
? <span className="inline-block px-2.5 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-700" title="Booked the day red via its own stop (obfuscation or daily-loss) — trading resumes next session">Personal DLL</span>
|
||||
: account.targetHit
|
||||
? <span className="inline-block px-2.5 py-0.5 rounded-full text-xs font-semibold bg-green-100 text-green-700">Target Hit</span>
|
||||
: <span className="inline-block px-2.5 py-0.5 rounded-full text-xs font-semibold bg-slate-100 text-slate-500">Flat</span>}
|
||||
@@ -478,6 +491,11 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo
|
||||
TopstepX
|
||||
</span>
|
||||
)}
|
||||
{firm.platform === 'dxtrade' && (
|
||||
<span className="text-xs bg-emerald-100 text-emerald-700 px-1.5 py-0.5 rounded font-semibold">
|
||||
DXtrade
|
||||
</span>
|
||||
)}
|
||||
{firm.platform === 'topstepx' && hasCredentials && !firm.username && (
|
||||
<span
|
||||
className="text-xs bg-amber-100 text-amber-700 px-1.5 py-0.5 rounded font-semibold"
|
||||
@@ -506,7 +524,7 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo
|
||||
<td colSpan={5} className="px-4 py-2.5">
|
||||
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
<span className="text-xs text-slate-500">
|
||||
Remove the saved {firm.platform === 'topstepx' ? 'TopstepX API key' : 'Tradovate login'} for {firm.firm}? Its accounts disappear until you enter one again.
|
||||
Remove the saved {credentialNoun(firm.platform)} for {firm.firm}? Its accounts disappear until you enter one again.
|
||||
</span>
|
||||
<button
|
||||
onClick={removeCredentials}
|
||||
@@ -532,7 +550,7 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setConfirmCreds(true); }}
|
||||
className="text-slate-400 hover:text-red-500 hover:bg-red-50 p-1 rounded-md transition-colors inline-flex"
|
||||
title={firm.platform === 'topstepx' ? 'Remove saved TopstepX API key' : 'Remove saved Tradovate login'}
|
||||
title={`Remove saved ${credentialNoun(firm.platform)}`}
|
||||
>
|
||||
<KeyOffIcon />
|
||||
</button>
|
||||
@@ -554,7 +572,7 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo
|
||||
state.accounts.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-5 pl-12 text-sm text-slate-400 italic">
|
||||
{state.connected ? 'No accounts at this firm' : 'Connecting to Tradovate…'}
|
||||
{state.connected ? 'No accounts at this firm' : 'Connecting…'}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user