From a2d9df5ba4a9f92e16a1a77fda65ea6f5c63ec8f Mon Sep 17 00:00:00 2001 From: Brandon Li Date: Sat, 26 Sep 2026 18:11:42 -0500 Subject: [PATCH] DXtrade: count closed-trade P&L, week-long volatility, broker leverage caps, swap in the take-profit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Transfers: a close books its SETTLEMENT and COMMISSION under one transaction code, and keying by the code alone let the commission overwrite the P&L. Keys now carry the type, so the day-start balance, daily-loss room, realized P&L, daily history and books check all see closed-trade profit and loss. - Volatility from a week of hourly closes (was 48 h), still measured once per trade date, so one quiet weekend can't triple a hold estimate. - Each symbol's margin rate from the account's instrument details caps the position at available funds / rate (Tradeify: 5:1 crypto, 2:1 stocks); the account type's max notional stays as an optional ceiling. - Firms with overnight financing add the swap a winning leg is expected to pay (the winners' mean hold, (A²+2AB)/3σ²) to its take-profit, and the sizing and hold estimate count it. Co-Authored-By: Claude Opus 5.5 --- README.md | 16 ++++-- app/api/state/route.ts | 15 ++++- app/page.tsx | 7 ++- lib/auto-trade.ts | 40 ++++++++++---- lib/dxtrade-class.ts | 53 +++++------------- lib/dxtrade-helpers.ts | 59 ++++++++++++++++++++ lib/platform-client.ts | 8 ++- lib/trading-logic.ts | 98 ++++++++++++++++++++++++++++----- scripts/check-trading-logic.mjs | 90 +++++++++++++++++++++++++++++- types.ts | 4 ++ 10 files changed, 309 insertions(+), 81 deletions(-) diff --git a/README.md b/README.md index f15942f..b17ca82 100644 --- a/README.md +++ b/README.md @@ -116,14 +116,20 @@ per-contract fees and the CME calendar. - **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** — each leg's notional is sized from the instrument's - recent volatility so its expected hold is about this long (blank = 12 h), - and **financing %/day** if the firm charges swap. + spread plus both commissions, divided by its volatility (hourly closes over + the past week, measured once a day). +- **Target hold hours** — each leg's notional is sized from that volatility so + its expected hold is about this long (blank = 12 h). The position never + exceeds what the account's available funds can margin at the broker's + rate for the symbol (Tradeify: 5:1 crypto, 2:1 stocks), so a quiet market or + a big leg can hold longer than the target. +- **Financing %/day** if the firm charges swap (Tradeify 1-Step: `0.033`). + Each take-profit then also carries the swap a winning leg is expected to pay + before it fills, so a win still clears the full leg. 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**. +fees) and **Max notional**, an optional ceiling on top of the broker's margin. Before the scheduler trades a DXtrade firm, run the smoke test against a real login (with this instance's client for the firm disconnected): diff --git a/app/api/state/route.ts b/app/api/state/route.ts index 38d3fb8..6d1134d 100644 --- a/app/api/state/route.ts +++ b/app/api/state/route.ts @@ -7,7 +7,7 @@ import { historyFresh, type PlatformContract } from '@/lib/platform-client'; import type { AccountState } from '@/types'; import { combineCmeSessions, computeDayPlan, cycleCompletion, resolveEffectiveConfig, currentTradeDateCT, - obfuscationStopDollars, dllRoom as computeDllRoom, dllStopDollars, botStopsActive, sizeNotional, + obfuscationStopDollars, dllRoom as computeDllRoom, dllStopDollars, botStopsActive, sizeNotional, notionalCap, DEFAULT_TARGET_HOLD_HOURS, LEG_COMPLETION_EPSILON, } from '@/lib/trading-logic'; @@ -126,7 +126,8 @@ export async function GET() { // DXtrade: size the next leg the way fireLeg would, on the // instrument the selector last picked and today's measured - // volatility, to show how long it is expected to hold. + // volatility — under the same notional and margin caps — + // to show how long it is expected to hold. const pick = (client as { lastPickedContract?: PlatformContract | null }).lastPickedContract; if (plan && plan.remainingToday > 0 && pick?.stepNotional && pick.volatilityPerHour) { const pct = pick.commissionPct ?? f.commissionPct ?? 0; @@ -145,7 +146,13 @@ export async function GET() { commissionPct: pct, spreadFraction: pick.spreadFraction, financingPctPerDay: f.financingPctPerDay ?? 0, - maxNotional: cfg.maxNotional ?? 0, + // The next leg opens flat: while this one is open its + // margin is tied up, so size against the balance. + maxNotional: notionalCap({ + maxNotional: cfg.maxNotional ?? 0, + marginRate: pick.marginRate, + availableFunds: client.positions[acc.id] ? cash.amount : (cash.availableFunds ?? cash.amount), + }), }); if (sizing) { holdEstimate = { @@ -154,6 +161,8 @@ export async function GET() { notional: Math.round(sizing.notional), volatilityPerDay: pick.volatilityPerHour * Math.sqrt(24), winProbability: sizing.winProbability, + swapReserve: sizing.swapReserve, + leverage: pick.marginRate ? Math.round(10 / pick.marginRate) / 10 : null, }; } } diff --git a/app/page.tsx b/app/page.tsx index 98def78..94df943 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -393,8 +393,11 @@ function AccountRow({ account, firm, hideDead, privacy, onAssigned }: { {account.holdEstimate && ( 0 ? `; its take-profit carries ~$${fmt(account.holdEstimate.swapReserve)} of expected swap` : '')} > {' · hold ~'}{account.holdEstimate.hours < 48 ? `${Math.round(account.holdEstimate.hours)}h` : `${(account.holdEstimate.hours / 24).toFixed(1)}d`} diff --git a/lib/auto-trade.ts b/lib/auto-trade.ts index 161192b..9063031 100644 --- a/lib/auto-trade.ts +++ b/lib/auto-trade.ts @@ -14,6 +14,7 @@ import { getAccountConfig } from './account-type-resolver'; import { combineCmeSessions, computeDayPlan, groupLegFraction, pickLegAmount, resolveEffectiveConfig, currentTradeDateCT, obfuscationStopDollars, takeProfitPlan, percentFee, dllRoom, dllStopDollars, dllViable, stopTicks, sizeNotional, + notionalCap, expectedSwapToWin, DEFAULT_TARGET_HOLD_HOURS, isNoTradeTime, isPastSessionClose, BIG_DAY_THRESHOLD, LEG_COMPLETION_EPSILON, MAX_LEGS_PER_DAY, POINT_VALUES, @@ -968,10 +969,13 @@ async function flattenAndFail( * Size: futures trade one contract per $1,000 of leg; a platform that prices * positions (stepNotional) sizes by notional instead — whatever makes the * leg's expected hold the firm's target, from the instrument's recent - * volatility — under the account type's notional cap. + * volatility — under the account type's notional cap and the instrument's + * margin rate (the firm's leverage). * * Fees: a percent-of-notional firm's fee is computed off the fill; otherwise - * it is read off the realized-P&L change a second after it. + * it is read off the realized-P&L change a second after it. A firm that + * charges overnight swap has the swap a winning leg is expected to pay added + * to its take-profit. * * Stop: the tighter of the obfuscation stop and the hard daily-loss stop. * With a daily loss limit, no position is left without one — no room for a @@ -1026,6 +1030,12 @@ async function fireLeg( const stepNotional = legContract.stepNotional ?? 0; if (stepNotional > 0) { + const liveCash = client.accountCashBalances[acc.id] ?? item.cash; + const cap = notionalCap({ + maxNotional: cfg.maxNotional ?? 0, + marginRate: legContract.marginRate, + availableFunds: liveCash.availableFunds ?? liveCash.amount, + }); const obfStop = Number.isFinite(obfuscation) && obfuscation > 0 ? obfuscation : Infinity; const stopDollarsAt = (notional: number) => Math.min( obfStop, @@ -1039,14 +1049,13 @@ async function fireLeg( commissionPct: pct, spreadFraction: legContract.spreadFraction, financingPctPerDay: firm.financingPctPerDay ?? 0, - maxNotional: cfg.maxNotional ?? 0, + maxNotional: cap, }); if (!sizing) { throw new Error(`no position size for ${legContract.name} — its recent volatility is unknown`); } contracts = Math.max(1, Math.round(sizing.notional / stepNotional)); - const maxNotional = cfg.maxNotional ?? 0; - if (maxNotional > 0) contracts = Math.min(contracts, Math.max(1, Math.floor(maxNotional / stepNotional))); + if (cap > 0) contracts = Math.min(contracts, Math.max(1, Math.floor(cap / stepNotional))); if ((legContract.maxContracts ?? 0) > 0) contracts = Math.min(contracts, legContract.maxContracts!); contracts = Math.max(contracts, legContract.minContracts ?? 1); // The real size is known now: re-check the daily-loss room with it. @@ -1085,11 +1094,6 @@ async function fireLeg( measured = entryCommissionMeasured(item.cash.realizedPnL, updatedCash.realizedPnL, contracts); entryFee = measured ?? FALLBACK_COMMISSION_PER_CONTRACT * contracts; } - const tp = takeProfitPlan({ - side: action, entry: fill.price, leg: legAmount, pointValue: pv, contracts, - tickSize: legContract.tickSize, commissionPct: pct, entryCommission: entryFee, - }); - // The stop. On a measured-fee firm whose fee didn't show, the daily-loss // stop reserves the worst per-contract fee, not the $2.50 fallback. const dllFee = pct > 0 ? entryFee : (measured ?? MAX_COMMISSION_PER_CONTRACT * contracts); @@ -1100,6 +1104,20 @@ async function fireLeg( `daily-loss room ($${round2(item.dllRoom)}) doesn't cover a one-tick stop`); } + // The take-profit clears the leg, both fees and — where the firm charges + // overnight swap — what a winning leg is expected to pay in it. + const swapReserve = expectedSwapToWin({ + distance: legAmount + 2 * entryFee, + stopDollars: ticks !== null ? ticks * legContract.tickSize * pv * contracts : Infinity, + notional: fill.price * pv * contracts, + volatilityPerHour: legContract.volatilityPerHour, + financingPctPerDay: firm.financingPctPerDay ?? 0, + }); + const tp = takeProfitPlan({ + side: action, entry: fill.price, leg: legAmount + swapReserve, pointValue: pv, contracts, + tickSize: legContract.tickSize, commissionPct: pct, entryCommission: entryFee, + }); + let exitOrder: { orderId?: number }; let stopPrice: number | null = null; if (ticks !== null) { @@ -1143,7 +1161,7 @@ async function fireLeg( ? ` / stop @ ${stopPrice}` + (dllOn ? ` (dll room $${round2(item.dllRoom)} → $${round2(dllDollars)}${Number.isFinite(obfuscation) && obfuscation > 0 ? `, obf $${round2(obfuscation)}` : ''})` : '') : ''; - console.log(`${tag} ${acc.name} (${item.firmName}) ${action} ${contracts}x${instrumentLabel} @ ${fill.price} | leg $${legAmount} of day $${item.plan.dayTarget} (remaining $${item.plan.remainingToday}) [${item.plan.path}${item.drain ? ' drain' : ''}] (+$${tp.totalCommission.toFixed(2)} comm)${sizeNote} | exit @ ${tp.exitPrice}${stopNote} (orderId=${exitOrder.orderId})`); + console.log(`${tag} ${acc.name} (${item.firmName}) ${action} ${contracts}x${instrumentLabel} @ ${fill.price} | leg $${legAmount} of day $${item.plan.dayTarget} (remaining $${item.plan.remainingToday}) [${item.plan.path}${item.drain ? ' drain' : ''}] (+$${tp.totalCommission.toFixed(2)} comm${swapReserve > 0 ? `, +$${swapReserve.toFixed(2)} expected swap` : ''})${sizeNote} | exit @ ${tp.exitPrice}${stopNote} (orderId=${exitOrder.orderId})`); return { account: acc.name, diff --git a/lib/dxtrade-class.ts b/lib/dxtrade-class.ts index 84316bd..229b962 100644 --- a/lib/dxtrade-class.ts +++ b/lib/dxtrade-class.ts @@ -44,8 +44,9 @@ import { OrderNotPlacedError, isOrderNotPlaced, errorMessage } from './platform- import { dxAccountId, accountDisplayName, accountCodeOf, encodeContractName, decodeContractName, roundToStep, lastUtcReset, ctTradeDayStart, timeOf, parseDurationMs, ciNum, isFundType, signedFundValue, newOrderCode, - inTradingHours, realizedVolPerHour, scoreCandidate, candidateRejection, + inTradingHours, realizedVolPerHour, scoreCandidate, candidateRejection, parseTransfers, marginRateOf, } from './dxtrade-helpers'; +import type { DxTxn } from './dxtrade-helpers'; import { hyperliquidCoins, hyperliquidCoinFor, hyperliquidQuote, hyperliquidHourlyCloses } from './hyperliquid-feed'; // ── timings ────────────────────────────────────────────────────────────────── @@ -80,7 +81,9 @@ const PICK_TTL_MS = 45_000; const INSTRUMENT_TTL_MS = 60 * 60_000; const ACCOUNT_INSTRUMENT_TTL_MS = 10 * 60_000; const CANDLE_TTL_MS = 15 * 60_000; -const CANDLE_HOURS = 48; +/** Volatility lookback: a week, so one quiet weekend (or one busy day) can't + * swing a multi-day hold estimate — the week's rhythm is priced in. */ +const CANDLE_HOURS = 7 * 24; /** History is fetched this far back at most (or from registration). */ const HISTORY_MAX_DAYS = 180; /** Balance the books can't explain by this much holds the account. */ @@ -129,15 +132,6 @@ interface DxOrder { rejectReason: string | undefined; } -interface DxTxn { - key: string; - account: string; - type: string; - value: number; - time: number; - orderCode: string | undefined; -} - /** DXtrade answered with an error body. */ class DxApiError extends Error { constructor(message: string, readonly status: number, readonly code: number | undefined) { @@ -191,32 +185,6 @@ function parseOrder(raw: unknown): DxOrder | null { }; } -function parseTransfers(body: unknown): DxTxn[] { - const transfers = (body as { cashTransfers?: unknown[] })?.cashTransfers ?? []; - const out: DxTxn[] = []; - for (const t of transfers) { - const transfer = t as Record; - const txns = Array.isArray(transfer.cashTransactions) ? transfer.cashTransactions : []; - for (const x of txns) { - const tx = x as Record; - const account = accountCodeOf(tx.account) ?? accountCodeOf(transfer.account); - const value = ciNum(tx, 'value'); - const time = timeOf(tx.transactionTime ?? transfer.transactionTime); - if (!account || value === undefined || !Number.isFinite(time) || typeof tx.type !== 'string') continue; - const code = typeof tx.transactionCode === 'string' ? tx.transactionCode : ''; - out.push({ - key: code || `${transfer.transferCode ?? ''}|${tx.type}|${time}|${value}|${tx.orderCode ?? ''}|${tx.tradeCode ?? ''}`, - account, - type: tx.type, - value, - time, - orderCode: typeof tx.orderCode === 'string' ? tx.orderCode : undefined, - }); - } - } - return out; -} - function chunks(items: T[], size: number): T[][] { const out: T[][] = []; for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size)); @@ -249,7 +217,7 @@ export class DXTradeClient implements PlatformClient { private userPath: string | null = null; public accountList: PlatformAccount[] = []; - public accountCashBalances: { [accountId: number]: { amount: number; realizedPnL: number; equity?: number } } = {}; + public accountCashBalances: { [accountId: number]: { amount: number; realizedPnL: number; equity?: number; availableFunds?: number } } = {}; public positions: { [accountId: number]: { contractId: string; netPos: number; netPrice: number; timestamp: Date }; } = {}; @@ -689,7 +657,9 @@ export class DXTradeClient implements PlatformClient { for (const t of map.values()) { if (!isFundType(t.type) && currentTradeDateCT(new Date(t.time)) === today) realized += t.value; } - this.accountCashBalances[acc.id] = { amount: balance, realizedPnL: round2(realized), equity: ciNum(m, 'equity') }; + this.accountCashBalances[acc.id] = { + amount: balance, realizedPnL: round2(realized), equity: ciNum(m, 'equity'), availableFunds: ciNum(m, 'availableFunds'), + }; // No floor to report — except for an account the firm has // closed for good (a breach): the balance itself, so the // dashboards and the reporter count it dead, not just inactive. @@ -1256,6 +1226,7 @@ export class DXTradeClient implements PlatformClient { : Math.floor((maxOrder.value * (ciNum(inst, 'lotSize') || 1)) / step); } const minContracts = minOrder > 0 ? Math.ceil(minOrder / step - 1e-9) : 1; + const marginRate = marginRateOf(det); const rejection = !(tick > 0) ? 'no price increment' : candidateRejection({ @@ -1281,7 +1252,8 @@ export class DXTradeClient implements PlatformClient { board.push({ symbol: cand.symbol, score, - note: `spread ${(spread * 100).toFixed(3)}%${vol?.vol ? `, σ ${(vol.vol * Math.sqrt(24) * 100).toFixed(2)}%/day` : ''}`, + note: `spread ${(spread * 100).toFixed(3)}%${vol?.vol ? `, σ ${(vol.vol * Math.sqrt(24) * 100).toFixed(2)}%/day` : ''}` + + (marginRate ? `, ${+(1 / marginRate).toFixed(1)}:1` : ''), contract: { id: cand.symbol, name: encodeContractName(cand.symbol, step), @@ -1293,6 +1265,7 @@ export class DXTradeClient implements PlatformClient { spreadFraction: spread, maxContracts, minContracts, + marginRate, }, }); } diff --git a/lib/dxtrade-helpers.ts b/lib/dxtrade-helpers.ts index 628c071..a0f631e 100644 --- a/lib/dxtrade-helpers.ts +++ b/lib/dxtrade-helpers.ts @@ -155,6 +155,65 @@ export function signedFundValue(type: string, value: number): number { return type === 'WITHDRAWAL' ? -Math.abs(value) : Math.abs(value); } +export interface DxTxn { + key: string; + account: string; + type: string; + value: number; + time: number; + orderCode: string | undefined; +} + +/** + * The cash transactions in a /transfers answer, flattened. A close books its + * SETTLEMENT (the trade's P&L) and its COMMISSION under one transaction code, + * so the key carries the type — and a running count, should a transfer ever + * hold two of one type — or one would overwrite the other. + */ +export function parseTransfers(body: unknown): DxTxn[] { + const transfers = (body as { cashTransfers?: unknown[] })?.cashTransfers ?? []; + const out: DxTxn[] = []; + for (const t of transfers) { + const transfer = t as Record; + const txns = Array.isArray(transfer.cashTransactions) ? transfer.cashTransactions : []; + const seen = new Map(); + for (const x of txns) { + const tx = x as Record; + const account = accountCodeOf(tx.account) ?? accountCodeOf(transfer.account); + const value = ciNum(tx, 'value'); + const time = timeOf(tx.transactionTime ?? transfer.transactionTime); + if (!account || value === undefined || !Number.isFinite(time) || typeof tx.type !== 'string') continue; + const code = typeof tx.transactionCode === 'string' ? tx.transactionCode : ''; + const base = code + ? `${code}|${tx.type}` + : `${transfer.transferCode ?? ''}|${tx.type}|${time}|${value}|${tx.orderCode ?? ''}|${tx.tradeCode ?? ''}`; + const n = seen.get(base) ?? 0; + seen.set(base, n + 1); + out.push({ + key: n === 0 ? base : `${base}|${n}`, + account, + type: tx.type, + value, + time, + orderCode: typeof tx.orderCode === 'string' ? tx.orderCode : undefined, + }); + } + } + return out; +} + +/** + * The margin an account posts per dollar of notional on an instrument — its + * leverage cap, inverted (0.2 = 5:1). A tiered rate reads as its steepest + * tier. undefined when the details don't say. + */ +export function marginRateOf(details: unknown): number | undefined { + const rate = (details as { marginRate?: { tiers?: { value?: unknown }[]; value?: unknown } } | undefined)?.marginRate; + const values = [...(rate?.tiers ?? []).map((t) => Number(t?.value)), Number(rate?.value)] + .filter((v) => Number.isFinite(v) && v > 0 && v <= 1); + return values.length > 0 ? Math.max(...values) : undefined; +} + /** 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); diff --git a/lib/platform-client.ts b/lib/platform-client.ts index bb433b9..e42df3e 100644 --- a/lib/platform-client.ts +++ b/lib/platform-client.ts @@ -38,6 +38,9 @@ export interface PlatformContract { maxContracts?: number; /** Smallest order the broker takes, in engine contracts. */ minContracts?: number; + /** Margin posted per dollar of notional (0.2 = 5:1 leverage), where the + * broker says — caps a position at available funds ÷ this. */ + marginRate?: number; } export interface PlatformClient { @@ -45,8 +48,9 @@ export interface PlatformClient { /** realizedPnL is today's. pendingRoll (Tradovate): the broker still shows * the ended day's record and today's share of it can't be told apart — * the account waits for the broker to roll it. equity: balance plus open - * P&L, where the platform reports it. */ - accountCashBalances: { [accountId: number]: { amount: number; realizedPnL: number; pendingRoll?: boolean; equity?: number } }; + * P&L, where the platform reports it; availableFunds: what margin can + * still draw on, where the platform reports it. */ + accountCashBalances: { [accountId: number]: { amount: number; realizedPnL: number; pendingRoll?: boolean; equity?: number; availableFunds?: number } }; positions: { [accountId: number]: { contractId: number | string; diff --git a/lib/trading-logic.ts b/lib/trading-logic.ts index b9e035b..88dfcf2 100644 --- a/lib/trading-logic.ts +++ b/lib/trading-logic.ts @@ -1269,17 +1269,80 @@ export function botStopsActive(cfg: { firstDayStop: boolean; minDayStop: boolean /** The hold a leg is sized for when the firm doesn't set one. */ export const DEFAULT_TARGET_HOLD_HOURS = 12; +/** Share of the broker's margin headroom a new position may use: the rest + * absorbs the price moving between sizing and the fill, and the entry fee. */ +export const MARGIN_HEADROOM = 0.97; + +/** + * The largest position an account may open: the account type's notional cap + * and, where the broker states the instrument's margin rate, what the + * account's available funds can margin (the firm's leverage — 5:1 on crypto, + * 2:1 on stocks at Tradeify247). 0 = no cap. + */ +export function notionalCap(o: { maxNotional: number; marginRate?: number; availableFunds?: number }): number { + const caps: number[] = []; + if (o.maxNotional > 0) caps.push(o.maxNotional); + if ((o.marginRate ?? 0) > 0 && Number.isFinite(o.availableFunds) && o.availableFunds! > 0) { + caps.push((MARGIN_HEADROOM * o.availableFunds!) / o.marginRate!); + } + return caps.length > 0 ? Math.min(...caps) : 0; +} + +/** The swap reserve never grows past this share of the take-profit distance + * — beyond it the leg is sized too small to hold at all. */ +const MAX_SWAP_RESERVE_FRACTION = 0.25; + +/** + * The overnight swap a winning leg is expected to pay before its take-profit + * fills, so the take-profit can carry it and a win still clears the leg. + * + * Same driftless walk as sizeNotional, in dollars: σ$ = notional·σ per √hour, + * the take-profit A away, the stop B away. Given it wins, the walk takes + * (A² + 2AB)/(3σ$²) hours on average — longer than the unconditional hold, + * since the quick endings are mostly stop-outs. The swap is charged at each + * daily reset the position is held over, about one per 24 h held, on the + * notional. A includes the reserve itself, so it is solved as a fixed point. + * A stopless leg is taken as B = A. + * + * `distance` = the take-profit distance before swap: leg + round-trip fees. + */ +export function expectedSwapToWin(o: { + distance: number; + stopDollars: number; + notional: number; + volatilityPerHour?: number; + financingPctPerDay?: number; +}): number { + const daily = Math.max(0, o.financingPctPerDay ?? 0) / 100; + const sigma = o.volatilityPerHour ?? 0; + if (!(daily > 0) || !(sigma > 0) || !(o.notional > 0) || !(o.distance > 0)) return 0; + const variance = (o.notional * sigma) ** 2; + const stopped = Number.isFinite(o.stopDollars) && o.stopDollars > 0; + const cap = o.distance * MAX_SWAP_RESERVE_FRACTION; + let swap = 0; + for (let i = 0; i < 50; i++) { + const a = o.distance + swap; + const b = stopped ? o.stopDollars : a; + const winHours = (a * a + 2 * a * b) / (3 * variance); + const next = Math.min(cap, daily * o.notional * (winHours / 24)); + const done = Math.abs(next - swap) < 0.005; + swap = next; + if (done) break; + } + return Math.ceil(swap * 100) / 100; +} + /** * 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. + * take-profit A dollars away (leg + fees + expected swap) 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. * * A stopless leg (no daily loss limit, no obfuscation stop) is sized as if * its stop sat as far away as its target. Returns null without a volatility. @@ -1295,9 +1358,9 @@ export function sizeNotional(o: { commissionPct: number; spreadFraction?: number; financingPctPerDay?: number; - /** Hard cap on the position; 0 = none. */ + /** Hard cap on the position (see notionalCap); 0 = none. */ maxNotional: number; -}): { notional: number; predictedHoldHours: number; winProbability: number | null } | null { +}): { notional: number; predictedHoldHours: number; winProbability: number | null; swapReserve: number } | null { const sigma = o.volatilityPerHour ?? 0; if (!(sigma > 0)) return null; const hold = o.targetHoldHours > 0 ? o.targetHoldHours : DEFAULT_TARGET_HOLD_HOURS; @@ -1305,16 +1368,20 @@ export function sizeNotional(o: { const s = Math.max(0, o.spreadFraction ?? 0); const fh = Math.max(0, o.financingPctPerDay ?? 0) / 100 / 24; const distances = (n: number) => { - const a = o.leg + 2 * c * n; const stop = o.stopDollarsAt(n); const stopped = Number.isFinite(stop) && stop > 0; - return { a, b: stopped ? stop : a, stopped }; + const swap = expectedSwapToWin({ + distance: o.leg + 2 * c * n, stopDollars: stop, notional: n, + volatilityPerHour: sigma, financingPctPerDay: o.financingPctPerDay, + }); + const a = o.leg + 2 * c * n + swap; + return { a, b: stopped ? stop : a, stopped, swap }; }; - // 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). + // Fees and swap depend on N and the daily-loss stop depends on fees: a + // few fixed-point passes settle it (the correction is second order). let n = o.leg * 20; - for (let pass = 0; pass < 3; pass++) { + for (let pass = 0; pass < 4; pass++) { const { a, b } = distances(n); const nTime = Math.sqrt((a * b) / (sigma * sigma * hold)); const nCost = fh > 0 ? Math.sqrt((fh * a * b) / (sigma * sigma * (2 * c + s))) : 0; @@ -1322,11 +1389,12 @@ export function sizeNotional(o: { } if (!Number.isFinite(n) || n <= 0) return null; const notional = o.maxNotional > 0 ? Math.min(n, o.maxNotional) : n; - const { a, b, stopped } = distances(notional); + const { a, b, stopped, swap } = distances(notional); return { notional, predictedHoldHours: (a / notional) * (b / notional) / (sigma * sigma), winProbability: stopped ? b / (a + b) : null, + swapReserve: swap, }; } diff --git a/scripts/check-trading-logic.mjs b/scripts/check-trading-logic.mjs index d933cdb..46e9ad6 100644 --- a/scripts/check-trading-logic.mjs +++ b/scripts/check-trading-logic.mjs @@ -11,7 +11,7 @@ import assert from 'node:assert/strict'; import { percentFee, takeProfitPlan, obfuscationStopDollars, dllRoom, dllStopDollars, dllViable, stopTicks, botStopsActive, sizeNotional, isNoTradeTime, isPastSessionClose, nextTradingDate, effectiveCombine, DEFAULT_TARGET_HOLD_HOURS, - projectPerfectPath, cycleCompletion, DLL_RESERVE_FRACTION, DLL_MIN_STOP, + projectPerfectPath, cycleCompletion, DLL_RESERVE_FRACTION, DLL_MIN_STOP, notionalCap, expectedSwapToWin, MARGIN_HEADROOM, } from '../lib/trading-logic.ts'; // Deterministic PRNG so a failure reproduces. @@ -307,6 +307,7 @@ test('random-walk simulation agrees with the predicted hold and win rate', () => const predicted = (A / notional) * (B / notional) / (sigma * sigma); let wins = 0; let totalHours = 0; + let winHours = 0; const paths = 3000; for (let p = 0; p < paths; p++) { let x = 0; @@ -315,12 +316,59 @@ test('random-walk simulation agrees with the predicted hold and win rate', () => x += r() < 0.5 ? stepDollars : -stepDollars; steps++; } - if (x >= A) wins++; + if (x >= A) { wins++; winHours += steps * dtHours; } 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}`); + // Winners take longer than the average leg: (A² + 2AB) / 3σ$² — what + // expectedSwapToWin charges swap over. + const predictedWin = (A * A + 2 * A * B) / (3 * (notional * sigma) ** 2); + assert.ok(Math.abs(winHours / wins - predictedWin) / predictedWin < 0.1, `winners ${winHours / wins}h vs ${predictedWin}h`); +}); + +test('expected swap to win: a fixed point over the winners\' hold, zero without swap, capped', () => { + const sigma = 0.022 / Math.sqrt(24); // ETH over a week, ~2.2%/day + const o = { distance: 12400, stopDollars: 2300, notional: 200000, volatilityPerHour: sigma, financingPctPerDay: 0.033 }; + const swap = expectedSwapToWin(o); + const a = o.distance + swap; + const b = o.stopDollars; + const winHours = (a * a + 2 * a * b) / (3 * (o.notional * sigma) ** 2); + assert.ok(Math.abs(swap - 0.00033 * o.notional * (winHours / 24)) < 0.02, `swap ${swap} over ${winHours}h`); + assert.ok(swap > 200 && swap < 400, `swap ${swap}`); + // Quieter means a longer hold and more swap; none without a rate or σ. + assert.ok(expectedSwapToWin({ ...o, volatilityPerHour: sigma * 0.6 }) > swap); + assert.equal(expectedSwapToWin({ ...o, financingPctPerDay: 0 }), 0); + assert.equal(expectedSwapToWin({ ...o, volatilityPerHour: undefined }), 0); + // Stopless reads as B = A: a longer walk than with the stop. + assert.ok(expectedSwapToWin({ ...o, stopDollars: Infinity }) > swap); + // A leg that can barely move is capped at a quarter of its distance. + assert.equal(expectedSwapToWin({ ...o, volatilityPerHour: sigma / 50 }), 3100); +}); + +test('the notional cap: the account type, the margin the funds allow, the tighter', () => { + assert.equal(notionalCap({ maxNotional: 200000 }), 200000); + assert.equal(notionalCap({ maxNotional: 0 }), 0); + assert.equal(notionalCap({ maxNotional: 0, marginRate: 0.2, availableFunds: 100000 }), MARGIN_HEADROOM * 500000); + assert.equal(notionalCap({ maxNotional: 0, marginRate: 0.5, availableFunds: 99757.39 }), MARGIN_HEADROOM * 99757.39 * 2); + assert.equal(notionalCap({ maxNotional: 200000, marginRate: 0.2, availableFunds: 100000 }), 200000); + assert.equal(notionalCap({ maxNotional: 0, marginRate: 0.2, availableFunds: undefined }), 0); +}); + +test('sizing carries the swap reserve into the take-profit distance', () => { + const sigma = 0.0124 / Math.sqrt(24); + const stopDollarsAt = (n) => dllStopDollars({ room: 2760.15, dailyLossLimit: 3000, entryFee: 0.0004 * n, commissionPct: 0.04 }); + const base = { leg: 12239, stopDollarsAt, volatilityPerHour: sigma, targetHoldHours: 12, commissionPct: 0.04, spreadFraction: 0.0001 }; + const withSwap = sizeNotional({ ...base, financingPctPerDay: 0.033, maxNotional: 200000 }); + const noSwap = sizeNotional({ ...base, financingPctPerDay: 0, maxNotional: 200000 }); + assert.equal(noSwap.swapReserve, 0); + assert.ok(withSwap.swapReserve > 500, `swap ${withSwap.swapReserve}`); + const a = 12239 + 2 * 0.0004 * 200000 + withSwap.swapReserve; + const b = stopDollarsAt(200000); + assert.ok(Math.abs(withSwap.predictedHoldHours - (a / 200000) * (b / 200000) / (sigma * sigma)) < 1e-6); + assert.ok(withSwap.predictedHoldHours > noSwap.predictedHoldHours); + assert.ok(withSwap.winProbability < noSwap.winProbability); }); // ── every-day calendar ─────────────────────────────────────────────────────── @@ -375,9 +423,45 @@ test('an every-day firm can finish its cycle on a Saturday', () => { import { dxAccountId, accountDisplayName, accountCodeOf, encodeContractName, decodeContractName, roundToStep, lastUtcReset, ctTradeDayStart, parseDurationMs, ciNum, signedFundValue, inTradingHours, - realizedVolPerHour, scoreCandidate, candidateRejection, MIN_VOL_CANDLES, + realizedVolPerHour, scoreCandidate, candidateRejection, MIN_VOL_CANDLES, parseTransfers, marginRateOf, } from '../lib/dxtrade-helpers.ts'; +test('transfers: a close\'s SETTLEMENT and COMMISSION share a code and both count', () => { + // As Tradeify247 answered for a flatten. + const body = { cashTransfers: [ + { account: 'default:1S100K2166398', transferCode: '2354051:1417097', transactionTime: '2026-09-26T22:33:39.615Z', cashTransactions: [ + { account: 'default:1S100K2166398', transactionCode: '2354051:1417097', type: 'COMMISSION', value: -79.95, transactionTime: '2026-09-26T22:33:39.615Z', orderCode: 'x:af-1-e' }, + ] }, + { account: 'default:1S100K2166398', transferCode: '2354051:1417102', transactionTime: '2026-09-26T22:33:42.197Z', cashTransactions: [ + { account: 'default:1S100K2166398', transactionCode: '2354051:1417102', type: 'SETTLEMENT', value: -5.43, transactionTime: '2026-09-26T22:33:42.197Z', orderCode: 'x:af-2-x' }, + { account: 'default:1S100K2166398', transactionCode: '2354051:1417102', type: 'COMMISSION', value: -79.95, transactionTime: '2026-09-26T22:33:42.197Z', orderCode: 'x:af-2-x' }, + ] }, + ] }; + const txns = parseTransfers(body); + assert.equal(txns.length, 3); + const byKey = new Map(txns.map((t) => [t.key, t])); + assert.equal(byKey.size, 3, 'every transaction keeps its own key'); + assert.equal(Math.round([...byKey.values()].reduce((s, t) => s + t.value, 0) * 100) / 100, -165.33); + // The same answer again (the next sync, another page) keys identically. + assert.deepEqual(parseTransfers(body).map((t) => t.key), txns.map((t) => t.key)); + // Two of one type in one transfer stay apart too. + const twice = { cashTransfers: [{ transferCode: 't', cashTransactions: [ + { account: 'default:A', transactionCode: 'c', type: 'COMMISSION', value: -1, transactionTime: '2026-09-26T00:00:00Z' }, + { account: 'default:A', transactionCode: 'c', type: 'COMMISSION', value: -2, transactionTime: '2026-09-26T00:00:00Z' }, + ] }] }; + assert.equal(new Set(parseTransfers(twice).map((t) => t.key)).size, 2); +}); + +test('margin rates: flat and tiered, the steepest tier, junk ignored', () => { + assert.equal(marginRateOf({ marginRate: { rateType: 'FLAT', tiers: [{ endVolume: 'Infinity', value: 0.2 }] } }), 0.2); + assert.equal(marginRateOf({ marginRate: { rateType: 'FLAT', tiers: [{ endVolume: 'Infinity', value: 0.5 }] } }), 0.5); + assert.equal(marginRateOf({ marginRate: { tiers: [{ endVolume: 1e6, value: 0.1 }, { endVolume: 'Infinity', value: 0.25 }] } }), 0.25); + assert.equal(marginRateOf({ marginRate: { value: 0.05 } }), 0.05); + assert.equal(marginRateOf({}), undefined); + assert.equal(marginRateOf(undefined), undefined); + assert.equal(marginRateOf({ marginRate: { tiers: [{ value: 'x' }, { value: 0 }, { value: 3 }] } }), undefined); +}); + 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')); diff --git a/types.ts b/types.ts index f5fbd48..97bdae9 100644 --- a/types.ts +++ b/types.ts @@ -103,6 +103,10 @@ export interface AccountState { notional: number; volatilityPerDay: number; winProbability: number | null; + /** Expected swap a winning leg pays, added to its take-profit. */ + swapReserve: number; + /** The broker's leverage on the symbol (5 = 5:1); null when unstated. */ + leverage: number | null; } | null; /** Actual profit target to hit — max(stage profit target, consistency realTarget = maxDay/consistency). */ effectiveProfitTarget: number | null;