Compare commits
55
Commits
ed62788480
...
master
@@ -63,6 +63,8 @@ scripts/update.log
|
||||
scripts/.built-commit
|
||||
scripts/.update-failed
|
||||
scripts/update-worker.log
|
||||
# present = this checkout is never auto-updated or healed (a dev checkout)
|
||||
scripts/.no-auto-update
|
||||
# the previous build, set aside while the updater builds (Tailwind's source
|
||||
# scan also honours this, so it never reads the old bundles)
|
||||
/.next-prev/
|
||||
|
||||
@@ -167,7 +167,7 @@ per-contract fees and the CME calendar.
|
||||
Per account type, in the **Hard Daily Loss** column: the **daily loss limit**
|
||||
(Tradeify: 3% of size — every leg then carries a stop that keeps the firm's
|
||||
day inside it, less fees and a reserve of 10% of the limit or 0.1% of the
|
||||
position, whichever is more) and the **static max drawdown** (Tradeify
|
||||
position, whichever is more — 0.02% on Vest, whose stops fill closer) and the **static max drawdown** (Tradeify
|
||||
1-Step: $6,000 on 100K). When hitting the day's loss limit would leave the
|
||||
account within 15% of that loss above its max-drawdown floor, the leg is a
|
||||
*last stand*: no daily-loss stop, running to its take-profit or the firm's
|
||||
@@ -184,7 +184,93 @@ 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 DXtrade selector, the futures draw and instrument lists).
|
||||
hours, the DXtrade selector, the futures draw and instrument lists), plus local
|
||||
HTTP/WebSocket checks against a temporary database.
|
||||
|
||||
### Outbound user agent
|
||||
|
||||
On first server load, each instance randomly selects a desktop Chrome,
|
||||
Firefox, or Edge user agent and saves it as `user_agent` in its settings.
|
||||
Settings → Network shows the selected value. It stays the same across requests,
|
||||
WebSocket reconnects, page reloads, and server restarts, and travels with backups.
|
||||
An existing saved value is reused; an absent or blank value is generated automatically.
|
||||
|
||||
All server Axios calls use `lib/http-client.ts`; import Axios from
|
||||
`@/lib/http-client` when adding requests. Its defaults and interceptor also apply
|
||||
to `axios.create()` clients, so per-request headers cannot accidentally replace
|
||||
the saved user agent. ESLint rejects direct application imports of the Axios package.
|
||||
Use `brokerWebSocket` or `signalrWebSocket` from `lib/outbound-proxy.ts` for broker
|
||||
sockets, and `directWebSocket` for control sockets. All share the same user agent;
|
||||
the broker proxy still applies only to broker traffic.
|
||||
|
||||
## Vest firms (Vest Markets)
|
||||
|
||||
Vest (next.vestmarkets.com) is a perps prop firm: index, metal and energy perps
|
||||
on CME hours, plus stocks, FX and crypto. Set it up on the master's **Firms**
|
||||
page with platform **Vest**. It shares the DXtrade engine: its own symbols
|
||||
(new firms start on `NDX-USD-PERP`, `SPX-USD-PERP`, `GC-PERP`, standing in for
|
||||
NQ, ES and GC), notional sizing from volatility, percent fees, hard daily-loss
|
||||
stops, and a scheduler lane of its own. Leave **Trades every day** off for the
|
||||
index and metal perps: they close for weekends and 5–6 PM ET.
|
||||
|
||||
**Login is relayed, not stored.** Vest signs people in with Google (through
|
||||
Privy) and renews the session with a device key the browser keeps, so this
|
||||
instance cannot log in by itself. Instead:
|
||||
|
||||
1. In Chrome on this machine (with the AutoFirmer extension, 0.17.0+), open
|
||||
next.vestmarkets.com and sign in. The person signs in once; Vest keeps the
|
||||
session for 30 days.
|
||||
2. The extension relays the tab's short-lived access token to this dashboard
|
||||
(`POST /api/vest/session`, held in memory only) every time the Vest app
|
||||
renews it. If no Vest tab is open, the extension opens one in the background.
|
||||
3. The Vest firm's credential on this dashboard is the **wallet address** of
|
||||
that login, and it binds itself: the first relayed session fills it in when
|
||||
the firm has none. A saved wallet is never replaced. If the tab is signed in
|
||||
to a different login, the firm says so; remove its credential to switch.
|
||||
With two logins relayed at once, nothing binds by itself, and the credential
|
||||
form offers each relayed wallet with one click.
|
||||
|
||||
With no live session for the wallet, the client sits unauthenticated and says
|
||||
so; it never tries to log in.
|
||||
|
||||
What the client reads from Vest (lib/vest-class.ts, mapped from the web app's
|
||||
own traffic since Vest publishes no API):
|
||||
|
||||
- Accounts: every eval and funded account on the login, named for their rules,
|
||||
e.g. `V50K-E-D3-T10-34` (50K eval, 3% daily loss, 10% target, attempt 34),
|
||||
`V50K-F-D3-34` (funded) and `V25K-I-7` (instant). Account-type prefixes on the
|
||||
master match those names.
|
||||
- Floors: Vest states each account's max-drawdown floor (the auto-liq threshold
|
||||
here) and daily-loss floor. The engine's daily room is never more than Vest
|
||||
leaves.
|
||||
- Fees and leverage: each symbol's taker fee (0.25 bp on the index and metal
|
||||
perps) and margin rate, from Vest's exchange info. Set the firm's
|
||||
**Commission %** to the same (`0.0025`) for the pre-trade checks.
|
||||
- Exits: the take-profit and stop are legs of the position on Vest's side,
|
||||
and Vest cancels the other when one triggers.
|
||||
- Trades: every position on the accounts, the bot's and anyone else's, from
|
||||
Vest's position history (`GET /v3/positions`) — size, open and close price,
|
||||
P&L, fees, funding and why it closed (stop loss, take profit, liquidation,
|
||||
or an order on the account). Read hourly and a minute after a position
|
||||
closes, into the `trades` table. Vest can't tell an API order from a click,
|
||||
so the bot marks its own opens and closes as it sends them; anything
|
||||
unmarked since marking began was someone else's. The **Trades** button on
|
||||
the Vest firm's dashboard row lists them, and the account page shows its own.
|
||||
- Claims: the bot claims a funded account's profit by itself at each stage's
|
||||
target — whether or not the scheduler is running (a claim opens nothing).
|
||||
The dashboard's **Auto-claim payouts** box turns it off (`auto_claim`). A
|
||||
claim holds every further claim on the account until Vest's transactions
|
||||
show its WITHDRAW; one whose request failed in transit holds them too, until
|
||||
the transactions show it went in, or an hour of them shows it didn't (one
|
||||
that never went out, for want of an account token, holds nothing). Each
|
||||
claim counts as its own payout in the report, even two on one Vest day, and
|
||||
the cycle a day's second claim starts is sent with its own `cycleSeq` (the
|
||||
master needs to key on it to keep that cycle apart). The account page's **Claim
|
||||
now** button claims the whole profit above the account size at once — any
|
||||
amount from $1 — when the account is flat; it asks first, since it moves
|
||||
money to the login's main account.
|
||||
|
||||
`npm run check` includes the Vest helpers (scripts/check-vest.mjs).
|
||||
|
||||
---
|
||||
|
||||
|
||||
+168
-39
@@ -17,6 +17,8 @@ import {
|
||||
import type { FirmConfig, FirmState, AccountState, AccountConfig } from '@/types';
|
||||
import { stagePillClasses } from '@/lib/stage-colors';
|
||||
import { projectPerfectPath } from '@/lib/trading-logic';
|
||||
import { RulesDate } from '@/app/components/RulesDate';
|
||||
import { TradesTable } from '@/app/components/TradesTable';
|
||||
|
||||
/** Reassign this account to another of its firm's account types, or hand it
|
||||
* back to the auto-resolver. Mirrors the control on the dashboard. */
|
||||
@@ -81,6 +83,106 @@ function fmt(value: number) {
|
||||
return value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
/** Claim now: the account's whole profit above its size, to the firm's main
|
||||
* account (Vest's Claim Profit) — for payout accounts on a firm that takes
|
||||
* claims by API. Confirmed inline, since it moves money. */
|
||||
function ClaimNowButton({ accountId, onDone }: { accountId: number; onDone: () => void }) {
|
||||
type Claimable = { claimable: number; target: number | null; firm: string } | { claimable: null; reason: string; unsupported: boolean };
|
||||
const [info, setInfo] = useState<Claimable | null>(null);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null);
|
||||
|
||||
const check = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/accounts/${accountId}/claim`, { cache: 'no-store' });
|
||||
if (res.ok) setInfo(await res.json());
|
||||
} catch {
|
||||
/* keep the last answer */
|
||||
}
|
||||
}, [accountId]);
|
||||
|
||||
useEffect(() => {
|
||||
void check();
|
||||
const t = setInterval(() => { void check(); }, 10_000);
|
||||
return () => clearInterval(t);
|
||||
}, [check]);
|
||||
|
||||
async function claim(amount: number) {
|
||||
setBusy(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const res = await fetch(`/api/accounts/${accountId}/claim`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ amount }),
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`);
|
||||
setMessage({
|
||||
ok: true,
|
||||
text: `Claimed $${fmt(body.claimed)}${body.received != null ? ` — $${fmt(body.received)} to your main account` : ''} · stage ${body.stage} next`,
|
||||
});
|
||||
onDone();
|
||||
} catch (err) {
|
||||
setMessage({ ok: false, text: err instanceof Error ? err.message : 'Claim failed' });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
setConfirming(false);
|
||||
void check();
|
||||
}
|
||||
}
|
||||
|
||||
if (!info || (info.claimable === null && info.unsupported)) return null;
|
||||
|
||||
if (confirming && info.claimable !== null) {
|
||||
const amount = info.claimable;
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-slate-600">
|
||||
Claim ${fmt(amount)} to your {info.firm} main account? The firm pays your split of it.
|
||||
</span>
|
||||
<button
|
||||
onClick={() => { void claim(amount); }}
|
||||
disabled={busy}
|
||||
className="px-2.5 py-1 bg-emerald-600 hover:bg-emerald-700 disabled:opacity-50 text-white text-xs font-semibold rounded-md transition-colors"
|
||||
>
|
||||
{busy ? 'Claiming…' : `Claim $${fmt(amount)}`}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirming(false)}
|
||||
disabled={busy}
|
||||
className="px-2.5 py-1 bg-slate-100 hover:bg-slate-200 disabled:opacity-50 text-slate-600 text-xs font-semibold rounded-md transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{message && <span className={`text-xs ${message.ok ? 'text-emerald-600' : 'text-red-500'}`}>{message.text}</span>}
|
||||
{info.claimable !== null ? (
|
||||
<button
|
||||
onClick={() => { setMessage(null); setConfirming(true); }}
|
||||
className="px-2.5 py-1 bg-emerald-50 hover:bg-emerald-100 text-emerald-700 text-xs font-semibold rounded-md transition-colors"
|
||||
title={`Claim the whole profit above the account size now${info.target !== null ? ` (the bot claims by itself at the stage's $${fmt(info.target)} target)` : ''}`}
|
||||
>
|
||||
Claim now · ${fmt(info.claimable)}
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
className="px-2.5 py-1 bg-slate-100 text-slate-400 text-xs font-semibold rounded-md cursor-not-allowed"
|
||||
title={info.reason}
|
||||
>
|
||||
Claim now — {info.reason}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Wipes the account's cached P&L / fund / meta rows and refetches from
|
||||
* Tradovate. Confirmed inline because rows older than the report's 28-day
|
||||
* window cannot come back. */
|
||||
@@ -180,6 +282,7 @@ function ObjRow({ passed, label, value }: { passed: boolean; label: ReactNode; v
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function EquityTooltip({ active, payload, label }: any) {
|
||||
if (!active || !payload?.length) return null;
|
||||
// Always read from the raw data point so fill-only series don't interfere
|
||||
@@ -385,9 +488,11 @@ export default function AccountPage() {
|
||||
}, [accountId]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
// The first load from a timer too, like the polls: not setState
|
||||
// from the effect's own body.
|
||||
const first = setTimeout(() => { void load(); }, 0);
|
||||
const interval = setInterval(() => { void load(); }, 5000);
|
||||
return () => clearInterval(interval);
|
||||
return () => { clearTimeout(first); clearInterval(interval); };
|
||||
}, [load]);
|
||||
|
||||
if (!account) {
|
||||
@@ -409,8 +514,14 @@ export default function AccountPage() {
|
||||
const fundTransactions = account.fundTransactions ?? [];
|
||||
const fundMap = new Map(fundTransactions.map((f) => [f.date, f.amount]));
|
||||
|
||||
const hasLossLimit = cfg != null && cfg.minDayPnL !== -999;
|
||||
const daysPassed = cfg != null && account.daysTraded >= cfg.minTradingDays;
|
||||
// The firm's hard daily loss limit, and the least a day must make to count
|
||||
// as a trading day — two different numbers; neither is set on every type.
|
||||
// The limit and the min days are the current stage's, which may set its own.
|
||||
const stageLossLimit = account.effectiveDailyLossLimit ?? cfg?.dailyLossLimit ?? 0;
|
||||
const dailyLossLimit = stageLossLimit > 0 ? stageLossLimit : null;
|
||||
const minDayPnL = (cfg?.minDayPnL ?? 0) > 0 ? cfg!.minDayPnL : null;
|
||||
const minTradingDays = account.effectiveMinTradingDays ?? cfg?.minTradingDays ?? 0;
|
||||
const daysPassed = cfg != null && account.daysTraded >= minTradingDays;
|
||||
// Dead when balance hits the liquidation floor — Tradovate reports its
|
||||
// (positive) floor; TopstepX floors are reconstructed MLLs, negative or
|
||||
// +$0.01 once locked at breakeven. 0 = no floor known.
|
||||
@@ -427,7 +538,6 @@ export default function AccountPage() {
|
||||
const profitPassed = cfg != null && totalProfit >= (account.effectiveProfitTarget ?? cfg.profitTarget);
|
||||
// dailyTarget is computed server-side in the state API — single source of truth.
|
||||
const dailyTarget = account.dailyTarget ?? null;
|
||||
const lossPassed = !hasLossLimit || (cfg != null && totalProfit >= cfg.minDayPnL);
|
||||
|
||||
// Consistency target: the total profit level at which the best day no longer
|
||||
// violates the consistency ratio. Only meaningful once a positive day exists.
|
||||
@@ -448,13 +558,19 @@ export default function AccountPage() {
|
||||
}
|
||||
const sortedEvents = [...allDates].sort().map((d) => eventsByDate.get(d)!);
|
||||
|
||||
// Running equity after each event, summed here rather than inside the
|
||||
// map below: a render callback mustn't reassign an outer variable.
|
||||
const runningAfter: number[] = [];
|
||||
let running = 0;
|
||||
for (const ev of sortedEvents) {
|
||||
if (ev.fundAmt !== undefined) running += ev.fundAmt; // withdrawals reduce equity
|
||||
if (ev.pnl !== undefined) running += ev.pnl;
|
||||
runningAfter.push(running);
|
||||
}
|
||||
const equityData = [
|
||||
{ label: '', equity: 0, pnl: 0, origin: true, pos: 0, neg: 0, isWithdrawal: false },
|
||||
...sortedEvents.map((ev) => {
|
||||
if (ev.fundAmt !== undefined) running += ev.fundAmt; // withdrawals reduce equity
|
||||
if (ev.pnl !== undefined) running += ev.pnl;
|
||||
const equity = Math.round(running * 100) / 100;
|
||||
...sortedEvents.map((ev, i) => {
|
||||
const equity = Math.round(runningAfter[i] * 100) / 100;
|
||||
return {
|
||||
label: fmtDate(ev.date),
|
||||
equity,
|
||||
@@ -493,7 +609,7 @@ export default function AccountPage() {
|
||||
// books exactly its plan, cycles complete, and the configured withdrawal
|
||||
// fires the day a cycle finishes. Same planner the trade path runs, so the
|
||||
// numbers (bumps and drain amounts included) are what would actually be
|
||||
// aimed for. Shown up to TWO payouts ahead.
|
||||
// aimed for. Shown up to TWO payout days ahead.
|
||||
const rawProjection = (cfg && !isDead) ? projectPerfectPath({
|
||||
accountId: account.id,
|
||||
cfg,
|
||||
@@ -501,7 +617,15 @@ export default function AccountPage() {
|
||||
fundTransactions,
|
||||
balance: account.amount,
|
||||
realizedPnL: account.realizedPnL,
|
||||
maxWithdrawals: 2,
|
||||
// The day the firm's numbers follow (Vest's, until 20:00 ET).
|
||||
rawTradeDate: account.tradeDate,
|
||||
// The state stage counts the payouts that pick it: today's for a type
|
||||
// whose stages reset each day, else every one, each on its own.
|
||||
stageInputs: cfg.stagesSameDay === true
|
||||
? { stagesSameDay: true, sameDayWithdrawals: account.stage - 1 }
|
||||
: { payouts: account.stage - 1 },
|
||||
// Such a type pays out once per stage on each of those days.
|
||||
maxWithdrawals: cfg.stagesSameDay === true ? 2 * (1 + cfg.withdrawalStages.length) : 2,
|
||||
payout: {
|
||||
withdrawalPct: cfg.withdrawalPct,
|
||||
payoutCap: cfg.payoutCap,
|
||||
@@ -517,23 +641,21 @@ export default function AccountPage() {
|
||||
: undefined,
|
||||
},
|
||||
}) : [];
|
||||
// Cut the projection at the second payout — days beyond it are noise here.
|
||||
const secondPayoutIdx = (() => {
|
||||
let seen = 0;
|
||||
for (let i = 0; i < rawProjection.length; i++) {
|
||||
const p = rawProjection[i];
|
||||
if (p.withdrawal !== undefined || p.payoutReady) {
|
||||
seen++;
|
||||
if (seen >= 2 || p.payoutReady) return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
})();
|
||||
const projection = secondPayoutIdx >= 0 ? rawProjection.slice(0, secondPayoutIdx + 1) : rawProjection;
|
||||
// Cut the projection after the second payout day — days beyond it are
|
||||
// noise here. The path's dates never go back, and a payoutReady entry is
|
||||
// always its last.
|
||||
const payoutDays = [...new Set(rawProjection.filter((p) => p.withdrawal !== undefined || p.payoutReady).map((p) => p.date))];
|
||||
const projection = payoutDays.length >= 2 ? rawProjection.filter((p) => p.date <= payoutDays[1]) : rawProjection;
|
||||
// A payout day is the cycle's FINAL trading day — it can carry both the
|
||||
// day's booked P&L and the withdrawal.
|
||||
const projPnlMap = new Map(projection.filter((p) => p.pnl !== 0).map((p) => [p.date, p.pnl]));
|
||||
const projFundMap = new Map(projection.filter((p) => p.withdrawal !== undefined).map((p) => [p.date, p.withdrawal!]));
|
||||
// day's booked P&L and the withdrawal. A same-day type's day can hold
|
||||
// several cycles: their P&L and withdrawals add up, and the day shows
|
||||
// the last stage it traded.
|
||||
const projPnlMap = new Map<string, number>();
|
||||
const projFundMap = new Map<string, number>();
|
||||
for (const p of projection) {
|
||||
if (p.pnl !== 0) projPnlMap.set(p.date, Math.round(((projPnlMap.get(p.date) ?? 0) + p.pnl) * 100) / 100);
|
||||
if (p.withdrawal !== undefined) projFundMap.set(p.date, Math.round(((projFundMap.get(p.date) ?? 0) + p.withdrawal) * 100) / 100);
|
||||
}
|
||||
const projStageMap = new Map(projection.map((p) => [p.date, p.stage]));
|
||||
/** Cumulative probability of the perfect path surviving through each projected day. */
|
||||
const projProbMap = new Map(projection.map((p) => [p.date, p.prob]));
|
||||
@@ -548,14 +670,20 @@ export default function AccountPage() {
|
||||
];
|
||||
const calendarMonths = [...new Set(allCalendarDates.map((d) => d.slice(0, 7)))].sort();
|
||||
|
||||
// Build stage map: stage increments after each withdrawal (negative fund txn)
|
||||
// Build stage map: stage increments after each withdrawal (negative fund txn).
|
||||
// A type whose stages reset each day starts every day on stage 1 and
|
||||
// trades its next stage after a payout that day, as the projected days
|
||||
// show — one fund row a day holds all of that day's payouts, so a day
|
||||
// with one reads stage 2.
|
||||
const sortedWithdrawalDates = fundTransactions
|
||||
.filter((f) => f.amount < 0)
|
||||
.map((f) => f.date)
|
||||
.sort();
|
||||
const stageMap = new Map<string, number>();
|
||||
for (const d of displayPnL) {
|
||||
const stage = sortedWithdrawalDates.filter((wd) => wd < d.date).length + 1;
|
||||
const stage = cfg?.stagesSameDay === true
|
||||
? sortedWithdrawalDates.filter((wd) => wd === d.date).length + 1
|
||||
: sortedWithdrawalDates.filter((wd) => wd < d.date).length + 1;
|
||||
stageMap.set(d.date, stage);
|
||||
}
|
||||
|
||||
@@ -576,6 +704,7 @@ export default function AccountPage() {
|
||||
onAssigned={() => { void load(); }}
|
||||
/>
|
||||
)}
|
||||
<RulesDate account={account} firm={firm} onSaved={() => { void load(); }} />
|
||||
<span className="text-slate-300">/</span>
|
||||
<h1 className="text-slate-900 font-bold text-sm">{account.name}</h1>
|
||||
{isDead && (
|
||||
@@ -583,7 +712,10 @@ export default function AccountPage() {
|
||||
☠ DEAD
|
||||
</span>
|
||||
)}
|
||||
<WipeDataButton accountId={account.id} onDone={() => { void load(); }} />
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<ClaimNowButton accountId={account.id} onDone={() => { void load(); }} />
|
||||
<WipeDataButton accountId={account.id} onDone={() => { void load(); }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isDead && (
|
||||
@@ -610,7 +742,8 @@ export default function AccountPage() {
|
||||
positive={profitPct != null && profitPct >= 0}
|
||||
/>
|
||||
<PerfRow label="Trading Days" value={account.daysTraded} />
|
||||
<PerfRow label="Daily Loss Limit" value={hasLossLimit ? `$${fmt(cfg!.minDayPnL)}` : 'None'} />
|
||||
<PerfRow label="Daily Loss Limit" value={dailyLossLimit !== null ? `$${fmt(dailyLossLimit)}` : 'None'} />
|
||||
<PerfRow label="Min Day P&L" value={minDayPnL !== null ? `$${fmt(minDayPnL)}` : 'None'} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col w-1/2 bg-white border border-slate-200 rounded-xl shadow-sm p-6">
|
||||
@@ -630,15 +763,8 @@ export default function AccountPage() {
|
||||
<ObjRow
|
||||
passed={daysPassed}
|
||||
label="Trading Days"
|
||||
value={cfg ? `${account.daysTraded} of ${cfg.minTradingDays}` : '—'}
|
||||
value={cfg ? `${account.daysTraded} of ${minTradingDays}` : '—'}
|
||||
/>
|
||||
{hasLossLimit && (
|
||||
<ObjRow
|
||||
passed={lossPassed}
|
||||
label="Daily Loss Limit"
|
||||
value={`$${fmt(cfg!.minDayPnL)}`}
|
||||
/>
|
||||
)}
|
||||
{dailyTarget != null && (
|
||||
<div className="flex items-center justify-between py-3 border-b border-slate-100 last:border-0">
|
||||
<span className="text-slate-500 text-sm">Next Trading Day Amount</span>
|
||||
@@ -790,6 +916,7 @@ export default function AccountPage() {
|
||||
stroke={`url(#${strokeGradId})`}
|
||||
strokeWidth={2.5}
|
||||
fill="none"
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
dot={(props: any) => {
|
||||
if (props.payload?.origin) return <g key={props.key} />;
|
||||
return (
|
||||
@@ -840,6 +967,8 @@ export default function AccountPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TradesTable query={`accountId=${accountId}`} title="Trades" />
|
||||
|
||||
{/* Cash History Table */}
|
||||
{(displayPnL.length > 0 || fundTransactions.length > 0) && (
|
||||
<div className="bg-white border border-slate-200 rounded-xl shadow-sm p-6 mt-5">
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { claimableNow, claimAccountNow, ClaimRefusedError } from '@/lib/auto-trade';
|
||||
import { errorMessage } from '@/lib/platform-client';
|
||||
|
||||
/**
|
||||
* Claim now (the account page's button). GET: how much the account could
|
||||
* claim right now — its whole profit above the account size — or why not.
|
||||
* POST { amount }: claim it, to the login's main account (Vest's Claim
|
||||
* Profit). Only a person pressing the button calls this; the bot claims on
|
||||
* its own at each stage's target (runClaims).
|
||||
*/
|
||||
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
return NextResponse.json(claimableNow(Number(id)));
|
||||
}
|
||||
|
||||
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
// JSON only: a cross-site form or no-cors fetch can't send it without a
|
||||
// preflight this route never answers.
|
||||
if (!(req.headers.get('content-type') ?? '').includes('application/json')) {
|
||||
return NextResponse.json({ error: 'JSON body required' }, { status: 415 });
|
||||
}
|
||||
const { id } = await params;
|
||||
const body = await req.json().catch(() => ({})) as { amount?: unknown };
|
||||
const amount = Number(body.amount);
|
||||
if (!Number.isFinite(amount)) return NextResponse.json({ error: 'amount required' }, { status: 400 });
|
||||
try {
|
||||
return NextResponse.json({ ok: true, ...await claimAccountNow(Number(id), amount) });
|
||||
} catch (err) {
|
||||
if (err instanceof ClaimRefusedError) return NextResponse.json({ error: err.message }, { status: 400 });
|
||||
console.error(`[claim-now] account ${id}: ${errorMessage(err)}`);
|
||||
return NextResponse.json({ error: `the claim failed: ${errorMessage(err)}` }, { status: 502 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { setRulesDate } from '@/lib/db';
|
||||
import { sortableIso } from '@/lib/kept-rules';
|
||||
|
||||
type Params = { params: Promise<{ id: string }> };
|
||||
|
||||
/** A rules date may run this far ahead of the server's clock — room for a
|
||||
* browser whose clock or time zone is ahead, not for dating accounts later. */
|
||||
const MAX_FUTURE_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
function parseAccountId(raw: string): number | null {
|
||||
const id = parseInt(raw, 10);
|
||||
return Number.isNaN(id) ? null : id;
|
||||
}
|
||||
|
||||
/** Set an account's rules date by hand. The date decides which of its type's
|
||||
* rule sets it trades on — one dated before a firm's rule change keeps the
|
||||
* old rules — and a date set here is never moved by broker evidence again.
|
||||
* The trade path reads it per tick, so nothing needs re-resolving. */
|
||||
export async function PUT(req: NextRequest, { params }: Params) {
|
||||
const { id: idStr } = await params;
|
||||
const accountId = parseAccountId(idStr);
|
||||
if (accountId === null) return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
|
||||
|
||||
const body = await req.json().catch(() => null) as { rulesDate?: unknown } | null;
|
||||
const raw = body?.rulesDate;
|
||||
const ms = typeof raw === 'string' ? Date.parse(raw) : NaN;
|
||||
if (!Number.isFinite(ms)) {
|
||||
return NextResponse.json({ error: 'rulesDate must be an ISO date' }, { status: 400 });
|
||||
}
|
||||
if (ms > Date.now() + MAX_FUTURE_MS) {
|
||||
return NextResponse.json({ error: 'A rules date cannot be more than a day ahead' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Stored as a plain four-digit-year toISOString(), the one format every
|
||||
// rules-date writer uses — lowerAutoRulesDate compares them as strings,
|
||||
// so nothing before 2000 (or that doesn't print as one) gets in.
|
||||
const rulesDate = sortableIso(ms);
|
||||
if (!rulesDate) {
|
||||
return NextResponse.json({ error: 'A rules date cannot be before 2000' }, { status: 400 });
|
||||
}
|
||||
if (!setRulesDate(accountId, rulesDate, true)) {
|
||||
return NextResponse.json({ error: 'Unknown account' }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json({ ok: true, rulesDate });
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { getSetting } from '@/lib/db';
|
||||
import { corsJson, corsPreflight } from '../cors';
|
||||
|
||||
/** The extension's 2FA lookup. It knows the tab's host; this dashboard knows
|
||||
* which person it is (the master-issued `instance_id`) and where the master
|
||||
* is, so it asks the master for this person's codes on that site and passes
|
||||
* them through.
|
||||
*
|
||||
* Never an error status: no link to the master, or a master that can't be
|
||||
* reached, is just "no codes" with a reason — the extension shows nothing. */
|
||||
export async function GET(request: Request) {
|
||||
const host = new URL(request.url).searchParams.get('host')?.trim().toLowerCase();
|
||||
if (!host) return corsJson({ codes: [], reason: 'host is required' });
|
||||
|
||||
const master = getSetting('master_dashboard_url')?.trim().replace(/\/+$/, '');
|
||||
const person = getSetting('instance_id')?.trim();
|
||||
if (!master || !person) return corsJson({ codes: [], reason: 'not linked to master' });
|
||||
|
||||
const url = `${master}/api/otp?person=${encodeURIComponent(person)}&host=${encodeURIComponent(host)}`;
|
||||
try {
|
||||
const res = await fetch(url, { cache: 'no-store', signal: AbortSignal.timeout(5000) });
|
||||
if (!res.ok) return corsJson({ codes: [], reason: `master answered ${res.status}` });
|
||||
const data = (await res.json()) as { codes?: unknown[]; serverTime?: number; scope?: string };
|
||||
// A master from before per-person lookups ignores ?person= and answers
|
||||
// with every stored code — never show those.
|
||||
if (data.scope !== 'person') return corsJson({ codes: [], reason: 'master is too old for 2FA lookups' });
|
||||
return corsJson({ codes: data.codes ?? [], serverTime: data.serverTime ?? Date.now() });
|
||||
} catch (err) {
|
||||
return corsJson({ codes: [], reason: `master unreachable: ${(err as Error).message}` });
|
||||
}
|
||||
}
|
||||
|
||||
export async function OPTIONS() {
|
||||
return corsPreflight();
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { getClients, resetClients } from '@/lib/clients';
|
||||
import { getTradableFirms } from '@/lib/firm-catalog';
|
||||
import { getAccountConfig } from '@/lib/account-type-resolver';
|
||||
import { combineCmeSessions, computeDayPlan, resolveEffectiveConfig, currentTradeDateCT } from '@/lib/trading-logic';
|
||||
import axios from 'axios';
|
||||
import axios from '@/lib/http-client';
|
||||
|
||||
/**
|
||||
* Per-account snapshot of everything computeDayPlan sees, plus the book-balance
|
||||
|
||||
@@ -16,7 +16,9 @@ type Params = { params: Promise<{ id: string }> };
|
||||
* 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. */
|
||||
* password is unused. Vest firms need the login's wallet address in the API
|
||||
* key slot: Vest's session is relayed by the extension from a signed-in Vest
|
||||
* tab (lib/vest-session.ts), and the address says whose to trade. */
|
||||
export async function PUT(req: NextRequest, { params }: Params) {
|
||||
const { id } = await params;
|
||||
|
||||
@@ -33,7 +35,9 @@ export async function PUT(req: NextRequest, { params }: Params) {
|
||||
? 'apiKey is required for a TopstepX firm'
|
||||
: firm.platform === 'dxtrade'
|
||||
? 'username and password are required for a DXtrade firm'
|
||||
: 'username and password are required';
|
||||
: firm.platform === 'vest'
|
||||
? 'a Vest firm needs the wallet address of the Vest login (0x followed by 40 hex characters)'
|
||||
: 'username and password are required';
|
||||
return NextResponse.json({ error }, { status: 400 });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getClients } from '@/lib/clients';
|
||||
import axios from 'axios';
|
||||
import axios from '@/lib/http-client';
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getCachedFirm } from '@/lib/firm-catalog';
|
||||
import { getCachedFirm, getKeptRulesFor } from '@/lib/firm-catalog';
|
||||
import { firmInstruments, instrumentsSource } from '@/lib/instruments';
|
||||
import { getCredentials } from '@/lib/db';
|
||||
|
||||
@@ -25,7 +25,11 @@ export async function GET(
|
||||
hasCredentials: !!creds,
|
||||
instruments: firmInstruments(firm),
|
||||
instrumentsSource: instrumentsSource(firm),
|
||||
accounts: firm.accountTypes,
|
||||
// Same shape as GET /api/firms: each type with its old rule sets' windows.
|
||||
accounts: firm.accountTypes.map((a) => ({
|
||||
...a,
|
||||
kept: getKeptRulesFor(a.id).map((k) => ({ id: k.id, openedAt: k.openedAt, closedAt: k.closedAt })),
|
||||
})),
|
||||
catalogUpdatedAt: firm.updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
+11
-2
@@ -1,8 +1,13 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { firmInstruments, instrumentsSource } from '@/lib/instruments';
|
||||
import { getCachedCatalog, hasPlatformCredentials } from '@/lib/firm-catalog';
|
||||
import { getCachedCatalog, getKeptRulesFor, hasPlatformCredentials } from '@/lib/firm-catalog';
|
||||
import { getCredentials } from '@/lib/db';
|
||||
import type { FirmConfig } from '@/types';
|
||||
import type { FirmConfig, KeptRulesWindow } from '@/types';
|
||||
|
||||
/** The windows of a live account type's old rule sets, by closedAt. */
|
||||
function keptWindows(accountTypeId: string): KeptRulesWindow[] {
|
||||
return getKeptRulesFor(accountTypeId).map((k) => ({ id: k.id, openedAt: k.openedAt, closedAt: k.closedAt }));
|
||||
}
|
||||
|
||||
/**
|
||||
* The firms this machine knows about — the master's catalog, annotated with
|
||||
@@ -14,6 +19,9 @@ import type { FirmConfig } from '@/types';
|
||||
*
|
||||
* The stored password is deliberately not returned. It used to be, and nothing
|
||||
* ever read it.
|
||||
*
|
||||
* Each account type carries the windows of the old rule sets it keeps (`kept`)
|
||||
* so the dashboard's rules-date control can say which rules a date lands on.
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
@@ -48,6 +56,7 @@ export async function GET() {
|
||||
withdrawalStages: a.withdrawalStages,
|
||||
dailyLossLimit: a.dailyLossLimit ?? 0,
|
||||
tradesEveryDay: a.tradesEveryDay === true,
|
||||
kept: keptWindows(a.id),
|
||||
})),
|
||||
catalogUpdatedAt: firm.updatedAt,
|
||||
};
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getSetting, setSetting } from '@/lib/db';
|
||||
import { scheduleBackupPush } from '@/lib/backup-sync';
|
||||
import { getRequestUserAgent } from '@/lib/request-user-agent';
|
||||
|
||||
const VALID_KEYS = ['max_concurrent_accounts', 'tick_interval_seconds', 'master_dashboard_url', 'instance_name', 'trading_hours', 'twocaptcha_api_key', 'twocaptcha_proxy', 'master_backup_token', 'proxy_url'] as const;
|
||||
const VALID_KEYS = ['max_concurrent_accounts', 'tick_interval_seconds', 'master_dashboard_url', 'instance_name', 'trading_hours', 'twocaptcha_api_key', 'twocaptcha_proxy', 'master_backup_token', 'proxy_url', 'auto_claim', 'auto_activate'] as const;
|
||||
type SettingKey = typeof VALID_KEYS[number];
|
||||
|
||||
export async function GET() {
|
||||
@@ -13,6 +14,8 @@ export async function GET() {
|
||||
// Read-only on purpose (not in VALID_KEYS): the master issues this uuid,
|
||||
// and editing it by hand would detach the instance from its person.
|
||||
result.instance_id = getSetting('instance_id');
|
||||
// Generated once per instance. Callers cannot rotate an active identity.
|
||||
result.user_agent = getRequestUserAgent();
|
||||
result.backup_last_push_at = getSetting('backup_last_push_at');
|
||||
result.backup_last_error = getSetting('backup_last_error');
|
||||
return NextResponse.json(result);
|
||||
@@ -22,6 +25,15 @@ export async function PATCH(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json() as Partial<Record<SettingKey, string | number>>;
|
||||
|
||||
// Claims and activations run unless it reads exactly 'off' — say so
|
||||
// exactly, and before anything is written.
|
||||
for (const key of ['auto_claim', 'auto_activate'] as const) {
|
||||
const v = body[key];
|
||||
if (v !== undefined && v !== null && v !== 'on' && v !== 'off') {
|
||||
return NextResponse.json({ error: `${key} is 'on' or 'off'` }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of VALID_KEYS) {
|
||||
if (key in body) {
|
||||
const raw = body[key];
|
||||
|
||||
+69
-33
@@ -1,15 +1,17 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { loadDailyPnL } from '@/lib/db';
|
||||
import { listAssignments, loadDailyPnL } from '@/lib/db';
|
||||
import { getClients } from '@/lib/clients';
|
||||
import { getCachedCatalog } from '@/lib/firm-catalog';
|
||||
import { getAccountTypeById, getCachedCatalog } from '@/lib/firm-catalog';
|
||||
import { ruleDiffs } from '@/lib/kept-rules';
|
||||
import { getAccountConfig, getInferredSize, matchAccountType } from '@/lib/account-type-resolver';
|
||||
import { historyFresh } from '@/lib/platform-client';
|
||||
import { rankNextLeg } from '@/lib/leg-estimate';
|
||||
import { rankNextLeg, dayLossRoom } from '@/lib/leg-estimate';
|
||||
import { obfuscationDayBooked } from '@/lib/auto-trade';
|
||||
import type { AccountState } from '@/types';
|
||||
import {
|
||||
combineCmeSessions, computeDayPlan, cycleCompletion, resolveEffectiveConfig, currentTradeDateCT,
|
||||
obfuscationStopDollars, dllRoom as computeDllRoom, dllStopDollars, botStopsActive,
|
||||
maxLossRoom, lastStand as isLastStand,
|
||||
combineCmeSessions, computeDayPlan, cycleCompletion, resolveEffectiveConfig, tradeDateFor,
|
||||
obfuscationStopDollars, dllStopDollars, botStopsActive, stageInputsFor, stagePayouts,
|
||||
maxLossRoom, lastStand as isLastStand, shortestNextLeg,
|
||||
LEG_COMPLETION_EPSILON,
|
||||
} from '@/lib/trading-logic';
|
||||
|
||||
@@ -17,7 +19,6 @@ export async function GET() {
|
||||
try {
|
||||
const firms = getCachedCatalog();
|
||||
const clients = getClients();
|
||||
const tradeDate = currentTradeDateCT();
|
||||
|
||||
const state = firms.map((f) => {
|
||||
const client = clients.get(f.id);
|
||||
@@ -28,6 +29,14 @@ export async function GET() {
|
||||
if (!client || client.accountList.length === 0) {
|
||||
return { firmId: f.id, firm: f.name, connected, accounts: [] };
|
||||
}
|
||||
// The day the firm's accounts trade on (tradeDateFor), as the
|
||||
// engine plans them — Vest's turns at 20:00 ET, not 5 PM CT.
|
||||
const tradeDate = tradeDateFor(client);
|
||||
// Accounts whose rules date was set by hand — one read per firm
|
||||
// rather than one per account.
|
||||
const rulesDateByHand = new Set(listAssignments(f.id)
|
||||
.filter((row) => row.rules_date_manual === 1)
|
||||
.map((row) => row.account_id));
|
||||
const accounts = client.accountList.map((acc) => {
|
||||
const cash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 };
|
||||
|
||||
@@ -54,8 +63,11 @@ export async function GET() {
|
||||
const isDead = autoLiqThreshold !== 0 && cash.amount <= autoLiqThreshold;
|
||||
const allFundTxns = client.fundTransactions?.[acc.id] ?? [];
|
||||
const priorProfit = client.priorProfit?.[acc.id] ?? 0;
|
||||
// Stage = 1 + number of withdrawals (negative fund transactions)
|
||||
const stage = 1 + allFundTxns.filter((f) => f.amount < 0).length;
|
||||
// Stage = 1 + number of payouts (negative fund transactions, or
|
||||
// the client's own list where a row holds several), or of
|
||||
// today's for a type whose stages reset each day.
|
||||
const stageInputs = stageInputsFor(client, acc.id, cfg ?? {});
|
||||
const stage = 1 + stagePayouts(allFundTxns, stageInputs);
|
||||
// Stage-resolved config: a drain stage swaps in its own min-day
|
||||
// (default $5) and no profit target.
|
||||
const effective = cfg ? resolveEffectiveConfig(
|
||||
@@ -66,7 +78,8 @@ export async function GET() {
|
||||
cfg.withdrawalStages,
|
||||
priorProfit,
|
||||
allFundTxns,
|
||||
cfg.minDayPnL
|
||||
cfg.minDayPnL,
|
||||
stageInputs,
|
||||
) : null;
|
||||
// Days traded counts only days that hit minDayPnL (when set) — that's what
|
||||
// the firm requires toward the min trading day rule.
|
||||
@@ -83,6 +96,20 @@ export async function GET() {
|
||||
const ambiguous = cfg
|
||||
? matchAccountType(acc.name, inferredAccountSize, f.accountTypes).ambiguous
|
||||
: false;
|
||||
// An account on one of its type's old rule sets: cfg already
|
||||
// carries those rules; say which set, and how it differs from
|
||||
// the type's current rules.
|
||||
const live = resolved.keptRules && resolved.accountTypeId
|
||||
? getAccountTypeById(resolved.accountTypeId)
|
||||
: undefined;
|
||||
const keptRules: AccountState['keptRules'] = resolved.keptRules && cfg && live
|
||||
? {
|
||||
id: resolved.keptRules.id,
|
||||
openedAt: resolved.keptRules.openedAt,
|
||||
closedAt: resolved.keptRules.closedAt,
|
||||
differs: ruleDiffs(cfg, live),
|
||||
}
|
||||
: null;
|
||||
let targetHit = false;
|
||||
let dailyTarget: { amount: number; path: 'first_day' | 'normal_day' | 'reduced_day' } | null = null;
|
||||
let remainingToday: number | null = null;
|
||||
@@ -107,39 +134,29 @@ export async function GET() {
|
||||
// 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),
|
||||
});
|
||||
}
|
||||
const dll = effective?.dailyLossLimit ?? 0;
|
||||
dllRoom = dayLossRoom(client, acc.id, dll, cash, combined.realizedPnL, plan ? shortestNextLeg(plan) : 0);
|
||||
// The max-drawdown floor, and whether the next leg is a last
|
||||
// stand — no daily-loss stop — as fireLeg decides it.
|
||||
const floorRoom = maxLossRoom({
|
||||
balance: cash.amount, accountSize: cfg.accountSize, maxLoss: cfg.maxLoss ?? 0,
|
||||
autoLiqThreshold: client.autoLiqThresholds?.[acc.id],
|
||||
});
|
||||
lastStandNext = dllRoom !== null && isLastStand({ dllRoom, dailyLossLimit: dll, maxLossRoom: floorRoom });
|
||||
if (plan && (botStopsActive(cfg))) {
|
||||
const obf = obfuscationStopDollars(cfg, dailyPnL, plan, combined.tradeDate, plan.remainingToday);
|
||||
const hard = dllRoom !== null && !lastStandNext
|
||||
? 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;
|
||||
}
|
||||
// A day an obfuscation stop booked has no next leg — the
|
||||
// make-back rides tomorrow, stopless — so no next stop, last
|
||||
// stand or hold to show. Its remaining target has grown by
|
||||
// the loss: a $150 min day stopped at −$306 would read
|
||||
// "stop $912" for a leg that never fires.
|
||||
const dayBooked = obfuscationDayBooked(cfg, acc.id, tradeDate, combined.realizedPnL) !== null;
|
||||
lastStandNext = !dayBooked && dllRoom !== null && isLastStand({ dllRoom, dailyLossLimit: dll, maxLossRoom: floorRoom });
|
||||
|
||||
// DXtrade: the next leg sized on every surveyed instrument as
|
||||
// fireLeg would, and the one a batch would pick.
|
||||
if (plan && plan.remainingToday > 0) {
|
||||
if (plan && !dayBooked && plan.remainingToday > 0) {
|
||||
const best = rankNextLeg({
|
||||
client, accountId: acc.id, cfg, firm: f, leg: plan.remainingToday, dllRoom,
|
||||
client, accountId: acc.id, cfg: { ...cfg, dailyLossLimit: dll }, firm: f, leg: plan.remainingToday, dllRoom,
|
||||
obfuscationDollars: obfuscationStopDollars(cfg, dailyPnL, plan, combined.tradeDate, plan.remainingToday),
|
||||
cash,
|
||||
cash, tradeDate: combined.tradeDate,
|
||||
})[0];
|
||||
if (best) {
|
||||
holdEstimate = {
|
||||
@@ -156,6 +173,16 @@ export async function GET() {
|
||||
};
|
||||
}
|
||||
}
|
||||
if (plan && !dayBooked && botStopsActive(cfg, effective)) {
|
||||
const obf = obfuscationStopDollars(cfg, dailyPnL, plan, combined.tradeDate, plan.remainingToday);
|
||||
// The reserve grows with the position (dllReserve): at
|
||||
// the size the next leg would trade, where it's known.
|
||||
const hard = dllRoom !== null && !lastStandNext
|
||||
? dllStopDollars({ room: dllRoom, dailyLossLimit: dll, entryFee: 0, commissionPct: 0, notional: holdEstimate?.notional, slippageFraction: client.stopSlippageFraction })
|
||||
: Infinity;
|
||||
const stop = Math.min(Number.isFinite(obf) && obf > 0 ? obf : Infinity, hard);
|
||||
if (Number.isFinite(stop) && stop > 0) nextStop = Math.round(stop * 100) / 100;
|
||||
}
|
||||
|
||||
// Effective profit target = max(stage target, consistency realTarget).
|
||||
// If a big day forces the consistency rule, the account must reach maxDay/consistency
|
||||
@@ -226,6 +253,7 @@ export async function GET() {
|
||||
completed = cycleCompletion({
|
||||
accountId: acc.id,
|
||||
rawTradeDate: tradeDate,
|
||||
client,
|
||||
cycleDailyPnL: client.dailyPnL[acc.id],
|
||||
rawRealizedPnL: cash.realizedPnL,
|
||||
combineCmeHolidays: cfg.combineCmeHolidays,
|
||||
@@ -244,11 +272,14 @@ export async function GET() {
|
||||
// The combined day's P&L, not just the current session's —
|
||||
// on the Tuesday after a holiday this includes Sun/Mon.
|
||||
realizedPnL: combined.realizedPnL,
|
||||
// The firm's raw trade date, which the account page
|
||||
// projects the payouts from.
|
||||
tradeDate,
|
||||
daysTraded,
|
||||
// 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)
|
||||
personalDll: !!cfg && !!effective && botStopsActive(cfg, effective)
|
||||
&& acc.active && !client.positions[acc.id]
|
||||
&& combined.realizedPnL <= -25,
|
||||
positionDirection: client.positions[acc.id]
|
||||
@@ -268,6 +299,8 @@ export async function GET() {
|
||||
lastStand: lastStandNext,
|
||||
remainingToday,
|
||||
effectiveProfitTarget,
|
||||
effectiveDailyLossLimit: effective?.dailyLossLimit ?? null,
|
||||
effectiveMinTradingDays: effective?.minTradingDays ?? null,
|
||||
dailyPnL,
|
||||
fullDailyPnL: combineCmeSessions(
|
||||
cfg?.combineCmeHolidays === true,
|
||||
@@ -282,6 +315,9 @@ export async function GET() {
|
||||
accountType: cfg,
|
||||
inferredAccountSize,
|
||||
accountTypeAmbiguous: ambiguous,
|
||||
rulesDate: resolved.rulesDate,
|
||||
rulesDateManual: rulesDateByHand.has(acc.id),
|
||||
keptRules,
|
||||
};
|
||||
});
|
||||
return { firmId: f.id, firm: f.name, connected, accounts };
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getClients } from '@/lib/clients';
|
||||
import { loadTrades, getSetting, TRADES_SINCE_SETTING } from '@/lib/db';
|
||||
import type { TradeView } from '@/types';
|
||||
|
||||
/**
|
||||
* GET /api/trades?accountId=… | ?firmId=… [&limit=…] — recorded positions,
|
||||
* newest first: one account, a firm's current accounts, or all of them.
|
||||
*
|
||||
* Who opened and closed each one: the bot marks its own orders as it sends
|
||||
* them, so after marking began (TRADES_SINCE_SETTING) anything unmarked was
|
||||
* someone else's. A close Vest calls "user_requested" is an order on the
|
||||
* account — the bot's if it marked it, else outside. Trades built from
|
||||
* futures fills (lib/trade-recorder.ts, DXtrade's record) carry their marks
|
||||
* already: 'bot', 'outside', or 'unknown' from before the bot noted its own
|
||||
* orders.
|
||||
*/
|
||||
export async function GET(req: Request) {
|
||||
const url = new URL(req.url);
|
||||
const accountId = Number(url.searchParams.get('accountId'));
|
||||
const firmId = url.searchParams.get('firmId');
|
||||
const limit = Math.min(Math.max(Math.floor(Number(url.searchParams.get('limit'))) || 200, 1), 1000);
|
||||
|
||||
let accountIds: number[] | undefined;
|
||||
if (Number.isFinite(accountId) && accountId > 0) accountIds = [accountId];
|
||||
else if (firmId) accountIds = getClients().get(firmId)?.accountList.map((a) => a.id) ?? [];
|
||||
|
||||
const since = Number(getSetting(TRADES_SINCE_SETTING) ?? NaN);
|
||||
const tracked = (at: number) => Number.isFinite(since) && at >= since;
|
||||
|
||||
const trades: TradeView[] = loadTrades({ accountIds, limit }).map((t) => {
|
||||
const open = t.closedAt === null && t.closeReason === null && t.closedBy === null;
|
||||
const closedBy = open ? null
|
||||
: t.closedBy === 'bot' ? 'bot'
|
||||
: t.closeReason === 'user_requested' ? (t.closedBy === 'outside' || tracked(t.closedAt ?? t.openedAt) ? 'outside' : 'user_requested')
|
||||
: t.closeReason ?? (t.closedBy === 'outside' ? 'outside' : t.closedBy === 'unknown' ? 'user_requested' : 'unknown');
|
||||
const net = open || t.pnl === null ? null
|
||||
: Math.round((t.pnl - Math.abs(t.fee ?? 0) + (t.funding ?? 0)) * 100) / 100;
|
||||
return {
|
||||
positionId: t.positionId,
|
||||
accountId: t.accountId,
|
||||
accountName: t.accountName,
|
||||
symbol: t.symbol,
|
||||
side: t.side,
|
||||
quantity: t.quantity,
|
||||
openPrice: t.openPrice,
|
||||
closePrice: open ? null : t.closePrice,
|
||||
pnl: open ? null : t.pnl,
|
||||
fee: t.fee,
|
||||
funding: t.funding,
|
||||
net,
|
||||
openedAt: t.openedAt,
|
||||
closedAt: t.closedAt,
|
||||
openedBy: t.openedBy === 'bot' ? 'bot' : t.openedBy === 'unknown' ? null : t.openedBy === 'outside' || tracked(t.openedAt) ? 'outside' : null,
|
||||
closedBy,
|
||||
};
|
||||
});
|
||||
return NextResponse.json(trades);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { putVestSession, listVestSessions } from '@/lib/vest-session';
|
||||
import { autoBindVestWallet } from '@/lib/vest-wallet';
|
||||
import { getTradableFirms } from '@/lib/firm-catalog';
|
||||
import { getClients } from '@/lib/clients';
|
||||
import { corsJson, corsPreflight } from '../../autobuyer/cors';
|
||||
|
||||
/** The wallets this machine's Vest firms trade (their saved credential), lower-case. */
|
||||
function wantedWallets(): string[] {
|
||||
const wallets = getTradableFirms()
|
||||
.filter((f) => f.platform === 'vest')
|
||||
.map((f) => f.apiKey.trim().toLowerCase());
|
||||
return [...new Set(wallets)];
|
||||
}
|
||||
|
||||
/**
|
||||
* The extension's Vest relay (lib/vest-session.ts): POST the access token the
|
||||
* signed-in Vest tab just renewed — { accessToken, expiresAt, primaryAddr,
|
||||
* userId } — and it is held in memory for this machine's Vest client. A Vest
|
||||
* firm here with no credential yet is bound to that login's wallet
|
||||
* (lib/vest-wallet.ts). The extension posts again every minute, so a restart
|
||||
* here is back on the session within a minute. GET
|
||||
* lists what has been relayed (never the tokens) and which wallets this
|
||||
* machine wants, so the extension knows whether to keep a Vest tab open and
|
||||
* the dashboard can offer the address as the firm's credential.
|
||||
*
|
||||
* Same LAN-only, any-origin CORS as the AutoBuyer routes: the extension's
|
||||
* service worker calls it.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = await req.json() as Record<string, unknown>;
|
||||
} catch {
|
||||
return corsJson({ error: 'JSON body required' }, { status: 400 });
|
||||
}
|
||||
const refused = putVestSession({
|
||||
accessToken: body.accessToken,
|
||||
expiresAt: body.expiresAt,
|
||||
primaryAddr: body.primaryAddr,
|
||||
userId: body.userId,
|
||||
});
|
||||
if (refused) return corsJson({ ok: false, reason: refused }, { status: 400 });
|
||||
|
||||
const bound = autoBindVestWallet();
|
||||
const wanted = wantedWallets();
|
||||
const addr = String(body.primaryAddr).toLowerCase();
|
||||
// A client waiting on this wallet's session syncs now, not at its next poll.
|
||||
for (const client of getClients().values()) {
|
||||
const vest = client as { onVestSession?: (wallet: string) => void };
|
||||
vest.onVestSession?.(addr);
|
||||
}
|
||||
return corsJson({ ok: true, used: wanted.includes(addr), wanted, bound });
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
return corsJson({ sessions: listVestSessions(), wanted: wantedWallets() });
|
||||
}
|
||||
|
||||
export async function OPTIONS() {
|
||||
return corsPreflight();
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useState, type MouseEvent } from 'react';
|
||||
import type { AccountState, FirmAccountType, FirmConfig, KeptRulesWindow } from '@/types';
|
||||
import { evidenceDay, pickedDayRulesDate, pickKeptRules, unchangedDaySaves } from '@/lib/kept-rules';
|
||||
|
||||
/**
|
||||
* An account's rules date, shown next to its name, and the "old rules" chip.
|
||||
*
|
||||
* When a firm's rules change on the master with "New accounts only", accounts
|
||||
* dated before the change keep the old rules. The date defaults to when the
|
||||
* account started (or when this machine first saw it); clicking it edits it
|
||||
* inline — a past day is stored as its start, today as now (the master's
|
||||
* "today means from now") — with a preview of which rules that lands on.
|
||||
* Used on the dashboard row and the account page.
|
||||
*/
|
||||
|
||||
/** What the master's Firms page calls each rule field (its lib/kept-rules.ts
|
||||
* ruleLabel), capitalized — so an "old rules" difference reads the same on
|
||||
* both. */
|
||||
const RULE_LABELS: Record<string, string> = {
|
||||
profitTarget: 'Profit target',
|
||||
consistency: 'Consistency',
|
||||
minDayPnL: 'Min day P&L',
|
||||
minTradingDays: 'Min trading days',
|
||||
maxLoss: 'Max drawdown',
|
||||
maxPositionSize: 'Max contracts',
|
||||
targetSameEquity: 'Same equity after first w/d',
|
||||
splitEnabled: 'Split legs',
|
||||
minLegs: 'Min legs',
|
||||
firstDayStop: 'Day 1 $1k stop',
|
||||
minDayStop: 'Min-day 2× stop',
|
||||
combineCmeHolidays: 'CME holiday combining',
|
||||
withdrawalStages: 'Withdrawal stages',
|
||||
payoutable: 'Payoutable',
|
||||
trailingDrawdown: 'Trailing drawdown',
|
||||
dailyLossLimit: 'Hard daily loss',
|
||||
};
|
||||
|
||||
function money(n: number): string {
|
||||
return `$${n.toLocaleString('en-US', { maximumFractionDigits: 2 })}`;
|
||||
}
|
||||
|
||||
/** One rule value the way the Firms page shows it: money as $3,000, ratios as
|
||||
* 50%, switches on/off, stages by count. */
|
||||
function formatRule(field: string, value: unknown): string {
|
||||
if (value === undefined || value === null) return 'unset';
|
||||
if (typeof value === 'boolean') return value ? 'on' : 'off';
|
||||
if (Array.isArray(value)) {
|
||||
return value.length === 0 ? 'none' : `${value.length} stage${value.length === 1 ? '' : 's'}`;
|
||||
}
|
||||
if (typeof value !== 'number') return JSON.stringify(value);
|
||||
switch (field) {
|
||||
case 'consistency':
|
||||
return value === 0 ? 'none' : `${Math.round(value * 100)}%`;
|
||||
case 'minDayPnL':
|
||||
return value <= -999 ? 'none' : money(value);
|
||||
case 'maxPositionSize':
|
||||
return value === 0 ? 'no limit' : String(value);
|
||||
case 'minLegs':
|
||||
return value === 0 ? 'auto' : String(value);
|
||||
case 'trailingDrawdown':
|
||||
return value === 0 ? 'unset' : money(value);
|
||||
case 'maxLoss':
|
||||
case 'dailyLossLimit':
|
||||
return value === 0 ? 'none' : money(Math.abs(value));
|
||||
case 'profitTarget':
|
||||
return money(value);
|
||||
default:
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
/** "Consistency 50% (type now 40%)". Two edits that summarize alike — three
|
||||
* stages either way — just say the field changed. */
|
||||
function describeDiff(d: { field: string; kept: unknown; now: unknown }): string {
|
||||
const label = RULE_LABELS[d.field] ?? d.field;
|
||||
const kept = formatRule(d.field, d.kept);
|
||||
const now = formatRule(d.field, d.now);
|
||||
return kept === now ? `${label} changed since` : `${label} ${kept} (type now ${now})`;
|
||||
}
|
||||
|
||||
/** "Oct 18, 2026, 2:05 PM". */
|
||||
function fullWhen(iso: string): string {
|
||||
return new Date(iso).toLocaleString('en-US', {
|
||||
month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
/** "Oct 12", with the year when it isn't this year's; `time` adds "2:05 PM". */
|
||||
function shortWhen(iso: string, time = false): string {
|
||||
const d = new Date(iso);
|
||||
const opts: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric' };
|
||||
if (d.getFullYear() !== new Date().getFullYear()) opts.year = 'numeric';
|
||||
if (time) {
|
||||
opts.hour = 'numeric';
|
||||
opts.minute = '2-digit';
|
||||
}
|
||||
return d.toLocaleString('en-US', opts);
|
||||
}
|
||||
|
||||
/** Which rules dates a kept set covers, for the chip's tooltip. */
|
||||
function windowText(k: KeptRulesWindow): string {
|
||||
return k.openedAt
|
||||
? `kept for accounts dated from ${fullWhen(k.openedAt)} to before ${fullWhen(k.closedAt)}`
|
||||
: `kept for accounts dated before ${fullWhen(k.closedAt)}`;
|
||||
}
|
||||
|
||||
/** The same, short, for the editor's preview line. */
|
||||
function windowShort(k: KeptRulesWindow): string {
|
||||
return k.openedAt
|
||||
? `${shortWhen(k.openedAt, true)} – ${shortWhen(k.closedAt, true)}`
|
||||
: `before ${shortWhen(k.closedAt, true)}`;
|
||||
}
|
||||
|
||||
/** "Oct 12" for a calendar day (YYYY-MM-DD) itself, whatever the browser's
|
||||
* zone, with the year when it isn't this year's; `year` always adds it. */
|
||||
function dayLabel(day: string, year = false): string {
|
||||
const d = new Date(`${day}T12:00:00.000Z`);
|
||||
const opts: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric', timeZone: 'UTC' };
|
||||
if (year || d.getUTCFullYear() !== new Date().getFullYear()) opts.year = 'numeric';
|
||||
return d.toLocaleString('en-US', opts);
|
||||
}
|
||||
|
||||
/** YYYY-MM-DD of a moment in the browser's time zone — a date input's value. */
|
||||
function localDay(d: Date): string {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/** A date input's value as that day's local midnight; null when unparsable. */
|
||||
function localMidnight(day: string): Date | null {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(day);
|
||||
if (!m) return null;
|
||||
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
|
||||
return Number.isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
/** The rules date a picked day stores (lib/kept-rules.ts pickedDayRulesDate):
|
||||
* today as now, any other day as its start. null when unparsable. */
|
||||
function storedFor(day: string): string | null {
|
||||
return pickedDayRulesDate(day, {
|
||||
today: localDay(new Date()),
|
||||
nowMs: Date.now(),
|
||||
localMidnightMs: localMidnight(day)?.getTime() ?? NaN,
|
||||
});
|
||||
}
|
||||
|
||||
/** The amber chip on an account trading on one of its type's old rule sets. */
|
||||
export function OldRulesChip({ keptRules }: { keptRules: NonNullable<AccountState['keptRules']> }) {
|
||||
const lines = keptRules.differs.length > 0
|
||||
? keptRules.differs.map(describeDiff)
|
||||
: ['Same as the type\'s current rules'];
|
||||
return (
|
||||
<span
|
||||
className="inline-block px-1.5 py-0.5 rounded text-[10px] font-semibold bg-amber-100 text-amber-700 shrink-0 font-sans whitespace-nowrap"
|
||||
title={`Old rules — ${windowText(keptRules)}\n${lines.join('\n')}`}
|
||||
>
|
||||
old rules
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** The rules date and, when the account is on an old rule set, its chip.
|
||||
* `firm` names the firm whose type's kept windows the editor's preview
|
||||
* reads (none without it) — fetched fresh each time the editor opens, as a
|
||||
* page's firm listing can predate a rule change on the master; `onSaved`
|
||||
* should refresh /api/state. */
|
||||
export function RulesDate({ account, firm, onSaved }: {
|
||||
account: AccountState;
|
||||
firm: FirmConfig | null;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState('');
|
||||
// The date and day the editor opened on: the 5s state poll can move the
|
||||
// date underneath (evidence lowering it), and an untouched ✓ must still
|
||||
// mean "leave it", not "store the day it showed when opened".
|
||||
const [opened, setOpened] = useState<{ date: string; day: string } | null>(null);
|
||||
// The type's kept windows as the server has them now; null while loading
|
||||
// or when unknown.
|
||||
const [kept, setKept] = useState<KeptRulesWindow[] | null>(null);
|
||||
const openCount = useRef(0);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// No assignment row yet (the resolver hasn't seen the account): nothing to
|
||||
// show or set.
|
||||
if (account.rulesDate === null) return null;
|
||||
const rulesDate = account.rulesDate;
|
||||
// Broker evidence is the end of a Chicago day: shown as that day in any
|
||||
// zone, not as the next one east of Chicago.
|
||||
const brokerDay = evidenceDay(rulesDate);
|
||||
const currentDay = brokerDay ?? localDay(new Date(rulesDate));
|
||||
|
||||
// What ✓ would store, and the rules it lands on.
|
||||
const chosen = editing ? storedFor(draft) : null;
|
||||
const landsOn = kept && chosen ? pickKeptRules(kept, chosen) : null;
|
||||
const preview = !kept || !chosen
|
||||
? null
|
||||
: landsOn ? `→ old rules (${windowShort(landsOn)})` : '→ current rules';
|
||||
|
||||
function open(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
setDraft(currentDay);
|
||||
setOpened({ date: rulesDate, day: currentDay });
|
||||
setError('');
|
||||
setKept(null);
|
||||
setEditing(true);
|
||||
const ticket = ++openCount.current;
|
||||
const fallback = firm?.accounts.find((t) => t.id === account.accountTypeId)?.kept ?? null;
|
||||
if (!firm || !account.accountTypeId) return;
|
||||
void (async () => {
|
||||
let windows: KeptRulesWindow[] | null = fallback;
|
||||
try {
|
||||
const res = await fetch(`/api/firms/${encodeURIComponent(firm.id)}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json() as { accounts?: FirmAccountType[] };
|
||||
windows = data.accounts?.find((t) => t.id === account.accountTypeId)?.kept ?? null;
|
||||
}
|
||||
} catch {
|
||||
// Offline: the page's copy is the best there is.
|
||||
}
|
||||
if (ticket === openCount.current) setKept(windows);
|
||||
})();
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const value = storedFor(draft);
|
||||
if (!value || !opened) {
|
||||
setError('Pick a date');
|
||||
return;
|
||||
}
|
||||
// An unchanged day keeps the exact date, unless storing it moves the
|
||||
// account to other rules — what the preview then says ✓ does.
|
||||
if (draft === opened.day && !unchangedDaySaves(kept, opened.date, value)) {
|
||||
setEditing(false);
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await fetch(`/api/accounts/${account.id}/rules-date`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ rulesDate: value }),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error ?? 'Failed');
|
||||
setEditing(false);
|
||||
onSaved();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 font-sans font-normal" onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
type="date"
|
||||
value={draft}
|
||||
max={localDay(new Date())}
|
||||
autoFocus
|
||||
disabled={saving}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') void save();
|
||||
if (e.key === 'Escape') setEditing(false);
|
||||
}}
|
||||
className="border border-slate-200 rounded-md px-1 py-0.5 text-[11px] bg-white text-slate-700 focus:outline-none focus:ring-1 focus:ring-blue-400"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void save()}
|
||||
disabled={saving}
|
||||
className="px-1.5 py-0.5 bg-blue-100 hover:bg-blue-200 disabled:opacity-50 text-blue-700 text-[11px] font-semibold rounded-md transition-colors"
|
||||
title="Save the rules date"
|
||||
>
|
||||
{saving ? '…' : '✓'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(false)}
|
||||
disabled={saving}
|
||||
className="px-1.5 py-0.5 bg-slate-100 hover:bg-slate-200 disabled:opacity-50 text-slate-600 text-[11px] font-semibold rounded-md transition-colors"
|
||||
title="Cancel"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
{preview && <span className="text-[11px] text-slate-400 whitespace-nowrap">{preview}</span>}
|
||||
{error && <span className="text-[11px] text-red-500 whitespace-nowrap">{error}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 font-sans font-normal">
|
||||
<button
|
||||
type="button"
|
||||
onClick={open}
|
||||
className="inline-flex items-center gap-1 text-[11px] text-slate-400 hover:text-slate-600 tabular-nums whitespace-nowrap transition-colors"
|
||||
title={'Rules date — accounts dated before a firm\'s rule change keep the old rules. Click to change.\n'
|
||||
+ (brokerDay
|
||||
? `End of ${dayLabel(brokerDay, true)} in Chicago${account.rulesDateManual ? '' : ' — when the broker shows it opened'}`
|
||||
: fullWhen(rulesDate))
|
||||
+ (account.rulesDateManual ? ' (set by hand)' : '')}
|
||||
>
|
||||
{brokerDay ? dayLabel(brokerDay) : shortWhen(rulesDate)}
|
||||
{account.rulesDateManual && (
|
||||
<span className="w-1 h-1 rounded-full bg-blue-400 shrink-0" title="Set by hand" />
|
||||
)}
|
||||
</button>
|
||||
{account.keptRules && <OldRulesChip keptRules={account.keptRules} />}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import type { TradeView } from '@/types';
|
||||
|
||||
/**
|
||||
* Recorded positions from /api/trades — every trade on the accounts, the
|
||||
* bot's and anyone else's, with who opened it and how it closed. On the
|
||||
* dashboard under each firm (inline) and on the account page (as a card,
|
||||
* hidden while there's nothing to show).
|
||||
*/
|
||||
|
||||
const REFRESH_MS = 30_000;
|
||||
|
||||
const PILL = 'inline-block px-1.5 py-0.5 rounded text-[10px] font-semibold whitespace-nowrap';
|
||||
|
||||
const CLOSED_BY: Record<string, { label: string; cls: string; title: string }> = {
|
||||
bot: { label: 'Bot', cls: 'bg-violet-100 text-violet-700', title: "The bot's own close order" },
|
||||
outside: { label: 'Outside', cls: 'bg-amber-100 text-amber-700', title: 'Closed by an order the bot did not send — by hand on the platform, another tool, or the firm’s own liquidation' },
|
||||
stop_loss: { label: 'Stop loss', cls: 'bg-red-100 text-red-700', title: 'Its stop-loss triggered' },
|
||||
take_profit: { label: 'Take profit', cls: 'bg-green-100 text-green-700', title: 'Its take-profit triggered' },
|
||||
liquidation: { label: 'Liquidation', cls: 'bg-red-200 text-red-800', title: 'Liquidated by Vest' },
|
||||
auto_deleverage: { label: 'Auto-deleverage', cls: 'bg-red-100 text-red-700', title: 'Closed by Vest’s auto-deleverage' },
|
||||
temporary_leverage_limit: { label: 'Leverage limit', cls: 'bg-orange-100 text-orange-700', title: 'Reduced by Vest’s temporary (weekend) leverage limit' },
|
||||
corporate_action: { label: 'Corporate action', cls: 'bg-slate-100 text-slate-600', title: 'Closed by a corporate action' },
|
||||
user_requested: { label: 'Self-requested', cls: 'bg-slate-100 text-slate-600', title: 'An order on the account closed it — from before the bot marked its own closes, so whose is unknown' },
|
||||
unknown: { label: 'Unknown', cls: 'bg-slate-100 text-slate-500', title: 'Closed while the platform’s history wasn’t being read — when and how aren’t on record' },
|
||||
};
|
||||
|
||||
function money(v: number): string {
|
||||
return v.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
function signed(v: number): string {
|
||||
return `${v >= 0 ? '+' : '−'}$${money(Math.abs(v))}`;
|
||||
}
|
||||
|
||||
function price(v: number | null): string {
|
||||
return v === null ? '—' : v.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 4 });
|
||||
}
|
||||
|
||||
function when(ms: number): string {
|
||||
return new Date(ms).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function OpenedBy({ by }: { by: TradeView['openedBy'] }) {
|
||||
if (by === 'bot') return <span className={`${PILL} bg-violet-100 text-violet-700`} title="Opened by the bot">Bot</span>;
|
||||
if (by === 'outside') return <span className={`${PILL} bg-amber-100 text-amber-700`} title="Opened by an order the bot did not send — by hand on the platform, or another tool">Outside</span>;
|
||||
return <span className="text-slate-400" title="Opened before the bot began marking its own trades">—</span>;
|
||||
}
|
||||
|
||||
function ClosedBy({ by }: { by: string | null }) {
|
||||
if (by === null) return <span className={`${PILL} bg-sky-100 text-sky-700`}>Open</span>;
|
||||
const c = CLOSED_BY[by] ?? { label: by, cls: 'bg-slate-100 text-slate-600', title: by };
|
||||
return <span className={`${PILL} ${c.cls}`} title={c.title}>{c.label}</span>;
|
||||
}
|
||||
|
||||
export function TradesTable({ query, title, showAccount = false, formatAccount = (n) => n, limit = 200 }: {
|
||||
/** accountId=… or firmId=… */
|
||||
query: string;
|
||||
/** Set: render as a titled card, and nothing at all while empty. */
|
||||
title?: string;
|
||||
showAccount?: boolean;
|
||||
formatAccount?: (name: string) => string;
|
||||
limit?: number;
|
||||
}) {
|
||||
const [trades, setTrades] = useState<TradeView[] | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/trades?${query}&limit=${limit}`);
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
setTrades(await res.json());
|
||||
setFailed(false);
|
||||
} catch {
|
||||
setFailed(true);
|
||||
}
|
||||
}, [query, limit]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
const t = setInterval(() => { void load(); }, REFRESH_MS);
|
||||
return () => clearInterval(t);
|
||||
}, [load]);
|
||||
|
||||
if (title && (!trades || trades.length === 0)) return null;
|
||||
|
||||
const closed = (trades ?? []).filter((t) => t.net !== null);
|
||||
const net = closed.reduce((s, t) => s + (t.net ?? 0), 0);
|
||||
const fees = closed.reduce((s, t) => s + Math.abs(t.fee ?? 0), 0);
|
||||
|
||||
const summary = trades && trades.length > 0 && (
|
||||
<span className="text-xs text-slate-400">
|
||||
{trades.length} trade{trades.length === 1 ? '' : 's'}{trades.length >= limit ? ` (latest ${limit})` : ''}
|
||||
{closed.length > 0 && (
|
||||
<>
|
||||
{' · net '}
|
||||
<span className={net >= 0 ? 'text-green-600' : 'text-red-500'}>{signed(net)}</span>
|
||||
{` · fees $${money(fees)}`}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
|
||||
const table = !trades
|
||||
? <div className="text-sm text-slate-400 italic py-2">{failed ? 'Couldn’t load trades' : 'Loading trades…'}</div>
|
||||
: trades.length === 0
|
||||
? <div className="text-sm text-slate-400 italic py-2">No trades recorded yet — they appear a few minutes after a fill, once the platform’s history is read</div>
|
||||
: (
|
||||
<div className="overflow-x-auto max-h-[28rem] overflow-y-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="sticky top-0 bg-white">
|
||||
<tr className="border-b border-slate-100 text-slate-400">
|
||||
<th className="text-left font-medium py-2 pr-3">Opened</th>
|
||||
<th className="text-left font-medium py-2 pr-3">Closed</th>
|
||||
{showAccount && <th className="text-left font-medium py-2 pr-3">Account</th>}
|
||||
<th className="text-left font-medium py-2 pr-3">Market</th>
|
||||
<th className="text-left font-medium py-2 pr-3">Side</th>
|
||||
<th className="text-right font-medium py-2 pr-3">Size</th>
|
||||
<th className="text-right font-medium py-2 pr-3">Open</th>
|
||||
<th className="text-right font-medium py-2 pr-3">Close</th>
|
||||
<th className="text-right font-medium py-2 pr-3" title="Realized P&L before fees and funding">P&L</th>
|
||||
<th className="text-right font-medium py-2 pr-3">Fees</th>
|
||||
<th className="text-right font-medium py-2 pr-3" title="P&L − fees + funding">Net</th>
|
||||
<th className="text-left font-medium py-2 pr-3">Opened by</th>
|
||||
<th className="text-left font-medium py-2">Closed by</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{trades.map((t) => (
|
||||
<tr key={t.positionId} className="border-b border-slate-50 last:border-0 hover:bg-slate-50">
|
||||
<td className="py-1.5 pr-3 text-slate-500 tabular-nums whitespace-nowrap">{when(t.openedAt)}</td>
|
||||
<td className="py-1.5 pr-3 text-slate-500 tabular-nums whitespace-nowrap">{t.closedAt !== null ? when(t.closedAt) : '—'}</td>
|
||||
{showAccount && <td className="py-1.5 pr-3 font-mono text-slate-700 whitespace-nowrap">{formatAccount(t.accountName)}</td>}
|
||||
<td className="py-1.5 pr-3 text-slate-700 whitespace-nowrap">{t.symbol}</td>
|
||||
<td className={`py-1.5 pr-3 font-semibold ${t.side === 'long' ? 'text-green-600' : 'text-red-500'}`}>{t.side === 'long' ? 'Long' : 'Short'}</td>
|
||||
<td className="py-1.5 pr-3 text-right tabular-nums text-slate-600">{t.quantity > 0 ? t.quantity.toLocaleString(undefined, { maximumFractionDigits: 4 }) : '—'}</td>
|
||||
<td className="py-1.5 pr-3 text-right tabular-nums text-slate-600">{price(t.openPrice)}</td>
|
||||
<td className="py-1.5 pr-3 text-right tabular-nums text-slate-600">{price(t.closePrice)}</td>
|
||||
<td className={`py-1.5 pr-3 text-right tabular-nums ${t.pnl === null ? 'text-slate-400' : t.pnl >= 0 ? 'text-green-600' : 'text-red-500'}`}>
|
||||
{t.pnl === null ? '—' : signed(t.pnl)}
|
||||
</td>
|
||||
<td className="py-1.5 pr-3 text-right tabular-nums text-slate-500">{t.fee === null ? '—' : `$${money(Math.abs(t.fee))}`}</td>
|
||||
<td className={`py-1.5 pr-3 text-right tabular-nums font-semibold ${t.net === null ? 'text-slate-400' : t.net >= 0 ? 'text-green-600' : 'text-red-500'}`}>
|
||||
{t.net === null ? '—' : signed(t.net)}
|
||||
</td>
|
||||
<td className="py-1.5 pr-3"><OpenedBy by={t.openedBy} /></td>
|
||||
<td className="py-1.5"><ClosedBy by={t.closedBy} /></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (title) {
|
||||
return (
|
||||
<div className="bg-white border border-slate-200 rounded-xl shadow-sm p-6 mt-5">
|
||||
<div className="flex items-baseline justify-between mb-4">
|
||||
<h2 className="text-slate-900 font-bold text-base">{title}</h2>
|
||||
{summary}
|
||||
</div>
|
||||
{table}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-baseline justify-between mb-1">
|
||||
<span className="text-xs font-semibold text-slate-600">Trades</span>
|
||||
{summary}
|
||||
</div>
|
||||
{table}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+177
-22
@@ -4,6 +4,8 @@ import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import type { FirmConfig, FirmState, AccountState } from '@/types';
|
||||
import { stagePillClasses } from '@/lib/stage-colors';
|
||||
import { RulesDate } from '@/app/components/RulesDate';
|
||||
import { TradesTable } from '@/app/components/TradesTable';
|
||||
|
||||
type SortKey = 'name' | 'balance' | 'dayPnL' | 'daysTraded' | 'target' | 'status';
|
||||
type SortDir = 'asc' | 'desc';
|
||||
@@ -194,14 +196,91 @@ function credentialNoun(platform: FirmConfig['platform']): string {
|
||||
case 'topstepx': return 'TopstepX API key';
|
||||
case 'dxtrade': return 'DXtrade login';
|
||||
case 'tradovate': return 'Tradovate login';
|
||||
case 'vest': return 'Vest wallet address';
|
||||
}
|
||||
}
|
||||
|
||||
/** Inline credential entry, shown to the right of a firm that has none.
|
||||
* Tradovate and DXtrade want username + password; TopstepX wants the API key
|
||||
* (username optional for now, though login needs it eventually — password
|
||||
* unused). */
|
||||
* unused); Vest the login's wallet address, whose session the extension
|
||||
* relays from a signed-in Vest tab. */
|
||||
function CredentialForm({ firm, onSaved }: { firm: FirmConfig; onSaved: () => void }) {
|
||||
if (firm.platform === 'vest') return <VestWalletForm firm={firm} onSaved={onSaved} />;
|
||||
return <LoginForm firm={firm} onSaved={onSaved} />;
|
||||
}
|
||||
|
||||
/** A Vest firm's credential: the wallet its Vest login belongs to. It binds
|
||||
* itself as soon as the extension relays a Vest tab signed in on this
|
||||
* machine (lib/vest-wallet.ts); this form is for when it can't — two logins
|
||||
* relayed at once, or none yet — and offers the relayed wallets with one
|
||||
* click. */
|
||||
function VestWalletForm({ firm, onSaved }: { firm: FirmConfig; onSaved: () => void }) {
|
||||
const [wallet, setWallet] = useState('');
|
||||
const [seen, setSeen] = useState<string[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/vest/session', { cache: 'no-store' })
|
||||
.then((r) => r.json())
|
||||
.then((d: { sessions?: { primaryAddr: string }[] }) => setSeen([...new Set((d.sessions ?? []).map((x) => x.primaryAddr))]))
|
||||
.catch(() => setSeen([]));
|
||||
}, []);
|
||||
|
||||
async function save(address: string) {
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await fetch(`/api/firms/${firm.id}/credentials`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ apiKey: address.trim() }),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error ?? 'Failed');
|
||||
setWallet('');
|
||||
onSaved();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const valid = /^0x[0-9a-fA-F]{40}$/.test(wallet.trim());
|
||||
return (
|
||||
<div className="flex items-center gap-1.5" onClick={(e) => e.stopPropagation()}>
|
||||
{seen.map((address) => (
|
||||
<button
|
||||
key={address}
|
||||
onClick={() => void save(address)}
|
||||
disabled={saving}
|
||||
title="The wallet of the Vest tab signed in on this machine"
|
||||
className="px-2 py-1 bg-emerald-100 hover:bg-emerald-200 disabled:opacity-50 text-emerald-800 text-xs font-mono rounded-md"
|
||||
>
|
||||
Use {address.slice(0, 6)}…{address.slice(-4)}
|
||||
</button>
|
||||
))}
|
||||
<input
|
||||
className="border border-slate-200 rounded-md px-2 py-1 text-xs bg-white focus:outline-none focus:ring-1 focus:ring-blue-400 w-72 font-mono"
|
||||
placeholder={seen.length > 0 ? 'or another wallet 0x…' : 'Sign in to Vest in Chrome: it binds itself (or paste 0x…)'}
|
||||
value={wallet}
|
||||
onChange={(e) => setWallet(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' && valid) void save(wallet); }}
|
||||
/>
|
||||
<button
|
||||
onClick={() => void save(wallet)}
|
||||
disabled={saving || !valid}
|
||||
className="px-2.5 py-1 bg-blue-100 hover:bg-blue-200 disabled:opacity-50 text-blue-700 text-xs font-semibold rounded-md transition-colors"
|
||||
>
|
||||
{saving ? '…' : 'Save'}
|
||||
</button>
|
||||
{error && <span className="text-xs text-red-500">{error}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoginForm({ firm, onSaved }: { firm: FirmConfig; onSaved: () => void }) {
|
||||
const isTopstepX = firm.platform === 'topstepx';
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
@@ -342,12 +421,14 @@ function WipeAllButton() {
|
||||
);
|
||||
}
|
||||
|
||||
function AccountRow({ account, firm, hideDead, privacy, onAssigned }: {
|
||||
function AccountRow({ account, firm, hideDead, privacy, onAssigned, onStateChanged }: {
|
||||
account: AccountState;
|
||||
firm: FirmConfig;
|
||||
hideDead: boolean;
|
||||
privacy: boolean;
|
||||
onAssigned: () => void;
|
||||
/** Refetches /api/state — after a rules date is saved. */
|
||||
onStateChanged: () => void;
|
||||
}) {
|
||||
const cfg = account.accountType;
|
||||
const dead = isAccountDead(account);
|
||||
@@ -359,8 +440,9 @@ function AccountRow({ account, firm, hideDead, privacy, onAssigned }: {
|
||||
return (
|
||||
<tr className={`border-b border-slate-100 hover:bg-slate-50 transition-colors${!account.active || dead ? ' opacity-50' : ''}`}>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2 font-medium text-slate-800 pl-4 font-mono">
|
||||
{maskName(account.name, privacy)}
|
||||
<div className="flex items-center gap-2 pl-4">
|
||||
<span className="font-medium text-slate-800 font-mono">{maskName(account.name, privacy)}</span>
|
||||
<RulesDate account={account} firm={firm} onSaved={onStateChanged} />
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-600 tabular-nums">${fmt(account.amount)}</td>
|
||||
@@ -371,7 +453,7 @@ function AccountRow({ account, firm, hideDead, privacy, onAssigned }: {
|
||||
<AccountTypeCell account={account} firm={firm} onAssigned={onAssigned} />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-600 tabular-nums">
|
||||
{account.daysTraded} / {cfg?.minTradingDays ?? '—'}
|
||||
{account.daysTraded} / {account.effectiveMinTradingDays ?? cfg?.minTradingDays ?? '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-600 tabular-nums">
|
||||
<div className="flex flex-col">
|
||||
@@ -391,7 +473,7 @@ function AccountRow({ account, firm, hideDead, privacy, onAssigned }: {
|
||||
<span className="text-red-600" title="Last stand: hitting the day's loss limit would leave this account within 15% of that loss above its max-drawdown floor — nothing left to trade on — so the next leg carries no daily-loss stop and runs to its take-profit or the firm's breach"> · no stop (last stand)</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. A reserve — 10% of the limit, or 0.1% of the position if larger — is never spent."> · DLL room ${fmt(account.dllRoom)}</span>
|
||||
<span className="text-slate-500" title="Hard daily loss limit: what the firm's day may still lose before a breach. A reserve — 10% of the limit, or 0.1% of the position if larger (0.02% on Vest) — is never spent."> · DLL room ${fmt(account.dllRoom)}</span>
|
||||
)}
|
||||
{account.holdEstimate && (
|
||||
<span
|
||||
@@ -440,7 +522,7 @@ function AccountRow({ account, firm, hideDead, privacy, onAssigned }: {
|
||||
);
|
||||
}
|
||||
|
||||
function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbol, onChanged }: {
|
||||
function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbol, onChanged, onStateChanged }: {
|
||||
state: FirmState;
|
||||
firm: FirmConfig;
|
||||
hideDead: boolean;
|
||||
@@ -449,8 +531,10 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo
|
||||
sortDir: SortDir;
|
||||
tradeSymbol: string;
|
||||
onChanged: () => void;
|
||||
onStateChanged: () => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(true);
|
||||
const [showTrades, setShowTrades] = useState(false);
|
||||
const [confirmCreds, setConfirmCreds] = useState(false);
|
||||
const [removingCreds, setRemovingCreds] = useState(false);
|
||||
const hasCredentials = firm.hasCredentials;
|
||||
@@ -498,7 +582,7 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo
|
||||
<td className="px-4 py-3.5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="font-bold text-sm text-slate-800">{firm.firm}</span>
|
||||
{firm.platform !== 'dxtrade' && tradeSymbol !== 'Auto' && !firm.instruments?.includes(tradeSymbol) && (
|
||||
{firm.platform !== 'dxtrade' && firm.platform !== 'vest' && tradeSymbol !== 'Auto' && !firm.instruments?.includes(tradeSymbol) && (
|
||||
<span className="text-xs bg-red-100 text-red-700 px-1.5 py-0.5 rounded font-semibold" title="Not on this firm's instrument list (master Firms page) — it sits this symbol out">
|
||||
{tradeSymbol} not listed
|
||||
</span>
|
||||
@@ -513,6 +597,11 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo
|
||||
DXtrade
|
||||
</span>
|
||||
)}
|
||||
{firm.platform === 'vest' && (
|
||||
<span className="text-xs bg-violet-100 text-violet-700 px-1.5 py-0.5 rounded font-semibold" title="Trades through the session the extension relays from a signed-in Vest tab">
|
||||
Vest
|
||||
</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"
|
||||
@@ -563,6 +652,15 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo
|
||||
)}
|
||||
<td className="px-4 py-3.5">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{hasCredentials && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setShowTrades((v) => !v); }}
|
||||
className={`px-2 py-0.5 text-xs font-semibold rounded-md transition-colors ${showTrades ? 'bg-violet-100 text-violet-700' : 'text-slate-500 hover:bg-slate-200'}`}
|
||||
title="Every trade on this firm's accounts — the bot's and anyone else's — with how each one closed"
|
||||
>
|
||||
Trades
|
||||
</button>
|
||||
)}
|
||||
{hasCredentials && !confirmCreds && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setConfirmCreds(true); }}
|
||||
@@ -585,6 +683,14 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{showTrades && hasCredentials && (
|
||||
<tr className="border-b border-slate-100">
|
||||
<td colSpan={7} className="px-4 py-3 pl-12 bg-white">
|
||||
<TradesTable query={`firmId=${encodeURIComponent(firm.id)}`} showAccount formatAccount={(n) => maskName(n, privacy)} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{open && hasCredentials && (
|
||||
state.accounts.length === 0 ? (
|
||||
<tr>
|
||||
@@ -601,6 +707,7 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo
|
||||
hideDead={hideDead}
|
||||
privacy={privacy}
|
||||
onAssigned={onChanged}
|
||||
onStateChanged={onStateChanged}
|
||||
/>
|
||||
))
|
||||
)
|
||||
@@ -636,6 +743,8 @@ export default function Home() {
|
||||
const [tickInterval, setTickInterval] = useState('60');
|
||||
const [tradeLoading, setTradeLoading] = useState(false);
|
||||
const [stopAfterAll, setStopAfterAll] = useState(false);
|
||||
const [autoClaim, setAutoClaim] = useState(true);
|
||||
const [autoActivate, setAutoActivate] = useState(true);
|
||||
|
||||
const handleSort = (col: SortKey) => {
|
||||
if (sortKey === col) {
|
||||
@@ -673,6 +782,18 @@ export default function Home() {
|
||||
fetchConfig();
|
||||
};
|
||||
|
||||
const fetchState = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/state');
|
||||
if (res.ok) {
|
||||
const data: FirmState[] = await res.json();
|
||||
setFirms(data);
|
||||
}
|
||||
} catch {
|
||||
// server not running yet
|
||||
}
|
||||
};
|
||||
|
||||
const fetchScheduler = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/auto-trade');
|
||||
@@ -693,27 +814,16 @@ export default function Home() {
|
||||
|
||||
fetch('/api/settings')
|
||||
.then((r) => r.json())
|
||||
.then((s: { tick_interval_seconds?: string | null }) => {
|
||||
.then((s: { tick_interval_seconds?: string | null; auto_claim?: string | null; auto_activate?: string | null }) => {
|
||||
if (s.tick_interval_seconds != null) setTickInterval(s.tick_interval_seconds);
|
||||
setAutoClaim(s.auto_claim !== 'off');
|
||||
setAutoActivate(s.auto_activate !== 'off');
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
fetchConfig();
|
||||
fetchCatalog();
|
||||
fetchScheduler();
|
||||
|
||||
const fetchState = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/state');
|
||||
if (res.ok) {
|
||||
const data: FirmState[] = await res.json();
|
||||
setFirms(data);
|
||||
}
|
||||
} catch {
|
||||
// server not running yet
|
||||
}
|
||||
};
|
||||
|
||||
fetchState();
|
||||
const stateInterval = setInterval(fetchState, 5000);
|
||||
const schedulerInterval = setInterval(fetchScheduler, 5000);
|
||||
@@ -894,6 +1004,50 @@ export default function Home() {
|
||||
/>
|
||||
<span className="text-xs text-slate-400 whitespace-nowrap">Stop after all eligible</span>
|
||||
</label>
|
||||
{config.some((f) => f.platform === 'vest') && (
|
||||
<label
|
||||
className="flex items-center gap-1.5 ml-2 cursor-pointer select-none"
|
||||
title="Claim a funded account's profit as soon as it reaches its stage's target (Vest) — whether or not the scheduler is running"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoClaim}
|
||||
onChange={(e) => {
|
||||
const on = e.target.checked;
|
||||
setAutoClaim(on);
|
||||
fetch('/api/settings', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ auto_claim: on ? 'on' : 'off' }),
|
||||
}).then((r) => { if (!r.ok) setAutoClaim(!on); }).catch(() => setAutoClaim(!on));
|
||||
}}
|
||||
className="rounded border-slate-300 text-emerald-500 focus:ring-emerald-500"
|
||||
/>
|
||||
<span className="text-xs text-slate-400 whitespace-nowrap">Auto-claim payouts</span>
|
||||
</label>
|
||||
)}
|
||||
{config.some((f) => f.platform === 'vest') && (
|
||||
<label
|
||||
className="flex items-center gap-1.5 ml-2 cursor-pointer select-none"
|
||||
title="Activate a passed evaluation's funded account as soon as it passes (Vest) — whether or not the scheduler is running"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoActivate}
|
||||
onChange={(e) => {
|
||||
const on = e.target.checked;
|
||||
setAutoActivate(on);
|
||||
fetch('/api/settings', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ auto_activate: on ? 'on' : 'off' }),
|
||||
}).then((r) => { if (!r.ok) setAutoActivate(!on); }).catch(() => setAutoActivate(!on));
|
||||
}}
|
||||
className="rounded border-slate-300 text-emerald-500 focus:ring-emerald-500"
|
||||
/>
|
||||
<span className="text-xs text-slate-400 whitespace-nowrap">Auto-activate funded</span>
|
||||
</label>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{hasAnyPosition && (
|
||||
<button
|
||||
@@ -965,6 +1119,7 @@ export default function Home() {
|
||||
sortDir={sortDir}
|
||||
tradeSymbol={tradeSymbol}
|
||||
onChanged={fetchConfig}
|
||||
onStateChanged={fetchState}
|
||||
/>
|
||||
);
|
||||
})
|
||||
|
||||
@@ -47,6 +47,7 @@ interface AppSettings {
|
||||
trading_hours: string | null;
|
||||
master_backup_token: string | null;
|
||||
proxy_url: string | null;
|
||||
user_agent: string | null;
|
||||
backup_last_push_at: string | null;
|
||||
backup_last_error: string | null;
|
||||
}
|
||||
@@ -77,6 +78,7 @@ export default function SettingsPage() {
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [backupToken, setBackupToken] = useState<string>('');
|
||||
const [proxyUrl, setProxyUrl] = useState<string>('');
|
||||
const [userAgent, setUserAgent] = useState<string>('');
|
||||
const [backupStatus, setBackupStatus] = useState<{ at: string | null; error: string | null }>({ at: null, error: null });
|
||||
const [restoreList, setRestoreList] = useState<BackupMeta[] | null>(null);
|
||||
const [restoreBusy, setRestoreBusy] = useState(false);
|
||||
@@ -130,6 +132,7 @@ export default function SettingsPage() {
|
||||
if (s.proxy_url != null) {
|
||||
setProxyUrl(s.proxy_url);
|
||||
}
|
||||
setUserAgent(s.user_agent ?? '');
|
||||
setBackupStatus({ at: s.backup_last_push_at, error: s.backup_last_error || null });
|
||||
});
|
||||
|
||||
@@ -440,6 +443,16 @@ export default function SettingsPage() {
|
||||
className="w-72 rounded-lg border border-slate-200 bg-slate-50 px-3 py-1.5 text-sm font-mono text-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="border-t border-slate-100 px-4 py-3">
|
||||
<p className="text-sm font-medium text-slate-800">User agent</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">
|
||||
Chrome, Firefox, or Edge is randomly selected on first load and saved.
|
||||
All server Axios requests and WebSocket connections reuse it, including after restarts.
|
||||
</p>
|
||||
<p className="mt-2 break-all rounded-lg bg-slate-50 px-3 py-2 text-xs font-mono text-slate-700">
|
||||
{userAgent || 'Loading…'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Instruments ── */}
|
||||
|
||||
@@ -5,6 +5,29 @@ import nextTs from "eslint-config-next/typescript";
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
{
|
||||
files: ['**/*.{js,mjs,cjs,ts,tsx}'],
|
||||
ignores: ['lib/http-client.ts', 'lib/outbound-proxy.ts'],
|
||||
rules: {
|
||||
'no-restricted-imports': ['error', {
|
||||
patterns: [{
|
||||
group: ['axios', 'axios/*'],
|
||||
message: 'Import Axios from @/lib/http-client so requests keep the saved user agent, proxy and timeout.',
|
||||
}],
|
||||
}],
|
||||
'no-restricted-modules': ['error', {
|
||||
paths: [{
|
||||
name: 'axios',
|
||||
message: 'Use @/lib/http-client for the configured Axios client.',
|
||||
}],
|
||||
patterns: ['axios/*'],
|
||||
}],
|
||||
'no-restricted-syntax': ['error', {
|
||||
selector: 'ImportExpression[source.value=/^axios(\\/|$)/]',
|
||||
message: 'Import @/lib/http-client for the configured Axios client.',
|
||||
}],
|
||||
},
|
||||
},
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
|
||||
@@ -58,6 +58,70 @@ going back to `<all_urls>`: it would put every tab you visit in reach, and limit
|
||||
firm needs its host in *both* lists — `host_permissions` and the `content_scripts`
|
||||
matches — or captchas on that site will have no callback to fire.
|
||||
|
||||
## 2FA codes
|
||||
|
||||
On a firm site, the extension shows the machine's person's authenticator code
|
||||
for that site in a small chip in the bottom-right corner, with a Copy button
|
||||
(minimise it to a "2FA" pill; that sticks for the tab's session).
|
||||
|
||||
```
|
||||
extension --GET /api/autobuyer/otp?host=<tab host>--> dashboard
|
||||
dashboard --GET <master>/api/otp?person=<instance_id>&host=<tab host>--> master
|
||||
```
|
||||
|
||||
The master holds the secrets and matches the host against each firm's **Site**
|
||||
link (Firms page), then returns the codes assigned to this person and that firm
|
||||
(Codes page). The chip only needs a code to be assigned — it doesn't wait for a
|
||||
code field. It stays hidden for a minute after the runner last located
|
||||
something, so automated clicks never land on it.
|
||||
|
||||
A new 2FA site needs its host in `host_permissions` (not the captcha
|
||||
`content_scripts` list), a Site link on its firm on the master, and its codes
|
||||
assigned on the master's Codes page.
|
||||
|
||||
## Vest session relay
|
||||
|
||||
Vest signs people in with Google (through Privy) in the browser and renews the
|
||||
session with a device key the browser will not export, so the dashboard cannot
|
||||
log in to Vest by itself. The extension relays the browser's session instead:
|
||||
|
||||
```
|
||||
vest-relay.js (Vest tab, MAIN world) keeps the access token the Vest app last renewed
|
||||
background.js relayVestSession lifts it every 10s, posts it when it changes (and every minute)
|
||||
dashboard POST /api/vest/session holds it in memory for the firm's Vest client,
|
||||
and binds an unset Vest firm to the login's wallet
|
||||
```
|
||||
|
||||
- The hook only reads the Vest app's own `/v3/auth/refresh` and `/register`
|
||||
answers. It renews nothing and sends nothing; the session stays the app's,
|
||||
so the bot never logs the tab out.
|
||||
- It takes the login's token only. The app renews each trading account's own
|
||||
token through the same `/v3/auth/refresh` (with that account's refresh token
|
||||
in the body), and one of those sees a single account. Before 0.17.2 the hook
|
||||
relayed those too, and the dashboard dropped every other account; the
|
||||
dashboard now refuses such a token and falls back to the login's.
|
||||
- The token lives ~15 minutes and the app renews it by itself while the tab is
|
||||
open. The tab is marked not auto-discardable, since a discarded tab stops
|
||||
renewing.
|
||||
- Several Vest tabs each renew on their own, and every renewal moves the login
|
||||
on to a new session (localStorage `vest-auth-session`). A token from before
|
||||
still reads, but Vest refuses to mint an account's token with it (401, code
|
||||
1002), so the bot's orders and history reads fail. Since 0.18.0 the hook
|
||||
records each token's session id, and the relay sends the token Vest's
|
||||
current session holds, whichever tab has it, not the first tab's. A tab
|
||||
hooked by an older build is matched by its token's expiry instead; one that
|
||||
renewed before any hook was in it shows in the popup as "none holds the
|
||||
current session" until it renews again or is reloaded.
|
||||
- A sign-in's answer (`/register`) carries the login's own refresh token, and
|
||||
is taken too since 0.18.0; before, the first token after a sign-in waited
|
||||
for the tab's first renewal.
|
||||
- When the dashboard has a Vest firm (its credential is the login's wallet)
|
||||
and no Vest tab is open, the extension opens one in the background. The app
|
||||
signs back in from its 30-day session. After that the person signs in again
|
||||
in that tab.
|
||||
- This is the one place the extension reads a tab you are not looking at. It
|
||||
only reads `next.vestmarkets.com` tabs, and only that one value.
|
||||
|
||||
## Notes
|
||||
|
||||
- The extension never captures the dashboard's own pages — otherwise it would
|
||||
|
||||
@@ -10,6 +10,11 @@
|
||||
*
|
||||
* The locate queue is served whenever clicks are waiting. The AutoBuyer is
|
||||
* ready at all times; a run happens because someone asked for one.
|
||||
*
|
||||
* On a firm site, the person's 2FA code for it shows in a corner chip.
|
||||
*
|
||||
* A signed-in Vest tab's session is relayed to the dashboard, whose Vest client
|
||||
* trades with it (relayVestSession).
|
||||
*/
|
||||
|
||||
const DEFAULTS = {
|
||||
@@ -900,6 +905,7 @@ async function serveLocateRequest(cfg) {
|
||||
let error = null;
|
||||
try {
|
||||
let { tab, opened } = await resolveLocateTab(cfg, request);
|
||||
await hideOtpChip(tab.id);
|
||||
|
||||
// A navigate step points the tab at a specific page first. Skip it when we
|
||||
// are already there — reloading would throw away page state for nothing,
|
||||
@@ -1176,6 +1182,435 @@ async function tick() {
|
||||
} catch (err) {
|
||||
await setState({ connected: true, error: err.message });
|
||||
}
|
||||
|
||||
try {
|
||||
await maybeShowOtp(cfg);
|
||||
} catch (err) {
|
||||
await setState({ connected: true, error: `2FA chip: ${err.message}` });
|
||||
}
|
||||
|
||||
try {
|
||||
await relayVestSession(cfg);
|
||||
} catch (err) {
|
||||
await setState({ connected: true, vest: `Vest relay: ${err.message}` });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Vest: relay the signed-in tab's session to the dashboard ────────────────
|
||||
//
|
||||
// Vest signs people in through Google in the browser and renews the session
|
||||
// with a device key the browser won't hand out, so the dashboard cannot log in
|
||||
// by itself. vest-relay.js, in the Vest tab, keeps the latest access token the
|
||||
// Vest app renewed for itself (~15 minutes each); this lifts it and posts it to
|
||||
// the dashboard whenever it changes. Nothing here renews or signs anything: the
|
||||
// session stays the app's.
|
||||
//
|
||||
// With several Vest tabs open, each renews on its own, and each renewal moves
|
||||
// the login on to a new session: the other tabs' tokens still read, but Vest
|
||||
// refuses to mint an account's token with them (401, code 1002) — the bot's
|
||||
// history reads and orders fail until the tab it relays renews again. So the
|
||||
// token relayed is the one Vest's current session holds (pickVestSession),
|
||||
// whichever tab it is in, not the first tab's.
|
||||
|
||||
const VEST_PATTERN = 'https://next.vestmarkets.com/*';
|
||||
const VEST_URL = 'https://next.vestmarkets.com/portfolio';
|
||||
const VEST_EVERY_MS = 10_000;
|
||||
/** The session is posted again this often even unchanged: the dashboard
|
||||
* keeps it in memory only, so a restart there needs it back. */
|
||||
const VEST_REPOST_MS = 60_000;
|
||||
/** A Vest tab opened for the relay isn't opened again for this long. */
|
||||
const VEST_REOPEN_MS = 10 * 60_000;
|
||||
|
||||
let vestLastCheck = 0;
|
||||
let vestLastRelayed = '';
|
||||
let vestLastPostAt = 0;
|
||||
let vestOpenedAt = 0;
|
||||
|
||||
/** Runs in the Vest tab's MAIN world: the session vest-relay.js holds, while
|
||||
* it has life left, and the current session the Vest app keeps for all its
|
||||
* tabs. Self-contained. Version 2's token whenever it holds one: it sees
|
||||
* every login renewal version 1 does once it's in, so a version-1 token it
|
||||
* lacks is an account's (the 0.17.0/0.17.1 hooks kept those too). */
|
||||
function pageVestSession() {
|
||||
const live = (s) => (s && typeof s.accessToken === 'string' && s.expiresAt > Date.now() + 30000 ? s : null);
|
||||
const session = live(window.__afVestLogin) ?? live(window.__afVestSession);
|
||||
let current = null;
|
||||
try {
|
||||
const raw = JSON.parse(localStorage.getItem('vest-auth-session') || 'null');
|
||||
if (raw && typeof raw === 'object') {
|
||||
current = {
|
||||
refreshTokenId: typeof raw.refreshTokenId === 'string' && raw.refreshTokenId ? raw.refreshTokenId : null,
|
||||
accessTokenExpiresAt: Number.isFinite(raw.accessTokenExpiresAt) ? raw.accessTokenExpiresAt : null,
|
||||
};
|
||||
}
|
||||
} catch { /* no session kept */ }
|
||||
return { hooked: window.__afVestHook === 2, session, current };
|
||||
}
|
||||
|
||||
/** Whether a tab's token is the one the Vest app's current session holds:
|
||||
* the same session id — the app's own check before it mints — else, for a
|
||||
* token caught by a version-1 hook (no id), the same expiry, which the app
|
||||
* writes into the session with the token. */
|
||||
function isCurrentVestSession(session, current) {
|
||||
if (!session || !current) return false;
|
||||
if (session.refreshTokenId && current.refreshTokenId) return session.refreshTokenId === current.refreshTokenId;
|
||||
return current.accessTokenExpiresAt !== null && session.expiresAt === current.accessTokenExpiresAt;
|
||||
}
|
||||
|
||||
/** Out of every Vest tab's pageVestSession, the session to relay: the
|
||||
* current one, else — no tab holds it (one renewed before its hook was in)
|
||||
* — the latest to expire, which still reads. `current` is null when no tab
|
||||
* shows the app's session record to judge by. */
|
||||
function pickVestSession(pages) {
|
||||
const live = pages.filter((p) => p && p.session);
|
||||
if (live.length === 0) return null;
|
||||
const current = live.find((p) => isCurrentVestSession(p.session, p.current));
|
||||
if (current) return { session: current.session, current: true };
|
||||
const newest = live.reduce((x, y) => (y.session.expiresAt > x.session.expiresAt ? y : x));
|
||||
return { session: newest.session, current: pages.some((p) => p && p.current) ? false : null };
|
||||
}
|
||||
|
||||
/** The wallets the dashboard's Vest firms trade; [] from an older dashboard. */
|
||||
async function fetchVestWanted(cfg) {
|
||||
try {
|
||||
const res = await fetch(`${cfg.apiBase}/api/vest/session`, { cache: 'no-store' });
|
||||
if (!res.ok) return [];
|
||||
const body = await res.json();
|
||||
return Array.isArray(body.wanted) ? body.wanted : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function relayVestSession(cfg) {
|
||||
const now = Date.now();
|
||||
if (now - vestLastCheck < VEST_EVERY_MS) return;
|
||||
vestLastCheck = now;
|
||||
|
||||
const tabs = (await chrome.tabs.query({ url: VEST_PATTERN })).filter((t) => /^https:/.test(t.url || ''));
|
||||
if (tabs.length === 0) {
|
||||
// This machine trades Vest: open the app in the background. It signs
|
||||
// itself back in from its 30-day session, and the hook catches that.
|
||||
const wanted = await fetchVestWanted(cfg);
|
||||
if (wanted.length === 0) return;
|
||||
if (now - vestOpenedAt > VEST_REOPEN_MS) {
|
||||
vestOpenedAt = now;
|
||||
await chrome.tabs.create({ url: VEST_URL, active: false });
|
||||
await setState({ vest: 'Opened a Vest tab to relay its session' });
|
||||
} else {
|
||||
await setState({ vest: 'No Vest tab open' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Every tab: the current session may be in any of them.
|
||||
const pages = [];
|
||||
for (const tab of tabs) {
|
||||
// A discarded tab stops renewing the session; keep Chrome from it.
|
||||
if (tab.autoDiscardable !== false) chrome.tabs.update(tab.id, { autoDiscardable: false }).catch(() => {});
|
||||
const [injection] = await chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id }, world: 'MAIN', func: pageVestSession,
|
||||
}).catch(() => [null]);
|
||||
const out = injection?.result;
|
||||
if (out && !out.hooked) {
|
||||
// A tab open from before this build: hook it now, and its app's
|
||||
// next renewal is caught.
|
||||
await chrome.scripting.executeScript({ target: { tabId: tab.id }, world: 'MAIN', files: ['vest-relay.js'] }).catch(() => {});
|
||||
}
|
||||
if (out) pages.push(out);
|
||||
}
|
||||
const picked = pickVestSession(pages);
|
||||
if (!picked) {
|
||||
await setState({ vest: 'Vest tab open — waiting for its session (sign in there if it shows Log In)' });
|
||||
return;
|
||||
}
|
||||
const found = picked.session;
|
||||
|
||||
const fingerprint = `${found.expiresAt}:${found.accessToken.length}:${found.accessToken.slice(-16)}`;
|
||||
if (fingerprint === vestLastRelayed && now - vestLastPostAt < VEST_REPOST_MS) return;
|
||||
const res = await fetch(`${cfg.apiBase}/api/vest/session`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
accessToken: found.accessToken, expiresAt: found.expiresAt, primaryAddr: found.primaryAddr, userId: found.userId,
|
||||
}),
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (res.status === 404) throw new Error('this dashboard is too old for the Vest relay');
|
||||
if (!res.ok) throw new Error(`dashboard refused the session: ${body.reason || body.error || `HTTP ${res.status}`}`);
|
||||
vestLastRelayed = fingerprint;
|
||||
vestLastPostAt = now;
|
||||
const short = `${found.primaryAddr.slice(0, 6)}…${found.primaryAddr.slice(-4)}`;
|
||||
const mins = Math.max(0, Math.round((found.expiresAt - Date.now()) / 60000));
|
||||
const note = Array.isArray(body.bound) && body.bound.length > 0 ? ` · bound to ${body.bound.join(', ')}`
|
||||
: body.used ? '' : ' (no Vest firm here uses this wallet)';
|
||||
// A tab that renewed before its hook was in holds the current session
|
||||
// unseen: until it renews again, orders and history reads can fail —
|
||||
// reloading it hooks it.
|
||||
const tabsNote = picked.current === false
|
||||
? (tabs.length === 1 ? " · its token isn't Vest's current session — reload the Vest tab"
|
||||
: ` · ${tabs.length} Vest tabs and none holds the current session — reload them, or keep one open`)
|
||||
: tabs.length > 1 ? ` · ${tabs.length} Vest tabs${picked.current ? ', relaying the current one' : ''}` : '';
|
||||
await setState({ vest: `Relayed ${short}${note} · good ${mins}m${tabsNote}` });
|
||||
}
|
||||
|
||||
// ── 2FA: the person's authenticator code for the firm site you are on ───────
|
||||
//
|
||||
// The master keeps the authenticator secrets, each assigned to a firm (whose
|
||||
// site link it matches against) and a person. The dashboard knows which
|
||||
// person this machine is, so the extension only sends the tab's host and gets
|
||||
// back that person's live codes for the site — shown in a small chip in the
|
||||
// corner with a Copy button.
|
||||
|
||||
/** Refetch at least this often, so a newly assigned code turns up. */
|
||||
const OTP_REFRESH_MS = 30_000;
|
||||
/** No chip for this long after the runner last located something: its clicks
|
||||
* land on screen coordinates, and a chip there would take them. */
|
||||
const OTP_QUIET_AFTER_LOCATE_MS = 60_000;
|
||||
|
||||
let otpCache = null; // { host, codes, fetchedAt }
|
||||
let lastLocateAt = 0;
|
||||
const chipTabs = new Set(); // tabs we have drawn a chip in
|
||||
|
||||
/** This person's codes for a host, with expiresAt moved onto this clock. An
|
||||
* unreachable dashboard returns nothing and retries next tick; any answer —
|
||||
* including an old dashboard's 404 — is cached until a code expires. */
|
||||
async function fetchOtpCodes(cfg, host) {
|
||||
const now = Date.now();
|
||||
if (otpCache && otpCache.host === host && now - otpCache.fetchedAt < OTP_REFRESH_MS
|
||||
&& otpCache.codes.every((c) => c.expiresAt > now)) {
|
||||
return otpCache.codes;
|
||||
}
|
||||
|
||||
let codes = [];
|
||||
try {
|
||||
const res = await fetch(`${cfg.apiBase}/api/autobuyer/otp?host=${encodeURIComponent(host)}`, { cache: 'no-store' });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
// The master's clock made the codes; a countdown on ours needs the gap.
|
||||
const drift = (Number(data.serverTime) || now) - now;
|
||||
codes = (Array.isArray(data.codes) ? data.codes : [])
|
||||
.filter((c) => c && typeof c.code === 'string')
|
||||
.map((c) => ({
|
||||
label: String(c.label || ''),
|
||||
firmName: c.firmName ? String(c.firmName) : '',
|
||||
code: c.code,
|
||||
period: Number(c.period) || 30,
|
||||
expiresAt: Number(c.expiresAt) - drift,
|
||||
}));
|
||||
}
|
||||
} catch {
|
||||
return otpCache && otpCache.host === host ? otpCache.codes.filter((c) => c.expiresAt > now) : [];
|
||||
}
|
||||
otpCache = { host, codes, fetchedAt: now };
|
||||
return codes;
|
||||
}
|
||||
|
||||
async function maybeShowOtp(cfg) {
|
||||
const tab = await pickTab();
|
||||
if (!tab) return;
|
||||
if (Date.now() - lastLocateAt < OTP_QUIET_AFTER_LOCATE_MS) return;
|
||||
|
||||
let host;
|
||||
try { host = new URL(tab.url).hostname; } catch { return; }
|
||||
|
||||
const codes = await fetchOtpCodes(cfg, host);
|
||||
// An empty list is still drawn once where a chip was, which removes it.
|
||||
if (!codes.length && !chipTabs.has(tab.id)) return;
|
||||
await chrome.scripting.executeScript({ target: { tabId: tab.id }, func: pageOtpChip, args: [codes] });
|
||||
if (codes.length) chipTabs.add(tab.id); else chipTabs.delete(tab.id);
|
||||
}
|
||||
|
||||
/** Take the chip down before the runner measures or clicks anything there. */
|
||||
async function hideOtpChip(tabId) {
|
||||
lastLocateAt = Date.now();
|
||||
if (!chipTabs.has(tabId)) return;
|
||||
chipTabs.delete(tabId);
|
||||
try {
|
||||
await chrome.scripting.executeScript({ target: { tabId }, func: pageOtpChip, args: [[]] });
|
||||
} catch {
|
||||
// tab gone or navigated away — nothing left to hide
|
||||
}
|
||||
}
|
||||
|
||||
chrome.tabs.onRemoved.addListener((tabId) => chipTabs.delete(tabId));
|
||||
|
||||
/** Draws, updates or (with no codes) removes the 2FA chip. Runs in the
|
||||
* extension's isolated world and must be self-contained. The chip lives in a
|
||||
* shadow root so the site's CSS can't reach it, and counts down on its own
|
||||
* between the worker's updates. */
|
||||
function pageOtpChip(codes) {
|
||||
const ID = 'autofirmer-otp';
|
||||
const COLLAPSED_KEY = 'autofirmer-otp-collapsed';
|
||||
let host = document.getElementById(ID);
|
||||
|
||||
if (!codes || !codes.length) {
|
||||
if (host) host.remove();
|
||||
return;
|
||||
}
|
||||
// A chip drawn by an older build has no updater; start over.
|
||||
if (host && typeof host.__afOtp !== 'function') {
|
||||
host.remove();
|
||||
host = null;
|
||||
}
|
||||
if (host) {
|
||||
host.__afOtp(codes);
|
||||
return;
|
||||
}
|
||||
|
||||
host = document.createElement('div');
|
||||
host.id = ID;
|
||||
host.style.cssText = 'all: initial; position: fixed; right: 16px; bottom: 16px; z-index: 2147483647;';
|
||||
const root = host.attachShadow({ mode: 'open' });
|
||||
root.innerHTML = `
|
||||
<style>
|
||||
:host { all: initial; }
|
||||
* { box-sizing: border-box; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
|
||||
.chip { width: 260px; background: #fff; color: #0f172a; border: 1px solid #e2e8f0; border-radius: 12px;
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, .18); overflow: hidden; }
|
||||
.head { display: flex; align-items: center; justify-content: space-between; padding: 8px 10px 6px 12px;
|
||||
font-size: 11px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: #64748b; }
|
||||
.icon { border: 0; background: none; color: #94a3b8; font-size: 16px; line-height: 1; cursor: pointer; padding: 2px 4px; border-radius: 4px; }
|
||||
.icon:hover { color: #334155; background: #f1f5f9; }
|
||||
.row { padding: 6px 12px 10px; border-top: 1px solid #f1f5f9; }
|
||||
.row:first-child { border-top: 0; }
|
||||
.label { font-size: 12px; font-weight: 600; color: #334155; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.line { display: flex; align-items: center; gap: 8px; margin-top: 2px; }
|
||||
.code { font-size: 24px; font-weight: 700; letter-spacing: .12em; font-variant-numeric: tabular-nums; }
|
||||
.secs { font-size: 11px; font-weight: 600; color: #94a3b8; font-variant-numeric: tabular-nums; margin-left: auto; }
|
||||
.soon { color: #dc2626; }
|
||||
.copy { border: 1px solid #e2e8f0; background: #fff; color: #475569; font-size: 12px; font-weight: 600;
|
||||
padding: 5px 10px; border-radius: 8px; cursor: pointer; }
|
||||
.copy:hover { background: #f1f5f9; }
|
||||
.copy.done { border-color: #86efac; color: #15803d; background: #f0fdf4; }
|
||||
.bar { height: 3px; background: #f1f5f9; border-radius: 2px; margin-top: 6px; overflow: hidden; }
|
||||
.fill { height: 100%; background: #22c55e; }
|
||||
.fill.soon { background: #ef4444; }
|
||||
.pill { border: 1px solid #e2e8f0; background: #fff; color: #0f172a; font-size: 12px; font-weight: 700;
|
||||
letter-spacing: .06em; padding: 7px 12px; border-radius: 999px; cursor: pointer;
|
||||
box-shadow: 0 6px 18px rgba(15, 23, 42, .16); }
|
||||
.pill:hover { background: #f8fafc; }
|
||||
</style>
|
||||
<div class="chip">
|
||||
<div class="head"><span>2FA code</span><button class="icon" title="Minimise">–</button></div>
|
||||
<div class="rows"></div>
|
||||
</div>
|
||||
<button class="pill" title="Show the 2FA code">2FA</button>`;
|
||||
|
||||
const chip = root.querySelector('.chip');
|
||||
const pill = root.querySelector('.pill');
|
||||
const rowsEl = root.querySelector('.rows');
|
||||
let rows = [];
|
||||
let signature = '';
|
||||
|
||||
const collapsed = () => { try { return sessionStorage.getItem(COLLAPSED_KEY) === '1'; } catch { return false; } };
|
||||
const setCollapsed = (on) => {
|
||||
try { sessionStorage.setItem(COLLAPSED_KEY, on ? '1' : '0'); } catch { /* storage blocked */ }
|
||||
chip.style.display = on ? 'none' : '';
|
||||
pill.style.display = on ? '' : 'none';
|
||||
};
|
||||
root.querySelector('.icon').addEventListener('click', () => setCollapsed(true));
|
||||
pill.addEventListener('click', () => setCollapsed(false));
|
||||
setCollapsed(collapsed());
|
||||
|
||||
function flash(button, text, done) {
|
||||
button.textContent = text;
|
||||
button.classList.toggle('done', done);
|
||||
setTimeout(() => { button.textContent = 'Copy'; button.classList.remove('done'); }, 1500);
|
||||
}
|
||||
|
||||
// execCommand first: it is synchronous inside the click, needs no
|
||||
// clipboard permission, and never stalls on a prompt the way the async
|
||||
// Clipboard API can. Focus goes back to whatever had it (the code field).
|
||||
function copy(text, button) {
|
||||
const had = document.activeElement;
|
||||
const area = document.createElement('textarea');
|
||||
area.value = text;
|
||||
area.setAttribute('readonly', '');
|
||||
area.style.cssText = 'position: fixed; top: 0; left: 0; opacity: 0;';
|
||||
root.appendChild(area);
|
||||
area.select();
|
||||
let ok = false;
|
||||
try { ok = document.execCommand('copy'); } catch { /* fall through */ }
|
||||
area.remove();
|
||||
if (had && had !== document.body && typeof had.focus === 'function') had.focus();
|
||||
if (ok) return flash(button, 'Copied', true);
|
||||
if (!navigator.clipboard) return flash(button, 'Copy failed', false);
|
||||
navigator.clipboard.writeText(text).then(
|
||||
() => flash(button, 'Copied', true),
|
||||
() => flash(button, 'Copy failed', false),
|
||||
);
|
||||
}
|
||||
|
||||
function build(list) {
|
||||
rowsEl.textContent = '';
|
||||
rows = list.map((c) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'row';
|
||||
const label = document.createElement('div');
|
||||
label.className = 'label';
|
||||
label.textContent = [c.firmName, c.label].filter(Boolean).join(' · ');
|
||||
label.title = label.textContent;
|
||||
const line = document.createElement('div');
|
||||
line.className = 'line';
|
||||
const code = document.createElement('span');
|
||||
code.className = 'code';
|
||||
const secs = document.createElement('span');
|
||||
secs.className = 'secs';
|
||||
const button = document.createElement('button');
|
||||
button.className = 'copy';
|
||||
button.textContent = 'Copy';
|
||||
// Keep focus where it is (the site's code field), so ⌘V follows.
|
||||
button.addEventListener('mousedown', (e) => e.preventDefault());
|
||||
button.addEventListener('click', () => copy(c.code, button));
|
||||
line.append(code, secs, button);
|
||||
const bar = document.createElement('div');
|
||||
bar.className = 'bar';
|
||||
const fill = document.createElement('div');
|
||||
fill.className = 'fill';
|
||||
bar.appendChild(fill);
|
||||
row.append(label, line, bar);
|
||||
rowsEl.appendChild(row);
|
||||
return { c, code, secs, fill, button };
|
||||
});
|
||||
}
|
||||
|
||||
function paint() {
|
||||
const now = Date.now();
|
||||
for (const r of rows) {
|
||||
const msLeft = r.c.expiresAt - now;
|
||||
const left = Math.max(0, Math.ceil(msLeft / 1000));
|
||||
const soon = left <= 5;
|
||||
// Expired: blank it until the worker brings the next one (seconds).
|
||||
const half = Math.ceil(r.c.code.length / 2);
|
||||
r.code.textContent = left > 0 ? `${r.c.code.slice(0, half)} ${r.c.code.slice(half)}` : '… …';
|
||||
r.code.classList.toggle('soon', soon && left > 0);
|
||||
r.secs.textContent = left > 0 ? `${left}s` : '';
|
||||
r.secs.classList.toggle('soon', soon);
|
||||
r.fill.style.width = `${Math.max(0, Math.min(1, msLeft / (r.c.period * 1000))) * 100}%`;
|
||||
r.fill.classList.toggle('soon', soon);
|
||||
r.button.disabled = left === 0;
|
||||
}
|
||||
}
|
||||
|
||||
host.__afOtp = (list) => {
|
||||
const next = list.map((c) => `${c.firmName}|${c.label}|${c.code}`).join('\n');
|
||||
if (next !== signature) {
|
||||
signature = next;
|
||||
build(list);
|
||||
} else {
|
||||
rows.forEach((r, i) => { r.c = list[i]; });
|
||||
}
|
||||
paint();
|
||||
};
|
||||
|
||||
const timer = setInterval(() => {
|
||||
if (!host.isConnected) { clearInterval(timer); return; }
|
||||
paint();
|
||||
}, 250);
|
||||
|
||||
(document.body || document.documentElement).appendChild(host);
|
||||
host.__afOtp(codes);
|
||||
}
|
||||
|
||||
// ── Keeping ourselves current ───────────────────────────────────────────────
|
||||
|
||||
+10
-2
@@ -1,17 +1,19 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "AutoFirmer Capture",
|
||||
"version": "0.15.0",
|
||||
"version": "0.18.0",
|
||||
|
||||
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxdbOMRR+58a9dHlbIKtlALtS3E4eltBGqfTnUc0lzJuWj5ZA4+5YFSdUnxv5N1ogxwpJUGkUV3MnKJuZnTekCmsNwjCy41a20Xtc7jQLHrIBZkesB6cB+d0l9Vo8M6EC3ZMD8Iv6MdAalU3GZh1Hn9vm24NjJ+xUT1qNIVHEcNUIb9evYobR/XHv+JAp6bOFnaLM+3wpyYKe4efMhsz0F86u9thRNhQnraTqUWfnKNpsHPK2zivaYHrziEEWX7Y3EsMHkf2FRhkUe7IDoxeJM49DBToTo9sAJUkcgLeGCGZLo7H3mcxyn/zERYR+rD3k5xdeBtjoZHXFx2+FanLb9wIDAQAB",
|
||||
"update_url": "https://ext.juicerroom.com/updates.xml",
|
||||
|
||||
"description": "Scrapes the HTML of the firm tab you are viewing and posts it to the AutoFirmer dashboard.",
|
||||
"description": "Scrapes the HTML of the firm tab you are viewing and posts it to the AutoFirmer dashboard, shows the 2FA code for the firm site you are on, and relays a signed-in Vest tab's session to the dashboard.",
|
||||
"permissions": ["scripting", "tabs", "storage", "alarms"],
|
||||
"host_permissions": [
|
||||
"https://*.tradeify.co/*",
|
||||
"https://*.lucidtrading.com/*",
|
||||
"https://*.topstep.com/*",
|
||||
"https://*.apextraderfunding.com/*",
|
||||
"https://*.vestmarkets.com/*",
|
||||
"http://localhost/*",
|
||||
"http://127.0.0.1/*"
|
||||
],
|
||||
@@ -28,6 +30,12 @@
|
||||
"run_at": "document_start",
|
||||
"all_frames": true,
|
||||
"world": "MAIN"
|
||||
},
|
||||
{
|
||||
"matches": ["https://next.vestmarkets.com/*"],
|
||||
"js": ["vest-relay.js"],
|
||||
"run_at": "document_start",
|
||||
"world": "MAIN"
|
||||
}
|
||||
],
|
||||
"action": {
|
||||
|
||||
@@ -29,6 +29,7 @@ async function render() {
|
||||
document.getElementById('status').innerHTML =
|
||||
`<span class="dot" style="background:${color}"></span><strong>${label}</strong>` +
|
||||
detail +
|
||||
(state.vest ? `<div class="muted">Vest: ${esc(state.vest)}</div>` : '') +
|
||||
(state.error ? `<div class="err">${esc(state.error)}</div>` : '');
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/* AutoFirmer — Vest session hook.
|
||||
*
|
||||
* Runs in the page's MAIN world on next.vestmarkets.com, at document_start
|
||||
* (manifest content script) or injected later into a tab that was already open
|
||||
* (background.js relayVestSession). Installs once per page.
|
||||
*
|
||||
* Vest's app renews its own ~15-minute access token by itself: at load, then
|
||||
* whenever one nears expiry (POST /v3/auth/refresh, or /v3/auth/register at
|
||||
* sign-in). Those answers carry the token, its expiry, the login's wallet and
|
||||
* the session it belongs to (refreshTokenId). This hook reads them off the
|
||||
* app's own XHRs and keeps the latest on `window.__afVestLogin`, where
|
||||
* background.js collects it and relays it to the local dashboard, whose Vest
|
||||
* client trades with it.
|
||||
*
|
||||
* Every tab renews on its own, and each renewal moves the login on to a new
|
||||
* session (localStorage `vest-auth-session`): a token from before still reads,
|
||||
* but Vest refuses to mint an account's token with it (401, code 1002). The
|
||||
* session id lets background.js relay the tab whose token is the current one.
|
||||
*
|
||||
* It sends nothing anywhere, changes no request and never renews anything
|
||||
* itself: the session stays the app's, refreshed by the app with its own
|
||||
* device key, so the tab is never logged out by the bot.
|
||||
*
|
||||
* The app also renews each trading account's own token through the same
|
||||
* /v3/auth/refresh, sending that account's refresh token in the body (the
|
||||
* login's rides in a cookie) and getting it back renewed. Those tokens see
|
||||
* one account only, so they are skipped: relaying one would leave the
|
||||
* dashboard with a single account. A sign-in's answer carries the login's
|
||||
* own refresh token, and is taken.
|
||||
*
|
||||
* Version 2. A page hooked by version 1 (`__afVestHook === true`, keeping
|
||||
* `__afVestSession`, no session id, no sign-ins) takes this one beside it.
|
||||
*/
|
||||
|
||||
(() => {
|
||||
if (window.__afVestHook === 2) return;
|
||||
window.__afVestHook = 2;
|
||||
|
||||
const AUTH = /^https:\/\/api-gateway\.hz\.vestmarkets\.com\/v3\/auth\/(refresh|register)(\?|$)/;
|
||||
const SIGN_IN = /\/v3\/auth\/register(\?|$)/;
|
||||
|
||||
/** A request body that carries a refresh token: an account's renewal. */
|
||||
function accountRenewal(reqBody) {
|
||||
return typeof reqBody === 'string' && /"refreshToken"\s*:\s*"[^"]/.test(reqBody);
|
||||
}
|
||||
|
||||
function take(url, status, text, accountRequest) {
|
||||
if (accountRequest || status < 200 || status >= 300 || !AUTH.test(url)) return;
|
||||
let body;
|
||||
try { body = JSON.parse(text); } catch { return; }
|
||||
if (!body || typeof body.accessToken !== 'string' || !body.accessToken) return;
|
||||
// A renewal answering with a refresh token is an account's.
|
||||
if (!SIGN_IN.test(url) && typeof body.refreshToken === 'string' && body.refreshToken) return;
|
||||
const expiresAt = Number(body.accessExpiresAtMs);
|
||||
if (!Number.isFinite(expiresAt)) return;
|
||||
window.__afVestLogin = {
|
||||
accessToken: body.accessToken,
|
||||
expiresAt,
|
||||
primaryAddr: typeof body.primaryAddr === 'string' ? body.primaryAddr : '',
|
||||
userId: typeof body.userId === 'string' ? body.userId : '',
|
||||
refreshTokenId: typeof body.refreshTokenId === 'string' && body.refreshTokenId ? body.refreshTokenId : null,
|
||||
at: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
const open = XMLHttpRequest.prototype.open;
|
||||
const send = XMLHttpRequest.prototype.send;
|
||||
XMLHttpRequest.prototype.open = function (method, url) {
|
||||
try { this.__afVestUrl = String(url); } catch { /* ignore */ }
|
||||
return open.apply(this, arguments);
|
||||
};
|
||||
XMLHttpRequest.prototype.send = function (reqBody) {
|
||||
if (this.__afVestUrl && AUTH.test(this.__afVestUrl)) {
|
||||
let acct = false;
|
||||
try { acct = accountRenewal(reqBody); } catch { /* ignore */ }
|
||||
this.addEventListener('loadend', () => {
|
||||
try {
|
||||
if (this.responseType === '' || this.responseType === 'text') take(this.__afVestUrl, this.status, this.responseText, acct);
|
||||
else if (this.responseType === 'json') take(this.__afVestUrl, this.status, JSON.stringify(this.response), acct);
|
||||
} catch { /* never break the app */ }
|
||||
});
|
||||
}
|
||||
return send.apply(this, arguments);
|
||||
};
|
||||
|
||||
const fetch0 = window.fetch;
|
||||
window.fetch = async function (input, init) {
|
||||
const res = await fetch0.apply(this, arguments);
|
||||
try {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input && input.url;
|
||||
const acct = accountRenewal(init && init.body);
|
||||
if (url && AUTH.test(url)) res.clone().text().then((t) => take(url, res.status, t, acct)).catch(() => {});
|
||||
} catch { /* never break the app */ }
|
||||
return res;
|
||||
};
|
||||
})();
|
||||
+165
-19
@@ -9,31 +9,58 @@
|
||||
*
|
||||
* An Unassigned account does not trade: every caller already guards on a
|
||||
* missing config, which is the behaviour we want.
|
||||
*
|
||||
* Which of its type's rule sets an account trades on is the other half (see
|
||||
* lib/kept-rules.ts): each assignment row carries a rules date, and an
|
||||
* account dated inside one of the type's kept rule sets trades that set's
|
||||
* rules — getAccountConfig applies it, so every trading read sees it.
|
||||
*/
|
||||
|
||||
import type { CatalogAccountType } from './master-protocol';
|
||||
import type { CatalogAccountType, KeptAccountRules } from './master-protocol';
|
||||
import type { PlatformClient } from './platform-client';
|
||||
import { firstFundDate } from './platform-client';
|
||||
import {
|
||||
getAssignment,
|
||||
setAssignment,
|
||||
setAutoAssignment,
|
||||
clearAssignment,
|
||||
unpinAssignment,
|
||||
lowerAutoRulesDate,
|
||||
stampMissingRulesDate,
|
||||
listAssignments,
|
||||
loadAccountMeta,
|
||||
saveAccountMeta,
|
||||
} from './db';
|
||||
import { getAccountTypeById, getCachedFirm } from './firm-catalog';
|
||||
import { getAccountTypeById, getCachedFirm, getCatalogUpdatedAt, getKeptRulesFor, getMasterOffsetMs } from './firm-catalog';
|
||||
import {
|
||||
identityStillMatches, lowersRulesDate, openingEvidence, overlayKept, pickKeptRules, stampRulesDate,
|
||||
type EffectiveAccountType, type KeptRulesWindow,
|
||||
} from './kept-rules';
|
||||
|
||||
const INFERRED_SIZE_KEY = 'inferred_account_size';
|
||||
|
||||
/** Accounts this process has run the evidence step on with their fund
|
||||
* history loaded (see ensureRulesEvidence). On `global` like the clients,
|
||||
* so the trade path and the resolver share it whichever bundle loads them. */
|
||||
const ge = global as typeof globalThis & { __rulesEvidenceApplied?: Set<number> };
|
||||
function evidenceApplied(): Set<number> {
|
||||
return (ge.__rulesEvidenceApplied ??= new Set());
|
||||
}
|
||||
|
||||
export type AssignmentSource = 'manual' | 'auto' | 'unassigned';
|
||||
|
||||
export interface ResolvedAssignment {
|
||||
/** Always the live account type's id — a kept rule set's never is one. */
|
||||
accountTypeId: string | null;
|
||||
accountType: CatalogAccountType | null;
|
||||
/** The rules the account trades on: its live type, or the live type with
|
||||
* a kept rule set's rules on top (then keptRules says which). */
|
||||
accountType: EffectiveAccountType | null;
|
||||
source: AssignmentSource;
|
||||
/** True when more than one account type matched and the tie-break decided. */
|
||||
ambiguous: boolean;
|
||||
/** The account's rules date (ISO); null = none recorded, current rules. */
|
||||
rulesDate: string | null;
|
||||
/** The kept rule set the account trades on; null = its type's current rules. */
|
||||
keptRules: KeptRulesWindow | null;
|
||||
}
|
||||
|
||||
const UNASSIGNED: ResolvedAssignment = {
|
||||
@@ -41,8 +68,23 @@ const UNASSIGNED: ResolvedAssignment = {
|
||||
accountType: null,
|
||||
source: 'unassigned',
|
||||
ambiguous: false,
|
||||
rulesDate: null,
|
||||
keptRules: null,
|
||||
};
|
||||
|
||||
/** One overlay per kept set and live type, so a tick's many lookups share one
|
||||
* object the way they share the live type. Both come from the same catalog
|
||||
* index, so a sync replaces them together. */
|
||||
const overlays = new WeakMap<KeptAccountRules, { live: CatalogAccountType; effective: EffectiveAccountType }>();
|
||||
|
||||
function overlaid(live: CatalogAccountType, kept: KeptAccountRules): EffectiveAccountType {
|
||||
const hit = overlays.get(kept);
|
||||
if (hit && hit.live === live) return hit.effective;
|
||||
const effective = overlayKept(live, kept);
|
||||
overlays.set(kept, { live, effective });
|
||||
return effective;
|
||||
}
|
||||
|
||||
/** The earliest positive fund transaction — the account's opening balance.
|
||||
* Returns null while the reports fetch hasn't produced one yet, which is the
|
||||
* "wait until it is findable" case rather than an error. */
|
||||
@@ -93,13 +135,20 @@ export function matchAccountType(
|
||||
}
|
||||
|
||||
/** THE account→config lookup. Reads the assignment table; never writes.
|
||||
* Replaces the longest-prefix helper that used to be copied into five files. */
|
||||
* Replaces the longest-prefix helper that used to be copied into five files.
|
||||
* Every trading read of an account's rules comes through here, so this is
|
||||
* where an account dated inside a kept rule set gets that set's rules —
|
||||
* manual pins and auto assignments alike. One row read, the rest from the
|
||||
* in-memory catalog: it runs per account per tick. */
|
||||
export function getAccountConfig(accountId: number): ResolvedAssignment {
|
||||
const row = getAssignment(accountId);
|
||||
if (!row?.account_type_id) return UNASSIGNED;
|
||||
if (!row) return UNASSIGNED;
|
||||
// `?? null`: a row read before the rules-date migration ran has no column.
|
||||
const rulesDate = row.rules_date ?? null;
|
||||
if (!row.account_type_id) return { ...UNASSIGNED, rulesDate };
|
||||
|
||||
const accountType = getAccountTypeById(row.account_type_id);
|
||||
if (!accountType) {
|
||||
const live = getAccountTypeById(row.account_type_id);
|
||||
if (!live) {
|
||||
// The account type was deleted on the master. A manual pin is kept so
|
||||
// that a master flap which recreates it restores the user's intent, but
|
||||
// it reports as unassigned until then, so the account stops trading.
|
||||
@@ -108,14 +157,20 @@ export function getAccountConfig(accountId: number): ResolvedAssignment {
|
||||
accountType: null,
|
||||
source: 'unassigned',
|
||||
ambiguous: false,
|
||||
rulesDate,
|
||||
keptRules: null,
|
||||
};
|
||||
}
|
||||
|
||||
const kept = pickKeptRules(getKeptRulesFor(live.id), rulesDate);
|
||||
const accountType: EffectiveAccountType = kept ? overlaid(live, kept) : live;
|
||||
return {
|
||||
accountTypeId: accountType.id,
|
||||
accountTypeId: live.id,
|
||||
accountType,
|
||||
source: row.manual === 1 ? 'manual' : 'auto',
|
||||
ambiguous: false,
|
||||
rulesDate,
|
||||
keptRules: accountType.keptRules ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -130,28 +185,106 @@ export function getInferredSize(accountId: number, client: PlatformClient): numb
|
||||
return inferAccountSize(client.fundTransactions?.[accountId] ?? []);
|
||||
}
|
||||
|
||||
/** Re-resolves every account on one firm's client. Skips manual overrides.
|
||||
* Called after each Tradovate sync and after the catalog changes. */
|
||||
/** The rules date a row first written now gets — on the master's clock when
|
||||
* this one runs behind, and never before the catalog's last change. */
|
||||
function freshRulesDate(): string {
|
||||
return stampRulesDate(Date.now(), getMasterOffsetMs(), getCatalogUpdatedAt());
|
||||
}
|
||||
|
||||
/** The broker's evidence of when the account opened (the report's
|
||||
* firstFundDate, read as lib/kept-rules.ts openingEvidence says); undefined
|
||||
* until its fund history has loaded, null when that history doesn't say. */
|
||||
function brokerEvidence(client: PlatformClient, accountId: number): string | null | undefined {
|
||||
const funds = client.fundTransactions?.[accountId];
|
||||
if (!funds) return undefined;
|
||||
return openingEvidence(firstFundDate(funds, undefined), client.brokerAccountMeta?.(accountId)?.registrationTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* The evidence step for one account, for the trade path: once its fund
|
||||
* history has loaded, lower an auto rules date to the broker's evidence
|
||||
* before anything reads the account's rules. The resolver does the same on
|
||||
* every pass, but only after a sync — and history lands between syncs, when
|
||||
* an account turns tradable (historyReady needs the fund rows loaded, on
|
||||
* every platform) and could trade on its first-seen date for up to a sync.
|
||||
* Once per account per process; a no-op until the history has loaded and the
|
||||
* account has an assignment row.
|
||||
*/
|
||||
export function ensureRulesEvidence(client: PlatformClient, accountId: number): void {
|
||||
if (evidenceApplied().has(accountId)) return;
|
||||
const evidence = brokerEvidence(client, accountId);
|
||||
if (evidence === undefined) return;
|
||||
const row = getAssignment(accountId);
|
||||
if (!row) return;
|
||||
if (evidence && lowersRulesDate(row, evidence)) lowerAutoRulesDate(accountId, evidence);
|
||||
evidenceApplied().add(accountId);
|
||||
}
|
||||
|
||||
/** Re-resolves every account on one firm's client. Skips manual overrides,
|
||||
* and auto ones on a kept rule set (see below). Lowers rules dates to the
|
||||
* broker's evidence on the way. Called after each sync and after the
|
||||
* catalog changes. */
|
||||
export function resolveFirmAssignments(
|
||||
firmId: string,
|
||||
client: PlatformClient,
|
||||
): { assigned: number; unassigned: number; manual: number } {
|
||||
const firm = getCachedFirm(firmId);
|
||||
const types = firm?.accountTypes ?? [];
|
||||
const stamp = freshRulesDate();
|
||||
|
||||
let assigned = 0;
|
||||
let unassigned = 0;
|
||||
let manual = 0;
|
||||
|
||||
for (const account of client.accountList) {
|
||||
if (getAssignment(account.id)?.manual === 1) {
|
||||
let row = getAssignment(account.id);
|
||||
// A row an older build wrote without a rules date (between a build's
|
||||
// migration and its restart, or after a rollback): dated now, as the
|
||||
// migration dates rows, before the evidence below can lower it.
|
||||
if (row && !row.rules_date) {
|
||||
stampMissingRulesDate(account.id, stamp);
|
||||
row = { ...row, rules_date: stamp };
|
||||
}
|
||||
|
||||
// Evidence next, for every account, pinned or not: once its fund
|
||||
// history has loaded, the broker shows when it opened (the report's
|
||||
// firstFundDate), and an auto rules date later than that comes down
|
||||
// to it. Ahead of the kept-set check below, which reads the date.
|
||||
const loadedFunds = client.fundTransactions?.[account.id];
|
||||
const evidence = brokerEvidence(client, account.id) ?? null;
|
||||
if (row && evidence && lowersRulesDate(row, evidence)) {
|
||||
lowerAutoRulesDate(account.id, evidence);
|
||||
row = { ...row, rules_date: evidence };
|
||||
}
|
||||
if (row && loadedFunds) evidenceApplied().add(account.id);
|
||||
|
||||
if (row?.manual === 1) {
|
||||
manual++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const fundTxns = client.fundTransactions?.[account.id] ?? [];
|
||||
const fundTxns = loadedFunds ?? [];
|
||||
const cached = loadAccountMeta(account.id, INFERRED_SIZE_KEY);
|
||||
|
||||
// Sticky: an account on one of its type's kept rule sets stays bound
|
||||
// to that type, so a tie-break flip to another matching type can never
|
||||
// move it off its old rules. Only while it still matches the type's
|
||||
// prefix and size, though: a kept set takes both from the live type,
|
||||
// so after a size edit it would trade on a size it doesn't have.
|
||||
// Unstuck, it re-matches as it did before kept rules existed —
|
||||
// usually to Unassigned, which stops it trading.
|
||||
if (row?.account_type_id) {
|
||||
const live = getAccountTypeById(row.account_type_id);
|
||||
if (
|
||||
live && live.firmId === firmId
|
||||
&& identityStillMatches(account.name, cached, live)
|
||||
&& pickKeptRules(getKeptRulesFor(live.id), row.rules_date ?? null)
|
||||
) {
|
||||
assigned++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let matched: { type: CatalogAccountType | null; ambiguous: boolean } = { type: null, ambiguous: false };
|
||||
let matchedSize: number | null = null;
|
||||
|
||||
@@ -194,24 +327,37 @@ export function resolveFirmAssignments(
|
||||
if (matchedSize !== null && cached === null) {
|
||||
saveAccountMeta(account.id, INFERRED_SIZE_KEY, String(matchedSize));
|
||||
}
|
||||
setAutoAssignment(account.id, firmId, matched.type.id);
|
||||
setAutoAssignment(account.id, firmId, matched.type.id, stamp);
|
||||
assigned++;
|
||||
} else {
|
||||
setAutoAssignment(account.id, firmId, null);
|
||||
setAutoAssignment(account.id, firmId, null, stamp);
|
||||
unassigned++;
|
||||
}
|
||||
// A row this pass created is dated "first seen now"; the evidence may
|
||||
// already date it earlier.
|
||||
if (!row && evidence) lowerAutoRulesDate(account.id, evidence);
|
||||
if (!row && loadedFunds) evidenceApplied().add(account.id);
|
||||
}
|
||||
|
||||
return { assigned, unassigned, manual };
|
||||
}
|
||||
|
||||
export function setManualAssignment(accountId: number, firmId: string, accountTypeId: string | null): void {
|
||||
setAssignment(accountId, firmId, accountTypeId, true);
|
||||
/** insertRulesDate dates the row when the pin creates it (default: now, as
|
||||
* stampRulesDate gives it); re-pinning keeps the account's rules date. */
|
||||
export function setManualAssignment(
|
||||
accountId: number,
|
||||
firmId: string,
|
||||
accountTypeId: string | null,
|
||||
insertRulesDate: string = freshRulesDate(),
|
||||
): void {
|
||||
setAssignment(accountId, firmId, accountTypeId, true, insertRulesDate);
|
||||
}
|
||||
|
||||
/** Drops the override so the auto-resolver takes the account back over. */
|
||||
/** Drops the override so the auto-resolver takes the account back over. The
|
||||
* row and its rules date stay: unpinning must not make an old account look
|
||||
* new. */
|
||||
export function clearManualAssignment(accountId: number): boolean {
|
||||
return clearAssignment(accountId);
|
||||
return unpinAssignment(accountId);
|
||||
}
|
||||
|
||||
export { listAssignments };
|
||||
|
||||
+617
-94
File diff suppressed because it is too large
Load Diff
@@ -19,7 +19,7 @@ import {
|
||||
getSetting,
|
||||
setCredentials,
|
||||
setInstrumentEnabled,
|
||||
setAssignment,
|
||||
restoreAssignment,
|
||||
runInTransaction,
|
||||
} from './db';
|
||||
import type { BackupPayload } from './backup-sync';
|
||||
@@ -116,8 +116,10 @@ export function applyBackup(payload: BackupPayload, opts: { keepMasterUrl?: bool
|
||||
setInstrumentEnabled(i.symbol, i.enabled);
|
||||
counts.instruments++;
|
||||
}
|
||||
// Rules dates travel with them: a restored machine must not date
|
||||
// its accounts afresh, or old ones would leave their kept rules.
|
||||
for (const a of payload.assignments ?? []) {
|
||||
setAssignment(a.account_id, a.firm_id, a.account_type_id ?? null, a.manual === 1);
|
||||
restoreAssignment(a);
|
||||
counts.assignments++;
|
||||
}
|
||||
});
|
||||
|
||||
+4
-2
@@ -26,7 +26,7 @@ import {
|
||||
getInstruments,
|
||||
listAllAssignments,
|
||||
type FirmCredentialRow,
|
||||
type AssignmentRow,
|
||||
type BackupAssignmentRow,
|
||||
} from './db';
|
||||
|
||||
export interface BackupPayload {
|
||||
@@ -35,7 +35,9 @@ export interface BackupPayload {
|
||||
settings: Record<string, string>;
|
||||
credentials: FirmCredentialRow[];
|
||||
instruments: { symbol: string; enabled: boolean }[];
|
||||
assignments: AssignmentRow[];
|
||||
/** Whole rows (SELECT *), rules dates included — absent from a backup
|
||||
* an older build pushed. */
|
||||
assignments: BackupAssignmentRow[];
|
||||
}
|
||||
|
||||
const BACKUP_PATH = '/api/instances/backup';
|
||||
|
||||
+21
-5
@@ -1,6 +1,7 @@
|
||||
import { TradovateClient } from './tradovate-class';
|
||||
import { TopstepXClient } from './topstepx-class';
|
||||
import { DXTradeClient } from './dxtrade-class';
|
||||
import { VestClient } from './vest-class';
|
||||
import type { FirmPlatform } from './master-protocol';
|
||||
import { errorMessage, isClientReady, type PlatformClient } from './platform-client';
|
||||
import { getTradableFirms, startCatalogSync } from './firm-catalog';
|
||||
@@ -11,8 +12,10 @@ import { resolveContracts } from './contract-resolver';
|
||||
import { startReporter, sendReport } from './reporter';
|
||||
import { startBackupSync } from './backup-sync';
|
||||
import { applyBrokerHttpDefaults, applyBrokerProxy } from './outbound-proxy';
|
||||
import axios from './http-client';
|
||||
import { startMasterLink } from './master-link';
|
||||
import { resumeSchedulerIfPersisted } from './auto-trade';
|
||||
import { startUpdaterWatchdog } from './updater-watchdog';
|
||||
import { resumeSchedulerIfPersisted, startClaimWatcher } from './auto-trade';
|
||||
import { superviseQuotes, SUPERVISE_INTERVAL_MS, type QuoteClient } from './live-quotes';
|
||||
|
||||
// Use global to persist the client pool across HMR reloads in dev mode.
|
||||
@@ -100,6 +103,11 @@ export function initClient(
|
||||
case 'tradovate':
|
||||
client = new TradovateClient(creds.username, creds.password, onSyncComplete);
|
||||
break;
|
||||
case 'vest':
|
||||
// The saved "API key" is the Vest login's wallet: whose session,
|
||||
// relayed by the extension, this client trades.
|
||||
client = new VestClient({ firmId: id, firmName, wallet: creds.apiKey }, onSyncComplete);
|
||||
break;
|
||||
default: {
|
||||
const unknown: never = platform;
|
||||
throw new Error(`no client for platform ${String(unknown)}`);
|
||||
@@ -181,13 +189,16 @@ export function getClients(): Map<string, PlatformClient> {
|
||||
|
||||
if (!g.__tradovateClientsInitialized) {
|
||||
g.__tradovateClientsInitialized = true;
|
||||
// Payouts at target are claimed whether or not the scheduler runs —
|
||||
// started first, so a later init step that throws can't leave it off.
|
||||
startClaimWatcher();
|
||||
try {
|
||||
// Before anything dials out: a timeout on every broker HTTP call,
|
||||
// and the operator's proxy (if any) under the broker clients.
|
||||
// Master traffic stays direct by design. (outbound-proxy applies
|
||||
// Master traffic stays direct by design. (http-client applies
|
||||
// both when it loads, too; these are idempotent.)
|
||||
applyBrokerHttpDefaults();
|
||||
applyBrokerProxy();
|
||||
applyBrokerHttpDefaults(axios.defaults);
|
||||
applyBrokerProxy(axios.defaults);
|
||||
|
||||
// Pull the catalog before anything else — without it there are no
|
||||
// firms to start clients for. Subsequent changes re-reconcile and
|
||||
@@ -227,6 +238,9 @@ export function getClients(): Map<string, PlatformClient> {
|
||||
// Start master dashboard reporter
|
||||
startReporter();
|
||||
|
||||
// Bring this machine's auto-updater back if its PM2 cron stops firing.
|
||||
startUpdaterWatchdog();
|
||||
|
||||
// Push settings+credentials backups to the master, so a dead VPS
|
||||
// can be replaced and this identity adopted.
|
||||
startBackupSync();
|
||||
@@ -371,10 +385,12 @@ const REBUILD_COOLDOWN_MS = 20 * 60_000;
|
||||
* and fixing the credentials (or the firm's settings) rebuilds them through
|
||||
* reconcileClients anyway. TopstepX with an API key but no username never
|
||||
* tries to log in; DXTrade with no server URL can't, and after refused
|
||||
* credentials it stops trying so as not to lock the account. */
|
||||
* credentials it stops trying so as not to lock the account; Vest waits on
|
||||
* the extension to relay a signed-in tab's session. */
|
||||
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;
|
||||
if (platform === 'vest') return (client as VestClient).needsAttention === true;
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* Results are cached and refreshed periodically (default: every 30 minutes).
|
||||
*/
|
||||
|
||||
import axios from 'axios';
|
||||
import axios from './http-client';
|
||||
import { errorMessage } from './platform-client';
|
||||
|
||||
interface ContractInfo {
|
||||
@@ -37,8 +37,13 @@ const PROBE_WINDOW = 6;
|
||||
// route handlers (settings page, manual trades) never see.
|
||||
const _g = globalThis as typeof globalThis & {
|
||||
__contractCache?: Map<string, { contract: ResolvedContract; resolvedAt: number }>;
|
||||
__contractRoots?: Map<string, string>;
|
||||
};
|
||||
const cache = (_g.__contractCache ??= new Map());
|
||||
/** Contract id → the root it was resolved for. Unlike the cache it never
|
||||
* expires: a position opened hours ago still needs its instrument's name
|
||||
* (lib/trade-mix.ts). Front months are few, so it stays small. */
|
||||
const roots = (_g.__contractRoots ??= new Map());
|
||||
const CACHE_TTL_MS = 30 * 60 * 1000; // 30 minutes
|
||||
|
||||
export function getCachedContract(symbol: string): ResolvedContract | null {
|
||||
@@ -51,6 +56,12 @@ export function getCachedContract(symbol: string): ResolvedContract | null {
|
||||
return entry.contract;
|
||||
}
|
||||
|
||||
/** The root a resolved contract id belongs to, however long ago it was
|
||||
* resolved; null for one this process never resolved. */
|
||||
export function rootOfContract(contractId: number | string): string | null {
|
||||
return roots.get(String(contractId)) ?? null;
|
||||
}
|
||||
|
||||
export function getAllCachedContracts(): Record<string, ResolvedContract | null> {
|
||||
const result: Record<string, ResolvedContract | null> = {};
|
||||
for (const [symbol, entry] of cache) {
|
||||
@@ -71,7 +82,7 @@ async function getContinuousExchange(product: string): Promise<string | null> {
|
||||
try {
|
||||
const res = await axios.get(
|
||||
`https://query1.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(product + '=F')}`,
|
||||
{ params: { range: '1d', interval: '1d' }, headers: { 'User-Agent': 'Mozilla/5.0' } }
|
||||
{ params: { range: '1d', interval: '1d' } }
|
||||
);
|
||||
const meta = res.data?.chart?.result?.[0]?.meta;
|
||||
if (!meta) return null;
|
||||
@@ -106,7 +117,7 @@ async function findHighestVolumeContract(
|
||||
try {
|
||||
const res = await axios.get(
|
||||
`https://query1.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(ticker)}`,
|
||||
{ params: { range: '1d', interval: '1d' }, headers: { 'User-Agent': 'Mozilla/5.0' } }
|
||||
{ params: { range: '1d', interval: '1d' } }
|
||||
);
|
||||
const result = res.data?.chart?.result?.[0];
|
||||
const meta = result?.meta;
|
||||
@@ -205,6 +216,7 @@ export async function resolveContracts(
|
||||
};
|
||||
results[sym] = contract;
|
||||
cache.set(sym, { contract, resolvedAt: Date.now() });
|
||||
roots.set(String(contract.id), sym);
|
||||
|
||||
if (rolledFrom) {
|
||||
console.log(`[contract-resolver] ${sym}: ${rolledFrom} → ${match.name} ROLLED (by volume)`);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { mkdirSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { sortableIso, stampRulesDate } from './kept-rules';
|
||||
import type { ClaimedCycle } from './trading-logic';
|
||||
|
||||
// AUTOFIRMER_DB lets several instances share one checkout, each with its own
|
||||
// database. It is read once, at module load — schema creation and seeding are
|
||||
@@ -78,6 +80,62 @@ try {
|
||||
// Column already exists
|
||||
}
|
||||
|
||||
// Migration: each account's rules date — which of its account type's rule
|
||||
// sets it trades on (lib/kept-rules.ts): an account dated before a firm's
|
||||
// rule change keeps the rules from before it. Written when this machine first
|
||||
// records the account (or from a restored backup), lowered to the broker's
|
||||
// evidence of when the account opened, or set by hand (rules_date_manual,
|
||||
// which evidence never moves). Every writer stores a toISOString() UTC
|
||||
// string, so string order is time order.
|
||||
//
|
||||
// The run that adds the column dates every existing row at the migration
|
||||
// time, never NULL: no account changes rules merely because its machine
|
||||
// updated. The resolver then lowers those auto dates to the broker's
|
||||
// evidence, so an account the evidence shows opened before a rule change
|
||||
// goes back to the old rules. Column and backfill commit together, so
|
||||
// `next build` workers racing the migration can't leave rows undated.
|
||||
|
||||
/** The date an undated row gets: now, but never before the catalog's last
|
||||
* change — lib/kept-rules.ts stampRulesDate's floor (every build keeps
|
||||
* settings.catalog_updated_at current), so a machine whose clock runs
|
||||
* behind can't backdate an account first seen after a rule change into the
|
||||
* rule set that change closed. Call inside the writing transaction. */
|
||||
function undatedRowsDate(): string {
|
||||
let catalogUpdatedAt: string | null = null;
|
||||
try {
|
||||
const row = db.prepare("SELECT value FROM settings WHERE key = 'catalog_updated_at'").get() as { value?: unknown } | undefined;
|
||||
if (typeof row?.value === 'string') catalogUpdatedAt = row.value;
|
||||
} catch {
|
||||
// No settings table yet: a fresh database, with no rows to date.
|
||||
}
|
||||
return stampRulesDate(Date.now(), null, catalogUpdatedAt);
|
||||
}
|
||||
|
||||
try {
|
||||
db.transaction(() => {
|
||||
db.exec('ALTER TABLE account_assignments ADD COLUMN rules_date TEXT');
|
||||
db.prepare('UPDATE account_assignments SET rules_date = ? WHERE rules_date IS NULL').run(undatedRowsDate());
|
||||
}).immediate();
|
||||
} catch {
|
||||
// Column already exists
|
||||
}
|
||||
try {
|
||||
db.exec('ALTER TABLE account_assignments ADD COLUMN rules_date_manual INTEGER NOT NULL DEFAULT 0');
|
||||
} catch {
|
||||
// Column already exists
|
||||
}
|
||||
// Rows an older build wrote after that run have no date: it keeps serving
|
||||
// between a build's migration and the restart, and comes back in a rollback,
|
||||
// and its INSERT has no rules_date. Dated the same way at every start, so no
|
||||
// row stays undated (current rules forever, with no date to set by hand).
|
||||
try {
|
||||
db.transaction(() => {
|
||||
db.prepare('UPDATE account_assignments SET rules_date = ? WHERE rules_date IS NULL').run(undatedRowsDate());
|
||||
}).immediate();
|
||||
} catch {
|
||||
// No rules_date column — the migration above failed; nothing to date.
|
||||
}
|
||||
|
||||
// ── Firm credentials ─────────────────────────────────────────────────────────
|
||||
|
||||
export interface FirmCredentialRow {
|
||||
@@ -133,8 +191,18 @@ export interface AssignmentRow {
|
||||
account_type_id: string | null;
|
||||
manual: number; // 0 | 1
|
||||
resolved_at: string;
|
||||
/** ISO (toISOString). Which of the type's rule sets the account trades
|
||||
* on; null only on a row an older build wrote after the migration —
|
||||
* the current rules until evidence dates it. */
|
||||
rules_date: string | null;
|
||||
rules_date_manual: number; // 0 | 1 — set by hand; evidence leaves it alone
|
||||
}
|
||||
|
||||
/** An assignment as a backup carries it: one from a build before rules
|
||||
* dates has neither column. */
|
||||
export type BackupAssignmentRow = Omit<AssignmentRow, 'rules_date' | 'rules_date_manual'>
|
||||
& Partial<Pick<AssignmentRow, 'rules_date' | 'rules_date_manual'>>;
|
||||
|
||||
export function getAssignment(accountId: number): AssignmentRow | undefined {
|
||||
return db.prepare('SELECT * FROM account_assignments WHERE account_id = ?').get(accountId) as AssignmentRow | undefined;
|
||||
}
|
||||
@@ -148,33 +216,116 @@ export function listAllAssignments(): AssignmentRow[] {
|
||||
return db.prepare('SELECT * FROM account_assignments').all() as AssignmentRow[];
|
||||
}
|
||||
|
||||
/** insertRulesDate dates a row this call creates (default now — callers pass
|
||||
* lib/kept-rules.ts stampRulesDate). An existing row keeps its rules date
|
||||
* and how it was set: re-resolving or re-pinning an account never makes it
|
||||
* look new. Only a row with no date at all (an older build's) takes
|
||||
* insertRulesDate. */
|
||||
export function setAssignment(
|
||||
accountId: number,
|
||||
firmId: string,
|
||||
accountTypeId: string | null,
|
||||
manual: boolean,
|
||||
insertRulesDate?: string,
|
||||
): void {
|
||||
const now = new Date().toISOString();
|
||||
db.prepare(`
|
||||
INSERT INTO account_assignments (account_id, firm_id, account_type_id, manual, resolved_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
INSERT INTO account_assignments (account_id, firm_id, account_type_id, manual, resolved_at, rules_date)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(account_id) DO UPDATE SET
|
||||
firm_id = excluded.firm_id,
|
||||
account_type_id = excluded.account_type_id,
|
||||
manual = excluded.manual,
|
||||
resolved_at = excluded.resolved_at
|
||||
`).run(accountId, firmId, accountTypeId, manual ? 1 : 0, new Date().toISOString());
|
||||
resolved_at = excluded.resolved_at,
|
||||
rules_date = COALESCE(account_assignments.rules_date, excluded.rules_date)
|
||||
`).run(accountId, firmId, accountTypeId, manual ? 1 : 0, now, insertRulesDate ?? now);
|
||||
}
|
||||
|
||||
/** Writes an auto-resolved assignment, leaving any manual override alone.
|
||||
* The `manual = 0` guard is what makes a user's pick stick across syncs. */
|
||||
export function setAutoAssignment(accountId: number, firmId: string, accountTypeId: string | null): void {
|
||||
export function setAutoAssignment(
|
||||
accountId: number,
|
||||
firmId: string,
|
||||
accountTypeId: string | null,
|
||||
insertRulesDate?: string,
|
||||
): void {
|
||||
const existing = getAssignment(accountId);
|
||||
if (existing?.manual === 1) return;
|
||||
setAssignment(accountId, firmId, accountTypeId, false);
|
||||
setAssignment(accountId, firmId, accountTypeId, false, insertRulesDate);
|
||||
}
|
||||
|
||||
export function clearAssignment(accountId: number): boolean {
|
||||
return db.prepare('DELETE FROM account_assignments WHERE account_id = ?').run(accountId).changes > 0;
|
||||
/** Drops a manual pin but keeps the row — and with it the rules date, so
|
||||
* unpinning can't make an old account look new. The auto-resolver takes the
|
||||
* account back on its next pass. False when there is no row. */
|
||||
export function unpinAssignment(accountId: number): boolean {
|
||||
return db.prepare('UPDATE account_assignments SET manual = 0, account_type_id = NULL, resolved_at = ? WHERE account_id = ?')
|
||||
.run(new Date().toISOString(), accountId).changes > 0;
|
||||
}
|
||||
|
||||
/** Sets an account's rules date (an ISO toISOString() string); manual marks
|
||||
* it set by hand, which broker evidence never moves. False when the account
|
||||
* has no row. */
|
||||
export function setRulesDate(accountId: number, iso: string, manual: boolean): boolean {
|
||||
return db.prepare('UPDATE account_assignments SET rules_date = ?, rules_date_manual = ? WHERE account_id = ?')
|
||||
.run(iso, manual ? 1 : 0, accountId).changes > 0;
|
||||
}
|
||||
|
||||
/** Dates a row that has none (one an older build wrote) at `iso`; a dated
|
||||
* row is left alone. */
|
||||
export function stampMissingRulesDate(accountId: number, iso: string): void {
|
||||
db.prepare('UPDATE account_assignments SET rules_date = ? WHERE account_id = ? AND rules_date IS NULL').run(iso, accountId);
|
||||
}
|
||||
|
||||
/** Moves an auto rules date earlier to `iso` (the broker's evidence of when
|
||||
* the account opened, lib/kept-rules.ts openingEvidence); never later and
|
||||
* never a date set by hand. Compared as strings, which is time order only
|
||||
* because every rules date is a plain four-digit-year UTC toISOString()
|
||||
* string (lib/kept-rules.ts sortableIso) — keep it that way. */
|
||||
export function lowerAutoRulesDate(accountId: number, iso: string): void {
|
||||
db.prepare(`
|
||||
UPDATE account_assignments SET rules_date = ?
|
||||
WHERE account_id = ? AND rules_date_manual = 0 AND (rules_date IS NULL OR rules_date > ?)
|
||||
`).run(iso, accountId, iso);
|
||||
}
|
||||
|
||||
/**
|
||||
* One backed-up assignment onto this machine, every column (both restore
|
||||
* paths: lib/backup-restore.ts, and scripts/seed-settings.js by hand). The
|
||||
* backup's rules date and its manual flag win when the backup has one; a
|
||||
* backup from an older build has none, so an existing row keeps its own and
|
||||
* a new row is dated now, at the restore.
|
||||
*/
|
||||
export function restoreAssignment(row: BackupAssignmentRow): void {
|
||||
const now = new Date().toISOString();
|
||||
const resolvedAt = typeof row.resolved_at === 'string' ? row.resolved_at : now;
|
||||
// Only a date that sorts as a string counts as the backup's (anything
|
||||
// else is restored like an older build's undated row).
|
||||
const rulesDate = typeof row.rules_date === 'string' ? sortableIso(Date.parse(row.rules_date)) : null;
|
||||
if (rulesDate) {
|
||||
db.prepare(`
|
||||
INSERT INTO account_assignments (account_id, firm_id, account_type_id, manual, resolved_at, rules_date, rules_date_manual)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(account_id) DO UPDATE SET
|
||||
firm_id = excluded.firm_id,
|
||||
account_type_id = excluded.account_type_id,
|
||||
manual = excluded.manual,
|
||||
resolved_at = excluded.resolved_at,
|
||||
rules_date = excluded.rules_date,
|
||||
rules_date_manual = excluded.rules_date_manual
|
||||
`).run(row.account_id, row.firm_id, row.account_type_id ?? null, row.manual === 1 ? 1 : 0, resolvedAt,
|
||||
rulesDate, row.rules_date_manual === 1 ? 1 : 0);
|
||||
return;
|
||||
}
|
||||
db.prepare(`
|
||||
INSERT INTO account_assignments (account_id, firm_id, account_type_id, manual, resolved_at, rules_date)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(account_id) DO UPDATE SET
|
||||
firm_id = excluded.firm_id,
|
||||
account_type_id = excluded.account_type_id,
|
||||
manual = excluded.manual,
|
||||
resolved_at = excluded.resolved_at,
|
||||
rules_date = COALESCE(account_assignments.rules_date, excluded.rules_date)
|
||||
`).run(row.account_id, row.firm_id, row.account_type_id ?? null, row.manual === 1 ? 1 : 0, resolvedAt, now);
|
||||
}
|
||||
|
||||
// ── Settings ─────────────────────────────────────────────────────────────────
|
||||
@@ -239,6 +390,18 @@ export function setSetting(key: string, value: string): void {
|
||||
db.prepare(`INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`).run(key, value);
|
||||
}
|
||||
|
||||
/** Seed a generated setting once, including across simultaneous server workers.
|
||||
* Acquire the write lock before reading so they cannot choose different values. */
|
||||
export function getOrCreateSetting(key: string, create: () => string): string {
|
||||
return db.transaction(() => {
|
||||
const saved = getSetting(key);
|
||||
if (saved?.trim()) return saved;
|
||||
const value = create();
|
||||
setSetting(key, value);
|
||||
return value;
|
||||
}).immediate();
|
||||
}
|
||||
|
||||
// ── Instruments ──────────────────────────────────────────────────────────────
|
||||
|
||||
const SYMBOLS = ['NQ','MNQ','ES','MES','YM','MYM','RTY','M2K','GC','MGC','SI','CL','MCL','NG','ZB','ZN','ZF','6E','6J','6B'];
|
||||
@@ -247,12 +410,17 @@ const SYMBOLS = ['NQ','MNQ','ES','MES','YM','MYM','RTY','M2K','GC','MGC','SI','C
|
||||
// from the Instruments page, they just start off.
|
||||
const DEFAULT_ENABLED = new Set(['NQ', 'GC', 'CL']);
|
||||
|
||||
// Seed instruments table if empty
|
||||
const instrCount = (db.prepare('SELECT COUNT(*) as count FROM instruments').get() as { count: number }).count;
|
||||
if (instrCount === 0) {
|
||||
const ins = db.prepare('INSERT INTO instruments (symbol, enabled) VALUES (?, ?)');
|
||||
// Seed instruments table if empty. Check and insert share one IMMEDIATE
|
||||
// transaction: `next build` imports this module from several workers at once,
|
||||
// and on a fresh machine they all saw an empty table and raced to insert the
|
||||
// same symbols (UNIQUE constraint failed: instruments.symbol). OR IGNORE is
|
||||
// the backstop if the lock is ever skipped.
|
||||
db.transaction(() => {
|
||||
const instrCount = (db.prepare('SELECT COUNT(*) as count FROM instruments').get() as { count: number }).count;
|
||||
if (instrCount > 0) return;
|
||||
const ins = db.prepare('INSERT OR IGNORE INTO instruments (symbol, enabled) VALUES (?, ?)');
|
||||
for (const s of SYMBOLS) ins.run(s, DEFAULT_ENABLED.has(s) ? 1 : 0);
|
||||
}
|
||||
}).immediate();
|
||||
|
||||
export interface InstrumentRow {
|
||||
symbol: string;
|
||||
@@ -328,6 +496,22 @@ export function loadFundTransactions(accountId: number): { date: string; amount:
|
||||
return db.prepare('SELECT date, amount FROM fund_transactions WHERE account_id = ? ORDER BY date').all(accountId) as { date: string; amount: number }[];
|
||||
}
|
||||
|
||||
/** Deletes one day's fund transaction and sets account meta keys back in the
|
||||
* same transaction (null deletes the key), so no reader sees the row gone
|
||||
* with the cycle it started still in place. TopstepX's reconcile uses it to
|
||||
* take back a day whose bookings netted to fee noise. */
|
||||
export function dropFundTransaction(accountId: number, date: string, meta: { [key: string]: string | null }): void {
|
||||
const del = db.prepare('DELETE FROM account_meta WHERE account_id = ? AND key = ?');
|
||||
const txn = db.transaction(() => {
|
||||
db.prepare('DELETE FROM fund_transactions WHERE account_id = ? AND date = ?').run(accountId, date);
|
||||
for (const [key, value] of Object.entries(meta)) {
|
||||
if (value === null) del.run(accountId, key);
|
||||
else saveAccountMeta(accountId, key, value);
|
||||
}
|
||||
});
|
||||
txn();
|
||||
}
|
||||
|
||||
// ── Account Meta ─────────────────────────────────────────────────────────────
|
||||
|
||||
db.exec(`
|
||||
@@ -373,6 +557,29 @@ export function loadEndedCycleStart(accountId: number): { fundDate: string; incl
|
||||
}
|
||||
}
|
||||
|
||||
/** The completed cycles this instance's own claims ended (trading-logic
|
||||
* claimedCycle), oldest first — saved by the claim (auto-trade afterClaim),
|
||||
* since it can end one before any report sees it complete; the reporter
|
||||
* names each for the catch-up's week. Gone with a wipe, as the claims
|
||||
* themselves are. */
|
||||
export function saveClaimedCycles(accountId: number, list: ClaimedCycle[]): void {
|
||||
saveAccountMeta(accountId, 'claimed_cycles', JSON.stringify(list));
|
||||
}
|
||||
|
||||
export function loadClaimedCycles(accountId: number): ClaimedCycle[] {
|
||||
const raw = loadAccountMeta(accountId, 'claimed_cycles');
|
||||
if (raw === null) return [];
|
||||
try {
|
||||
const list = JSON.parse(raw) as unknown;
|
||||
if (!Array.isArray(list)) return [];
|
||||
return list.filter((c): c is ClaimedCycle => typeof c?.date === 'string' && typeof c.cycle === 'string'
|
||||
&& Number.isInteger(c.stage) && typeof c.drain === 'boolean' && Number.isInteger(c.cycleSeq))
|
||||
.map(({ date, stage, drain, cycle, cycleSeq, at }) => ({ date, stage, drain, cycle, cycleSeq, ...(Number.isFinite(at) ? { at } : {}) }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Deletes every cached row for an account — daily P&L, fund transactions and
|
||||
* meta — so the next report fetch rebuilds it from scratch. Rows older than
|
||||
* the report's 28-day window are gone for good; that is the point (stale
|
||||
@@ -394,6 +601,247 @@ export function wipeAccountData(accountId: number): void {
|
||||
txn();
|
||||
}
|
||||
|
||||
// ── Trades ───────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Every position a platform's history shows — the bot's and anyone else's
|
||||
// (Vest: GET /v3/positions). The platform can't tell an API order from a
|
||||
// click, so opened_by / closed_by record what this instance did itself:
|
||||
// 'bot' when its own order opened or closed the position, NULL otherwise —
|
||||
// or 'outside', set by hand for a trade from before marking began whose
|
||||
// origin the bot's own log settles.
|
||||
// A wipe leaves these alone: they're refetched, but the marks aren't.
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS trades (
|
||||
position_id TEXT PRIMARY KEY,
|
||||
account_id INTEGER NOT NULL,
|
||||
account_name TEXT NOT NULL,
|
||||
symbol TEXT NOT NULL,
|
||||
side TEXT NOT NULL,
|
||||
quantity REAL NOT NULL,
|
||||
leverage REAL,
|
||||
open_price REAL,
|
||||
close_price REAL,
|
||||
pnl REAL,
|
||||
fee REAL,
|
||||
funding REAL,
|
||||
opened_at INTEGER NOT NULL,
|
||||
closed_at INTEGER,
|
||||
close_reason TEXT,
|
||||
opened_by TEXT,
|
||||
closed_by TEXT,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS trades_account_opened ON trades (account_id, opened_at);
|
||||
`);
|
||||
|
||||
/** settings key: when this instance began marking its own trades (epoch
|
||||
* ms). A position opened before it and never marked is of unknown origin;
|
||||
* one opened after it was opened outside the bot. */
|
||||
export const TRADES_SINCE_SETTING = 'trades_tracked_since';
|
||||
|
||||
export interface TradeRow {
|
||||
positionId: string;
|
||||
accountId: number;
|
||||
accountName: string;
|
||||
symbol: string;
|
||||
side: 'long' | 'short';
|
||||
quantity: number;
|
||||
leverage: number | null;
|
||||
openPrice: number | null;
|
||||
closePrice: number | null;
|
||||
pnl: number | null;
|
||||
fee: number | null;
|
||||
funding: number | null;
|
||||
openedAt: number;
|
||||
closedAt: number | null;
|
||||
closeReason: string | null;
|
||||
openedBy: string | null;
|
||||
closedBy: string | null;
|
||||
}
|
||||
|
||||
/** The platform's view of positions: inserts new ones, refreshes known ones.
|
||||
* A size it no longer reports (0) keeps the one already stored, a field it
|
||||
* leaves out keeps its value, and the bot's marks are never touched. */
|
||||
export function saveTrades(rows: Omit<TradeRow, 'openedBy' | 'closedBy'>[]): void {
|
||||
const up = db.prepare(`
|
||||
INSERT INTO trades (position_id, account_id, account_name, symbol, side, quantity, leverage, open_price, close_price,
|
||||
pnl, fee, funding, opened_at, closed_at, close_reason, updated_at)
|
||||
VALUES (@positionId, @accountId, @accountName, @symbol, @side, @quantity, @leverage, @openPrice, @closePrice,
|
||||
@pnl, @fee, @funding, @openedAt, @closedAt, @closeReason, @updatedAt)
|
||||
ON CONFLICT(position_id) DO UPDATE SET
|
||||
account_name = excluded.account_name,
|
||||
symbol = excluded.symbol,
|
||||
side = excluded.side,
|
||||
quantity = CASE WHEN excluded.quantity > 0 THEN excluded.quantity ELSE trades.quantity END,
|
||||
leverage = COALESCE(excluded.leverage, trades.leverage),
|
||||
open_price = COALESCE(excluded.open_price, trades.open_price),
|
||||
close_price = CASE WHEN COALESCE(excluded.closed_at, trades.closed_at) IS NULL THEN NULL
|
||||
ELSE COALESCE(excluded.close_price, trades.close_price) END,
|
||||
pnl = CASE WHEN COALESCE(excluded.closed_at, trades.closed_at) IS NULL THEN NULL
|
||||
ELSE COALESCE(excluded.pnl, trades.pnl) END,
|
||||
fee = COALESCE(excluded.fee, trades.fee),
|
||||
funding = COALESCE(excluded.funding, trades.funding),
|
||||
opened_at = excluded.opened_at,
|
||||
closed_at = COALESCE(excluded.closed_at, trades.closed_at),
|
||||
close_reason = COALESCE(excluded.close_reason, trades.close_reason),
|
||||
updated_at = excluded.updated_at
|
||||
`);
|
||||
const now = Date.now();
|
||||
const txn = db.transaction(() => {
|
||||
for (const r of rows) up.run({ ...r, updatedAt: now });
|
||||
});
|
||||
txn();
|
||||
}
|
||||
|
||||
/** This instance's own order opened (or closed) the position — stored at
|
||||
* once, before the platform's history shows it. */
|
||||
export function markTrade(
|
||||
by: 'opened' | 'closed',
|
||||
t: { positionId: string; accountId: number; accountName: string; symbol: string; side: 'long' | 'short'; quantity: number; openPrice: number | null; openedAt: number },
|
||||
): void {
|
||||
const col = by === 'opened' ? 'opened_by' : 'closed_by';
|
||||
db.prepare(`
|
||||
INSERT INTO trades (position_id, account_id, account_name, symbol, side, quantity, open_price, opened_at, ${col}, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'bot', ?)
|
||||
ON CONFLICT(position_id) DO UPDATE SET ${col} = 'bot', updated_at = excluded.updated_at
|
||||
`).run(t.positionId, t.accountId, t.accountName, t.symbol, t.side, t.quantity, t.openPrice, t.openedAt, Date.now());
|
||||
}
|
||||
|
||||
/** Which of these positions this instance's own order opened (markTrade). */
|
||||
export function botOpenedPositions(positionIds: string[]): Set<string> {
|
||||
if (positionIds.length === 0) return new Set();
|
||||
const rows = db.prepare(`SELECT position_id AS positionId FROM trades
|
||||
WHERE opened_by = 'bot' AND position_id IN (${positionIds.map(() => '?').join(', ')})`)
|
||||
.all(...positionIds) as { positionId: string }[];
|
||||
return new Set(rows.map((r) => r.positionId));
|
||||
}
|
||||
|
||||
/** Vest accounts with a trade the table still shows open (Vest's position
|
||||
* ids are bare numbers; the fill-built trades below carry a prefix). */
|
||||
export function openTradeAccounts(): { accountId: number; accountName: string }[] {
|
||||
return db.prepare(`SELECT account_id AS accountId, MAX(account_name) AS accountName FROM trades
|
||||
WHERE closed_at IS NULL AND position_id NOT LIKE '%:%' GROUP BY account_id`)
|
||||
.all() as { accountId: number; accountName: string }[];
|
||||
}
|
||||
|
||||
// ── Own orders, and trades built from fills ──────────────────────────────────
|
||||
//
|
||||
// TopstepX and Tradovate report fills, not positions, and neither says who
|
||||
// placed an order. So every order this instance places is noted here as it
|
||||
// goes in, and a trade built from fills (lib/trade-recorder.ts) is the bot's
|
||||
// when its opening fill's order is one of these. Its kind tells how the bot
|
||||
// closed one: its stop, its take-profit limit, or a market order. Those
|
||||
// trades carry a platform prefix on their id ("tsx:…", "tdv:…") and their
|
||||
// marks are worked out afresh on each pass: 'bot', 'outside', or 'unknown'
|
||||
// for a fill from before this table began.
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS own_orders (
|
||||
account_id INTEGER NOT NULL,
|
||||
order_id TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
placed_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (account_id, order_id)
|
||||
);
|
||||
`);
|
||||
|
||||
/** settings key: when this instance began noting its own futures orders. */
|
||||
const OWN_ORDERS_SINCE_SETTING = 'own_orders_since';
|
||||
const ownOrdersSinceMs = Number(getOrCreateSetting(OWN_ORDERS_SINCE_SETTING, () => String(Date.now())));
|
||||
|
||||
export function ownOrdersSince(): number {
|
||||
return ownOrdersSinceMs;
|
||||
}
|
||||
|
||||
export type OwnOrderKind = 'market' | 'limit' | 'stop';
|
||||
|
||||
export function recordOwnOrder(accountId: number, orderId: string | number, kind: OwnOrderKind): void {
|
||||
db.prepare('INSERT OR IGNORE INTO own_orders (account_id, order_id, kind, placed_at) VALUES (?, ?, ?, ?)')
|
||||
.run(accountId, String(orderId), kind, Date.now());
|
||||
}
|
||||
|
||||
export function loadOwnOrders(accountId: number): Map<string, OwnOrderKind> {
|
||||
const rows = db.prepare('SELECT order_id AS orderId, kind FROM own_orders WHERE account_id = ?').all(accountId) as { orderId: string; kind: OwnOrderKind }[];
|
||||
return new Map(rows.map((r) => [r.orderId, r.kind]));
|
||||
}
|
||||
|
||||
export type FillTradeRow = Omit<TradeRow, 'leverage' | 'funding'>;
|
||||
|
||||
/** One account's stored trades off one platform — for one contract, when
|
||||
* `symbol` is given. */
|
||||
export function loadFillTrades(accountId: number, prefix: string, symbol?: string): FillTradeRow[] {
|
||||
return db.prepare(`
|
||||
SELECT position_id AS positionId, account_id AS accountId, account_name AS accountName, symbol, side, quantity,
|
||||
open_price AS openPrice, close_price AS closePrice, pnl, fee, opened_at AS openedAt, closed_at AS closedAt,
|
||||
close_reason AS closeReason, opened_by AS openedBy, closed_by AS closedBy
|
||||
FROM trades WHERE account_id = ? AND position_id LIKE ? ${symbol === undefined ? '' : 'AND symbol = ?'}
|
||||
`).all(accountId, `${prefix}:%`, ...(symbol === undefined ? [] : [symbol])) as FillTradeRow[];
|
||||
}
|
||||
|
||||
/** Trades rebuilt from fills: every field as worked out this pass, except a
|
||||
* null openedBy (a carried trade's) keeps the stored one. */
|
||||
export function saveFillTrades(rows: FillTradeRow[]): void {
|
||||
const up = db.prepare(`
|
||||
INSERT INTO trades (position_id, account_id, account_name, symbol, side, quantity, open_price, close_price,
|
||||
pnl, fee, opened_at, closed_at, close_reason, opened_by, closed_by, updated_at)
|
||||
VALUES (@positionId, @accountId, @accountName, @symbol, @side, @quantity, @openPrice, @closePrice,
|
||||
@pnl, @fee, @openedAt, @closedAt, @closeReason, @openedBy, @closedBy, @updatedAt)
|
||||
ON CONFLICT(position_id) DO UPDATE SET
|
||||
account_name = excluded.account_name,
|
||||
side = excluded.side,
|
||||
quantity = excluded.quantity,
|
||||
open_price = COALESCE(excluded.open_price, trades.open_price),
|
||||
close_price = excluded.close_price,
|
||||
pnl = excluded.pnl,
|
||||
fee = COALESCE(excluded.fee, trades.fee),
|
||||
opened_at = excluded.opened_at,
|
||||
closed_at = excluded.closed_at,
|
||||
close_reason = excluded.close_reason,
|
||||
opened_by = COALESCE(excluded.opened_by, trades.opened_by),
|
||||
closed_by = excluded.closed_by,
|
||||
updated_at = excluded.updated_at
|
||||
`);
|
||||
const now = Date.now();
|
||||
db.transaction(() => {
|
||||
for (const r of rows) up.run({ ...r, updatedAt: now });
|
||||
})();
|
||||
}
|
||||
|
||||
/** Trades a pass found never existed (built off a history that moved
|
||||
* under it). */
|
||||
export function deleteTrades(positionIds: string[]): void {
|
||||
const del = db.prepare('DELETE FROM trades WHERE position_id = ?');
|
||||
db.transaction(() => {
|
||||
for (const id of positionIds) del.run(id);
|
||||
})();
|
||||
}
|
||||
|
||||
/** Open trades whose close the history no longer reaches: closed, how and
|
||||
* when unknown. */
|
||||
export function closeTradesUnseen(positionIds: string[]): void {
|
||||
const up = db.prepare(`UPDATE trades SET close_reason = 'unknown', updated_at = ? WHERE position_id = ? AND closed_at IS NULL AND close_reason IS NULL`);
|
||||
const now = Date.now();
|
||||
db.transaction(() => {
|
||||
for (const id of positionIds) up.run(now, id);
|
||||
})();
|
||||
}
|
||||
|
||||
/** Newest first (by close, else open); every account when accountIds is
|
||||
* undefined. */
|
||||
export function loadTrades(o: { accountIds?: number[]; limit: number }): TradeRow[] {
|
||||
if (o.accountIds && o.accountIds.length === 0) return [];
|
||||
const where = o.accountIds ? `WHERE account_id IN (${o.accountIds.map(() => '?').join(', ')})` : '';
|
||||
return db.prepare(`
|
||||
SELECT position_id AS positionId, account_id AS accountId, account_name AS accountName, symbol, side, quantity, leverage,
|
||||
open_price AS openPrice, close_price AS closePrice, pnl, fee, funding, opened_at AS openedAt, closed_at AS closedAt,
|
||||
close_reason AS closeReason, opened_by AS openedBy, closed_by AS closedBy
|
||||
FROM trades ${where}
|
||||
ORDER BY COALESCE(closed_at, opened_at) DESC
|
||||
LIMIT ?
|
||||
`).all(...(o.accountIds ?? []), o.limit) as TradeRow[];
|
||||
}
|
||||
|
||||
// ── AutoBuyer captures ───────────────────────────────────────────────────────
|
||||
|
||||
db.exec(`
|
||||
|
||||
+157
-14
@@ -31,29 +31,36 @@
|
||||
* account shows as accountStatus ≠ FULL_TRADING → active=false.
|
||||
*/
|
||||
|
||||
import axios from 'axios';
|
||||
import { currentTradeDateCT, FIRM_RESET_UTC_HOUR, DEFAULT_TARGET_HOLD_HOURS, rankForTarget } from './trading-logic';
|
||||
import axios from './http-client';
|
||||
import { currentTradeDateCT, FIRM_RESET_UTC_HOUR, rankForTarget } from './trading-logic';
|
||||
import { sizingHoldHours } from './person-size';
|
||||
import {
|
||||
saveDailyPnL, loadDailyPnL, loadAccountMeta, saveAccountMeta, loadFundTransactions, saveFundTransactions,
|
||||
saveEndedCycleStart,
|
||||
saveEndedCycleStart, loadFillTrades, saveFillTrades, closeTradesUnseen, type FillTradeRow,
|
||||
} from './db';
|
||||
import { getAccountConfig } from './account-type-resolver';
|
||||
import { getCachedFirm } from './firm-catalog';
|
||||
import { firmInstruments } from './instruments';
|
||||
import type { SymbolMapCandidate } from './master-protocol';
|
||||
import type { PlatformClient, PlatformAccount, PlatformContract, ClientHealth, PickContext } from './platform-client';
|
||||
import { OrderNotPlacedError, isOrderNotPlaced, errorMessage } from './platform-client';
|
||||
import type { PlatformClient, PlatformAccount, PlatformContract, ClientHealth, PickContext, OrderIntent } from './platform-client';
|
||||
import { OrderNotPlacedError, isOrderNotPlaced, errorMessage, orderEffect } from './platform-client';
|
||||
import {
|
||||
dxAccountId, accountDisplayName, accountCodeOf, encodeContractName, decodeContractName, roundToStep,
|
||||
lastUtcReset, ctTradeDayStart, firmDayOf, timeOf, parseDurationMs, ciNum, isFundType, signedFundValue, newOrderCode,
|
||||
inTradingHours, realizedVolPerHour, scoreCandidate, candidateRejection, parseTransfers, marginRateOf,
|
||||
parseHistoryOrder, dxTrades,
|
||||
} from './dxtrade-helpers';
|
||||
import type { DxTxn } from './dxtrade-helpers';
|
||||
import type { DxTxn, DxHistoryOrder, DxKnownPosition } from './dxtrade-helpers';
|
||||
import { hyperliquidCoins, hyperliquidCoinFor, hyperliquidQuote, hyperliquidHourlyCloses } from './hyperliquid-feed';
|
||||
|
||||
// ── timings ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const SYNC_INTERVAL_MS = 60_000;
|
||||
/** The trades record reads an active account's orders this often — sooner
|
||||
* once its positions or the day's cash show something happened. */
|
||||
const TRADES_RECORD_MS = 5 * 60_000;
|
||||
/** How far back the trades record reads orders and cash. */
|
||||
const TRADES_WINDOW_MS = 2 * 86_400_000;
|
||||
const SYNC_WEDGED_MS = 3 * 60_000;
|
||||
const WATCHDOG_INTERVAL_MS = 60_000;
|
||||
/** Logged in longer than this with no good sync for as long → re-login. */
|
||||
@@ -256,6 +263,12 @@ export class DXTradeClient implements PlatformClient {
|
||||
private recentTxns = new Map<number, Map<string, DxTxn>>();
|
||||
/** Accounts whose balance and day's transactions came back this pass. */
|
||||
private dayFresh = new Set<number>();
|
||||
/** The trades record: when each account was last read, and what its
|
||||
* positions and day's cash looked like then. */
|
||||
private tradesRecordedAt = new Map<number, { at: number; sig: string }>();
|
||||
private tradesRecordInFlight = false;
|
||||
/** The order history refused a time-from: read the latest orders instead. */
|
||||
private historyTakesNoTimeFrom = false;
|
||||
/** When each open position was first seen, for hold-time logging. */
|
||||
private seenOpenAt = new Map<number, { positionCode: string; at: number; naggedAt: number }>();
|
||||
|
||||
@@ -684,6 +697,7 @@ export class DXTradeClient implements PlatformClient {
|
||||
await this.reapStrayOrders();
|
||||
this.checkNakedPositions();
|
||||
if (superseded()) return;
|
||||
if (fresh) void this.recordTrades();
|
||||
|
||||
console.log(`${this.tag} sync — ${list.length} account(s), ${Object.keys(this.positions).length} open position(s)`);
|
||||
this.maybeFetchDaysTraded();
|
||||
@@ -1018,6 +1032,12 @@ export class DXTradeClient implements PlatformClient {
|
||||
&& this.fundTransactions[accountId] !== undefined;
|
||||
}
|
||||
|
||||
/** The account's accountStatus and registration time, for the report. */
|
||||
public brokerAccountMeta(accountId: number): { status: string; registrationTime: number } | undefined {
|
||||
const meta = this.accountMeta.get(accountId);
|
||||
return meta ? { status: meta.status, registrationTime: meta.registrationTime } : undefined;
|
||||
}
|
||||
|
||||
public forgetHistory(accountId: number, accountName: string): void {
|
||||
this.historyLoadedIds.delete(accountId);
|
||||
this.booksHeld.delete(accountId);
|
||||
@@ -1216,7 +1236,7 @@ export class DXTradeClient implements PlatformClient {
|
||||
const ref = this.referenceAccount();
|
||||
if (!ref) return null;
|
||||
const firm = getCachedFirm(this.cfg.firmId);
|
||||
const targetHold = firm?.targetHoldHours || DEFAULT_TARGET_HOLD_HOURS;
|
||||
const targetHold = sizingHoldHours(firm);
|
||||
const symbols = [...new Set(candidates.map((c) => c.symbol))];
|
||||
const [insts, dets, quotes, vols] = await Promise.all([
|
||||
this.instruments(symbols),
|
||||
@@ -1323,6 +1343,122 @@ export class DXTradeClient implements PlatformClient {
|
||||
return {};
|
||||
}
|
||||
|
||||
// ── trades record ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Every position on the accounts, the bot's and anyone else's, in the
|
||||
* trades table the dashboard lists — off the last two days' filled orders
|
||||
* and their SETTLEMENT and COMMISSION. The bot's orders carry its af-
|
||||
* codes, so who opened and closed each needs nothing kept locally. Only
|
||||
* an account with something going on is read (an open position, the
|
||||
* day's cash, a trade still open in the table), each at most every few
|
||||
* minutes. Never fails the sync.
|
||||
*/
|
||||
private async recordTrades(): Promise<void> {
|
||||
if (this.tradesRecordInFlight || !this.token || this.intentionalDisconnect) return;
|
||||
const now = Date.now();
|
||||
const due: { acc: PlatformAccount; code: string; sig: string; stored: FillTradeRow[] }[] = [];
|
||||
for (const acc of this.accountList) {
|
||||
const code = this.codeById.get(acc.id);
|
||||
if (!code || !this.dayFresh.has(acc.id)) continue;
|
||||
const positions = (this.positionsDetail.get(acc.id) ?? []).map((p) => p.positionCode).sort().join(',');
|
||||
const trading = [...(this.recentTxns.get(acc.id)?.values() ?? [])].filter((t) => !isFundType(t.type)).length;
|
||||
const sig = positions || trading > 0 ? `${positions}|${trading}` : '';
|
||||
const stored = loadFillTrades(acc.id, 'dx');
|
||||
const last = this.tradesRecordedAt.get(acc.id);
|
||||
const storedOpen = stored.some((t) => t.closedAt === null && t.closeReason === null);
|
||||
if (sig === '' && !storedOpen && (!last || last.sig === '')) continue;
|
||||
if (last && last.sig === sig && now - last.at < TRADES_RECORD_MS) continue;
|
||||
due.push({ acc, code, sig, stored });
|
||||
}
|
||||
if (due.length === 0) return;
|
||||
|
||||
this.tradesRecordInFlight = true;
|
||||
try {
|
||||
const from = new Date(now - TRADES_WINDOW_MS).toISOString();
|
||||
const txns = (await this.perAccounts<unknown>('/accounts/transfers', due.map((d) => d.code), (d) => [d],
|
||||
{ 'time-from': from, limit: 1000 }, 'history')).flatMap((body) => parseTransfers(body));
|
||||
for (const { acc, code, sig, stored } of due) {
|
||||
if (this.intentionalDisconnect) return;
|
||||
const orders = await this.historyOrders(code, from);
|
||||
if (orders === null) continue;
|
||||
this.saveTrades(acc, orders, txns.filter((t) => t.account === code), stored);
|
||||
this.tradesRecordedAt.set(acc.id, { at: Date.now(), sig });
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`${this.tag} trades record failed: ${errorMessage(err)}`);
|
||||
} finally {
|
||||
this.tradesRecordInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** An account's filled orders since `from`; null when they couldn't be read. */
|
||||
private async historyOrders(code: string, from: string): Promise<DxHistoryOrder[] | null> {
|
||||
const path = `/accounts/${encodeURIComponent(code)}/orders/history`;
|
||||
try {
|
||||
let data: { orders?: unknown[] } | undefined;
|
||||
try {
|
||||
({ data } = await this.request<{ orders?: unknown[] }>('history', 'GET', path, {
|
||||
params: this.historyTakesNoTimeFrom ? { limit: 500 } : { 'time-from': from, limit: 500 },
|
||||
}));
|
||||
} catch (err) {
|
||||
if (!(err instanceof DxApiError) || err.status !== 400 || this.historyTakesNoTimeFrom) throw err;
|
||||
this.historyTakesNoTimeFrom = true;
|
||||
({ data } = await this.request<{ orders?: unknown[] }>('history', 'GET', path, { params: { limit: 500 } }));
|
||||
}
|
||||
const since = Date.parse(from);
|
||||
return (data?.orders ?? []).map(parseHistoryOrder).filter((o): o is DxHistoryOrder => o !== null && o.at >= since);
|
||||
} catch (err) {
|
||||
console.warn(`${this.tag} order history for ${accountDisplayName(code)} failed: ${errorMessage(err)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private saveTrades(acc: PlatformAccount, orders: DxHistoryOrder[], txns: DxTxn[], stored: FillTradeRow[]): void {
|
||||
const prefix = `dx:${acc.id}:`;
|
||||
const open = new Map<string, DxKnownPosition>();
|
||||
for (const p of this.positionsDetail.get(acc.id) ?? []) {
|
||||
open.set(p.positionCode, {
|
||||
symbol: p.symbol, side: p.side === 'BUY' ? 'long' : 'short', quantity: p.quantity,
|
||||
openPrice: Number.isFinite(p.openPrice) ? p.openPrice : null, openedAt: p.openTime,
|
||||
});
|
||||
}
|
||||
const known = new Map<string, DxKnownPosition>(stored.map((t) => [t.positionId.slice(prefix.length), {
|
||||
symbol: t.symbol, side: t.side, quantity: t.quantity, openPrice: t.openPrice, openedAt: t.openedAt,
|
||||
}]));
|
||||
const ours = (code: string | null) => code !== null && code.startsWith('af-');
|
||||
const rows: FillTradeRow[] = dxTrades(orders, txns, open, known)
|
||||
.filter((t) => Number.isFinite(t.openedAt))
|
||||
.map((t) => {
|
||||
const closed = t.closedAt !== null;
|
||||
const exit = closed && ours(t.closeCode) ? t.closeType : null;
|
||||
return {
|
||||
positionId: `${prefix}${t.positionCode}`,
|
||||
accountId: acc.id,
|
||||
accountName: acc.name,
|
||||
symbol: t.symbol,
|
||||
side: t.side,
|
||||
quantity: t.quantity,
|
||||
openPrice: t.openPrice,
|
||||
closePrice: t.closePrice,
|
||||
pnl: t.pnl,
|
||||
fee: t.fee,
|
||||
openedAt: t.openedAt,
|
||||
closedAt: t.closedAt,
|
||||
closeReason: exit === 'STOP' ? 'stop_loss' : exit === 'LIMIT' ? 'take_profit' : null,
|
||||
openedBy: t.openCode === null ? null : ours(t.openCode) ? 'bot' : 'outside',
|
||||
closedBy: !closed || exit === 'STOP' || exit === 'LIMIT' ? null : ours(t.closeCode) ? 'bot' : 'outside',
|
||||
};
|
||||
});
|
||||
saveFillTrades(rows);
|
||||
// Open in the table, gone from the account, and nothing in the
|
||||
// history closed it: closed out of sight.
|
||||
const seen = new Set(rows.map((r) => r.positionId));
|
||||
closeTradesUnseen(stored
|
||||
.filter((t) => t.closedAt === null && t.closeReason === null && !seen.has(t.positionId) && !open.has(t.positionId.slice(prefix.length)))
|
||||
.map((t) => t.positionId));
|
||||
}
|
||||
|
||||
// ── orders ───────────────────────────────────────────────────────────────
|
||||
|
||||
private async freshPositions(account: string): Promise<DxPosition[]> {
|
||||
@@ -1421,10 +1557,10 @@ export class DXTradeClient implements PlatformClient {
|
||||
|
||||
/**
|
||||
* Place an order and wait for its fill: an OPEN when the account is flat,
|
||||
* a CLOSE of the position when it holds the opposite side. Adding to a
|
||||
* position is refused, and so is an OPEN against another account's
|
||||
* opposite position in the same symbol (no hedging). Resolves with the
|
||||
* fill `price`.
|
||||
* a CLOSE of the position when it holds the opposite side — only the one
|
||||
* `intent` names, when given (orderEffect). Adding to a position is
|
||||
* refused, and so is an OPEN against another account's opposite position
|
||||
* in the same symbol (no hedging). Resolves with the fill `price`.
|
||||
*/
|
||||
public async sendOrder(
|
||||
accountId: number,
|
||||
@@ -1433,9 +1569,10 @@ export class DXTradeClient implements PlatformClient {
|
||||
action: 'Buy' | 'Sell',
|
||||
orderType: 'Market' | 'Limit',
|
||||
price?: number,
|
||||
intent?: OrderIntent,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
return await this.sendOrderAndWait(accountId, contractSymbol, quantity, action, orderType, price);
|
||||
return await this.sendOrderAndWait(accountId, contractSymbol, quantity, action, orderType, price, intent);
|
||||
} catch (err) {
|
||||
if (!isOrderNotPlaced(err)) this.suspectAccounts.set(accountId, Date.now());
|
||||
throw err;
|
||||
@@ -1449,6 +1586,7 @@ export class DXTradeClient implements PlatformClient {
|
||||
action: 'Buy' | 'Sell',
|
||||
orderType: 'Market' | 'Limit',
|
||||
price?: number,
|
||||
intent?: OrderIntent,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const { account, symbol, step } = this.orderContext(accountId, contractSymbol);
|
||||
const side: 'BUY' | 'SELL' = action === 'Buy' ? 'BUY' : 'SELL';
|
||||
@@ -1458,8 +1596,13 @@ export class DXTradeClient implements PlatformClient {
|
||||
} catch (err) {
|
||||
throw new OrderNotPlacedError(`DXtrade position check failed — order not sent: ${errorMessage(err)}`);
|
||||
}
|
||||
const existing = positions.find((p) => p.symbol === symbol);
|
||||
if (existing && existing.side === side) throw new OrderNotPlacedError(`adding to an open ${symbol} position is not supported`);
|
||||
const held = positions.find((p) => p.symbol === symbol);
|
||||
const sideOf = (s: 'BUY' | 'SELL'): 'long' | 'short' => (s === 'BUY' ? 'long' : 'short');
|
||||
const mine = this.positionsDetail.get(accountId)?.find((p) => p.symbol === symbol);
|
||||
// orderEffect picks OPEN or CLOSE; `existing` is the position a CLOSE closes.
|
||||
const existing = orderEffect({
|
||||
symbol, held: held ? sideOf(held.side) : null, recorded: mine ? sideOf(mine.side) : null, side: sideOf(side), intent,
|
||||
}) === 'close' ? held : undefined;
|
||||
|
||||
const code = newOrderCode(existing ? 'x' : 'e');
|
||||
let body: Record<string, unknown>;
|
||||
|
||||
@@ -328,3 +328,156 @@ export function candidateRejection(o: {
|
||||
if (o.currency && o.currency !== o.accountCurrency) return `priced in ${o.currency}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── trades ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** A filled order off /orders/history, as the trades record needs it. */
|
||||
export interface DxHistoryOrder {
|
||||
orderCode: string;
|
||||
orderId: number | undefined;
|
||||
/** MARKET, LIMIT, STOP. */
|
||||
type: string;
|
||||
instrument: string;
|
||||
positionCode: string | undefined;
|
||||
effect: 'OPEN' | 'CLOSE' | undefined;
|
||||
side: 'BUY' | 'SELL' | undefined;
|
||||
/** Filled. */
|
||||
qty: number;
|
||||
/** Average fill price. */
|
||||
price: number | undefined;
|
||||
/** The last fill, else the order's own time. */
|
||||
at: number;
|
||||
}
|
||||
|
||||
/** null for an order that never filled. */
|
||||
export function parseHistoryOrder(raw: unknown): DxHistoryOrder | null {
|
||||
const o = raw as Record<string, unknown>;
|
||||
if (!o || typeof o !== 'object' || typeof o.orderCode !== 'string') return null;
|
||||
const legs = Array.isArray(o.legs) ? (o.legs as Record<string, unknown>[]) : [];
|
||||
const leg = legs[0] ?? {};
|
||||
const qty = Math.abs(ciNum(leg, 'filledQuantity') ?? ciNum(o, 'filledQuantity') ?? 0);
|
||||
if (!(qty > 0)) return null;
|
||||
const executions = Array.isArray(o.executions) ? (o.executions as Record<string, unknown>[]) : [];
|
||||
const fillTimes = executions
|
||||
.filter((e) => (ciNum(e, 'lastQuantity') ?? ciNum(e, 'filledQuantity') ?? 0) > 0)
|
||||
.map((e) => timeOf(e.transactionTime))
|
||||
.filter(Number.isFinite);
|
||||
const at = fillTimes.length > 0 ? Math.max(...fillTimes) : [timeOf(o.transactionTime), timeOf(o.issueTime)].find(Number.isFinite) ?? NaN;
|
||||
if (!Number.isFinite(at)) return null;
|
||||
const effect = leg.positionEffect === 'OPEN' || leg.positionEffect === 'CLOSE' ? leg.positionEffect : undefined;
|
||||
const sideRaw = leg.side ?? o.side;
|
||||
return {
|
||||
orderCode: o.orderCode,
|
||||
orderId: ciNum(o, 'orderId'),
|
||||
type: String(o.type ?? ''),
|
||||
instrument: String(leg.instrument ?? o.instrument ?? ''),
|
||||
positionCode: typeof leg.positionCode === 'string' && leg.positionCode ? leg.positionCode : undefined,
|
||||
effect,
|
||||
side: sideRaw === 'BUY' || sideRaw === 'SELL' ? sideRaw : undefined,
|
||||
qty,
|
||||
price: ciNum(leg, 'averagePrice')
|
||||
?? [...executions].reverse().map((e) => ciNum(e, 'averagePrice')).find((p) => p !== undefined && p > 0),
|
||||
at,
|
||||
};
|
||||
}
|
||||
|
||||
/** One position, from the orders that opened and closed it. */
|
||||
export interface DxTrade {
|
||||
positionCode: string;
|
||||
symbol: string;
|
||||
side: 'long' | 'short';
|
||||
quantity: number;
|
||||
openPrice: number | null;
|
||||
closePrice: number | null;
|
||||
/** The SETTLEMENT its closes booked; null while open or unseen. */
|
||||
pnl: number | null;
|
||||
/** Its COMMISSION, opens and closes; null when none was seen. */
|
||||
fee: number | null;
|
||||
openedAt: number;
|
||||
closedAt: number | null;
|
||||
/** The opening order's code; null when it opened before the history. */
|
||||
openCode: string | null;
|
||||
/** The order that closed it, and its type. */
|
||||
closeCode: string | null;
|
||||
closeType: string | null;
|
||||
}
|
||||
|
||||
/** What the record knows of a position already: the open one on the
|
||||
* account now, or one stored from an earlier pass. */
|
||||
export interface DxKnownPosition {
|
||||
symbol: string;
|
||||
side: 'long' | 'short';
|
||||
quantity: number;
|
||||
openPrice: number | null;
|
||||
openedAt: number;
|
||||
}
|
||||
|
||||
const vwap = (orders: DxHistoryOrder[]): number | null => {
|
||||
const priced = orders.filter((o) => o.price !== undefined);
|
||||
const qty = priced.reduce((s, o) => s + o.qty, 0);
|
||||
return qty > 0 ? priced.reduce((s, o) => s + o.price! * o.qty, 0) / qty : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* The account's positions, out of its filled orders (one account) and its
|
||||
* cash transactions. On a position-based account an opening order's position
|
||||
* code is its order id, so an OPEN leg without one takes that. A position the
|
||||
* account still holds (`open`) stays open whatever closed part of it; one
|
||||
* whose opening is older than the orders at hand is completed from `known`,
|
||||
* and left out when nothing knows it.
|
||||
*/
|
||||
export function dxTrades(orders: DxHistoryOrder[], txns: DxTxn[], open: Map<string, DxKnownPosition>, known: Map<string, DxKnownPosition>): DxTrade[] {
|
||||
const groups = new Map<string, DxHistoryOrder[]>();
|
||||
for (const o of orders) {
|
||||
const code = o.positionCode ?? (o.effect === 'OPEN' && o.orderId !== undefined ? String(o.orderId) : undefined);
|
||||
if (!code) continue;
|
||||
groups.set(code, [...(groups.get(code) ?? []), o]);
|
||||
}
|
||||
for (const code of open.keys()) if (!groups.has(code)) groups.set(code, []);
|
||||
|
||||
const settled = new Map<string, number>();
|
||||
const commission = new Map<string, number>();
|
||||
for (const t of txns) {
|
||||
if (!t.orderCode) continue;
|
||||
if (t.type === 'SETTLEMENT') settled.set(t.orderCode, (settled.get(t.orderCode) ?? 0) + t.value);
|
||||
else if (t.type === 'COMMISSION') commission.set(t.orderCode, (commission.get(t.orderCode) ?? 0) + Math.abs(t.value));
|
||||
}
|
||||
|
||||
const out: DxTrade[] = [];
|
||||
for (const [code, list] of groups) {
|
||||
const sorted = [...list].sort((a, b) => a.at - b.at);
|
||||
const now = open.get(code);
|
||||
const before = now ?? known.get(code);
|
||||
// An OPEN leg, or one on the same side as the opening.
|
||||
const firstOpen = sorted.find((o) => o.effect === 'OPEN') ?? (before ? undefined : sorted.find((o) => o.effect === undefined));
|
||||
const openSide = firstOpen?.side ?? (before ? (before.side === 'long' ? 'BUY' : 'SELL') : undefined);
|
||||
if (!openSide) continue;
|
||||
const opens = sorted.filter((o) => o.effect === 'OPEN' || (o.effect === undefined && o.side === openSide));
|
||||
const closes = sorted.filter((o) => !opens.includes(o));
|
||||
if (opens.length === 0 && !before) continue;
|
||||
|
||||
const openPrice = opens.length > 0 ? vwap(opens) : before!.openPrice;
|
||||
const closeQty = closes.reduce((s, o) => s + o.qty, 0);
|
||||
const closed = !now && closeQty > 0;
|
||||
const last = closed ? closes[closes.length - 1] : undefined;
|
||||
const codes = sorted.map((o) => o.orderCode);
|
||||
const fees = codes.filter((c) => commission.has(c));
|
||||
const pnlCodes = closes.map((o) => o.orderCode).filter((c) => settled.has(c));
|
||||
out.push({
|
||||
positionCode: code,
|
||||
symbol: opens[0]?.instrument || before?.symbol || closes[0]?.instrument || '',
|
||||
side: openSide === 'BUY' ? 'long' : 'short',
|
||||
quantity: Math.max(opens.reduce((s, o) => s + o.qty, 0), now?.quantity ?? 0, before?.quantity ?? 0),
|
||||
openPrice: openPrice ?? now?.openPrice ?? null,
|
||||
closePrice: closed ? vwap(closes) : null,
|
||||
pnl: closed && pnlCodes.length > 0 ? Math.round(pnlCodes.reduce((s, c) => s + settled.get(c)!, 0) * 100) / 100 : null,
|
||||
fee: fees.length > 0 ? Math.round(fees.reduce((s, c) => s + commission.get(c)!, 0) * 100) / 100 : null,
|
||||
openedAt: opens.length > 0 ? opens[0].at : before!.openedAt,
|
||||
closedAt: last ? last.at : null,
|
||||
openCode: opens.length > 0 ? opens[0].orderCode : null,
|
||||
closeCode: last?.orderCode ?? null,
|
||||
closeType: last?.type ?? null,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* Trades out of fills, for the futures platforms (TopstepX, Tradovate). A
|
||||
* trade runs from flat back to flat: adds and partial closes stay inside it,
|
||||
* and a fill that crosses zero closes one trade and opens the next. Vest and
|
||||
* DXtrade report whole positions themselves.
|
||||
*
|
||||
* No imports, so scripts/check-fill-trades.mjs can test it directly.
|
||||
*/
|
||||
|
||||
export interface Fill {
|
||||
id: string;
|
||||
orderId: string;
|
||||
at: number;
|
||||
side: 'buy' | 'sell';
|
||||
qty: number;
|
||||
price: number;
|
||||
/** What the fill cost (positive); null when unknown. */
|
||||
fee: number | null;
|
||||
/** The P&L the platform booked on this fill, before fees (TopstepX's
|
||||
* closing half-turns); null to work it out from the prices. */
|
||||
pnl: number | null;
|
||||
}
|
||||
|
||||
/** A trade already stored as open, from before the fills at hand begin —
|
||||
* continued when they start part-way through a position. */
|
||||
export interface CarriedTrade {
|
||||
id: string;
|
||||
side: 'long' | 'short';
|
||||
quantity: number;
|
||||
openPrice: number | null;
|
||||
fee: number | null;
|
||||
openedAt: number;
|
||||
}
|
||||
|
||||
export interface FillTrade {
|
||||
/** The first fill's id, or the carried trade's. */
|
||||
id: string;
|
||||
side: 'long' | 'short';
|
||||
/** The most held at once. */
|
||||
quantity: number;
|
||||
/** Average of the fills that opened or added to it. */
|
||||
openPrice: number | null;
|
||||
/** Average of the fills that reduced it; null while open. */
|
||||
closePrice: number | null;
|
||||
/** Before fees; null while open, or when it can't be told. */
|
||||
pnl: number | null;
|
||||
fee: number | null;
|
||||
openedAt: number;
|
||||
closedAt: number | null;
|
||||
/** The order whose fill opened it; null for a carried trade. */
|
||||
openOrderId: string | null;
|
||||
/** The order whose fill took it flat; null while open. */
|
||||
closeOrderId: string | null;
|
||||
carried: boolean;
|
||||
}
|
||||
|
||||
const EPS = 1e-9;
|
||||
const round2 = (n: number) => Math.round(n * 100) / 100;
|
||||
const signed = (f: Fill) => (f.side === 'buy' ? f.qty : -f.qty);
|
||||
|
||||
/** Oldest first; ties by id, numerically when both are numbers. */
|
||||
export function byFillTime(a: Fill, b: Fill): number {
|
||||
if (a.at !== b.at) return a.at - b.at;
|
||||
const an = Number(a.id);
|
||||
const bn = Number(b.id);
|
||||
if (Number.isFinite(an) && Number.isFinite(bn)) return an - bn;
|
||||
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
||||
}
|
||||
|
||||
/** The position before the first of `fills`, given the one after the last. */
|
||||
export function startingNet(fills: Fill[], currentNet: number): number {
|
||||
const before = currentNet - fills.reduce((s, f) => s + signed(f), 0);
|
||||
return Math.abs(before) < EPS ? 0 : before;
|
||||
}
|
||||
|
||||
interface Building {
|
||||
id: string;
|
||||
dir: 1 | -1;
|
||||
net: number;
|
||||
maxAbs: number;
|
||||
openQty: number;
|
||||
openNotional: number | null;
|
||||
closeQty: number;
|
||||
closeNotional: number;
|
||||
platformPnl: number;
|
||||
pnlFromPlatform: boolean;
|
||||
fee: number | null;
|
||||
openedAt: number;
|
||||
openOrderId: string | null;
|
||||
carried: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The trades `fills` (one account, one contract) make. `startNet` is the
|
||||
* position before the first fill (startingNet): when it isn't flat, the
|
||||
* fills start inside a position — `carry` continues the stored trade when it
|
||||
* is on the same side, and without one those fills are passed over until the
|
||||
* account is flat. `pointValue` (dollars per point per contract) works out
|
||||
* the P&L of a fill the platform gave none for.
|
||||
*/
|
||||
export function fillsToTrades(fills: Fill[], o: { startNet: number; pointValue: number | null; carry?: CarriedTrade | null }): FillTrade[] {
|
||||
const out: FillTrade[] = [];
|
||||
let net = Math.abs(o.startNet) < EPS ? 0 : o.startNet;
|
||||
let cur: Building | null = null;
|
||||
if (net !== 0 && o.carry && (o.carry.side === 'long') === net > 0) {
|
||||
const qty = Math.abs(net);
|
||||
cur = {
|
||||
id: o.carry.id,
|
||||
dir: net > 0 ? 1 : -1,
|
||||
net,
|
||||
maxAbs: Math.max(qty, o.carry.quantity),
|
||||
openQty: qty,
|
||||
openNotional: o.carry.openPrice === null ? null : o.carry.openPrice * qty,
|
||||
closeQty: 0,
|
||||
closeNotional: 0,
|
||||
platformPnl: 0,
|
||||
pnlFromPlatform: true,
|
||||
fee: o.carry.fee,
|
||||
openedAt: o.carry.openedAt,
|
||||
openOrderId: null,
|
||||
carried: true,
|
||||
};
|
||||
}
|
||||
|
||||
const open = (f: Fill, qty: number, feeShare: number | null): Building => ({
|
||||
id: f.id,
|
||||
dir: f.side === 'buy' ? 1 : -1,
|
||||
net: f.side === 'buy' ? qty : -qty,
|
||||
maxAbs: qty,
|
||||
openQty: qty,
|
||||
openNotional: f.price * qty,
|
||||
closeQty: 0,
|
||||
closeNotional: 0,
|
||||
platformPnl: 0,
|
||||
pnlFromPlatform: true,
|
||||
fee: feeShare,
|
||||
openedAt: f.at,
|
||||
openOrderId: f.orderId,
|
||||
carried: false,
|
||||
});
|
||||
const addFee = (b: Building, fee: number | null) => {
|
||||
b.fee = b.fee === null || fee === null ? null : b.fee + fee;
|
||||
};
|
||||
|
||||
for (const f of [...fills].sort(byFillTime)) {
|
||||
if (!(f.qty > 0)) continue;
|
||||
const s = signed(f);
|
||||
if (cur === null && net !== 0) {
|
||||
// Inside a position whose start isn't on record: wait for flat.
|
||||
if (Math.sign(s) === Math.sign(net)) {
|
||||
net += s;
|
||||
continue;
|
||||
}
|
||||
const closing = Math.min(Math.abs(s), Math.abs(net));
|
||||
net += Math.sign(s) * closing;
|
||||
if (Math.abs(net) < EPS) net = 0;
|
||||
const rest = f.qty - closing;
|
||||
if (net === 0 && rest > EPS) {
|
||||
cur = open(f, rest, f.fee === null ? null : f.fee * (rest / f.qty));
|
||||
net = cur.net;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (cur === null) {
|
||||
cur = open(f, f.qty, f.fee);
|
||||
net = cur.net;
|
||||
continue;
|
||||
}
|
||||
if (Math.sign(s) === cur.dir) {
|
||||
cur.net += s;
|
||||
net = cur.net;
|
||||
cur.maxAbs = Math.max(cur.maxAbs, Math.abs(cur.net));
|
||||
cur.openQty += f.qty;
|
||||
cur.openNotional = cur.openNotional === null ? null : cur.openNotional + f.price * f.qty;
|
||||
addFee(cur, f.fee);
|
||||
continue;
|
||||
}
|
||||
const closing = Math.min(f.qty, Math.abs(cur.net));
|
||||
const rest = f.qty - closing;
|
||||
addFee(cur, f.fee === null ? null : f.fee * (closing / f.qty));
|
||||
cur.closeQty += closing;
|
||||
cur.closeNotional += f.price * closing;
|
||||
if (f.pnl === null) cur.pnlFromPlatform = false;
|
||||
else cur.platformPnl += f.pnl;
|
||||
cur.net += Math.sign(s) * closing;
|
||||
if (Math.abs(cur.net) < EPS) cur.net = 0;
|
||||
net = cur.net;
|
||||
if (cur.net !== 0) continue;
|
||||
|
||||
out.push(finish(cur, f, o.pointValue));
|
||||
cur = null;
|
||||
if (rest > EPS) {
|
||||
cur = open(f, rest, f.fee === null ? null : f.fee * (rest / f.qty));
|
||||
net = cur.net;
|
||||
}
|
||||
}
|
||||
if (cur !== null) {
|
||||
out.push({
|
||||
id: cur.id,
|
||||
side: cur.dir === 1 ? 'long' : 'short',
|
||||
quantity: cur.maxAbs,
|
||||
openPrice: cur.openNotional === null ? null : cur.openNotional / cur.openQty,
|
||||
closePrice: null,
|
||||
pnl: null,
|
||||
fee: cur.fee === null ? null : round2(cur.fee),
|
||||
openedAt: cur.openedAt,
|
||||
closedAt: null,
|
||||
openOrderId: cur.openOrderId,
|
||||
closeOrderId: null,
|
||||
carried: cur.carried,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function finish(b: Building, last: Fill, pointValue: number | null): FillTrade {
|
||||
// Flat to flat, so as much was bought as sold: the P&L is the difference
|
||||
// in what each side came to.
|
||||
const computed = b.openNotional === null || pointValue === null
|
||||
? null
|
||||
: b.dir * (b.closeNotional - b.openNotional) * pointValue;
|
||||
const pnl = b.pnlFromPlatform ? b.platformPnl : computed;
|
||||
return {
|
||||
id: b.id,
|
||||
side: b.dir === 1 ? 'long' : 'short',
|
||||
quantity: b.maxAbs,
|
||||
openPrice: b.openNotional === null ? null : b.openNotional / b.openQty,
|
||||
closePrice: b.closeQty > 0 ? b.closeNotional / b.closeQty : null,
|
||||
pnl: pnl === null ? null : round2(pnl),
|
||||
fee: b.fee === null ? null : round2(b.fee),
|
||||
openedAt: b.openedAt,
|
||||
closedAt: last.at,
|
||||
openOrderId: b.openOrderId,
|
||||
closeOrderId: last.orderId,
|
||||
carried: b.carried,
|
||||
};
|
||||
}
|
||||
|
||||
/** "MNQZ6" → "MNQ"; a name with no month code comes back as it is. */
|
||||
export function contractRoot(name: string): string {
|
||||
const m = /^([A-Z0-9]+?)[FGHJKMNQUVXZ]\d{1,2}$/.exec(name.trim().toUpperCase());
|
||||
return m ? m[1] : name.trim().toUpperCase();
|
||||
}
|
||||
+177
-53
@@ -17,10 +17,13 @@
|
||||
* the two are not linked yet.)
|
||||
*/
|
||||
|
||||
import { isFirmPlatform } from './master-protocol';
|
||||
import { sanitizeInstruments, DEFAULT_MAX_ENTRY_SPREAD } from './instrument-specs';
|
||||
import { isFirmPlatform, isNotionalPlatform } from './master-protocol';
|
||||
import { isVestWalletAddress } from './vest-helpers';
|
||||
import { sanitizeInstruments, sanitizeInstrumentLimits, DEFAULT_MAX_ENTRY_SPREAD } from './instrument-specs';
|
||||
import { normalizeAccountType } from './kept-rules';
|
||||
import type {
|
||||
CatalogAccountType, CatalogFirm, CatalogTradingSettings, FirmCatalogPayload, FirmPlatform, SymbolMap, SymbolMapCandidate,
|
||||
CatalogAccountType, CatalogFirm, CatalogTradingSettings, FirmCatalogPayload, FirmPlatform, KeptAccountRules, SymbolMap,
|
||||
SymbolMapCandidate,
|
||||
} from './master-protocol';
|
||||
import {
|
||||
getSetting,
|
||||
@@ -39,24 +42,53 @@ const FETCH_TIMEOUT_MS = 10_000;
|
||||
const COLD_RETRY_MIN_MS = 10_000;
|
||||
const COLD_RETRY_MAX_MS = 60_000;
|
||||
|
||||
/** The version of validatePayload below, stamped on the payload it caches.
|
||||
* Bump it whenever the parser starts keeping a field an older one dropped:
|
||||
* a cache from before then lacks the field however often it is re-read, and
|
||||
* `?since=` would answer "unchanged" until the master next changes, so the
|
||||
* first sync on a newer parser fetches the whole catalog.
|
||||
* 2: kept rule sets (keptAccountRules) and the firm's maxConcurrentAccounts.
|
||||
* Stamped only on a payload from a master that sends keptAccountRules: one
|
||||
* from an older master can't vouch for kept sets it never sent.
|
||||
* 3: Vest firms, which a build from before the platform skipped. A platform
|
||||
* learned later needs no bump: the cache names the platforms it skipped
|
||||
* (skippedPlatforms), and a build that knows one fetches in full. */
|
||||
export const CATALOG_PARSER_VERSION = 3;
|
||||
|
||||
/** A Date header further off than this is somebody else's clock, not the master's. */
|
||||
const MAX_MASTER_OFFSET_MS = 365 * 24 * 60 * 60 * 1000;
|
||||
|
||||
interface CatalogCache {
|
||||
payload: FirmCatalogPayload | null;
|
||||
/** Index over every firm's account types, for the id lookup. */
|
||||
typesById: Map<string, CatalogAccountType>;
|
||||
/** Each live account type's kept rule sets, by closedAt (oldest window
|
||||
* first). Kept ids are deliberately not in typesById: nothing can be
|
||||
* pinned to one. */
|
||||
keptByLiveId: Map<string, KeptAccountRules[]>;
|
||||
}
|
||||
|
||||
const g = global as typeof globalThis & {
|
||||
__firmCatalog?: CatalogCache;
|
||||
__firmCatalogTimer?: ReturnType<typeof setTimeout>;
|
||||
__firmCatalogColdDelay?: number;
|
||||
/** The master's clock minus this one, from the last sync's Date header. */
|
||||
__masterOffsetMs?: number;
|
||||
};
|
||||
|
||||
function indexPayload(payload: FirmCatalogPayload | null): CatalogCache {
|
||||
const typesById = new Map<string, CatalogAccountType>();
|
||||
const keptByLiveId = new Map<string, KeptAccountRules[]>();
|
||||
for (const firm of payload?.firms ?? []) {
|
||||
for (const type of firm.accountTypes) typesById.set(type.id, type);
|
||||
for (const kept of firm.keptAccountRules ?? []) {
|
||||
const list = keptByLiveId.get(kept.keptFrom);
|
||||
if (list) list.push(kept);
|
||||
else keptByLiveId.set(kept.keptFrom, [kept]);
|
||||
}
|
||||
}
|
||||
return { payload, typesById };
|
||||
for (const list of keptByLiveId.values()) list.sort((a, b) => Date.parse(a.closedAt) - Date.parse(b.closedAt));
|
||||
return { payload, typesById, keptByLiveId };
|
||||
}
|
||||
|
||||
function cache(): CatalogCache {
|
||||
@@ -66,7 +98,18 @@ function cache(): CatalogCache {
|
||||
const raw = getSetting('catalog_payload');
|
||||
if (raw) {
|
||||
try {
|
||||
payload = validatePayload(JSON.parse(raw));
|
||||
const stored = JSON.parse(raw);
|
||||
payload = validatePayload(stored);
|
||||
// Whatever parsed the blob when it was stored, not this parser: a
|
||||
// field an older build dropped on the way in can't come back from
|
||||
// re-reading the blob.
|
||||
const parser = (stored as { parser?: unknown }).parser;
|
||||
if (typeof parser === 'number') payload.parser = parser;
|
||||
else delete payload.parser;
|
||||
// Likewise the platforms it skipped: their firms aren't in the blob.
|
||||
const skipped = (stored as { skippedPlatforms?: unknown }).skippedPlatforms;
|
||||
if (Array.isArray(skipped)) payload.skippedPlatforms = skipped.filter((p): p is string => typeof p === 'string');
|
||||
else delete payload.skippedPlatforms;
|
||||
} catch {
|
||||
console.error('[catalog] cached payload is unreadable — ignoring it');
|
||||
}
|
||||
@@ -87,6 +130,37 @@ export function getAccountTypeById(typeId: string): CatalogAccountType | undefin
|
||||
return cache().typesById.get(typeId);
|
||||
}
|
||||
|
||||
/** The kept rule sets of one live account type, oldest window first ([] when
|
||||
* it keeps none). lib/account-type-resolver.ts picks an account's by its
|
||||
* rules date. */
|
||||
export function getKeptRulesFor(liveTypeId: string): KeptAccountRules[] {
|
||||
return cache().keptByLiveId.get(liveTypeId) ?? [];
|
||||
}
|
||||
|
||||
/** The kept rule set ids loaded for a firm, for the report — null while the
|
||||
* cached catalog came from a parser older than CATALOG_PARSER_VERSION, which
|
||||
* can't be trusted to hold them (the master then reads this machine as not
|
||||
* applying kept rules yet). */
|
||||
export function getKeptRuleIdsForFirm(firmId: string): string[] | null {
|
||||
const payload = cache().payload;
|
||||
if (!payload || (payload.parser ?? 0) < CATALOG_PARSER_VERSION) return null;
|
||||
return (payload.firms.find((f) => f.id === firmId)?.keptAccountRules ?? []).map((k) => k.id);
|
||||
}
|
||||
|
||||
/** The master's clock minus this machine's, as of the last good sync (null
|
||||
* before one this process). Dates new accounts on the master's clock when
|
||||
* this one runs behind (lib/kept-rules.ts stampRulesDate). */
|
||||
export function getMasterOffsetMs(): number | null {
|
||||
return g.__masterOffsetMs ?? null;
|
||||
}
|
||||
|
||||
/** When the cached catalog last changed on the master (its payload's
|
||||
* updatedAt); null with no cache. No account first seen after a change can
|
||||
* be dated before it. */
|
||||
export function getCatalogUpdatedAt(): string | null {
|
||||
return cache().payload?.updatedAt ?? null;
|
||||
}
|
||||
|
||||
export function getCatalogMeta(): {
|
||||
version: number;
|
||||
updatedAt: string | null;
|
||||
@@ -104,7 +178,8 @@ 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). */
|
||||
* TopstepX API key, a username + password (Tradovate, DXTrade), or for Vest
|
||||
* the login's wallet address (its session is relayed by the extension). */
|
||||
export function hasPlatformCredentials(
|
||||
platform: FirmPlatform,
|
||||
creds: { username: string; password: string; apiKey: string } | null | undefined,
|
||||
@@ -112,6 +187,7 @@ export function hasPlatformCredentials(
|
||||
if (!creds) return false;
|
||||
switch (platform) {
|
||||
case 'topstepx': return !!creds.apiKey;
|
||||
case 'vest': return isVestWalletAddress(creds.apiKey);
|
||||
case 'tradovate':
|
||||
case 'dxtrade': return !!creds.username && !!creds.password;
|
||||
}
|
||||
@@ -131,7 +207,10 @@ export function getTradableFirms(): (CatalogFirm & { username: string; password:
|
||||
|
||||
// ── Sync ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const g2 = global as typeof globalThis & { __catalogUnknownPlatformWarned?: Set<string> };
|
||||
const g2 = global as typeof globalThis & {
|
||||
__catalogUnknownPlatformWarned?: Set<string>;
|
||||
__catalogKeptSkipWarned?: Set<string>;
|
||||
};
|
||||
|
||||
/** Once per firm per process — the catalog is re-validated every 60s. */
|
||||
function warnUnknownPlatform(id: string, name: string, platform: unknown): void {
|
||||
@@ -164,12 +243,41 @@ function sanitizeSymbolMap(raw: unknown): SymbolMap {
|
||||
return map;
|
||||
}
|
||||
|
||||
function isStages(value: unknown): value is { profit: number; consistency: number; minTradingDays: number }[] {
|
||||
return Array.isArray(value) && value.every((s) =>
|
||||
typeof s === 'object' && s !== null
|
||||
&& typeof (s as Record<string, unknown>).profit === 'number'
|
||||
&& typeof (s as Record<string, unknown>).consistency === 'number'
|
||||
&& typeof (s as Record<string, unknown>).minTradingDays === 'number');
|
||||
function isDateString(value: unknown): value is string {
|
||||
return typeof value === 'string' && Number.isFinite(Date.parse(value));
|
||||
}
|
||||
|
||||
/**
|
||||
* A firm's kept rule sets, each as strict as an account type: a malformed
|
||||
* entry throws and the whole sync is rejected. One that names no live type
|
||||
* of this firm is skipped instead (warned once) — no account can map to it.
|
||||
*/
|
||||
function parseKeptRules(
|
||||
firm: { id: string; name: string },
|
||||
raw: unknown[],
|
||||
accountTypes: CatalogAccountType[],
|
||||
tradesEveryDay: boolean,
|
||||
): KeptAccountRules[] {
|
||||
const liveIds = new Set(accountTypes.map((t) => t.id));
|
||||
const kept: KeptAccountRules[] = [];
|
||||
raw.forEach((k: unknown, j: number) => {
|
||||
const where = `kept rules ${j} of ${firm.name}`;
|
||||
const type = normalizeAccountType(k, { tradesEveryDay, where });
|
||||
const entry = k as Record<string, unknown>;
|
||||
if (typeof entry.keptFrom !== 'string') throw new Error(`${where} is missing keptFrom`);
|
||||
if (entry.openedAt !== null && !isDateString(entry.openedAt)) throw new Error(`${where} has bad openedAt`);
|
||||
if (!isDateString(entry.closedAt)) throw new Error(`${where} has bad closedAt`);
|
||||
if (!liveIds.has(entry.keptFrom) || type.firmId !== firm.id) {
|
||||
const warned = (g2.__catalogKeptSkipWarned ??= new Set());
|
||||
if (!warned.has(type.id)) {
|
||||
warned.add(type.id);
|
||||
console.warn(`[catalog] skipping kept rules ${type.id} of "${firm.name}": it names no account type of this firm (keptFrom ${entry.keptFrom}, firmId ${type.firmId})`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
kept.push({ ...type, keptFrom: entry.keptFrom, openedAt: entry.openedAt, closedAt: entry.closedAt });
|
||||
});
|
||||
return kept;
|
||||
}
|
||||
|
||||
/** Rejects anything that isn't a complete catalog. A half-parsed payload would
|
||||
@@ -182,6 +290,8 @@ function validatePayload(raw: unknown): FirmCatalogPayload {
|
||||
if (!Array.isArray(body.firms)) throw new Error('missing firms');
|
||||
|
||||
const firms: CatalogFirm[] = [];
|
||||
// Stamped on the payload, so a build that learns one refetches its firms.
|
||||
const skippedPlatforms = new Set<string>();
|
||||
body.firms.forEach((f: unknown, i: number) => {
|
||||
if (typeof f !== 'object' || f === null) throw new Error(`firm ${i} is not an object`);
|
||||
const firm = f as Record<string, unknown>;
|
||||
@@ -196,47 +306,23 @@ function validatePayload(raw: unknown): FirmCatalogPayload {
|
||||
const platform = firm.platform === undefined ? 'tradovate' : firm.platform;
|
||||
if (!isFirmPlatform(platform)) {
|
||||
warnUnknownPlatform(firm.id, firm.name, platform);
|
||||
skippedPlatforms.add(String(platform));
|
||||
return;
|
||||
}
|
||||
// Percent commission and every-day trading are DXtrade settings;
|
||||
// futures firms keep measured per-contract fees and the CME calendar.
|
||||
const dx = platform === 'dxtrade';
|
||||
// Percent commission and every-day trading are settings of the
|
||||
// notional platforms (DXtrade, Vest); futures firms keep measured
|
||||
// per-contract fees and the CME calendar.
|
||||
const dx = isNotionalPlatform(platform);
|
||||
const tradesEveryDay = dx && firm.tradesEveryDay === true;
|
||||
if (!Array.isArray(firm.accountTypes)) throw new Error(`firm ${firm.name} is missing accountTypes`);
|
||||
|
||||
const accountTypes: CatalogAccountType[] = firm.accountTypes.map((t: unknown, j: number) => {
|
||||
if (typeof t !== 'object' || t === null) throw new Error(`account type ${j} is not an object`);
|
||||
const type = t as Record<string, unknown>;
|
||||
for (const key of ['id', 'firmId', 'label', 'prefix', 'updatedAt']) {
|
||||
if (typeof type[key] !== 'string') throw new Error(`account type ${j} is missing ${key}`);
|
||||
}
|
||||
for (const key of [
|
||||
'accountSize', 'profitTarget', 'consistency', 'minDayPnL',
|
||||
'minTradingDays', 'maxLoss', 'maxPositionSize',
|
||||
]) {
|
||||
if (typeof type[key] !== 'number') throw new Error(`account type ${j} is missing ${key}`);
|
||||
}
|
||||
if (!isStages(type.withdrawalStages)) throw new Error(`account type ${j} has bad withdrawalStages`);
|
||||
// Lenient like platform: a master from before the split toggle
|
||||
// doesn't send the field, and those account types all split. Same
|
||||
// for CME holiday combining, where the old behavior was "off".
|
||||
return {
|
||||
...type,
|
||||
splitEnabled: type.splitEnabled !== false,
|
||||
// An every-day firm has no CME calendar to fold holidays by.
|
||||
combineCmeHolidays: type.combineCmeHolidays === true && !tradesEveryDay,
|
||||
// Lenient: a master from before the min-legs field means "no minimum".
|
||||
minLegs: typeof type.minLegs === 'number' ? type.minLegs : 0,
|
||||
// Lenient: the obfuscation stops are opt-in, so absent means off.
|
||||
firstDayStop: type.firstDayStop === true,
|
||||
minDayStop: type.minDayStop === true,
|
||||
// Lenient: no hard daily limit, no notional cap.
|
||||
dailyLossLimit: nonNegative(type.dailyLossLimit),
|
||||
// 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;
|
||||
});
|
||||
const accountTypes: CatalogAccountType[] = firm.accountTypes.map((t: unknown, j: number) =>
|
||||
normalizeAccountType(t, { tradesEveryDay, where: `account type ${j}` }));
|
||||
// Only when the master sent the list: absent = a master from before
|
||||
// kept rules, [] = every account on the current rules.
|
||||
const keptAccountRules = Array.isArray(firm.keptAccountRules)
|
||||
? parseKeptRules({ id: firm.id, name: firm.name }, firm.keptAccountRules, accountTypes, tradesEveryDay)
|
||||
: undefined;
|
||||
|
||||
firms.push({
|
||||
id: firm.id,
|
||||
@@ -245,8 +331,10 @@ function validatePayload(raw: unknown): FirmCatalogPayload {
|
||||
// The master's list when it sends one; absent from an older
|
||||
// master, whose bannedSymbols / symbolMap then apply.
|
||||
...(Array.isArray(firm.instruments) ? { instruments: sanitizeInstruments(platform, firm.instruments) } : {}),
|
||||
...(!dx ? { instrumentLimits: sanitizeInstrumentLimits(firm.instrumentLimits) } : {}),
|
||||
bannedSymbols: Array.isArray(firm.bannedSymbols) ? (firm.bannedSymbols as string[]) : [],
|
||||
accountTypes,
|
||||
...(keptAccountRules ? { keptAccountRules } : {}),
|
||||
serverUrl: typeof firm.serverUrl === 'string' ? firm.serverUrl : '',
|
||||
loginDomain: typeof firm.loginDomain === 'string' ? firm.loginDomain : '',
|
||||
commissionPct: dx ? nonNegative(firm.commissionPct) : 0,
|
||||
@@ -254,12 +342,25 @@ function validatePayload(raw: unknown): FirmCatalogPayload {
|
||||
symbolMap: sanitizeSymbolMap(firm.symbolMap),
|
||||
targetHoldHours: nonNegative(firm.targetHoldHours),
|
||||
financingPctPerDay: nonNegative(firm.financingPctPerDay),
|
||||
// Lenient: absent or malformed = no firm cap.
|
||||
maxConcurrentAccounts: Math.floor(nonNegative(firm.maxConcurrentAccounts)),
|
||||
updatedAt: typeof firm.updatedAt === 'string' ? firm.updatedAt : new Date(0).toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
const trading = parseTradingSettings(body.trading);
|
||||
return { version: body.version, updatedAt: body.updatedAt, firms, ...(trading ? { trading } : {}) };
|
||||
// A kept-aware master sends keptAccountRules on every firm (even []). A
|
||||
// payload without it is a master from before kept rules: cached without
|
||||
// a parser version, so the cache isn't trusted to hold kept sets — every
|
||||
// sync fetches in full (no `since`), and the first one after a kept-aware
|
||||
// master is back brings them, though its catalog version never moved.
|
||||
const keptAware = body.firms.every((f: unknown) =>
|
||||
typeof f === 'object' && f !== null && Array.isArray((f as Record<string, unknown>).keptAccountRules));
|
||||
return {
|
||||
version: body.version, updatedAt: body.updatedAt, firms, ...(trading ? { trading } : {}),
|
||||
...(keptAware ? { parser: CATALOG_PARSER_VERSION } : {}),
|
||||
...(skippedPlatforms.size > 0 ? { skippedPlatforms: [...skippedPlatforms] } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** The master's trading settings; undefined when it sent none (or nothing
|
||||
@@ -292,6 +393,17 @@ function recordError(message: string): void {
|
||||
setSetting('catalog_last_sync_at', new Date().toISOString());
|
||||
}
|
||||
|
||||
/** The master's clock off a response's Date header (whole seconds, read on
|
||||
* arrival — close enough to date accounts by). An unreadable header, or one
|
||||
* a year off, leaves the last offset standing. */
|
||||
function recordMasterClock(header: string | null): void {
|
||||
const at = header ? Date.parse(header) : NaN;
|
||||
if (!Number.isFinite(at)) return;
|
||||
const offset = at - Date.now();
|
||||
if (Math.abs(offset) > MAX_MASTER_OFFSET_MS) return;
|
||||
g.__masterOffsetMs = offset;
|
||||
}
|
||||
|
||||
/** If the master's database was rebuilt, its firms come back with fresh ids and
|
||||
* every credential row orphans. Match the survivors up by name (once) rather
|
||||
* than making someone retype every password. */
|
||||
@@ -322,14 +434,20 @@ export async function syncCatalog(): Promise<{ ok: boolean; changed: boolean; ve
|
||||
// before anyone has got round to naming it.
|
||||
// `since` only against the master the cache came from: two masters'
|
||||
// version numbers can collide, and an 'unchanged' from the wrong one
|
||||
// would keep the other's catalog.
|
||||
// would keep the other's catalog. Nor for a cache an older parser wrote:
|
||||
// it lacks what that parser dropped, and 'unchanged' would keep it so —
|
||||
// fields, or firms on a platform it didn't know and this build does.
|
||||
const hasCache = getCachedCatalog().length > 0;
|
||||
const source = url.origin;
|
||||
if (hasCache && getSetting('catalog_source_url') === source) url.searchParams.set('since', String(version));
|
||||
const cached = cache().payload;
|
||||
const cacheCurrent = (cached?.parser ?? 0) >= CATALOG_PARSER_VERSION
|
||||
&& !(cached?.skippedPlatforms ?? []).some((p) => isFirmPlatform(p));
|
||||
if (hasCache && cacheCurrent && getSetting('catalog_source_url') === source) url.searchParams.set('since', String(version));
|
||||
|
||||
try {
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
||||
if (!res.ok) throw new Error(`master returned ${res.status}`);
|
||||
recordMasterClock(res.headers.get('date'));
|
||||
const body = await res.json();
|
||||
|
||||
if (body && typeof body === 'object' && (body as Record<string, unknown>).unchanged === true) {
|
||||
@@ -352,13 +470,19 @@ export async function syncCatalog(): Promise<{ ok: boolean; changed: boolean; ve
|
||||
}
|
||||
|
||||
// Same reasoning one level down: a firm losing every account type
|
||||
// unassigns all of its accounts. Keep the previous types and flag it.
|
||||
// unassigns all of its accounts. Keep the previous types and flag it,
|
||||
// with the kept rule sets that went with them (whose keptFrom named
|
||||
// types this payload lacks, so none of its own survived parsing). An
|
||||
// empty kept list on its own is never suspicious: it is every account
|
||||
// on the current rules.
|
||||
const previous = new Map(getCachedCatalog().map((f) => [f.id, f]));
|
||||
const suspicious: string[] = [];
|
||||
for (const firm of payload.firms) {
|
||||
const before = previous.get(firm.id);
|
||||
if (firm.accountTypes.length === 0 && before && before.accountTypes.length > 0) {
|
||||
firm.accountTypes = before.accountTypes;
|
||||
if (before.keptAccountRules) firm.keptAccountRules = before.keptAccountRules;
|
||||
else delete firm.keptAccountRules;
|
||||
suspicious.push(firm.name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/** All application Axios imports belong here. New calls and axios.create()
|
||||
* clients inherit the instance's browser identity, proxy and timeout. */
|
||||
import axios, { type AxiosInstance } from 'axios';
|
||||
import { applyBrokerHttpDefaults, applyBrokerProxy } from './outbound-proxy';
|
||||
import { getRequestUserAgent } from './request-user-agent';
|
||||
|
||||
const g = globalThis as typeof globalThis & { __configuredHttpClients?: WeakSet<AxiosInstance> };
|
||||
const configured = g.__configuredHttpClients ??= new WeakSet<AxiosInstance>();
|
||||
|
||||
function configure<T extends AxiosInstance>(client: T): T {
|
||||
if (configured.has(client)) return client;
|
||||
applyBrokerHttpDefaults(client.defaults);
|
||||
// Builds import route modules but must not choose an instance's identity.
|
||||
if (process.env.NEXT_PHASE !== 'phase-production-build') {
|
||||
client.defaults.headers.common['User-Agent'] = getRequestUserAgent();
|
||||
try {
|
||||
applyBrokerProxy(client.defaults);
|
||||
} catch (err) {
|
||||
console.error(`[proxy] could not apply proxy_url at load: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
client.interceptors.request.use((config) => {
|
||||
// Force replacement even for differently cased headers or `false`.
|
||||
// This also replaces a stale per-call UA without losing auth headers.
|
||||
config.headers.set('User-Agent', getRequestUserAgent(), true);
|
||||
return config;
|
||||
}, undefined, { synchronous: true });
|
||||
|
||||
// Axios copies defaults into child clients, but not interceptors.
|
||||
const create = client.create.bind(client);
|
||||
client.create = (options) => configure(create(options));
|
||||
configured.add(client);
|
||||
return client;
|
||||
}
|
||||
|
||||
export default configure(axios);
|
||||
@@ -12,8 +12,6 @@
|
||||
* No key, no account: POST https://api.hyperliquid.xyz/info.
|
||||
*/
|
||||
|
||||
import axios from 'axios';
|
||||
|
||||
const INFO_URL = 'https://api.hyperliquid.xyz/info';
|
||||
const TIMEOUT_MS = 10_000;
|
||||
const META_TTL_MS = 60 * 60_000;
|
||||
@@ -21,6 +19,8 @@ const META_TTL_MS = 60 * 60_000;
|
||||
const DEXES = ['', 'xyz'];
|
||||
|
||||
async function info<T>(body: Record<string, unknown>): Promise<T> {
|
||||
// Keep the pure quote helpers usable without opening the instance database.
|
||||
const { default: axios } = await import('./http-client');
|
||||
const res = await axios.post(INFO_URL, body, {
|
||||
timeout: TIMEOUT_MS,
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS + 5_000),
|
||||
|
||||
+22
-6
@@ -107,10 +107,24 @@ export function dailyMoveDollars(closes: number[], pointValue: number, barsPerDa
|
||||
}
|
||||
|
||||
/** Contracts a futures leg trades: one per $1,000, capped by the account
|
||||
* type's max contracts (0 = no cap) — the rule fireLeg sizes by. */
|
||||
export function futuresContracts(leg: number, maxPositionSize: number): number {
|
||||
* type's max contracts and the firm's limit on the instrument (0 = no
|
||||
* cap) — the rule fireLeg sizes by. */
|
||||
export function futuresContracts(leg: number, maxPositionSize: number, instrumentLimit = 0): number {
|
||||
const raw = Math.max(1, Math.ceil(leg / 1000));
|
||||
return maxPositionSize > 0 ? Math.min(raw, maxPositionSize) : raw;
|
||||
const caps = [maxPositionSize, instrumentLimit].filter((c) => c > 0);
|
||||
return caps.length > 0 ? Math.min(raw, ...caps) : raw;
|
||||
}
|
||||
|
||||
/** The most contracts a firm allows on one instrument, as its catalog entry
|
||||
* says: CME roots only, whole numbers from 1 to 1000. Anything else is
|
||||
* dropped — no limit beyond the account type's max contracts. */
|
||||
export function sanitizeInstrumentLimits(raw: unknown): Record<string, number> {
|
||||
const out: Record<string, number> = {};
|
||||
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return out;
|
||||
for (const [root, value] of Object.entries(raw as Record<string, unknown>)) {
|
||||
if (CME_SPECS[root] && typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= 1000) out[root] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -245,7 +259,9 @@ const DXTRADE_SYMBOL = /^[A-Za-z0-9./_:-]{1,32}$/;
|
||||
* specs for, or valid DXtrade symbols. Deduplicated, in order. */
|
||||
export function sanitizeInstruments(platform: string, raw: unknown): string[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const futures = platform !== 'dxtrade';
|
||||
// Notional platforms (DXtrade, Vest) list their own symbols. Spelled out
|
||||
// here: this file has no imports (see master-protocol isNotionalPlatform).
|
||||
const futures = platform !== 'dxtrade' && platform !== 'vest';
|
||||
const out: string[] = [];
|
||||
for (const value of raw) {
|
||||
if (typeof value !== 'string') continue;
|
||||
@@ -267,10 +283,10 @@ export function resolveAllowlist(
|
||||
localEnabled: readonly string[],
|
||||
): { roots: string[]; source: 'master' | 'legacy' } {
|
||||
if (Array.isArray(firm.instruments)) return { roots: sanitizeInstruments(firm.platform, firm.instruments), source: 'master' };
|
||||
if (firm.platform === 'dxtrade') {
|
||||
if (firm.platform === 'dxtrade' || firm.platform === 'vest') {
|
||||
const map = firm.symbolMap ?? {};
|
||||
const keys = Object.keys(map).sort((a, b) => (a === '*' ? -1 : b === '*' ? 1 : a.localeCompare(b)));
|
||||
return { roots: sanitizeInstruments('dxtrade', keys.flatMap((k) => (map[k] ?? []).map((c) => c.symbol))), source: 'legacy' };
|
||||
return { roots: sanitizeInstruments(firm.platform, keys.flatMap((k) => (map[k] ?? []).map((c) => c.symbol))), source: 'legacy' };
|
||||
}
|
||||
const banned = new Set(firm.bannedSymbols ?? []);
|
||||
return { roots: sanitizeInstruments(firm.platform, localEnabled.filter((r) => !banned.has(r))), source: 'legacy' };
|
||||
|
||||
+17
-7
@@ -19,7 +19,7 @@
|
||||
* entry path asks spreadBlock before sending.
|
||||
*/
|
||||
|
||||
import axios from 'axios';
|
||||
import axios from './http-client';
|
||||
import { getInstruments, getSetting } from './db';
|
||||
import { getTradableFirms, getTradingSettings } from './firm-catalog';
|
||||
import { entrySpread, quoteSource } from './live-quotes';
|
||||
@@ -27,6 +27,7 @@ import { getCachedContract } from './contract-resolver';
|
||||
import { currentTradeDateCT } from './trading-logic';
|
||||
import { errorMessage } from './platform-client';
|
||||
import type { InstrumentStats } from './master-protocol';
|
||||
import { isNotionalPlatform } from './master-protocol';
|
||||
import {
|
||||
CME_SPECS, CME_ROOTS, dailyMoveDollars, futuresContracts, expectedHoldHours, sessionProgress,
|
||||
drawChances, pickByChance, resolveAllowlist, spreadTooWide, MAX_HOLD_HOURS, type DrawCandidate,
|
||||
@@ -36,6 +37,7 @@ export interface FirmLike {
|
||||
id?: string;
|
||||
platform: string;
|
||||
instruments?: string[];
|
||||
instrumentLimits?: Record<string, number>;
|
||||
bannedSymbols?: string[];
|
||||
symbolMap?: Record<string, { symbol: string }[]>;
|
||||
}
|
||||
@@ -55,7 +57,7 @@ export function instrumentsSource(firm: FirmLike): 'master' | 'legacy' {
|
||||
}
|
||||
|
||||
export function isFuturesFirm(firm: { platform: string }): boolean {
|
||||
return firm.platform !== 'dxtrade';
|
||||
return !isNotionalPlatform(firm.platform);
|
||||
}
|
||||
|
||||
/** Every root a futures firm on this machine lists, in CME order — what the
|
||||
@@ -97,7 +99,6 @@ async function fetchMarket(root: string): Promise<void> {
|
||||
try {
|
||||
const res = await axios.get(`https://query1.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(spec.yahoo)}`, {
|
||||
params: { interval: '60m', range: '7d' },
|
||||
headers: { 'User-Agent': 'Mozilla/5.0' },
|
||||
timeout: FETCH_TIMEOUT_MS,
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS + 5_000),
|
||||
});
|
||||
@@ -155,6 +156,8 @@ export interface LegShape {
|
||||
leg: number;
|
||||
stopDollars: number;
|
||||
maxPositionSize: number;
|
||||
/** The firm's most contracts per instrument, by root (absent = none). */
|
||||
limits?: Record<string, number>;
|
||||
}
|
||||
|
||||
function progressNow(now: Date): number | null {
|
||||
@@ -188,11 +191,13 @@ export function futuresBoard(roots: readonly string[], leg: LegShape | null, now
|
||||
disabled: string[];
|
||||
wide: string[];
|
||||
} {
|
||||
const contracts = leg ? futuresContracts(leg.leg, leg.maxPositionSize) : 1;
|
||||
const max = maxEntrySpread();
|
||||
const candidates = roots.filter((r) => CME_SPECS[r]).map((root) => {
|
||||
const dailyMove = market.get(root)?.dailyMove ?? null;
|
||||
const spread = entrySpread(root, now.getTime());
|
||||
// The size this leg would trade on this root: a firm's cap on the
|
||||
// instrument slows it, and may push it past the hold cap.
|
||||
const contracts = leg ? futuresContracts(leg.leg, leg.maxPositionSize, leg.limits?.[root] ?? 0) : 1;
|
||||
return {
|
||||
symbol: root,
|
||||
spreadDollars: spread.dollars,
|
||||
@@ -254,10 +259,10 @@ export function instrumentStatsFor(
|
||||
lead: (LegShape & { account: string }) | null,
|
||||
): InstrumentStats {
|
||||
const listed = firmInstruments(firm);
|
||||
const board = futuresBoard(listed, lead);
|
||||
const limits = firm.instrumentLimits ?? {};
|
||||
const board = futuresBoard(listed, lead ? { ...lead, limits } : null);
|
||||
const disabled = new Set(board.disabled);
|
||||
const max = maxEntrySpread();
|
||||
const leg = lead ? { leg: lead.leg, stopDollars: lead.stopDollars, contracts: futuresContracts(lead.leg, lead.maxPositionSize) } : null;
|
||||
return {
|
||||
at: new Date().toISOString(),
|
||||
account: lead?.account ?? null,
|
||||
@@ -268,7 +273,12 @@ export function instrumentStatsFor(
|
||||
quoteSource: quoteSource(),
|
||||
instruments: CME_ROOTS.map((root) => {
|
||||
const dailyMove = market.get(root)?.dailyMove ?? null;
|
||||
const holdHours = leg ? expectedHoldHours({ ...leg, dailyMove }) : null;
|
||||
const holdHours = lead
|
||||
? expectedHoldHours({
|
||||
leg: lead.leg, stopDollars: lead.stopDollars, dailyMove,
|
||||
contracts: futuresContracts(lead.leg, lead.maxPositionSize, limits[root] ?? 0),
|
||||
})
|
||||
: null;
|
||||
const spread = entrySpread(root);
|
||||
return {
|
||||
symbol: root,
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
/**
|
||||
* Kept rules — the old rule sets a firm's account type keeps for its older
|
||||
* accounts.
|
||||
*
|
||||
* When the owner changes an account type's rules on the master's Firms page
|
||||
* and picks "New accounts only", the master freezes the old rules as a kept
|
||||
* rule set with a window [openedAt, closedAt). Every account here carries a
|
||||
* rules date (account_assignments.rules_date): an account whose date falls
|
||||
* inside a window trades its type's live fields with that set's RULE fields
|
||||
* on top; every other account trades the current rules. The account keeps
|
||||
* reporting the live type's id — a kept set is never an account type.
|
||||
*
|
||||
* Pure (only `import type`), so scripts/check-kept-rules.mjs loads it
|
||||
* directly. The field lists must stay identical to the master's
|
||||
* lib/kept-rules.ts.
|
||||
*/
|
||||
|
||||
import type { CatalogAccountType, KeptAccountRules } from './master-protocol';
|
||||
|
||||
/** The fields a kept rule set freezes: what the bot trades by. Changing one
|
||||
* is what asks the owner "new accounts only, or all accounts?". */
|
||||
export const RULE_FIELDS = [
|
||||
'profitTarget', 'consistency', 'minDayPnL', 'minTradingDays', 'maxLoss',
|
||||
'maxPositionSize', 'targetSameEquity', 'splitEnabled', 'minLegs', 'firstDayStop', 'minDayStop',
|
||||
'combineCmeHolidays', 'withdrawalStages', 'payoutable', 'trailingDrawdown', 'dailyLossLimit', 'stagesSameDay',
|
||||
] as const satisfies readonly (keyof CatalogAccountType)[];
|
||||
|
||||
/** Always the live type's: accounts are matched to a type by them, so a kept
|
||||
* set can't hold different ones (the master refuses "new accounts only"
|
||||
* when either changes). */
|
||||
export const IDENTITY_FIELDS = ['prefix', 'accountSize'] as const satisfies readonly (keyof CatalogAccountType)[];
|
||||
|
||||
/** The payout calendar's projection inputs: always the live type's, never
|
||||
* frozen. (Per-stage withdrawalPct / payoutCap ride inside withdrawalStages,
|
||||
* so they are rules.) */
|
||||
export const PROJECTION_FIELDS = [
|
||||
'expectedPayout', 'withdrawalPct', 'payoutCap', 'profitSplit',
|
||||
] as const satisfies readonly (keyof CatalogAccountType)[];
|
||||
|
||||
export type RuleField = (typeof RULE_FIELDS)[number];
|
||||
|
||||
/** Which kept rule set an account trades on. */
|
||||
export interface KeptRulesWindow {
|
||||
id: string;
|
||||
openedAt: string | null;
|
||||
closedAt: string;
|
||||
}
|
||||
|
||||
/** The rules an account trades on: its live account type, or — when its
|
||||
* rules date falls in a kept set — the live type with that set's rules on
|
||||
* top, marked with `keptRules`. */
|
||||
export type EffectiveAccountType = CatalogAccountType & { keptRules?: KeptRulesWindow };
|
||||
|
||||
// ── Catalog parsing ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Same as lib/firm-catalog.ts's: a positive finite number, else 0. */
|
||||
function nonNegative(value: unknown): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0;
|
||||
}
|
||||
|
||||
function isStages(value: unknown): value is { profit: number; consistency: number; minTradingDays: number }[] {
|
||||
return Array.isArray(value) && value.every((s) =>
|
||||
typeof s === 'object' && s !== null
|
||||
&& typeof (s as Record<string, unknown>).profit === 'number'
|
||||
&& typeof (s as Record<string, unknown>).consistency === 'number'
|
||||
&& typeof (s as Record<string, unknown>).minTradingDays === 'number');
|
||||
}
|
||||
|
||||
/**
|
||||
* One account type off the master's catalog, validated and normalized — the
|
||||
* live types and the kept rule sets alike, so a kept set reads exactly the
|
||||
* way its type did. Throws on anything malformed (`where` names it in the
|
||||
* message): a half-parsed catalog would silently unassign accounts, so the
|
||||
* whole sync is better rejected and the last good cache kept.
|
||||
*/
|
||||
export function normalizeAccountType(
|
||||
raw: unknown,
|
||||
ctx: { tradesEveryDay: boolean; where: string },
|
||||
): CatalogAccountType {
|
||||
const { tradesEveryDay, where } = ctx;
|
||||
if (typeof raw !== 'object' || raw === null) throw new Error(`${where} is not an object`);
|
||||
const type = raw as Record<string, unknown>;
|
||||
for (const key of ['id', 'firmId', 'label', 'prefix', 'updatedAt']) {
|
||||
if (typeof type[key] !== 'string') throw new Error(`${where} is missing ${key}`);
|
||||
}
|
||||
for (const key of [
|
||||
'accountSize', 'profitTarget', 'consistency', 'minDayPnL',
|
||||
'minTradingDays', 'maxLoss', 'maxPositionSize',
|
||||
]) {
|
||||
if (typeof type[key] !== 'number') throw new Error(`${where} is missing ${key}`);
|
||||
}
|
||||
if (!isStages(type.withdrawalStages)) throw new Error(`${where} has bad withdrawalStages`);
|
||||
// Lenient like platform: a master from before the split toggle
|
||||
// doesn't send the field, and those account types all split. Same
|
||||
// for CME holiday combining, where the old behavior was "off".
|
||||
return {
|
||||
...type,
|
||||
splitEnabled: type.splitEnabled !== false,
|
||||
// An every-day firm has no CME calendar to fold holidays by.
|
||||
combineCmeHolidays: type.combineCmeHolidays === true && !tradesEveryDay,
|
||||
// Lenient: a master from before the min-legs field means "no minimum".
|
||||
minLegs: typeof type.minLegs === 'number' ? type.minLegs : 0,
|
||||
// Lenient: the obfuscation stops are opt-in, so absent means off.
|
||||
firstDayStop: type.firstDayStop === true,
|
||||
minDayStop: type.minDayStop === true,
|
||||
// Lenient: no hard daily limit, no notional cap.
|
||||
dailyLossLimit: nonNegative(type.dailyLossLimit),
|
||||
// Lenient: stages count every payout unless the master says otherwise.
|
||||
stagesSameDay: type.stagesSameDay === true,
|
||||
// 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;
|
||||
}
|
||||
|
||||
// ── Which rules an account trades on ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The kept set whose window holds the rules date: openedAt (null = no lower
|
||||
* bound) <= date < closedAt — an account dated exactly at a change's cutoff
|
||||
* is on the newer rules. null when no window holds it, when the account has
|
||||
* no rules date, or when the date can't be read: the current rules.
|
||||
*/
|
||||
export function pickKeptRules<T extends { openedAt: string | null; closedAt: string }>(
|
||||
sets: T[] | undefined,
|
||||
rulesDate: string | null,
|
||||
): T | null {
|
||||
if (!sets || sets.length === 0 || !rulesDate) return null;
|
||||
const date = Date.parse(rulesDate);
|
||||
if (!Number.isFinite(date)) return null;
|
||||
for (const set of sets) {
|
||||
// Comparisons written so an unreadable bound (NaN) never matches.
|
||||
if (!(date < Date.parse(set.closedAt))) continue;
|
||||
if (set.openedAt !== null && !(Date.parse(set.openedAt) <= date)) continue;
|
||||
return set;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The live type with a kept set's rules on top. Everything that isn't a rule
|
||||
* stays the live type's — id, firmId, label, prefix, accountSize, updatedAt,
|
||||
* tradesEveryDay and the projection fields — so the account still matches,
|
||||
* groups and reports as its live type. A rule field the kept set doesn't
|
||||
* carry at all keeps the live value.
|
||||
*/
|
||||
export function overlayKept(live: CatalogAccountType, kept: KeptAccountRules): EffectiveAccountType {
|
||||
const effective: EffectiveAccountType = {
|
||||
...live,
|
||||
keptRules: { id: kept.id, openedAt: kept.openedAt, closedAt: kept.closedAt },
|
||||
};
|
||||
const target = effective as unknown as Record<string, unknown>;
|
||||
for (const field of RULE_FIELDS) {
|
||||
if (kept[field] !== undefined) target[field] = kept[field];
|
||||
}
|
||||
return effective;
|
||||
}
|
||||
|
||||
/** A stage the way the master's parseAccountTypeInput builds it, so stored
|
||||
* JSON from before drain / minDayPnL existed doesn't read as a change. */
|
||||
function normalizeStage(stage: unknown): unknown {
|
||||
if (typeof stage !== 'object' || stage === null) return stage;
|
||||
const s = stage as Record<string, unknown>;
|
||||
const drain = s.drain === true;
|
||||
return {
|
||||
profit: s.profit,
|
||||
consistency: s.consistency,
|
||||
minTradingDays: s.minTradingDays,
|
||||
drain,
|
||||
minDayPnL: typeof s.minDayPnL === 'number' ? s.minDayPnL : (drain ? 5 : 0),
|
||||
...(typeof s.withdrawalPct === 'number' ? { withdrawalPct: s.withdrawalPct } : {}),
|
||||
...(typeof s.payoutCap === 'number' ? { payoutCap: s.payoutCap } : {}),
|
||||
...(typeof s.dailyLossLimit === 'number' ? { dailyLossLimit: s.dailyLossLimit } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** A rule field's value as the bot reads it, for comparing: an absent
|
||||
* optional flag or amount is its default. */
|
||||
function comparable(field: RuleField, value: unknown): unknown {
|
||||
switch (field) {
|
||||
case 'withdrawalStages': return Array.isArray(value) ? value.map(normalizeStage) : value;
|
||||
case 'splitEnabled': return value !== false;
|
||||
case 'targetSameEquity':
|
||||
case 'firstDayStop':
|
||||
case 'minDayStop':
|
||||
case 'combineCmeHolidays':
|
||||
case 'stagesSameDay':
|
||||
case 'payoutable': return value === true;
|
||||
case 'minLegs':
|
||||
case 'trailingDrawdown':
|
||||
case 'dailyLossLimit': return typeof value === 'number' ? value : 0;
|
||||
default: return value;
|
||||
}
|
||||
}
|
||||
|
||||
/** JSON with every object's keys sorted, so key order never reads as a change. */
|
||||
function canonical(value: unknown): string {
|
||||
return JSON.stringify(value, (_key, v: unknown) => (
|
||||
typeof v === 'object' && v !== null && !Array.isArray(v)
|
||||
? Object.fromEntries(Object.entries(v as Record<string, unknown>).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)))
|
||||
: v
|
||||
)) ?? 'undefined';
|
||||
}
|
||||
|
||||
/** The rule fields where an account's effective rules differ from its live
|
||||
* type's, with both raw values (the dashboard formats them). Empty when the
|
||||
* account is on the current rules, or its kept set happens to match them. */
|
||||
export function ruleDiffs(
|
||||
effective: Pick<CatalogAccountType, RuleField>,
|
||||
live: Pick<CatalogAccountType, RuleField>,
|
||||
): { field: RuleField; kept: unknown; now: unknown }[] {
|
||||
const diffs: { field: RuleField; kept: unknown; now: unknown }[] = [];
|
||||
for (const field of RULE_FIELDS) {
|
||||
if (canonical(comparable(field, effective[field])) === canonical(comparable(field, live[field]))) continue;
|
||||
diffs.push({ field, kept: effective[field], now: live[field] });
|
||||
}
|
||||
return diffs;
|
||||
}
|
||||
|
||||
// ── Rules dates ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The rules date for an account this machine records for the first time:
|
||||
* the latest of this clock, the master's clock (this clock plus the offset
|
||||
* the catalog sync measured, when known) and the catalog's own last change.
|
||||
* A machine whose clock runs behind must never date a brand-new account
|
||||
* inside a rule set the master closed moments ago.
|
||||
*/
|
||||
export function stampRulesDate(nowMs: number, masterOffsetMs: number | null, catalogUpdatedAt: string | null): string {
|
||||
let ms = nowMs;
|
||||
if (masterOffsetMs !== null && Number.isFinite(masterOffsetMs)) ms = Math.max(ms, nowMs + masterOffsetMs);
|
||||
const catalog = catalogUpdatedAt ? Date.parse(catalogUpdatedAt) : NaN;
|
||||
if (Number.isFinite(catalog)) ms = Math.max(ms, catalog);
|
||||
return new Date(ms).toISOString();
|
||||
}
|
||||
|
||||
/** 2000-01-01T00:00:00.000Z — no rules date is earlier. */
|
||||
const EARLIEST_RULES_DATE_MS = Date.UTC(2000, 0, 1);
|
||||
|
||||
/** The plain toISOString() shape — four-digit year, milliseconds, Z. String
|
||||
* order is time order only between strings of exactly this shape. */
|
||||
const PLAIN_ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
|
||||
|
||||
/**
|
||||
* An instant as a storable rules date: its toISOString(), when that is the
|
||||
* plain four-digit-year shape and not before 2000 — so every stored rules
|
||||
* date sorts as a string (lib/db.ts lowerAutoRulesDate compares them so).
|
||||
* null for anything else (NaN, a pre-2000 or a six-digit-year instant).
|
||||
*/
|
||||
export function sortableIso(ms: number): string | null {
|
||||
if (!Number.isFinite(ms) || ms < EARLIEST_RULES_DATE_MS || Math.abs(ms) > 8.64e15) return null;
|
||||
const iso = new Date(ms).toISOString();
|
||||
return PLAIN_ISO.test(iso) ? iso : null;
|
||||
}
|
||||
|
||||
// The fund rows' dates — CME trade dates (Tradovate, TopstepX) and DXtrade's
|
||||
// firm days (ending 22:00Z) — are calendar days that end no later than
|
||||
// midnight in Chicago, so that is the calendar evidence is read in.
|
||||
|
||||
let chicagoClock: Intl.DateTimeFormat | undefined;
|
||||
|
||||
/** Chicago's wall clock minus UTC at an instant, in ms: −5 h under CDT,
|
||||
* −6 h under CST (Intl knows the DST rules; no dependency needed). */
|
||||
function chicagoOffsetMs(ms: number): number {
|
||||
chicagoClock ??= new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: 'America/Chicago', hourCycle: 'h23',
|
||||
year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric', second: 'numeric',
|
||||
});
|
||||
const p: Record<string, number> = {};
|
||||
for (const part of chicagoClock.formatToParts(new Date(ms))) {
|
||||
if (part.type !== 'literal') p[part.type] = Number(part.value);
|
||||
}
|
||||
// Some engines spell midnight "24" even under h23.
|
||||
const wall = Date.UTC(p.year, p.month - 1, p.day, p.hour === 24 ? 0 : p.hour, p.minute, p.second);
|
||||
return wall - (ms - (((ms % 1000) + 1000) % 1000));
|
||||
}
|
||||
|
||||
/** Whether `day` is YYYY-MM-DD naming a real calendar day (no Feb 31). */
|
||||
function isCalendarDay(day: unknown): day is string {
|
||||
if (typeof day !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(day)) return false;
|
||||
const ms = Date.parse(`${day}T00:00:00.000Z`);
|
||||
return Number.isFinite(ms) && new Date(ms).toISOString().slice(0, 10) === day;
|
||||
}
|
||||
|
||||
/** The UTC instant of a wall-clock time on a Chicago calendar day. Exact for
|
||||
* any time a DST change neither skips nor repeats (they happen at 2 AM), so
|
||||
* for the day's first and last millisecond. NaN for a bad day. */
|
||||
function chicagoWallMs(day: string, time: string): number {
|
||||
if (!isCalendarDay(day)) return NaN;
|
||||
const naive = Date.parse(`${day}T${time}Z`);
|
||||
// The offset where the answer lands, which a DST change between the naive
|
||||
// instant and the real one can make differ from the naive instant's.
|
||||
const first = naive - chicagoOffsetMs(naive);
|
||||
return naive - chicagoOffsetMs(first);
|
||||
}
|
||||
|
||||
/** The Chicago calendar day (YYYY-MM-DD) an instant falls on; null when unreadable. */
|
||||
export function chicagoDay(iso: string): string | null {
|
||||
const ms = Date.parse(iso);
|
||||
if (!Number.isFinite(ms) || Math.abs(ms) > 8.64e15) return null;
|
||||
return new Date(ms + chicagoOffsetMs(ms)).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** The first millisecond of a Chicago calendar day, as a rules date — what a
|
||||
* day picked by hand can never be earlier than, so it is never inside the
|
||||
* evidence of the day before it. null for a bad or pre-2000 day. */
|
||||
export function chicagoDayStart(day: string): string | null {
|
||||
return sortableIso(chicagoWallMs(day, '00:00:00.000'));
|
||||
}
|
||||
|
||||
/**
|
||||
* The rules date the broker's evidence gives an account: the end of its first
|
||||
* fund day (YYYY-MM-DD, platform-client.ts firstFundDate) in Chicago —
|
||||
* D 23:59:59.999 America/Chicago, so 04:59:59.999Z the next day under CDT and
|
||||
* 05:59:59.999Z under CST. Every source's day ends by then, so the evidence is
|
||||
* never earlier than the moment it stands for, and an account opened on the
|
||||
* day of a rule change — even that evening — counts as opened after it. null
|
||||
* for anything else.
|
||||
*/
|
||||
export function evidenceRulesDate(firstFundDate: string | null | undefined): string | null {
|
||||
if (!isCalendarDay(firstFundDate)) return null;
|
||||
return sortableIso(chicagoWallMs(firstFundDate, '23:59:59.999'));
|
||||
}
|
||||
|
||||
/** The Chicago day a rules date stands for when it is exactly the end of one
|
||||
* — the shape evidenceRulesDate stores — so the dashboard shows the broker's
|
||||
* day in any time zone rather than the next one. null for any other date. */
|
||||
export function evidenceDay(rulesDate: string): string | null {
|
||||
const day = chicagoDay(rulesDate);
|
||||
return day !== null && evidenceRulesDate(day) === rulesDate ? day : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The rules date a day picked by hand on the dashboard stores. Today is now
|
||||
* (`nowMs`) — the master's default cutoff is "now", and today's start would
|
||||
* sit before a change made earlier today, leaving no way onto the current
|
||||
* rules. Any other day is its start: the browser's midnight
|
||||
* (`localMidnightMs`), but never before Chicago's, so a day picked in a
|
||||
* browser east of Chicago is never inside the evidence of the day before
|
||||
* (which ends at Chicago midnight). null when unreadable.
|
||||
*/
|
||||
export function pickedDayRulesDate(
|
||||
day: string,
|
||||
at: { today: string; nowMs: number; localMidnightMs: number },
|
||||
): string | null {
|
||||
if (day === at.today) return sortableIso(at.nowMs);
|
||||
const chicago = chicagoDayStart(day);
|
||||
if (!chicago || !Number.isFinite(at.localMidnightMs)) return null;
|
||||
return sortableIso(Math.max(at.localMidnightMs, Date.parse(chicago)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether ✓ on the very day the editor opened on stores it. An unchanged day
|
||||
* keeps the exact date — storing the day's start can land before a same-day
|
||||
* rule change and put a running account back on old rules — unless storing
|
||||
* it moves the account to other rules, which the editor's preview shows
|
||||
* (today's "now" past a same-day change, say). With the type's windows
|
||||
* unknown, never. `openedDate` is the date the editor opened on, not one a
|
||||
* later poll brought.
|
||||
*/
|
||||
export function unchangedDaySaves(
|
||||
kept: { id: string; openedAt: string | null; closedAt: string }[] | null,
|
||||
openedDate: string,
|
||||
stored: string,
|
||||
): boolean {
|
||||
if (!kept) return false;
|
||||
return (pickKeptRules(kept, openedDate)?.id ?? null) !== (pickKeptRules(kept, stored)?.id ?? null);
|
||||
}
|
||||
|
||||
/**
|
||||
* The broker's evidence of when an account opened, once its fund history has
|
||||
* loaded: the end of its earliest positive fund row's day (evidenceRulesDate),
|
||||
* or — with no such row — the DXtrade registration instant itself. Never the
|
||||
* registration's calendar day: that is a Chicago date, and an evening
|
||||
* registration must not read as earlier than it happened. null when neither
|
||||
* says (or a value is unreadable).
|
||||
*/
|
||||
export function openingEvidence(firstFundDay: string | null | undefined, registeredAt: number | null | undefined): string | null {
|
||||
if (typeof firstFundDay === 'string') return evidenceRulesDate(firstFundDay);
|
||||
return typeof registeredAt === 'number' ? sortableIso(registeredAt) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an account bound to a live type still matches that type's identity
|
||||
* — its name carries the type's prefix and the size cached when it matched
|
||||
* equals the type's size (0 = 0 for a $0-start type). An account on a kept
|
||||
* rule set stays bound to its type only while this holds: kept sets take the
|
||||
* live type's prefix and size, so after a size edit a "sticky" account would
|
||||
* trade on a size it doesn't have.
|
||||
*/
|
||||
export function identityStillMatches(
|
||||
accountName: string,
|
||||
cachedSize: string | null,
|
||||
live: Pick<CatalogAccountType, 'prefix' | 'accountSize'>,
|
||||
): boolean {
|
||||
if (!accountName.startsWith(live.prefix)) return false;
|
||||
if (cachedSize === null || cachedSize.trim() === '') return false;
|
||||
const size = Number(cachedSize);
|
||||
return Number.isFinite(size) && Math.abs(size - live.accountSize) < 0.01;
|
||||
}
|
||||
|
||||
/** Whether broker evidence moves an auto rules date: only earlier, never a
|
||||
* date set by hand. The same test lib/db.ts lowerAutoRulesDate applies in
|
||||
* SQL (string order is time order: every rules date is a sortableIso()). */
|
||||
export function lowersRulesDate(row: { rules_date: string | null; rules_date_manual: number }, evidence: string): boolean {
|
||||
return row.rules_date_manual === 0 && (row.rules_date === null || row.rules_date > evidence);
|
||||
}
|
||||
+43
-13
@@ -11,10 +11,43 @@ import type { PlatformContract } from './platform-client';
|
||||
import type { SymbolStats } from './master-protocol';
|
||||
import {
|
||||
combineCmeSessions, computeDayPlan, resolveEffectiveConfig, obfuscationStopDollars, dllRoom as computeDllRoom,
|
||||
planLegSize, rankForTarget, maxLossRoom, lastStand, dllStopDollars, DEFAULT_TARGET_HOLD_HOURS,
|
||||
planLegSize, rankForTarget, maxLossRoom, lastStand, dllStopDollars, stageInputsFor, floorRoom, shortestNextLeg,
|
||||
FALLBACK_COMMISSION_PER_CONTRACT,
|
||||
} from './trading-logic';
|
||||
|
||||
/**
|
||||
* The day's hard-loss room as the engine's dailyLossGate works it out, for
|
||||
* the dashboard paths: against the platform's own floor where it states one
|
||||
* (Vest), else the day's P&L. null = not known yet (or no limit, dll ≤ 0).
|
||||
*/
|
||||
export function dayLossRoom(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
client: any,
|
||||
accountId: number,
|
||||
dll: number,
|
||||
cash: { amount: number; realizedPnL: number },
|
||||
combinedRealized: number,
|
||||
/** The shortest take-profit the next leg may have (shortestNextLeg). */
|
||||
nextLeg: number,
|
||||
): number | null {
|
||||
if (!(dll > 0)) return null;
|
||||
const floor: number | null | undefined = client.dailyLossFloor?.(accountId);
|
||||
if (floor === null) return null;
|
||||
if (typeof floor === 'number') {
|
||||
return floorRoom({
|
||||
dailyLossLimit: dll, closedBalance: cash.amount, floor,
|
||||
realizedToday: Math.min(cash.realizedPnL, combinedRealized),
|
||||
gain: client.dayGainForRoom?.(accountId, { leg: nextLeg, limit: dll }) ?? 0,
|
||||
});
|
||||
}
|
||||
const dayStart = client.dayStartBalance?.(accountId);
|
||||
return dayStart === null ? null : computeDllRoom({
|
||||
dailyLossLimit: dll, closedBalance: cash.amount, dayStartBalance: dayStart,
|
||||
realizedToday: Math.min(cash.realizedPnL, combinedRealized),
|
||||
});
|
||||
}
|
||||
import { sizingHoldHours } from './person-size';
|
||||
|
||||
export type CandidateLeg = SymbolStats['symbols'][number] & {
|
||||
swapReserve: number;
|
||||
leverage: number | null;
|
||||
@@ -39,10 +72,12 @@ export function rankNextLeg(o: {
|
||||
dllRoom: number | null;
|
||||
obfuscationDollars: number;
|
||||
cash: { amount: number; availableFunds?: number };
|
||||
/** The trade date the hold is drawn for (sizingHoldHours); default today. */
|
||||
tradeDate?: string;
|
||||
}): CandidateLeg[] {
|
||||
const candidates: PlatformContract[] = o.client.lastCandidates ?? [];
|
||||
if (!(o.leg > 0) || candidates.length === 0) return [];
|
||||
const target = o.firm.targetHoldHours || DEFAULT_TARGET_HOLD_HOURS;
|
||||
const target = sizingHoldHours(o.firm, o.tradeDate);
|
||||
const floorRoom = maxLossRoom({
|
||||
balance: o.cash.amount, accountSize: o.cfg.accountSize, maxLoss: o.cfg.maxLoss ?? 0,
|
||||
autoLiqThreshold: o.client.autoLiqThresholds?.[o.accountId],
|
||||
@@ -65,6 +100,7 @@ export function rankNextLeg(o: {
|
||||
maxLossRoom: floorRoom,
|
||||
obfuscationDollars: o.obfuscationDollars,
|
||||
availableFunds: funds,
|
||||
slippageFraction: o.client.stopSlippageFraction,
|
||||
});
|
||||
const p = plan(true);
|
||||
if (!p.sizing || p.cost === null) return [];
|
||||
@@ -102,6 +138,7 @@ function accountPlan(
|
||||
const effective = resolveEffectiveConfig(
|
||||
cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity, cfg.withdrawalStages,
|
||||
client.priorProfit?.[acc.id] ?? 0, client.fundTransactions?.[acc.id] ?? [], cfg.minDayPnL,
|
||||
stageInputsFor(client, acc.id, cfg),
|
||||
);
|
||||
const plan = computeDayPlan(
|
||||
acc.id, combined.tradeDate, effective.profitTarget, effective.consistency,
|
||||
@@ -109,15 +146,8 @@ function accountPlan(
|
||||
cfg.splitEnabled, effective.drain,
|
||||
);
|
||||
if (!plan) return null;
|
||||
const dll = cfg.dailyLossLimit ?? 0;
|
||||
let room: number | null = null;
|
||||
if (dll > 0) {
|
||||
const dayStart = client.dayStartBalance?.(acc.id);
|
||||
room = dayStart === null ? null : computeDllRoom({
|
||||
dailyLossLimit: dll, closedBalance: cash.amount, dayStartBalance: dayStart,
|
||||
realizedToday: Math.min(cash.realizedPnL, combined.realizedPnL),
|
||||
});
|
||||
}
|
||||
const dll = effective.dailyLossLimit;
|
||||
const room = dayLossRoom(client, acc.id, dll, cash, combined.realizedPnL, shortestNextLeg(plan));
|
||||
const obfuscation = obfuscationStopDollars(cfg, combined.dailyPnL, plan, combined.tradeDate, plan.remainingToday);
|
||||
return { cfg, cash, plan, dll, room, obfuscation };
|
||||
}
|
||||
@@ -144,8 +174,8 @@ export function accountNextLeg(
|
||||
leg: p.plan.remainingToday,
|
||||
lastStand: p.room !== null && lastStand({ dllRoom: p.room, dailyLossLimit: p.dll, maxLossRoom: floorRoom }),
|
||||
candidates: rankNextLeg({
|
||||
client, accountId: acc.id, cfg: p.cfg, firm, leg: p.plan.remainingToday, dllRoom: p.room,
|
||||
obfuscationDollars: p.obfuscation, cash: p.cash,
|
||||
client, accountId: acc.id, cfg: { ...p.cfg, dailyLossLimit: p.dll }, firm, leg: p.plan.remainingToday, dllRoom: p.room,
|
||||
obfuscationDollars: p.obfuscation, cash: p.cash, tradeDate,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
+2
-3
@@ -106,14 +106,13 @@ class TopstepxFeed implements Feed {
|
||||
|
||||
constructor(readonly firmId: string, readonly firmName: string, token: () => string | null) {
|
||||
// Like the user hub: one websocket, through the operator's proxy.
|
||||
const proxiedWs = signalrWebSocket();
|
||||
const configuredWs = signalrWebSocket();
|
||||
this.hub = new signalR.HubConnectionBuilder()
|
||||
.withUrl(MARKET_HUB, {
|
||||
skipNegotiation: true,
|
||||
transport: signalR.HttpTransportType.WebSockets,
|
||||
accessTokenFactory: () => token() ?? '',
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
...(proxiedWs ? { WebSocket: proxiedWs as any } : {}),
|
||||
...{ WebSocket: configuredWs },
|
||||
})
|
||||
.withAutomaticReconnect()
|
||||
.configureLogging(signalR.LogLevel.Warning)
|
||||
|
||||
+7
-1
@@ -24,6 +24,8 @@ import { queueRun } from './queue-run';
|
||||
import { pushBackup } from './backup-sync';
|
||||
import { getBuildVersion } from './version';
|
||||
import { updaterStatus } from './updater-status';
|
||||
import { runUpdateNow } from './updater-watchdog';
|
||||
import { directWebSocket } from './outbound-proxy';
|
||||
import type {
|
||||
Command,
|
||||
InstanceCapabilities,
|
||||
@@ -87,6 +89,7 @@ export function collectCapabilities(): InstanceCapabilities {
|
||||
}))
|
||||
),
|
||||
runnerOnline,
|
||||
updateNow: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -131,6 +134,9 @@ async function execute(command: Command): Promise<unknown> {
|
||||
case 'backup-now':
|
||||
return pushBackup(true);
|
||||
|
||||
case 'update-now':
|
||||
return runUpdateNow();
|
||||
|
||||
default: {
|
||||
// A newer master may know commands this build does not. Say so
|
||||
// rather than failing silently.
|
||||
@@ -234,7 +240,7 @@ function connect() {
|
||||
|
||||
let socket: WebSocket;
|
||||
try {
|
||||
socket = new WebSocket(url);
|
||||
socket = directWebSocket(url);
|
||||
} catch (err: any) {
|
||||
scheduleReconnect(err?.message ?? 'could not open socket');
|
||||
return;
|
||||
|
||||
+116
-11
@@ -36,11 +36,19 @@ export interface AccountTypeStats {
|
||||
* cycle ready to withdraw, 'drain' = a drain stage's days booked;
|
||||
* cycle = the date of the fund transaction that started the cycle ('' for
|
||||
* the account's first cycle) — firm + name + cycle identify one
|
||||
* completed cycle. The list also carries caught-up entries: for up to 7
|
||||
* completed cycle, but where one day started several (Vest: claim,
|
||||
* trade again, claim again — a fund row holds the day's payouts):
|
||||
* cycleSeq = which of that day's cycles, 1 for the second and so on,
|
||||
* sent only when above 0 (trading-logic completionPlace). A master that
|
||||
* keys on it tells them apart; one that doesn't folds them into the
|
||||
* day's first. The list also carries caught-up entries: for up to 7
|
||||
* days after a fund transaction starts a new cycle, it includes the cycle
|
||||
* that just ended if that cycle had met the rule. Caught-up entries are
|
||||
* not counted in `completed`. Optional: older instances omit it. */
|
||||
completedAccounts?: { name: string; date: string; stage: number; kind: 'pass' | 'payout' | 'drain'; cycle: string }[];
|
||||
* that just ended if that cycle had met the rule — and, for 7 days after
|
||||
* it, each cycle the instance's own payout claim ended complete, dated
|
||||
* the claim's day and listed last, newest first (a claim can end one
|
||||
* before any report sees it). Caught-up and claimed entries are not
|
||||
* counted in `completed`. Optional: older instances omit it. */
|
||||
completedAccounts?: { name: string; date: string; stage: number; kind: 'pass' | 'payout' | 'drain'; cycle: string; cycleSeq?: number }[];
|
||||
/** Live accounts by trading days completed toward the current target:
|
||||
* {daysTraded: accountCount}. Feeds the master's payout calendar, so
|
||||
* accounts that are dead, DLL-locked, or in the red are left out — the
|
||||
@@ -81,6 +89,10 @@ export interface AccountTypeStats {
|
||||
stage: number; drain?: boolean;
|
||||
}[];
|
||||
}[];
|
||||
/** Live accounts of this slice on each kept rule set — the slice itself
|
||||
* stays keyed by the live account type, whose id is the only one ever
|
||||
* reported as an accountTypeId. Optional. */
|
||||
keptRules?: { id: string; accounts: number }[];
|
||||
}
|
||||
|
||||
export interface FirmStats {
|
||||
@@ -105,10 +117,48 @@ export interface FirmStats {
|
||||
* instances omit it.
|
||||
*
|
||||
* withdrawals = how many times the balance has gone down for a payout
|
||||
* (negative fund transactions), which tells the master a completed
|
||||
* cycle's money is out. Omitted while the account's fund history hasn't
|
||||
* loaded, so a restart can't read as zero withdrawals. */
|
||||
accounts?: { name: string; size: number; payoutable: boolean | null; withdrawals?: number }[];
|
||||
* (negative fund transactions; on Vest, each claim, though a day's share
|
||||
* one fund row), which tells the master a completed cycle's money is
|
||||
* out. Omitted while the account's fund history hasn't loaded, so a
|
||||
* restart can't read as zero withdrawals.
|
||||
*
|
||||
* The rest come from statusVersion builds (platform-client.ts
|
||||
* accountStatus), each left out when null or at its default. status:
|
||||
* unknown when the balance is missing (reason cash_missing — never dead),
|
||||
* dead on the floor test, history_pending, locked when the firm stopped
|
||||
* it trading (reason says how), else live. balance and floor (0 = none)
|
||||
* in dollars; accountTypeId = the resolved catalog type (absent =
|
||||
* unassigned); firstFundDate = the earliest positive fund row, YYYY-MM-DD
|
||||
* (DXtrade: the registration date when there is none); positiveFunds
|
||||
* counts those rows, absent until the fund history loads; brokerStatus =
|
||||
* the broker's own flags (Tradovate closed/archived/restricted, DXtrade
|
||||
* accountStatus, TopstepX canTrade:false). */
|
||||
accounts?: {
|
||||
name: string; size: number; payoutable: boolean | null; withdrawals?: number;
|
||||
status?: 'live' | 'locked' | 'dead' | 'history_pending' | 'unknown';
|
||||
reason?: string;
|
||||
balance?: number;
|
||||
floor?: number;
|
||||
accountTypeId?: string;
|
||||
firstFundDate?: string;
|
||||
positiveFunds?: number;
|
||||
brokerStatus?: string;
|
||||
}[];
|
||||
/** 1 on every firm from a build that sends the account status fields,
|
||||
* including one with no accounts. Absent = an older build: its accounts
|
||||
* are unknown, never dead. An empty accounts[] from a statusVersion build
|
||||
* is a synced firm with no accounts. Optional. */
|
||||
statusVersion?: 1;
|
||||
/** The catalog firm's uuid (statusVersion builds), so the master needn't
|
||||
* resolve the name. Optional. */
|
||||
firmId?: string;
|
||||
/** Kept rule set ids this machine has loaded for the firm. Present (even
|
||||
* []) = this build applies kept rules and its cached catalog was parsed
|
||||
* by a parser that understands them. Absent = old build or stale cache. */
|
||||
keptRuleIds?: string[];
|
||||
/** ISO time the firm's last good sync finished (statusVersion builds), sent
|
||||
* once synced. The statuses count only while it is recent. Optional. */
|
||||
syncedAt?: string;
|
||||
/** Per account type, summing to the firm's own totals. Optional so a master
|
||||
* on an older build simply ignores it. */
|
||||
accountTypes?: AccountTypeStats[];
|
||||
@@ -277,6 +327,8 @@ export interface InstanceCapabilities {
|
||||
symbols: string[];
|
||||
automations: AutomationSummary[];
|
||||
runnerOnline: boolean;
|
||||
/** Answers the 'update-now' command; the master offers it only then. */
|
||||
updateNow: boolean;
|
||||
}
|
||||
|
||||
// ── Firm catalog ─────────────────────────────────────────────────────────────
|
||||
@@ -322,7 +374,11 @@ export interface CatalogAccountType {
|
||||
* minTradingDays qualifying days of ~minDayPnL (default $5, randomized
|
||||
* $5–$15, traded as tiny MNQ legs), then wait for the next withdrawal.
|
||||
* withdrawalPct/payoutCap override the type's payout defaults per stage. */
|
||||
withdrawalStages: { profit: number; consistency: number; minTradingDays: number; drain?: boolean; minDayPnL?: number; withdrawalPct?: number; payoutCap?: number }[];
|
||||
withdrawalStages: { profit: number; consistency: number; minTradingDays: number; drain?: boolean; minDayPnL?: number; withdrawalPct?: number; payoutCap?: number; dailyLossLimit?: number }[];
|
||||
/** True = the stage is chosen by the payouts made since the firm's last
|
||||
* daily reset, and every new day starts back at the base stage (Vest:
|
||||
* claim, then trade again the same day). Absent from older masters. */
|
||||
stagesSameDay?: boolean;
|
||||
/** Payout-projection settings, owned by the master's payout calendar but
|
||||
* consumed here to compute each account's future payout chain. Optional:
|
||||
* an older master doesn't send them. */
|
||||
@@ -348,15 +404,39 @@ export interface CatalogAccountType {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** Old rules an account type keeps for its accounts whose rules date is
|
||||
* inside [openedAt, closedAt) — set when the owner changes the type's rules
|
||||
* for new accounts only. Built as: live type X's fields, overwritten by the
|
||||
* snapshot's RULE fields, plus these. Never an account type itself: nothing
|
||||
* is pinned or reported by its id as an accountTypeId (lib/kept-rules.ts). */
|
||||
export interface KeptAccountRules extends CatalogAccountType {
|
||||
/** This kept set's own uuid — never a live account type id. */
|
||||
id: string;
|
||||
/** The live account type id (X). */
|
||||
keptFrom: string;
|
||||
/** ISO; inclusive lower bound on the rules date; null = none. */
|
||||
openedAt: string | null;
|
||||
/** ISO; exclusive upper bound. */
|
||||
closedAt: 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 const FIRM_PLATFORMS = ['tradovate', 'topstepx', 'dxtrade', 'vest'] 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);
|
||||
}
|
||||
|
||||
/** Platforms whose firms trade their own symbols by notional (DXtrade's
|
||||
* CFDs, Vest's perps) rather than CME contracts: the firm's instrument list
|
||||
* is platform symbols, fees are a percent of notional, and each firm is a
|
||||
* scheduler lane of its own. */
|
||||
export function isNotionalPlatform(platform: string | undefined): boolean {
|
||||
return platform === 'dxtrade' || platform === 'vest';
|
||||
}
|
||||
|
||||
/** One symbol a DXTrade firm may trade for a CME root. Position size comes
|
||||
* from volatility and the firm's target hold, and the commission is the
|
||||
* firm's (an older master's notionalPerContract or per-symbol commissionPct
|
||||
@@ -381,10 +461,24 @@ export interface CatalogFirm {
|
||||
* own symbols for DXtrade (see lib/instruments.ts). Absent from a master
|
||||
* from before the lists — then bannedSymbols and symbolMap apply. */
|
||||
instruments?: string[];
|
||||
/** Futures firms: the most contracts the firm allows on an instrument,
|
||||
* by root, for every account type (absent = no limit beyond the type's
|
||||
* max contracts). */
|
||||
instrumentLimits?: Record<string, number>;
|
||||
/** The firm's website ('' = none; absent from older masters). The master
|
||||
* matches the extension's tab against it to find a person's 2FA code. */
|
||||
siteUrl?: string;
|
||||
/** The most of the firm's accounts this machine trades at once, on top of
|
||||
* its own max_concurrent_accounts (0 = no firm cap; absent from older
|
||||
* masters). */
|
||||
maxConcurrentAccounts?: number;
|
||||
/** Older masters' way to exclude roots; newer ones derive it from
|
||||
* `instruments`. */
|
||||
bannedSymbols: string[];
|
||||
accountTypes: CatalogAccountType[];
|
||||
/** Old rule sets the firm's account types keep for older accounts.
|
||||
* Optional; old builds drop it. */
|
||||
keptAccountRules?: KeptAccountRules[];
|
||||
/** DXTrade REST base, e.g. https://dx.tradeify247.co/dxsca-web ('' otherwise). */
|
||||
serverUrl?: string;
|
||||
/** DXTrade login domain ('' = 'default'). */
|
||||
@@ -418,6 +512,14 @@ export interface FirmCatalogPayload {
|
||||
firms: CatalogFirm[];
|
||||
/** Absent from a master from before its Settings page. */
|
||||
trading?: CatalogTradingSettings;
|
||||
/** Instance-local, never sent by the master: the catalog parser version
|
||||
* (lib/firm-catalog.ts CATALOG_PARSER_VERSION) that produced a cached
|
||||
* payload. An older parser dropped fields it didn't know, so a cache
|
||||
* without it, or below the current one, isn't trusted to hold them. */
|
||||
parser?: number;
|
||||
/** Instance-local, like parser: the platforms of the firms that parser
|
||||
* skipped as unknown to it. A build that knows one fetches in full. */
|
||||
skippedPlatforms?: string[];
|
||||
}
|
||||
|
||||
export interface FirmCatalogUnchanged {
|
||||
@@ -432,7 +534,10 @@ export type Command =
|
||||
| { kind: 'autobuyer-run'; automationId: string; inputs?: Record<string, number> }
|
||||
| { kind: 'report-now' }
|
||||
/** Push a fresh settings/credentials backup right now. */
|
||||
| { kind: 'backup-now' };
|
||||
| { kind: 'backup-now' }
|
||||
/** Heal this machine's auto-updater if it stopped, then check for an
|
||||
* update at once, skipping a failed update's retry wait. */
|
||||
| { kind: 'update-now' };
|
||||
|
||||
export type MasterMessage =
|
||||
/** personId is the person's stable uuid; the instance stores it and
|
||||
|
||||
+36
-40
@@ -21,20 +21,20 @@
|
||||
* process default is the scoped choice. It covers axios only; websockets and
|
||||
* the SignalR hub keep their own liveness handling in the clients.
|
||||
*
|
||||
* Both defaults are applied when this module loads. Every broker client
|
||||
* imports it, and that matters: each server bundle (instrumentation.ts, the
|
||||
* route handlers) can carry its own copy of axios, and a copy that never had
|
||||
* the defaults applied would dial the brokers direct, with no timeout.
|
||||
* lib/http-client applies these defaults to every Axios client it exports,
|
||||
* including clients created with axios.create(), in every server bundle.
|
||||
*/
|
||||
|
||||
import axios from 'axios';
|
||||
import { ProxyAgent, type Dispatcher } from 'undici';
|
||||
import type { AxiosDefaults } from 'axios';
|
||||
import { ProxyAgent, WebSocket as UndiciWebSocket, type Dispatcher } from 'undici';
|
||||
import NodeWebSocket from 'ws';
|
||||
import { socksDispatcher } from 'fetch-socks';
|
||||
import { HttpProxyAgent } from 'http-proxy-agent';
|
||||
import { HttpsProxyAgent } from 'https-proxy-agent';
|
||||
import { SocksProxyAgent } from 'socks-proxy-agent';
|
||||
import type { Agent } from 'node:http';
|
||||
import { getSetting } from './db';
|
||||
import { getRequestUserAgent } from './request-user-agent';
|
||||
|
||||
const SCHEMES = ['http:', 'https:', 'socks5:', 'socks5h:'] as const;
|
||||
|
||||
@@ -120,18 +120,18 @@ export function getProxyUrl(): string | null {
|
||||
/** Puts the proxy under every axios call. All axios usage in this app is
|
||||
* broker/quote traffic (TopstepX, Tradovate REST, contract resolver), so the
|
||||
* process-wide default is the scoped choice, not a global one. Idempotent;
|
||||
* runs when this module loads and again first in the getClients() bootstrap.
|
||||
* runs when the shared HTTP client loads and in the getClients() bootstrap.
|
||||
*
|
||||
* "Already applied" is judged on the axios defaults themselves, not a
|
||||
* process-wide flag: the proxy state is shared through globalThis, but each
|
||||
* bundle's copy of axios has its own defaults, and a global flag set by the
|
||||
* first copy would leave every other one dialling direct. */
|
||||
export function applyBrokerProxy(): void {
|
||||
export function applyBrokerProxy(defaults: AxiosDefaults): void {
|
||||
const s = state();
|
||||
if (!s.url || axios.defaults.httpsAgent === s.httpsAgent) return;
|
||||
axios.defaults.proxy = false; // agents own the routing; ignore env HTTP(S)_PROXY handling
|
||||
axios.defaults.httpAgent = s.httpAgent!;
|
||||
axios.defaults.httpsAgent = s.httpsAgent!;
|
||||
if (!s.url || defaults.httpsAgent === s.httpsAgent) return;
|
||||
defaults.proxy = false; // agents own the routing; ignore env HTTP(S)_PROXY handling
|
||||
defaults.httpAgent = s.httpAgent!;
|
||||
defaults.httpsAgent = s.httpsAgent!;
|
||||
}
|
||||
|
||||
/** Idle limit for one broker HTTP request. axios applies it as a socket
|
||||
@@ -147,53 +147,49 @@ export function applyBrokerProxy(): void {
|
||||
export const BROKER_HTTP_TIMEOUT_MS = 60_000;
|
||||
|
||||
/** Gives axios a timeout unless one is already set (axios's own default, 0,
|
||||
* means wait forever). Idempotent; runs when this module loads and at the
|
||||
* means wait forever). Idempotent; runs when the shared HTTP client loads and at the
|
||||
* top of the getClients() bootstrap. Timeouts surface as ECONNABORTED, which
|
||||
* the Tradovate report fetch already retries as transient. */
|
||||
export function applyBrokerHttpDefaults(): void {
|
||||
if (!axios.defaults.timeout) axios.defaults.timeout = BROKER_HTTP_TIMEOUT_MS;
|
||||
export function applyBrokerHttpDefaults(defaults: AxiosDefaults): void {
|
||||
if (!defaults.timeout) defaults.timeout = BROKER_HTTP_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
// At load, so no broker request from this bundle can go out before them. The
|
||||
// proxy is skipped during `next build`: the build imports route modules to
|
||||
// collect page data, and it dials nothing that would need one. Nothing may
|
||||
// throw out of here: a module that fails to load takes every route that
|
||||
// imports it (the whole trading side) down with it.
|
||||
applyBrokerHttpDefaults();
|
||||
try {
|
||||
if (process.env.NEXT_PHASE !== 'phase-production-build') applyBrokerProxy();
|
||||
} catch (err) {
|
||||
console.error(`[proxy] could not apply proxy_url at load: ${err instanceof Error ? err.message : String(err)}`);
|
||||
/** Direct control-channel socket: same browser identity, no broker proxy. */
|
||||
export function directWebSocket(wsUrl: string): WebSocket {
|
||||
return new UndiciWebSocket(wsUrl, {
|
||||
headers: { 'User-Agent': getRequestUserAgent() },
|
||||
}) as unknown as WebSocket;
|
||||
}
|
||||
|
||||
/** A websocket to a BROKER endpoint: the built-in (undici) WebSocket, dialled
|
||||
* through the proxy when one is set. The master link keeps using plain
|
||||
* `new WebSocket(...)` — do not use this for it. */
|
||||
/** Broker socket with the same browser identity and optional broker proxy.
|
||||
* Use the installed Undici: Node's built-in WebSocket may ignore headers. */
|
||||
export function brokerWebSocket(wsUrl: string): WebSocket {
|
||||
const { dispatcher } = state();
|
||||
if (!dispatcher) return new WebSocket(wsUrl);
|
||||
// The runtime accepts { dispatcher } fine; only the DOM lib type doesn't.
|
||||
return new (WebSocket as unknown as new (u: string, o: object) => WebSocket)(wsUrl, { dispatcher });
|
||||
return new UndiciWebSocket(wsUrl, {
|
||||
...(dispatcher ? { dispatcher } : {}),
|
||||
headers: { 'User-Agent': getRequestUserAgent() },
|
||||
}) as unknown as WebSocket;
|
||||
}
|
||||
|
||||
/** A `ws`-package constructor with the proxy agent baked in, for SignalR's
|
||||
* `withUrl({ WebSocket })` option. undefined = no proxy, let SignalR use its
|
||||
* own require('ws'). */
|
||||
/** SignalR always uses this constructor, even without a proxy, because its
|
||||
* own transport supplies a library User-Agent that we must replace. */
|
||||
export function signalrWebSocket(): unknown {
|
||||
const s = state();
|
||||
if (!s.httpsAgent) return undefined;
|
||||
const agent = s.httpsAgent;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const WS = require('ws') as new (address: string, protocols?: unknown, options?: object) => unknown;
|
||||
|
||||
return class ProxiedWebSocket extends (WS as new (...args: never[]) => object) {
|
||||
return class ConfiguredWebSocket extends NodeWebSocket {
|
||||
constructor(address: string, protocols?: unknown, options?: Record<string, unknown>) {
|
||||
// ws also allows (address, options) — normalize before merging.
|
||||
if (protocols && typeof protocols === 'object' && !Array.isArray(protocols)) {
|
||||
options = protocols as Record<string, unknown>;
|
||||
protocols = undefined;
|
||||
}
|
||||
super(...[address, protocols, { ...(options ?? {}), agent }] as never[]);
|
||||
const headers = { ...(options?.headers as Record<string, string> | undefined) };
|
||||
for (const name of Object.keys(headers)) {
|
||||
if (name.toLowerCase() === 'user-agent') delete headers[name];
|
||||
}
|
||||
headers['User-Agent'] = getRequestUserAgent();
|
||||
const agent = address.startsWith('ws:') ? s.httpAgent : s.httpsAgent;
|
||||
super(address, protocols, { ...(options ?? {}), ...(agent ? { agent } : {}), headers });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* The hold this machine sizes DXtrade and Vest legs for. Every person's
|
||||
* machine would otherwise size a like account in a like state to the same
|
||||
* position as everyone else's; each person's own draw (personHoldHours) puts
|
||||
* their sizes within ±PERSON_SIZE_SPREAD of the plan instead, the same for
|
||||
* all of their accounts and new each trade date.
|
||||
*/
|
||||
|
||||
import { getSetting } from './db';
|
||||
import { currentTradeDateCT, personHoldHours, DEFAULT_TARGET_HOLD_HOURS } from './trading-logic';
|
||||
|
||||
/** Who this machine trades for: the master's uuid for the person, else the
|
||||
* instance name it reports under. */
|
||||
function personKey(): string {
|
||||
return getSetting('instance_id') || getSetting('instance_name') || '';
|
||||
}
|
||||
|
||||
/** The hold a DXtrade or Vest firm's legs are sized for on this machine today. */
|
||||
export function sizingHoldHours(
|
||||
firm: { targetHoldHours?: number } | null | undefined,
|
||||
tradeDate: string = currentTradeDateCT(),
|
||||
): number {
|
||||
return personHoldHours(firm?.targetHoldHours || DEFAULT_TARGET_HOLD_HOURS, personKey(), tradeDate);
|
||||
}
|
||||
+336
-2
@@ -10,7 +10,7 @@
|
||||
* safe.
|
||||
*/
|
||||
|
||||
import type { FirmPlatform } from './master-protocol';
|
||||
import type { FirmPlatform, FirmStats } from './master-protocol';
|
||||
|
||||
export interface PlatformAccount {
|
||||
id: number;
|
||||
@@ -48,8 +48,16 @@ export interface PlatformContract {
|
||||
/** Margin posted per dollar of notional (0.2 = 5:1 leverage), where the
|
||||
* broker says — caps a position at available funds ÷ this. */
|
||||
marginRate?: number;
|
||||
/** When the instrument's market next closes for long (epoch ms; Vest: a
|
||||
* weekend or holiday, not the maintenance hour), where the platform
|
||||
* says: no leg starts that wouldn't be done by then (clearsClose), each
|
||||
* account's at its own size (fireLeg). */
|
||||
closesAt?: number;
|
||||
}
|
||||
|
||||
/** What an order is for, where the caller knows (see sendOrder). */
|
||||
export type OrderIntent = 'open' | 'close';
|
||||
|
||||
export interface PlatformClient {
|
||||
accountList: PlatformAccount[];
|
||||
/** realizedPnL is today's. pendingRoll (Tradovate): the broker still shows
|
||||
@@ -74,6 +82,11 @@ export interface PlatformClient {
|
||||
fundTransactions: { [accountId: number]: { date: string; amount: number }[] };
|
||||
priorProfit: { [accountId: number]: number };
|
||||
autoLiqThresholds: { [accountId: number]: number };
|
||||
/** Share of a position's notional a stop may fill past its price — the
|
||||
* part of the daily-loss reserve that grows with size (dllReserve).
|
||||
* Only where this platform's stops are measured to fill closer than
|
||||
* DLL_SLIPPAGE_FRACTION, the default. */
|
||||
stopSlippageFraction?: number;
|
||||
|
||||
fetchDaysComplete: boolean;
|
||||
syncComplete: boolean;
|
||||
@@ -100,6 +113,18 @@ export interface PlatformClient {
|
||||
* read as a fresh stage-1 cycle, so such an account must not trade.
|
||||
*/
|
||||
historyReady(accountId: number): boolean;
|
||||
/** historyReady() as it stands, for a reader that must not change it: no
|
||||
* new hold, no baseline moved. Only where historyReady() has side effects
|
||||
* (Tradovate's balance-drift check); elsewhere historyReady() is the read. */
|
||||
peekHistoryReady?(accountId: number): boolean;
|
||||
/** Dollars of balance the account's books don't explain yet, or null when
|
||||
* they do. Only where balances arrive live but fund events are booked
|
||||
* later (TopstepX: the hub, then the next sync); the engine holds the
|
||||
* account until they're booked, or a payout plans as lost profit. */
|
||||
unbookedBalanceChange?(accountId: number): number | null;
|
||||
/** What the broker says about the account beyond active (DXTrade: its
|
||||
* accountStatus and registration time, epoch ms or NaN). */
|
||||
brokerAccountMeta?(accountId: number): { status: string; registrationTime: number } | undefined;
|
||||
/**
|
||||
* 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
|
||||
@@ -109,6 +134,55 @@ export interface PlatformClient {
|
||||
* account rather than size a stop on a guess.
|
||||
*/
|
||||
dayStartBalance?(accountId: number): number | null;
|
||||
/**
|
||||
* The equity the account may not fall below today, where the platform
|
||||
* states its daily-loss floor itself and moves it with the day's
|
||||
* transfers (Vest: reset balance × (1 − rate), lowered by a payout). The
|
||||
* engine's room is then what's left of the stage's limit (widened by
|
||||
* dayGainForRoom), never more than balance − floor (floorRoom), so a
|
||||
* same-day payout that lowers the floor frees room to trade again. null =
|
||||
* the platform has one but it isn't known yet (hold); undefined = none
|
||||
* here (the engine falls back to dayStartBalance / realized P&L).
|
||||
*/
|
||||
dailyLossFloor?(accountId: number): number | null | undefined;
|
||||
/**
|
||||
* With dailyLossFloor: how much of the day's gain a leg starting now, its
|
||||
* take-profit `leg` away and its room `limit` before any gain, may add to
|
||||
* its daily-loss room (floorRoom's `gain`) — the gain still in the account
|
||||
* since the day's base, no more than keeps the leg all but sure to close
|
||||
* before the platform's daily reset (resetClearStop).
|
||||
*/
|
||||
dayGainForRoom?(accountId: number, o: { leg: number; limit: number }): number;
|
||||
/** The trade date at `now` (epoch ms) that this platform's daily rows and
|
||||
* realized P&L follow, where its day turns at another hour than the
|
||||
* 5 PM CT roll (Vest: 20:00 ET). The engine plans the accounts and
|
||||
* counts their legs by it (tradeDateFor); absent = currentTradeDateCT. */
|
||||
tradeDate?(now: number): string;
|
||||
/** Payouts made since the firm's last daily reset — what picks the stage
|
||||
* of an account type whose stages reset each day. */
|
||||
sameDayClaims?(accountId: number): number;
|
||||
/** Each payout taken from the account, by the day it counts on — one
|
||||
* entry per payout, where a fund row can hold several (Vest books a
|
||||
* whole day's claims as one); undefined until the platform's own record
|
||||
* of them has been read. Without it, each negative fund row is one, on
|
||||
* its date. */
|
||||
payoutDays?(accountId: number): string[] | undefined;
|
||||
/** Request a payout of `amount` from the account to the login's own main
|
||||
* account, where the platform takes one by API (Vest's Claim Profit).
|
||||
* Resolves once the platform accepted it and the books were updated;
|
||||
* rejects with the platform's reason otherwise. */
|
||||
claimProfit?(accountId: number, amount: number): Promise<{ claimed: number; received: number | null }>;
|
||||
/** A claim this client made that no good sync has read past yet: the
|
||||
* balance on hand may not show it, so no claim may be judged on it. */
|
||||
claimPending?(accountId: number): boolean;
|
||||
/** Passed evaluations whose funded account the platform activates by
|
||||
* API (Vest's "Go to funded account"), by account id — less any this
|
||||
* client already asked for that no sync has read back yet. */
|
||||
activatable?(): number[];
|
||||
/** Activate the funded account of a passed evaluation. Resolves once the
|
||||
* platform opened it, with its name when the answer gives one (the next
|
||||
* sync lists it); rejects with the platform's reason otherwise. */
|
||||
activateFunded?(accountId: number): Promise<{ funded: string | null }>;
|
||||
/** Drop the account's history from memory (the wipe routes), so it reads
|
||||
* unloaded until the next fetch rebuilds it. */
|
||||
forgetHistory(accountId: number, accountName: string): void;
|
||||
@@ -118,13 +192,20 @@ export interface PlatformClient {
|
||||
findFrontMonthContract(productName: string, pick?: PickContext): Promise<PlatformContract | null>;
|
||||
fetchInstrumentFees(symbols: string[]): Promise<{ [symbol: string]: number }>;
|
||||
|
||||
/** `intent`, where the caller knows what the order is for: an entry
|
||||
* ('open') must never close a position it didn't know was there, a
|
||||
* flatten ('close') must never open one on an account that turned out
|
||||
* flat. A platform that checks it (orderEffect) refuses the order
|
||||
* otherwise (NothingToCloseError for a close with nothing to close);
|
||||
* left out, or on a platform that doesn't check, the position decides. */
|
||||
sendOrder(
|
||||
accountId: number,
|
||||
contractSymbol: string,
|
||||
quantity: number,
|
||||
action: 'Buy' | 'Sell',
|
||||
orderType: 'Market' | 'Limit',
|
||||
price?: number
|
||||
price?: number,
|
||||
intent?: OrderIntent,
|
||||
): Promise<Record<string, unknown>>;
|
||||
|
||||
placeOrderNoWait(
|
||||
@@ -189,6 +270,202 @@ export function isClientReady(client: PlatformClient, now = Date.now()): boolean
|
||||
return client.syncComplete && client.lastSyncAt > 0 && now - client.lastSyncAt < SYNC_STALE_MS;
|
||||
}
|
||||
|
||||
// ── Account status, as the master's ledger reads it ──────────────────────────
|
||||
//
|
||||
// Pure, so scripts/check-account-status.mjs can load this file. The reporter
|
||||
// is the only caller: the scheduler, the dashboards and the trading decisions
|
||||
// keep their own copies of the dead test for now.
|
||||
|
||||
/** FirmStats.statusVersion: this build sends the status fields. The master
|
||||
* reads a firm without it as an older build — unknown, never dead. */
|
||||
export const REPORT_STATUS_VERSION = 1 as const;
|
||||
|
||||
export type ReportedAccount = NonNullable<FirmStats['accounts']>[number];
|
||||
export type AccountReportStatus = NonNullable<ReportedAccount['status']>;
|
||||
|
||||
export interface AccountStatusInput {
|
||||
/** The cash balance; null or undefined while the platform has none. */
|
||||
cash: number | null | undefined;
|
||||
/** Auto-liquidation floor; 0 = none known. */
|
||||
floor: number;
|
||||
active: boolean;
|
||||
historyReady: boolean;
|
||||
/** Why the platform has the account inactive, where it can say. */
|
||||
inactiveReason?: string;
|
||||
/** Why a dead account is dead, where the platform can say. */
|
||||
deadReason?: string;
|
||||
}
|
||||
|
||||
/** The dead test the reporter has always applied: a floor (0 = none, negative
|
||||
* = a reconstructed TopstepX MLL) the cash is at or below. It read a missing
|
||||
* balance as 0, so pass `cash ?? 0` for the old answer. */
|
||||
export function legacyDead(cashOrZero: number, floor: number): boolean {
|
||||
return floor !== 0 && cashOrZero <= floor;
|
||||
}
|
||||
|
||||
/**
|
||||
* The account's status, first match wins:
|
||||
* 1. no balance → unknown (cash_missing) — never dead, never 0;
|
||||
* 2. the legacy dead test → dead;
|
||||
* 3. history not loaded → history_pending;
|
||||
* 4. inactive → locked: the firm stopped it trading (DLL, liquidate-only,
|
||||
* canTrade=false, CLOSE_ONLY — a pass lock looks the same);
|
||||
* 5. live.
|
||||
* So with a balance, 'dead' is exactly legacyDead(cash, floor).
|
||||
*/
|
||||
export function accountStatus(input: AccountStatusInput): { status: AccountReportStatus; reason?: string } {
|
||||
const { cash, floor } = input;
|
||||
if (cash === null || cash === undefined) return { status: 'unknown', reason: 'cash_missing' };
|
||||
if (legacyDead(cash, floor)) return input.deadReason ? { status: 'dead', reason: input.deadReason } : { status: 'dead' };
|
||||
if (!input.historyReady) return { status: 'history_pending' };
|
||||
if (!input.active) return { status: 'locked', reason: input.inactiveReason || 'inactive' };
|
||||
return { status: 'live' };
|
||||
}
|
||||
|
||||
export interface BrokerFacts {
|
||||
brokerStatus?: string;
|
||||
inactiveReason?: string;
|
||||
deadReason?: string;
|
||||
registeredAt?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The broker's own words for an account, per platform. Tradovate's list
|
||||
* entries carry closed/archived/restricted, and an inactive one there is
|
||||
* liquidate-only (a DLL lockout and a pass look alike). TopstepX's active is
|
||||
* canTrade. DXTrade's accountStatus decides both — CLOSE_ONLY inactive, the
|
||||
* terminal ones dead (dxtrade-class.ts DEAD_STATUSES) — and FULL_TRADING is
|
||||
* the normal case, so it isn't repeated. Each field is left out when there is
|
||||
* nothing to say.
|
||||
*/
|
||||
export function brokerFacts(
|
||||
platform: FirmPlatform,
|
||||
account: { active: boolean; closed?: unknown; archived?: unknown; restricted?: unknown },
|
||||
meta?: { status: string; registrationTime: number },
|
||||
): BrokerFacts {
|
||||
if (platform === 'tradovate') {
|
||||
const flags = (['closed', 'archived', 'restricted'] as const).filter((k) => account[k] === true);
|
||||
return { inactiveReason: 'liquidate_only', ...(flags.length > 0 ? { brokerStatus: flags.join(',') } : {}) };
|
||||
}
|
||||
if (platform === 'topstepx') {
|
||||
return { inactiveReason: 'cannot_trade', ...(account.active ? {} : { brokerStatus: 'canTrade:false' }) };
|
||||
}
|
||||
if (platform === 'vest') {
|
||||
// Vest's capital account status is a number; 2 is the live one
|
||||
// (vest-helpers VEST_STATUS_ACTIVE). A breached account leaves the
|
||||
// active list, so no status here reads as terminal.
|
||||
const status = meta?.status;
|
||||
return {
|
||||
inactiveReason: status ? `status:${status}` : 'inactive',
|
||||
...(status && status !== '2' ? { brokerStatus: `status:${status}` } : {}),
|
||||
...(meta && validTime(meta.registrationTime) ? { registeredAt: meta.registrationTime } : {}),
|
||||
};
|
||||
}
|
||||
const status = meta?.status || undefined;
|
||||
return {
|
||||
inactiveReason: status === 'CLOSE_ONLY' ? 'close_only' : status ? `status:${status}` : 'inactive',
|
||||
...(status ? { deadReason: `terminal:${status}` } : {}),
|
||||
...(status && status !== 'FULL_TRADING' ? { brokerStatus: status } : {}),
|
||||
...(meta && validTime(meta.registrationTime) ? { registeredAt: meta.registrationTime } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export interface AccountReportInput extends AccountStatusInput, BrokerFacts {
|
||||
/** The account's resolved catalog type; null or undefined = unassigned. */
|
||||
accountTypeId: string | null | undefined;
|
||||
/** The account's fund rows; undefined until its fund history has loaded. */
|
||||
fundTransactions: { date: string; amount: number }[] | undefined;
|
||||
}
|
||||
|
||||
const CT_DATE = new Intl.DateTimeFormat('en-CA', { timeZone: 'America/Chicago', year: 'numeric', month: '2-digit', day: '2-digit' });
|
||||
const cents = (n: number) => Math.round(n * 100) / 100;
|
||||
/** Epoch ms a Date can hold — formatting anything else throws. */
|
||||
const validTime = (ms: number | undefined): ms is number => ms !== undefined && Number.isFinite(ms) && Math.abs(ms) <= 8.64e15;
|
||||
|
||||
/**
|
||||
* When the account opened, as far as the broker shows: its earliest positive
|
||||
* fund row's date (YYYY-MM-DD), or for a DXTrade account with none its
|
||||
* registration date, in Chicago. Undefined until the fund history has loaded
|
||||
* (`fundTransactions` undefined) and when neither says. The report's
|
||||
* firstFundDate. The resolver's evidence for an account's rules date reads
|
||||
* the fund rows through here (registeredAt undefined) but takes the
|
||||
* registration instant itself, not its day (lib/kept-rules.ts openingEvidence).
|
||||
*/
|
||||
export function firstFundDate(
|
||||
fundTransactions: { date: string; amount: number }[] | undefined,
|
||||
registeredAt: number | undefined,
|
||||
): string | undefined {
|
||||
if (!fundTransactions) return undefined;
|
||||
const positive = fundTransactions.filter((f) => f.amount > 0);
|
||||
if (positive.length > 0) return positive.reduce((min, f) => (f.date < min ? f.date : min), positive[0].date).slice(0, 10);
|
||||
return validTime(registeredAt) ? CT_DATE.format(new Date(registeredAt)) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The status fields one accounts[] entry adds. Every one is left out when it
|
||||
* is null or at its default — 2000 accounts must stay well under the master
|
||||
* socket's 1 MB frame. firstFundDate: see the function of that name;
|
||||
* positiveFunds counts the positive fund rows, and both wait for the fund
|
||||
* history.
|
||||
*/
|
||||
export function accountStatusFields(input: AccountReportInput): Omit<ReportedAccount, 'name' | 'size' | 'payoutable' | 'withdrawals'> {
|
||||
const { status, reason } = accountStatus(input);
|
||||
const positive = input.fundTransactions?.filter((f) => f.amount > 0);
|
||||
const firstFund = firstFundDate(input.fundTransactions, input.registeredAt);
|
||||
return {
|
||||
status,
|
||||
...(reason ? { reason } : {}),
|
||||
...(typeof input.cash === 'number' && Number.isFinite(input.cash) ? { balance: cents(input.cash) } : {}),
|
||||
...(Number.isFinite(input.floor) && input.floor !== 0 ? { floor: cents(input.floor) } : {}),
|
||||
...(input.accountTypeId ? { accountTypeId: input.accountTypeId } : {}),
|
||||
...(firstFund ? { firstFundDate: firstFund } : {}),
|
||||
...(positive ? { positiveFunds: positive.length } : {}),
|
||||
...(input.brokerStatus ? { brokerStatus: input.brokerStatus } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** The firm-level keys every FirmStats from this build adds. syncedAt only
|
||||
* once the client has synced: the master trusts the statuses while it is
|
||||
* recent (SYNC_STALE_MS). */
|
||||
export function firmStatusFields(firmId: string, syncComplete: boolean, lastSyncAt: number): Pick<FirmStats, 'statusVersion' | 'firmId' | 'syncedAt'> {
|
||||
return {
|
||||
statusVersion: REPORT_STATUS_VERSION,
|
||||
firmId,
|
||||
...(syncComplete && validTime(lastSyncAt) && lastSyncAt > 0 ? { syncedAt: new Date(lastSyncAt).toISOString() } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** FirmStats.keptRuleIds: the firm's kept rule set ids when this machine's
|
||||
* catalog can be trusted to hold them (lib/firm-catalog.ts
|
||||
* getKeptRuleIdsForFirm), else left out — the master then counts this
|
||||
* machine as trading every account on the newest rules. */
|
||||
export function keptRuleIdsField(keptRuleIds: string[] | null): Pick<FirmStats, 'keptRuleIds'> {
|
||||
return keptRuleIds ? { keptRuleIds } : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* FirmStats for a firm with no accounts to walk — no client yet, or an empty
|
||||
* broker list. accounts is [] only once synced: before that, a firm whose
|
||||
* accounts haven't arrived would read as every saved account erased. A synced
|
||||
* firm with none also says when it synced, so the master sees a fresh, empty
|
||||
* list rather than an unknown one.
|
||||
*/
|
||||
export function emptyFirmStats(
|
||||
firm: { id: string; name: string },
|
||||
client: { syncComplete: boolean; lastSyncAt: number } | undefined,
|
||||
keptRuleIds: string[] | null = null,
|
||||
): FirmStats {
|
||||
const synced = client?.syncComplete === true;
|
||||
return {
|
||||
firm: firm.name, totalAccounts: 0, accountsTraded: 0, inTrade: 0,
|
||||
targetHit: 0, partial: 0, loss: 0, personalDll: 0, completed: 0,
|
||||
...(synced ? { accounts: [] } : {}),
|
||||
accountTypes: [],
|
||||
...firmStatusFields(firm.id, synced, client?.lastSyncAt ?? 0),
|
||||
...keptRuleIdsField(keptRuleIds),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when a client KNOWS an order never reached the broker: no live
|
||||
* connection, the broker refused it, or it failed before anything was sent.
|
||||
@@ -209,6 +486,63 @@ export function isOrderNotPlaced(err: unknown): boolean {
|
||||
return !!err && typeof err === 'object' && (err as { orderNotPlaced?: unknown }).orderNotPlaced === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* A close-only order (sendOrder's 'close') refused because a fresh read
|
||||
* showed no position in the symbol to close, and the client's own record
|
||||
* agrees: the account is flat there, so a flatten that gets this has nothing
|
||||
* left to do — nothing rides without its stop.
|
||||
*/
|
||||
export class NothingToCloseError extends OrderNotPlacedError {
|
||||
readonly nothingToClose = true;
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'NothingToCloseError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Duck-typed, like isOrderNotPlaced. */
|
||||
export function isNothingToClose(err: unknown): boolean {
|
||||
return !!err && typeof err === 'object' && (err as { nothingToClose?: unknown }).nothingToClose === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* What an order on `side` does on a position-based platform (Vest, DXtrade),
|
||||
* given a fresh read of the account's position in the symbol (`held`: its
|
||||
* side, null when flat) and the client's own last record of it (`recorded`):
|
||||
* open, or close (or reduce) — or it throws why nothing is sent. With no
|
||||
* intent a flat account opens and the opposite side closes. An entry ('open')
|
||||
* never closes: a hand trade the engine didn't know about would be closed in
|
||||
* its place, and the leg's flatten would then open a position with no exits.
|
||||
* A flatten ('close') never opens, and a flat read is NothingToCloseError only
|
||||
* when the record agrees — one empty read just after a fill the client saw
|
||||
* may be the gateway lagging, and a leg riding without its stop must still be
|
||||
* shouted about. Adding to a position is never supported.
|
||||
*/
|
||||
export function orderEffect(o: {
|
||||
symbol: string;
|
||||
held: 'long' | 'short' | null;
|
||||
recorded: 'long' | 'short' | null;
|
||||
side: 'long' | 'short';
|
||||
intent?: OrderIntent;
|
||||
}): 'open' | 'close' {
|
||||
const { symbol, held, recorded, side, intent } = o;
|
||||
if (held === side) {
|
||||
throw new OrderNotPlacedError(intent === 'close'
|
||||
? `the account holds a ${held} ${symbol} position, not one to close — nothing sent`
|
||||
: `adding to an open ${symbol} position is not supported`);
|
||||
}
|
||||
if (held && intent === 'open') {
|
||||
throw new OrderNotPlacedError(`a ${held} ${symbol} position is already open — an entry never closes it, order not sent`);
|
||||
}
|
||||
if (!held && intent === 'close') {
|
||||
if (recorded && recorded !== side) {
|
||||
throw new OrderNotPlacedError(`a fresh read shows no ${symbol} position, but this client last saw a ${recorded} one open — nothing sent`);
|
||||
}
|
||||
throw new NothingToCloseError(`no ${symbol} position to close — nothing sent`);
|
||||
}
|
||||
return held ? 'close' : 'open';
|
||||
}
|
||||
|
||||
/** An error's message without the rest of it. AxiosErrors carry the request
|
||||
* config — Tradovate's login body (the password) and Bearer tokens — so they
|
||||
* must never be logged whole. */
|
||||
|
||||
+163
-34
@@ -11,20 +11,24 @@
|
||||
* POST keeps those working unchanged.
|
||||
*/
|
||||
|
||||
import { getSetting, loadEndedCycleStart } from './db';
|
||||
import { getSetting, loadClaimedCycles, loadEndedCycleStart } from './db';
|
||||
import { pushReport } from './master-link';
|
||||
import { getBuildVersion } from './version';
|
||||
import { tradeMixFor } from './trade-mix';
|
||||
import { updaterStatus } from './updater-status';
|
||||
import { getClients } from './clients';
|
||||
import { getTradableFirms } from './firm-catalog';
|
||||
import { getAccountTypeById, getKeptRuleIdsForFirm, getTradableFirms } from './firm-catalog';
|
||||
import { getAccountConfig } from './account-type-resolver';
|
||||
import { getSchedulerStatus } from './auto-trade';
|
||||
import { botStopsActive, combineCmeSessions, computeDayPlan, currentTradeDateCT, cycleCompletion, endedCycleCompletion, LEG_COMPLETION_EPSILON, projectPerfectPath, resolveEffectiveConfig, DEFAULT_TARGET_HOLD_HOURS } from './trading-logic';
|
||||
import {
|
||||
botStopsActive, combineCmeSessions, computeDayPlan, completionPlace, cycleCompletion, endedCycleCompletion, LEG_COMPLETION_EPSILON,
|
||||
projectPerfectPath, resolveEffectiveConfig, stageInputsFor, tradeDateFor, withinEndedCycleWindow,
|
||||
} from './trading-logic';
|
||||
import type { AccountTypeStats, FirmStats, InstrumentStats, SymbolStats } from './master-protocol';
|
||||
import { accountNextLeg, accountLegShape } from './leg-estimate';
|
||||
import { sizingHoldHours } from './person-size';
|
||||
import { instrumentStatsFor, isFuturesFirm } from './instruments';
|
||||
import { historyFresh } from './platform-client';
|
||||
import { accountStatusFields, brokerFacts, emptyFirmStats, firmStatusFields, historyFresh, keptRuleIdsField } from './platform-client';
|
||||
|
||||
function collectFirmStats() {
|
||||
// Only firms this machine has credentials for. A firm it cannot log into
|
||||
@@ -32,9 +36,6 @@ function collectFirmStats() {
|
||||
// the master's table with rows for firms it does not run.
|
||||
const firms = getTradableFirms();
|
||||
const clients = getClients();
|
||||
// One raw trade date for the whole report: the combined views below roll
|
||||
// it forward, the completion check needs it as is.
|
||||
const rawToday = currentTradeDateCT();
|
||||
// projectPerfectPath's `loops` test: a type whose completed cycle keeps
|
||||
// going pays out (a funded account), one that stops just passes.
|
||||
const loops = (cfg: { payoutable?: boolean; withdrawalStages: unknown[] }): boolean =>
|
||||
@@ -44,16 +45,15 @@ function collectFirmStats() {
|
||||
|
||||
return firms.map((f) => {
|
||||
const client = clients.get(f.id);
|
||||
if (!client || client.accountList.length === 0) {
|
||||
return {
|
||||
firm: f.name, totalAccounts: 0, accountsTraded: 0, inTrade: 0,
|
||||
targetHit: 0, partial: 0, loss: 0, personalDll: 0, completed: 0,
|
||||
// Empty only once synced — before that, a firm whose accounts
|
||||
// haven't arrived would read as every saved account erased.
|
||||
...(client?.syncComplete ? { accounts: [] } : {}),
|
||||
accountTypes: [],
|
||||
};
|
||||
}
|
||||
// Absent (null) while this machine's catalog can't vouch for them.
|
||||
const keptRuleIds = getKeptRuleIdsForFirm(f.id);
|
||||
// accounts: [] only once synced (see emptyFirmStats).
|
||||
if (!client || client.accountList.length === 0) return emptyFirmStats(f, client, keptRuleIds);
|
||||
// One raw trade date per firm, the one its accounts trade on
|
||||
// (tradeDateFor — Vest's turns at 20:00 ET, not 5 PM CT): the
|
||||
// combined views below roll it forward, the completion check needs
|
||||
// it as is.
|
||||
const rawToday = tradeDateFor(client);
|
||||
|
||||
let totalAccounts = 0;
|
||||
let accountsTraded = 0;
|
||||
@@ -71,16 +71,19 @@ function collectFirmStats() {
|
||||
// Per account type, so the master can say what the accounts still to
|
||||
// trade actually are. Keyed by account type id; unassigned accounts
|
||||
// share the null bucket — they inflate "left to trade" indefinitely,
|
||||
// which is precisely the thing worth being able to see.
|
||||
// which is precisely the thing worth being able to see. Always the
|
||||
// live type's id, label and size: accounts on a kept rule set count
|
||||
// in their live type's slice (and in its keptRules).
|
||||
const byType = new Map<string | null, AccountTypeStats>();
|
||||
const bucket = (cfg: { id: string; label: string; accountSize: number } | null): AccountTypeStats => {
|
||||
const key = cfg?.id ?? null;
|
||||
let entry = byType.get(key);
|
||||
if (!entry) {
|
||||
const live = key ? getAccountTypeById(key) ?? cfg : null;
|
||||
entry = {
|
||||
accountTypeId: key,
|
||||
label: cfg?.label ?? 'Unassigned',
|
||||
accountSize: cfg?.accountSize ?? 0,
|
||||
label: live?.label ?? 'Unassigned',
|
||||
accountSize: live?.accountSize ?? 0,
|
||||
totalAccounts: 0,
|
||||
accountsTraded: 0,
|
||||
inTrade: 0,
|
||||
@@ -92,11 +95,18 @@ function collectFirmStats() {
|
||||
}
|
||||
return entry;
|
||||
};
|
||||
// The completed cycles this instance's own claims ended, by slice
|
||||
// (listed after the accounts — see below).
|
||||
const claimedBySlice = new Map<AccountTypeStats, NonNullable<AccountTypeStats['completedAccounts']>>();
|
||||
/** When each claimed entry's claim was made, to list the newest first. */
|
||||
const claimedAt = new Map<object, number>();
|
||||
|
||||
for (const acc of client.accountList) {
|
||||
const cash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 };
|
||||
const reportedCash = client.accountCashBalances[acc.id];
|
||||
const cash = reportedCash ?? { amount: 0, realizedPnL: 0 };
|
||||
const autoLiqThreshold = client.autoLiqThresholds[acc.id] ?? 0;
|
||||
const cfg = getAccountConfig(acc.id).accountType;
|
||||
const resolved = getAccountConfig(acc.id);
|
||||
const cfg = resolved.accountType;
|
||||
// undefined until the account's fund history has loaded. The master
|
||||
// clears a completed cycle once the withdrawal it was waiting for
|
||||
// shows up here, so an account that hasn't loaded reports no count
|
||||
@@ -104,11 +114,33 @@ function collectFirmStats() {
|
||||
// standing, while a wrong zero would clear one early.
|
||||
const loadedFundTxns = client.fundTransactions?.[acc.id];
|
||||
const fundTxns = loadedFundTxns ?? [];
|
||||
// One per payout, on its day: a client whose fund rows hold a
|
||||
// whole day's (Vest: two claims the same day, one row) lists them
|
||||
// itself, and says nothing until it has read them — falling back
|
||||
// to the rows there would report fewer and then more again.
|
||||
const rowPayoutDays = (rows: { date: string; amount: number }[]) => rows.filter((f) => f.amount < 0).map((f) => f.date);
|
||||
const payoutDays = client.payoutDays
|
||||
? client.payoutDays(acc.id)
|
||||
: loadedFundTxns && rowPayoutDays(loadedFundTxns);
|
||||
const payoutDaysTaken = payoutDays ?? rowPayoutDays(fundTxns);
|
||||
const withdrawals = payoutDays?.length;
|
||||
const withdrawalsTaken = payoutDaysTaken.length;
|
||||
accounts.push({
|
||||
name: acc.name,
|
||||
size: cfg?.accountSize ?? 0,
|
||||
payoutable: cfg ? loops(cfg) : null,
|
||||
...(loadedFundTxns ? { withdrawals: loadedFundTxns.filter((f) => f.amount < 0).length } : {}),
|
||||
...(withdrawals !== undefined ? { withdrawals } : {}),
|
||||
// For the master's account ledger. Data only: nothing here
|
||||
// trades on it, and the totals below keep their own test.
|
||||
...statusFieldsOrUnknown(() => accountStatusFields({
|
||||
cash: reportedCash?.amount,
|
||||
floor: autoLiqThreshold,
|
||||
active: acc.active,
|
||||
historyReady: client.peekHistoryReady ? client.peekHistoryReady(acc.id) : client.historyReady(acc.id),
|
||||
accountTypeId: cfg?.id,
|
||||
fundTransactions: loadedFundTxns,
|
||||
...brokerFacts(f.platform, acc, client.brokerAccountMeta?.(acc.id)),
|
||||
})),
|
||||
});
|
||||
// 0 = no floor; negative floors are reconstructed TopstepX MLLs.
|
||||
const isDead = autoLiqThreshold !== 0 && cash.amount <= autoLiqThreshold;
|
||||
@@ -118,6 +150,12 @@ function collectFirmStats() {
|
||||
|
||||
totalAccounts++;
|
||||
slice.totalAccounts++;
|
||||
if (resolved.keptRules) {
|
||||
const keptId = resolved.keptRules.id;
|
||||
const kept = (slice.keptRules ??= []).find((k) => k.id === keptId);
|
||||
if (kept) kept.accounts++;
|
||||
else slice.keptRules.push({ id: keptId, accounts: 1 });
|
||||
}
|
||||
|
||||
// Same combined-day view as the trade path: for a combining firm,
|
||||
// "today" spans the holiday stretch. Computed for every live
|
||||
@@ -137,6 +175,7 @@ function collectFirmStats() {
|
||||
cfg.profitTarget, cfg.consistency, cfg.minTradingDays,
|
||||
cfg.targetSameEquity, cfg.withdrawalStages,
|
||||
client.priorProfit?.[acc.id] ?? 0, fundTxns, cfg.minDayPnL,
|
||||
stageInputsFor(client, acc.id, cfg),
|
||||
) : null;
|
||||
// Days traded, for the master's per-day pass counts — the same
|
||||
// number as the local dashboard's Days Traded column
|
||||
@@ -190,6 +229,8 @@ function collectFirmStats() {
|
||||
fundTransactions: fundTxns,
|
||||
balance: cash.amount,
|
||||
realizedPnL: cash.realizedPnL,
|
||||
rawTradeDate: rawToday,
|
||||
stageInputs: stageInputsFor(client, acc.id, cfg),
|
||||
payout: {
|
||||
withdrawalPct: cfg.withdrawalPct,
|
||||
payoutCap: cfg.payoutCap,
|
||||
@@ -229,7 +270,7 @@ function collectFirmStats() {
|
||||
maxDay: qualifying.length > 0 ? Math.max(...qualifying.map((d) => d.pnl)) : 0,
|
||||
sum,
|
||||
high: highWater,
|
||||
withdrawals: fundTxns.filter((f) => f.amount < 0).length,
|
||||
withdrawals: withdrawalsTaken,
|
||||
target: effective.profitTarget,
|
||||
consistency: effective.consistency,
|
||||
minDays: effective.minTradingDays,
|
||||
@@ -253,6 +294,7 @@ function collectFirmStats() {
|
||||
const ended = endedCycleCompletion({
|
||||
accountId: acc.id,
|
||||
rawTradeDate: rawToday,
|
||||
client,
|
||||
fullDailyPnL: client.fullDailyPnL[acc.id] ?? [],
|
||||
cycleDailyPnL: client.dailyPnL[acc.id],
|
||||
fundTransactions: fundTxns,
|
||||
@@ -263,16 +305,47 @@ function collectFirmStats() {
|
||||
cfg,
|
||||
});
|
||||
if (ended) {
|
||||
// Counted one per payout, as the final day counts this
|
||||
// cycle, so the two name it alike.
|
||||
const place = completionPlace({
|
||||
payoutDays: payoutDaysTaken, fundTransactions: fundTxns, cycle: ended.cycle,
|
||||
end: client.lastFundDates?.[acc.id] ?? undefined,
|
||||
});
|
||||
(slice.completedAccounts ??= []).push({
|
||||
name: acc.name,
|
||||
date: ended.date,
|
||||
stage: ended.stage,
|
||||
stage: place.stage,
|
||||
kind: completionKind(cfg, ended.drain),
|
||||
cycle: ended.cycle,
|
||||
...(place.cycleSeq > 0 ? { cycleSeq: place.cycleSeq } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// The completed cycles this instance's own claims ended
|
||||
// (afterClaim), named for the catch-up's week: a claim can end
|
||||
// one before any report saw it complete, where the catch-up
|
||||
// can't find it again (a Vest day's second claim, or one right
|
||||
// after the reset — trading-logic claimedCycle). Named only, as
|
||||
// the catch-up's: no counter moves.
|
||||
if (cfg) {
|
||||
for (const c of loadClaimedCycles(acc.id).reverse()) {
|
||||
if (!withinEndedCycleWindow(c.date, rawToday)) continue;
|
||||
let list = claimedBySlice.get(slice);
|
||||
if (!list) claimedBySlice.set(slice, list = []);
|
||||
const entry = {
|
||||
name: acc.name,
|
||||
date: c.date,
|
||||
stage: c.stage,
|
||||
kind: completionKind(cfg, c.drain),
|
||||
cycle: c.cycle,
|
||||
...(c.cycleSeq > 0 ? { cycleSeq: c.cycleSeq } : {}),
|
||||
};
|
||||
claimedAt.set(entry, c.at ?? 0);
|
||||
list.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
if (client.positions[acc.id]) {
|
||||
inTrade++;
|
||||
accountsTraded++;
|
||||
@@ -289,6 +362,7 @@ function collectFirmStats() {
|
||||
const completion = cycleCompletion({
|
||||
accountId: acc.id,
|
||||
rawTradeDate: rawToday,
|
||||
client,
|
||||
cycleDailyPnL: client.dailyPnL[acc.id],
|
||||
rawRealizedPnL: cash.realizedPnL,
|
||||
combineCmeHolidays: cfg.combineCmeHolidays,
|
||||
@@ -308,14 +382,18 @@ function collectFirmStats() {
|
||||
dayEntry.targetHit++;
|
||||
dayEntry.completed = (dayEntry.completed ?? 0) + 1;
|
||||
}
|
||||
// The fund transaction that started this cycle, and its
|
||||
// place among that day's payouts — the catch-up names the
|
||||
// same cycle by the same key.
|
||||
const cycle = client.lastFundDates?.[acc.id] ?? '';
|
||||
const place = completionPlace({ payoutDays: payoutDaysTaken, fundTransactions: fundTxns, cycle });
|
||||
(slice.completedAccounts ??= []).push({
|
||||
name: acc.name,
|
||||
date: combined.tradeDate,
|
||||
stage: 1 + fundTxns.filter((f) => f.amount < 0).length,
|
||||
stage: place.stage,
|
||||
kind: completionKind(cfg, effective.drain),
|
||||
// The fund transaction that started this cycle — the
|
||||
// catch-up names the same cycle by the same key.
|
||||
cycle: client.lastFundDates?.[acc.id] ?? '',
|
||||
cycle,
|
||||
...(place.cycleSeq > 0 ? { cycleSeq: place.cycleSeq } : {}),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -355,11 +433,11 @@ function collectFirmStats() {
|
||||
// A grind-day fill (dayTarget 0) can flag targetHit on a
|
||||
// negative day — a loss is still a loss, so red wins.
|
||||
// A red day on an account the bot puts stops on (the
|
||||
// obfuscation stops, or a hard daily loss limit) is its own
|
||||
// obfuscation stops, or its stage's hard daily loss limit) is its own
|
||||
// personal DLL, booked on purpose — not a genuine loss. (A
|
||||
// firm DLL lockout took the !acc.active branch above and
|
||||
// stays a loss.)
|
||||
if (botStopsActive(cfg) && combined.realizedPnL <= -25) {
|
||||
if (botStopsActive(cfg, effective) && combined.realizedPnL <= -25) {
|
||||
personalDllCount++;
|
||||
slice.personalDll = (slice.personalDll ?? 0) + 1;
|
||||
} else if (combined.realizedPnL < 0) {
|
||||
@@ -377,6 +455,23 @@ function collectFirmStats() {
|
||||
}
|
||||
}
|
||||
|
||||
// Claimed cycles go last in each slice's list, newest first: the
|
||||
// master reads only so many entries a slice (twice its accounts), so
|
||||
// what it has long had drops off before a final day, a catch-up or a
|
||||
// fresh claim. One those already name goes once.
|
||||
for (const [slice, claimed] of claimedBySlice) {
|
||||
const list = slice.completedAccounts ?? [];
|
||||
const key = (e: (typeof list)[number]) => `${e.name}\n${e.cycle}\n${e.cycleSeq ?? 0}`;
|
||||
const named = new Set(list.map(key));
|
||||
// Newest first across accounts: a day's ties go by the claim's time.
|
||||
for (const e of claimed.sort((a, b) => b.date.localeCompare(a.date) || (claimedAt.get(b) ?? 0) - (claimedAt.get(a) ?? 0))) {
|
||||
if (named.has(key(e))) continue;
|
||||
named.add(key(e));
|
||||
list.push(e);
|
||||
}
|
||||
if (list.length > 0) slice.completedAccounts = list;
|
||||
}
|
||||
|
||||
// Biggest shortfall first — the account types with the most left to do
|
||||
// are the ones worth reading, and Unassigned sinks to the bottom only
|
||||
// when it is genuinely small.
|
||||
@@ -394,10 +489,26 @@ function collectFirmStats() {
|
||||
...symbolStatsFor(f, client, rawToday),
|
||||
...instrumentStatsBlock(f, client, rawToday),
|
||||
mix: tradeMixFor(f, client),
|
||||
...firmStatusFields(f.id, client.syncComplete, client.lastSyncAt),
|
||||
...keptRuleIdsField(keptRuleIds),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** An account's status fields, or unknown if working them out throws — the
|
||||
* ledger's data must never cost the rest of the report. Logged once. */
|
||||
function statusFieldsOrUnknown(fields: () => ReturnType<typeof accountStatusFields>): ReturnType<typeof accountStatusFields> {
|
||||
try {
|
||||
return fields();
|
||||
} catch (err) {
|
||||
if (!_g.__accountStatusFailed) {
|
||||
_g.__accountStatusFailed = true;
|
||||
console.error('[reporter] account status failed — reporting it as unknown', err);
|
||||
}
|
||||
return { status: 'unknown', reason: 'status_error' };
|
||||
}
|
||||
}
|
||||
|
||||
/** The live accounts of a firm's largest prefix group first — the group a
|
||||
* batch fires — each with an account type. */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -462,7 +573,7 @@ function symbolStatsFor(
|
||||
symbolStats: {
|
||||
account: acc.name,
|
||||
leg: next.leg,
|
||||
targetHoldHours: f.targetHoldHours || DEFAULT_TARGET_HOLD_HOURS,
|
||||
targetHoldHours: Math.round(sizingHoldHours(f, tradeDate) * 10) / 10,
|
||||
lastStand: next.lastStand,
|
||||
at: new Date().toISOString(),
|
||||
symbols: next.candidates.map((c) => ({
|
||||
@@ -478,7 +589,18 @@ function symbolStatsFor(
|
||||
}
|
||||
|
||||
// HMR-safe global to avoid duplicate intervals
|
||||
const _g = globalThis as typeof globalThis & { __reporterInterval?: ReturnType<typeof setInterval> };
|
||||
const _g = globalThis as typeof globalThis & {
|
||||
__reporterInterval?: ReturnType<typeof setInterval>;
|
||||
/** The last report was too big for the socket (logged on each change). */
|
||||
__reportOverSocketLimit?: boolean;
|
||||
/** accountStatusFields has thrown once (logged the first time only). */
|
||||
__accountStatusFailed?: boolean;
|
||||
};
|
||||
|
||||
/** The master's socket closes on a frame over 1,000,000 bytes (maxPayload in
|
||||
* its server.mjs), and the link would resend it every tick — a report this
|
||||
* big goes by POST instead. */
|
||||
const SOCKET_REPORT_MAX_CHARS = 900_000;
|
||||
|
||||
/**
|
||||
* Collect and send one report. Exported so the control link can fire an
|
||||
@@ -495,7 +617,14 @@ export async function sendReport(): Promise<void> {
|
||||
const firms = collectFirmStats();
|
||||
const scheduler = getSchedulerStatus();
|
||||
|
||||
if (pushReport(firms, scheduler)) return;
|
||||
const overSocketLimit = JSON.stringify(firms).length > SOCKET_REPORT_MAX_CHARS;
|
||||
if (overSocketLimit !== (_g.__reportOverSocketLimit ?? false)) {
|
||||
_g.__reportOverSocketLimit = overSocketLimit;
|
||||
console.warn(overSocketLimit
|
||||
? `[reporter] report over ${SOCKET_REPORT_MAX_CHARS} chars — sending it by POST, not the socket`
|
||||
: '[reporter] report fits the socket again');
|
||||
}
|
||||
if (!overSocketLimit && pushReport(firms, scheduler)) return;
|
||||
|
||||
await fetch(new URL('/api/report', url), {
|
||||
method: 'POST',
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { randomInt } from 'node:crypto';
|
||||
import { getOrCreateSetting } from './db';
|
||||
|
||||
// Desktop Windows browser profiles. Choose a browser once per instance, not
|
||||
// once per request, so HTTP calls and socket reconnects keep the same identity.
|
||||
const USER_AGENTS = [
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36',
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:147.0) Gecko/20100101 Firefox/147.0',
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36 Edg/144.0.0.0',
|
||||
] as const;
|
||||
|
||||
const g = globalThis as typeof globalThis & { __requestUserAgent?: string };
|
||||
|
||||
export function getRequestUserAgent(): string {
|
||||
return g.__requestUserAgent ??= getOrCreateSetting('user_agent', () => USER_AGENTS[randomInt(USER_AGENTS.length)]);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Topstep's own commission on TopstepX — the part of a fill's cost the
|
||||
* gateway never reports.
|
||||
*
|
||||
* Trade/search and the user hub carry `fees` on every half-turn, but only the
|
||||
* exchange and NFA fees (NQ $1.39 a side, GC $1.66, CL $1.51, MNQ $0.36).
|
||||
* Topstep's commission — $0.50 a side on a full-size contract, $0.25 on a
|
||||
* micro — comes off the balance with no trade carrying it. Topstep's
|
||||
* "TopstepX Commissions and Fees" page totals both: NQ and ES $3.78 a round
|
||||
* turn, MNQ and MES $1.22, GC $4.32, CL $4.02. Left out, the books ran $1.00
|
||||
* a full-size round turn above the balance (2026-10-18: an XFA $27.50 off
|
||||
* after 26 NQ/GC round turns and 3 MNQ, each combine $1.00 per round turn).
|
||||
*
|
||||
* No imports, so scripts/check-instruments.mjs can test it directly.
|
||||
*/
|
||||
|
||||
/** Per contract and side, by CME root. */
|
||||
export const TOPSTEP_COMMISSION_PER_SIDE: Readonly<Record<string, number>> = {
|
||||
NQ: 0.5, MNQ: 0.25,
|
||||
ES: 0.5, MES: 0.25,
|
||||
YM: 0.5, MYM: 0.25,
|
||||
RTY: 0.5, M2K: 0.25,
|
||||
GC: 0.5, MGC: 0.25,
|
||||
SI: 0.5,
|
||||
CL: 0.5, MCL: 0.25,
|
||||
NG: 0.5,
|
||||
ZB: 0.5, ZN: 0.5, ZF: 0.5,
|
||||
'6E': 0.5, '6J': 0.5, '6B': 0.5,
|
||||
};
|
||||
|
||||
/** A product missing from the table pays the full-size rate, so the books
|
||||
* err toward the lower balance. */
|
||||
export const TOPSTEP_COMMISSION_FALLBACK = 0.5;
|
||||
|
||||
/** TopstepX's product code in a contract id → the CME root, where the two
|
||||
* differ. ENQ, GCE and CLE are seen on real fills; the rest follow the same
|
||||
* naming, and contracts the client resolves itself override them. */
|
||||
const PRODUCT_ROOTS: Readonly<Record<string, string>> = {
|
||||
ENQ: 'NQ', EP: 'ES', GCE: 'GC', SIE: 'SI', CLE: 'CL', MCLE: 'MCL', NGE: 'NG',
|
||||
USA: 'ZB', TYA: 'ZN', FVA: 'ZF', EU6: '6E', JY6: '6J', BP6: '6B',
|
||||
};
|
||||
|
||||
/** "CON.F.US.ENQ.Z26" → "ENQ"; null for anything else. */
|
||||
export function topstepxProductCode(contractId: unknown): string | null {
|
||||
const parts = String(contractId ?? '').split('.');
|
||||
return parts.length === 5 && parts[0] === 'CON' && parts[3] ? parts[3] : null;
|
||||
}
|
||||
|
||||
/** The CME root a TopstepX contract id trades, or null when it's none this
|
||||
* table knows. `learned` maps product codes to roots off the contracts the
|
||||
* client resolved itself; the table covers the rest of the history. */
|
||||
export function topstepxRoot(contractId: unknown, learned?: ReadonlyMap<string, string>): string | null {
|
||||
const code = topstepxProductCode(contractId);
|
||||
if (!code) return null;
|
||||
const known = (root: string | undefined) => (root !== undefined && root in TOPSTEP_COMMISSION_PER_SIDE ? root : null);
|
||||
return known(learned?.get(code)) ?? known(PRODUCT_ROOTS[code]) ?? known(code);
|
||||
}
|
||||
|
||||
/** Topstep's commission on one fill of `size` contracts (one side). */
|
||||
export function topstepCommission(root: string | null, size: number): number {
|
||||
const perSide = (root !== null ? TOPSTEP_COMMISSION_PER_SIDE[root] : undefined) ?? TOPSTEP_COMMISSION_FALLBACK;
|
||||
return Math.round(perSide * Math.abs(size || 0) * 100) / 100;
|
||||
}
|
||||
|
||||
/** How a contract reads on the dashboard: "CON.F.US.ENQ.Z26" → "NQZ6" (the
|
||||
* root where it's known, else the product code); anything else as it is. */
|
||||
export function topstepxSymbol(contractId: unknown, learned?: ReadonlyMap<string, string>): string {
|
||||
const parts = String(contractId ?? '').split('.');
|
||||
const code = topstepxProductCode(contractId);
|
||||
if (!code) return String(contractId ?? '');
|
||||
const expiry = /^([FGHJKMNQUVXZ])(\d{1,2})$/.exec(parts[4] ?? '');
|
||||
const root = topstepxRoot(contractId, learned) ?? code;
|
||||
return expiry ? `${root}${expiry[1]}${expiry[2].slice(-1)}` : root;
|
||||
}
|
||||
+263
-64
@@ -11,7 +11,9 @@
|
||||
* - There is no cash-history / fund-transaction report. Daily P&L comes from
|
||||
* POST /api/Trade/search (each half-turn carries fees; closing halves carry
|
||||
* profitAndLoss), grouped into Central-time trade dates with the same 5 PM
|
||||
* roll Tradovate uses. Deposits/withdrawals are invisible to the API, so
|
||||
* roll Tradovate uses. Those fees are exchange and NFA only — Topstep's
|
||||
* commission is added from lib/topstep-fees.ts. Deposits/withdrawals are
|
||||
* invisible to the API, so
|
||||
* fund transactions only ever come from whatever the local DB already holds.
|
||||
* - There is no OCO order. placeOcoExit places the limit and stop separately
|
||||
* and cancels the sibling the moment one fills (order events, plus a safety
|
||||
@@ -20,13 +22,16 @@
|
||||
* all zeros; a blown account shows up as canTrade=false → active=false.
|
||||
*/
|
||||
|
||||
import axios from 'axios';
|
||||
import axios from './http-client';
|
||||
import * as signalR from '@microsoft/signalr';
|
||||
import { currentTradeDateCT } from './trading-logic';
|
||||
import { currentTradeDateCT, FUND_EVENT_MIN, fundReconcileStep, type FundSighting } from './trading-logic';
|
||||
import { getCachedContract } from './contract-resolver';
|
||||
import { signalrWebSocket } from './outbound-proxy';
|
||||
import { saveDailyPnL, loadDailyPnL, loadAccountMeta, saveAccountMeta, loadFundTransactions, saveFundTransactions, saveEndedCycleStart } from './db';
|
||||
import { saveDailyPnL, loadDailyPnL, loadAccountMeta, saveAccountMeta, loadFundTransactions, saveFundTransactions, saveEndedCycleStart, dropFundTransaction, recordOwnOrder } from './db';
|
||||
import { getAccountConfig } from './account-type-resolver';
|
||||
import { topstepxProductCode, topstepxRoot, topstepxSymbol, topstepCommission, TOPSTEP_COMMISSION_FALLBACK } from './topstep-fees';
|
||||
import { recordFillTrades } from './trade-recorder';
|
||||
import type { Fill } from './fill-trades';
|
||||
import type { PlatformClient, PlatformAccount, PlatformContract, ClientHealth } from './platform-client';
|
||||
import { OrderNotPlacedError, isOrderNotPlaced, errorMessage } from './platform-client';
|
||||
|
||||
@@ -70,6 +75,10 @@ const TOKEN_LIFETIME_MS = 24 * 60 * 60 * 1000;
|
||||
* minutes after its caller gave up on it. Longer than the idle timeout, so
|
||||
* it only bites where that one can't. */
|
||||
const REST_DEADLINE_MS = 90_000;
|
||||
/** account_meta: what the day's first fund booking replaced (its date, and
|
||||
* last_fund_date and ended_cycle_start as they stood), so a reversal that
|
||||
* nets the day to fee noise can put them back. */
|
||||
const FUND_DAY_BEFORE_META = 'fund_day_before';
|
||||
|
||||
/** Rejects if `promise` hasn't settled within `ms`. The underlying operation
|
||||
* keeps running (callers discard whatever it belonged to); Promise.race
|
||||
@@ -242,6 +251,12 @@ export class TopstepXClient implements PlatformClient {
|
||||
* fetched without it (after a wipe took the fund rows) would plan the
|
||||
* account as stage 1 until the next sync. */
|
||||
private reconcilePendingIds = new Set<number>();
|
||||
/** Accounts unbookedBalanceChange has warned about, until their books
|
||||
* settle — one line per episode, not one per tick. */
|
||||
private unbookedWarned = new Set<number>();
|
||||
/** Unexplained balances reconcileFunds has seen but not booked yet: one
|
||||
* is booked only once a later check still sees it (fundReconcileStep). */
|
||||
private fundSightings = new Map<number, FundSighting>();
|
||||
/** See PlatformClient: when the last good pass started reading. */
|
||||
public lastSyncStartedAt = 0;
|
||||
/** Accounts whose last order ended with its outcome unknown: each sync
|
||||
@@ -259,10 +274,14 @@ export class TopstepXClient implements PlatformClient {
|
||||
* recomputed from these on every REST sync and every live trade event. */
|
||||
private todayTrades: { [accountId: number]: Map<number, TsxTrade> } = {};
|
||||
|
||||
/** Reported fees per trade date, per account — weights for spreading the
|
||||
* off-book fee drift when reconstructing the MLL floor. */
|
||||
/** Fees per trade date, per account (reported plus Topstep's commission) —
|
||||
* weights for spreading the off-book fee drift when reconstructing the
|
||||
* MLL floor. */
|
||||
private dailyFees: { [accountId: number]: Map<string, number> } = {};
|
||||
|
||||
/** Product codes already warned about having no Topstep commission. */
|
||||
private unknownCommissionCodes = new Set<string>();
|
||||
|
||||
/** Front-month contract per product symbol, plus name→contract for order
|
||||
* placement (sendOrder receives the contract NAME, e.g. "NQZ5"). */
|
||||
private contractByProduct = new Map<string, { contract: TsxContract; resolvedAt: number }>();
|
||||
@@ -659,14 +678,13 @@ export class TopstepXClient implements PlatformClient {
|
||||
// everything the hub does, just slower).
|
||||
// With skipNegotiation the hub is a single websocket to a broker
|
||||
// endpoint, so it follows the operator's proxy like the REST calls do.
|
||||
const proxiedWs = signalrWebSocket();
|
||||
const configuredWs = signalrWebSocket();
|
||||
const hub = new signalR.HubConnectionBuilder()
|
||||
.withUrl(USER_HUB, {
|
||||
skipNegotiation: true,
|
||||
transport: signalR.HttpTransportType.WebSockets,
|
||||
accessTokenFactory: () => this.token ?? '',
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
...(proxiedWs ? { WebSocket: proxiedWs as any } : {}),
|
||||
...{ WebSocket: configuredWs },
|
||||
})
|
||||
.withAutomaticReconnect()
|
||||
.configureLogging(signalR.LogLevel.Warning)
|
||||
@@ -789,21 +807,92 @@ export class TopstepXClient implements PlatformClient {
|
||||
}
|
||||
|
||||
private recomputeRealized(accountId: number): void {
|
||||
const existing = this.accountCashBalances[accountId];
|
||||
this.accountCashBalances[accountId] = {
|
||||
amount: existing?.amount ?? 0,
|
||||
realizedPnL: this.realizedToday(accountId),
|
||||
};
|
||||
}
|
||||
|
||||
/** The current trade date's realized P&L, net of fees, off the trades
|
||||
* held — read fresh, so it's 0 the moment the date rolls even before a
|
||||
* sync recomputes cash.realizedPnL. */
|
||||
private realizedToday(accountId: number): number {
|
||||
const today = currentTradeDateCT();
|
||||
const map = this.todayTrades[accountId];
|
||||
let realized = 0;
|
||||
if (map) {
|
||||
const learned = this.learnedProductRoots();
|
||||
for (const t of map.values()) {
|
||||
if (t.voided) continue;
|
||||
if (currentTradeDateCT(new Date(t.creationTimestamp)) !== today) continue;
|
||||
realized += (t.profitAndLoss ?? 0) - (t.fees ?? 0);
|
||||
realized += (t.profitAndLoss ?? 0) - this.tradeFees(t, learned);
|
||||
}
|
||||
}
|
||||
const existing = this.accountCashBalances[accountId];
|
||||
this.accountCashBalances[accountId] = {
|
||||
amount: existing?.amount ?? 0,
|
||||
realizedPnL: Math.round(realized * 100) / 100,
|
||||
};
|
||||
return Math.round(realized * 100) / 100;
|
||||
}
|
||||
|
||||
/** What a fill cost: the exchange and NFA fees TopstepX reports plus
|
||||
* Topstep's commission, which it doesn't (lib/topstep-fees.ts). */
|
||||
private tradeFees(t: TsxTrade, learned: ReadonlyMap<string, string>): number {
|
||||
const root = topstepxRoot(t.contractId, learned);
|
||||
if (root === null) {
|
||||
const code = topstepxProductCode(t.contractId) ?? String(t.contractId);
|
||||
if (!this.unknownCommissionCodes.has(code)) {
|
||||
this.unknownCommissionCodes.add(code);
|
||||
console.warn(`[topstepx] no Topstep commission known for ${code} — charging $${TOPSTEP_COMMISSION_FALLBACK} a side`);
|
||||
}
|
||||
}
|
||||
return (t.fees ?? 0) + topstepCommission(root, t.size);
|
||||
}
|
||||
|
||||
/** The trades the last two days' fills make, in the trades table the
|
||||
* dashboard lists (lib/trade-recorder.ts). Never fails the sync. */
|
||||
private recordTrades(acc: PlatformAccount, trades: TsxTrade[], open: { contractId: string; type: number; size: number }[]): void {
|
||||
try {
|
||||
const learned = this.learnedProductRoots();
|
||||
const byContract = new Map<string, Fill[]>();
|
||||
for (const p of open) byContract.set(p.contractId, []);
|
||||
for (const t of trades) {
|
||||
if (t.voided || !(t.size > 0)) continue;
|
||||
const at = Date.parse(t.creationTimestamp);
|
||||
if (!Number.isFinite(at)) continue;
|
||||
const fills = byContract.get(t.contractId) ?? [];
|
||||
fills.push({
|
||||
id: String(t.id),
|
||||
orderId: String(t.orderId),
|
||||
at,
|
||||
side: t.side === SIDE_BUY ? 'buy' : 'sell',
|
||||
qty: t.size,
|
||||
price: t.price,
|
||||
fee: this.tradeFees(t, learned),
|
||||
pnl: t.profitAndLoss,
|
||||
});
|
||||
byContract.set(t.contractId, fills);
|
||||
}
|
||||
recordFillTrades('tsx', acc, [...byContract].map(([contractId, fills]) => {
|
||||
const p = open.find((x) => x.contractId === contractId);
|
||||
return {
|
||||
symbol: topstepxSymbol(contractId, learned),
|
||||
pointValue: null,
|
||||
fills,
|
||||
currentNet: !p ? 0 : p.type === 1 ? p.size : -p.size,
|
||||
};
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error(`[topstepx] trade record failed for ${acc.name}: ${errorMessage(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Product code → root, off the contracts resolved this run
|
||||
* ("CON.F.US.GCE.Z26" resolved for GC → GCE is GC). */
|
||||
private learnedProductRoots(): Map<string, string> {
|
||||
const learned = new Map<string, string>();
|
||||
for (const [root, { contract }] of this.contractByProduct) {
|
||||
const code = topstepxProductCode(contract.id);
|
||||
if (code) learned.set(code, root);
|
||||
}
|
||||
return learned;
|
||||
}
|
||||
|
||||
// ── sync ─────────────────────────────────────────────────────────────────
|
||||
@@ -851,12 +940,15 @@ export class TopstepXClient implements PlatformClient {
|
||||
for (const acc of this.accountList) {
|
||||
const balance = (accRes.accounts ?? []).find((a) => a.id === acc.id)?.balance ?? 0;
|
||||
let accountFresh = true;
|
||||
/** This pass's open positions, for the trades record. */
|
||||
let openNow: { contractId: string; type: number; size: number }[] | null = null;
|
||||
|
||||
try {
|
||||
const posRes = await this.post<{ positions: { accountId: number; contractId: string; type: number; size: number; averagePrice: number; creationTimestamp: string }[] }>(
|
||||
'/api/Position/searchOpen', { accountId: acc.id });
|
||||
if (superseded()) return;
|
||||
const open = (posRes.positions ?? []).filter((p) => p.size > 0);
|
||||
openNow = open;
|
||||
if (open.length > 0) {
|
||||
this.applyPosition(open[0]);
|
||||
} else {
|
||||
@@ -892,6 +984,7 @@ export class TopstepXClient implements PlatformClient {
|
||||
map.set(t.id, t);
|
||||
}
|
||||
this.todayTrades[acc.id] = map;
|
||||
if (openNow !== null) this.recordTrades(acc, tradeRes.trades ?? [], openNow);
|
||||
} catch (err) {
|
||||
accountFresh = false;
|
||||
console.error(`[topstepx] trade sync failed for ${acc.name}: ${errorMessage(err)}`);
|
||||
@@ -992,65 +1085,153 @@ export class TopstepXClient implements PlatformClient {
|
||||
* and starts a new cycle; the very first check also books any historical
|
||||
* net withdrawal in one lump (fine — the last stage repeats).
|
||||
*
|
||||
* $200 keeps fee noise and rounding out; the smallest real withdrawal in
|
||||
* the drain flow is well above it. Skipped while a position is open (fills
|
||||
* in flight) or while the trade history is known-stale.
|
||||
* FUND_EVENT_MIN keeps fee noise and rounding out. An amount is booked
|
||||
* only once a later check still sees it: for one pass, a fill landing
|
||||
* between the balance read and the trade read looks just like a fund
|
||||
* event (fundReconcileStep). Skipped while a position is open (fills in
|
||||
* flight) or while the trade history is known-stale; until it books,
|
||||
* unbookedBalanceChange holds the account.
|
||||
*/
|
||||
/** Books a withdrawal/deposit the trades can't explain. True once it has
|
||||
* actually checked the books against the balance (false when it had to
|
||||
* skip: a position open, no history or balance yet, no account type). */
|
||||
* skip: a position open, no history or balance yet, no account type; or
|
||||
* when what it saw waits on a later check to be booked). */
|
||||
private reconcileFunds(account: PlatformAccount): boolean {
|
||||
if (this.positions[account.id]) return false;
|
||||
if (this.lastFetchErrors[account.name]) return false;
|
||||
const history = this.fullDailyPnL[account.id];
|
||||
if (!history) return false; // first daily-P&L fetch hasn't landed yet
|
||||
|
||||
const cash = this.accountCashBalances[account.id];
|
||||
if (!cash) return false;
|
||||
// The account's starting balance ($0 for Express, the funded size
|
||||
// otherwise) is part of the balance the books must explain — without
|
||||
// it a funded account books a phantom deposit for its whole size. Until
|
||||
// the account type is resolved that number is unknown, so hold off
|
||||
// rather than guess $0 and misfire on a funded account.
|
||||
const acctType = getAccountConfig(account.id).accountType;
|
||||
if (!acctType) return false;
|
||||
const startBal = acctType.accountSize;
|
||||
const delta = this.positions[account.id] || this.lastFetchErrors[account.name]
|
||||
? null
|
||||
: this.unbookedBalance(account.id);
|
||||
if (delta === null) {
|
||||
// A sighting is confirmed by the next check, not one after a skip.
|
||||
this.fundSightings.delete(account.id);
|
||||
return false;
|
||||
}
|
||||
const cash = this.accountCashBalances[account.id]!;
|
||||
const startBal = getAccountConfig(account.id).accountType!.accountSize;
|
||||
const today = currentTradeDateCT();
|
||||
const tradePnL = history
|
||||
.filter((d) => d.date !== today)
|
||||
.reduce((s, d) => s + d.pnl, 0) + cash.realizedPnL;
|
||||
const txns = this.fundTransactions[account.id] ?? [];
|
||||
const funds = txns.reduce((s, f) => s + f.amount, 0);
|
||||
const delta = Math.round((cash.amount - startBal - tradePnL - funds) * 100) / 100;
|
||||
const existingToday = txns.find((f) => f.date === today)?.amount ?? null;
|
||||
const before = this.fundDayBefore(account.id, today);
|
||||
const step = fundReconcileStep({
|
||||
delta,
|
||||
sighting: this.fundSightings.get(account.id) ?? null,
|
||||
now: Date.now(),
|
||||
existingToday,
|
||||
undoable: before !== null,
|
||||
});
|
||||
const books = Math.round((cash.amount - startBal - delta) * 100) / 100;
|
||||
if (step.kind === 'unconfirmed') {
|
||||
this.fundSightings.set(account.id, step.sighting);
|
||||
if (step.fresh) console.log(`[topstepx] ${account.name}: balance ${cash.amount} vs books ${books} — ${delta} unexplained; booked if a later pass still sees it`);
|
||||
return false;
|
||||
}
|
||||
this.fundSightings.delete(account.id);
|
||||
// The first check on this instance (or since a wipe) only has the
|
||||
// fetch window to explain the balance with: a lump it books is unseen
|
||||
// P&L and past payouts, not the end of a cycle this instance watched.
|
||||
const firstCheck = loadAccountMeta(account.id, 'funds_reconciled') === null;
|
||||
if (firstCheck) saveAccountMeta(account.id, 'funds_reconciled', today);
|
||||
if (Math.abs(delta) < 200) return true;
|
||||
if (step.kind === 'settled') return true;
|
||||
|
||||
console.log(`[topstepx] ${account.name}: balance ${cash.amount} vs books ${Math.round((tradePnL + funds) * 100) / 100} — recording ${delta < 0 ? 'withdrawal' : 'deposit'} of ${delta}`);
|
||||
// A new cycle starts today: note where the one it ends began, by
|
||||
// applyCycleSlice's rule for that date's own row, for the reporter's
|
||||
// catch-up. A second event today doesn't start another cycle.
|
||||
const prevFundDate = this.lastFundDates[account.id] ?? null;
|
||||
if (!firstCheck && prevFundDate !== today) {
|
||||
const prevAmt = prevFundDate ? (txns.find((f) => f.date === prevFundDate)?.amount ?? null) : null;
|
||||
saveEndedCycleStart(account.id, { fundDate: prevFundDate ?? '', includesDay: !(prevAmt !== null && prevAmt < 0) });
|
||||
if (step.kind === 'drop') {
|
||||
// A transient booked today and then reversed: the cycle it started
|
||||
// never happened, so the markers go back to what it replaced.
|
||||
console.log(`[topstepx] ${account.name}: balance ${cash.amount} vs books ${books} — today's fund bookings net to ${step.net}, a transient and its reversal; taking today's row back`);
|
||||
dropFundTransaction(account.id, today, {
|
||||
last_fund_date: before!.lastFundDate,
|
||||
ended_cycle_start: before!.endedCycleStart,
|
||||
[FUND_DAY_BEFORE_META]: null,
|
||||
});
|
||||
this.lastFundDates[account.id] = before!.lastFundDate;
|
||||
} else {
|
||||
console.log(`[topstepx] ${account.name}: balance ${cash.amount} vs books ${books} — recording ${delta < 0 ? 'withdrawal' : 'deposit'} of ${delta}`);
|
||||
// The day's first booking: keep what it replaces.
|
||||
if (existingToday === null) {
|
||||
saveAccountMeta(account.id, FUND_DAY_BEFORE_META, JSON.stringify({
|
||||
date: today,
|
||||
lastFundDate: loadAccountMeta(account.id, 'last_fund_date'),
|
||||
endedCycleStart: loadAccountMeta(account.id, 'ended_cycle_start'),
|
||||
}));
|
||||
}
|
||||
// A new cycle starts today: note where the one it ends began, by
|
||||
// applyCycleSlice's rule for that date's own row, for the reporter's
|
||||
// catch-up. A second event today doesn't start another cycle.
|
||||
const prevFundDate = this.lastFundDates[account.id] ?? null;
|
||||
if (!firstCheck && prevFundDate !== today) {
|
||||
const prevAmt = prevFundDate ? (txns.find((f) => f.date === prevFundDate)?.amount ?? null) : null;
|
||||
saveEndedCycleStart(account.id, { fundDate: prevFundDate ?? '', includesDay: !(prevAmt !== null && prevAmt < 0) });
|
||||
}
|
||||
// Same-day events merge into one row (the table keys on
|
||||
// account+date): step.amount is today's delta on top of whatever
|
||||
// is already booked for today.
|
||||
saveFundTransactions(account.id, account.name, [{ date: today, amount: step.amount }]);
|
||||
saveAccountMeta(account.id, 'last_fund_date', today);
|
||||
this.lastFundDates[account.id] = today;
|
||||
}
|
||||
// Same-day events merge into one row (the table keys on account+date),
|
||||
// so add today's delta onto whatever is already booked for today.
|
||||
const existingToday = txns.find((f) => f.date === today)?.amount ?? 0;
|
||||
saveFundTransactions(account.id, account.name, [
|
||||
{ date: today, amount: Math.round((existingToday + delta) * 100) / 100 },
|
||||
]);
|
||||
saveAccountMeta(account.id, 'last_fund_date', today);
|
||||
this.fundTransactions[account.id] = loadFundTransactions(account.id);
|
||||
this.lastFundDates[account.id] = today;
|
||||
this.applyCycleSlice(account.id);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** What today's first fund booking replaced — null when today has none
|
||||
* that reconcileFunds made (none yet, or a row it didn't book). */
|
||||
private fundDayBefore(accountId: number, today: string): { lastFundDate: string | null; endedCycleStart: string | null } | null {
|
||||
const raw = loadAccountMeta(accountId, FUND_DAY_BEFORE_META);
|
||||
if (raw === null) return null;
|
||||
try {
|
||||
const saved = JSON.parse(raw) as { date?: unknown; lastFundDate?: unknown; endedCycleStart?: unknown };
|
||||
const strOrNull = (v: unknown) => v === null || typeof v === 'string';
|
||||
if (saved.date !== today || !strOrNull(saved.lastFundDate) || !strOrNull(saved.endedCycleStart)) return null;
|
||||
return { lastFundDate: saved.lastFundDate as string | null, endedCycleStart: saved.endedCycleStart as string | null };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Balance the books don't explain: balance less the starting balance
|
||||
* ($0 for Express, the funded size otherwise), all trade P&L (today's off
|
||||
* the trades held) and all recorded fund transactions. null until the
|
||||
* history, the balance and the account type are all known — without the
|
||||
* type the starting balance is unknown, and guessing $0 would book a
|
||||
* funded account's whole size as a phantom deposit.
|
||||
*/
|
||||
private unbookedBalance(accountId: number): number | null {
|
||||
const history = this.fullDailyPnL[accountId];
|
||||
const cash = this.accountCashBalances[accountId];
|
||||
const acctType = getAccountConfig(accountId).accountType;
|
||||
if (!history || !cash || !acctType) return null;
|
||||
const today = currentTradeDateCT();
|
||||
const tradePnL = history
|
||||
.filter((d) => d.date !== today)
|
||||
.reduce((s, d) => s + d.pnl, 0) + this.realizedToday(accountId);
|
||||
const funds = (this.fundTransactions[accountId] ?? []).reduce((s, f) => s + f.amount, 0);
|
||||
return Math.round((cash.amount - acctType.accountSize - tradePnL - funds) * 100) / 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* See PlatformClient. The hub moves the balance the moment a payout
|
||||
* lands, but reconcileFunds books it only a sync or two later — and not
|
||||
* while a position is open. Planned on in between, the payout reads as
|
||||
* lost profit: on 2026-10-06 two Express accounts that had finished
|
||||
* their cycle re-planned as stage 1, fired a full 3×NQ leg 28 seconds
|
||||
* after their payouts instead of a drain day, and were liquidated
|
||||
* against the $0 floor a payout leaves. Not checked with a position open
|
||||
* (its fills are still landing, and the account can't trade anyway).
|
||||
*/
|
||||
public unbookedBalanceChange(accountId: number): number | null {
|
||||
const delta = this.positions[accountId] ? null : this.unbookedBalance(accountId);
|
||||
if (delta === null || Math.abs(delta) < FUND_EVENT_MIN) {
|
||||
this.unbookedWarned.delete(accountId);
|
||||
return null;
|
||||
}
|
||||
if (!this.unbookedWarned.has(accountId)) {
|
||||
this.unbookedWarned.add(accountId);
|
||||
const name = this.accountList.find((a) => a.id === accountId)?.name ?? accountId;
|
||||
console.warn(`[topstepx] ${name}: balance is $${delta} off its books — held out of trading until a sync books it`);
|
||||
}
|
||||
return delta;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse-engineer the account's MLL floor — TopstepX never reports it.
|
||||
*
|
||||
@@ -1061,9 +1242,10 @@ export class TopstepXClient implements PlatformClient {
|
||||
* floor = min(0, eodBalanceHigh − trailingDrawdown)
|
||||
*
|
||||
* The EOD balance curve is rebuilt from daily P&L + recorded fund events.
|
||||
* Balance the books don't explain (sub-threshold off-book fees) is spread
|
||||
* across days proportionally to each day's reported fees — validated
|
||||
* against a platform-reported MLL to within $0.11.
|
||||
* Balance the books don't explain (sub-threshold off-book fees — mostly
|
||||
* Topstep's commission, until the books carried it) is spread across days
|
||||
* proportionally to each day's fees — validated against a
|
||||
* platform-reported MLL to within $0.11.
|
||||
*
|
||||
* Stored in autoLiqThresholds (negative, or +$0.01 once locked at
|
||||
* breakeven — 0 must keep meaning "no floor"), which lights up the same
|
||||
@@ -1116,7 +1298,7 @@ export class TopstepXClient implements PlatformClient {
|
||||
const residual = Math.round((cash.amount - startBal - cash.realizedPnL - todayFund - booked) * 100) / 100;
|
||||
// Books not settled (an unabsorbed fill or an unbooked fund event) —
|
||||
// keep the previous floor rather than skewing the curve with it.
|
||||
if (Math.abs(residual) >= 200) return;
|
||||
if (Math.abs(residual) >= FUND_EVENT_MIN) return;
|
||||
const totalFees = dates.reduce((s, d) => s + (fees.get(d) ?? 0), 0);
|
||||
|
||||
let bal = startBal;
|
||||
@@ -1251,11 +1433,13 @@ export class TopstepXClient implements PlatformClient {
|
||||
this.lastFetchRaw[account.name] = trades.length > 0 ? JSON.stringify(trades[0]) : '(no trades)';
|
||||
|
||||
const dailyMap: { [date: string]: { pnl: number; fees: number } } = {};
|
||||
const learned = this.learnedProductRoots();
|
||||
for (const t of trades) {
|
||||
const date = currentTradeDateCT(new Date(t.creationTimestamp));
|
||||
const entry = (dailyMap[date] ??= { pnl: 0, fees: 0 });
|
||||
entry.pnl += (t.profitAndLoss ?? 0) - (t.fees ?? 0);
|
||||
entry.fees += t.fees ?? 0;
|
||||
const fees = this.tradeFees(t, learned);
|
||||
entry.pnl += (t.profitAndLoss ?? 0) - fees;
|
||||
entry.fees += fees;
|
||||
}
|
||||
|
||||
const fresh = Object.entries(dailyMap)
|
||||
@@ -1268,8 +1452,8 @@ export class TopstepXClient implements PlatformClient {
|
||||
|
||||
const merged = mergePnL(cached, fresh);
|
||||
|
||||
// Reported fees per day — the weights the MLL reconstruction
|
||||
// uses to place off-book fee drift. Fresh overwrites cached.
|
||||
// Fees per day — the weights the MLL reconstruction uses to
|
||||
// place off-book fee drift. Fresh overwrites cached.
|
||||
const fees = new Map<string, number>();
|
||||
for (const e of cached) fees.set(e.date, e.fees ?? 0);
|
||||
for (const e of fresh) fees.set(e.date, e.fees);
|
||||
@@ -1340,6 +1524,7 @@ export class TopstepXClient implements PlatformClient {
|
||||
public forgetHistory(accountId: number, accountName: string): void {
|
||||
this.historyLoadedIds.delete(accountId);
|
||||
this.reconcilePendingIds.delete(accountId);
|
||||
this.fundSightings.delete(accountId);
|
||||
delete this.dailyPnL[accountId];
|
||||
delete this.fullDailyPnL[accountId];
|
||||
delete this.daysTraded[accountId];
|
||||
@@ -1461,9 +1646,11 @@ export class TopstepXClient implements PlatformClient {
|
||||
trailPrice: null,
|
||||
customTag: null,
|
||||
});
|
||||
this.noteOwnOrder(accountId, res.orderId, type);
|
||||
return res.orderId;
|
||||
} catch (err) {
|
||||
if (err instanceof GatewayRefusedError) {
|
||||
if (err.orderId !== undefined) this.noteOwnOrder(accountId, err.orderId, type);
|
||||
const ambiguous = err.orderId !== undefined
|
||||
|| (err.errorCode !== undefined && AMBIGUOUS_PLACE_ERRORS.has(err.errorCode));
|
||||
if (!ambiguous) throw new OrderNotPlacedError(`TopstepX refused the order: ${err.message}`);
|
||||
@@ -1473,6 +1660,7 @@ export class TopstepXClient implements PlatformClient {
|
||||
const status = response?.status;
|
||||
// Same rule as a refusal: an answer that names an order means it exists.
|
||||
const namesOrder = typeof response?.data?.orderId === 'number' && response.data.orderId > 0;
|
||||
if (namesOrder) this.noteOwnOrder(accountId, response?.data?.orderId, type);
|
||||
if (typeof status === 'number' && REFUSED_BEFORE_HANDLER.has(status) && !namesOrder) {
|
||||
throw new OrderNotPlacedError(`TopstepX rejected the order request: ${errorMessage(err)}`);
|
||||
}
|
||||
@@ -1480,6 +1668,17 @@ export class TopstepXClient implements PlatformClient {
|
||||
}
|
||||
}
|
||||
|
||||
/** Notes an order as the bot's, so the trade its fill opens or closes is
|
||||
* credited to the bot (lib/trade-recorder.ts). */
|
||||
private noteOwnOrder(accountId: number, orderId: unknown, type: number): void {
|
||||
if (typeof orderId !== 'number' || !(orderId > 0)) return;
|
||||
try {
|
||||
recordOwnOrder(accountId, orderId, type === TYPE_STOP ? 'stop' : type === TYPE_LIMIT ? 'limit' : 'market');
|
||||
} catch (err) {
|
||||
console.error(`[topstepx] could not note order ${orderId} as the bot's: ${errorMessage(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** See PlatformClient: an account whose last order's outcome is unknown
|
||||
* counts as holding a working order until a sync's Order/searchOpen shows
|
||||
* none. */
|
||||
|
||||
+8
-7
@@ -5,16 +5,18 @@
|
||||
* Open positions come off the client's position map, each contract named by
|
||||
* the instrument it is: a DXtrade position carries its symbol; a futures one
|
||||
* is looked up among the contracts this machine has entered (recorded at the
|
||||
* fill, kept across restarts), then the resolver's cache and TopstepX's own.
|
||||
* fill, kept across restarts), TopstepX's own, and every contract the
|
||||
* resolver has resolved in this process (never expiring).
|
||||
* Entries are counted as they fill and kept in a setting per trade date, so
|
||||
* a restart doesn't lose the day.
|
||||
*/
|
||||
|
||||
import { getSetting, setSetting } from './db';
|
||||
import { currentTradeDateCT } from './trading-logic';
|
||||
import { getAllCachedContracts } from './contract-resolver';
|
||||
import { rootOfContract } from './contract-resolver';
|
||||
import { errorMessage } from './platform-client';
|
||||
import type { InstrumentCount, TradeMix } from './master-protocol';
|
||||
import { isNotionalPlatform } from './master-protocol';
|
||||
|
||||
const TODAY_KEY = 'trade_mix_today';
|
||||
const NAMES_KEY = 'trade_mix_contracts';
|
||||
@@ -71,16 +73,15 @@ export function tradeMixFor(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
client: any,
|
||||
): TradeMix {
|
||||
// Recorded at the fill, then TopstepX's own contracts, then every
|
||||
// contract this process resolved (Tradovate's, however long ago).
|
||||
const names = new Map<string, string>();
|
||||
for (const [root, contract] of Object.entries(getAllCachedContracts())) {
|
||||
if (contract) names.set(String(contract.id), root);
|
||||
}
|
||||
const own = typeof client.contractRoots === 'function' ? (client.contractRoots() as Map<string, string>) : null;
|
||||
for (const [id, root] of own ?? []) names.set(id, root);
|
||||
for (const [id, instrument] of Object.entries(readJson<Record<string, string>>(NAMES_KEY) ?? {})) names.set(id, instrument);
|
||||
const instrumentOf = (contractId: unknown): string => (firm.platform === 'dxtrade'
|
||||
const instrumentOf = (contractId: unknown): string => (isNotionalPlatform(firm.platform)
|
||||
? String(contractId).split('|')[0]
|
||||
: names.get(String(contractId)) ?? `#${String(contractId)}`);
|
||||
: names.get(String(contractId)) ?? rootOfContract(contractId as number | string) ?? `#${String(contractId)}`);
|
||||
|
||||
const open = new Map<string, InstrumentCount>();
|
||||
for (const acc of (client.accountList ?? []) as { id: number }[]) {
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { byFillTime, fillsToTrades, startingNet, type Fill, type CarriedTrade } from './fill-trades';
|
||||
import {
|
||||
loadOwnOrders, ownOrdersSince, loadFillTrades, saveFillTrades, deleteTrades, closeTradesUnseen,
|
||||
type FillTradeRow,
|
||||
} from './db';
|
||||
|
||||
/**
|
||||
* Records the trades a futures platform's fills make (lib/fill-trades.ts) in
|
||||
* the trades table the dashboard lists, with who opened and closed each: the
|
||||
* bot when the fill's order is one it placed (own_orders), and how — its
|
||||
* stop, its take-profit, or a market close.
|
||||
*
|
||||
* Each pass hands over a window of fills (TopstepX: two days; Tradovate: the
|
||||
* session's) with the account's position now, and the window's trades are
|
||||
* rebuilt whole. A history and a position read a moment apart can disagree —
|
||||
* a fill landing between the two calls — and that pass would rebuild the
|
||||
* wrong trades, so a contract is written only when the position before its
|
||||
* window comes out the same two passes running.
|
||||
*/
|
||||
|
||||
export interface ContractFills {
|
||||
/** As shown: "NQZ6". */
|
||||
symbol: string;
|
||||
/** Dollars per point per contract, for P&L the platform didn't give. */
|
||||
pointValue: number | null;
|
||||
fills: Fill[];
|
||||
/** Signed contracts held now. */
|
||||
currentNet: number;
|
||||
}
|
||||
|
||||
/** The position before each window, last pass: prefix:account:symbol → net. */
|
||||
const lastStart = new Map<string, number>();
|
||||
|
||||
export function recordFillTrades(prefix: string, account: { id: number; name: string }, contracts: ContractFills[]): void {
|
||||
let own: Map<string, string> | null = null;
|
||||
for (const c of contracts) {
|
||||
const fills = [...c.fills].sort(byFillTime);
|
||||
const startNet = startingNet(fills, c.currentNet);
|
||||
const key = `${prefix}:${account.id}:${c.symbol}`;
|
||||
const steady = lastStart.get(key) === startNet;
|
||||
lastStart.set(key, startNet);
|
||||
if (!steady) continue;
|
||||
|
||||
const stored = loadFillTrades(account.id, prefix, c.symbol);
|
||||
const isOpen = (t: FillTradeRow) => t.closedAt === null && t.closeReason === null;
|
||||
const windowStart = fills.length > 0 ? fills[0].at : Infinity;
|
||||
|
||||
if (fills.length === 0) {
|
||||
// Nothing in the window: an open trade stays open while the
|
||||
// position does, and is closed out of sight once it's gone.
|
||||
if (startNet === 0) closeTradesUnseen(stored.filter(isOpen).map((t) => t.positionId));
|
||||
continue;
|
||||
}
|
||||
|
||||
const carryRow = stored
|
||||
.filter((t) => isOpen(t) && t.openedAt < windowStart)
|
||||
.sort((a, b) => b.openedAt - a.openedAt)[0];
|
||||
const carry: CarriedTrade | null = carryRow
|
||||
? { id: carryRow.positionId, side: carryRow.side, quantity: carryRow.quantity, openPrice: carryRow.openPrice, fee: carryRow.fee, openedAt: carryRow.openedAt }
|
||||
: null;
|
||||
const trades = fillsToTrades(fills, { startNet, pointValue: c.pointValue, carry });
|
||||
|
||||
own ??= loadOwnOrders(account.id);
|
||||
const since = ownOrdersSince();
|
||||
const rows: FillTradeRow[] = trades.map((t) => {
|
||||
const closeKind = t.closeOrderId === null ? undefined : own!.get(t.closeOrderId);
|
||||
const closed = t.closedAt !== null;
|
||||
return {
|
||||
positionId: t.carried ? t.id : `${prefix}:${t.id}`,
|
||||
accountId: account.id,
|
||||
accountName: account.name,
|
||||
symbol: c.symbol,
|
||||
side: t.side,
|
||||
quantity: t.quantity,
|
||||
openPrice: t.openPrice,
|
||||
closePrice: t.closePrice,
|
||||
pnl: t.pnl,
|
||||
fee: t.fee,
|
||||
openedAt: t.openedAt,
|
||||
closedAt: t.closedAt,
|
||||
closeReason: !closed ? null : closeKind === 'stop' ? 'stop_loss' : closeKind === 'limit' ? 'take_profit' : null,
|
||||
openedBy: t.carried ? null
|
||||
: t.openOrderId !== null && own!.has(t.openOrderId) ? 'bot'
|
||||
: t.openedAt >= since ? 'outside' : 'unknown',
|
||||
// A trade opened before own orders were noted had its exits
|
||||
// placed then too: whose close it was is unknown, not outside.
|
||||
closedBy: !closed || closeKind === 'stop' || closeKind === 'limit' ? null
|
||||
: closeKind === 'market' ? 'bot'
|
||||
: t.openedAt >= since ? 'outside' : 'unknown',
|
||||
};
|
||||
});
|
||||
saveFillTrades(rows);
|
||||
|
||||
const ids = new Set(rows.map((r) => r.positionId));
|
||||
// Built last time off a window that has since moved under it.
|
||||
deleteTrades(stored.filter((t) => t.openedAt >= windowStart && !ids.has(t.positionId)).map((t) => t.positionId));
|
||||
// Open from before the window, and the window doesn't carry it on:
|
||||
// it closed somewhere the history no longer reaches.
|
||||
closeTradesUnseen(stored.filter((t) => isOpen(t) && t.openedAt < windowStart && !ids.has(t.positionId)).map((t) => t.positionId));
|
||||
}
|
||||
}
|
||||
+567
-48
@@ -16,6 +16,8 @@ export interface WithdrawalStage {
|
||||
* fields fall back to the account type's defaults. */
|
||||
withdrawalPct?: number;
|
||||
payoutCap?: number;
|
||||
/** This stage's hard daily loss limit in dollars; absent = the type's. */
|
||||
dailyLossLimit?: number;
|
||||
}
|
||||
|
||||
export interface EffectiveConfig {
|
||||
@@ -24,6 +26,30 @@ export interface EffectiveConfig {
|
||||
minTradingDays: number;
|
||||
minDayPnL: number;
|
||||
drain: boolean;
|
||||
/** The stage's hard daily loss limit (0 = none): its own, else the type's. */
|
||||
dailyLossLimit: number;
|
||||
}
|
||||
|
||||
/** What picks a stage beyond the fund rows: the type's daily loss limit (a
|
||||
* stage may override it), and for a type whose stages reset each day, how
|
||||
* many payouts the account has made since the firm's last daily reset. */
|
||||
export interface StageInputs {
|
||||
dailyLossLimit?: number;
|
||||
stagesSameDay?: boolean;
|
||||
sameDayWithdrawals?: number;
|
||||
/** Every payout the account has made, one per payout, where a fund row
|
||||
* can hold several (PlatformClient.payoutDays — Vest books a day's
|
||||
* claims as one row). Absent = one per negative fund row. */
|
||||
payouts?: number;
|
||||
}
|
||||
|
||||
/** The payouts that pick an account's stage: today's for a type whose
|
||||
* stages reset each day, else every one — the client's own count when it
|
||||
* gives one (StageInputs.payouts), else the negative fund rows. */
|
||||
export function stagePayouts(fundTransactions: { date: string; amount: number }[], inputs: StageInputs = {}): number {
|
||||
if (inputs.stagesSameDay) return Math.max(0, Math.floor(inputs.sameDayWithdrawals ?? 0));
|
||||
if (typeof inputs.payouts === 'number' && Number.isFinite(inputs.payouts)) return Math.max(0, Math.floor(inputs.payouts));
|
||||
return fundTransactions.filter((f) => f.amount < 0).length;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,20 +73,26 @@ export function resolveEffectiveConfig(
|
||||
priorProfit: number,
|
||||
fundTransactions: { date: string; amount: number }[],
|
||||
minDayPnL = 0,
|
||||
inputs: StageInputs = {},
|
||||
): EffectiveConfig {
|
||||
const dailyLossLimit = Math.max(0, inputs.dailyLossLimit ?? 0);
|
||||
if (targetSameEquity) {
|
||||
// Simple Mode A: effective target = profitTarget - equityProfit, computed in computeDailyTarget.
|
||||
// Since computeDailyTarget already compares (amount - accountSize) against profitTarget,
|
||||
// no adjustment needed here — just return the base values.
|
||||
return { profitTarget, consistency, minTradingDays, minDayPnL, drain: false };
|
||||
return { profitTarget, consistency, minTradingDays, minDayPnL, drain: false, dailyLossLimit };
|
||||
}
|
||||
if (withdrawalStages.length > 0) {
|
||||
const withdrawalCount = fundTransactions.filter((f) => f.amount < 0).length;
|
||||
// Same-day stages (Vest): only today's payouts count, so every new
|
||||
// day starts back at stage 1 — the room a payout frees lasts only
|
||||
// until the firm's next daily reset.
|
||||
const withdrawalCount = stagePayouts(fundTransactions, inputs);
|
||||
if (withdrawalCount === 0) {
|
||||
return { profitTarget, consistency, minTradingDays, minDayPnL, drain: false }; // Stage 1 = base values
|
||||
return { profitTarget, consistency, minTradingDays, minDayPnL, drain: false, dailyLossLimit }; // Stage 1 = base values
|
||||
}
|
||||
const idx = Math.min(withdrawalCount - 1, withdrawalStages.length - 1);
|
||||
const stage = withdrawalStages[idx];
|
||||
const stageLimit = typeof stage.dailyLossLimit === 'number' && stage.dailyLossLimit >= 0 ? stage.dailyLossLimit : dailyLossLimit;
|
||||
if (stage.drain === true) {
|
||||
return {
|
||||
profitTarget: 0,
|
||||
@@ -73,12 +105,16 @@ export function resolveEffectiveConfig(
|
||||
// qualifying bar sits at the grid floor — every draw counts.
|
||||
minDayPnL: DRAIN_DAY_MIN,
|
||||
drain: true,
|
||||
dailyLossLimit: stageLimit,
|
||||
};
|
||||
}
|
||||
// Fall back to base minTradingDays when the stage doesn't specify one (0 or missing)
|
||||
return { profitTarget: stage.profit, consistency: stage.consistency, minTradingDays: stage.minTradingDays || minTradingDays, minDayPnL, drain: false };
|
||||
return {
|
||||
profitTarget: stage.profit, consistency: stage.consistency, minTradingDays: stage.minTradingDays || minTradingDays,
|
||||
minDayPnL, drain: false, dailyLossLimit: stageLimit,
|
||||
};
|
||||
}
|
||||
return { profitTarget, consistency, minTradingDays, minDayPnL, drain: false };
|
||||
return { profitTarget, consistency, minTradingDays, minDayPnL, drain: false, dailyLossLimit };
|
||||
}
|
||||
|
||||
/** @deprecated Use resolveEffectiveConfig instead */
|
||||
@@ -140,6 +176,18 @@ export function currentTradeDateCT(now: Date = new Date()): string {
|
||||
return new Date(date).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* The trade date a client's accounts are planned on and count their legs by:
|
||||
* the platform's own day where its daily rows and realized P&L follow one
|
||||
* that turns at another hour (Vest: 20:00 ET, two hours after the 5 PM CT
|
||||
* roll), else the Central trade date. Between the two rolls the Central date
|
||||
* would count the platform's still-open day both as a prior day and as
|
||||
* today's realized P&L, and start the day's leg count over mid-day.
|
||||
*/
|
||||
export function tradeDateFor(client: { tradeDate?: (now: number) => string } | null | undefined, now: Date = new Date()): string {
|
||||
return typeof client?.tradeDate === 'function' ? client.tradeDate(now.getTime()) : currentTradeDateCT(now);
|
||||
}
|
||||
|
||||
/** Drain-day target grid: $4–$15 in $0.50 steps (23 values, MNQ's
|
||||
* per-contract tick), always traded on MNQ. Drain days qualify at ≥
|
||||
* DRAIN_DAY_MIN, so every draw counts. Seeded per (account, trade date) like
|
||||
@@ -417,6 +465,11 @@ export function projectPerfectPath(opts: {
|
||||
fundTransactions: { date: string; amount: number }[];
|
||||
balance: number;
|
||||
realizedPnL: number;
|
||||
/** What picks the stage beyond the fund rows (stageInputsFor). A type
|
||||
* whose stages reset each day trades today on today's payouts and
|
||||
* every projected day from the base stage. Absent = every withdrawal
|
||||
* counts. */
|
||||
stageInputs?: StageInputs;
|
||||
/** Trading days to look ahead. */
|
||||
horizonDays?: number;
|
||||
maxWithdrawals?: number;
|
||||
@@ -438,6 +491,9 @@ export function projectPerfectPath(opts: {
|
||||
};
|
||||
/** Defaults to the current time. */
|
||||
now?: Date;
|
||||
/** The raw trade date the inputs follow (tradeDateFor — Vest's turns at
|
||||
* 20:00 ET); defaults to the Central one at `now`. */
|
||||
rawTradeDate?: string;
|
||||
}): ProjectedDay[] {
|
||||
const { accountId, cfg } = opts;
|
||||
const horizon = opts.horizonDays ?? 40;
|
||||
@@ -448,7 +504,7 @@ export function projectPerfectPath(opts: {
|
||||
const dd = Math.max(0, opts.payout?.drawdown ?? 0);
|
||||
const everyDay = cfg.tradesEveryDay === true;
|
||||
|
||||
const rawToday = currentTradeDateCT(opts.now);
|
||||
const rawToday = opts.rawTradeDate ?? currentTradeDateCT(opts.now);
|
||||
// The firm's own day view: a combining firm folds holiday-session P&L
|
||||
// into its clearing date — a Labor-Day trade and the Tuesday after it are
|
||||
// ONE trading day, exactly as the firm counts min days. Identity view
|
||||
@@ -503,17 +559,38 @@ export function projectPerfectPath(opts: {
|
||||
return room / (room + dayTarget);
|
||||
};
|
||||
|
||||
const effectiveNow = () => resolveEffectiveConfig(
|
||||
// A type whose stages reset each day (Vest) counts only the payouts made
|
||||
// since its day began: today's real ones plus the path's own on that
|
||||
// date, so every projected day starts on the base stage.
|
||||
const sameDay = opts.stageInputs?.stagesSameDay === true;
|
||||
/** The path's own payouts, by the date they are claimed on. */
|
||||
const paidOn = new Map<string, number>();
|
||||
/** The account's real payouts, one per payout (stagePayouts). */
|
||||
const paidReal = stagePayouts(opts.fundTransactions, { payouts: opts.stageInputs?.payouts });
|
||||
/** Payouts before the stage traded on `day`: that day's own for a
|
||||
* same-day type, else every withdrawal so far. */
|
||||
const paidBefore = (day: string): number => (sameDay
|
||||
? (day === today ? Math.max(0, Math.floor(opts.stageInputs?.sameDayWithdrawals ?? 0)) : 0) + (paidOn.get(day) ?? 0)
|
||||
: paidReal + withdrawals);
|
||||
/** A same-day type trades its next stage on the date a payout lands
|
||||
* (runClaims claims, then trades again on the room the claim freed) —
|
||||
* once through its stages a day: the last one repeats, and the path
|
||||
* stops there. */
|
||||
const tradesAgain = (day: string): boolean => sameDay
|
||||
&& (paidOn.get(day) ?? 0) > 0
|
||||
&& paidBefore(day) <= cfg.withdrawalStages.length;
|
||||
const effectiveOn = (day: string) => resolveEffectiveConfig(
|
||||
cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity,
|
||||
cfg.withdrawalStages, 0, funds, cfg.minDayPnL,
|
||||
sameDay ? { stagesSameDay: true, sameDayWithdrawals: paidBefore(day) } : { payouts: paidBefore(day) },
|
||||
);
|
||||
/** The payout parameters of the stage currently being traded: per-stage
|
||||
/** The payout parameters of the stage being completed: per-stage
|
||||
* overrides when set, else the type defaults. */
|
||||
const stagePayout = (): { pct: number; cap: number } => {
|
||||
const count = funds.filter((f) => f.amount < 0).length;
|
||||
const stagePayout = (stage: number): { pct: number; cap: number } => {
|
||||
const count = stage - 1;
|
||||
if (count === 0 || cfg.withdrawalStages.length === 0) return { pct: basePct, cap: baseCap };
|
||||
const stage = cfg.withdrawalStages[Math.min(count - 1, cfg.withdrawalStages.length - 1)];
|
||||
return { pct: stage.withdrawalPct ?? basePct, cap: stage.payoutCap ?? baseCap };
|
||||
const s = cfg.withdrawalStages[Math.min(count - 1, cfg.withdrawalStages.length - 1)];
|
||||
return { pct: s.withdrawalPct ?? basePct, cap: s.payoutCap ?? baseCap };
|
||||
};
|
||||
const qualifyingDays = (minDay: number, excludeDate: string) => (
|
||||
minDay > 0
|
||||
@@ -525,7 +602,7 @@ export function projectPerfectPath(opts: {
|
||||
// Skipped when "today" is a weekend date (Saturday, or Friday evening
|
||||
// after the roll): there is no session to finish.
|
||||
if (todayHasSession) {
|
||||
const eff = effectiveNow();
|
||||
const eff = effectiveOn(today);
|
||||
const plan = computeDayPlan(
|
||||
accountId, today, eff.profitTarget, eff.consistency, sim, eff.minDayPnL,
|
||||
eff.minTradingDays, balance - cfg.accountSize, opts.realizedPnL, cfg.splitEnabled, eff.drain,
|
||||
@@ -554,7 +631,7 @@ export function projectPerfectPath(opts: {
|
||||
// over and over. Only a non-payoutable eval stops after one completion.
|
||||
const loops = cfg.withdrawalStages.length > 0 || cfg.payoutable === true;
|
||||
const finishCycle = (onDate: string, dayPnl: number, stage: number, drain: boolean): boolean => {
|
||||
const { pct, cap } = stagePayout();
|
||||
const { pct, cap } = stagePayout(stage);
|
||||
const rawGross = round2(Math.max(0, balance - cfg.accountSize) * pct);
|
||||
const gross = cap > 0 ? Math.min(rawGross, round2(cap)) : rawGross;
|
||||
const net = round2(gross * profitSplit);
|
||||
@@ -563,6 +640,9 @@ export function projectPerfectPath(opts: {
|
||||
balance = round2(balance - gross);
|
||||
sim = []; // withdrawal excludes its day — fresh cycle
|
||||
withdrawals++;
|
||||
// A cycle that stood complete before today is claimed today.
|
||||
const claimDay = onDate < today ? today : onDate;
|
||||
paidOn.set(claimDay, (paidOn.get(claimDay) ?? 0) + 1);
|
||||
high = Math.max(high, dd); // a payout locks the trail at breakeven
|
||||
out.push({ date: onDate, pnl: dayPnl, stage, drain, withdrawal: -gross, prob, gross, net });
|
||||
return false;
|
||||
@@ -576,14 +656,14 @@ export function projectPerfectPath(opts: {
|
||||
// requestable after that session's close. Never "today" — a projection
|
||||
// computed over the weekend must not slide a Friday finish to Sunday.
|
||||
{
|
||||
const eff = effectiveNow();
|
||||
const eff = effectiveOn(today);
|
||||
const probe = nextDay(today);
|
||||
const plan = computeDayPlan(
|
||||
accountId, probe, eff.profitTarget, eff.consistency, sim, eff.minDayPnL,
|
||||
eff.minTradingDays, balance - cfg.accountSize, 0, cfg.splitEnabled, eff.drain,
|
||||
);
|
||||
if (plan && plan.dayTarget <= 0 && eff.minTradingDays <= qualifyingDays(eff.minDayPnL, probe)) {
|
||||
const stage = 1 + funds.filter((f) => f.amount < 0).length;
|
||||
const stage = 1 + paidBefore(today);
|
||||
const traded = sim.filter((d) => d.pnl !== 0);
|
||||
const doneOn = traded.length > 0
|
||||
? traded.reduce((a, b) => (a.date > b.date ? a : b)).date
|
||||
@@ -592,11 +672,15 @@ export function projectPerfectPath(opts: {
|
||||
}
|
||||
}
|
||||
|
||||
// A same-day type's next stage trades on the date its payout landed,
|
||||
// today's included; anything else moves on a day.
|
||||
let date = today;
|
||||
for (let step = 0; step < horizon; step++) {
|
||||
date = nextDay(date);
|
||||
const eff = effectiveNow();
|
||||
const stage = 1 + funds.filter((f) => f.amount < 0).length;
|
||||
let again = todayHasSession && tradesAgain(today);
|
||||
for (let step = 0; again || step < horizon;) {
|
||||
if (again) again = false;
|
||||
else { date = nextDay(date); step++; }
|
||||
const eff = effectiveOn(date);
|
||||
const stage = 1 + paidBefore(date);
|
||||
const plan = computeDayPlan(
|
||||
accountId, date, eff.profitTarget, eff.consistency, sim, eff.minDayPnL,
|
||||
eff.minTradingDays, balance - cfg.accountSize, 0, cfg.splitEnabled, eff.drain,
|
||||
@@ -609,6 +693,7 @@ export function projectPerfectPath(opts: {
|
||||
// The cycle completed mid-projection with nothing left to trade —
|
||||
// the pending payout lands on this day.
|
||||
if (finishCycle(date, 0, stage, eff.drain)) break;
|
||||
again = tradesAgain(date);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -622,7 +707,7 @@ export function projectPerfectPath(opts: {
|
||||
|
||||
// Did this day complete the cycle? Then it IS the payout day.
|
||||
const nd = nextDay(date);
|
||||
const eff2 = effectiveNow();
|
||||
const eff2 = effectiveOn(date);
|
||||
const plan2 = computeDayPlan(
|
||||
accountId, nd, eff2.profitTarget, eff2.consistency, sim, eff2.minDayPnL,
|
||||
eff2.minTradingDays, balance - cfg.accountSize, 0, cfg.splitEnabled, eff2.drain,
|
||||
@@ -632,6 +717,7 @@ export function projectPerfectPath(opts: {
|
||||
&& eff2.minTradingDays <= qualifyingDays(eff2.minDayPnL, nd);
|
||||
if (doneAfter) {
|
||||
if (finishCycle(date, pnl, stage, eff.drain)) break;
|
||||
again = tradesAgain(date);
|
||||
} else {
|
||||
out.push({ date, pnl, stage, drain: eff.drain, prob });
|
||||
}
|
||||
@@ -647,14 +733,22 @@ export type CycleCompletion = 'none' | 'today' | 'before';
|
||||
|
||||
/** How long a new trade date waits before it can read 'today': the platforms
|
||||
* reset realizedPnL on their own clock (a Tradovate cashBalance event, the
|
||||
* TopstepX 60s sync), not at 5 PM sharp, and until then the last session's
|
||||
* number — already booked under its own date — would count as a second day. */
|
||||
* TopstepX 60s sync, Vest's next sync after 20:00 ET), not at the roll
|
||||
* sharp, and until then the last session's number — already booked under
|
||||
* its own date — would count as a second day. */
|
||||
const ROLL_SETTLE_MS = 5 * 60 * 1000;
|
||||
|
||||
/** How long after a fund transaction starts a new cycle the cycle it ended is
|
||||
* still checked for a missed completion. */
|
||||
const ENDED_CYCLE_WINDOW_DAYS = 7;
|
||||
|
||||
/** Is `date` (a fund date, or a claimed cycle's — claimedCycle) within the
|
||||
* week its ended cycle is still named? False for an unparseable date. */
|
||||
export function withinEndedCycleWindow(date: string, rawTradeDate: string): boolean {
|
||||
const ageDays = (Date.parse(`${rawTradeDate}T00:00:00Z`) - Date.parse(`${date}T00:00:00Z`)) / (24 * 60 * 60 * 1000);
|
||||
return ageDays <= ENDED_CYCLE_WINDOW_DAYS; // NaN: false
|
||||
}
|
||||
|
||||
/** Nothing left to trade AND the days booked: with no min-day amount the
|
||||
* plan coasts at 0 while days are still owed, so count them too. */
|
||||
function cycleOwesNothing(
|
||||
@@ -679,13 +773,14 @@ function cycleOwesNothing(
|
||||
* weekend (it is the last session's stale number, already booked under its
|
||||
* own date), a Monday with a weekend-dated row — the live session filed under
|
||||
* a weekend date, which would count it twice — and the first minutes after
|
||||
* the 5 PM roll. An every-day firm's weekends are real sessions, so only the
|
||||
* roll window applies to it. */
|
||||
* the roll: the client's own (tradeDateFor), else 5 PM CT. An every-day
|
||||
* firm's weekends are real sessions, so only the roll window applies to it. */
|
||||
function sessionDoubtful(
|
||||
rawTradeDate: string,
|
||||
cycleDailyPnL: { date: string; pnl: number }[],
|
||||
now: Date,
|
||||
everyDay = false,
|
||||
client?: { tradeDate?: (now: number) => string },
|
||||
): boolean {
|
||||
const day = new Date(`${rawTradeDate}T00:00:00Z`);
|
||||
const dow = day.getUTCDay();
|
||||
@@ -696,7 +791,7 @@ function sessionDoubtful(
|
||||
if (cycleDailyPnL.some((d) => d.date === saturday || d.date === sunday)) return true;
|
||||
}
|
||||
// Settle window: a few minutes ago it was still an earlier trade date.
|
||||
return currentTradeDateCT(new Date(now.getTime() - ROLL_SETTLE_MS)) < rawTradeDate;
|
||||
return tradeDateFor(client, new Date(now.getTime() - ROLL_SETTLE_MS)) < rawTradeDate;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -709,14 +804,18 @@ function sessionDoubtful(
|
||||
* 'none' instead: a weekend (realizedPnL is the last session's stale number,
|
||||
* already booked under its own date), a Monday with a weekend-dated row — the
|
||||
* live session filed under a weekend date, which would count it twice — and
|
||||
* the first minutes after the 5 PM roll. 'before' skips those guards: it only
|
||||
* the first minutes after the roll. 'before' skips those guards: it only
|
||||
* keeps a locked, untraded pass out of the reporter's losses.
|
||||
*/
|
||||
export function cycleCompletion(opts: {
|
||||
accountId: number;
|
||||
/** currentTradeDateCT() — NOT the combined date, which rolls a weekend
|
||||
* forward to Monday. */
|
||||
/** tradeDateFor(client) — NOT the combined date, which rolls a weekend
|
||||
* forward to Monday. On a platform whose day turns later than 5 PM CT
|
||||
* (Vest), the Central date would count its open day twice. */
|
||||
rawTradeDate: string;
|
||||
/** The account's client: the settle window follows its day's roll
|
||||
* (tradeDateFor). */
|
||||
client?: { tradeDate?: (now: number) => string };
|
||||
/** Current-cycle daily P&L as the client holds it (client.dailyPnL). */
|
||||
cycleDailyPnL: { date: string; pnl: number }[];
|
||||
rawRealizedPnL: number;
|
||||
@@ -739,7 +838,7 @@ export function cycleCompletion(opts: {
|
||||
const prior = view.dailyPnL.filter((d) => d.date !== view.tradeDate);
|
||||
|
||||
if (cycleOwesNothing(accountId, eff, prior, view.tradeDate, equityProfit, view.realizedPnL, splitEnabled)) return 'before';
|
||||
if (sessionDoubtful(rawTradeDate, opts.cycleDailyPnL, opts.now ?? new Date(), everyDay)) return 'none';
|
||||
if (sessionDoubtful(rawTradeDate, opts.cycleDailyPnL, opts.now ?? new Date(), everyDay, opts.client)) return 'none';
|
||||
|
||||
const withToday = [...prior, { date: view.tradeDate, pnl: view.realizedPnL }];
|
||||
const probe = nextTradingDate(view.tradeDate, everyDay);
|
||||
@@ -747,6 +846,32 @@ export function cycleCompletion(opts: {
|
||||
return 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* A completed cycle's place among the account's payouts, counted one per
|
||||
* payout: `payoutDays` holds each payout's day once per payout
|
||||
* (PlatformClient.payoutDays — a Vest day's two claims share a fund row —
|
||||
* else the negative fund rows' dates). stage = 1 + the payouts before the
|
||||
* cycle's own: those before `end`, the fund date that ended it (the
|
||||
* catch-up), else every one (today's final day). cycleSeq = the payouts on
|
||||
* the cycle's start day (`cycle`, '' = the account's first) that came
|
||||
* before it, beyond the one whose fund row names that day — 0 but for a
|
||||
* cycle that a second payout the same day started, which `cycle` alone
|
||||
* can't tell from that day's first.
|
||||
*/
|
||||
export function completionPlace(o: {
|
||||
payoutDays: string[];
|
||||
fundTransactions: { date: string; amount: number }[];
|
||||
cycle: string;
|
||||
end?: string;
|
||||
}): { stage: number; cycleSeq: number } {
|
||||
const stage = 1 + o.payoutDays.filter((d) => o.end === undefined || d < o.end).length;
|
||||
if (o.cycle === '') return { stage, cycleSeq: 0 };
|
||||
// A deposit's day (Vest's activation) names the cycle it started; each
|
||||
// payout that day starts another.
|
||||
const named = o.fundTransactions.some((f) => f.date === o.cycle && f.amount < 0) ? 1 : 0;
|
||||
return { stage, cycleSeq: Math.max(0, o.payoutDays.filter((d) => d === o.cycle).length - named) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Catch-up for a missed final day: did the cycle the latest fund transaction
|
||||
* just ended stand complete — profit target, consistency and minimum days all
|
||||
@@ -774,8 +899,10 @@ export function cycleCompletion(opts: {
|
||||
*/
|
||||
export function endedCycleCompletion(opts: {
|
||||
accountId: number;
|
||||
/** currentTradeDateCT(). */
|
||||
/** tradeDateFor(client). */
|
||||
rawTradeDate: string;
|
||||
/** As in cycleCompletion. */
|
||||
client?: { tradeDate?: (now: number) => string };
|
||||
/** Every cycle's daily P&L, raw (client.fullDailyPnL). */
|
||||
fullDailyPnL: { date: string; pnl: number }[];
|
||||
/** Current-cycle daily P&L, raw (client.dailyPnL). */
|
||||
@@ -794,6 +921,8 @@ export function endedCycleCompletion(opts: {
|
||||
targetSameEquity: boolean; withdrawalStages: WithdrawalStage[];
|
||||
combineCmeHolidays: boolean; splitEnabled: boolean;
|
||||
tradesEveryDay?: boolean;
|
||||
/** Stages reset each day (StageInputs). */
|
||||
stagesSameDay?: boolean;
|
||||
};
|
||||
/** Defaults to the current time. */
|
||||
now?: Date;
|
||||
@@ -802,9 +931,8 @@ export function endedCycleCompletion(opts: {
|
||||
const everyDay = cfg.tradesEveryDay === true;
|
||||
const combine = effectiveCombine(cfg);
|
||||
if (lastFundDate === null || start === null) return null;
|
||||
const ageDays = (Date.parse(`${rawTradeDate}T00:00:00Z`) - Date.parse(`${lastFundDate}T00:00:00Z`)) / (24 * 60 * 60 * 1000);
|
||||
if (!(ageDays <= ENDED_CYCLE_WINDOW_DAYS)) return null; // NaN included: an unparseable date
|
||||
if (sessionDoubtful(rawTradeDate, opts.cycleDailyPnL, opts.now ?? new Date(), everyDay)) return null;
|
||||
if (!withinEndedCycleWindow(lastFundDate, rawTradeDate)) return null;
|
||||
if (sessionDoubtful(rawTradeDate, opts.cycleDailyPnL, opts.now ?? new Date(), everyDay, opts.client)) return null;
|
||||
|
||||
const latest = (a: string, b: string) => (b > a ? b : a);
|
||||
const earlierFunds = fundTransactions.filter((f) => f.date < lastFundDate);
|
||||
@@ -827,10 +955,14 @@ export function endedCycleCompletion(opts: {
|
||||
const fundAmt = fundTransactions.filter((f) => f.date === lastFundDate).reduce((s, f) => s + f.amount, 0);
|
||||
const equityBefore = round2(opts.equityProfit - curTotal - fundAmt);
|
||||
|
||||
// The stage the ended cycle traded on: only the withdrawals before it.
|
||||
// The stage the ended cycle traded on: only the withdrawals before it —
|
||||
// for a type whose stages reset each day, only that day's. One fund row
|
||||
// a day shows none before it, and the rows here hold that whole day,
|
||||
// which began on the base stage: it is judged there.
|
||||
const eff = resolveEffectiveConfig(
|
||||
cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity,
|
||||
cfg.withdrawalStages, 0, earlierFunds, cfg.minDayPnL,
|
||||
cfg.stagesSameDay === true ? { stagesSameDay: true, sameDayWithdrawals: 0 } : {},
|
||||
);
|
||||
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.
|
||||
@@ -841,12 +973,83 @@ export function endedCycleCompletion(opts: {
|
||||
|
||||
return {
|
||||
date: traded.map((d) => d.date).reduce(latest),
|
||||
// The payout's number among the account's withdrawals, a same-day
|
||||
// type's too: the master clears the entry once that many have booked.
|
||||
stage: 1 + earlierFunds.filter((f) => f.amount < 0).length,
|
||||
drain: eff.drain,
|
||||
cycle: prevFundDate ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
/** A completed cycle a payout claim ended (claimedCycle). */
|
||||
export interface ClaimedCycle { date: string; stage: number; drain: boolean; cycle: string; cycleSeq: number; at?: number }
|
||||
|
||||
/**
|
||||
* The cycle a payout claim about to go out ends (PlatformClient.claimProfit),
|
||||
* when it stands complete — cycleCompletion's 'today' or 'before' on the
|
||||
* client's numbers just before the claim — named and numbered as the final
|
||||
* day names it (completionPlace), so the master dedupes the two. date = the
|
||||
* claim's trade date. Null for a claim short of the rule (a Claim now below
|
||||
* the target) or on numbers not read yet.
|
||||
*
|
||||
* Taken before the claim books its payout, and kept once it goes through
|
||||
* (auto-trade afterClaim). The claim starts a new cycle at once, and two of
|
||||
* the cycles it can end no report would name otherwise. A Vest day's second
|
||||
* cycle: the auto-claim at the next tick beats the 30 s report, and its
|
||||
* payout books into that day's fund row, so the latest fund date doesn't
|
||||
* move for the catch-up. And one claimed right after the 20:00 ET reset,
|
||||
* which puts it back on the first stage, whose target the day's leftover
|
||||
* profit meets: all of its P&L sits in the day row of the cycle before it,
|
||||
* so endedCycleCompletion reads none of it.
|
||||
*/
|
||||
export function claimedCycle(
|
||||
client: {
|
||||
accountCashBalances: { [accountId: number]: { amount: number; realizedPnL: number } | undefined };
|
||||
dailyPnL: { [accountId: number]: { date: string; pnl: number }[] | undefined };
|
||||
fundTransactions?: { [accountId: number]: { date: string; amount: number }[] | undefined };
|
||||
lastFundDates?: { [accountId: number]: string | null | undefined };
|
||||
priorProfit?: { [accountId: number]: number | undefined };
|
||||
tradeDate?: (now: number) => string;
|
||||
sameDayClaims?: (accountId: number) => number;
|
||||
payoutDays?: (accountId: number) => string[] | undefined;
|
||||
},
|
||||
accountId: number,
|
||||
cfg: {
|
||||
profitTarget: number; consistency: number; minTradingDays: number; minDayPnL: number; accountSize: number;
|
||||
targetSameEquity: boolean; withdrawalStages: WithdrawalStage[]; splitEnabled: boolean;
|
||||
combineCmeHolidays?: boolean; tradesEveryDay?: boolean; dailyLossLimit?: number; stagesSameDay?: boolean;
|
||||
},
|
||||
now: Date = new Date(),
|
||||
tolerance = 0,
|
||||
): ClaimedCycle | null {
|
||||
const cash = client.accountCashBalances[accountId];
|
||||
const cycleDailyPnL = client.dailyPnL[accountId];
|
||||
const fundTransactions = client.fundTransactions?.[accountId];
|
||||
if (!cash || !cycleDailyPnL || !fundTransactions) return null;
|
||||
const effective = resolveEffectiveConfig(
|
||||
cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity, cfg.withdrawalStages,
|
||||
client.priorProfit?.[accountId] ?? 0, fundTransactions, cfg.minDayPnL, stageInputsFor(client, accountId, cfg),
|
||||
);
|
||||
// `tolerance`: how far short of the target the claim still ends the
|
||||
// cycle — the engine's day is done within LEG_COMPLETION_EPSILON of it.
|
||||
const judged = tolerance > 0 && effective.profitTarget > tolerance
|
||||
? { ...effective, profitTarget: effective.profitTarget - tolerance } : effective;
|
||||
const rawTradeDate = tradeDateFor(client, now);
|
||||
const completion = cycleCompletion({
|
||||
accountId, rawTradeDate, client, cycleDailyPnL, rawRealizedPnL: cash.realizedPnL,
|
||||
combineCmeHolidays: cfg.combineCmeHolidays === true, tradesEveryDay: cfg.tradesEveryDay === true,
|
||||
effective: judged, equityProfit: cash.amount - cfg.accountSize, splitEnabled: cfg.splitEnabled, now,
|
||||
});
|
||||
if (completion === 'none') return null;
|
||||
const cycle = client.lastFundDates?.[accountId] ?? '';
|
||||
const payoutDays = client.payoutDays?.(accountId) ?? fundTransactions.filter((f) => f.amount < 0).map((f) => f.date);
|
||||
const place = completionPlace({ payoutDays, fundTransactions, cycle });
|
||||
return {
|
||||
date: combineCmeSessions(effectiveCombine(cfg), [], rawTradeDate, 0).tradeDate,
|
||||
stage: place.stage, drain: effective.drain, cycle, cycleSeq: place.cycleSeq, at: now.getTime(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the next trading day's profit target for an account.
|
||||
*
|
||||
@@ -1064,6 +1267,117 @@ export function computeDayPlan(
|
||||
};
|
||||
}
|
||||
|
||||
// ── payouts not yet booked ───────────────────────────────────────────────────
|
||||
|
||||
/** A drop at least this far below a finished cycle's books holds the account. */
|
||||
export const PAYOUT_HOLD_MIN = 200;
|
||||
|
||||
/**
|
||||
* Where a finished cycle (day target 0) left the books: its fund rows, and
|
||||
* day-start equity less the P&L booked by then. Until a fund row is added,
|
||||
* booked P&L is all that may move day-start equity — relative, so history
|
||||
* missing from before the cache (or an initial deposit row some platforms
|
||||
* book and others don't) cancels out.
|
||||
*/
|
||||
export interface FinishedCycleMark { funds: string; offset: number }
|
||||
|
||||
/** Fund rows as one comparable value: a second payout on a date that already
|
||||
* has one changes the amount, not the row count. */
|
||||
export function fundRowsKey(txns: { date: string; amount: number }[]): string {
|
||||
return txns.map((f) => `${f.date}:${f.amount}`).join('|');
|
||||
}
|
||||
|
||||
export function markFinishedCycle(funds: string, dayStartEquity: number, bookedPnL: number): FinishedCycleMark {
|
||||
return { funds, offset: round2(dayStartEquity - bookedPnL) };
|
||||
}
|
||||
|
||||
/**
|
||||
* How far day-start equity has fallen below a finished cycle's books with no
|
||||
* fund row booked since — a payout the platform hasn't booked yet. null when
|
||||
* there's no mark, a fund row has moved on from it, or the drop is fee noise.
|
||||
* Planned on, that drop reads as lost profit: the finished cycle reopens as
|
||||
* the same stage and re-earns the payout at full size, instead of moving to
|
||||
* the next stage (a drain day) once the payout books.
|
||||
*/
|
||||
export function unbookedPayoutDrop(
|
||||
mark: FinishedCycleMark | null,
|
||||
funds: string,
|
||||
dayStartEquity: number,
|
||||
bookedPnL: number,
|
||||
): number | null {
|
||||
if (!mark || mark.funds !== funds) return null;
|
||||
const drop = round2(mark.offset - (dayStartEquity - bookedPnL));
|
||||
return drop >= PAYOUT_HOLD_MIN ? drop : null;
|
||||
}
|
||||
|
||||
// ── fund events from the balance (TopstepX) ──────────────────────────────────
|
||||
|
||||
/** Balance the books don't explain past this is a fund event (TopstepX's
|
||||
* reconcileFunds books it); under it, fee noise and rounding. The smallest
|
||||
* real withdrawal in the drain flow is well above it. */
|
||||
export const FUND_EVENT_MIN = 200;
|
||||
/** A sighting is booked only once a later pass, at least this much later,
|
||||
* sees the same amount... */
|
||||
export const FUND_CONFIRM_MS = 30_000;
|
||||
/** ...to within this (fee noise between the two passes). */
|
||||
export const FUND_CONFIRM_TOLERANCE = 5;
|
||||
|
||||
/** An unexplained balance seen, not yet booked: the amount and when it was
|
||||
* first seen at about that amount. */
|
||||
export interface FundSighting { delta: number; since: number }
|
||||
|
||||
export type FundStep =
|
||||
/** Under FUND_EVENT_MIN: nothing to book. */
|
||||
| { kind: 'settled' }
|
||||
/** Not yet seen twice far enough apart. `sighting` is what to keep for
|
||||
* the next pass; `fresh` when it is new (or the amount moved). */
|
||||
| { kind: 'unconfirmed'; sighting: FundSighting; fresh: boolean }
|
||||
/** Book today's row as `amount` (today's existing row merged in). */
|
||||
| { kind: 'book'; amount: number }
|
||||
/** Today's bookings net under FUND_EVENT_MIN: a transient and its
|
||||
* reversal. Take today's row back and leave `net` unbooked. */
|
||||
| { kind: 'drop'; net: number };
|
||||
|
||||
/**
|
||||
* One fresh pass's decision on the balance the books don't explain.
|
||||
*
|
||||
* The pass reads the balance first and the positions and trades after it, so
|
||||
* a fill landing in between shows in the trades but not yet in the balance
|
||||
* (or, the other way, in the balance before its trade row). For one pass the
|
||||
* books are off by the whole fill. On 2026-10-05 two Express accounts booked a
|
||||
* $3,001.16 "deposit" that way, and the next pass booked the $3,002.16
|
||||
* "withdrawal" that undid it. The two merged into a −$1.00 row, the
|
||||
* commission drift of the day. A negative row is a withdrawal: it started a
|
||||
* new cycle, dropped the day's −$3,003.32 out of it and moved both accounts
|
||||
* to their drain stage. A real fund event stays put, so an amount is booked
|
||||
* only when a pass at least FUND_CONFIRM_MS after the first sighting still
|
||||
* sees it.
|
||||
*
|
||||
* `undoable`: today's row was booked by this reconcile alone, with what it
|
||||
* replaced saved (the first booking of a day saves it), so it can be taken
|
||||
* back whole. A transient that outlasts the confirmation and is then
|
||||
* reversed nets under FUND_EVENT_MIN, and the day is dropped, not left as a
|
||||
* tiny row.
|
||||
*/
|
||||
export function fundReconcileStep(opts: {
|
||||
delta: number;
|
||||
sighting: FundSighting | null;
|
||||
now: number;
|
||||
/** Today's fund row, null when there is none. */
|
||||
existingToday: number | null;
|
||||
undoable: boolean;
|
||||
}): FundStep {
|
||||
const { delta, sighting, now, existingToday } = opts;
|
||||
if (Math.abs(delta) < FUND_EVENT_MIN) return { kind: 'settled' };
|
||||
if (sighting === null || Math.abs(sighting.delta - delta) > FUND_CONFIRM_TOLERANCE) {
|
||||
return { kind: 'unconfirmed', sighting: { delta, since: now }, fresh: true };
|
||||
}
|
||||
if (now - sighting.since < FUND_CONFIRM_MS) return { kind: 'unconfirmed', sighting, fresh: false };
|
||||
const amount = round2((existingToday ?? 0) + delta);
|
||||
if (existingToday !== null && opts.undoable && Math.abs(amount) < FUND_EVENT_MIN) return { kind: 'drop', net: amount };
|
||||
return { kind: 'book', amount };
|
||||
}
|
||||
|
||||
// ── fees ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** An entry's fee when the balance update hasn't shown it yet — at least one
|
||||
@@ -1085,11 +1399,28 @@ export function percentFee(pct: number, price: number, pointValue: number, contr
|
||||
return Math.ceil(raw * 100 - 1e-9) / 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* A price on the tick grid, free of float fuzz. 4217.6 − 87 × 0.1 is
|
||||
* 4208.900000000001 in floating point, and TopstepX refuses a price off the
|
||||
* grid ("Invalid price. Price is outside allowed range") — a GC short rode
|
||||
* with no take-profit on 2026-10-02. An averaged multi-contract fill can sit
|
||||
* between ticks too (by 1/contracts of a tick or more), so an off-grid price
|
||||
* goes `mode`'s way; fuzz within a millionth of a tick is never pushed a
|
||||
* whole tick.
|
||||
*/
|
||||
export function onTick(price: number, tickSize: number, mode: 'up' | 'down'): number {
|
||||
if (!(tickSize > 0)) return price;
|
||||
const n = price / tickSize;
|
||||
const k = mode === 'up' ? Math.ceil(n - 1e-6) : Math.floor(n + 1e-6);
|
||||
return Math.round(k * tickSize * 1e10) / 1e10;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* entry fee — the same formula the fire path always used, reproduced exactly,
|
||||
* then put on the tick grid away from the entry (onTick).
|
||||
*
|
||||
* 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
|
||||
@@ -1116,7 +1447,7 @@ export function takeProfitPlan(o: {
|
||||
const grossTarget = o.leg + totalCommission;
|
||||
const ticks = Math.ceil(grossTarget / k / o.tickSize);
|
||||
return {
|
||||
exitPrice: o.entry + dir * ticks * o.tickSize,
|
||||
exitPrice: onTick(o.entry + dir * ticks * o.tickSize, o.tickSize, o.side === 'Buy' ? 'up' : 'down'),
|
||||
ticks,
|
||||
entryFee: o.entryCommission,
|
||||
exitFee: o.entryCommission,
|
||||
@@ -1174,18 +1505,119 @@ export function obfuscationStopDollars(
|
||||
* that fills worse than its price (gaps, thin weekend books). */
|
||||
export const DLL_RESERVE_FRACTION = 0.10;
|
||||
/** …or this share of the position's notional, when that is more: a stop's
|
||||
* slippage grows with the position, a flat reserve doesn't. */
|
||||
* slippage grows with the position, a flat reserve doesn't. A platform whose
|
||||
* stops are known to fill closer states its own (stopSlippageFraction). */
|
||||
export const DLL_SLIPPAGE_FRACTION = 0.001;
|
||||
|
||||
/** The part of the day's room a stop leaves unspent. */
|
||||
export function dllReserve(dailyLossLimit: number, notional = 0): number {
|
||||
return Math.max(DLL_RESERVE_FRACTION * dailyLossLimit, DLL_SLIPPAGE_FRACTION * Math.max(0, notional));
|
||||
export function dllReserve(dailyLossLimit: number, notional = 0, slippageFraction = DLL_SLIPPAGE_FRACTION): number {
|
||||
const share = Number.isFinite(slippageFraction) && slippageFraction >= 0 ? slippageFraction : DLL_SLIPPAGE_FRACTION;
|
||||
return Math.max(DLL_RESERVE_FRACTION * dailyLossLimit, share * Math.max(0, notional));
|
||||
}
|
||||
/** 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;
|
||||
|
||||
/** The stage inputs for one account: the type's daily loss limit, and for a
|
||||
* type whose stages reset each day, the client's count of today's payouts —
|
||||
* for any other, of every payout, where the client lists them one by one
|
||||
* (payoutDays) and has read them. */
|
||||
export function stageInputsFor(
|
||||
client: { sameDayClaims?: (accountId: number) => number; payoutDays?: (accountId: number) => string[] | undefined },
|
||||
accountId: number,
|
||||
cfg: { dailyLossLimit?: number; stagesSameDay?: boolean },
|
||||
): StageInputs {
|
||||
const sameDay = cfg.stagesSameDay === true;
|
||||
const payouts = !sameDay && typeof client.payoutDays === 'function' ? client.payoutDays(accountId)?.length : undefined;
|
||||
return {
|
||||
dailyLossLimit: cfg.dailyLossLimit ?? 0,
|
||||
stagesSameDay: sameDay,
|
||||
sameDayWithdrawals: sameDay && typeof client.sameDayClaims === 'function' ? client.sameDayClaims(accountId) : 0,
|
||||
...(payouts !== undefined ? { payouts } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** A leg is given this many of its target holds to be done before a daily
|
||||
* reset counts its open loss against the next day: under a fair walk, a leg
|
||||
* whose take-profit and stop are within 2:1 is still open after four holds
|
||||
* about 1–1.5% of the time, and the reset guard fits those. */
|
||||
export const RESET_CLEAR_HOLDS = 4;
|
||||
|
||||
/** The shortest take-profit the plan's next leg may have — what
|
||||
* resetClearStop is held to: a split day can carve a leg as small as
|
||||
* MIN_LEG (pickLegAmount), else the leg is all that remains. */
|
||||
export function shortestNextLeg(plan: { remainingToday: number; splitAllowed: boolean }): number {
|
||||
return plan.splitAllowed ? Math.min(plan.remainingToday, MIN_LEG) : plan.remainingToday;
|
||||
}
|
||||
|
||||
/**
|
||||
* The widest stop a leg whose take-profit is `leg` away may carry and still
|
||||
* be all but surely closed by a daily reset `resetInMs` away; 0 when the
|
||||
* reset is under RESET_CLEAR_HOLDS holds off. The walk's exit time has a
|
||||
* tail that decays as e^(−π²·t/(2g·hold)), g = (A+B)²/(A·B): a lopsided leg
|
||||
* (a $5,000 stop on a $500 take-profit) lingers far longer than its hold. So
|
||||
* the leg may be as lopsided as k = holds-to-reset ÷ RESET_CLEAR_HOLDS times
|
||||
* the 2:1 shape (g = 4.5) allows — g ≤ 4.5k — which keeps its chance of
|
||||
* still being open at the reset what a 2:1 leg's is at four holds.
|
||||
*/
|
||||
export function resetClearStop(o: { leg: number; resetInMs: number; holdHours: number }): number {
|
||||
if (!(o.leg > 0) || !(o.holdHours > 0) || !Number.isFinite(o.resetInMs)) return 0;
|
||||
const k = o.resetInMs / (RESET_CLEAR_HOLDS * o.holdHours * 3_600_000);
|
||||
if (k < 1) return 0;
|
||||
const c = 4.5 * k - 2;
|
||||
return (o.leg * (c + Math.sqrt(c * c - 4))) / 2;
|
||||
}
|
||||
|
||||
/** Whether a leg of `holdHours` starting now is all but sure to be done
|
||||
* before its market closes `closeInMs` from now: the RESET_CLEAR_HOLDS
|
||||
* holds a daily reset is given (resetClearStop). A leg still open at the
|
||||
* close can't be exited until the market reopens, and meets the reopen's
|
||||
* gap with its stop. */
|
||||
export function clearsClose(o: { closeInMs: number; holdHours: number }): boolean {
|
||||
return o.closeInMs >= RESET_CLEAR_HOLDS * o.holdHours * 3_600_000;
|
||||
}
|
||||
|
||||
/** Why a leg of `holdHours` starting at `now` doesn't open: its market
|
||||
* closes at `closesAt` (epoch ms) before it clears (clearsClose). null when
|
||||
* it clears, or no close is known (a futures contract). */
|
||||
export function legCloseNote(closesAt: number | null | undefined, holdHours: number, now: number): string | null {
|
||||
if (closesAt === null || closesAt === undefined || clearsClose({ closeInMs: closesAt - now, holdHours })) return null;
|
||||
return `closes in ${((closesAt - now) / 3_600_000).toFixed(1)}h, under ${RESET_CLEAR_HOLDS} holds of ${holdHours.toFixed(1)}h`;
|
||||
}
|
||||
|
||||
/**
|
||||
* dllRoom where the platform states its daily-loss floor itself (Vest: the
|
||||
* equity the account may not fall below today, already moved by the day's
|
||||
* transfers): what's left of the stage's limit after the day's losses, and
|
||||
* never more than the floor leaves. The firm's floor alone isn't enough — a
|
||||
* limit set below the firm's (Vest's 3% is $1,500; the bot's day stops at
|
||||
* $1,229) would let a day that already lost $1,111 trade the $389 Vest still
|
||||
* allows. Infinity when there is no limit.
|
||||
*
|
||||
* `gain` widens it: on a split day the next leg may give back what the
|
||||
* earlier legs made, plus the limit — a $1,400 day up $2,604 gets $4,004 —
|
||||
* as Vest's own floor does. The client says how much (dayGainForRoom): the
|
||||
* gain still in the account, no more than keeps the leg all but sure to be
|
||||
* closed by the reset (resetClearStop) — a leg still open there has all of
|
||||
* its loss counted against the next day. Never more than the day's realized
|
||||
* gain either way.
|
||||
*/
|
||||
export function floorRoom(o: {
|
||||
dailyLossLimit: number;
|
||||
closedBalance: number;
|
||||
floor: number;
|
||||
/** The day's realized P&L since its base (Vest: since this client's
|
||||
* latest claim today, else since the reset). */
|
||||
realizedToday: number;
|
||||
/** What of the day's gain the room may grow by; 0 (default) = none. */
|
||||
gain?: number;
|
||||
}): number {
|
||||
if (!(o.dailyLossLimit > 0)) return Infinity;
|
||||
const widen = Math.min(Math.max(0, o.realizedToday), Math.max(0, o.gain ?? 0));
|
||||
return round2(Math.min(o.dailyLossLimit + Math.min(0, o.realizedToday) + widen, o.closedBalance - o.floor));
|
||||
}
|
||||
|
||||
/**
|
||||
* How much the firm's day may still lose before a hard daily-loss breach.
|
||||
* Infinity when the account type has no limit.
|
||||
@@ -1223,10 +1655,10 @@ export function dllRoom(o: {
|
||||
* reset has its entry fee on the old day's books, and the swap takes no more
|
||||
* than that slot on the new day's.
|
||||
*/
|
||||
export function dllStopDollars(o: { room: number; dailyLossLimit: number; entryFee: number; commissionPct: number; notional?: number }): number {
|
||||
export function dllStopDollars(o: { room: number; dailyLossLimit: number; entryFee: number; commissionPct: number; notional?: number; slippageFraction?: number }): number {
|
||||
if (!Number.isFinite(o.room)) return Infinity;
|
||||
const r = Math.max(0, o.commissionPct) / 100;
|
||||
return (o.room - dllReserve(o.dailyLossLimit, o.notional) - 2 * o.entryFee - 0.01) / (1 + r);
|
||||
return (o.room - dllReserve(o.dailyLossLimit, o.notional, o.slippageFraction) - 2 * o.entryFee - 0.01) / (1 + r);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1258,7 +1690,9 @@ export const LAST_STAND_FRACTION = 0.15;
|
||||
*/
|
||||
export function lastStand(o: { dllRoom: number; dailyLossLimit: number; maxLossRoom: number | null }): boolean {
|
||||
if (o.maxLossRoom === null || !(o.dailyLossLimit > 0) || !Number.isFinite(o.dllRoom) || !(o.dllRoom > 0)) return false;
|
||||
return o.maxLossRoom - o.dllRoom <= LAST_STAND_FRACTION * o.dllRoom;
|
||||
// A full day's loss is the limit, however much a green day widened the
|
||||
// room (floorRoom): a stop-out leaving $300 over the floor keeps a day.
|
||||
return o.maxLossRoom - o.dllRoom <= LAST_STAND_FRACTION * Math.min(o.dllRoom, o.dailyLossLimit);
|
||||
}
|
||||
|
||||
/** On a last stand: how far price can move against the leg before the firm's
|
||||
@@ -1301,10 +1735,34 @@ export function stopTicks(o: {
|
||||
}
|
||||
|
||||
/** 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;
|
||||
* obfuscation options or a hard daily loss limit: the current stage's
|
||||
* (resolveEffectiveConfig) when given, which a stage may set on a type with
|
||||
* none. 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 },
|
||||
effective?: { dailyLossLimit: number },
|
||||
): boolean {
|
||||
return cfg.firstDayStop || cfg.minDayStop || (effective ? effective.dailyLossLimit : cfg.dailyLossLimit ?? 0) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The side every new entry on this machine must take, so nothing it opens
|
||||
* hedges the person against themselves: the side of every position the
|
||||
* machine's accounts hold, in any lane (another firm's, a hand trade's), else
|
||||
* the side another lane's batch is placing right now. 'mixed' = it holds both
|
||||
* sides already (hand trades, or from before this rule), so no side can be
|
||||
* joined without hedging one of them. null = free to choose.
|
||||
*
|
||||
* The side, not the instrument: NQ futures against an NDX perp is the same
|
||||
* bet, and ES or a crypto index close enough to it that one side for
|
||||
* everything is the only rule that never hedges.
|
||||
*/
|
||||
export function machineSide(netPositions: number[], reserved: 'Buy' | 'Sell' | null): 'Buy' | 'Sell' | 'mixed' | null {
|
||||
const long = netPositions.some((n) => n > 0) || reserved === 'Buy';
|
||||
const short = netPositions.some((n) => n < 0) || reserved === 'Sell';
|
||||
if (long && short) return 'mixed';
|
||||
return long ? 'Buy' : short ? 'Sell' : null;
|
||||
}
|
||||
|
||||
// ── position size ────────────────────────────────────────────────────────────
|
||||
@@ -1312,6 +1770,44 @@ 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;
|
||||
|
||||
/** How far one person's DXtrade sizes move off the plan, each way. */
|
||||
export const PERSON_SIZE_SPREAD = 0.15;
|
||||
|
||||
/**
|
||||
* One person's size factor for a trade date, in [1 − spread, 1 + spread].
|
||||
* Seeded (FNV-1a) by the person, so all of one person's accounts size alike
|
||||
* while another person's land elsewhere, and drawn afresh each trade date.
|
||||
* FNV alone barely moves its top bits when only the key's last character
|
||||
* changes (the next day's date), so the hash is mixed (murmur3's finalizer)
|
||||
* before it is scaled.
|
||||
*/
|
||||
export function personSizeFactor(personKey: string, tradeDate: string): number {
|
||||
const key = `size|${personKey}|${tradeDate}`;
|
||||
let hash = 0x811c9dc5;
|
||||
for (let i = 0; i < key.length; i++) {
|
||||
hash ^= key.charCodeAt(i);
|
||||
hash = Math.imul(hash, 0x01000193) >>> 0;
|
||||
}
|
||||
hash ^= hash >>> 16;
|
||||
hash = Math.imul(hash, 0x85ebca6b);
|
||||
hash ^= hash >>> 13;
|
||||
hash = Math.imul(hash, 0xc2b2ae35);
|
||||
hash = (hash ^ (hash >>> 16)) >>> 0;
|
||||
return 1 - PERSON_SIZE_SPREAD + (hash / 0x100000000) * 2 * PERSON_SIZE_SPREAD;
|
||||
}
|
||||
|
||||
/**
|
||||
* The hold a person's DXtrade leg is sized for: the firm's target, moved so
|
||||
* the size comes out personSizeFactor times the plan. Size goes as 1/√hold
|
||||
* (sizeNotional), so hold / factor² does it, and every rule that ranks or
|
||||
* checks against the hold sees the same number the size was made for.
|
||||
*/
|
||||
export function personHoldHours(targetHoldHours: number, personKey: string, tradeDate: string): number {
|
||||
const hold = targetHoldHours > 0 ? targetHoldHours : DEFAULT_TARGET_HOLD_HOURS;
|
||||
const factor = personSizeFactor(personKey, tradeDate);
|
||||
return hold / (factor * factor);
|
||||
}
|
||||
|
||||
/** 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;
|
||||
@@ -1475,6 +1971,8 @@ export function planLegSize(o: {
|
||||
/** Infinity (or ≤ 0) = no obfuscation stop. */
|
||||
obfuscationDollars: number;
|
||||
availableFunds?: number;
|
||||
/** The platform's stopSlippageFraction; default DLL_SLIPPAGE_FRACTION. */
|
||||
slippageFraction?: number;
|
||||
}): {
|
||||
lastStand: boolean;
|
||||
cap: number;
|
||||
@@ -1490,7 +1988,7 @@ export function planLegSize(o: {
|
||||
obf,
|
||||
!dllOn ? Infinity
|
||||
: stand ? breachDistance({ dllRoom: o.dllRoom, maxLossRoom: o.maxLossRoom, entryFee: r * notional })
|
||||
: dllStopDollars({ room: o.dllRoom, dailyLossLimit: o.dailyLossLimit, entryFee: r * notional, commissionPct: o.commissionPct, notional }),
|
||||
: dllStopDollars({ room: o.dllRoom, dailyLossLimit: o.dailyLossLimit, entryFee: r * notional, commissionPct: o.commissionPct, notional, slippageFraction: o.slippageFraction }),
|
||||
);
|
||||
const cap = notionalCap({ marginRate: o.contract.marginRate, availableFunds: o.availableFunds });
|
||||
const step = o.contract.stepNotional ?? 0;
|
||||
@@ -1622,3 +2120,24 @@ export function isPastSessionClose(now: Date, everyDay: boolean): boolean {
|
||||
if (day === 'Sat' || day === 'Sun') return false;
|
||||
return minutes >= 14 * 60 + 55 && minutes < 17 * 60;
|
||||
}
|
||||
|
||||
/**
|
||||
* A firm's cap on how many of its accounts trade at once, on top of the
|
||||
* machine's own max concurrent accounts: keeps `items` in order, at most
|
||||
* `firm.maxConcurrentAccounts` per firm less what that firm already holds
|
||||
* open (`held`, by firm id). A cap of 0 (or none) means no firm cap.
|
||||
*/
|
||||
export function withinFirmCaps<T extends { firm: { id: string; maxConcurrentAccounts?: number } }>(
|
||||
items: readonly T[],
|
||||
held: ReadonlyMap<string, number> = new Map(),
|
||||
): T[] {
|
||||
const taken = new Map(held);
|
||||
return items.filter(({ firm }) => {
|
||||
const cap = firm.maxConcurrentAccounts ?? 0;
|
||||
if (cap <= 0) return true;
|
||||
const count = taken.get(firm.id) ?? 0;
|
||||
if (count >= cap) return false;
|
||||
taken.set(firm.id, count + 1);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
+151
-2
@@ -1,12 +1,14 @@
|
||||
// Create a class with a constructor that takes a username and password and sets the credentials
|
||||
|
||||
import axios from 'axios';
|
||||
import axios from './http-client';
|
||||
import type { AccountItem, AuthLoginResponse, Contract } from './tradovate-helpers';
|
||||
import { computeSec, randomUUIDV4 } from './tradovate-helpers';
|
||||
import { currentTradeDateCT } from './trading-logic';
|
||||
import { POINT_VALUES } from './instrument-specs';
|
||||
import { getCachedContract, resolveContracts } from './contract-resolver';
|
||||
import { saveDailyPnL, loadDailyPnL, saveAccountMeta, loadAccountMeta, saveFundTransactions, loadFundTransactions, saveEndedCycleStart } from './db';
|
||||
import { saveDailyPnL, loadDailyPnL, saveAccountMeta, loadAccountMeta, saveFundTransactions, loadFundTransactions, saveEndedCycleStart, recordOwnOrder, type OwnOrderKind } from './db';
|
||||
import { recordFillTrades, type ContractFills } from './trade-recorder';
|
||||
import { contractRoot, type Fill } from './fill-trades';
|
||||
import { brokerWebSocket } from './outbound-proxy';
|
||||
import { FILLS_FALLBACK_NOTE, OrderNotPlacedError, SYNC_STALE_MS, errorMessage, type ClientHealth } from './platform-client';
|
||||
|
||||
@@ -113,6 +115,8 @@ const FINISHED_ORDER_STATUSES = new Set(['Filled', 'Canceled', 'Rejected', 'Expi
|
||||
* a payout is hundreds. */
|
||||
const FUND_DRIFT_TOLERANCE = 25;
|
||||
const CONTRACT_FIND_TIMEOUT_MS = 15_000;
|
||||
/** How often a product's fees are asked for again, for the trades record. */
|
||||
const FEES_REFRESH_MS = 6 * 60 * 60 * 1000;
|
||||
/** On-demand front-month resolve (several Yahoo lookups, then Tradovate). */
|
||||
const CONTRACT_RESOLVE_DEADLINE_MS = 30_000;
|
||||
const READY_STATE_NAMES = ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED'];
|
||||
@@ -371,6 +375,13 @@ export class TradovateClient {
|
||||
* outcome clears it. */
|
||||
private cashHistoryRefusedSince = new Map<number, number>();
|
||||
/** Interval handles tracked so they can be cleared on reconnect */
|
||||
/** Contract id → name ("NQZ6"), for the trades record. */
|
||||
private contractNames = new Map<number, string>();
|
||||
private contractLookupInFlight = false;
|
||||
/** Product → fees per contract per side, for the trades record. */
|
||||
private feePerSide = new Map<string, number>();
|
||||
private feesAskedAt = new Map<string, number>();
|
||||
private feesInFlight = false;
|
||||
private syncInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private heartbeatInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private tokenRenewalInterval: ReturnType<typeof setInterval> | null = null;
|
||||
@@ -1049,6 +1060,8 @@ export class TradovateClient {
|
||||
{} as { [accountId: number]: { contractId: number; netPos: number; netPrice: number; timestamp: Date } }
|
||||
);
|
||||
|
||||
this.recordTrades(response);
|
||||
|
||||
this.lastSyncAt = Date.now();
|
||||
this.lastSyncStartedAt = requestedAt || this.lastSyncAt;
|
||||
// A session counts as working once it has synced (abandonSocket).
|
||||
@@ -1544,6 +1557,15 @@ export class TradovateClient {
|
||||
return !this.balanceDrifted(accountId);
|
||||
}
|
||||
|
||||
/** historyReady() without its drift check, which can start a hold and
|
||||
* moves the balance baseline — for the reporter, which must change
|
||||
* neither. A drift not yet held reads ready until the scheduler looks. */
|
||||
public peekHistoryReady(accountId: number): boolean {
|
||||
if (!this.historyLoadedIds.has(accountId)) return false;
|
||||
if (this.dailyPnL[accountId] === undefined || this.fundTransactions[accountId] === undefined) return false;
|
||||
return !this.historyHolds.has(accountId);
|
||||
}
|
||||
|
||||
/** Drop the account's history from memory (a wipe): it reads unloaded, and
|
||||
* stays out of trading, until a Cash History rebuilds it — the wipe took
|
||||
* its fund transactions, which neither the Fills report nor the cache can
|
||||
@@ -2019,6 +2041,128 @@ export class TradovateClient {
|
||||
}
|
||||
}
|
||||
|
||||
/** Notes an order as the bot's, so the trade its fill opens or closes is
|
||||
* credited to the bot (lib/trade-recorder.ts). */
|
||||
private noteOwnOrder(accountId: number, orderId: unknown, kind: OwnOrderKind): void {
|
||||
if (typeof orderId !== 'number' || !(orderId > 0)) return;
|
||||
try {
|
||||
recordOwnOrder(accountId, orderId, kind);
|
||||
} catch (err) {
|
||||
console.error(`${this.tag} could not note order ${orderId} as the bot's: ${errorMessage(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The trades the sync snapshot's fills make (the session's), in the
|
||||
* trades table the dashboard lists (lib/trade-recorder.ts). A fill names
|
||||
* its order, not its account, so the snapshot's orders say whose it is.
|
||||
* Never fails the sync.
|
||||
*/
|
||||
private recordTrades(response: { contracts?: unknown[]; orders?: unknown[]; positions?: unknown[]; fills?: unknown[] }): void {
|
||||
try {
|
||||
for (const c of (response.contracts ?? []) as { id?: unknown; name?: unknown }[]) {
|
||||
if (typeof c?.id === 'number' && typeof c.name === 'string') this.contractNames.set(c.id, c.name);
|
||||
}
|
||||
const accountOfOrder = new Map<number, number>();
|
||||
for (const o of (response.orders ?? []) as { id?: unknown; accountId?: unknown }[]) {
|
||||
if (typeof o?.id === 'number' && typeof o.accountId === 'number') accountOfOrder.set(o.id, o.accountId);
|
||||
}
|
||||
const books = new Map<number, Map<number, { fills: Fill[]; net: number; netAt: number }>>();
|
||||
const book = (accountId: number, contractId: number) => {
|
||||
const contracts = books.get(accountId) ?? new Map<number, { fills: Fill[]; net: number; netAt: number }>();
|
||||
books.set(accountId, contracts);
|
||||
const b = contracts.get(contractId) ?? { fills: [], net: 0, netAt: -Infinity };
|
||||
contracts.set(contractId, b);
|
||||
return b;
|
||||
};
|
||||
for (const p of (response.positions ?? []) as { accountId?: unknown; contractId?: unknown; netPos?: unknown; timestamp?: unknown }[]) {
|
||||
if (typeof p?.accountId !== 'number' || typeof p.contractId !== 'number') continue;
|
||||
const b = book(p.accountId, p.contractId);
|
||||
const at = Date.parse(String(p.timestamp ?? '')) || 0;
|
||||
if (at >= b.netAt) {
|
||||
b.net = typeof p.netPos === 'number' ? p.netPos : 0;
|
||||
b.netAt = at;
|
||||
}
|
||||
}
|
||||
for (const raw of (response.fills ?? []) as Record<string, unknown>[]) {
|
||||
const f = raw ?? {};
|
||||
if (f.active === false || typeof f.id !== 'number' || typeof f.orderId !== 'number' || typeof f.contractId !== 'number') continue;
|
||||
const qty = Number(f.qty);
|
||||
const price = Number(f.price);
|
||||
const at = Date.parse(String(f.timestamp ?? ''));
|
||||
const accountId = accountOfOrder.get(f.orderId);
|
||||
if (accountId === undefined || !(qty > 0) || !Number.isFinite(price) || !Number.isFinite(at)) continue;
|
||||
book(accountId, f.contractId).fills.push({
|
||||
id: String(f.id), orderId: String(f.orderId), at, side: f.action === 'Buy' ? 'buy' : 'sell', qty, price, fee: null, pnl: null,
|
||||
});
|
||||
}
|
||||
|
||||
const unnamed = new Set<number>();
|
||||
const roots = new Set<string>();
|
||||
for (const acc of this.accountList) {
|
||||
const contracts = books.get(acc.id);
|
||||
if (!contracts) continue;
|
||||
const list: ContractFills[] = [];
|
||||
for (const [contractId, b] of contracts) {
|
||||
if (b.fills.length === 0 && b.net === 0) continue;
|
||||
const name = this.contractNames.get(contractId);
|
||||
if (!name) {
|
||||
unnamed.add(contractId);
|
||||
continue;
|
||||
}
|
||||
const root = contractRoot(name);
|
||||
roots.add(root);
|
||||
const perSide = this.feePerSide.get(root);
|
||||
list.push({
|
||||
symbol: name,
|
||||
pointValue: POINT_VALUES[root] ?? null,
|
||||
currentNet: b.net,
|
||||
fills: b.fills.map((f) => ({ ...f, fee: perSide === undefined ? null : Math.round(perSide * f.qty * 100) / 100 })),
|
||||
});
|
||||
}
|
||||
if (list.length > 0) recordFillTrades('tdv', acc, list);
|
||||
}
|
||||
if (unnamed.size > 0) void this.lookUpContractNames([...unnamed]);
|
||||
this.refreshFees(roots);
|
||||
} catch (err) {
|
||||
console.error(`${this.tag} trade record failed: ${errorMessage(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Names for contracts the snapshot didn't describe — the next sync's
|
||||
* record uses them. */
|
||||
private async lookUpContractNames(ids: number[]): Promise<void> {
|
||||
if (this.contractLookupInFlight) return;
|
||||
this.contractLookupInFlight = true;
|
||||
try {
|
||||
await this.sendRequest('contract/items', `ids=${ids.join(',')}`, CONTRACT_FIND_TIMEOUT_MS, (data) => {
|
||||
for (const c of (Array.isArray(data) ? data : []) as { id?: unknown; name?: unknown }[]) {
|
||||
if (typeof c?.id === 'number' && typeof c.name === 'string') this.contractNames.set(c.id, c.name);
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn(`${this.tag} contract/items failed: ${errorMessage(err)}`);
|
||||
} finally {
|
||||
this.contractLookupInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Each product's fees per contract per side (getproductfeeparams), for
|
||||
* the trades record: asked for a product once, then every few hours. */
|
||||
private refreshFees(roots: Set<string>): void {
|
||||
const now = Date.now();
|
||||
const due = [...roots].filter((r) => now - (this.feesAskedAt.get(r) ?? 0) >= FEES_REFRESH_MS);
|
||||
if (due.length === 0 || this.feesInFlight || this.products.length === 0) return;
|
||||
this.feesInFlight = true;
|
||||
for (const r of due) this.feesAskedAt.set(r, now);
|
||||
this.fetchInstrumentFees(due)
|
||||
.then((fees) => {
|
||||
for (const [root, fee] of Object.entries(fees)) this.feePerSide.set(root, fee);
|
||||
})
|
||||
.catch((err) => console.warn(`${this.tag} fee lookup failed: ${errorMessage(err)}`))
|
||||
.finally(() => { this.feesInFlight = false; });
|
||||
}
|
||||
|
||||
/** A string or empty ack is the broker refusing the order — nothing was placed. */
|
||||
private static acceptedAck(response: unknown, endpoint: string): { orderId?: number; id?: number; commandStatus?: string } {
|
||||
if (typeof response === 'string') throw new OrderNotPlacedError(response || `${endpoint} refused`);
|
||||
@@ -2139,6 +2283,7 @@ export class TradovateClient {
|
||||
const ack = TradovateClient.acceptedAck(response, 'order/placeorder');
|
||||
// Tradovate returns the command object: { id, commandStatus, orderId, ... }
|
||||
const orderId = ack.orderId ?? ack.id;
|
||||
this.noteOwnOrder(accountId, orderId, orderType === 'Limit' ? 'limit' : 'market');
|
||||
console.log(`${this.tag} sendOrder orderId=${orderId} status=${ack.commandStatus}`);
|
||||
|
||||
// Wait for the fill event — it carries the real execution price
|
||||
@@ -2171,6 +2316,7 @@ export class TradovateClient {
|
||||
ACK_TIMEOUT_MS,
|
||||
(response) => {
|
||||
const ack = TradovateClient.acceptedAck(response, 'order/placeorder');
|
||||
this.noteOwnOrder(accountId, ack.orderId ?? ack.id, orderType === 'Limit' ? 'limit' : 'market');
|
||||
return { orderId: ack.orderId ?? ack.id };
|
||||
},
|
||||
);
|
||||
@@ -2210,6 +2356,9 @@ export class TradovateClient {
|
||||
ACK_TIMEOUT_MS,
|
||||
(response) => {
|
||||
const ack = TradovateClient.acceptedAck(response, 'order/placeoco');
|
||||
// PlaceOCOResult: orderId is the take-profit limit, ocoId its stop.
|
||||
this.noteOwnOrder(accountId, ack.orderId ?? ack.id, 'limit');
|
||||
this.noteOwnOrder(accountId, (response as { ocoId?: unknown }).ocoId, 'stop');
|
||||
return { orderId: ack.orderId ?? ack.id };
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Keeps this machine's auto-updater (scripts/update-check.mjs) alive.
|
||||
*
|
||||
* PM2 fires the update checker every minute, and nothing on the machine
|
||||
* notices when it stops. On the fleet it stops on its own: PM2's cron dies
|
||||
* inside a live daemon (six Windows machines in October 2026; the mechanism
|
||||
* is in update-check.mjs heal), and a `pm2 stop` or a reboot after an unsaved
|
||||
* process-list change does the same. The master's dashboard notices ("updater
|
||||
* not running"), but then someone has to get onto the machine. The app is a
|
||||
* long-running PM2 process, which PM2 keeps alive reliably, so it checks: once
|
||||
* the last check is older than the master's 10 minutes, it asks the script to
|
||||
* heal, i.e. get PM2 firing it again, and run a check.
|
||||
*
|
||||
* It only revives an updater that has run here before (scripts/.last-check
|
||||
* exists). A checkout that never had one is someone's choice, and
|
||||
* scripts/.no-auto-update switches a checkout off outright (a dev checkout).
|
||||
* Only the checkout's first instance watches — PM2 name `autofirmer`, which
|
||||
* scripts/instances.mjs keeps for instance 1 — since the others share its
|
||||
* updater.
|
||||
*
|
||||
* The master's "Update now" command comes through here as well.
|
||||
*/
|
||||
import { spawn } from 'node:child_process';
|
||||
import { existsSync, readFileSync, statSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const EVERY_MS = 5 * 60_000;
|
||||
/** The master's UPDATER_STALE_MS: past this its dashboard says "not running". */
|
||||
const STALE_MS = 10 * 60_000;
|
||||
/** A heal that didn't take (PM2 missing, say) is retried no sooner than this. */
|
||||
const RETRY_MS = 30 * 60_000;
|
||||
|
||||
const scriptsDir = () => path.join(process.cwd(), 'scripts');
|
||||
const optedOut = () => existsSync(path.join(scriptsDir(), '.no-auto-update'));
|
||||
|
||||
// Per process: instrumentation.ts and the route handlers each load this module.
|
||||
const g = globalThis as typeof globalThis & { __updaterWatchdog?: { lastHealAt: number } };
|
||||
|
||||
/** When the updater last checked (epoch ms), or null if it never has here. */
|
||||
function lastCheckAt(): number | null {
|
||||
const file = path.join(scriptsDir(), '.last-check');
|
||||
try {
|
||||
// "<ISO time> <status>", rewritten on every check.
|
||||
const at = Date.parse(readFileSync(file, 'utf8').split(/\s/, 1)[0]);
|
||||
return Number.isFinite(at) ? at : statSync(file).mtimeMs;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start update-check.mjs on its own. Detached, because what it starts may
|
||||
* restart this very process; the script logs to scripts/update.log itself.
|
||||
*/
|
||||
function spawnUpdateCheck(args: string[]): number {
|
||||
const child = spawn(process.execPath, [path.join(scriptsDir(), 'update-check.mjs'), ...args], {
|
||||
cwd: process.cwd(),
|
||||
detached: true,
|
||||
// No console window popping up on a Windows trading desktop.
|
||||
windowsHide: true,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
child.on('error', (err) => console.error(`[updater-watchdog] update-check.mjs failed to start: ${err.message}`));
|
||||
child.unref();
|
||||
if (!child.pid) throw new Error('could not start scripts/update-check.mjs');
|
||||
return child.pid;
|
||||
}
|
||||
|
||||
function tick(): void {
|
||||
const state = g.__updaterWatchdog;
|
||||
if (!state || optedOut()) return;
|
||||
const last = lastCheckAt();
|
||||
if (last === null) return; // never ran here: not ours to start
|
||||
const now = Date.now();
|
||||
if (now - last <= STALE_MS || now - state.lastHealAt < RETRY_MS) return;
|
||||
state.lastHealAt = now;
|
||||
try {
|
||||
const pid = spawnUpdateCheck(['--heal']);
|
||||
console.log(`[updater-watchdog] last update check ${Math.round((now - last) / 60_000)} min ago — asked update-check.mjs to heal (pid ${pid})`);
|
||||
} catch (err) {
|
||||
console.error(`[updater-watchdog] ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function startUpdaterWatchdog(): void {
|
||||
if (g.__updaterWatchdog) return;
|
||||
// Under PM2 only (pm_id is PM2's): `next dev`, a sandbox copy or a
|
||||
// by-hand `next start` has no updater to keep alive.
|
||||
if (process.env.pm_id === undefined || process.env.name !== 'autofirmer') return;
|
||||
g.__updaterWatchdog = { lastHealAt: 0 };
|
||||
setInterval(tick, EVERY_MS).unref();
|
||||
}
|
||||
|
||||
/**
|
||||
* The master's "Update now": heal the updater if it stopped, then check at
|
||||
* once, skipping a failed update's retry wait. Answers before the update
|
||||
* runs — it may restart this process — so its outcome reaches the master
|
||||
* with the next report's updater status.
|
||||
*/
|
||||
export function runUpdateNow(): { started: true; pid: number } {
|
||||
if (optedOut()) throw new Error('updates are switched off on this machine (scripts/.no-auto-update)');
|
||||
return { started: true, pid: spawnUpdateCheck(['--heal', '--now']) };
|
||||
}
|
||||
+2459
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,818 @@
|
||||
/**
|
||||
* Pure helpers for the Vest client (lib/vest-class.ts) — parsing the gateway's
|
||||
* answers and errors, naming accounts, Vest's trading day, profit-claim
|
||||
* bookkeeping, market hours, volatility.
|
||||
*
|
||||
* Only Node built-ins are imported, so scripts/check-vest.mjs loads this file
|
||||
* directly (node --experimental-strip-types).
|
||||
*/
|
||||
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
/** A Vest login's primary wallet: 0x + 40 hex. The instance's saved Vest
|
||||
* "credential" is this address — it says whose relayed session to trade. */
|
||||
export function isVestWalletAddress(value: unknown): value is string {
|
||||
return typeof value === 'string' && /^0x[0-9a-fA-F]{40}$/.test(value.trim());
|
||||
}
|
||||
|
||||
/** Stable numeric id for a Vest account uuid, in the same range as DXtrade's
|
||||
* (dxAccountId) but keyed apart, so every numeric account table keeps
|
||||
* working. */
|
||||
export function vestAccountId(uuid: string): number {
|
||||
const digest = createHash('sha256').update(`vest|${uuid.toLowerCase()}`).digest();
|
||||
return 2 ** 50 + digest.readUIntBE(0, 6) * 16 + (digest[6] >> 4);
|
||||
}
|
||||
|
||||
/** A decimal string or number from the gateway → a number (undefined when
|
||||
* missing or not a number). */
|
||||
export function num(raw: unknown): number | undefined {
|
||||
if (typeof raw === 'number') return Number.isFinite(raw) ? raw : undefined;
|
||||
if (typeof raw !== 'string' || raw.trim() === '') return undefined;
|
||||
const n = Number(raw);
|
||||
return Number.isFinite(n) ? n : undefined;
|
||||
}
|
||||
|
||||
/** Epoch ms from the gateway's epoch ms, epoch seconds or ISO strings; NaN
|
||||
* when unreadable. */
|
||||
export function toMs(raw: unknown): number {
|
||||
if (typeof raw === 'number' && Number.isFinite(raw)) return raw < 1e11 ? raw * 1000 : raw;
|
||||
if (typeof raw === 'string' && raw.trim() !== '') {
|
||||
const n = Number(raw);
|
||||
if (Number.isFinite(n)) return n < 1e11 ? n * 1000 : n;
|
||||
const t = Date.parse(raw);
|
||||
return Number.isFinite(t) ? t : NaN;
|
||||
}
|
||||
return NaN;
|
||||
}
|
||||
|
||||
/** `value` on a decimal grid, as the gateway wants it: a plain string with at
|
||||
* most `decimals` places, trailing zeros trimmed (never exponent notation). */
|
||||
export function formatDecimal(value: number, decimals: number): string {
|
||||
const d = Math.max(0, Math.min(18, Math.floor(decimals)));
|
||||
const fixed = value.toFixed(d);
|
||||
return d > 0 ? fixed.replace(/\.?0+$/, '') || '0' : fixed;
|
||||
}
|
||||
|
||||
// ── gateway errors ──────────────────────────────────────────────────────────
|
||||
|
||||
/** The gateway answered with an error status. */
|
||||
export class VestApiError extends Error {
|
||||
readonly status: number;
|
||||
readonly code: string | undefined;
|
||||
constructor(message: string, status: number, code: string | undefined) {
|
||||
super(message);
|
||||
this.name = 'VestApiError';
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
/** The request never went out: the account token it needed couldn't be had. */
|
||||
export class VestNotSentError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'VestNotSentError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a failed request proves nothing it asked for happened: it never
|
||||
* went out, or the gateway refused it (a 4xx, but not a timeout). A
|
||||
* transport failure or a 5xx may have gone in. */
|
||||
export function nothingWentIn(err: unknown): boolean {
|
||||
return err instanceof VestNotSentError
|
||||
|| (err instanceof VestApiError && err.status >= 400 && err.status < 500 && err.status !== 408);
|
||||
}
|
||||
|
||||
/** The account token a request needs, from `mint`. Any failure to mint one —
|
||||
* a timeout, a 5xx, an answer without a token — is the request not sent,
|
||||
* never an outcome that could have gone in. */
|
||||
export async function tokenForRequest(path: string, mint: () => Promise<string>): Promise<string> {
|
||||
try {
|
||||
return await mint();
|
||||
} catch (err) {
|
||||
throw new VestNotSentError(`${path} not sent — no account token: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Vest's trading day ──────────────────────────────────────────────────────
|
||||
|
||||
const ET_PARTS = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: 'America/New_York', hourCycle: 'h23',
|
||||
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit',
|
||||
});
|
||||
|
||||
/** Vest's daily reset hour, America/New_York (its docs: 20:00 ET). */
|
||||
export const VEST_RESET_HOUR_ET = 20;
|
||||
|
||||
/**
|
||||
* The trading day a moment belongs to, YYYY-MM-DD. Vest's day runs 20:00 ET
|
||||
* to 20:00 ET and is named for the date it ends on, like the CME session
|
||||
* that opens the evening before — so 21:00 ET on the 26th is the 27th's day.
|
||||
*/
|
||||
export function vestDayOf(ms: number): string {
|
||||
const parts = Object.fromEntries(ET_PARTS.formatToParts(new Date(ms)).map((p) => [p.type, p.value]));
|
||||
const date = `${parts.year}-${parts.month}-${parts.day}`;
|
||||
if (Number(parts.hour) < VEST_RESET_HOUR_ET) return date;
|
||||
const next = new Date(`${date}T12:00:00Z`);
|
||||
next.setUTCDate(next.getUTCDate() + 1);
|
||||
return next.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** The last 20:00 ET at or before `now`, epoch ms. 20:00 ET is 00:00 UTC
|
||||
* in summer and 01:00 UTC in winter; whichever of those hours, on this or
|
||||
* an earlier UTC day, reads as 20:00 in New York is the boundary. */
|
||||
export function lastVestResetMs(now: number): number {
|
||||
const day = Date.UTC(new Date(now).getUTCFullYear(), new Date(now).getUTCMonth(), new Date(now).getUTCDate());
|
||||
for (let back = 0; back <= 2; back++) {
|
||||
for (const hour of [1, 0]) {
|
||||
const t = day - back * 86_400_000 + hour * 3_600_000;
|
||||
if (t > now) continue;
|
||||
const parts = Object.fromEntries(ET_PARTS.formatToParts(new Date(t)).map((p) => [p.type, p.value]));
|
||||
if (Number(parts.hour) === VEST_RESET_HOUR_ET) return t;
|
||||
}
|
||||
}
|
||||
return NaN;
|
||||
}
|
||||
|
||||
/** The next 20:00 ET after `now`, epoch ms (a day can be 23 or 25 hours). */
|
||||
export function nextVestResetMs(now: number): number {
|
||||
for (let h = 1; h <= 26; h++) {
|
||||
const t = lastVestResetMs(now + h * 3_600_000);
|
||||
if (t > now) return t;
|
||||
}
|
||||
return NaN;
|
||||
}
|
||||
|
||||
/** Vest's stopSlippageFraction: 0.02% of the position, about 6 NDX points.
|
||||
* Measured 2026-10-27 on 9 bot stop-outs (Me, $74k–$132k): fills 0.9 points
|
||||
* past the trigger on average, 2.25 at worst — the 0.1% default held back
|
||||
* about 30. The flat 10% of the limit still applies under it. */
|
||||
export const VEST_STOP_SLIPPAGE_FRACTION = 0.0002;
|
||||
|
||||
/**
|
||||
* What to do with an open position before the daily reset so the reset can't
|
||||
* breach it. At 20:00 ET Vest captures the realized balance (collateral in,
|
||||
* unrealized P&L out) and sets the day's floor to balance × (1 − rate); the
|
||||
* max-drawdown floor stays. A position whose loss allowance is wider than
|
||||
* that next-day room would breach the moment the reset lands, so its stop
|
||||
* comes in to where equity stays above the coming floor plus `reserve`, with
|
||||
* the exit fee paid:
|
||||
* 'none' — its stop is already at least that tight;
|
||||
* 'stop' — move (or place) the stop at `price`, on the tick grid, rounded
|
||||
* toward the position's safe side;
|
||||
* 'close' — the mark is already past that price (with no room left by
|
||||
* the account's other positions, it sits on the profit side),
|
||||
* or no room and no mark to tell.
|
||||
*/
|
||||
export function resetSafeStop(o: {
|
||||
side: 'long' | 'short';
|
||||
quantity: number;
|
||||
openPrice: number;
|
||||
mark: number;
|
||||
/** Realized balance, collateral included — what the reset captures —
|
||||
* less what the account's other positions may lose at their stops. */
|
||||
balance: number;
|
||||
/** The realized balance the floor is set from; default `balance`. */
|
||||
floorBalance?: number;
|
||||
dailyLossPct: number;
|
||||
maxDrawdownFloor: number | undefined;
|
||||
reserve: number;
|
||||
/** Taker fee as a fraction of notional. */
|
||||
exitFeeRate: number;
|
||||
currentStop: number | null;
|
||||
tick: number;
|
||||
}): { action: 'none' } | { action: 'stop'; price: number } | { action: 'close' } {
|
||||
const floor = Math.max((o.floorBalance ?? o.balance) * (1 - o.dailyLossPct), o.maxDrawdownFloor ?? -Infinity);
|
||||
const exitFee = o.exitFeeRate * o.quantity * (Number.isFinite(o.mark) && o.mark > 0 ? o.mark : o.openPrice);
|
||||
const allowed = o.balance - floor - o.reserve - exitFee;
|
||||
if (!(o.quantity > 0)) return { action: 'close' };
|
||||
// No room to lose (the account's other positions took it): the stop must
|
||||
// lock in at least the shortfall — on the profit side of the entry,
|
||||
// which a position already that far up can still carry.
|
||||
if (!(allowed > 0) && !Number.isFinite(o.mark)) return { action: 'close' };
|
||||
const long = o.side === 'long';
|
||||
const raw = long ? o.openPrice - allowed / o.quantity : o.openPrice + allowed / o.quantity;
|
||||
const price = o.tick > 0
|
||||
? (long ? Math.ceil(raw / o.tick - 1e-9) * o.tick : Math.floor(raw / o.tick + 1e-9) * o.tick)
|
||||
: raw;
|
||||
const rounded = Number(price.toFixed(10));
|
||||
if (o.currentStop !== null && (long ? o.currentStop >= rounded : o.currentStop <= rounded)) return { action: 'none' };
|
||||
if (Number.isFinite(o.mark) && (long ? o.mark <= rounded : o.mark >= rounded)) return { action: 'close' };
|
||||
return { action: 'stop', price: rounded };
|
||||
}
|
||||
|
||||
// ── accounts ────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface VestCapitalAccount {
|
||||
id: string;
|
||||
planId: string;
|
||||
planName: string;
|
||||
/** 'eval' | 'instant_funded' | … as the gateway names the product. */
|
||||
productType: string;
|
||||
/** 1-based stage; stages[stage-1] is the current one. */
|
||||
stage: number;
|
||||
/** Evaluation steps the plan has (a 1-Step's is 1, a 2-Step's 2). */
|
||||
stepsTotal: number;
|
||||
/** The evaluation step this account is (1-based; a funded one's is
|
||||
* stepsTotal + 1, as the Vest app counts it when Vest leaves it out). */
|
||||
step: number;
|
||||
/** Vest's account_type: 2 an evaluation, 3 a funded account. */
|
||||
accountType: number;
|
||||
/** Vest blocked the account (blocked_at set): nothing it offers applies. */
|
||||
blocked: boolean;
|
||||
/** The current stage: 'eval' or 'funded'. */
|
||||
kind: string;
|
||||
status: number;
|
||||
attemptIndex: number;
|
||||
initialCapital: number;
|
||||
/** The current stage's profit target in dollars (0 = none). */
|
||||
profitTarget: number;
|
||||
targetEquity: number | undefined;
|
||||
maxDrawdownLimit: number | undefined;
|
||||
maxDailyLossPct: number;
|
||||
dailyResetBalance: number | undefined;
|
||||
dailyLossFloor: number | undefined;
|
||||
dailyResetAt: number;
|
||||
activatedAt: number;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
/** One entry of GET /v3/capital/accounts/active; null when it isn't one. */
|
||||
export function parseCapitalAccount(raw: unknown): VestCapitalAccount | null {
|
||||
const a = raw as Record<string, unknown>;
|
||||
if (!a || typeof a.id !== 'string') return null;
|
||||
const stages = Array.isArray(a.stages) ? (a.stages as Record<string, unknown>[]) : [];
|
||||
const stage = num(a.stage) ?? 1;
|
||||
const current = stages.find((s) => num(s.ordinal) === stage) ?? stages[stage - 1] ?? {};
|
||||
// The Vest app's own fallbacks, for an answer that leaves these out.
|
||||
const stepsTotal = num(a.steps_total) ?? (a.plan_product_type === 'eval' ? 1 : 0);
|
||||
return {
|
||||
id: a.id,
|
||||
planId: typeof a.plan_id === 'string' ? a.plan_id : '',
|
||||
planName: typeof a.plan_name === 'string' ? a.plan_name : '',
|
||||
productType: typeof a.plan_product_type === 'string' ? a.plan_product_type : '',
|
||||
stage,
|
||||
stepsTotal,
|
||||
step: num(a.step) ?? (stage === VEST_STAGE_FUNDED ? stepsTotal + 1 : 1),
|
||||
accountType: num(a.account_type) ?? 0,
|
||||
blocked: Boolean(a.blocked_at),
|
||||
kind: typeof current.kind === 'string' ? current.kind : '',
|
||||
status: num(a.status) ?? -1,
|
||||
attemptIndex: num(a.attempt_index) ?? 0,
|
||||
initialCapital: num(a.initial_capital) ?? 0,
|
||||
profitTarget: num(current.profit_target) ?? 0,
|
||||
targetEquity: num(a.target_equity),
|
||||
maxDrawdownLimit: num(a.max_drawdown_limit),
|
||||
maxDailyLossPct: num(a.max_daily_loss_pct) ?? 0,
|
||||
dailyResetBalance: num(a.daily_reset_balance),
|
||||
dailyLossFloor: num(a.daily_loss_floor),
|
||||
dailyResetAt: toMs(a.daily_reset_at),
|
||||
activatedAt: toMs(a.activated_at),
|
||||
createdAt: toMs(a.created_at),
|
||||
};
|
||||
}
|
||||
|
||||
/** Vest's capital account statuses, as the Vest app names them (its bundle,
|
||||
* 2026-10-29). Only an ACTIVE account trades. A passed evaluation sits at
|
||||
* GOAL_PASSED until its funded account is activated, and is CLAIMED after. */
|
||||
export const VEST_STATUS = {
|
||||
CREATED: 1,
|
||||
ACTIVE: 2,
|
||||
GOAL_PASSED: 3,
|
||||
FAILED_MAX_DRAWDOWN: 4,
|
||||
FAILED_MAX_DAILY_LOSS: 5,
|
||||
CLAIMED: 6,
|
||||
EXPIRED: 7,
|
||||
} as const;
|
||||
export const VEST_STATUS_ACTIVE = VEST_STATUS.ACTIVE;
|
||||
|
||||
/** Vest's stage field: an evaluation's, then the funded account's. */
|
||||
const VEST_STAGE_EVAL = 1;
|
||||
const VEST_STAGE_FUNDED = 2;
|
||||
/** Vest's account_type of an evaluation. */
|
||||
const VEST_ACCOUNT_TYPE_EVAL = 2;
|
||||
|
||||
/** A status as the log says it: Vest's own name, with its number. */
|
||||
export function vestStatusName(status: number): string {
|
||||
const name = Object.entries(VEST_STATUS).find(([, v]) => v === status)?.[0];
|
||||
return `${name ? name.toLowerCase().replace(/_/g, ' ') : 'unknown'} (status ${status})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A passed evaluation whose funded account Vest activates on request (POST
|
||||
* /v3/capital/activate-funded) — the Vest app's own test for its "Go to
|
||||
* funded account" button: an evaluation at GOAL_PASSED, not blocked, on its
|
||||
* plan's last step. An earlier step of a 2-Step passes on to the next step
|
||||
* by itself.
|
||||
*/
|
||||
export function awaitingActivation(a: VestCapitalAccount): boolean {
|
||||
const evaluation = a.accountType === VEST_ACCOUNT_TYPE_EVAL || a.stage === VEST_STAGE_EVAL;
|
||||
return evaluation && a.status === VEST_STATUS.GOAL_PASSED && !a.blocked && a.stepsTotal > 0 && a.step === a.stepsTotal;
|
||||
}
|
||||
|
||||
const pct = (fraction: number) => Math.round(fraction * 1000) / 10;
|
||||
|
||||
/**
|
||||
* What the dashboard calls the account and what account-type prefixes match
|
||||
* against. Vest itself only says "Account 34", so the name carries the rules
|
||||
* that tell its types apart:
|
||||
* V50K-E-D3-T10-34 evaluation, $50k, 3% daily loss, 10% target, attempt 34
|
||||
* V50K-F-D3-34 the funded stage of an evaluation account
|
||||
* V25K-I-7 instant funded (no daily loss limit, no target)
|
||||
* D0 = no daily loss limit. The target is the current stage's (a 2-Step's
|
||||
* second stage reads T5).
|
||||
*/
|
||||
export function vestAccountName(a: VestCapitalAccount): string {
|
||||
const size = a.initialCapital >= 1000 ? `${+(a.initialCapital / 1000).toFixed(1)}K` : `${Math.round(a.initialCapital)}`;
|
||||
const dll = `D${pct(a.maxDailyLossPct)}`;
|
||||
if (/instant/i.test(a.productType)) return `V${size}-I-${a.attemptIndex}`;
|
||||
if (a.kind === 'funded') return `V${size}-F-${dll}-${a.attemptIndex}`;
|
||||
const target = a.initialCapital > 0 && a.profitTarget > 0 ? `-T${pct(a.profitTarget / a.initialCapital)}` : '';
|
||||
return `V${size}-E-${dll}${target}-${a.attemptIndex}`;
|
||||
}
|
||||
|
||||
// ── positions and orders ────────────────────────────────────────────────────
|
||||
|
||||
export interface VestLeg {
|
||||
id: string;
|
||||
triggerPrice: number;
|
||||
executionType: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface VestPosition {
|
||||
positionId: string;
|
||||
accountId: string;
|
||||
symbol: string;
|
||||
side: 'long' | 'short';
|
||||
quantity: number;
|
||||
openPrice: number;
|
||||
/** Margin the position holds out of the free balance. */
|
||||
collateral: number;
|
||||
leverage: number;
|
||||
openedAt: number;
|
||||
stopLosses: VestLeg[];
|
||||
takeProfits: VestLeg[];
|
||||
}
|
||||
|
||||
function parseLegs(raw: unknown): VestLeg[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw.flatMap((l) => {
|
||||
const leg = l as Record<string, unknown>;
|
||||
const trigger = num(leg?.triggerPrice);
|
||||
if (typeof leg?.id !== 'string' || trigger === undefined) return [];
|
||||
return [{ id: leg.id, triggerPrice: trigger, executionType: String(leg.executionType ?? ''), status: String(leg.status ?? '') }];
|
||||
});
|
||||
}
|
||||
|
||||
/** One entry of GET /v3/positions/opened; null when it isn't an open one. */
|
||||
export function parsePosition(raw: unknown): VestPosition | null {
|
||||
const p = raw as Record<string, unknown>;
|
||||
const accountId = p?.accountId ?? p?.account_id;
|
||||
if (!p || typeof p.positionId !== 'string' || typeof accountId !== 'string' || typeof p.symbol !== 'string') return null;
|
||||
const quantity = Math.abs(num(p.quantity) ?? 0);
|
||||
if (!(quantity > 0)) return null;
|
||||
return {
|
||||
positionId: p.positionId,
|
||||
accountId,
|
||||
symbol: p.symbol,
|
||||
side: String(p.side).toLowerCase() === 'short' ? 'short' : 'long',
|
||||
quantity,
|
||||
openPrice: num(p.openPrice) ?? NaN,
|
||||
collateral: num(p.collateral) ?? 0,
|
||||
leverage: num(p.leverage) ?? 0,
|
||||
openedAt: toMs(p.openDate ?? p.createdAt),
|
||||
stopLosses: parseLegs(p.stopLosses),
|
||||
takeProfits: parseLegs(p.takeProfits),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* One position from GET /v3/positions — Vest's position history, the app's
|
||||
* History tab: closed positions with how they ended (an open one has no
|
||||
* close yet). pnl is Vest's realized P&L before fees and funding.
|
||||
*/
|
||||
export interface VestTrade {
|
||||
positionId: string;
|
||||
symbol: string;
|
||||
side: 'long' | 'short';
|
||||
/** 0 when Vest doesn't say (a store keeps the size it already knows). */
|
||||
quantity: number;
|
||||
leverage: number | null;
|
||||
openPrice: number | null;
|
||||
closePrice: number | null;
|
||||
pnl: number | null;
|
||||
fee: number | null;
|
||||
funding: number | null;
|
||||
openedAt: number;
|
||||
closedAt: number | null;
|
||||
/** user_requested, stop_loss, take_profit, liquidation, auto_deleverage,
|
||||
* temporary_leverage_limit, corporate_action. */
|
||||
closeReason: string | null;
|
||||
}
|
||||
|
||||
export function parseHistoryPosition(raw: unknown): VestTrade | null {
|
||||
const p = raw as Record<string, unknown>;
|
||||
const id = p?.positionId ?? p?.position_id;
|
||||
if (!p || typeof id !== 'string' || typeof p.symbol !== 'string') return null;
|
||||
const openedAt = toMs(p.openDate ?? p.open_date ?? p.createdAt ?? p.created_at);
|
||||
if (!Number.isFinite(openedAt)) return null;
|
||||
const closedMs = toMs(p.closeDate ?? p.close_date);
|
||||
const closedAt = Number.isFinite(closedMs) && closedMs > 0 ? closedMs : null;
|
||||
const openPrice = num(p.openPrice ?? p.open_price);
|
||||
// An open position comes with a placeholder close price and a $0 P&L
|
||||
// (seen: 290.25 on an NDX short at 29,538.96): only a close has them.
|
||||
const closePrice = closedAt === null ? undefined : num(p.closePrice ?? p.close_price);
|
||||
const reason = p.closeReason ?? p.close_reason;
|
||||
return {
|
||||
positionId: id,
|
||||
symbol: p.symbol,
|
||||
side: String(p.side).toLowerCase() === 'short' ? 'short' : 'long',
|
||||
quantity: Math.abs(num(p.quantity) ?? 0),
|
||||
leverage: num(p.leverage) ?? null,
|
||||
openPrice: openPrice !== undefined && openPrice > 0 ? openPrice : null,
|
||||
closePrice: closePrice !== undefined && closePrice > 0 ? closePrice : null,
|
||||
pnl: closedAt === null ? null : num(p.pnl) ?? null,
|
||||
fee: num(p.fee) ?? null,
|
||||
funding: num(p.funding) ?? null,
|
||||
openedAt,
|
||||
closedAt,
|
||||
closeReason: typeof reason === 'string' && reason !== '' ? reason : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Every string value in a JWT's payload, lower-case ([] when it isn't
|
||||
* one). Used only to compare against account ids — never logged. */
|
||||
export function jwtClaimStrings(token: string): string[] {
|
||||
const part = token.split('.')[1];
|
||||
if (!part) return [];
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = JSON.parse(Buffer.from(part.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const out: string[] = [];
|
||||
const walk = (v: unknown, depth: number): void => {
|
||||
if (typeof v === 'string') out.push(v.toLowerCase());
|
||||
else if (v && typeof v === 'object' && depth < 4) for (const x of Object.values(v)) walk(x, depth + 1);
|
||||
};
|
||||
walk(payload, 0);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a token the extension relayed is one Vest account's own token, not
|
||||
* the login's. The Vest app renews both through POST /v3/auth/refresh — the
|
||||
* login's every ~15 minutes, an account's while someone trades in it — and
|
||||
* an account's token sees only that account: /v3/capital/accounts/active
|
||||
* lists it alone, and /v3/accounts has no primary (wallet) account. The
|
||||
* login sees every account and its primary one.
|
||||
*/
|
||||
export function accountScopedToken(o: { capitalIds: string[]; hasPrimary: boolean; claims: string[] }): boolean {
|
||||
if (o.capitalIds.length > 1) return false;
|
||||
if (!o.hasPrimary) return true;
|
||||
return o.capitalIds.some((id) => o.claims.includes(id.toLowerCase()));
|
||||
}
|
||||
|
||||
/** Exit legs still waiting to trigger. */
|
||||
export function awaitingLegs(p: VestPosition): VestLeg[] {
|
||||
return [...p.stopLosses, ...p.takeProfits].filter((l) => l.status === 'awaiting');
|
||||
}
|
||||
|
||||
/**
|
||||
* The positions an open of unknown outcome (a timeout, a reset, a 5xx after
|
||||
* the send) may have made, as a read shows them — the bot's only when there
|
||||
* is exactly one. The one the open's answer named, when it named one. Else
|
||||
* on its account, in its symbol and side, with no exit legs (none went on
|
||||
* it), opened between `from` and `until` — the send and the error, widened
|
||||
* for the gateway's clock: a market open is IOC, so a later one (an outside
|
||||
* copy-trader's, which opens the same symbol on every account at once) isn't
|
||||
* it. More than one of those can't be told apart.
|
||||
*/
|
||||
export function unknownOpenFill(
|
||||
sent: { accountId: string; symbol: string; side: 'long' | 'short'; from: number; until: number; positionId?: string },
|
||||
positions: VestPosition[],
|
||||
): VestPosition[] {
|
||||
const mine = positions.filter((p) => p.accountId === sent.accountId);
|
||||
if (sent.positionId) return mine.filter((p) => p.positionId === sent.positionId);
|
||||
return mine.filter((p) => p.symbol === sent.symbol && p.side === sent.side
|
||||
&& p.stopLosses.length === 0 && p.takeProfits.length === 0
|
||||
&& p.openedAt >= sent.from && p.openedAt <= sent.until);
|
||||
}
|
||||
|
||||
export interface VestOpenOrder {
|
||||
orderId: string;
|
||||
accountId: string;
|
||||
positionId: string | undefined;
|
||||
symbol: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
/** One entry of GET /v3/positions/opened-orders (snake or camel case). */
|
||||
export function parseOpenOrder(raw: unknown): VestOpenOrder | null {
|
||||
const o = raw as Record<string, unknown>;
|
||||
const orderId = o?.order_id ?? o?.orderId ?? o?.id;
|
||||
const accountId = o?.account_id ?? o?.accountId;
|
||||
if (typeof orderId !== 'string' || typeof accountId !== 'string') return null;
|
||||
const positionId = o.position_id ?? o.positionId;
|
||||
return {
|
||||
orderId,
|
||||
accountId,
|
||||
positionId: typeof positionId === 'string' ? positionId : undefined,
|
||||
symbol: String(o.symbol ?? ''),
|
||||
createdAt: toMs(o.created_at ?? o.createdAt),
|
||||
};
|
||||
}
|
||||
|
||||
/** Unrealized P&L of a position at `mark`. */
|
||||
export function unrealizedPnl(p: Pick<VestPosition, 'side' | 'quantity' | 'openPrice'>, mark: number): number {
|
||||
if (!Number.isFinite(mark) || !Number.isFinite(p.openPrice)) return 0;
|
||||
return (p.side === 'long' ? 1 : -1) * p.quantity * (mark - p.openPrice);
|
||||
}
|
||||
|
||||
// ── transactions ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The gateway's transaction types (its app's own enum, 2026-10-26). Trading
|
||||
* P&L is fees, realized P&L, funding and the adjustments that come with them;
|
||||
* funds are money moved in or out; the rest only moves margin between the
|
||||
* free balance and positions, and leaves the account's total alone.
|
||||
*/
|
||||
export const VEST_TXN = {
|
||||
DEPOSIT: 1, WITHDRAW: 2, OPEN_POSITION: 3, CLOSE_POSITION: 4, CORRECTION: 5, FILL_MARGIN: 6, MARGIN_RELEASE: 7,
|
||||
FEE: 8, REALIZED_PNL: 9, FUNDING: 10, LEVERAGE_RESERVE: 11, LEVERAGE_RELEASE: 12, ADD_COLLATERAL: 13,
|
||||
REMOVE_COLLATERAL: 14, ORDER_MARGIN_RESERVE: 15, ORDER_MARGIN_RELEASE: 16, LIQUIDATION_DEFICIT_COVERAGE: 17,
|
||||
CORPORATE_ACTION_CASH_ADJUSTMENT: 18, FUNDED_PROFIT_CREDIT: 19, AFFILIATE_PAYOUT_CREDIT: 20, COMPENSATION: 22,
|
||||
BALANCE_MIGRATION: 23,
|
||||
} as const;
|
||||
|
||||
const TRADING_TXNS = new Set<number>([
|
||||
VEST_TXN.CORRECTION, VEST_TXN.FEE, VEST_TXN.REALIZED_PNL, VEST_TXN.FUNDING,
|
||||
VEST_TXN.LIQUIDATION_DEFICIT_COVERAGE, VEST_TXN.CORPORATE_ACTION_CASH_ADJUSTMENT,
|
||||
]);
|
||||
const FUND_TXNS = new Set<number>([
|
||||
VEST_TXN.DEPOSIT, VEST_TXN.WITHDRAW, VEST_TXN.FUNDED_PROFIT_CREDIT, VEST_TXN.AFFILIATE_PAYOUT_CREDIT,
|
||||
VEST_TXN.COMPENSATION, VEST_TXN.BALANCE_MIGRATION,
|
||||
]);
|
||||
|
||||
export type VestTxnKind = 'trade' | 'fund' | 'margin';
|
||||
|
||||
export function txnKind(type: number): VestTxnKind {
|
||||
if (TRADING_TXNS.has(type)) return 'trade';
|
||||
if (FUND_TXNS.has(type)) return 'fund';
|
||||
return 'margin';
|
||||
}
|
||||
|
||||
export interface VestTxn {
|
||||
/** action_id, else type|time|amount — what a page overlap dedupes on. */
|
||||
key: string;
|
||||
type: number;
|
||||
kind: VestTxnKind;
|
||||
/** Signed: what it did to the account's balance. */
|
||||
amount: number;
|
||||
time: number;
|
||||
}
|
||||
|
||||
/** One entry of GET /v3/transactions; null when unreadable. A withdrawal is
|
||||
* always negative and a deposit positive, whatever sign the gateway used. */
|
||||
export function parseTxn(raw: unknown): VestTxn | null {
|
||||
const t = raw as Record<string, unknown>;
|
||||
const type = num(t?.transaction_type ?? t?.transactionType);
|
||||
const amount = num(t?.amount);
|
||||
const time = toMs(t?.created_at ?? t?.createdAt);
|
||||
if (type === undefined || amount === undefined || !Number.isFinite(time)) return null;
|
||||
const signed = type === VEST_TXN.WITHDRAW ? -Math.abs(amount) : type === VEST_TXN.DEPOSIT ? Math.abs(amount) : amount;
|
||||
const action = t.action_id ?? t.actionId;
|
||||
return {
|
||||
key: typeof action === 'string' || typeof action === 'number' ? `${action}|${type}` : `${type}|${time}|${amount}`,
|
||||
type,
|
||||
kind: txnKind(type),
|
||||
amount: signed,
|
||||
time,
|
||||
};
|
||||
}
|
||||
|
||||
/** Daily trading P&L and fund rows by Vest day, from the account's
|
||||
* transactions. */
|
||||
export function bookTxns(txns: VestTxn[]): { daily: Map<string, { pnl: number; fees: number }>; funds: Map<string, number> } {
|
||||
const daily = new Map<string, { pnl: number; fees: number }>();
|
||||
const funds = new Map<string, number>();
|
||||
for (const t of txns) {
|
||||
const day = vestDayOf(t.time);
|
||||
if (t.kind === 'fund') {
|
||||
funds.set(day, (funds.get(day) ?? 0) + t.amount);
|
||||
} else if (t.kind === 'trade') {
|
||||
const e = daily.get(day) ?? { pnl: 0, fees: 0 };
|
||||
e.pnl += t.amount;
|
||||
if (t.type === VEST_TXN.FEE) e.fees += Math.abs(t.amount);
|
||||
daily.set(day, e);
|
||||
}
|
||||
}
|
||||
return { daily, funds };
|
||||
}
|
||||
|
||||
// ── profit claims ───────────────────────────────────────────────────────────
|
||||
|
||||
/** A claim shows in Vest's transactions as a WITHDRAW of the same amount
|
||||
* within this long of it. */
|
||||
export const CLAIM_MATCH_MS = 30 * 60_000;
|
||||
/** A claim of unknown outcome (the request failed in transit) that no
|
||||
* WITHDRAW matches in a history read this long after it never happened. */
|
||||
export const UNKNOWN_CLAIM_EXPIRES_MS = 60 * 60_000;
|
||||
|
||||
/** A claim this client sent: the amount, when (at = once it was answered;
|
||||
* sentAt = before it went out, absent on claims saved before it was kept),
|
||||
* and the balance it left. unknown = the request failed in transit, so it
|
||||
* may or may not have gone in — it holds further claims, but nothing is
|
||||
* booked on it. */
|
||||
export interface OwnClaim { at: number; sentAt?: number; amount: number; base: number; unknown?: true }
|
||||
|
||||
/** A WITHDRAW transaction: when, and its (negative) amount. */
|
||||
export interface VestWithdrawal { at: number; amount: number }
|
||||
|
||||
/** The WITHDRAW that shows the claim — its amount, within CLAIM_MATCH_MS,
|
||||
* the nearest in time — or undefined while Vest's transactions don't. */
|
||||
export function claimWithdrawal(withdrawals: VestWithdrawal[], c: OwnClaim): VestWithdrawal | undefined {
|
||||
let best: VestWithdrawal | undefined;
|
||||
for (const w of withdrawals) {
|
||||
if (Math.abs(Math.abs(w.amount) - c.amount) >= 0.01 || Math.abs(w.at - c.at) >= CLAIM_MATCH_MS) continue;
|
||||
if (!best || Math.abs(w.at - c.at) < Math.abs(best.at - c.at)) best = w;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** The claims Vest's transactions, as read at `readAt`, don't show: those
|
||||
* still pending, and those that lapse — of unknown outcome and still not
|
||||
* shown an hour on, so never made. One Vest accepted never lapses. */
|
||||
export function settleClaims(claims: OwnClaim[], withdrawals: VestWithdrawal[], readAt: number): { pending: OwnClaim[]; lapsed: OwnClaim[] } {
|
||||
const pending: OwnClaim[] = [];
|
||||
const lapsed: OwnClaim[] = [];
|
||||
for (const c of claims) {
|
||||
if (claimWithdrawal(withdrawals, c)) continue;
|
||||
if (c.unknown && readAt - c.at > UNKNOWN_CLAIM_EXPIRES_MS) lapsed.push(c);
|
||||
else pending.push(c);
|
||||
}
|
||||
return { pending, lapsed };
|
||||
}
|
||||
|
||||
/**
|
||||
* The account's fund rows, one per Vest day: Vest's own (bookTxns' funds),
|
||||
* plus each claim this client made that the transactions don't show yet, on
|
||||
* the day it was booked. And the stored days to take back: a day a claim
|
||||
* was booked on that Vest's transactions leave empty, its WITHDRAW being
|
||||
* dated another day (the request spanned 20:00 ET, or the clocks differ) —
|
||||
* kept, the payout would count twice.
|
||||
*/
|
||||
export function claimFundRows(o: {
|
||||
funds: Map<string, number>; claims: OwnClaim[]; withdrawals: VestWithdrawal[]; stored: { date: string }[];
|
||||
}): { rows: { date: string; amount: number }[]; dropped: string[] } {
|
||||
const funds = new Map(o.funds);
|
||||
const made = o.claims.filter((c) => !c.unknown);
|
||||
for (const c of made) {
|
||||
if (claimWithdrawal(o.withdrawals, c)) continue;
|
||||
const day = vestDayOf(c.at);
|
||||
funds.set(day, (funds.get(day) ?? 0) - c.amount);
|
||||
}
|
||||
const rows = [...funds.entries()]
|
||||
.map(([date, amount]) => ({ date, amount: Math.round(amount * 100) / 100 }))
|
||||
.filter((f) => f.amount !== 0)
|
||||
.sort((x, y) => x.date.localeCompare(y.date));
|
||||
const claimDays = new Set(made.map((c) => vestDayOf(c.at)));
|
||||
const dropped = o.stored.map((f) => f.date).filter((d) => claimDays.has(d) && !rows.some((r) => r.date === d));
|
||||
return { rows, dropped };
|
||||
}
|
||||
|
||||
/**
|
||||
* What a history read makes of this client's claims (VestClient.bookHistory):
|
||||
* Vest's WITHDRAWs; the claims that lapse and those still outstanding
|
||||
* (settleClaims); the fund rows and the stored days to take back
|
||||
* (claimFundRows); and the balance the transactions add up to — `opening`
|
||||
* (the capital of an account Vest funded without a deposit row) included,
|
||||
* the claims they don't show yet taken out — that the books check holds the
|
||||
* account to. A lapsed claim is out of it: counted, one that never went in
|
||||
* would hold the account for good.
|
||||
*/
|
||||
export function readClaims(o: {
|
||||
txns: VestTxn[]; funds: Map<string, number>; claims: OwnClaim[]; stored: { date: string }[]; readAt: number; opening: number;
|
||||
}): {
|
||||
withdrawals: VestWithdrawal[]; lapsed: OwnClaim[]; outstanding: OwnClaim[];
|
||||
rows: { date: string; amount: number }[]; dropped: string[]; expected: number;
|
||||
} {
|
||||
const withdrawals = o.txns.filter((t) => t.type === VEST_TXN.WITHDRAW).map((t) => ({ at: t.time, amount: t.amount }));
|
||||
const { pending, lapsed } = settleClaims(o.claims, withdrawals, o.readAt);
|
||||
const { rows, dropped } = claimFundRows({ funds: o.funds, claims: o.claims.filter((c) => !lapsed.includes(c)), withdrawals, stored: o.stored });
|
||||
const unbooked = pending.reduce((s, c) => s + c.amount, 0);
|
||||
const expected = o.txns.reduce((s, t) => s + (t.kind === 'margin' ? 0 : t.amount), 0) + o.opening - unbooked;
|
||||
return { withdrawals, lapsed, outstanding: pending, rows, dropped, expected };
|
||||
}
|
||||
|
||||
/** Each payout taken from the account, by its Vest day, one entry per
|
||||
* payout: every WITHDRAW Vest's transactions show (claims made by hand
|
||||
* among them), and each claim of this client's they don't show yet, on the
|
||||
* day it was booked — a fund row can't say how many, it holds a whole Vest
|
||||
* day's. A day whose fund rows net negative with neither still counts once,
|
||||
* as it did before. Sorted. */
|
||||
export function payoutDaysOf(withdrawals: VestWithdrawal[], claims: OwnClaim[], fundRows: { date: string; amount: number }[]): string[] {
|
||||
const days = [
|
||||
...withdrawals.map((w) => vestDayOf(w.at)),
|
||||
...claims.filter((c) => !c.unknown && !claimWithdrawal(withdrawals, c)).map((c) => vestDayOf(c.at)),
|
||||
];
|
||||
for (const f of fundRows) if (f.amount < 0 && !days.includes(f.date)) days.push(f.date);
|
||||
return days.sort();
|
||||
}
|
||||
|
||||
/** Payouts since `since` (the last 20:00 ET): this client's claims — each at
|
||||
* its WITHDRAW's time once Vest shows it, Vest's day being the one it
|
||||
* counts on, and until then at when it was sent — or the WITHDRAWs Vest
|
||||
* shows, whichever counts more (a claim made by hand shows only there).
|
||||
* Sent, not answered: Vest dates it no earlier (clocks in sync), so a
|
||||
* claim answered just after 20:00 ET can't put the fresh day on a payout
|
||||
* stage before Vest says it's that day's; one Vest dates after the turn
|
||||
* reads as the day before until then, the cautious way round. */
|
||||
export function payoutsSince(withdrawals: VestWithdrawal[], claims: OwnClaim[], since: number): number {
|
||||
const mine = claims.filter((c) => !c.unknown && (claimWithdrawal(withdrawals, c)?.at ?? c.sentAt ?? c.at) >= since).length;
|
||||
const seen = withdrawals.filter((w) => w.at >= since).length;
|
||||
return Math.max(mine, seen);
|
||||
}
|
||||
|
||||
// ── market data ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Fewest hourly returns a volatility is measured from. */
|
||||
export const MIN_HOURLY_RETURNS = 24;
|
||||
|
||||
/**
|
||||
* σ of log returns per √hour from hourly klines ([openTime, o, h, l, close, …],
|
||||
* any order). Only returns between consecutive hours count: an index perp
|
||||
* closes for weekends and the daily maintenance hour, and a return across
|
||||
* such a gap is a gap, not an hour's move. null when too few are left.
|
||||
*/
|
||||
export function hourlyVolatility(klines: unknown[]): number | null {
|
||||
const rows = klines.flatMap((k) => {
|
||||
if (!Array.isArray(k)) return [];
|
||||
const t = num(k[0]);
|
||||
const close = num(k[4]);
|
||||
return t !== undefined && close !== undefined && close > 0 ? [{ t: toMs(t), close }] : [];
|
||||
}).sort((a, b) => a.t - b.t);
|
||||
const returns: number[] = [];
|
||||
for (let i = 1; i < rows.length; i++) {
|
||||
if (Math.abs(rows[i].t - rows[i - 1].t - 3_600_000) > 60_000) continue;
|
||||
returns.push(Math.log(rows[i].close / rows[i - 1].close));
|
||||
}
|
||||
if (returns.length < MIN_HOURLY_RETURNS) return null;
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the market-hours periods (GET /v4/market-hours/{assetId}) have the
|
||||
* symbol open for trading at `now`, and for how long yet. A symbol with no
|
||||
* periods at all (crypto: always open) reads as open with no end. A closure
|
||||
* (weekly, the daily maintenance hour) counts as closed even where the
|
||||
* gateway still says OPEN: no leg should start into one.
|
||||
*/
|
||||
export function marketOpen(periods: unknown, now: number): { open: boolean; closesAt: number | null } {
|
||||
if (!Array.isArray(periods) || periods.length === 0) return { open: true, closesAt: null };
|
||||
const list = hoursPeriods(periods);
|
||||
const i = list.findIndex((p) => p.from <= now && now < p.to);
|
||||
if (i < 0 || !list[i].open) return { open: false, closesAt: null };
|
||||
// Consecutive open periods (overnight → pre-market → regular …) are one stretch.
|
||||
let end = list[i].to;
|
||||
for (let j = i + 1; j < list.length && list[j].open && list[j].from <= end + 1; j++) end = list[j].to;
|
||||
return { open: true, closesAt: end };
|
||||
}
|
||||
|
||||
/** The market-hours periods, read and in order; open = trading, with no
|
||||
* closure (see marketOpen). */
|
||||
function hoursPeriods(periods: unknown[]): { from: number; to: number; open: boolean }[] {
|
||||
return periods.flatMap((p) => {
|
||||
const r = p as {
|
||||
fromMs?: unknown; toMs?: unknown;
|
||||
state?: { tradingStatus?: unknown; closure?: unknown; price?: { allow?: unknown } };
|
||||
};
|
||||
const from = num(r?.fromMs);
|
||||
const to = num(r?.toMs);
|
||||
if (from === undefined || to === undefined) return [];
|
||||
const closure = r.state?.closure;
|
||||
const open = r.state?.tradingStatus === 'OPEN' && r.state?.price?.allow !== false
|
||||
&& (closure === undefined || closure === 'unspecified');
|
||||
return [{ from, to, open }];
|
||||
}).sort((a, b) => a.from - b.from);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the closure that starts at `closesAt` (marketOpen's) keeps the
|
||||
* market shut across one of Vest's 8 PM ET resets — a weekend, a holiday —
|
||||
* or for as far as the periods go. A position held into it can't be exited
|
||||
* or fitted to that reset until the market reopens, and meets the reopen's
|
||||
* gap with its stop; the daily maintenance hour reopens before the reset.
|
||||
* A close the periods don't show (they just end there) isn't one.
|
||||
*/
|
||||
export function longClosure(periods: unknown, closesAt: number): boolean {
|
||||
if (!Array.isArray(periods)) return false;
|
||||
const list = hoursPeriods(periods);
|
||||
if (!list.some((p) => p.to > closesAt)) return false;
|
||||
const reopensAt = list.find((p) => p.open && p.from >= closesAt)?.from;
|
||||
return reopensAt === undefined || nextVestResetMs(closesAt) < reopensAt;
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Vest sessions relayed by the extension.
|
||||
*
|
||||
* Vest logs a person in through Google/Privy in the browser, and refreshes
|
||||
* the session with a device key the browser will not export, so this
|
||||
* machine cannot log in by itself. Instead the AutoFirmer extension watches
|
||||
* the signed-in Vest tab: whenever the Vest app renews its own ~15-minute
|
||||
* access token, the extension posts it here (POST /api/vest/session). The
|
||||
* Vest client (lib/vest-class.ts) trades with the latest one for its wallet
|
||||
* and mints per-account tokens from it.
|
||||
*
|
||||
* Kept in memory only — never in the DB, the logs, a report or a backup. A
|
||||
* restart forgets them; the Vest tab's next renewal (or the extension's
|
||||
* next tick) relays a fresh one within minutes.
|
||||
*/
|
||||
|
||||
export interface VestSession {
|
||||
accessToken: string;
|
||||
/** Epoch ms the access token stops working. */
|
||||
expiresAt: number;
|
||||
/** The wallet the Vest login belongs to, lower-case. */
|
||||
primaryAddr: string;
|
||||
userId: string;
|
||||
receivedAt: number;
|
||||
}
|
||||
|
||||
/** What the dashboard and the extension may see of a session: never the token. */
|
||||
export interface VestSessionInfo {
|
||||
primaryAddr: string;
|
||||
userId: string;
|
||||
expiresAt: number;
|
||||
receivedAt: number;
|
||||
}
|
||||
|
||||
const g = globalThis as typeof globalThis & {
|
||||
__vestSessions?: Map<string, VestSession>;
|
||||
__vestPrevious?: Map<string, VestSession>;
|
||||
__vestRefused?: Map<string, number>;
|
||||
};
|
||||
|
||||
function store(): Map<string, VestSession> {
|
||||
return (g.__vestSessions ??= new Map());
|
||||
}
|
||||
|
||||
/** The session each wallet's current one replaced — what a refused token
|
||||
* falls back to. */
|
||||
function previous(): Map<string, VestSession> {
|
||||
return (g.__vestPrevious ??= new Map());
|
||||
}
|
||||
|
||||
/** Tokens the client found to be one account's, not the login's (by their
|
||||
* tail — enough to tell them apart, never the whole token), with when. */
|
||||
function refused(): Map<string, number> {
|
||||
return (g.__vestRefused ??= new Map());
|
||||
}
|
||||
|
||||
const REFUSED_KEEP = 50;
|
||||
|
||||
/** Shortest life a relayed token must have left to be taken. */
|
||||
const MIN_REMAINING_MS = 30_000;
|
||||
/** Longest a relayed token may claim to live — a Vest access token is
|
||||
* ~15 minutes; anything far beyond that is not one. */
|
||||
const MAX_REMAINING_MS = 6 * 60 * 60_000;
|
||||
|
||||
/** Take a relayed session. Returns why it was refused, or null. A token for
|
||||
* a wallet only ever replaces one that expires sooner. */
|
||||
export function putVestSession(input: { accessToken: unknown; expiresAt: unknown; primaryAddr: unknown; userId: unknown }, now = Date.now()): string | null {
|
||||
const token = typeof input.accessToken === 'string' ? input.accessToken.trim() : '';
|
||||
const expiresAt = Number(input.expiresAt);
|
||||
const addr = typeof input.primaryAddr === 'string' ? input.primaryAddr.trim().toLowerCase() : '';
|
||||
if (!/^[\w-]+\.[\w-]+\.[\w-]+$/.test(token) || token.length > 8192) return 'accessToken is not a JWT';
|
||||
if (!/^0x[0-9a-f]{40}$/.test(addr)) return 'primaryAddr is not a wallet address';
|
||||
if (!Number.isFinite(expiresAt) || expiresAt - now < MIN_REMAINING_MS) return 'token already expired';
|
||||
if (expiresAt - now > MAX_REMAINING_MS) return 'expiresAt is implausibly far away';
|
||||
if (refused().has(token.slice(-32))) return "that is one Vest account's token, not the login's — reload the Vest tab, or update the extension";
|
||||
const prev = store().get(addr);
|
||||
if (prev && prev.expiresAt > expiresAt && prev.accessToken !== token) return null; // keep the fresher one
|
||||
if (prev && prev.accessToken !== token) previous().set(addr, prev);
|
||||
store().set(addr, {
|
||||
accessToken: token,
|
||||
expiresAt,
|
||||
primaryAddr: addr,
|
||||
userId: typeof input.userId === 'string' ? input.userId : '',
|
||||
receivedAt: now,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The live session for a wallet, or null when none has been relayed or it
|
||||
* is about to expire. */
|
||||
export function vestSessionFor(primaryAddr: string, now = Date.now(), minRemainingMs = MIN_REMAINING_MS): VestSession | null {
|
||||
const s = store().get(primaryAddr.trim().toLowerCase());
|
||||
return s && s.expiresAt - now >= minRemainingMs ? s : null;
|
||||
}
|
||||
|
||||
/** The client found the wallet's token to be one account's own (see
|
||||
* accountScopedToken): drop it, and take it no more if it's relayed again —
|
||||
* the login's own token, when the Vest app next renews it, takes its place. */
|
||||
export function refuseVestSession(primaryAddr: string, token: string, now = Date.now()): void {
|
||||
const key = primaryAddr.trim().toLowerCase();
|
||||
refused().set(token.slice(-32), now);
|
||||
while (refused().size > REFUSED_KEEP) refused().delete(refused().keys().next().value as string);
|
||||
if (store().get(key)?.accessToken !== token) return;
|
||||
store().delete(key);
|
||||
// Back to the token it replaced, if that one is still good.
|
||||
const before = previous().get(key);
|
||||
previous().delete(key);
|
||||
if (before && !refused().has(before.accessToken.slice(-32)) && before.expiresAt - now >= MIN_REMAINING_MS) store().set(key, before);
|
||||
}
|
||||
|
||||
/** Drop a wallet's session — the gateway refused it. */
|
||||
export function dropVestSession(primaryAddr: string, token: string): void {
|
||||
const key = primaryAddr.trim().toLowerCase();
|
||||
if (store().get(key)?.accessToken === token) store().delete(key);
|
||||
}
|
||||
|
||||
/** Every relayed session, newest first, without tokens. */
|
||||
export function listVestSessions(): VestSessionInfo[] {
|
||||
return [...store().values()]
|
||||
.sort((a, b) => b.receivedAt - a.receivedAt)
|
||||
.map(({ primaryAddr, userId, expiresAt, receivedAt }) => ({ primaryAddr, userId, expiresAt, receivedAt }));
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Binds the Vest login the extension relays to this machine's Vest firm, so
|
||||
* nobody has to copy a wallet address into the dashboard.
|
||||
*
|
||||
* A Vest firm's only credential here is the wallet of the login whose session
|
||||
* it trades (lib/vest-session.ts). One person per machine, and Vest allows one
|
||||
* profile per person, so the Vest tab signed in on this machine is the one to
|
||||
* trade. It is bound automatically when:
|
||||
* - the firm has no credential yet. A saved one is never replaced: someone
|
||||
* chose it, and a different login in the tab is a mistake to flag (the
|
||||
* client says so), not one to follow;
|
||||
* - exactly one wallet has a live relayed session. Two logins in two tabs
|
||||
* can't be told apart, so the dashboard's one-click form decides.
|
||||
*/
|
||||
|
||||
import { getCachedCatalog } from './firm-catalog';
|
||||
import { getCredentials, setCredentials } from './db';
|
||||
import { listVestSessions } from './vest-session';
|
||||
import { reconcileClients } from './clients';
|
||||
import { scheduleBackupPush } from './backup-sync';
|
||||
|
||||
/** Bind the relayed wallet to every Vest firm here that has no credential.
|
||||
* Returns the names of the firms it bound. */
|
||||
export function autoBindVestWallet(now = Date.now()): string[] {
|
||||
const live = [...new Set(listVestSessions().filter((s) => s.expiresAt > now).map((s) => s.primaryAddr))];
|
||||
if (live.length !== 1) return [];
|
||||
const wallet = live[0];
|
||||
const bound: string[] = [];
|
||||
for (const firm of getCachedCatalog()) {
|
||||
if (firm.platform !== 'vest') continue;
|
||||
const creds = getCredentials(firm.id);
|
||||
if (creds && (creds.apiKey || creds.username || creds.password)) continue;
|
||||
setCredentials(firm.id, firm.name, '', '', wallet);
|
||||
bound.push(firm.name);
|
||||
console.log(`[vest] ${firm.name}: bound to wallet ${wallet}, the Vest login signed in on this machine`);
|
||||
}
|
||||
if (bound.length > 0) {
|
||||
reconcileClients();
|
||||
scheduleBackupPush();
|
||||
}
|
||||
return bound;
|
||||
}
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"check": "node --experimental-strip-types --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --test scripts/check-trading-logic.mjs scripts/check-instruments.mjs"
|
||||
"check": "node --experimental-strip-types --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --test scripts/check-trading-logic.mjs scripts/check-instruments.mjs scripts/check-account-status.mjs scripts/check-kept-rules.mjs scripts/check-request-user-agent.mjs scripts/check-vest.mjs scripts/check-vest-client.mjs scripts/check-trades.mjs scripts/check-catalog.mjs scripts/check-extension-vest.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@microsoft/signalr": "^10.0.11",
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
// Checks for the account status the reporter sends the master's ledger
|
||||
// (lib/platform-client.ts accountStatus and the helpers around it).
|
||||
//
|
||||
// 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/platform-client.ts at import time (its only
|
||||
// imports are `import type`).
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
accountStatus, accountStatusFields, brokerFacts, emptyFirmStats, firmStatusFields, firstFundDate, keptRuleIdsField, legacyDead,
|
||||
REPORT_STATUS_VERSION, SYNC_STALE_MS,
|
||||
} from '../lib/platform-client.ts';
|
||||
|
||||
const FLOORS = { negative: -2000, zero: 0, positive: 48_000 };
|
||||
const OFFSETS = { above: 1500, at: 0, below: -1500 };
|
||||
|
||||
/** Every case of {cash present/missing, floor <0/0/>0, cash above/at/below the
|
||||
* floor, active, history ready}. */
|
||||
function* matrix() {
|
||||
for (const [floorName, floor] of Object.entries(FLOORS)) {
|
||||
for (const [offsetName, offset] of Object.entries(OFFSETS)) {
|
||||
for (const missing of [false, true]) {
|
||||
for (const active of [true, false]) {
|
||||
for (const historyReady of [true, false]) {
|
||||
const cash = missing ? undefined : floor + offset;
|
||||
yield { label: `${floorName} floor, cash ${missing ? 'missing' : offsetName}, active ${active}, history ${historyReady}`, cash, floor, active, historyReady };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test('legacyDead is the test reporter.ts:114 used before', () => {
|
||||
for (const c of matrix()) {
|
||||
const cashOrZero = c.cash ?? 0;
|
||||
// Copied from reporter.ts at 8747dd6: autoLiqThreshold !== 0 && cash.amount <= autoLiqThreshold
|
||||
assert.equal(legacyDead(cashOrZero, c.floor), c.floor !== 0 && cashOrZero <= c.floor, c.label);
|
||||
}
|
||||
});
|
||||
|
||||
test('dead is exactly the legacy test whenever there is a balance', () => {
|
||||
let cases = 0;
|
||||
for (const c of matrix()) {
|
||||
if (c.cash === undefined) continue;
|
||||
cases++;
|
||||
const { status } = accountStatus(c);
|
||||
assert.equal(status === 'dead', legacyDead(c.cash, c.floor), c.label);
|
||||
}
|
||||
assert.equal(cases, 36);
|
||||
// A balance of exactly 0 is a balance, not a missing one.
|
||||
assert.deepEqual(accountStatus({ cash: 0, floor: 48_000, active: true, historyReady: true }), { status: 'dead' });
|
||||
assert.deepEqual(accountStatus({ cash: 0, floor: 0, active: true, historyReady: true }), { status: 'live' });
|
||||
});
|
||||
|
||||
test('a missing balance is unknown, never dead and never 0', () => {
|
||||
for (const c of matrix()) {
|
||||
if (c.cash !== undefined) continue;
|
||||
assert.deepEqual(accountStatus(c), { status: 'unknown', reason: 'cash_missing' }, c.label);
|
||||
}
|
||||
assert.deepEqual(accountStatus({ cash: null, floor: 48_000, active: true, historyReady: true }), { status: 'unknown', reason: 'cash_missing' });
|
||||
});
|
||||
|
||||
test('the legacy invariant: the old dead count = dead + cash-missing with a positive floor', () => {
|
||||
for (const c of matrix()) {
|
||||
const { status, reason } = accountStatus(c);
|
||||
const oldDead = legacyDead(c.cash ?? 0, c.floor);
|
||||
const newDead = status === 'dead' || (status === 'unknown' && reason === 'cash_missing' && c.floor > 0);
|
||||
assert.equal(newDead, oldDead, c.label);
|
||||
}
|
||||
});
|
||||
|
||||
test('priority: dead, then history pending, then locked, then live', () => {
|
||||
const base = { cash: 50_000, floor: 48_000, active: true, historyReady: true };
|
||||
assert.deepEqual(accountStatus(base), { status: 'live' });
|
||||
assert.deepEqual(accountStatus({ ...base, active: false }), { status: 'locked', reason: 'inactive' });
|
||||
assert.deepEqual(accountStatus({ ...base, active: false, inactiveReason: 'liquidate_only' }), { status: 'locked', reason: 'liquidate_only' });
|
||||
assert.deepEqual(accountStatus({ ...base, active: false, historyReady: false }), { status: 'history_pending' });
|
||||
assert.deepEqual(accountStatus({ ...base, cash: 47_000, active: false, historyReady: false }), { status: 'dead' });
|
||||
assert.deepEqual(accountStatus({ ...base, cash: 47_000, deadReason: 'terminal:TERMINATED' }), { status: 'dead', reason: 'terminal:TERMINATED' });
|
||||
// A floor of 0 is no floor: never dead, whatever the balance.
|
||||
assert.equal(accountStatus({ ...base, cash: -10, floor: 0 }).status, 'live');
|
||||
});
|
||||
|
||||
test('broker facts per platform', () => {
|
||||
assert.deepEqual(brokerFacts('tradovate', { active: true, closed: false, archived: false, restricted: false }), { inactiveReason: 'liquidate_only' });
|
||||
assert.deepEqual(brokerFacts('tradovate', { active: false, closed: true, archived: true, restricted: false }),
|
||||
{ inactiveReason: 'liquidate_only', brokerStatus: 'closed,archived' });
|
||||
assert.deepEqual(brokerFacts('topstepx', { active: true }), { inactiveReason: 'cannot_trade' });
|
||||
assert.deepEqual(brokerFacts('topstepx', { active: false }), { inactiveReason: 'cannot_trade', brokerStatus: 'canTrade:false' });
|
||||
const registered = Date.parse('2026-10-03T03:00:00Z');
|
||||
assert.deepEqual(brokerFacts('dxtrade', { active: true }, { status: 'FULL_TRADING', registrationTime: registered }),
|
||||
{ inactiveReason: 'status:FULL_TRADING', deadReason: 'terminal:FULL_TRADING', registeredAt: registered });
|
||||
assert.deepEqual(brokerFacts('dxtrade', { active: false }, { status: 'CLOSE_ONLY', registrationTime: NaN }),
|
||||
{ inactiveReason: 'close_only', deadReason: 'terminal:CLOSE_ONLY', brokerStatus: 'CLOSE_ONLY' });
|
||||
assert.deepEqual(brokerFacts('dxtrade', { active: false }, undefined), { inactiveReason: 'inactive' });
|
||||
});
|
||||
|
||||
test('a DXtrade account the firm terminated reads dead with the terminal status', () => {
|
||||
// dxtrade-class.ts sets the floor to the balance for DEAD_STATUSES.
|
||||
const facts = brokerFacts('dxtrade', { active: false }, { status: 'TERMINATED', registrationTime: NaN });
|
||||
const fields = accountStatusFields({ cash: 94_210.5, floor: 94_210.5, active: false, historyReady: true, accountTypeId: null, fundTransactions: [], ...facts });
|
||||
assert.equal(fields.status, 'dead');
|
||||
assert.equal(fields.reason, 'terminal:TERMINATED');
|
||||
assert.equal(fields.brokerStatus, 'TERMINATED');
|
||||
// A CLOSE_ONLY account is inactive (floor 0 there), not dead.
|
||||
const closeOnly = accountStatusFields({ cash: 94_210.5, floor: 0, active: false, historyReady: true, accountTypeId: 't', fundTransactions: [],
|
||||
...brokerFacts('dxtrade', { active: false }, { status: 'CLOSE_ONLY', registrationTime: NaN }) });
|
||||
assert.deepEqual([closeOnly.status, closeOnly.reason], ['locked', 'close_only']);
|
||||
});
|
||||
|
||||
test('per-account fields: defaults and nulls are left out', () => {
|
||||
const fields = accountStatusFields({ cash: undefined, floor: 0, active: true, historyReady: false, accountTypeId: null, fundTransactions: undefined, inactiveReason: 'liquidate_only' });
|
||||
assert.deepEqual(fields, { status: 'unknown', reason: 'cash_missing' });
|
||||
|
||||
const live = accountStatusFields({
|
||||
cash: 50_123.456, floor: 47_623.451, active: true, historyReady: true, accountTypeId: 'cc9d7145-f85c-4b61-9e90-b7091b6f7dcc',
|
||||
fundTransactions: [{ date: '2026-09-30', amount: -1000 }, { date: '2026-09-25', amount: 50_000 }, { date: '2026-10-02', amount: 25 }],
|
||||
inactiveReason: 'liquidate_only',
|
||||
});
|
||||
assert.deepEqual(live, {
|
||||
status: 'live', balance: 50_123.46, floor: 47_623.45, accountTypeId: 'cc9d7145-f85c-4b61-9e90-b7091b6f7dcc',
|
||||
firstFundDate: '2026-09-25', positiveFunds: 2,
|
||||
});
|
||||
|
||||
// Fund history loaded with no positive rows: a count of 0, no date.
|
||||
const none = accountStatusFields({ cash: 10, floor: 0, active: true, historyReady: true, accountTypeId: 't', fundTransactions: [] });
|
||||
assert.deepEqual([none.positiveFunds, 'firstFundDate' in none], [0, false]);
|
||||
});
|
||||
|
||||
test('DXtrade: the registration date (Chicago) stands in for the first fund date', () => {
|
||||
const registeredAt = Date.parse('2026-10-03T03:00:00Z'); // Oct 2, 22:00 CDT
|
||||
const input = { cash: 100_000, floor: 0, active: true, historyReady: true, accountTypeId: 't', registeredAt };
|
||||
assert.equal(accountStatusFields({ ...input, fundTransactions: [] }).firstFundDate, '2026-10-02');
|
||||
// A real fund row wins; and nothing until the fund history has loaded.
|
||||
assert.equal(accountStatusFields({ ...input, fundTransactions: [{ date: '2026-10-05', amount: 100_000 }] }).firstFundDate, '2026-10-05');
|
||||
assert.equal('firstFundDate' in accountStatusFields({ ...input, fundTransactions: undefined }), false);
|
||||
// A time no Date can hold is no date — and must not throw.
|
||||
for (const bad of [1.7e18, -1e16, NaN]) {
|
||||
assert.equal('registeredAt' in brokerFacts('dxtrade', { active: true }, { status: 'FULL_TRADING', registrationTime: bad }), false);
|
||||
assert.equal('firstFundDate' in accountStatusFields({ ...input, registeredAt: bad, fundTransactions: [] }), false);
|
||||
}
|
||||
assert.deepEqual(firmStatusFields('f', true, 1e17), { statusVersion: 1, firmId: 'f' });
|
||||
});
|
||||
|
||||
test('firstFundDate: the helper the report and the rules-date evidence share', () => {
|
||||
const registeredAt = Date.parse('2026-10-03T03:00:00Z'); // Oct 2, 22:00 CDT
|
||||
assert.equal(firstFundDate(undefined, registeredAt), undefined);
|
||||
assert.equal(firstFundDate([], registeredAt), '2026-10-02');
|
||||
assert.equal(firstFundDate([], undefined), undefined);
|
||||
assert.equal(firstFundDate([{ date: '2026-10-05T00:00:00', amount: 100_000 }, { date: '2026-10-04', amount: -50 }], registeredAt), '2026-10-05');
|
||||
assert.equal(firstFundDate([{ date: '2026-10-09', amount: 25 }, { date: '2026-10-05', amount: 100_000 }], undefined), '2026-10-05');
|
||||
// Always what accountStatusFields reports.
|
||||
for (const funds of [undefined, [], [{ date: '2026-09-25', amount: 50_000 }], [{ date: '2026-09-30', amount: -1 }]]) {
|
||||
for (const reg of [undefined, registeredAt, NaN]) {
|
||||
const fields = accountStatusFields({ cash: 1, floor: 0, active: true, historyReady: true, accountTypeId: 't', fundTransactions: funds, registeredAt: reg });
|
||||
assert.equal(fields.firstFundDate, firstFundDate(funds, reg));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('firm-level fields: statusVersion and firmId always, syncedAt once synced', () => {
|
||||
const at = Date.parse('2026-10-06T14:00:00Z');
|
||||
assert.deepEqual(firmStatusFields('f-1', true, at), { statusVersion: 1, firmId: 'f-1', syncedAt: '2026-10-06T14:00:00.000Z' });
|
||||
assert.deepEqual(firmStatusFields('f-1', true, 0), { statusVersion: 1, firmId: 'f-1' });
|
||||
assert.deepEqual(firmStatusFields('f-1', false, at), { statusVersion: 1, firmId: 'f-1' });
|
||||
assert.equal(REPORT_STATUS_VERSION, 1);
|
||||
assert.equal(SYNC_STALE_MS, 5 * 60 * 1000);
|
||||
});
|
||||
|
||||
test('keptRuleIds: present (even empty) when the catalog vouches for them, absent otherwise', () => {
|
||||
assert.deepEqual(keptRuleIdsField(null), {});
|
||||
assert.deepEqual(keptRuleIdsField([]), { keptRuleIds: [] });
|
||||
assert.deepEqual(keptRuleIdsField(['k1', 'k2']), { keptRuleIds: ['k1', 'k2'] });
|
||||
});
|
||||
|
||||
test('the no-accounts branch keeps every existing key and value', () => {
|
||||
const firm = { id: '71016d02-752f-4078-9e24-e74c9d75d1c8', name: 'Tradeify' };
|
||||
// What reporter.ts sent before (its :46-56 branch), for each client state.
|
||||
const legacy = (client) => ({
|
||||
firm: firm.name, totalAccounts: 0, accountsTraded: 0, inTrade: 0,
|
||||
targetHit: 0, partial: 0, loss: 0, personalDll: 0, completed: 0,
|
||||
...(client?.syncComplete ? { accounts: [] } : {}),
|
||||
accountTypes: [],
|
||||
});
|
||||
const at = Date.parse('2026-10-06T14:00:00Z');
|
||||
const states = [undefined, { syncComplete: false, lastSyncAt: 0 }, { syncComplete: false, lastSyncAt: at }, { syncComplete: true, lastSyncAt: 0 }, { syncComplete: true, lastSyncAt: at }];
|
||||
// No kept-rule ids (an older cache: null, or not passed), none, some.
|
||||
const keptCases = [undefined, null, [], ['3f0c2b7e-0000-4000-8000-000000000001']];
|
||||
for (const client of states) {
|
||||
for (const kept of keptCases) {
|
||||
const got = kept === undefined ? emptyFirmStats(firm, client) : emptyFirmStats(firm, client, kept);
|
||||
const old = legacy(client);
|
||||
const label = `${JSON.stringify(client)} kept ${JSON.stringify(kept)}`;
|
||||
for (const [key, value] of Object.entries(old)) assert.deepEqual(got[key], value, `${label}: ${key}`);
|
||||
const added = Object.keys(got).filter((k) => !(k in old)).sort();
|
||||
const synced = client?.syncComplete === true;
|
||||
assert.equal(got.statusVersion, 1);
|
||||
assert.equal(got.firmId, firm.id);
|
||||
assert.equal('accounts' in got, synced);
|
||||
assert.equal('syncedAt' in got, synced && client.lastSyncAt > 0);
|
||||
assert.deepEqual(got.keptRuleIds, Array.isArray(kept) ? kept : undefined, label);
|
||||
assert.deepEqual(added, ['firmId', 'statusVersion', ...(got.syncedAt ? ['syncedAt'] : []), ...(Array.isArray(kept) ? ['keptRuleIds'] : [])].sort(), label);
|
||||
// The old keys stay first and in their order: only new keys follow.
|
||||
assert.deepEqual(Object.keys(got).slice(0, Object.keys(old).length), Object.keys(old));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ── Size: 4 firms × 500 accounts against the master socket's 1 MB frame ──────
|
||||
|
||||
const TYPE_ID = 'f5ad1527-8bd7-4ca0-9e35-bcadf78e52e9';
|
||||
const pad = (n, w) => String(n).padStart(w, '0');
|
||||
|
||||
/** The entry reporter.ts sent before, with a realistic 14-character name. */
|
||||
function legacyEntry(i) {
|
||||
return { name: `TDFY${pad(i, 10)}`, size: 150_000, payoutable: false, withdrawals: 2 };
|
||||
}
|
||||
|
||||
/** Every new field populated, worst realistic case per platform. */
|
||||
function fullFields(platform, i) {
|
||||
const balance = 149_823.45 - (i % 97);
|
||||
if (platform === 'tradovate') {
|
||||
return accountStatusFields({ cash: balance, floor: 147_500.12, active: false, historyReady: true, accountTypeId: TYPE_ID,
|
||||
fundTransactions: [{ date: '2026-09-25', amount: 150_000 }, { date: '2026-10-01', amount: 25 }],
|
||||
...brokerFacts('tradovate', { active: false, closed: true, archived: true, restricted: true }) });
|
||||
}
|
||||
if (platform === 'dxtrade') {
|
||||
return accountStatusFields({ cash: balance, floor: balance, active: false, historyReady: true, accountTypeId: TYPE_ID,
|
||||
fundTransactions: [{ date: '2026-09-25', amount: 150_000 }],
|
||||
...brokerFacts('dxtrade', { active: false }, { status: 'TERMINATED', registrationTime: Date.parse('2026-09-25T15:00:00Z') }) });
|
||||
}
|
||||
return accountStatusFields({ cash: balance, floor: 147_500.12, active: false, historyReady: true, accountTypeId: TYPE_ID,
|
||||
fundTransactions: [{ date: '2026-09-25', amount: 150_000 }],
|
||||
...brokerFacts('topstepx', { active: false }) });
|
||||
}
|
||||
|
||||
/** A typical live account: no reason, no broker status. */
|
||||
function typicalFields(i) {
|
||||
return accountStatusFields({ cash: 50_123.45 + (i % 89), floor: 47_623.45, active: true, historyReady: true, accountTypeId: TYPE_ID,
|
||||
fundTransactions: [{ date: '2026-09-25', amount: 50_000 }], ...brokerFacts('tradovate', { active: true, closed: false, archived: false, restricted: false }) });
|
||||
}
|
||||
|
||||
const added = (fields, i) => JSON.stringify({ ...legacyEntry(i), ...fields }).length - JSON.stringify(legacyEntry(i)).length;
|
||||
const PLATFORMS = ['tradovate', 'tradovate', 'topstepx', 'dxtrade'];
|
||||
|
||||
const ALL_FIELDS = ['status', 'reason', 'balance', 'floor', 'accountTypeId', 'firstFundDate', 'positiveFunds', 'brokerStatus'];
|
||||
|
||||
test('size: the new fields add < 230 bytes per fully populated account, < 180 per typical live one', () => {
|
||||
let worst = 0;
|
||||
for (const platform of new Set(PLATFORMS)) {
|
||||
assert.deepEqual(Object.keys(fullFields(platform, 0)).sort(), [...ALL_FIELDS].sort(), `${platform} entry is fully populated`);
|
||||
assert.equal(fullFields(platform, 0).accountTypeId.length, 36);
|
||||
for (let i = 0; i < 500; i++) worst = Math.max(worst, added(fullFields(platform, i), i));
|
||||
}
|
||||
assert.deepEqual(Object.keys(typicalFields(0)).sort(), ['accountTypeId', 'balance', 'firstFundDate', 'floor', 'positiveFunds', 'status']);
|
||||
let typical = 0;
|
||||
for (let i = 0; i < 500; i++) typical = Math.max(typical, added(typicalFields(i), i));
|
||||
console.log(` # new fields per account: ${worst} B fully populated (worst platform), ${typical} B typical live`);
|
||||
assert.ok(worst < 230, `fully populated adds ${worst} B`);
|
||||
assert.ok(typical < 180, `typical live adds ${typical} B`);
|
||||
});
|
||||
|
||||
test('size: 2000 fully populated accounts < 600 KB, the whole report body < 900 KB', () => {
|
||||
const firms = PLATFORMS.map((platform, f) => ({
|
||||
firm: `Firm ${f}`, totalAccounts: 500, accountsTraded: 500, inTrade: 0,
|
||||
targetHit: 0, partial: 0, loss: 500, personalDll: 0, completed: 0,
|
||||
accounts: Array.from({ length: 500 }, (_, i) => ({ ...legacyEntry(f * 500 + i), ...fullFields(platform, i) })),
|
||||
accountTypes: [{ accountTypeId: TYPE_ID, label: '150K Eval', accountSize: 150_000, totalAccounts: 500, accountsTraded: 500, inTrade: 0, targetHit: 0, partial: 0, loss: 500 }],
|
||||
...firmStatusFields(`00000000-0000-4000-8000-00000000000${f}`, true, Date.parse('2026-10-06T14:00:00Z')),
|
||||
}));
|
||||
const accountsBytes = JSON.stringify(firms.flatMap((f) => f.accounts)).length;
|
||||
const body = JSON.stringify({
|
||||
instance: 'Someone', id: '00000000-0000-4000-8000-000000000000', firms,
|
||||
scheduler: { running: true, lastTicks: [] }, version: '8747dd6', updater: null,
|
||||
}).length;
|
||||
console.log(` # 2000 accounts: ${accountsBytes} B; report body: ${body} B`);
|
||||
assert.ok(accountsBytes < 600_000, `accounts serialize to ${accountsBytes} B`);
|
||||
assert.ok(body < 900_000, `report body is ${body} B`);
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
// Checks for the firm catalog sync in lib/firm-catalog.ts, against a stand-in
|
||||
// master on a local port and a throwaway database — never the live DB or the
|
||||
// real master.
|
||||
//
|
||||
// npm run check
|
||||
import { after, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { registerHooks } from 'node:module';
|
||||
import { createServer } from 'node:http';
|
||||
import { once } from 'node:events';
|
||||
|
||||
// Resolve the extensionless local imports used by Next, without a build.
|
||||
function resolveTypes(specifier, context, nextResolve) {
|
||||
try {
|
||||
return nextResolve(specifier, context);
|
||||
} catch (err) {
|
||||
if (err.code !== 'ERR_MODULE_NOT_FOUND' || !specifier.startsWith('.')) throw err;
|
||||
return nextResolve(`${specifier}.ts`, context);
|
||||
}
|
||||
}
|
||||
registerHooks({ resolve: resolveTypes });
|
||||
|
||||
const directory = mkdtempSync(join(tmpdir(), 'autofirmer-catalog-'));
|
||||
process.env.AUTOFIRMER_DB = join(directory, 'test.sqlite');
|
||||
after(() => rmSync(directory, { recursive: true, force: true }));
|
||||
|
||||
const { getSetting, setSetting } = await import('../lib/db.ts');
|
||||
const { syncCatalog, getCachedCatalog, getKeptRuleIdsForFirm, CATALOG_PARSER_VERSION } = await import('../lib/firm-catalog.ts');
|
||||
|
||||
const firm = (id, name, platform) => ({
|
||||
id, name, platform, keptAccountRules: [], updatedAt: '2026-10-26T19:00:00.000Z',
|
||||
accountTypes: [{
|
||||
id: `${id}-50k`, firmId: id, label: '50k', prefix: `${id.toUpperCase()}-`, updatedAt: '2026-10-26T19:00:00.000Z',
|
||||
accountSize: 50000, profitTarget: 3000, consistency: 0, minDayPnL: 0, minTradingDays: 0, maxLoss: 3000,
|
||||
maxPositionSize: 0, withdrawalStages: [],
|
||||
}],
|
||||
});
|
||||
const apex = firm('apex', 'Apex', 'tradovate');
|
||||
const vest = firm('vest', 'VestMarkets', 'vest');
|
||||
|
||||
// The master's catalog, answering ?since= as app/api/firms/catalog does, and
|
||||
// the `since` each sync sent (null = a full fetch).
|
||||
let served = { version: 41, updatedAt: '2026-10-26T19:20:00.000Z', firms: [apex, vest] };
|
||||
const sinceSent = [];
|
||||
const server = createServer((req, res) => {
|
||||
const since = new URL(req.url, 'http://master').searchParams.get('since');
|
||||
sinceSent.push(since);
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify(since !== null && Number(since) === served.version
|
||||
? { version: served.version, updatedAt: served.updatedAt, unchanged: true }
|
||||
: served));
|
||||
});
|
||||
server.listen(0, '127.0.0.1');
|
||||
await once(server, 'listening');
|
||||
after(() => server.close());
|
||||
const master = `http://127.0.0.1:${server.address().port}`;
|
||||
|
||||
/** This machine's cache as some build stored it, read afresh as after a restart. */
|
||||
function storedCache(payload) {
|
||||
setSetting('master_dashboard_url', master);
|
||||
setSetting('catalog_source_url', master);
|
||||
setSetting('catalog_version', String(payload.version));
|
||||
setSetting('catalog_payload', JSON.stringify(payload));
|
||||
globalThis.__firmCatalog = undefined;
|
||||
sinceSent.length = 0;
|
||||
}
|
||||
|
||||
const names = () => getCachedCatalog().map((f) => f.name);
|
||||
|
||||
test('a cache a pre-Vest build stored is fetched in full by a build that knows Vest', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
// Parser 2 skipped the Vest firm and said nothing of it; the master's
|
||||
// catalog hasn't changed since, so ?since= would answer "unchanged".
|
||||
storedCache({ version: 41, updatedAt: served.updatedAt, firms: [apex], parser: 2 });
|
||||
assert.equal((await syncCatalog()).ok, true);
|
||||
assert.deepEqual(sinceSent, [null]);
|
||||
assert.deepEqual(names(), ['Apex', 'VestMarkets']);
|
||||
// Current from then on: the next sync asks ?since= again.
|
||||
await syncCatalog();
|
||||
assert.deepEqual(sinceSent, [null, '41']);
|
||||
});
|
||||
|
||||
test('a skipped platform is named in the cache, and a build that knows it fetches in full', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
// The master adds a firm on a platform this build doesn't know.
|
||||
served = { version: 42, updatedAt: '2026-10-27T15:00:00.000Z', firms: [apex, vest, firm('px', 'PX Firm', 'projectx')] };
|
||||
storedCache({ version: 41, updatedAt: '2026-10-26T19:20:00.000Z', firms: [apex, vest], parser: CATALOG_PARSER_VERSION });
|
||||
await syncCatalog();
|
||||
assert.deepEqual(names(), ['Apex', 'VestMarkets']);
|
||||
assert.deepEqual(JSON.parse(getSetting('catalog_payload')).skippedPlatforms, ['projectx']);
|
||||
// While this build still doesn't know it, syncs stay incremental, and the
|
||||
// cache still vouches for the kept rule sets of the firms it holds.
|
||||
globalThis.__firmCatalog = undefined;
|
||||
sinceSent.length = 0;
|
||||
await syncCatalog();
|
||||
assert.deepEqual(sinceSent, ['42']);
|
||||
assert.deepEqual(getKeptRuleIdsForFirm('apex'), []);
|
||||
// A build that knows the platform it skipped (Vest here) fetches in full.
|
||||
storedCache({ version: 42, updatedAt: served.updatedAt, firms: [apex], parser: CATALOG_PARSER_VERSION, skippedPlatforms: ['vest'] });
|
||||
await syncCatalog();
|
||||
assert.deepEqual(sinceSent, [null]);
|
||||
assert.ok(names().includes('VestMarkets'));
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
// Checks for the extension's Vest relay: the hook in the Vest tab
|
||||
// (extension/vest-relay.js) and the choice of which tab's token the background
|
||||
// relays (extension/background.js pageVestSession / pickVestSession).
|
||||
//
|
||||
// npm run check
|
||||
//
|
||||
// The hook runs in a sandbox standing in for the Vest page — its own XHR and
|
||||
// fetch, answered here; the background's functions are lifted out of its
|
||||
// source, since it is a service worker and not a module. Nothing reaches Vest
|
||||
// or Chrome. The answers are shaped like the Vest app's own (index bundle,
|
||||
// 2026-10-29): a renewal's carries refreshTokenId, a sign-in's the login's
|
||||
// refreshToken too, an account's renewal a refreshToken in its request.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const read = (path) => readFileSync(new URL(`../${path}`, import.meta.url), 'utf8');
|
||||
const HOOK = read('extension/vest-relay.js');
|
||||
const BACKGROUND = read('extension/background.js');
|
||||
|
||||
const GATEWAY = 'https://api-gateway.hz.vestmarkets.com';
|
||||
const WALLET = '0x7ce10771819f80130e204ddb5ca1522e48a9b451';
|
||||
const answer = (extra) => JSON.stringify({ accessToken: 'eyJ.login.sig', accessExpiresAtMs: 1_800_000_000_000, primaryAddr: WALLET, userId: 'u', ...extra });
|
||||
|
||||
/** A Vest page with the hook run in it (after `before`, a hook already
|
||||
* there): `xhr(url, body, status, text)` and `fetch(...)` play one of the
|
||||
* app's requests through it. */
|
||||
function page(before = {}) {
|
||||
class FakeXHR {
|
||||
constructor() { this.listeners = []; this.responseType = ''; }
|
||||
open(method, url) { this.url = url; }
|
||||
send() {}
|
||||
addEventListener(type, fn) { if (type === 'loadend') this.listeners.push(fn); }
|
||||
}
|
||||
const answers = [];
|
||||
const sandbox = {
|
||||
XMLHttpRequest: FakeXHR, URL,
|
||||
fetch: async () => answers.shift(),
|
||||
...before,
|
||||
};
|
||||
sandbox.window = sandbox;
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(HOOK, sandbox);
|
||||
return {
|
||||
sandbox,
|
||||
run: () => vm.runInContext(HOOK, sandbox),
|
||||
xhr(url, body, status, text) {
|
||||
const x = new sandbox.XMLHttpRequest();
|
||||
x.open('POST', url);
|
||||
x.send(body);
|
||||
x.status = status;
|
||||
x.responseText = text;
|
||||
for (const fn of x.listeners) fn();
|
||||
},
|
||||
async fetch(url, body, status, text) {
|
||||
answers.push({ status, clone: () => ({ text: async () => text }) });
|
||||
await sandbox.window.fetch(url, { method: 'POST', body });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("the hook keeps the login's renewed token with its session id, and an account's never", async () => {
|
||||
const p = page();
|
||||
assert.equal(p.sandbox.__afVestHook, 2);
|
||||
p.xhr(`${GATEWAY}/v3/auth/refresh`, JSON.stringify({ requestId: 'r1', rememberMe: true }), 200, answer({ refreshTokenId: 'rt-1' }));
|
||||
assert.equal(p.sandbox.__afVestLogin.accessToken, 'eyJ.login.sig');
|
||||
assert.equal(p.sandbox.__afVestLogin.refreshTokenId, 'rt-1');
|
||||
assert.equal(p.sandbox.__afVestLogin.primaryAddr, WALLET);
|
||||
|
||||
// An account's renewal, by its request, then by its answer alone.
|
||||
p.xhr(`${GATEWAY}/v3/auth/refresh`, JSON.stringify({ refreshToken: 'acct-rt', requestId: 'r2' }), 200, answer({ accessToken: 'eyJ.acct.sig', refreshTokenId: 'rt-a' }));
|
||||
await p.fetch(`${GATEWAY}/v3/auth/refresh`, undefined, 200, answer({ accessToken: 'eyJ.acct2.sig', refreshToken: 'acct-rt', refreshTokenId: 'rt-b' }));
|
||||
assert.equal(p.sandbox.__afVestLogin.accessToken, 'eyJ.login.sig');
|
||||
|
||||
// The login's next renewal, through fetch; a refused one leaves it be.
|
||||
await p.fetch(`${GATEWAY}/v3/auth/refresh`, JSON.stringify({ requestId: 'r3' }), 200, answer({ accessToken: 'eyJ.login2.sig', refreshTokenId: 'rt-2' }));
|
||||
p.xhr(`${GATEWAY}/v3/auth/refresh`, JSON.stringify({ requestId: 'r4' }), 401, answer({ accessToken: 'eyJ.no.sig', refreshTokenId: 'rt-x' }));
|
||||
assert.equal(p.sandbox.__afVestLogin.accessToken, 'eyJ.login2.sig');
|
||||
assert.equal(p.sandbox.__afVestLogin.refreshTokenId, 'rt-2');
|
||||
});
|
||||
|
||||
test("a sign-in's token is taken, though its answer carries the login's refresh token", () => {
|
||||
const p = page();
|
||||
p.xhr(`${GATEWAY}/v3/auth/register`, JSON.stringify({ primaryAddr: WALLET }), 200, answer({ accessToken: 'eyJ.signin.sig', refreshToken: 'login-rt', refreshTokenId: 'rt-s' }));
|
||||
assert.equal(p.sandbox.__afVestLogin.accessToken, 'eyJ.signin.sig');
|
||||
assert.equal(p.sandbox.__afVestLogin.refreshTokenId, 'rt-s');
|
||||
});
|
||||
|
||||
test('the hook installs once, and beside a version-1 hook', () => {
|
||||
const p = page({ __afVestHook: true, __afVestSession: { accessToken: 'eyJ.old.sig', expiresAt: 1 } });
|
||||
assert.equal(p.sandbox.__afVestHook, 2);
|
||||
const send = p.sandbox.XMLHttpRequest.prototype.send;
|
||||
p.run();
|
||||
assert.equal(p.sandbox.XMLHttpRequest.prototype.send, send);
|
||||
});
|
||||
|
||||
// The background's own functions, out of its source.
|
||||
function lift(name) {
|
||||
const m = BACKGROUND.match(new RegExp(`^function ${name}\\([\\s\\S]*?^}`, 'm'));
|
||||
assert.ok(m, `${name} not found in background.js`);
|
||||
return m[0];
|
||||
}
|
||||
const { isCurrentVestSession, pickVestSession } = new Function(`${lift('isCurrentVestSession')}\n${lift('pickVestSession')}\nreturn { isCurrentVestSession, pickVestSession };`)();
|
||||
const pageVestSession = (window, stored) => new Function('window', 'localStorage', `${lift('pageVestSession')}\nreturn pageVestSession();`)(
|
||||
window, { getItem: (key) => (key === 'vest-auth-session' ? stored : null) },
|
||||
);
|
||||
|
||||
const later = (min) => Date.now() + min * 60_000;
|
||||
const session = (token, expiresAt, refreshTokenId) => ({ accessToken: token, expiresAt, primaryAddr: WALLET, userId: 'u', ...(refreshTokenId !== undefined ? { refreshTokenId } : {}) });
|
||||
const record = (refreshTokenId, accessTokenExpiresAt) => JSON.stringify({ refreshTokenId, accessTokenExpiresAt, refreshTokenExpiresAt: later(43200), rememberMe: true });
|
||||
|
||||
test("a tab reports its live token (version 2's, else version 1's) and the app's current session", () => {
|
||||
const expiresAt = later(12);
|
||||
let out = pageVestSession({ __afVestHook: 2, __afVestLogin: session('eyJ.v2.sig', expiresAt, 'rt-2'), __afVestSession: session('eyJ.v1.sig', expiresAt) }, record('rt-2', expiresAt));
|
||||
assert.equal(out.hooked, true);
|
||||
assert.equal(out.session.accessToken, 'eyJ.v2.sig');
|
||||
assert.deepEqual(out.current, { refreshTokenId: 'rt-2', accessTokenExpiresAt: expiresAt });
|
||||
|
||||
// Hooked by version 1 only: its token, and the tab asks for version 2.
|
||||
out = pageVestSession({ __afVestHook: true, __afVestSession: session('eyJ.v1.sig', later(9)) }, record('rt-3', later(14)));
|
||||
assert.equal(out.hooked, false);
|
||||
assert.equal(out.session.accessToken, 'eyJ.v1.sig');
|
||||
|
||||
// A 0.17.0 hook kept an account's renewal too: version 2's login token
|
||||
// wins over it, though it expires sooner.
|
||||
out = pageVestSession({ __afVestHook: 2, __afVestLogin: session('eyJ.v2.sig', later(5), 'rt-1'), __afVestSession: session('eyJ.acct.sig', later(13)) }, null);
|
||||
assert.equal(out.session.accessToken, 'eyJ.v2.sig');
|
||||
assert.equal(out.current, null);
|
||||
|
||||
// Nearly expired, or a record that doesn't read.
|
||||
out = pageVestSession({ __afVestHook: 2, __afVestLogin: session('eyJ.v2.sig', Date.now() + 20_000, 'rt-1') }, '{nope');
|
||||
assert.equal(out.session, null);
|
||||
assert.equal(out.current, null);
|
||||
});
|
||||
|
||||
test("the token relayed is the one the current session holds, whichever tab has it — not the first tab's", () => {
|
||||
// Tab 1 renewed first; tab 2 renewed after it, so the session is tab 2's.
|
||||
const tab1 = { hooked: true, session: session('eyJ.tab1.sig', later(6), 'rt-1'), current: { refreshTokenId: 'rt-2', accessTokenExpiresAt: later(13) } };
|
||||
const tab2 = { hooked: true, session: session('eyJ.tab2.sig', later(13), 'rt-2'), current: { refreshTokenId: 'rt-2', accessTokenExpiresAt: later(13) } };
|
||||
assert.deepEqual(pickVestSession([tab1, tab2]), { session: tab2.session, current: true });
|
||||
assert.equal(isCurrentVestSession(tab1.session, tab1.current), false);
|
||||
|
||||
// Tab 1 renews again: now its token is the current one.
|
||||
const rt3 = { refreshTokenId: 'rt-3', accessTokenExpiresAt: later(15) };
|
||||
const tab1b = { ...tab1, session: session('eyJ.tab1b.sig', later(15), 'rt-3'), current: rt3 };
|
||||
assert.equal(pickVestSession([{ ...tab2, current: rt3 }, tab1b]).session.accessToken, 'eyJ.tab1b.sig');
|
||||
|
||||
// Version-1 hooks keep no id: matched by the expiry the app keeps with it.
|
||||
const v1a = { hooked: false, session: session('eyJ.v1a.sig', later(4)), current: { refreshTokenId: 'rt-9', accessTokenExpiresAt: later(11) } };
|
||||
const v1b = { hooked: false, session: session('eyJ.v1b.sig', v1a.current.accessTokenExpiresAt), current: v1a.current };
|
||||
assert.deepEqual(pickVestSession([v1a, v1b]), { session: v1b.session, current: true });
|
||||
});
|
||||
|
||||
test('with no tab holding the current session the newest is relayed, and said; with no record, unjudged', () => {
|
||||
const current = { refreshTokenId: 'rt-5', accessTokenExpiresAt: later(14) };
|
||||
const a = { hooked: true, session: session('eyJ.a.sig', later(3), 'rt-3'), current };
|
||||
const b = { hooked: true, session: session('eyJ.b.sig', later(8), 'rt-4'), current };
|
||||
assert.deepEqual(pickVestSession([a, b]), { session: b.session, current: false });
|
||||
assert.deepEqual(pickVestSession([{ ...a, current: null }, { ...b, current: null }]), { session: b.session, current: null });
|
||||
// A tab with no token yet still shows the record.
|
||||
assert.equal(pickVestSession([{ hooked: true, session: null, current }, { ...b, current: null }]).current, false);
|
||||
assert.equal(pickVestSession([{ hooked: true, session: null, current }]), null);
|
||||
assert.equal(pickVestSession([]), null);
|
||||
});
|
||||
@@ -10,9 +10,10 @@ import assert from 'node:assert/strict';
|
||||
import {
|
||||
CME_SPECS, CME_ROOTS, POINT_VALUES, MICRO_OF, tickValue, spreadDollars, dailyMoveDollars, futuresContracts,
|
||||
expectedHoldHours, sessionProgress, drawChances, pickByChance, housekeepingRoot, sanitizeInstruments,
|
||||
resolveAllowlist, MAX_HOLD_HOURS, DRAW_FLOOR, DEFAULT_MAX_ENTRY_SPREAD, spreadTooWide,
|
||||
resolveAllowlist, MAX_HOLD_HOURS, DRAW_FLOOR, DEFAULT_MAX_ENTRY_SPREAD, spreadTooWide, sanitizeInstrumentLimits,
|
||||
} from '../lib/instrument-specs.ts';
|
||||
import { mergeTopstepxQuote, parseTradovateFrame, bookSpread } from '../lib/quote-helpers.ts';
|
||||
import { TOPSTEP_COMMISSION_PER_SIDE, topstepxProductCode, topstepxRoot, topstepCommission } from '../lib/topstep-fees.ts';
|
||||
|
||||
// Deterministic PRNG so a failure reproduces.
|
||||
function rng(seed) {
|
||||
@@ -234,3 +235,63 @@ test('the draw leaves out what is over the spread limit', () => {
|
||||
// All of them wide: nothing to draw.
|
||||
assert.deepEqual(drawChances(cands.map((c) => ({ ...c, wide: true })), 0.5).chances, []);
|
||||
});
|
||||
|
||||
test('a firm\'s per-instrument limit caps the contracts, with the account type\'s max', () => {
|
||||
// One per $1,000 of leg ...
|
||||
assert.equal(futuresContracts(4200, 0), 5);
|
||||
// ... capped by the account type's max, the firm's limit, whichever is lower.
|
||||
assert.equal(futuresContracts(4200, 0, 3), 3);
|
||||
assert.equal(futuresContracts(4200, 4, 2), 2);
|
||||
assert.equal(futuresContracts(4200, 2, 4), 2);
|
||||
assert.equal(futuresContracts(4200, 0, 0), 5);
|
||||
// Never under one contract.
|
||||
assert.equal(futuresContracts(300, 0, 5), 1);
|
||||
// A capped leg holds longer: 5 contracts on NQ vs a cap of 2.
|
||||
const free = expectedHoldHours({ leg: 5000, stopDollars: Infinity, contracts: futuresContracts(5000, 0), dailyMove: 5227 });
|
||||
const capped = expectedHoldHours({ leg: 5000, stopDollars: Infinity, contracts: futuresContracts(5000, 0, 2), dailyMove: 5227 });
|
||||
assert.ok(Math.abs(capped / free - 6.25) < 1e-9, `${capped} vs ${free}`);
|
||||
});
|
||||
|
||||
test('instrument limits off the catalog: CME roots, whole numbers 1-1000', () => {
|
||||
assert.deepEqual(sanitizeInstrumentLimits({ NQ: 15, CL: 5, GC: 0, ES: 2.5, ZZ: 3, SI: 2000, YM: '4' }), { NQ: 15, CL: 5 });
|
||||
assert.deepEqual(sanitizeInstrumentLimits(null), {});
|
||||
assert.deepEqual(sanitizeInstrumentLimits(['NQ']), {});
|
||||
});
|
||||
|
||||
test('Topstep commission: every root, a micro at half the full-size rate', () => {
|
||||
assert.deepEqual(Object.keys(TOPSTEP_COMMISSION_PER_SIDE).sort(), [...CME_ROOTS].sort());
|
||||
for (const root of CME_ROOTS) assert.equal(TOPSTEP_COMMISSION_PER_SIDE[root], CME_SPECS[root].micro ? 0.25 : 0.5, root);
|
||||
});
|
||||
|
||||
test('Topstep commission: TopstepX contract ids to roots', () => {
|
||||
// Codes seen on real fills.
|
||||
assert.equal(topstepxRoot('CON.F.US.ENQ.Z26'), 'NQ');
|
||||
assert.equal(topstepxRoot('CON.F.US.MNQ.Z26'), 'MNQ');
|
||||
assert.equal(topstepxRoot('CON.F.US.GCE.Z26'), 'GC');
|
||||
assert.equal(topstepxRoot('CON.F.US.CLE.X26'), 'CL');
|
||||
assert.equal(topstepxProductCode('CON.F.US.ENQ.Z26'), 'ENQ');
|
||||
// Not a contract id, or a product with no entry.
|
||||
assert.equal(topstepxRoot('NQZ6'), null);
|
||||
assert.equal(topstepxRoot(undefined), null);
|
||||
assert.equal(topstepxRoot('CON.F.US.MBT.Z26'), null);
|
||||
// A contract the client resolved names its own code; a stray key doesn't.
|
||||
assert.equal(topstepxRoot('CON.F.US.MBT.Z26', new Map([['MBT', 'MES']])), 'MES');
|
||||
assert.equal(topstepxRoot('CON.F.US.ENQ.Z26', new Map([['ENQ', '*']])), 'NQ');
|
||||
});
|
||||
|
||||
test('Topstep commission: reported fees plus commission make the published round turn', () => {
|
||||
// TopstepX reports exchange + NFA a side; Topstep's page totals a round turn.
|
||||
const roundTurn = (root, reportedPerSide, contracts) =>
|
||||
Math.round((2 * reportedPerSide * contracts + 2 * topstepCommission(root, contracts)) * 100) / 100;
|
||||
assert.equal(roundTurn('NQ', 1.39, 1), 3.78);
|
||||
assert.equal(roundTurn('MNQ', 0.36, 1), 1.22);
|
||||
assert.equal(roundTurn('GC', 1.66, 1), 4.32);
|
||||
assert.equal(roundTurn('CL', 1.51, 1), 4.02);
|
||||
assert.equal(roundTurn('NQ', 1.39, 3), 11.34);
|
||||
// A sell fill's size is still a cost; an unknown product pays the full-size rate.
|
||||
assert.equal(topstepCommission('NQ', -3), 1.5);
|
||||
assert.equal(topstepCommission(null, 2), 1);
|
||||
// 2026-10-18, an XFA $27.50 under its books: 26 full-size round turns and 3 MNQ.
|
||||
const missing = 26 * 2 * topstepCommission('NQ', 1) + 3 * 2 * topstepCommission('MNQ', 1);
|
||||
assert.equal(missing, 27.5);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,517 @@
|
||||
// Checks for kept rules — which of an account type's rule sets an account
|
||||
// trades on (lib/kept-rules.ts).
|
||||
//
|
||||
// npm run check
|
||||
//
|
||||
// lib/kept-rules.ts has only `import type`s, so Node strips its types and
|
||||
// loads it directly; .mjs keeps this file out of Next's type check.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
RULE_FIELDS, IDENTITY_FIELDS, PROJECTION_FIELDS, normalizeAccountType, pickKeptRules, overlayKept, ruleDiffs,
|
||||
stampRulesDate, evidenceRulesDate, lowersRulesDate, sortableIso, chicagoDay, chicagoDayStart, evidenceDay,
|
||||
openingEvidence, identityStillMatches, pickedDayRulesDate, unchangedDaySaves,
|
||||
} from '../lib/kept-rules.ts';
|
||||
|
||||
const LIVE = {
|
||||
id: 'live-x', firmId: 'firm-1', label: '50K Eval', prefix: 'TDFY', accountSize: 50_000,
|
||||
profitTarget: 2500, consistency: 0.4, minDayPnL: 150, minTradingDays: 3, maxLoss: 2000, maxPositionSize: 5,
|
||||
targetSameEquity: false, splitEnabled: true, minLegs: 2, firstDayStop: true, minDayStop: false, combineCmeHolidays: false,
|
||||
withdrawalStages: [{ profit: 2000, consistency: 0.4, minTradingDays: 5, drain: false, minDayPnL: 0 }],
|
||||
payoutable: true, trailingDrawdown: 2000, expectedPayout: 1000, withdrawalPct: 0.5, payoutCap: 3000, profitSplit: 0.9,
|
||||
dailyLossLimit: 1000, stagesSameDay: false, tradesEveryDay: false, updatedAt: '2026-10-18T14:05:00.000Z',
|
||||
};
|
||||
|
||||
/** `obj` without `keys`. */
|
||||
const omit = (obj, keys) => Object.fromEntries(Object.entries(obj).filter(([k]) => !keys.includes(k)));
|
||||
|
||||
/** A kept set as the master builds it: the live type, the old rules on top. */
|
||||
function keptOf(rules, window, id = 'kept-1') {
|
||||
return {
|
||||
...LIVE, ...rules, id, keptFrom: LIVE.id, createdAt: '2026-01-01T00:00:00.000Z',
|
||||
openedAt: window.openedAt, closedAt: window.closedAt,
|
||||
};
|
||||
}
|
||||
|
||||
// ── field classes ───────────────────────────────────────────────────────────
|
||||
|
||||
test('field classes: the spec\'s lists, identical to the master\'s, and disjoint', () => {
|
||||
assert.deepEqual([...RULE_FIELDS], [
|
||||
'profitTarget', 'consistency', 'minDayPnL', 'minTradingDays', 'maxLoss', 'maxPositionSize', 'targetSameEquity',
|
||||
'splitEnabled', 'minLegs', 'firstDayStop', 'minDayStop', 'combineCmeHolidays', 'withdrawalStages', 'payoutable',
|
||||
'trailingDrawdown', 'dailyLossLimit', 'stagesSameDay',
|
||||
]);
|
||||
assert.deepEqual([...IDENTITY_FIELDS], ['prefix', 'accountSize']);
|
||||
assert.deepEqual([...PROJECTION_FIELDS], ['expectedPayout', 'withdrawalPct', 'payoutCap', 'profitSplit']);
|
||||
const all = [...RULE_FIELDS, ...IDENTITY_FIELDS, ...PROJECTION_FIELDS, 'label'];
|
||||
assert.equal(new Set(all).size, all.length);
|
||||
// Every field of a catalog account type is in exactly one class, or is
|
||||
// bookkeeping the live type always owns.
|
||||
const bookkeeping = ['id', 'firmId', 'updatedAt', 'tradesEveryDay'];
|
||||
assert.deepEqual(Object.keys(LIVE).filter((k) => !all.includes(k) && !bookkeeping.includes(k)), []);
|
||||
});
|
||||
|
||||
// ── normalizeAccountType: exactly the old validatePayload callback ──────────
|
||||
|
||||
/** lib/firm-catalog.ts validatePayload's per-type callback before the move
|
||||
* (git show HEAD:lib/firm-catalog.ts), verbatim but for the types. */
|
||||
function legacyNormalize(t, j, tradesEveryDay) {
|
||||
const nonNegative = (value) => (typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0);
|
||||
const isStages = (value) => Array.isArray(value) && value.every((s) =>
|
||||
typeof s === 'object' && s !== null
|
||||
&& typeof s.profit === 'number'
|
||||
&& typeof s.consistency === 'number'
|
||||
&& typeof s.minTradingDays === 'number');
|
||||
if (typeof t !== 'object' || t === null) throw new Error(`account type ${j} is not an object`);
|
||||
const type = t;
|
||||
for (const key of ['id', 'firmId', 'label', 'prefix', 'updatedAt']) {
|
||||
if (typeof type[key] !== 'string') throw new Error(`account type ${j} is missing ${key}`);
|
||||
}
|
||||
for (const key of [
|
||||
'accountSize', 'profitTarget', 'consistency', 'minDayPnL',
|
||||
'minTradingDays', 'maxLoss', 'maxPositionSize',
|
||||
]) {
|
||||
if (typeof type[key] !== 'number') throw new Error(`account type ${j} is missing ${key}`);
|
||||
}
|
||||
if (!isStages(type.withdrawalStages)) throw new Error(`account type ${j} has bad withdrawalStages`);
|
||||
return {
|
||||
...type,
|
||||
splitEnabled: type.splitEnabled !== false,
|
||||
combineCmeHolidays: type.combineCmeHolidays === true && !tradesEveryDay,
|
||||
minLegs: typeof type.minLegs === 'number' ? type.minLegs : 0,
|
||||
firstDayStop: type.firstDayStop === true,
|
||||
minDayStop: type.minDayStop === true,
|
||||
dailyLossLimit: nonNegative(type.dailyLossLimit),
|
||||
stagesSameDay: type.stagesSameDay === true,
|
||||
tradesEveryDay,
|
||||
};
|
||||
}
|
||||
|
||||
function outcome(fn) {
|
||||
try {
|
||||
return { ok: JSON.stringify(fn()) };
|
||||
} catch (err) {
|
||||
return { error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
test('normalizeAccountType is the old per-type validation, byte for byte (values, key order, errors)', () => {
|
||||
const wire = omit(LIVE, ['tradesEveryDay']);
|
||||
const variants = [
|
||||
wire,
|
||||
// An old master: none of the lenient fields.
|
||||
omit(wire, ['splitEnabled', 'minLegs', 'firstDayStop', 'minDayStop', 'combineCmeHolidays', 'dailyLossLimit']),
|
||||
{ ...wire, splitEnabled: false, combineCmeHolidays: true, minLegs: '3', firstDayStop: 'yes', dailyLossLimit: -5 },
|
||||
{ ...wire, dailyLossLimit: NaN, minDayStop: true, extra: { nested: [1, 2] } },
|
||||
{ ...wire, withdrawalStages: [] },
|
||||
// Malformed: each must throw the same message.
|
||||
null, 'type', 42,
|
||||
{ ...wire, id: 7 }, { ...wire, label: undefined }, { ...wire, updatedAt: null },
|
||||
{ ...wire, accountSize: '50000' }, { ...wire, maxPositionSize: undefined },
|
||||
{ ...wire, withdrawalStages: [{ profit: 1, consistency: 0.5 }] }, { ...wire, withdrawalStages: 'none' },
|
||||
{ ...wire, withdrawalStages: [null] },
|
||||
];
|
||||
let errors = 0;
|
||||
for (const tradesEveryDay of [false, true]) {
|
||||
variants.forEach((v, j) => {
|
||||
const legacy = outcome(() => legacyNormalize(v, j, tradesEveryDay));
|
||||
const now = outcome(() => normalizeAccountType(v, { tradesEveryDay, where: `account type ${j}` }));
|
||||
assert.deepEqual(now, legacy, `variant ${j}, every day ${tradesEveryDay}`);
|
||||
if (legacy.error) errors++;
|
||||
});
|
||||
}
|
||||
assert.equal(errors, 2 * 11);
|
||||
// The kept-set wording only changes the name in the message.
|
||||
assert.throws(() => normalizeAccountType({ ...wire, prefix: 1 }, { tradesEveryDay: false, where: 'kept rules 0 of Tradeify' }),
|
||||
{ message: 'kept rules 0 of Tradeify is missing prefix' });
|
||||
});
|
||||
|
||||
// ── pickKeptRules ───────────────────────────────────────────────────────────
|
||||
|
||||
const G1 = { id: 'g1', openedAt: null, closedAt: '2026-10-02T15:00:00.000Z' };
|
||||
const G2 = { id: 'g2', openedAt: '2026-10-02T15:00:00.000Z', closedAt: '2026-10-18T14:05:00.000Z' };
|
||||
const G3 = { id: 'g3', openedAt: '2026-10-18T14:05:00.000Z', closedAt: '2026-10-18T19:30:00.000Z' };
|
||||
const GENERATIONS = [G1, G2, G3];
|
||||
|
||||
test('pickKeptRules: the window holding the date, [openedAt, closedAt)', () => {
|
||||
const pick = (d, sets = GENERATIONS) => pickKeptRules(sets, d)?.id ?? null;
|
||||
// No lower bound on the first generation.
|
||||
assert.equal(pick('2020-01-01T00:00:00.000Z'), 'g1');
|
||||
assert.equal(pick('2026-10-02T14:59:59.999Z'), 'g1');
|
||||
// A date exactly at a cutoff is on the newer side: closedAt excluded,
|
||||
// openedAt included.
|
||||
assert.equal(pick('2026-10-02T15:00:00.000Z'), 'g2');
|
||||
assert.equal(pick('2026-10-18T14:04:59.999Z'), 'g2');
|
||||
assert.equal(pick('2026-10-18T14:05:00.000Z'), 'g3');
|
||||
assert.equal(pick('2026-10-18T19:29:59.999Z'), 'g3');
|
||||
// From the last cutoff on: the current rules.
|
||||
assert.equal(pick('2026-10-18T19:30:00.000Z'), null);
|
||||
assert.equal(pick('2027-01-01T00:00:00.000Z'), null);
|
||||
// Compared as times, not strings: other ISO spellings of the same instant.
|
||||
assert.equal(pick('2026-10-02T10:00:00-05:00'), 'g2');
|
||||
assert.equal(pick('2026-10-02T09:59:59.999-05:00'), 'g1');
|
||||
// Order of the list doesn't matter for disjoint windows.
|
||||
assert.equal(pick('2026-10-10T00:00:00.000Z', [G3, G1, G2]), 'g2');
|
||||
// Returns the set itself (the generic carries through).
|
||||
assert.equal(pickKeptRules(GENERATIONS, '2026-10-10T00:00:00.000Z'), G2);
|
||||
});
|
||||
|
||||
test('pickKeptRules: no date, no sets, or an unreadable date — the current rules', () => {
|
||||
assert.equal(pickKeptRules(GENERATIONS, null), null);
|
||||
assert.equal(pickKeptRules(GENERATIONS, ''), null);
|
||||
assert.equal(pickKeptRules(GENERATIONS, 'not a date'), null);
|
||||
assert.equal(pickKeptRules(undefined, '2026-10-10T00:00:00.000Z'), null);
|
||||
assert.equal(pickKeptRules([], '2026-10-10T00:00:00.000Z'), null);
|
||||
// An unreadable bound never matches (the catalog parse rejects them anyway).
|
||||
assert.equal(pickKeptRules([{ id: 'bad', openedAt: null, closedAt: 'soon' }], '2020-01-01T00:00:00.000Z'), null);
|
||||
assert.equal(pickKeptRules([{ id: 'bad', openedAt: 'once', closedAt: '2030-01-01T00:00:00.000Z' }], '2026-01-01T00:00:00.000Z'), null);
|
||||
// An empty window (openedAt == closedAt) holds nothing.
|
||||
assert.equal(pickKeptRules([{ id: 'empty', openedAt: G2.closedAt, closedAt: G2.closedAt }], G2.closedAt), null);
|
||||
});
|
||||
|
||||
// ── overlayKept ─────────────────────────────────────────────────────────────
|
||||
|
||||
test('overlayKept: the kept rules on top; identity, label, projections and bookkeeping stay live', () => {
|
||||
const oldRules = {
|
||||
profitTarget: 3000, consistency: 0.5, minDayPnL: 200, minTradingDays: 5, maxLoss: 2500, maxPositionSize: 3,
|
||||
targetSameEquity: true, splitEnabled: false, minLegs: 0, firstDayStop: false, minDayStop: true, combineCmeHolidays: true,
|
||||
withdrawalStages: [], payoutable: false, trailingDrawdown: 2500, dailyLossLimit: 0, stagesSameDay: false,
|
||||
};
|
||||
// A master that sent other identity/projection values on the kept set:
|
||||
// they must not leak through.
|
||||
const kept = keptOf({ ...oldRules, label: 'OLD', prefix: 'OLD', accountSize: 1, expectedPayout: 1, withdrawalPct: 1,
|
||||
payoutCap: 1, profitSplit: 1, firmId: 'other', updatedAt: '1999-01-01T00:00:00.000Z', tradesEveryDay: true }, G2);
|
||||
const liveBefore = structuredClone(LIVE);
|
||||
const keptBefore = structuredClone(kept);
|
||||
const eff = overlayKept(LIVE, kept);
|
||||
|
||||
for (const field of RULE_FIELDS) assert.deepEqual(eff[field], oldRules[field], field);
|
||||
for (const field of ['id', 'firmId', 'label', 'prefix', 'accountSize', 'updatedAt', 'tradesEveryDay', ...PROJECTION_FIELDS]) {
|
||||
assert.deepEqual(eff[field], LIVE[field], field);
|
||||
}
|
||||
assert.deepEqual(eff.keptRules, { id: 'kept-1', openedAt: G2.openedAt, closedAt: G2.closedAt });
|
||||
// Nothing of the kept set's own bookkeeping comes along.
|
||||
for (const key of ['keptFrom', 'openedAt', 'closedAt', 'createdAt']) assert.equal(key in eff, false, key);
|
||||
assert.deepEqual(Object.keys(eff).sort(), [...Object.keys(LIVE), 'keptRules'].sort());
|
||||
// Inputs untouched.
|
||||
assert.deepEqual(LIVE, liveBefore);
|
||||
assert.deepEqual(kept, keptBefore);
|
||||
});
|
||||
|
||||
test('overlayKept: a rule field the kept set lacks keeps the live value', () => {
|
||||
const kept = keptOf({ consistency: 0.5 }, G1);
|
||||
delete kept.payoutable;
|
||||
kept.trailingDrawdown = undefined;
|
||||
const eff = overlayKept(LIVE, kept);
|
||||
assert.equal(eff.consistency, 0.5);
|
||||
assert.equal(eff.payoutable, LIVE.payoutable);
|
||||
assert.equal(eff.trailingDrawdown, LIVE.trailingDrawdown);
|
||||
});
|
||||
|
||||
// ── ruleDiffs ───────────────────────────────────────────────────────────────
|
||||
|
||||
test('ruleDiffs: the rule fields that differ, raw values both sides', () => {
|
||||
assert.deepEqual(ruleDiffs(LIVE, LIVE), []);
|
||||
const eff = overlayKept(LIVE, keptOf({ consistency: 0.5, profitTarget: 3000 }, G1));
|
||||
assert.deepEqual(ruleDiffs(eff, LIVE), [
|
||||
{ field: 'profitTarget', kept: 3000, now: 2500 },
|
||||
{ field: 'consistency', kept: 0.5, now: 0.4 },
|
||||
]);
|
||||
// Non-rule fields never count.
|
||||
assert.deepEqual(ruleDiffs({ ...LIVE, label: 'x', prefix: 'y', profitSplit: 0.1 }, LIVE), []);
|
||||
const stages = [{ profit: 3000, consistency: 0.4, minTradingDays: 5, drain: false, minDayPnL: 0 }];
|
||||
assert.deepEqual(ruleDiffs({ ...LIVE, withdrawalStages: stages }, LIVE), [
|
||||
{ field: 'withdrawalStages', kept: stages, now: LIVE.withdrawalStages },
|
||||
]);
|
||||
});
|
||||
|
||||
test('ruleDiffs: stored JSON from before a field existed is not a change', () => {
|
||||
// A stage without drain / minDayPnL is what parseAccountTypeInput fills
|
||||
// with drain false, minDayPnL 0 (5 for a drain stage); key order is free.
|
||||
const bare = { ...LIVE, withdrawalStages: [{ minTradingDays: 5, consistency: 0.4, profit: 2000 }] };
|
||||
assert.deepEqual(ruleDiffs(bare, LIVE), []);
|
||||
const drainFilled = { ...LIVE, withdrawalStages: [{ profit: 0, consistency: 0, minTradingDays: 5, drain: true, minDayPnL: 5 }] };
|
||||
const drainBare = { ...LIVE, withdrawalStages: [{ profit: 0, consistency: 0, minTradingDays: 5, drain: true }] };
|
||||
assert.deepEqual(ruleDiffs(drainBare, drainFilled), []);
|
||||
// A per-stage payout override is a rule.
|
||||
assert.equal(ruleDiffs({ ...LIVE, withdrawalStages: [{ ...LIVE.withdrawalStages[0], payoutCap: 1500 }] }, LIVE).length, 1);
|
||||
// An absent optional flag or amount is its default.
|
||||
const noOptionals = omit(LIVE, ['payoutable', 'trailingDrawdown']);
|
||||
assert.deepEqual(ruleDiffs(noOptionals, { ...LIVE, payoutable: false, trailingDrawdown: 0 }), []);
|
||||
assert.deepEqual(ruleDiffs({ ...LIVE, splitEnabled: undefined }, LIVE), []);
|
||||
assert.deepEqual(ruleDiffs({ ...LIVE, splitEnabled: undefined }, { ...LIVE, splitEnabled: false }).map((d) => d.field), ['splitEnabled']);
|
||||
});
|
||||
|
||||
// ── rules dates ─────────────────────────────────────────────────────────────
|
||||
|
||||
test('stampRulesDate: the latest of this clock, the master\'s and the catalog\'s last change', () => {
|
||||
const now = Date.parse('2026-10-18T14:00:00.000Z');
|
||||
assert.equal(stampRulesDate(now, null, null), '2026-10-18T14:00:00.000Z');
|
||||
// This clock 5 minutes behind the master's.
|
||||
assert.equal(stampRulesDate(now, 5 * 60_000, null), '2026-10-18T14:05:00.000Z');
|
||||
// This clock ahead: its own time stands.
|
||||
assert.equal(stampRulesDate(now, -5 * 60_000, null), '2026-10-18T14:00:00.000Z');
|
||||
// The catalog changed after both: no new account dates before it.
|
||||
assert.equal(stampRulesDate(now, 60_000, '2026-10-18T14:07:30.000Z'), '2026-10-18T14:07:30.000Z');
|
||||
assert.equal(stampRulesDate(now, null, '2026-10-01T00:00:00.000Z'), '2026-10-18T14:00:00.000Z');
|
||||
// Unreadable inputs are ignored.
|
||||
assert.equal(stampRulesDate(now, NaN, 'never'), '2026-10-18T14:00:00.000Z');
|
||||
assert.equal(stampRulesDate(now, null, ''), '2026-10-18T14:00:00.000Z');
|
||||
});
|
||||
|
||||
test('stampRulesDate: a machine whose clock is behind never dates a new account inside the set just closed', () => {
|
||||
// The owner saves "new accounts only" at 14:05 master time; this machine's
|
||||
// clock reads 14:01 when it first sees a brand-new account at 14:05.
|
||||
const cutoff = '2026-10-18T14:05:00.000Z';
|
||||
const kept = [{ id: 'g', openedAt: null, closedAt: cutoff }];
|
||||
const local = Date.parse('2026-10-18T14:01:00.000Z');
|
||||
// Unclamped, the account would land in the old rules.
|
||||
assert.equal(pickKeptRules(kept, new Date(local).toISOString())?.id, 'g');
|
||||
// With the master's offset, or just the catalog's updatedAt, it doesn't.
|
||||
assert.equal(pickKeptRules(kept, stampRulesDate(local, 4 * 60_000, null)), null);
|
||||
assert.equal(pickKeptRules(kept, stampRulesDate(local, null, cutoff)), null);
|
||||
// A machine whose master offset isn't known yet still clamps to the catalog.
|
||||
assert.equal(pickKeptRules(kept, stampRulesDate(local, null, '2026-10-18T14:05:00.250Z')), null);
|
||||
});
|
||||
|
||||
test('evidenceRulesDate: the end of the first fund day in Chicago — CDT and CST', () => {
|
||||
// CDT (UTC−5): 23:59:59.999 CDT is 04:59:59.999Z the next day.
|
||||
assert.equal(evidenceRulesDate('2026-10-18'), '2026-10-19T04:59:59.999Z');
|
||||
assert.equal(evidenceRulesDate('2026-07-04'), '2026-07-05T04:59:59.999Z');
|
||||
// CST (UTC−6): 05:59:59.999Z the next day.
|
||||
assert.equal(evidenceRulesDate('2026-01-15'), '2026-01-16T05:59:59.999Z');
|
||||
assert.equal(evidenceRulesDate('2026-12-31'), '2027-01-01T05:59:59.999Z');
|
||||
assert.equal(evidenceRulesDate('2028-02-29'), '2028-03-01T05:59:59.999Z');
|
||||
for (const bad of [null, undefined, '', '2026-10-18T00:00:00Z', '18/10/2026', '2026-1-8', '2026-02-31', '2026-13-01', 20261018]) {
|
||||
assert.equal(evidenceRulesDate(bad), null, String(bad));
|
||||
}
|
||||
});
|
||||
|
||||
test('evidenceRulesDate: DST boundary days end on the right side of the change', () => {
|
||||
// 2026: CST→CDT at 2 AM on Sunday Mar 8, CDT→CST at 2 AM on Sunday Nov 1.
|
||||
assert.equal(evidenceRulesDate('2026-03-07'), '2026-03-08T05:59:59.999Z'); // last CST day
|
||||
assert.equal(evidenceRulesDate('2026-03-08'), '2026-03-09T04:59:59.999Z'); // springs forward: ends in CDT
|
||||
assert.equal(evidenceRulesDate('2026-03-09'), '2026-03-10T04:59:59.999Z');
|
||||
assert.equal(evidenceRulesDate('2026-10-31'), '2026-11-01T04:59:59.999Z'); // last CDT day
|
||||
assert.equal(evidenceRulesDate('2026-11-01'), '2026-11-02T05:59:59.999Z'); // falls back: ends in CST
|
||||
assert.equal(evidenceRulesDate('2026-11-02'), '2026-11-03T05:59:59.999Z');
|
||||
// The day before each boundary and the boundary day are 23 h / 25 h long
|
||||
// in Chicago, measured end to end.
|
||||
const len = (a, b) => (Date.parse(evidenceRulesDate(b)) - Date.parse(evidenceRulesDate(a))) / 3_600_000;
|
||||
assert.equal(len('2026-03-07', '2026-03-08'), 23);
|
||||
assert.equal(len('2026-10-31', '2026-11-01'), 25);
|
||||
assert.equal(len('2026-06-01', '2026-06-02'), 24);
|
||||
});
|
||||
|
||||
test('evidenceRulesDate: every day of 2026–2027 ends 1 ms before the next one starts, in Chicago', () => {
|
||||
for (let ms = Date.UTC(2026, 0, 1); ms < Date.UTC(2028, 0, 1); ms += 86_400_000) {
|
||||
const day = new Date(ms).toISOString().slice(0, 10);
|
||||
const next = new Date(ms + 86_400_000).toISOString().slice(0, 10);
|
||||
const end = evidenceRulesDate(day);
|
||||
assert.equal(Date.parse(chicagoDayStart(next)) - Date.parse(end), 1, day);
|
||||
// Both read back as their own Chicago day, and the end as evidence.
|
||||
assert.equal(chicagoDay(end), day);
|
||||
assert.equal(chicagoDay(chicagoDayStart(next)), next);
|
||||
assert.equal(evidenceDay(end), day);
|
||||
// Under either offset the end is 04:59:59.999Z or 05:59:59.999Z the next UTC day.
|
||||
assert.match(end, new RegExp(`^${next}T0[45]:59:59\\.999Z$`));
|
||||
}
|
||||
});
|
||||
|
||||
test('evidenceRulesDate: an account opened on the day of a change — even that evening — counts as after it', () => {
|
||||
// A change at 14:05 CDT on Oct 18: an account first funded that day is
|
||||
// after it; one funded the day before keeps the old rules.
|
||||
const afternoon = [{ id: 'g', openedAt: null, closedAt: '2026-10-18T19:05:00.000Z' }];
|
||||
assert.equal(pickKeptRules(afternoon, evidenceRulesDate('2026-10-18')), null);
|
||||
assert.equal(pickKeptRules(afternoon, evidenceRulesDate('2026-10-17'))?.id, 'g');
|
||||
// A change at 8 PM CDT (01:00Z the next UTC day): a UTC end of day
|
||||
// (Oct 18 23:59:59.999Z = 6:59 PM CDT) would have put an account that
|
||||
// opened that evening before it.
|
||||
const evening = [{ id: 'g', openedAt: null, closedAt: '2026-10-19T01:00:00.000Z' }];
|
||||
assert.equal(pickKeptRules(evening, evidenceRulesDate('2026-10-18')), null);
|
||||
assert.equal(pickKeptRules(evening, evidenceRulesDate('2026-10-17'))?.id, 'g');
|
||||
// Same under CST: a change at 11:30 PM CST Jan 15 (05:30Z Jan 16).
|
||||
const winter = [{ id: 'g', openedAt: null, closedAt: '2026-01-16T05:30:00.000Z' }];
|
||||
assert.equal(pickKeptRules(winter, evidenceRulesDate('2026-01-15')), null);
|
||||
assert.equal(pickKeptRules(winter, evidenceRulesDate('2026-01-14'))?.id, 'g');
|
||||
});
|
||||
|
||||
test('chicagoDayStart / chicagoDay / evidenceDay', () => {
|
||||
assert.equal(chicagoDayStart('2026-10-18'), '2026-10-18T05:00:00.000Z');
|
||||
assert.equal(chicagoDayStart('2026-01-15'), '2026-01-15T06:00:00.000Z');
|
||||
assert.equal(chicagoDayStart('2026-03-08'), '2026-03-08T06:00:00.000Z'); // midnight is still CST
|
||||
assert.equal(chicagoDayStart('2026-11-01'), '2026-11-01T05:00:00.000Z'); // midnight is still CDT
|
||||
assert.equal(chicagoDayStart('2026-02-31'), null);
|
||||
assert.equal(chicagoDayStart('1999-12-31'), null); // before 2000: not a rules date
|
||||
assert.equal(chicagoDay('2026-10-19T04:59:59.999Z'), '2026-10-18');
|
||||
assert.equal(chicagoDay('2026-10-19T05:00:00.000Z'), '2026-10-19');
|
||||
assert.equal(chicagoDay('nope'), null);
|
||||
// Only the exact end of a Chicago day reads as evidence.
|
||||
assert.equal(evidenceDay('2026-10-19T04:59:59.999Z'), '2026-10-18');
|
||||
assert.equal(evidenceDay('2026-10-19T04:59:59.998Z'), null);
|
||||
assert.equal(evidenceDay('2026-10-18T23:59:59.999Z'), null); // the old UTC shape
|
||||
assert.equal(evidenceDay('2026-10-18T19:05:00.000Z'), null);
|
||||
});
|
||||
|
||||
test('sortableIso: plain four-digit-year ISO from 2000 on, else null', () => {
|
||||
assert.equal(sortableIso(Date.parse('2026-10-18T14:05:00Z')), '2026-10-18T14:05:00.000Z');
|
||||
assert.equal(sortableIso(Date.UTC(2000, 0, 1)), '2000-01-01T00:00:00.000Z');
|
||||
assert.equal(sortableIso(Date.UTC(2000, 0, 1) - 1), null);
|
||||
assert.equal(sortableIso(0), null);
|
||||
assert.equal(sortableIso(Date.UTC(9999, 11, 31, 23, 59, 59, 999)), '9999-12-31T23:59:59.999Z');
|
||||
assert.equal(sortableIso(Date.parse('+010000-01-01T00:00:00Z')), null); // would print as +010000-…
|
||||
assert.equal(sortableIso(8.64e15 + 1), null);
|
||||
assert.equal(sortableIso(NaN), null);
|
||||
assert.equal(sortableIso(Infinity), null);
|
||||
});
|
||||
|
||||
test('openingEvidence: a fund day ends in Chicago; the registration fallback is the instant itself', () => {
|
||||
// A positive fund row's day: the end of it in Chicago, whatever the registration.
|
||||
assert.equal(openingEvidence('2026-10-02', Date.parse('2026-09-01T00:00:00Z')), '2026-10-03T04:59:59.999Z');
|
||||
// Positive rows present but unreadable: no evidence (never the registration).
|
||||
assert.equal(openingEvidence('2026-02-31', Date.parse('2026-09-01T00:00:00Z')), null);
|
||||
// No positive rows: the registration instant, exactly.
|
||||
assert.equal(openingEvidence(undefined, Date.parse('2026-10-19T02:00:00Z')), '2026-10-19T02:00:00.000Z');
|
||||
// Unreadable or implausible registrations: none.
|
||||
for (const bad of [undefined, null, NaN, 0, 5, -1, Infinity, Date.parse('1999-12-31T23:00:00Z'), 9e15]) {
|
||||
assert.equal(openingEvidence(undefined, bad), null, String(bad));
|
||||
}
|
||||
});
|
||||
|
||||
test('openingEvidence: a DXtrade account registered after an evening cutoff is never lowered before it', () => {
|
||||
// "New accounts only" saved at 8 PM CDT Oct 18 (01:00Z Oct 19); a DX
|
||||
// account registered at 9 PM CDT (02:00Z) with no DEPOSIT row.
|
||||
const kept = [{ id: 'g', openedAt: null, closedAt: '2026-10-19T01:00:00.000Z' }];
|
||||
const stamped = '2026-10-19T02:00:05.000Z';
|
||||
const evidence = openingEvidence(undefined, Date.parse('2026-10-19T02:00:00Z'));
|
||||
const lowered = lowersRulesDate({ rules_date: stamped, rules_date_manual: 0 }, evidence) ? evidence : stamped;
|
||||
assert.equal(pickKeptRules(kept, lowered), null);
|
||||
// And at 9 PM CDT Oct 20 against an 8 PM cutoff that day.
|
||||
const kept2 = [{ id: 'g', openedAt: null, closedAt: '2026-10-21T01:00:00.000Z' }];
|
||||
assert.equal(pickKeptRules(kept2, openingEvidence(undefined, Date.parse('2026-10-21T02:00:00Z'))), null);
|
||||
// One registered before the cutoff, first seen after it, goes back to the old rules.
|
||||
const before = openingEvidence(undefined, Date.parse('2026-10-19T00:30:00Z'));
|
||||
assert.equal(lowersRulesDate({ rules_date: stamped, rules_date_manual: 0 }, before), true);
|
||||
assert.equal(pickKeptRules(kept, before)?.id, 'g');
|
||||
});
|
||||
|
||||
test('identityStillMatches: prefix and the cached size against the live type', () => {
|
||||
const live = { prefix: 'TDFY', accountSize: 50_000 };
|
||||
assert.equal(identityStillMatches('TDFY1234', '50000', live), true);
|
||||
assert.equal(identityStillMatches('TDFY1234', '50000.004', live), true);
|
||||
// A size edit on the type (50K → 100K, or → $0-start): no longer its size.
|
||||
assert.equal(identityStillMatches('TDFY1234', '50000', { ...live, accountSize: 100_000 }), false);
|
||||
assert.equal(identityStillMatches('TDFY1234', '50000', { ...live, accountSize: 0 }), false);
|
||||
// A prefix edit.
|
||||
assert.equal(identityStillMatches('TDFY1234', '50000', { ...live, prefix: 'TDFX' }), false);
|
||||
// A $0-start type caches 0 when it matches.
|
||||
assert.equal(identityStillMatches('XFA77', '0', { prefix: 'XFA', accountSize: 0 }), true);
|
||||
assert.equal(identityStillMatches('XFA77', '150000', { prefix: 'XFA', accountSize: 0 }), false);
|
||||
// Nothing cached, or junk: not provably the same account.
|
||||
assert.equal(identityStillMatches('TDFY1234', null, live), false);
|
||||
assert.equal(identityStillMatches('TDFY1234', 'abc', live), false);
|
||||
assert.equal(identityStillMatches('TDFY1234', '', { ...live, accountSize: 0 }), false);
|
||||
});
|
||||
|
||||
test('pickedDayRulesDate: today is now; another day its start, never before Chicago\'s', () => {
|
||||
const nowMs = Date.parse('2026-10-18T19:30:00.000Z');
|
||||
const at = (localMidnightMs, today = '2026-10-18') => ({ today, nowMs, localMidnightMs });
|
||||
// Today: now, whatever the zone.
|
||||
assert.equal(pickedDayRulesDate('2026-10-18', at(Date.parse('2026-10-18T05:00:00Z'))), '2026-10-18T19:30:00.000Z');
|
||||
// Chicago browser: its own midnight.
|
||||
assert.equal(pickedDayRulesDate('2026-10-15', at(Date.parse('2026-10-15T05:00:00Z'))), '2026-10-15T05:00:00.000Z');
|
||||
// Pacific browser: local midnight is later than Chicago's — stands.
|
||||
assert.equal(pickedDayRulesDate('2026-10-15', at(Date.parse('2026-10-15T07:00:00Z'))), '2026-10-15T07:00:00.000Z');
|
||||
// London (BST), Eastern and Karachi browsers: raised to Chicago's midnight.
|
||||
for (const local of ['2026-10-14T23:00:00Z', '2026-10-15T04:00:00Z', '2026-10-14T19:00:00Z']) {
|
||||
assert.equal(pickedDayRulesDate('2026-10-15', at(Date.parse(local))), '2026-10-15T05:00:00.000Z', local);
|
||||
}
|
||||
assert.equal(pickedDayRulesDate('2026-02-31', at(0)), null);
|
||||
assert.equal(pickedDayRulesDate('2026-10-15', at(NaN)), null);
|
||||
});
|
||||
|
||||
test('pickedDayRulesDate: a picked day never lands inside the evidence of the day before', () => {
|
||||
// A browser at UTC+1 picks Oct 15 for a set closing at Chicago midnight
|
||||
// of Oct 15: an account first funded Oct 14 stays on the old rules, the
|
||||
// hand-dated one on Oct 15 does not.
|
||||
const kept = [{ id: 'g', openedAt: null, closedAt: chicagoDayStart('2026-10-15') }];
|
||||
const picked = pickedDayRulesDate('2026-10-15', {
|
||||
today: '2026-10-18', nowMs: Date.parse('2026-10-18T12:00:00Z'), localMidnightMs: Date.parse('2026-10-14T23:00:00Z'),
|
||||
});
|
||||
assert.equal(pickKeptRules(kept, evidenceRulesDate('2026-10-14'))?.id, 'g');
|
||||
assert.equal(pickKeptRules(kept, picked), null);
|
||||
for (let ms = Date.UTC(2026, 0, 2); ms < Date.UTC(2027, 0, 1); ms += 86_400_000) {
|
||||
const day = new Date(ms).toISOString().slice(0, 10);
|
||||
const prev = new Date(ms - 86_400_000).toISOString().slice(0, 10);
|
||||
for (const offsetH of [-10, -8, -6, -5, -4, 0, 1, 5, 9, 14]) {
|
||||
const localMidnightMs = Date.parse(`${day}T00:00:00Z`) - offsetH * 3_600_000;
|
||||
const stored = pickedDayRulesDate(day, { today: '2030-01-01', nowMs: 0, localMidnightMs });
|
||||
assert.ok(stored > evidenceRulesDate(prev), `${day} at UTC${offsetH}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('unchangedDaySaves: an untouched day saves only when it moves the account to other rules', () => {
|
||||
// A change at 2:05 PM CDT today (19:05Z) with the default cutoff "now".
|
||||
const kept = [{ id: 'g', openedAt: null, closedAt: '2026-10-18T19:05:00.000Z' }];
|
||||
const at = { today: '2026-10-18', nowMs: Date.parse('2026-10-18T19:30:00Z'), localMidnightMs: Date.parse('2026-10-18T05:00:00Z') };
|
||||
// Dated this morning (old rules): ✓ on today stores now → current rules.
|
||||
const now = pickedDayRulesDate('2026-10-18', at);
|
||||
assert.equal(unchangedDaySaves(kept, '2026-10-18T15:00:00.000Z', now), true);
|
||||
assert.equal(pickKeptRules(kept, now), null);
|
||||
// Already after the change: nothing to save.
|
||||
assert.equal(unchangedDaySaves(kept, '2026-10-18T19:10:00.000Z', now), false);
|
||||
// A past day's start lands where the date already is: nothing to save.
|
||||
const oct2 = pickedDayRulesDate('2026-10-02', at);
|
||||
assert.equal(unchangedDaySaves(kept, evidenceRulesDate('2026-10-02'), oct2), false);
|
||||
// Windows not known (still loading, no firm): never.
|
||||
assert.equal(unchangedDaySaves(null, '2026-10-18T15:00:00.000Z', now), false);
|
||||
// The editor opened on Oct 18 (current rules) and a poll since lowered the
|
||||
// date to Oct 2: an untouched ✓ compares against the opened date, so it
|
||||
// doesn't pin Oct 18 by hand over the evidence.
|
||||
const sets = [{ id: 'g2', openedAt: null, closedAt: '2026-10-10T00:00:00.000Z' }];
|
||||
const oct18 = pickedDayRulesDate('2026-10-18', { ...at, today: '2026-10-19' });
|
||||
assert.equal(unchangedDaySaves(sets, '2026-10-18T15:00:00.000Z', oct18), false);
|
||||
});
|
||||
|
||||
test('lowersRulesDate: only earlier, only auto dates — the test lowerAutoRulesDate runs in SQL', () => {
|
||||
const evidence = '2026-10-01T23:59:59.999Z';
|
||||
assert.equal(lowersRulesDate({ rules_date: '2026-10-18T14:00:00.000Z', rules_date_manual: 0 }, evidence), true);
|
||||
assert.equal(lowersRulesDate({ rules_date: null, rules_date_manual: 0 }, evidence), true);
|
||||
assert.equal(lowersRulesDate({ rules_date: evidence, rules_date_manual: 0 }, evidence), false);
|
||||
assert.equal(lowersRulesDate({ rules_date: '2026-09-01T00:00:00.000Z', rules_date_manual: 0 }, evidence), false);
|
||||
// Set by hand: never moved.
|
||||
assert.equal(lowersRulesDate({ rules_date: '2026-10-18T14:00:00.000Z', rules_date_manual: 1 }, evidence), false);
|
||||
assert.equal(lowersRulesDate({ rules_date: null, rules_date_manual: 1 }, evidence), false);
|
||||
// String order is time order for the two formats that are ever stored.
|
||||
assert.equal('2026-10-01T23:59:59.999Z' < '2026-10-02T00:00:00.000Z', true);
|
||||
assert.equal(evidenceRulesDate('2026-10-02') > new Date(Date.parse('2026-10-02T23:59:59.998Z')).toISOString(), true);
|
||||
});
|
||||
|
||||
// ── the whole selection, as getAccountConfig runs it ────────────────────────
|
||||
|
||||
test('selection: generations, boundaries and clamps end to end', () => {
|
||||
const sets = [
|
||||
keptOf({ profitTarget: 3000, consistency: 0.5 }, G1, 'g1'),
|
||||
keptOf({ profitTarget: 2800, consistency: 0.45 }, G2, 'g2'),
|
||||
];
|
||||
const rulesFor = (rulesDate) => {
|
||||
const kept = pickKeptRules(sets, rulesDate);
|
||||
return kept ? overlayKept(LIVE, kept) : LIVE;
|
||||
};
|
||||
const cases = [
|
||||
[null, 2500, undefined],
|
||||
['2026-09-01T00:00:00.000Z', 3000, 'g1'],
|
||||
[G1.closedAt, 2800, 'g2'],
|
||||
[G2.closedAt, 2500, undefined],
|
||||
[evidenceRulesDate('2026-10-02'), 2800, 'g2'],
|
||||
[evidenceRulesDate('2026-10-01'), 3000, 'g1'],
|
||||
];
|
||||
for (const [date, target, keptId] of cases) {
|
||||
const eff = rulesFor(date);
|
||||
assert.equal(eff.profitTarget, target, String(date));
|
||||
assert.equal(eff.keptRules?.id, keptId, String(date));
|
||||
// Always reported as the live type.
|
||||
assert.equal(eff.id, LIVE.id);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
// Real local HTTP requests and WebSocket handshakes. Never use the live DB
|
||||
// or contact a broker. The existing check suite uses Node's type stripping.
|
||||
import { after, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { registerHooks } from 'node:module';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createServer } from 'node:http';
|
||||
import { connect } from 'node:net';
|
||||
import { once } from 'node:events';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import { HubConnectionBuilder, HttpTransportType, LogLevel } from '@microsoft/signalr';
|
||||
|
||||
// Resolve the extensionless local imports used by Next, without a build.
|
||||
function resolveTypes(specifier, context, nextResolve) {
|
||||
try {
|
||||
return nextResolve(specifier, context);
|
||||
} catch (err) {
|
||||
if (err.code !== 'ERR_MODULE_NOT_FOUND' || !specifier.startsWith('.')) throw err;
|
||||
return nextResolve(`${specifier}.ts`, context);
|
||||
}
|
||||
}
|
||||
registerHooks({ resolve: resolveTypes });
|
||||
|
||||
const directory = mkdtempSync(join(tmpdir(), 'autofirmer-user-agent-'));
|
||||
process.env.AUTOFIRMER_DB = join(directory, 'test.sqlite');
|
||||
delete process.env.NEXT_PHASE;
|
||||
after(() => rmSync(directory, { recursive: true, force: true }));
|
||||
|
||||
const { getSetting, setSetting } = await import('../lib/db.ts');
|
||||
// Do not let inherited environment proxy settings route local test traffic.
|
||||
process.env.NO_PROXY = '*';
|
||||
setSetting('proxy_url', '');
|
||||
const { getRequestUserAgent } = await import('../lib/request-user-agent.ts');
|
||||
const { default: axios } = await import('../lib/http-client.ts');
|
||||
const { brokerWebSocket, directWebSocket, signalrWebSocket } = await import('../lib/outbound-proxy.ts');
|
||||
const userAgent = getRequestUserAgent();
|
||||
|
||||
async function listen(server) {
|
||||
server.listen(0, '127.0.0.1');
|
||||
await once(server, 'listening');
|
||||
return `http://127.0.0.1:${server.address().port}`;
|
||||
}
|
||||
|
||||
async function socketHeaders(makeSocket, server, url, path = '/') {
|
||||
const accepted = once(server, 'connection');
|
||||
const client = makeSocket(url.replace('http:', 'ws:') + path);
|
||||
const opened = new Promise((resolve, reject) => {
|
||||
client.addEventListener('open', resolve, { once: true });
|
||||
client.addEventListener('error', reject, { once: true });
|
||||
});
|
||||
const [peer, request] = await accepted;
|
||||
try {
|
||||
await opened;
|
||||
return request.headers;
|
||||
} finally {
|
||||
client.close();
|
||||
peer.terminate();
|
||||
}
|
||||
}
|
||||
|
||||
test('first load saves a browser identity and another process reuses it', () => {
|
||||
assert.match(userAgent, /^Mozilla\/5\.0 .*(Chrome\/|Firefox\/)/);
|
||||
assert.equal(getSetting('user_agent'), userAgent);
|
||||
const value = execFileSync(process.execPath, [
|
||||
'--experimental-strip-types', '--disable-warning=MODULE_TYPELESS_PACKAGE_JSON',
|
||||
'--input-type=module', '-e',
|
||||
`import { registerHooks } from 'node:module';
|
||||
registerHooks({ resolve: ${resolveTypes.toString()} });
|
||||
const { getRequestUserAgent } = await import(${JSON.stringify(new URL('../lib/request-user-agent.ts', import.meta.url).href)});
|
||||
process.stdout.write(getRequestUserAgent());`,
|
||||
], { env: process.env, encoding: 'utf8' });
|
||||
assert.equal(value, userAgent);
|
||||
});
|
||||
|
||||
test('Axios calls, custom headers, and nested create clients use the saved identity', async () => {
|
||||
const server = createServer((req, res) => {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify(req.headers));
|
||||
});
|
||||
const url = await listen(server);
|
||||
try {
|
||||
const child = axios.create({ headers: { 'user-agent': 'old child identity' } });
|
||||
const grandchild = child.create({ headers: { 'User-Agent': false } });
|
||||
for (const client of [axios, child, grandchild]) {
|
||||
const result = await client.post(url, { hello: 'world' }, {
|
||||
headers: { 'uSeR-aGeNt': false, Authorization: 'Bearer local-test', 'X-Test': 'kept' },
|
||||
});
|
||||
assert.equal(result.data['user-agent'], userAgent);
|
||||
assert.equal(result.data.authorization, 'Bearer local-test');
|
||||
assert.equal(result.data['x-test'], 'kept');
|
||||
assert.equal(client.defaults.timeout, 60_000);
|
||||
}
|
||||
assert.equal((await axios(url)).data['user-agent'], userAgent);
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
});
|
||||
|
||||
test('direct, broker and SignalR handshakes preserve the saved identity and auth', async () => {
|
||||
const server = createServer();
|
||||
const wss = new WebSocketServer({ server });
|
||||
const url = await listen(server);
|
||||
try {
|
||||
for (const makeSocket of [directWebSocket, brokerWebSocket]) {
|
||||
assert.equal((await socketHeaders(makeSocket, wss, url))['user-agent'], userAgent);
|
||||
}
|
||||
const WS = signalrWebSocket();
|
||||
for (const overload of [false, true]) {
|
||||
const headers = await socketHeaders((address) => {
|
||||
const options = { headers: { 'user-agent': 'SignalR default', Cookie: 'session=local', Authorization: 'Bearer local-test' } };
|
||||
return overload ? new WS(address, options) : new WS(address, undefined, options);
|
||||
}, wss, url);
|
||||
assert.equal(headers['user-agent'], userAgent);
|
||||
assert.equal(headers.cookie, 'session=local');
|
||||
assert.equal(headers.authorization, 'Bearer local-test');
|
||||
}
|
||||
|
||||
// Exercise SignalR itself, including its generated UA/auth headers.
|
||||
wss.on('connection', (socket) => {
|
||||
socket.on('message', () => socket.send('{}\u001e'));
|
||||
});
|
||||
const hub = new HubConnectionBuilder().withUrl(url, {
|
||||
skipNegotiation: true,
|
||||
transport: HttpTransportType.WebSockets,
|
||||
WebSocket: WS,
|
||||
accessTokenFactory: () => 'signalr-test',
|
||||
}).configureLogging(LogLevel.None).build();
|
||||
const accepted = once(wss, 'connection');
|
||||
await hub.start();
|
||||
try {
|
||||
const [, request] = await accepted;
|
||||
assert.equal(request.headers['user-agent'], userAgent);
|
||||
assert.equal(request.headers.authorization, 'Bearer signalr-test');
|
||||
} finally {
|
||||
await hub.stop();
|
||||
}
|
||||
} finally {
|
||||
for (const socket of wss.clients) socket.terminate();
|
||||
await new Promise((resolve) => wss.close(resolve));
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
});
|
||||
|
||||
test('HTTP proxy keeps the identity on broker sockets while control sockets stay direct', async () => {
|
||||
const server = createServer();
|
||||
const wss = new WebSocketServer({ server });
|
||||
const url = await listen(server);
|
||||
let tunnels = 0;
|
||||
const connections = new Set();
|
||||
const proxy = createServer((req, res) => {
|
||||
// ws with HttpProxyAgent uses an absolute-form upgrade; handled below.
|
||||
res.writeHead(500).end();
|
||||
});
|
||||
function tunnel(req, socket, head, isConnect) {
|
||||
tunnels++;
|
||||
const target = new URL(isConnect ? `http://${req.url}` : req.url);
|
||||
const upstream = connect(Number(target.port), target.hostname, () => {
|
||||
if (isConnect) socket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
||||
else {
|
||||
upstream.write(`${req.method} ${target.pathname} HTTP/1.1\r\n`);
|
||||
for (let i = 0; i < req.rawHeaders.length; i += 2) upstream.write(`${req.rawHeaders[i]}: ${req.rawHeaders[i + 1]}\r\n`);
|
||||
upstream.write('\r\n');
|
||||
}
|
||||
if (head.length) upstream.write(head);
|
||||
socket.pipe(upstream).pipe(socket);
|
||||
});
|
||||
connections.add(socket);
|
||||
connections.add(upstream);
|
||||
socket.on('error', () => upstream.destroy());
|
||||
upstream.on('error', () => socket.destroy());
|
||||
}
|
||||
proxy.on('connect', (req, socket, head) => tunnel(req, socket, head, true));
|
||||
proxy.on('upgrade', (req, socket, head) => tunnel(req, socket, head, false));
|
||||
const proxyUrl = await listen(proxy);
|
||||
setSetting('proxy_url', proxyUrl);
|
||||
delete globalThis.__outboundProxy;
|
||||
try {
|
||||
for (const makeSocket of [brokerWebSocket, (address) => new (signalrWebSocket())(address)]) {
|
||||
const before = tunnels;
|
||||
assert.equal((await socketHeaders(makeSocket, wss, url))['user-agent'], userAgent);
|
||||
assert.equal(tunnels, before + 1);
|
||||
}
|
||||
const before = tunnels;
|
||||
assert.equal((await socketHeaders(directWebSocket, wss, url))['user-agent'], userAgent);
|
||||
assert.equal(tunnels, before);
|
||||
} finally {
|
||||
await globalThis.__outboundProxy.dispatcher.destroy();
|
||||
globalThis.__outboundProxy.httpAgent.destroy();
|
||||
globalThis.__outboundProxy.httpsAgent.destroy();
|
||||
delete globalThis.__outboundProxy;
|
||||
setSetting('proxy_url', '');
|
||||
for (const socket of connections) socket.destroy();
|
||||
for (const socket of wss.clients) socket.terminate();
|
||||
await new Promise((resolve) => wss.close(resolve));
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
await new Promise((resolve) => proxy.close(resolve));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
// Checks for building trades out of fills (lib/fill-trades.ts — TopstepX and
|
||||
// Tradovate) and out of DXtrade's order history (lib/dxtrade-helpers.ts).
|
||||
//
|
||||
// npm run check
|
||||
//
|
||||
// The files import only Node built-ins, so Node strips their types and loads
|
||||
// them directly; .mjs keeps this file out of Next's type check.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { fillsToTrades, startingNet, contractRoot, byFillTime } from '../lib/fill-trades.ts';
|
||||
import { topstepxSymbol } from '../lib/topstep-fees.ts';
|
||||
import { parseHistoryOrder, dxTrades } from '../lib/dxtrade-helpers.ts';
|
||||
|
||||
let n = 0;
|
||||
const fill = (side, qty, price, extra = {}) => {
|
||||
n += 1;
|
||||
return { id: String(1000 + n), orderId: String(5000 + n), at: 1_000 + n * 1_000, side, qty, price, fee: 1, pnl: null, ...extra };
|
||||
};
|
||||
|
||||
test('a round trip, with the platform’s P&L', () => {
|
||||
const a = fill('buy', 2, 100);
|
||||
const b = fill('sell', 2, 101, { pnl: 40 });
|
||||
const [t, ...rest] = fillsToTrades([b, a], { startNet: 0, pointValue: null });
|
||||
assert.equal(rest.length, 0);
|
||||
assert.deepEqual(t, {
|
||||
id: a.id, side: 'long', quantity: 2, openPrice: 100, closePrice: 101, pnl: 40, fee: 2,
|
||||
openedAt: a.at, closedAt: b.at, openOrderId: a.orderId, closeOrderId: b.orderId, carried: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('P&L from the prices when the platform gives none; adds and partial closes stay one trade', () => {
|
||||
const fills = [fill('buy', 1, 100), fill('buy', 1, 102), fill('sell', 1, 103), fill('sell', 1, 104)];
|
||||
const [t, ...rest] = fillsToTrades(fills, { startNet: 0, pointValue: 20 });
|
||||
assert.equal(rest.length, 0);
|
||||
assert.equal(t.quantity, 2);
|
||||
assert.equal(t.openPrice, 101);
|
||||
assert.equal(t.closePrice, 103.5);
|
||||
assert.equal(t.pnl, 100);
|
||||
assert.equal(t.fee, 4);
|
||||
assert.equal(t.closeOrderId, fills[3].orderId);
|
||||
// No point value and no platform P&L: unknown, not zero.
|
||||
assert.equal(fillsToTrades(fills, { startNet: 0, pointValue: null })[0].pnl, null);
|
||||
});
|
||||
|
||||
test('a short', () => {
|
||||
const fills = [fill('sell', 3, 50.5), fill('buy', 3, 50)];
|
||||
const [t] = fillsToTrades(fills, { startNet: 0, pointValue: 1000 });
|
||||
assert.equal(t.side, 'short');
|
||||
assert.equal(t.pnl, 1500);
|
||||
});
|
||||
|
||||
test('a fill through zero closes one trade and opens the next', () => {
|
||||
const a = fill('buy', 1, 100);
|
||||
const b = fill('sell', 3, 105, { fee: 3, pnl: 100 });
|
||||
const [first, second, ...rest] = fillsToTrades([a, b], { startNet: 0, pointValue: 20 });
|
||||
assert.equal(rest.length, 0);
|
||||
assert.equal(first.side, 'long');
|
||||
assert.equal(first.pnl, 100);
|
||||
assert.equal(first.fee, 2); // its own buy, and a third of the reversing fill
|
||||
assert.equal(first.closeOrderId, b.orderId);
|
||||
assert.equal(second.id, b.id);
|
||||
assert.equal(second.side, 'short');
|
||||
assert.equal(second.quantity, 2);
|
||||
assert.equal(second.openPrice, 105);
|
||||
assert.equal(second.fee, 2);
|
||||
assert.equal(second.closedAt, null);
|
||||
assert.equal(second.pnl, null);
|
||||
assert.equal(second.closePrice, null);
|
||||
});
|
||||
|
||||
test('fills that start inside a position: passed over, or carried on', () => {
|
||||
const close = fill('sell', 1, 110, { pnl: 200 });
|
||||
const a = fill('buy', 1, 100);
|
||||
const b = fill('sell', 1, 101, { pnl: 20 });
|
||||
// Nothing stored: the leading close belongs to nothing on record.
|
||||
const plain = fillsToTrades([close, a, b], { startNet: 1, pointValue: 20 });
|
||||
assert.equal(plain.length, 1);
|
||||
assert.equal(plain[0].id, a.id);
|
||||
// The stored open trade carries on and closes.
|
||||
const carry = { id: 'tsx:42', side: 'long', quantity: 1, openPrice: 100, fee: 0.5, openedAt: 10 };
|
||||
const [carried, next] = fillsToTrades([close, a, b], { startNet: 1, pointValue: 20, carry });
|
||||
assert.equal(carried.id, 'tsx:42');
|
||||
assert.equal(carried.carried, true);
|
||||
assert.equal(carried.openedAt, 10);
|
||||
assert.equal(carried.openOrderId, null);
|
||||
assert.equal(carried.pnl, 200);
|
||||
assert.equal(carried.fee, 1.5);
|
||||
assert.equal(carried.closedAt, close.at);
|
||||
assert.equal(next.id, a.id);
|
||||
// A carry on the other side isn't this position.
|
||||
assert.equal(fillsToTrades([close], { startNet: 1, pointValue: 20, carry: { ...carry, side: 'short' } }).length, 0);
|
||||
});
|
||||
|
||||
test('the position before the fills', () => {
|
||||
const buy = fill('buy', 1, 1);
|
||||
const sell = fill('sell', 1, 1);
|
||||
assert.equal(startingNet([buy, sell], 0), 0);
|
||||
assert.equal(startingNet([buy], 1), 0);
|
||||
assert.equal(startingNet([sell], 0), 1);
|
||||
assert.equal(startingNet([], -2), -2);
|
||||
});
|
||||
|
||||
test('fills sort by time, then numerically by id', () => {
|
||||
const a = { id: '9', at: 5 };
|
||||
const b = { id: '10', at: 5 };
|
||||
const c = { id: '1', at: 6 };
|
||||
assert.deepEqual([c, b, a].sort(byFillTime).map((f) => f.id), ['9', '10', '1']);
|
||||
});
|
||||
|
||||
test('contract roots and TopstepX names', () => {
|
||||
assert.equal(contractRoot('MNQZ6'), 'MNQ');
|
||||
assert.equal(contractRoot('ZNZ6'), 'ZN');
|
||||
assert.equal(contractRoot('NGZ6'), 'NG');
|
||||
assert.equal(contractRoot('6EZ6'), '6E');
|
||||
assert.equal(contractRoot('M2KH27'), 'M2K');
|
||||
assert.equal(contractRoot('CL'), 'CL');
|
||||
assert.equal(topstepxSymbol('CON.F.US.ENQ.Z26'), 'NQZ6');
|
||||
assert.equal(topstepxSymbol('CON.F.US.MNQ.H27'), 'MNQH7');
|
||||
assert.equal(topstepxSymbol('CON.F.US.XYZ.M26'), 'XYZM6');
|
||||
assert.equal(topstepxSymbol('something'), 'something');
|
||||
});
|
||||
|
||||
// DXtrade: a bot round trip on BTC/USD, as /orders/history and /transfers
|
||||
// answer (position-based account: the opening order's id is the position).
|
||||
const openOrder = {
|
||||
account: 'default:T247-1', orderId: 555, orderCode: 'af-mh2-abc-e', type: 'MARKET', instrument: 'BTC/USD',
|
||||
status: 'COMPLETED', finalStatus: true,
|
||||
legs: [{ instrument: 'BTC/USD', positionEffect: 'OPEN', side: 'BUY', quantity: 0.5, filledQuantity: 0.5, averagePrice: 60000 }],
|
||||
issueTime: '2026-10-26T01:00:00.000Z', transactionTime: '2026-10-26T01:00:01.000Z',
|
||||
executions: [{ transactionTime: '2026-10-26T01:00:00.500Z', lastQuantity: 0.5, averagePrice: 60000 }],
|
||||
};
|
||||
const stopOrder = {
|
||||
account: 'default:T247-1', orderId: 556, orderCode: 'af-mh3-def-sl', type: 'STOP', instrument: 'BTC/USD',
|
||||
status: 'COMPLETED', finalStatus: true,
|
||||
legs: [{ instrument: 'BTC/USD', positionEffect: 'CLOSE', positionCode: '555', side: 'SELL', filledQuantity: 0.5, averagePrice: 59000 }],
|
||||
issueTime: '2026-10-26T01:00:02.000Z', transactionTime: '2026-10-26T02:00:00.000Z', executions: [],
|
||||
};
|
||||
const txn = (type, value, orderCode) => ({ key: `${orderCode}|${type}`, account: 'default:T247-1', type, value, time: 0, orderCode });
|
||||
|
||||
test('DXtrade order history rows', () => {
|
||||
const o = parseHistoryOrder(openOrder);
|
||||
assert.equal(o.orderCode, 'af-mh2-abc-e');
|
||||
assert.equal(o.effect, 'OPEN');
|
||||
assert.equal(o.side, 'BUY');
|
||||
assert.equal(o.positionCode, undefined);
|
||||
assert.equal(o.qty, 0.5);
|
||||
assert.equal(o.price, 60000);
|
||||
assert.equal(o.at, Date.parse('2026-10-26T01:00:00.500Z'));
|
||||
const s = parseHistoryOrder(stopOrder);
|
||||
assert.equal(s.positionCode, '555');
|
||||
assert.equal(s.at, Date.parse('2026-10-26T02:00:00.000Z'));
|
||||
// Never filled: not a trade's.
|
||||
assert.equal(parseHistoryOrder({ ...openOrder, legs: [{ ...openOrder.legs[0], filledQuantity: 0 }] }), null);
|
||||
assert.equal(parseHistoryOrder({}), null);
|
||||
});
|
||||
|
||||
test('DXtrade positions from their orders and cash', () => {
|
||||
const orders = [parseHistoryOrder(stopOrder), parseHistoryOrder(openOrder)];
|
||||
const txns = [txn('SETTLEMENT', -500, 'af-mh3-def-sl'), txn('COMMISSION', -12, 'af-mh2-abc-e'), txn('COMMISSION', -11.8, 'af-mh3-def-sl')];
|
||||
const [t, ...rest] = dxTrades(orders, txns, new Map(), new Map());
|
||||
assert.equal(rest.length, 0);
|
||||
assert.equal(t.positionCode, '555');
|
||||
assert.equal(t.side, 'long');
|
||||
assert.equal(t.quantity, 0.5);
|
||||
assert.equal(t.openPrice, 60000);
|
||||
assert.equal(t.closePrice, 59000);
|
||||
assert.equal(t.pnl, -500);
|
||||
assert.equal(t.fee, 23.8);
|
||||
assert.equal(t.openCode, 'af-mh2-abc-e');
|
||||
assert.equal(t.closeCode, 'af-mh3-def-sl');
|
||||
assert.equal(t.closeType, 'STOP');
|
||||
|
||||
// Still held: open, whatever the history says.
|
||||
const held = new Map([['555', { symbol: 'BTC/USD', side: 'long', quantity: 0.5, openPrice: 60000, openedAt: 1 }]]);
|
||||
const [open] = dxTrades([parseHistoryOrder(openOrder)], [], held, new Map());
|
||||
assert.equal(open.closedAt, null);
|
||||
assert.equal(open.pnl, null);
|
||||
|
||||
// Opened before the history: completed from what's stored, or left out.
|
||||
const [late] = dxTrades([parseHistoryOrder(stopOrder)], txns, new Map(), held);
|
||||
assert.equal(late.openedAt, 1);
|
||||
assert.equal(late.openCode, null);
|
||||
assert.equal(late.pnl, -500);
|
||||
assert.equal(dxTrades([parseHistoryOrder(stopOrder)], txns, new Map(), new Map()).length, 0);
|
||||
|
||||
// A position open with no orders in the window still shows.
|
||||
const [quiet] = dxTrades([], [], held, new Map());
|
||||
assert.equal(quiet.positionCode, '555');
|
||||
assert.equal(quiet.openedAt, 1);
|
||||
});
|
||||
@@ -9,11 +9,13 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
percentFee, takeProfitPlan, obfuscationStopDollars, dllRoom, dllStopDollars, dllViable, stopTicks,
|
||||
percentFee, takeProfitPlan, onTick, obfuscationStopDollars, dllRoom, dllStopDollars, dllViable, stopTicks,
|
||||
botStopsActive, sizeNotional, isNoTradeTime, isPastSessionClose, nextTradingDate, effectiveCombine, DEFAULT_TARGET_HOLD_HOURS,
|
||||
projectPerfectPath, cycleCompletion, DLL_RESERVE_FRACTION, DLL_MIN_STOP, notionalCap, expectedSwapToWin, MARGIN_HEADROOM,
|
||||
projectPerfectPath, cycleCompletion, endedCycleCompletion, DLL_RESERVE_FRACTION, DLL_MIN_STOP, notionalCap, expectedSwapToWin, MARGIN_HEADROOM,
|
||||
dllReserve, DLL_SLIPPAGE_FRACTION, maxLossRoom, lastStand, breachDistance, LAST_STAND_FRACTION, planLegSize, rankForTarget,
|
||||
currentTradeDateCT, foldReason,
|
||||
currentTradeDateCT, foldReason, withinFirmCaps, personSizeFactor, personHoldHours, PERSON_SIZE_SPREAD, machineSide,
|
||||
resolveEffectiveConfig, stageInputsFor, stagePayouts, floorRoom, resetClearStop, shortestNextLeg, computeDayPlan, clearsClose, RESET_CLEAR_HOLDS,
|
||||
legCloseNote,
|
||||
} from '../lib/trading-logic.ts';
|
||||
|
||||
// Deterministic PRNG so a failure reproduces.
|
||||
@@ -28,6 +30,38 @@ function rng(seed) {
|
||||
};
|
||||
}
|
||||
|
||||
// ── prices on the tick grid ─────────────────────────────────────────────────
|
||||
|
||||
test('onTick: 2026-10-02 GC short — 87 ticks below 4217.6 is 4208.9, not 4208.900000000001', () => {
|
||||
assert.notEqual(4217.6 - 87 * 0.1, 4208.9); // the fuzz TopstepX refused
|
||||
const tp = takeProfitPlan({ side: 'Sell', entry: 4217.6, leg: 861.4, pointValue: 100, contracts: 1, tickSize: 0.1, commissionPct: 0, entryCommission: 2.8 });
|
||||
assert.equal(tp.ticks, 87);
|
||||
assert.equal(tp.exitPrice, 4208.9);
|
||||
assert.equal(JSON.stringify(tp.exitPrice), '4208.9');
|
||||
});
|
||||
|
||||
test('onTick: measured-fee take-profits always land on the grid, as the shortest decimal', () => {
|
||||
const r = rng(11);
|
||||
for (let i = 0; i < 5000; i++) {
|
||||
const side = r() < 0.5 ? 'Buy' : 'Sell';
|
||||
const [pv, tick] = [[100, 0.1], [1000, 0.01], [10, 0.1], [12.5, 0.0001], [1000, 0.03125], [50, 0.25]][Math.floor(r() * 6)];
|
||||
const decimals = String(tick).split('.')[1].length;
|
||||
const entry = Number((Math.round((50 + r() * 20000) / tick) * tick).toFixed(decimals));
|
||||
const tp = takeProfitPlan({ side, entry, leg: 5 + r() * 2500, pointValue: pv, contracts: 1 + Math.floor(r() * 4), tickSize: tick, commissionPct: 0, entryCommission: Math.round(r() * 1000) / 100 });
|
||||
assert.equal(tp.exitPrice, Number(tp.exitPrice.toFixed(decimals)), `${tp.exitPrice} has more decimals than tick ${tick}`);
|
||||
assert.equal(Math.round(Math.abs(tp.exitPrice - entry) / tick), tp.ticks);
|
||||
}
|
||||
});
|
||||
|
||||
test('onTick: an averaged fill between ticks goes the given way; fuzz never moves a tick', () => {
|
||||
assert.equal(onTick(7749.125, 0.25, 'up'), 7749.25); // 2 ES filled at .00 and .25
|
||||
assert.equal(onTick(7749.125, 0.25, 'down'), 7749);
|
||||
assert.equal(onTick(4208.900000000001, 0.1, 'up'), 4208.9);
|
||||
assert.equal(onTick(4208.899999999999, 0.1, 'down'), 4208.9);
|
||||
assert.equal(onTick(62.07, 0.01, 'up'), 62.07);
|
||||
assert.equal(onTick(123.5, 0, 'up'), 123.5); // no tick size known: untouched
|
||||
});
|
||||
|
||||
// ── parity with the pre-refactor inline code ────────────────────────────────
|
||||
|
||||
/** The take-profit exactly as auto-trade.ts computed it before takeProfitPlan. */
|
||||
@@ -51,7 +85,8 @@ test('takeProfitPlan reproduces the old formula when fees are measured', () => {
|
||||
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);
|
||||
// The same price, minus the old formula's float fuzz.
|
||||
assert.equal(got.exitPrice, Math.round(want.exitPrice * 1e10) / 1e10);
|
||||
assert.equal(got.ticks, want.ticks);
|
||||
assert.equal(got.totalCommission, want.totalCommission);
|
||||
assert.equal(got.grossTarget, want.grossTarget);
|
||||
@@ -271,6 +306,22 @@ test('dllViable and botStopsActive', () => {
|
||||
assert.equal(botStopsActive({ firstDayStop: true, minDayStop: false, dailyLossLimit: 0 }), true);
|
||||
});
|
||||
|
||||
test('botStopsActive: the current stage\'s daily loss limit, which a stage may set on a type with none', () => {
|
||||
const cfg = { firstDayStop: false, minDayStop: false, dailyLossLimit: 0 };
|
||||
const stages = [{ profit: 3000, consistency: 0, minTradingDays: 0, dailyLossLimit: 3000 }];
|
||||
const paidOnce = [{ date: '2026-10-25', amount: -1500 }];
|
||||
// Stage 2 places its $3,000 stop: a red day there is the personal DLL.
|
||||
const stage2 = resolveEffectiveConfig(1500, 0, 0, false, stages, 0, paidOnce, 0, { dailyLossLimit: 0 });
|
||||
assert.equal(botStopsActive(cfg, stage2), true);
|
||||
// Stage 1 of the same type places none.
|
||||
assert.equal(botStopsActive(cfg, resolveEffectiveConfig(1500, 0, 0, false, stages, 0, [], 0, { dailyLossLimit: 0 })), false);
|
||||
// A stage that sets 0 lifts the type's limit.
|
||||
const lifted = resolveEffectiveConfig(1500, 0, 0, false, [{ ...stages[0], dailyLossLimit: 0 }], 0, paidOnce, 0, { dailyLossLimit: 1229 });
|
||||
assert.equal(botStopsActive({ ...cfg, dailyLossLimit: 1229 }, lifted), false);
|
||||
// The obfuscation stops count whatever the stage.
|
||||
assert.equal(botStopsActive({ ...cfg, minDayStop: true }, lifted), true);
|
||||
});
|
||||
|
||||
// ── sizing ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const btcSigma = 0.03 / Math.sqrt(24); // 3%/day
|
||||
@@ -385,6 +436,21 @@ test('the daily-loss reserve grows with the position past 10% of the limit', ()
|
||||
assert.equal(dllStopDollars({ room: 3000, dailyLossLimit: 3000, entryFee: 0, commissionPct: 0 }), 2699.99);
|
||||
});
|
||||
|
||||
test('a platform whose stops fill closer reserves its own share of the position', () => {
|
||||
// Me's Vest eval on 2026-10-27: $1,400 of room on $495,439 of NDX. At the
|
||||
// 0.1% default the reserve is $495.44; at 0.02% the flat $140 binds.
|
||||
const n = 495439;
|
||||
assert.ok(Math.abs(dllReserve(1400, n) - 495.439) < 1e-9);
|
||||
assert.equal(dllReserve(1400, n, 0.0002), 140);
|
||||
assert.ok(Math.abs(dllReserve(1400, 900000, 0.0002) - 180) < 1e-9, 'past $700k the share binds again');
|
||||
// Left out, or not a share at all: the default.
|
||||
for (const bad of [undefined, NaN, -0.0002, Infinity]) assert.equal(dllReserve(1400, n, bad), dllReserve(1400, n), String(bad));
|
||||
assert.equal(dllReserve(1400, n, 0), 140, 'no slippage at all still keeps the flat 10%');
|
||||
const stop = (slippageFraction) => dllStopDollars({ room: 1400, dailyLossLimit: 1400, entryFee: 12.45, commissionPct: 0, notional: n, slippageFraction });
|
||||
assert.ok(Math.abs(stop(undefined) - (1400 - 495.439 - 24.9 - 0.01)) < 1e-9);
|
||||
assert.ok(Math.abs(stop(0.0002) - (1400 - 140 - 24.9 - 0.01)) < 1e-9);
|
||||
});
|
||||
|
||||
test('max-drawdown room: the static floor, the broker floor, the higher', () => {
|
||||
assert.equal(maxLossRoom({ balance: 99757.39, accountSize: 100000, maxLoss: 6000 }), 5757.39);
|
||||
assert.equal(maxLossRoom({ balance: 99757.39, accountSize: 100000, maxLoss: -6000 }), 5757.39);
|
||||
@@ -407,6 +473,13 @@ test('last stand: no daily-loss stop once hitting the daily limit would leave 15
|
||||
assert.equal(lastStand({ dllRoom: 1000, dailyLossLimit: 3000, maxLossRoom: 1200 }), false);
|
||||
assert.equal(lastStand({ dllRoom: 1000, dailyLossLimit: 3000, maxLossRoom: 1150 }), true);
|
||||
assert.equal(lastStand({ dllRoom: Infinity, dailyLossLimit: 0, maxLossRoom: 500 }), false, 'no daily limit, no daily stop to drop');
|
||||
// A green day's widened room is measured against a full day's limit: a
|
||||
// $1,400 eval starting $1,700 over its floor, up $1,000 — leg 2's $2,400
|
||||
// stop-out leaves $300, over 15% of $1,400, so it keeps its stop.
|
||||
assert.equal(lastStand({ dllRoom: 2400, dailyLossLimit: 1400, maxLossRoom: 2700 }), false);
|
||||
assert.equal(lastStand({ dllRoom: 2400, dailyLossLimit: 1400, maxLossRoom: 2610 }), true);
|
||||
// The room past the floor is a last stand however it was widened.
|
||||
assert.equal(lastStand({ dllRoom: 2400, dailyLossLimit: 1400, maxLossRoom: 2300 }), true);
|
||||
// The far side of the walk is the nearer floor, net of the entry fee.
|
||||
assert.equal(breachDistance({ dllRoom: 3000, maxLossRoom: 2900, entryFee: 120 }), 2780);
|
||||
assert.equal(breachDistance({ dllRoom: 2757.39, maxLossRoom: null, entryFee: 0 }), 2757.39);
|
||||
@@ -442,6 +515,154 @@ test('planLegSize: the stop, a last stand, the margin cap and the cost, as fireL
|
||||
assert.equal(planLegSize({ ...base, maxLossRoom: null, contract: {} }).sizing, null);
|
||||
});
|
||||
|
||||
test('resetClearStop: the widest stop a leg may carry and still close before the reset', () => {
|
||||
const h = 3_600_000;
|
||||
const near = (a, b) => Math.abs(a - b) < 1e-6;
|
||||
// Exactly four holds off: a 2:1 leg, no more.
|
||||
assert.ok(near(resetClearStop({ leg: 2395.76, resetInMs: 6 * h, holdHours: 1.5 }), 2 * 2395.76));
|
||||
// Under four holds (the 5:30 PM CT open batch, 1.5 h before Vest's
|
||||
// reset): no widening at all.
|
||||
assert.equal(resetClearStop({ leg: 2395.76, resetInMs: 1.5 * h, holdHours: 1.5 }), 0);
|
||||
assert.equal(resetClearStop({ leg: 2395.76, resetInMs: 6 * h - 1, holdHours: 1.5 }), 0);
|
||||
// Dingo's second leg at ~4 AM ET, 16 h off: far past the $4,004 the day allows.
|
||||
assert.ok(resetClearStop({ leg: 2395.76, resetInMs: 16 * h, holdHours: 1.5 }) > 20000);
|
||||
// A $500 final leg 10 h off may carry about $2,656, not the $5,900 a
|
||||
// $4,500 gain would give: g = (A+B)²/(AB) stays at 4.5 × 10/6.
|
||||
const lopsided = resetClearStop({ leg: 500, resetInMs: 10 * h, holdHours: 1.5 });
|
||||
assert.ok(Math.abs(lopsided - 2656) < 1, `${lopsided}`);
|
||||
assert.ok(near((500 + lopsided) ** 2 / (500 * lopsided), 4.5 * 10 / 6));
|
||||
assert.equal(resetClearStop({ leg: 0, resetInMs: 16 * h, holdHours: 1.5 }), 0);
|
||||
assert.equal(resetClearStop({ leg: 500, resetInMs: 16 * h, holdHours: 0 }), 0);
|
||||
assert.equal(resetClearStop({ leg: 500, resetInMs: NaN, holdHours: 1.5 }), 0);
|
||||
// The cap is held to the shortest leg the day may carve: a split day's
|
||||
// $2,395.76 can come out a $500 leg.
|
||||
assert.equal(shortestNextLeg({ remainingToday: 2395.76, splitAllowed: true }), 500);
|
||||
assert.equal(shortestNextLeg({ remainingToday: 320, splitAllowed: true }), 320);
|
||||
assert.equal(shortestNextLeg({ remainingToday: 2395.76, splitAllowed: false }), 2395.76);
|
||||
// Dingo at ~4 AM ET still gets the whole $2,604.24: a $500 leg 16 h off
|
||||
// may carry ~$4,950.
|
||||
assert.ok(resetClearStop({ leg: 500, resetInMs: 16 * h, holdHours: 1.5 }) - 1400 > 2604.24);
|
||||
});
|
||||
|
||||
test('clearsClose: no leg starts under four holds of its market\'s close', () => {
|
||||
const h = 3_600_000;
|
||||
assert.equal(RESET_CLEAR_HOLDS, 4);
|
||||
// The weekly close 7.5 h off, legs sized for 2 h: no entry.
|
||||
assert.equal(clearsClose({ closeInMs: 7.5 * h, holdHours: 2 }), false);
|
||||
// Exactly four holds off, or more: the clearance the daily reset gets.
|
||||
assert.equal(clearsClose({ closeInMs: 8 * h, holdHours: 2 }), true);
|
||||
assert.equal(clearsClose({ closeInMs: 8 * h - 1, holdHours: 2 }), false);
|
||||
assert.equal(clearsClose({ closeInMs: 20 * h, holdHours: 2 }), true);
|
||||
// A leg its leverage cap holds to 3.5 h needs 14 h, where a 2 h one is clear.
|
||||
assert.equal(clearsClose({ closeInMs: 10 * h, holdHours: 3.5 }), false);
|
||||
// It agrees with resetClearStop, which gives no stop at all under four holds.
|
||||
for (const off of [5.9, 6, 6.1, 12]) {
|
||||
assert.equal(clearsClose({ closeInMs: off * h, holdHours: 1.5 }), resetClearStop({ leg: 500, resetInMs: off * h, holdHours: 1.5 }) > 0);
|
||||
}
|
||||
// An unknown hold never clears.
|
||||
assert.equal(clearsClose({ closeInMs: 20 * h, holdHours: NaN }), false);
|
||||
});
|
||||
|
||||
test('legCloseNote: each account\'s leg is held to its market\'s close at its own size', () => {
|
||||
const h = 3_600_000;
|
||||
const now = Date.parse('2026-10-30T12:30:00Z');
|
||||
const close = now + 9 * h;
|
||||
// The batch's lead leg, 2 h: clear. Another account's, sized to 3.5 h: not.
|
||||
assert.equal(legCloseNote(close, 2, now), null);
|
||||
assert.equal(legCloseNote(close, 3.5, now), 'closes in 9.0h, under 4 holds of 3.5h');
|
||||
// A contract that names no close (futures, crypto) never holds a leg back.
|
||||
assert.equal(legCloseNote(undefined, 3.5, now), null);
|
||||
assert.equal(legCloseNote(null, 3.5, now), null);
|
||||
// An unknown hold doesn't open into a known close.
|
||||
assert.notEqual(legCloseNote(close, NaN, now), null);
|
||||
});
|
||||
|
||||
test('planLegSize: a split day\'s next leg, with the day\'s gain on its stop, sizes up for the same hold', () => {
|
||||
// Dingo's 2026-10-27 evals: up $2,604.24 on a $1,400 day, $2,395.76 to
|
||||
// pass, NDX at ~$29.5k on Vest (1.31%/day), sized for 1.5 h.
|
||||
const ndx = { stepNotional: 2.95, volatilityPerHour: 0.0131 / Math.sqrt(24), spreadFraction: 0.0000085, marginRate: 0.02 };
|
||||
const leg = {
|
||||
leg: 2395.76, contract: ndx, commissionPct: 0.0025, targetHoldHours: 1.5, financingPctPerDay: 0,
|
||||
dailyLossLimit: 1400, obfuscationDollars: Infinity, availableFunds: 52604.24, maxLossRoom: 5604.24,
|
||||
};
|
||||
const capped = planLegSize({ ...leg, dllRoom: 1400 });
|
||||
const room = floorRoom({ dailyLossLimit: 1400, closedBalance: 52604.24, floor: 48500, realizedToday: 2604.24, gain: 2604.24 });
|
||||
const widened = planLegSize({ ...leg, dllRoom: room });
|
||||
assert.equal(widened.lastStand, false);
|
||||
for (const p of [capped, widened]) assert.ok(Math.abs(p.sizing.predictedHoldHours - 1.5) < 0.05, `hold ${p.sizing.predictedHoldHours}`);
|
||||
assert.ok(widened.sizing.notional > 1.5 * capped.sizing.notional, `${widened.sizing.notional} vs ${capped.sizing.notional}`);
|
||||
// The stop gives back the day's gain plus the limit, less the reserve.
|
||||
const n = widened.sizing.notional;
|
||||
assert.ok(widened.walkAt(n) > 2604.24 && widened.walkAt(n) < 4004.24, `stop ${widened.walkAt(n)}`);
|
||||
});
|
||||
|
||||
test('planLegSize: a platform\'s own slippage share widens the stop and sizes the leg up for the same hold', () => {
|
||||
// Me's first-day Vest eval leg on 2026-10-27: $5,000 to pass, $1,400 of
|
||||
// room, NDX at ~$29.8k, sized for 2.6 h.
|
||||
const ndx = { stepNotional: 2.98, volatilityPerHour: 0.0131 / Math.sqrt(24), spreadFraction: 0.0000085, marginRate: 0.02 };
|
||||
const leg = {
|
||||
leg: 5000, contract: ndx, commissionPct: 0.0025, targetHoldHours: 2.6, financingPctPerDay: 0,
|
||||
dailyLossLimit: 1400, dllRoom: 1400, obfuscationDollars: Infinity, availableFunds: 50000, maxLossRoom: 3000,
|
||||
};
|
||||
const deflt = planLegSize(leg);
|
||||
const vest = planLegSize({ ...leg, slippageFraction: 0.0002 });
|
||||
assert.equal(planLegSize({ ...leg, slippageFraction: undefined }).sizing.notional, deflt.sizing.notional);
|
||||
for (const p of [deflt, vest]) {
|
||||
assert.equal(p.lastStand, false);
|
||||
assert.ok(Math.abs(p.sizing.predictedHoldHours - 2.6) < 0.05, `hold ${p.sizing.predictedHoldHours}`);
|
||||
}
|
||||
const r = 0.0025 / 100;
|
||||
const nd = deflt.sizing.notional, nv = vest.sizing.notional;
|
||||
assert.ok(Math.abs(deflt.walkAt(nd) - (1400 - 0.001 * nd - 2 * r * nd - 0.01) / (1 + r)) < 1e-9);
|
||||
assert.ok(Math.abs(vest.walkAt(nv) - (1400 - 140 - 2 * r * nv - 0.01) / (1 + r)) < 1e-9, 'the flat $140 binds');
|
||||
assert.ok(vest.walkAt(nv) > 1200 && deflt.walkAt(nd) < 950, `stops ${vest.walkAt(nv)} vs ${deflt.walkAt(nd)}`);
|
||||
assert.ok(nv > 1.1 * nd, `${nv} vs ${nd}`);
|
||||
assert.ok(vest.sizing.winProbability > deflt.sizing.winProbability);
|
||||
});
|
||||
|
||||
test('personSizeFactor: one draw per person and trade date, within the spread', () => {
|
||||
const people = Array.from({ length: 300 }, (_, i) => `0b6f${i.toString(16).padStart(4, '0')}-5c1e-4a7e-9d2b-${i}`);
|
||||
const draws = people.map((p) => personSizeFactor(p, '2026-10-20'));
|
||||
for (const f of draws) assert.ok(f >= 1 - PERSON_SIZE_SPREAD && f <= 1 + PERSON_SIZE_SPREAD, `factor ${f}`);
|
||||
// Spread over the whole range, not bunched at 1.
|
||||
assert.ok(Math.min(...draws) < 0.88 && Math.max(...draws) > 1.12, `${Math.min(...draws)}–${Math.max(...draws)}`);
|
||||
// Repeatable, so every sizing of the day agrees.
|
||||
assert.equal(personSizeFactor(people[0], '2026-10-20'), draws[0]);
|
||||
// Two people land within half a percent of each other about as often as
|
||||
// uniform draws do (2 × 0.005 / 0.3 ≈ 3%), not bunched together.
|
||||
const close = draws.filter((f, i) => i > 0 && Math.abs(f - draws[i - 1]) < 0.005).length;
|
||||
assert.ok(close < draws.length * 0.08, `${close} near-identical neighbours`);
|
||||
// A new trade date, a new draw: a month of one person's days covers the
|
||||
// range too, not a slow drift (the next date differs in its last digit).
|
||||
const days = Array.from({ length: 30 }, (_, i) => personSizeFactor(people[0], `2026-11-${String(i + 1).padStart(2, '0')}`));
|
||||
assert.ok(Math.max(...days) - Math.min(...days) > PERSON_SIZE_SPREAD, `days span ${Math.min(...days)}–${Math.max(...days)}`);
|
||||
const steps = days.slice(1).map((f, i) => Math.abs(f - days[i]));
|
||||
assert.ok(steps.reduce((a, b) => a + b, 0) / steps.length > 0.05, 'day to day the draw should jump, not creep');
|
||||
});
|
||||
|
||||
test('personHoldHours: the size comes out the person\'s factor times the plan, at the hold it was sized for', () => {
|
||||
const eth = { stepNotional: 26.86, volatilityPerHour: 0.022 / Math.sqrt(24), spreadFraction: 0.00004, marginRate: 0.2 };
|
||||
const base = {
|
||||
leg: 12239, contract: eth, commissionPct: 0.04, targetHoldHours: 12, financingPctPerDay: 0.033,
|
||||
dailyLossLimit: 3000, dllRoom: 2757.39, maxLossRoom: 5757.39, obfuscationDollars: Infinity, availableFunds: 99757.39,
|
||||
};
|
||||
const plan = planLegSize(base).sizing;
|
||||
for (const person of ['me-uuid', 'nik-uuid', 'ahmad-uuid', 'ziad-uuid']) {
|
||||
const factor = personSizeFactor(person, '2026-10-20');
|
||||
const hold = personHoldHours(12, person, '2026-10-20');
|
||||
const sized = planLegSize({ ...base, targetHoldHours: hold }).sizing;
|
||||
// Fees and the daily-loss stop move a little with the size, so the
|
||||
// ratio sits a touch nearer 1 than the factor.
|
||||
const ratio = sized.notional / plan.notional;
|
||||
assert.ok(Math.abs(ratio - factor) < 0.02, `${person}: ×${ratio.toFixed(4)} vs factor ${factor.toFixed(4)}`);
|
||||
assert.ok((ratio - 1) * (factor - 1) > 0, `${person}: moved the wrong way`);
|
||||
// The pick ranks against this hold, and the size reaches it.
|
||||
assert.ok(Math.abs(sized.predictedHoldHours - hold) < 0.1, `${person}: ${sized.predictedHoldHours}h vs ${hold}h`);
|
||||
}
|
||||
// No firm hold: the default, moved the same way.
|
||||
assert.equal(personHoldHours(0, 'me-uuid', '2026-10-20'), personHoldHours(DEFAULT_TARGET_HOLD_HOURS, 'me-uuid', '2026-10-20'));
|
||||
});
|
||||
|
||||
test('planLegSize: the broker\'s order size limits bind the sizing, so the pick sees the real hold', () => {
|
||||
const eth = { stepNotional: 26.86, volatilityPerHour: 0.022 / Math.sqrt(24), spreadFraction: 0.00004, marginRate: 0.2 };
|
||||
const base = {
|
||||
@@ -544,6 +765,39 @@ test('an every-day firm can finish its cycle on a Saturday', () => {
|
||||
assert.equal(cycleCompletion(opts), 'none');
|
||||
});
|
||||
|
||||
import { completionPlace } from '../lib/trading-logic.ts';
|
||||
|
||||
test('completionPlace: each payout counts, and a day\'s second cycle has a key of its own', () => {
|
||||
// Vest: activated on the 20th; claims c1 and c2 on the 27th share one
|
||||
// fund row; c3 on the 28th.
|
||||
const activated = { date: '2026-10-20', amount: 50000 };
|
||||
// Cycle A, the first, completes on the 27th ahead of any claim.
|
||||
assert.deepEqual(completionPlace({ payoutDays: [], fundTransactions: [activated], cycle: '2026-10-20' }), { stage: 1, cycleSeq: 0 });
|
||||
// c1 starts cycle B, which completes the same day.
|
||||
const b = completionPlace({ payoutDays: ['2026-10-27'], fundTransactions: [activated, { date: '2026-10-27', amount: -2250 }], cycle: '2026-10-27' });
|
||||
assert.deepEqual(b, { stage: 2, cycleSeq: 0 });
|
||||
// c2 starts cycle C on the same fund row, which completes on the 28th:
|
||||
// payout 3, and not B's key — the master would fold C into B.
|
||||
const both = [activated, { date: '2026-10-27', amount: -3750 }];
|
||||
const c = completionPlace({ payoutDays: ['2026-10-27', '2026-10-27'], fundTransactions: both, cycle: '2026-10-27' });
|
||||
assert.deepEqual(c, { stage: 3, cycleSeq: 1 });
|
||||
// C caught up after c3 ended it: the same stage and key as its final day.
|
||||
assert.deepEqual(completionPlace({
|
||||
payoutDays: ['2026-10-27', '2026-10-27', '2026-10-28'], fundTransactions: [...both, { date: '2026-10-28', amount: -1500 }],
|
||||
cycle: '2026-10-27', end: '2026-10-28',
|
||||
}), c);
|
||||
// One fund row per payout (every other platform): 1 + the rows before, no
|
||||
// cycleSeq — as the stage always read.
|
||||
const rows = [{ date: '2026-09-01', amount: -500 }, { date: '2026-09-15', amount: -800 }];
|
||||
const days = rows.map((f) => f.date);
|
||||
assert.deepEqual(completionPlace({ payoutDays: days, fundTransactions: rows, cycle: '2026-09-15' }), { stage: 3, cycleSeq: 0 });
|
||||
assert.deepEqual(completionPlace({ payoutDays: days, fundTransactions: rows, cycle: '2026-09-01', end: '2026-09-15' }), { stage: 2, cycleSeq: 0 });
|
||||
// The account's first cycle; and a claim on the day it was activated,
|
||||
// whose deposit row names the cycle it started.
|
||||
assert.deepEqual(completionPlace({ payoutDays: [], fundTransactions: [], cycle: '' }), { stage: 1, cycleSeq: 0 });
|
||||
assert.deepEqual(completionPlace({ payoutDays: ['2026-10-20'], fundTransactions: [{ date: '2026-10-20', amount: 47750 }], cycle: '2026-10-20' }), { stage: 2, cycleSeq: 1 });
|
||||
});
|
||||
|
||||
// ── DXTrade helpers (lib/dxtrade-helpers.ts) ─────────────────────────────────
|
||||
|
||||
import {
|
||||
@@ -738,3 +992,498 @@ test('tick reasons fold their amounts, so like reasons count together', () => {
|
||||
assert.equal(foldReason("waiting for the broker to roll the account's day"), "waiting for the broker to roll the account's day");
|
||||
assert.equal(foldReason('leg cap reached (5 today)'), 'leg cap reached (5 today)');
|
||||
});
|
||||
|
||||
test('withinFirmCaps: a firm\'s cap on accounts at once, in order, less what it holds', () => {
|
||||
const topstep = { id: 'ts', maxConcurrentAccounts: 3 };
|
||||
const apex = { id: 'apex', maxConcurrentAccounts: 0 };
|
||||
const lucid = { id: 'lucid' };
|
||||
const items = [
|
||||
...[1, 2, 3, 4, 5, 6].map((n) => ({ firm: topstep, n: `ts${n}` })),
|
||||
...[1, 2, 3, 4].map((n) => ({ firm: apex, n: `apex${n}` })),
|
||||
{ firm: lucid, n: 'lucid1' },
|
||||
];
|
||||
const names = (xs) => xs.map((x) => x.n);
|
||||
// 6 Topstep capped at 3 keep the first 3; uncapped firms keep everything.
|
||||
assert.deepEqual(names(withinFirmCaps(items)), ['ts1', 'ts2', 'ts3', 'apex1', 'apex2', 'apex3', 'apex4', 'lucid1']);
|
||||
// Two already open: one more slot.
|
||||
assert.deepEqual(names(withinFirmCaps(items, new Map([['ts', 2]]))), ['ts1', 'apex1', 'apex2', 'apex3', 'apex4', 'lucid1']);
|
||||
// At (or past) the cap: none.
|
||||
assert.deepEqual(names(withinFirmCaps(items, new Map([['ts', 4]]))), ['apex1', 'apex2', 'apex3', 'apex4', 'lucid1']);
|
||||
// Held counts don't touch an uncapped firm, and the input isn't mutated.
|
||||
const held = new Map([['apex', 9]]);
|
||||
assert.equal(withinFirmCaps(items, held).filter((x) => x.firm === apex).length, 4);
|
||||
assert.equal(held.get('apex'), 9);
|
||||
assert.equal(held.has('ts'), false);
|
||||
// Interleaved order is kept.
|
||||
const mixed = [{ firm: topstep, n: 'a' }, { firm: apex, n: 'b' }, { firm: topstep, n: 'c' }, { firm: topstep, n: 'd' }, { firm: topstep, n: 'e' }];
|
||||
assert.deepEqual(names(withinFirmCaps(mixed)), ['a', 'b', 'c', 'd']);
|
||||
assert.deepEqual(withinFirmCaps([]), []);
|
||||
});
|
||||
|
||||
// ── fund events from the balance (TopstepX reconcileFunds) ──────────────────
|
||||
|
||||
import { fundReconcileStep, FUND_CONFIRM_MS } from '../lib/trading-logic.ts';
|
||||
|
||||
/** reconcileFunds' bookkeeping for one trade date, pass by pass: `balance`
|
||||
* and `trades` (all trade P&L, today's included) as each pass read them.
|
||||
* `existing` is a row already on the day that reconcileFunds didn't book. */
|
||||
function reconcileDay(passes, existing = null) {
|
||||
let row = existing;
|
||||
let undoable = false; // the day's first booking saves what it replaces
|
||||
let sighting = null;
|
||||
const steps = [];
|
||||
for (const { at, balance, trades } of passes) {
|
||||
const delta = Math.round((balance - trades - (row ?? 0)) * 100) / 100;
|
||||
const step = fundReconcileStep({ delta, sighting, now: at * 1000, existingToday: row, undoable });
|
||||
steps.push(step);
|
||||
if (step.kind === 'unconfirmed') { sighting = step.sighting; continue; }
|
||||
sighting = null;
|
||||
if (step.kind === 'book') { if (row === null) undoable = true; row = step.amount; }
|
||||
if (step.kind === 'drop') { row = null; undoable = false; }
|
||||
}
|
||||
return { row, kinds: steps.map((s) => s.kind), steps };
|
||||
}
|
||||
|
||||
test('fund reconcile: a balance read before a fill the trades already show books nothing', () => {
|
||||
// 2026-10-05, EXPRESS-V2-CT-DLL-39029-23275136: the pass read the balance
|
||||
// (−2.16), then the trades with the −3,001.16 stop-out in them. One pass
|
||||
// later the balance had it (−3,004.32, $1.00 of commission off-book).
|
||||
// Booked as it came, that was +3,001.16, then −3,002.16, merged to −1.00.
|
||||
const express = reconcileDay([
|
||||
{ at: 0, balance: -2.16, trades: -3003.32 },
|
||||
{ at: 60, balance: -3004.32, trades: -3003.32 },
|
||||
]);
|
||||
assert.deepEqual(express.kinds, ['unconfirmed', 'settled']);
|
||||
assert.equal(express.steps[0].sighting.delta, 3001.16);
|
||||
assert.equal(express.row, null);
|
||||
|
||||
// 2026-10-08, 150KTC-SKU-V2-DLL-39029-99745612 (balance less the $150K
|
||||
// start): +2,474.01 then −2,492.01, which left a −18.00 row.
|
||||
const combine = reconcileDay([
|
||||
{ at: 0, balance: 2413.43, trades: -60.58 },
|
||||
{ at: 60, balance: -78.58, trades: -60.58 },
|
||||
]);
|
||||
assert.deepEqual(combine.kinds, ['unconfirmed', 'settled']);
|
||||
assert.equal(combine.row, null);
|
||||
});
|
||||
|
||||
test('fund reconcile: a real withdrawal books once a later pass still sees it', () => {
|
||||
// 2026-10-14, EXPRESS-V2-CT-DLL-39029-43788281: a $4,620 payout.
|
||||
const payout = reconcileDay([
|
||||
{ at: 0, balance: 4601.42, trades: 9221.42 },
|
||||
{ at: 10, balance: 4601.42, trades: 9221.42 }, // too soon to confirm
|
||||
{ at: 60, balance: 4601.42, trades: 9221.42 },
|
||||
]);
|
||||
assert.deepEqual(payout.kinds, ['unconfirmed', 'unconfirmed', 'book']);
|
||||
assert.equal(payout.steps[1].fresh, false);
|
||||
assert.equal(payout.steps[1].sighting.since, 0); // the clock runs from the first sighting
|
||||
assert.equal(payout.row, -4620);
|
||||
|
||||
// Fee noise between the passes still confirms; the later amount books.
|
||||
assert.equal(reconcileDay([
|
||||
{ at: 0, balance: 4601.42, trades: 9221.42 },
|
||||
{ at: FUND_CONFIRM_MS / 1000, balance: 4598.42, trades: 9221.42 },
|
||||
]).row, -4623);
|
||||
|
||||
// An amount that moves starts over: the second sighting is the new one.
|
||||
const moved = reconcileDay([
|
||||
{ at: 0, balance: 4601.42, trades: 9221.42 },
|
||||
{ at: 60, balance: 4901.42, trades: 9221.42 },
|
||||
{ at: 120, balance: 4901.42, trades: 9221.42 },
|
||||
]);
|
||||
assert.deepEqual(moved.kinds, ['unconfirmed', 'unconfirmed', 'book']);
|
||||
assert.equal(moved.steps[1].fresh, true);
|
||||
assert.equal(moved.row, -4320);
|
||||
});
|
||||
|
||||
test('fund reconcile: a transient that outlasts the confirmation is taken back whole', () => {
|
||||
// The balance lags for two passes, so +3,001.16 books; when it catches up,
|
||||
// the reversal nets the day to −1.00 and the day is dropped, not left as
|
||||
// a −1.00 "withdrawal" that splits the cycle.
|
||||
const lagging = reconcileDay([
|
||||
{ at: 0, balance: -2.16, trades: -3003.32 },
|
||||
{ at: 60, balance: -2.16, trades: -3003.32 },
|
||||
{ at: 120, balance: -3004.32, trades: -3003.32 },
|
||||
{ at: 180, balance: -3004.32, trades: -3003.32 },
|
||||
]);
|
||||
assert.deepEqual(lagging.kinds, ['unconfirmed', 'book', 'unconfirmed', 'drop']);
|
||||
assert.equal(lagging.steps[1].amount, 3001.16);
|
||||
assert.equal(lagging.steps[3].net, -1);
|
||||
assert.equal(lagging.row, null);
|
||||
|
||||
// A payout landing with the reversal is real: the day keeps its row.
|
||||
const withPayout = reconcileDay([
|
||||
{ at: 0, balance: -2.16, trades: -3003.32 },
|
||||
{ at: 60, balance: -2.16, trades: -3003.32 },
|
||||
{ at: 120, balance: -7624.32, trades: -3003.32 },
|
||||
{ at: 180, balance: -7624.32, trades: -3003.32 },
|
||||
]);
|
||||
assert.deepEqual(withPayout.kinds, ['unconfirmed', 'book', 'unconfirmed', 'book']);
|
||||
assert.equal(withPayout.row, -4621);
|
||||
|
||||
// Only a day reconcileFunds booked itself is taken back: a row it found
|
||||
// there (an import) merges as before.
|
||||
const imported = reconcileDay([
|
||||
{ at: 0, balance: -1, trades: 0 },
|
||||
{ at: 60, balance: -1, trades: 0 },
|
||||
], -4620);
|
||||
assert.deepEqual(imported.kinds, ['unconfirmed', 'book']);
|
||||
assert.equal(imported.row, -1);
|
||||
});
|
||||
|
||||
test('machineSide: one side per machine, across every lane', () => {
|
||||
// Nothing held, nothing being placed: free.
|
||||
assert.equal(machineSide([], null), null);
|
||||
// Vest shorts held (hand trades or the bot's): every lane must short.
|
||||
assert.equal(machineSide([-42.6545, -12.1], null), 'Sell');
|
||||
// A long NQ in the futures lane: the Vest lane must go long.
|
||||
assert.equal(machineSide([3], null), 'Buy');
|
||||
// Another lane's batch is placing longs right now, nothing filled yet.
|
||||
assert.equal(machineSide([], 'Buy'), 'Buy');
|
||||
assert.equal(machineSide([2], 'Buy'), 'Buy');
|
||||
// Both sides already: nothing may be added.
|
||||
assert.equal(machineSide([1, -1], null), 'mixed');
|
||||
assert.equal(machineSide([-5], 'Buy'), 'mixed');
|
||||
});
|
||||
|
||||
test('same-day stages: today\'s payouts pick the stage, and a stage may carry its own daily limit', () => {
|
||||
// The Vest 50k Funded type: $1,500 then claim, then $3,000 on the room the claim freed.
|
||||
const stages = [{ profit: 3000, consistency: 0, minTradingDays: 0, dailyLossLimit: 3000 }];
|
||||
const fundRows = [{ date: '2026-10-20', amount: 50000 }, { date: '2026-10-25', amount: -1500 }];
|
||||
const resolve = (inputs) => resolveEffectiveConfig(1500, 0, 0, false, stages, 0, fundRows, 0, inputs);
|
||||
|
||||
// Base stage, base limit: yesterday's payout doesn't count for a same-day type.
|
||||
const fresh = resolve({ dailyLossLimit: 1229, stagesSameDay: true, sameDayWithdrawals: 0 });
|
||||
assert.equal(fresh.profitTarget, 1500);
|
||||
assert.equal(fresh.dailyLossLimit, 1229);
|
||||
// After a claim today: stage 2, its own limit.
|
||||
const after = resolve({ dailyLossLimit: 1229, stagesSameDay: true, sameDayWithdrawals: 1 });
|
||||
assert.equal(after.profitTarget, 3000);
|
||||
assert.equal(after.dailyLossLimit, 3000);
|
||||
// A second claim today: the last stage repeats.
|
||||
assert.equal(resolve({ dailyLossLimit: 1229, stagesSameDay: true, sameDayWithdrawals: 2 }).profitTarget, 3000);
|
||||
// Without same-day stages the lifetime count picks it, as it always has.
|
||||
assert.equal(resolve({ dailyLossLimit: 1229 }).profitTarget, 3000);
|
||||
// A stage without its own limit keeps the type's; no inputs = no limit.
|
||||
const plain = resolveEffectiveConfig(1500, 0, 0, false, [{ profit: 2000, consistency: 0, minTradingDays: 0 }], 0, fundRows, 0, { dailyLossLimit: 1229 });
|
||||
assert.equal(plain.dailyLossLimit, 1229);
|
||||
assert.equal(resolveEffectiveConfig(1500, 0, 0, false, [], 0, [], 0).dailyLossLimit, 0);
|
||||
|
||||
// stageInputsFor asks the client only for a same-day type.
|
||||
const client = { sameDayClaims: () => 1 };
|
||||
assert.deepEqual(stageInputsFor(client, 7, { dailyLossLimit: 1229, stagesSameDay: true }), { dailyLossLimit: 1229, stagesSameDay: true, sameDayWithdrawals: 1 });
|
||||
assert.deepEqual(stageInputsFor(client, 7, { dailyLossLimit: 1229 }), { dailyLossLimit: 1229, stagesSameDay: false, sameDayWithdrawals: 0 });
|
||||
assert.deepEqual(stageInputsFor({}, 7, { stagesSameDay: true }), { dailyLossLimit: 0, stagesSameDay: true, sameDayWithdrawals: 0 });
|
||||
});
|
||||
|
||||
test('floorRoom: what\'s left of the stage\'s limit, widened by the day\'s gain the client allows, never past the platform\'s floor', () => {
|
||||
// Fresh day: $50k, Vest floor $48,500, the type's $1,229.
|
||||
assert.equal(floorRoom({ dailyLossLimit: 1229, closedBalance: 50000, floor: 48500, realizedToday: 0 }), 1229);
|
||||
// Lost $1,111.65 today: Vest still allows $388.35, but the bot's own
|
||||
// $1,229 day has only $117.35 left.
|
||||
assert.equal(floorRoom({ dailyLossLimit: 1229, closedBalance: 48888.35, floor: 48500, realizedToday: -1111.65 }), 117.35);
|
||||
// A green day alone doesn't widen the limit...
|
||||
assert.equal(floorRoom({ dailyLossLimit: 1229, closedBalance: 50600, floor: 48500, realizedToday: 600 }), 1229);
|
||||
// ...the gain the client allows does: the next leg may give it back plus
|
||||
// the limit, as on Dingo's 2026-10-27 evals ($1,400 a day, up $2,604.24)...
|
||||
assert.equal(floorRoom({ dailyLossLimit: 1229, closedBalance: 50600, floor: 48500, realizedToday: 600, gain: 600 }), 1829);
|
||||
assert.equal(floorRoom({ dailyLossLimit: 1400, closedBalance: 52604.24, floor: 48500, realizedToday: 2604.24, gain: 2604.24 }), 4004.24);
|
||||
// ...never past Vest's own floor...
|
||||
assert.equal(floorRoom({ dailyLossLimit: 1400, closedBalance: 52604.24, floor: 49000, realizedToday: 2604.24, gain: 2604.24 }), 3604.24);
|
||||
// ...and never by more than the day realized: $2,250 claimed by hand
|
||||
// leaves realized at $2,250 (added back) but none of it in the account.
|
||||
assert.equal(floorRoom({ dailyLossLimit: 1229, closedBalance: 50000, floor: 46250, realizedToday: 2250, gain: 0 }), 1229);
|
||||
assert.equal(floorRoom({ dailyLossLimit: 1229, closedBalance: 50600, floor: 48500, realizedToday: 600, gain: 5000 }), 1829);
|
||||
// A red day still counts in full, whatever gain is offered.
|
||||
assert.equal(floorRoom({ dailyLossLimit: 1229, closedBalance: 49500, floor: 48500, realizedToday: -500, gain: 300 }), 729);
|
||||
// Won $1,500 and claimed it: balance $50k, Vest's floor lowered to $47k,
|
||||
// the day's P&L counted from the claim. Stage 2's $3,000 gets all of it.
|
||||
assert.equal(floorRoom({ dailyLossLimit: 3000, closedBalance: 50000, floor: 47000, realizedToday: 0 }), 3000);
|
||||
// ...and after a $500 stage-2 loss, $2,500 either way.
|
||||
assert.equal(floorRoom({ dailyLossLimit: 3000, closedBalance: 49500, floor: 47000, realizedToday: -500 }), 2500);
|
||||
// If Vest hadn't lowered the floor, only what it really leaves.
|
||||
assert.equal(floorRoom({ dailyLossLimit: 3000, closedBalance: 50000, floor: 48500, realizedToday: 0 }), 1500);
|
||||
// Below the floor already: negative room (no trade).
|
||||
assert.equal(floorRoom({ dailyLossLimit: 1229, closedBalance: 48400, floor: 48500, realizedToday: -1600 }), -371);
|
||||
assert.equal(floorRoom({ dailyLossLimit: 0, closedBalance: 50000, floor: 48500, realizedToday: -900 }), Infinity);
|
||||
});
|
||||
|
||||
test('the day after a claim: a fresh cycle that aims at the next stage\'s target', () => {
|
||||
// Claimed everything above $50k; P&L since the claim is 0, the cycle has no days yet.
|
||||
const plan = computeDayPlan(7, '2026-10-27', 3000, 0, [], 0, 0, 0, 0, false, false);
|
||||
assert.ok(plan.dayTarget >= 3000 && plan.dayTarget <= 3025, `dayTarget ${plan.dayTarget}`);
|
||||
assert.equal(plan.remainingToday, plan.dayTarget);
|
||||
});
|
||||
|
||||
// ── what an order is for (lib/platform-client.ts orderEffect) ────────────────
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { orderEffect, OrderNotPlacedError, NothingToCloseError, isOrderNotPlaced, isNothingToClose } from '../lib/platform-client.ts';
|
||||
|
||||
test('an entry never closes a position, a flatten never opens one (Vest, DXtrade)', () => {
|
||||
/** The route, or the refusal thrown. `recorded`: the client's own record
|
||||
* of the symbol, by default what the fresh read shows. */
|
||||
const effect = (held, side, intent, recorded = held) => {
|
||||
try {
|
||||
return orderEffect({ symbol: 'NDX-USD-PERP', held, recorded, side, intent });
|
||||
} catch (err) {
|
||||
return err;
|
||||
}
|
||||
};
|
||||
// No intent: the position decides, as before (the manual order route).
|
||||
assert.equal(effect(null, 'long'), 'open');
|
||||
assert.equal(effect('short', 'long'), 'close');
|
||||
assert.match(effect('long', 'long').message, /adding to an open NDX-USD-PERP position/);
|
||||
// An entry on an account that already holds the symbol — a hand short the
|
||||
// engine's synced positions didn't show yet — is refused (the leg is
|
||||
// un-counted), not turned into a close of that short.
|
||||
assert.equal(effect(null, 'long', 'open'), 'open');
|
||||
for (const recorded of ['short', null]) {
|
||||
const entry = effect('short', 'long', 'open', recorded);
|
||||
assert.equal(isOrderNotPlaced(entry), true);
|
||||
assert.equal(isNothingToClose(entry), false);
|
||||
assert.match(entry.message, /short NDX-USD-PERP position is already open/);
|
||||
}
|
||||
assert.match(effect('long', 'long', 'open').message, /adding to an open/);
|
||||
// A flatten closes the leg it was sent for; on a flat account it opens
|
||||
// nothing, and says there is nothing to close when the record agrees.
|
||||
assert.equal(effect('long', 'short', 'close'), 'close');
|
||||
const flat = effect(null, 'short', 'close');
|
||||
assert.ok(flat instanceof NothingToCloseError);
|
||||
assert.equal(isOrderNotPlaced(flat), true);
|
||||
assert.equal(isNothingToClose(flat), true);
|
||||
assert.match(flat.message, /no NDX-USD-PERP position to close/);
|
||||
// A flat read just after a fill the client saw (the leg still on record)
|
||||
// may be the gateway lagging: nothing sent, but not "flat" — the flatten
|
||||
// keeps its alarm.
|
||||
const lagging = effect(null, 'short', 'close', 'long');
|
||||
assert.ok(lagging instanceof OrderNotPlacedError);
|
||||
assert.equal(isNothingToClose(lagging), false);
|
||||
assert.match(lagging.message, /fresh read shows no NDX-USD-PERP position, but this client last saw a long one open/);
|
||||
// Holding the flatten's own side is not the leg: refused, and not "flat".
|
||||
const wrong = effect('short', 'short', 'close');
|
||||
assert.ok(wrong instanceof OrderNotPlacedError);
|
||||
assert.equal(isNothingToClose(wrong), false);
|
||||
assert.match(wrong.message, /holds a short NDX-USD-PERP position, not one to close/);
|
||||
|
||||
assert.equal(isNothingToClose(new OrderNotPlacedError('Vest refused the order: 400')), false);
|
||||
assert.equal(isNothingToClose(new Error('outcome unknown')), false);
|
||||
assert.equal(isNothingToClose(undefined), false);
|
||||
});
|
||||
|
||||
test('the engine says what each order is for, and both position-based clients go by it', () => {
|
||||
const source = (file) => readFileSync(new URL(`../lib/${file}`, import.meta.url), 'utf8');
|
||||
// An order sent without its intent lets the position decide again: an
|
||||
// entry closing a hand trade, a flatten opening a position with no exits.
|
||||
const calls = [...source('auto-trade.ts').matchAll(/client\.sendOrder\(([^;]+?)\);/g)].map((m) => m[1].trim());
|
||||
assert.ok(calls.length >= 4, `found ${calls.length} sendOrder calls`);
|
||||
for (const args of calls) assert.match(args, /, '(open|close)'$/, `sendOrder(${args}) names no intent`);
|
||||
for (const file of ['vest-class.ts', 'dxtrade-class.ts']) {
|
||||
const src = source(file);
|
||||
assert.match(src, /return await this\.sendOrderAndWait\([^)]*, intent\);/, `${file}: sendOrder drops the intent`);
|
||||
assert.match(src, /= orderEffect\(\{[^}]*\bintent\b[^}]*\}\) === 'close' \? held : undefined;/, `${file}: orderEffect doesn't pick the route`);
|
||||
}
|
||||
});
|
||||
|
||||
// The Vest 50k Funded type: $1,500 and claim it all, then $3,000 on the room
|
||||
// the claim freed — and every new day back at $1,500.
|
||||
const vestFunded = {
|
||||
profitTarget: 1500, consistency: 0, minDayPnL: 0, minTradingDays: 0, accountSize: 50000,
|
||||
targetSameEquity: false, splitEnabled: true, payoutable: true, tradesEveryDay: true, combineCmeHolidays: false,
|
||||
withdrawalStages: [{ profit: 3000, consistency: 0, minTradingDays: 0, dailyLossLimit: 3000 }],
|
||||
};
|
||||
const claimedBefore = [{ date: '2026-10-20', amount: 50000 }, { date: '2026-10-25', amount: -1500 }];
|
||||
|
||||
test('projectPerfectPath: a same-day type trades each projected day from the base stage, then its next stages that day', () => {
|
||||
const opts = {
|
||||
accountId: 7, cfg: vestFunded, cycleDailyPnL: [], fundTransactions: claimedBefore,
|
||||
balance: 50000, realizedPnL: 0, payout: { withdrawalPct: 1 }, now: cdt('2026-10-27T12:00:00'),
|
||||
};
|
||||
const firstOf = (path, n) => path.slice(0, n).map((d) => [d.date, d.stage, d.withdrawal]);
|
||||
|
||||
// An earlier day's claim doesn't count: today and every day after it
|
||||
// make $1,500 on stage 1 and claim it, then $3,000 on stage 2 and claim
|
||||
// that — once through the stages a day.
|
||||
const fresh = projectPerfectPath({ ...opts, stageInputs: { dailyLossLimit: 1229, stagesSameDay: true, sameDayWithdrawals: 0 } });
|
||||
assert.deepEqual(firstOf(fresh, 5), [
|
||||
['2026-10-27', 1, -1500], ['2026-10-27', 2, -3000],
|
||||
['2026-10-28', 1, -1500], ['2026-10-28', 2, -3000],
|
||||
['2026-10-29', 1, -1500],
|
||||
]);
|
||||
|
||||
// Claimed once already today: today's cycle is stage 2's $3,000, tomorrow $1,500 and $3,000 again.
|
||||
const claimedToday = projectPerfectPath({ ...opts, stageInputs: { dailyLossLimit: 1229, stagesSameDay: true, sameDayWithdrawals: 1 } });
|
||||
assert.deepEqual(firstOf(claimedToday, 3), [['2026-10-27', 2, -3000], ['2026-10-28', 1, -1500], ['2026-10-28', 2, -3000]]);
|
||||
|
||||
// A cycle that stood complete yesterday is claimed today, so today trades stage 2.
|
||||
const unclaimed = projectPerfectPath({
|
||||
...opts, cycleDailyPnL: [{ date: '2026-10-26', pnl: 1500 }], balance: 51500,
|
||||
stageInputs: { dailyLossLimit: 1229, stagesSameDay: true, sameDayWithdrawals: 0 },
|
||||
});
|
||||
assert.deepEqual(firstOf(unclaimed, 3), [['2026-10-26', 1, -1500], ['2026-10-27', 2, -3000], ['2026-10-28', 1, -1500]]);
|
||||
|
||||
// Two listed stages: three payouts a day, and the day after starts over.
|
||||
const twoStages = projectPerfectPath({
|
||||
...opts, cfg: { ...vestFunded, withdrawalStages: [...vestFunded.withdrawalStages, { profit: 2000, consistency: 0, minTradingDays: 0 }] },
|
||||
maxWithdrawals: 10, stageInputs: { stagesSameDay: true, sameDayWithdrawals: 0 },
|
||||
});
|
||||
assert.deepEqual(firstOf(twoStages, 4).map(([date, stage]) => [date, stage]), [
|
||||
['2026-10-27', 1], ['2026-10-27', 2], ['2026-10-27', 3], ['2026-10-28', 1],
|
||||
]);
|
||||
|
||||
// Any other type counts every withdrawal, as it always has.
|
||||
const lifetime = projectPerfectPath(opts);
|
||||
assert.deepEqual(firstOf(lifetime, 3), [['2026-10-27', 2, -3000], ['2026-10-28', 3, -3000], ['2026-10-29', 4, -3000]]);
|
||||
assert.deepEqual(projectPerfectPath({ ...opts, stageInputs: { dailyLossLimit: 1229, stagesSameDay: false, sameDayWithdrawals: 0 } }), lifetime);
|
||||
});
|
||||
|
||||
test('projectPerfectPath: today is the raw trade date it is given — Vest\'s, still the day before from 5 PM CT to 20:00 ET', () => {
|
||||
const firstOf = (path, n) => path.slice(0, n).map((d) => [d.date, d.stage, d.withdrawal]);
|
||||
// Claimed once on Vest day 10-27 and $1,000 made since.
|
||||
const claimed = {
|
||||
accountId: 7, cfg: vestFunded, cycleDailyPnL: [], fundTransactions: [{ date: '2026-10-20', amount: 50000 }, { date: '2026-10-27', amount: -1500 }],
|
||||
balance: 51000, realizedPnL: 1000, payout: { withdrawalPct: 1 }, stageInputs: { stagesSameDay: true, sameDayWithdrawals: 1 },
|
||||
};
|
||||
const before = projectPerfectPath({ ...claimed, now: cdt('2026-10-27T16:30:00') });
|
||||
assert.deepEqual(firstOf(before, 3), [['2026-10-27', 2, -3000], ['2026-10-28', 1, -1500], ['2026-10-28', 2, -3000]]);
|
||||
// 5:30 PM CT: the Central date has rolled, Vest's hasn't — the day's
|
||||
// claim stays on it, and 10-28 keeps its stage-1 payout.
|
||||
assert.deepEqual(projectPerfectPath({ ...claimed, now: cdt('2026-10-27T17:30:00'), rawTradeDate: '2026-10-27' }), before);
|
||||
// No date given: the Central one, as before.
|
||||
assert.equal(projectPerfectPath({ ...claimed, now: cdt('2026-10-27T17:30:00') })[0].date, '2026-10-28');
|
||||
|
||||
// No claim yet, Vest day 10-27's row already booked: counted once, as today's.
|
||||
const open = {
|
||||
...claimed, fundTransactions: claimedBefore, cycleDailyPnL: [{ date: '2026-10-27', pnl: 1000 }],
|
||||
stageInputs: { stagesSameDay: true, sameDayWithdrawals: 0 },
|
||||
};
|
||||
const earlier = projectPerfectPath({ ...open, now: cdt('2026-10-27T16:30:00') });
|
||||
assert.deepEqual(firstOf(earlier, 2), [['2026-10-27', 1, -1500], ['2026-10-27', 2, -3000]]);
|
||||
assert.deepEqual(projectPerfectPath({ ...open, now: cdt('2026-10-27T17:30:00'), rawTradeDate: '2026-10-27' }), earlier);
|
||||
});
|
||||
|
||||
test('stages without the same-day reset count each payout, where one fund row holds a day\'s two (Vest)', () => {
|
||||
// Two listed stages; claims of $1,500 and $3,000 on Vest day 10-27 share its row.
|
||||
const stages = [{ profit: 3000, consistency: 0, minTradingDays: 0 }, { profit: 2000, consistency: 0, minTradingDays: 0 }];
|
||||
const rows = [{ date: '2026-10-20', amount: 50000 }, { date: '2026-10-27', amount: -4500 }];
|
||||
const resolve = (inputs) => resolveEffectiveConfig(1500, 0, 0, false, stages, 0, rows, 0, inputs);
|
||||
assert.equal(resolve({ payouts: 2 }).profitTarget, 2000);
|
||||
// No payout list: one per negative fund row, as always.
|
||||
assert.equal(resolve({}).profitTarget, 3000);
|
||||
// A same-day type still counts only today's.
|
||||
assert.equal(resolve({ stagesSameDay: true, sameDayWithdrawals: 0, payouts: 2 }).profitTarget, 1500);
|
||||
|
||||
// stageInputsFor takes the count from the client's payout list, when it has read one.
|
||||
const client = { sameDayClaims: () => 2, payoutDays: () => ['2026-10-27', '2026-10-27'] };
|
||||
assert.deepEqual(stageInputsFor(client, 7, {}), { dailyLossLimit: 0, stagesSameDay: false, sameDayWithdrawals: 0, payouts: 2 });
|
||||
assert.deepEqual(stageInputsFor({ payoutDays: () => undefined }, 7, {}), { dailyLossLimit: 0, stagesSameDay: false, sameDayWithdrawals: 0 });
|
||||
assert.deepEqual(stageInputsFor(client, 7, { stagesSameDay: true }), { dailyLossLimit: 0, stagesSameDay: true, sameDayWithdrawals: 2 });
|
||||
// The state route's stage label counts the same way.
|
||||
assert.equal(stagePayouts(rows, stageInputsFor(client, 7, {})), 2);
|
||||
assert.equal(stagePayouts(rows, stageInputsFor({}, 7, {})), 1);
|
||||
|
||||
// The projection starts from the same count: stage 3's $2,000 next, then it repeats.
|
||||
const opts = {
|
||||
accountId: 7, cfg: { ...vestFunded, withdrawalStages: stages }, cycleDailyPnL: [], fundTransactions: rows,
|
||||
balance: 50000, realizedPnL: 0, payout: { withdrawalPct: 1 }, now: cdt('2026-10-27T12:00:00'),
|
||||
};
|
||||
const firstOf = (path, n) => path.slice(0, n).map((d) => [d.date, d.stage, d.withdrawal]);
|
||||
assert.deepEqual(firstOf(projectPerfectPath({ ...opts, stageInputs: stageInputsFor(client, 7, {}) }), 2), [['2026-10-27', 3, -2000], ['2026-10-28', 4, -2000]]);
|
||||
assert.deepEqual(firstOf(projectPerfectPath(opts), 2), [['2026-10-27', 2, -3000], ['2026-10-28', 3, -2000]]);
|
||||
});
|
||||
|
||||
test('endedCycleCompletion: a same-day type\'s ended cycle is judged on its day\'s base stage', () => {
|
||||
// 2026-10-26 made $1,500 and the auto-claim took it before the reporter
|
||||
// saw the final day; the account had claimed the day before too. The
|
||||
// claim ended that day's stage 1, not a $3,000 stage 2.
|
||||
const opts = {
|
||||
accountId: 7, rawTradeDate: '2026-10-26',
|
||||
fullDailyPnL: [{ date: '2026-10-25', pnl: 1500 }, { date: '2026-10-26', pnl: 1500 }],
|
||||
cycleDailyPnL: [],
|
||||
fundTransactions: [...claimedBefore, { date: '2026-10-26', amount: -1500 }],
|
||||
lastFundDate: '2026-10-26',
|
||||
endedCycleStart: { fundDate: '2026-10-25', includesDay: false },
|
||||
rawRealizedPnL: 0, equityProfit: 0,
|
||||
cfg: { ...vestFunded, stagesSameDay: true },
|
||||
now: cdt('2026-10-26T12:00:00'),
|
||||
};
|
||||
// Its stage stays the payout's number among the account's withdrawals,
|
||||
// which the master clears the entry by.
|
||||
assert.deepEqual(endedCycleCompletion(opts), { date: '2026-10-26', stage: 2, drain: false, cycle: '2026-10-25' });
|
||||
// Any other type: judged on stage 2's $3,000, as before.
|
||||
assert.equal(endedCycleCompletion({ ...opts, cfg: vestFunded }), null);
|
||||
});
|
||||
|
||||
import { claimedCycle, withinEndedCycleWindow } from '../lib/trading-logic.ts';
|
||||
|
||||
test('claimedCycle: a claim names the complete cycle it ends, where the final day and the catch-up can\'t', () => {
|
||||
const cfg = { ...vestFunded, stagesSameDay: true, dailyLossLimit: 1500 };
|
||||
const activated = { date: '2026-10-20', amount: 50000 };
|
||||
/** A Vest client's numbers for account 7, just before a claim. */
|
||||
/** The cycle a claim names, its claim time checked and left out. */
|
||||
const claimed = (...args) => {
|
||||
const c = claimedCycle(...args);
|
||||
if (c) { assert.equal(c.at, args[3].getTime()); delete c.at; }
|
||||
return c;
|
||||
};
|
||||
const client = (o) => ({
|
||||
accountCashBalances: { 7: o.cash }, dailyPnL: { 7: o.cycle ?? [] }, fundTransactions: { 7: o.rows },
|
||||
lastFundDates: { 7: o.last }, priorProfit: { 7: 0 },
|
||||
tradeDate: () => o.day, sameDayClaims: () => o.today, payoutDays: o.payouts && (() => o.payouts),
|
||||
});
|
||||
|
||||
// Vest day 10-27, 6 PM ET: claim 1 took stage 1's $1,500 at 3 PM, and
|
||||
// stage 2 has made its $3,000 since. The auto-claim at the next tick
|
||||
// beats the 30 s report, and its payout books into 10-27's row: the
|
||||
// latest fund date never moves for the catch-up.
|
||||
const claimedOnce = [activated, { date: '2026-10-27', amount: -1500 }];
|
||||
const second = { cash: { amount: 53000, realizedPnL: 3000 }, rows: claimedOnce, last: '2026-10-27', day: '2026-10-27', today: 1, payouts: ['2026-10-27'] };
|
||||
assert.deepEqual(claimed(client(second), 7, cfg, cdt('2026-10-27T17:00:00')),
|
||||
{ date: '2026-10-27', stage: 2, drain: false, cycle: '2026-10-27', cycleSeq: 0 });
|
||||
// A Claim now short of stage 2's $3,000 names nothing.
|
||||
assert.equal(claimed(client({ ...second, cash: { amount: 51000, realizedPnL: 1000 } }), 7, cfg, cdt('2026-10-27T17:00:00')), null);
|
||||
// The auto-claim takes the day as done within $25 of the target (claimDue),
|
||||
// and names the cycle at the same tolerance — not further short.
|
||||
const shortBy10 = client({ ...second, cash: { amount: 52990, realizedPnL: 2990 } });
|
||||
assert.equal(claimed(shortBy10, 7, cfg, cdt('2026-10-27T17:00:00')), null);
|
||||
assert.deepEqual(claimed(shortBy10, 7, cfg, cdt('2026-10-27T17:00:00'), 25),
|
||||
{ date: '2026-10-27', stage: 2, drain: false, cycle: '2026-10-27', cycleSeq: 0 });
|
||||
assert.equal(claimed(client({ ...second, cash: { amount: 52900, realizedPnL: 2900 } }), 7, cfg, cdt('2026-10-27T17:00:00'), 25), null);
|
||||
// The day's third cycle, after that claim, has a key of its own.
|
||||
const twice = [activated, { date: '2026-10-27', amount: -4500 }];
|
||||
assert.deepEqual(claimed(client({ ...second, rows: twice, today: 2, payouts: ['2026-10-27', '2026-10-27'] }), 7, cfg, cdt('2026-10-27T17:00:00')),
|
||||
{ date: '2026-10-27', stage: 3, drain: false, cycle: '2026-10-27', cycleSeq: 1 });
|
||||
|
||||
// Stage 2 made only $1,500 of its $3,000 by the 20:00 ET reset, which
|
||||
// puts the account back on stage 1's $1,500: claimed at the first tick,
|
||||
// 8:00:30 PM ET.
|
||||
const afterReset = { cash: { amount: 51500, realizedPnL: 0 }, rows: claimedOnce, last: '2026-10-27', day: '2026-10-28', today: 0, payouts: ['2026-10-27'] };
|
||||
assert.deepEqual(claimed(client(afterReset), 7, cfg, cdt('2026-10-27T19:00:30')),
|
||||
{ date: '2026-10-28', stage: 2, drain: false, cycle: '2026-10-27', cycleSeq: 0 });
|
||||
// The catch-up after it reads none of that cycle's P&L, all in 10-27's
|
||||
// row, which the cycle before owns.
|
||||
assert.equal(endedCycleCompletion({
|
||||
accountId: 7, rawTradeDate: '2026-10-28', fullDailyPnL: [{ date: '2026-10-27', pnl: 3000 }], cycleDailyPnL: [],
|
||||
fundTransactions: [...claimedOnce, { date: '2026-10-28', amount: -1500 }], lastFundDate: '2026-10-28',
|
||||
endedCycleStart: { fundDate: '2026-10-27', includesDay: false }, rawRealizedPnL: 0, equityProfit: 0, cfg, now: cdt('2026-10-27T19:10:00'),
|
||||
}), null);
|
||||
|
||||
// Where the catch-up does name the cycle — its P&L on a day of its own —
|
||||
// the claim names it by the same key, so the master keeps one.
|
||||
const crossDay = { cash: { amount: 51500, realizedPnL: 1500 }, rows: claimedBefore, last: '2026-10-25', day: '2026-10-26', today: 0, payouts: ['2026-10-25'] };
|
||||
assert.deepEqual(claimed(client(crossDay), 7, cfg, cdt('2026-10-26T12:00:00')),
|
||||
{ date: '2026-10-26', stage: 2, drain: false, cycle: '2026-10-25', cycleSeq: 0 });
|
||||
// Without a payout list, one payout per negative fund row.
|
||||
assert.equal(claimed(client({ ...crossDay, payouts: undefined }), 7, cfg, cdt('2026-10-26T12:00:00')).stage, 2);
|
||||
// Numbers not read yet: nothing.
|
||||
assert.equal(claimed({ ...client(crossDay), accountCashBalances: {} }, 7, cfg, cdt('2026-10-26T12:00:00')), null);
|
||||
assert.equal(claimed({ ...client(crossDay), fundTransactions: {} }, 7, cfg, cdt('2026-10-26T12:00:00')), null);
|
||||
});
|
||||
|
||||
test('withinEndedCycleWindow: a week after the date, and never for one that doesn\'t parse', () => {
|
||||
assert.equal(withinEndedCycleWindow('2026-10-21', '2026-10-28'), true);
|
||||
assert.equal(withinEndedCycleWindow('2026-10-28', '2026-10-28'), true);
|
||||
assert.equal(withinEndedCycleWindow('2026-10-20', '2026-10-28'), false);
|
||||
assert.equal(withinEndedCycleWindow('', '2026-10-28'), false);
|
||||
assert.equal(withinEndedCycleWindow('10/27', '2026-10-28'), false);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,771 @@
|
||||
// Checks for the Vest client's own wiring (lib/vest-class.ts), past what its
|
||||
// pure helpers show: the market-close guard closing a leg whose open came back
|
||||
// unknown, and the candidate board keeping a symbol the close gate holds back;
|
||||
// a sync older than the bot's own fill, an open a restart cut off, an outside
|
||||
// trade beside an open of unknown outcome, and the client's day at 8 PM ET;
|
||||
// the completed cycles a Vest day's claims end, named at each claim; a
|
||||
// refused account-token mint, a new account's history read again within the
|
||||
// minute, and exits that wait out a lapse in the session.
|
||||
//
|
||||
// npm run check
|
||||
//
|
||||
// A scratch database, a fake clock, and every gateway call answered here —
|
||||
// nothing reaches Vest.
|
||||
import { after, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { registerHooks, stripTypeScriptTypes } from 'node:module';
|
||||
|
||||
// Resolve the extensionless local imports used by Next, without a build.
|
||||
function resolveTypes(specifier, context, nextResolve) {
|
||||
try {
|
||||
return nextResolve(specifier, context);
|
||||
} catch (err) {
|
||||
if (err.code !== 'ERR_MODULE_NOT_FOUND' || !specifier.startsWith('.')) throw err;
|
||||
return nextResolve(`${specifier}.ts`, context);
|
||||
}
|
||||
}
|
||||
// The client's classes use parameter properties, which plain type
|
||||
// stripping refuses: transform them instead.
|
||||
function loadTypes(url, context, nextLoad) {
|
||||
if (!url.startsWith('file:') || !url.endsWith('.ts')) return nextLoad(url, context);
|
||||
const source = stripTypeScriptTypes(readFileSync(fileURLToPath(url), 'utf8'), { mode: 'transform' });
|
||||
return { format: 'module', source, shortCircuit: true };
|
||||
}
|
||||
registerHooks({ resolve: resolveTypes, load: loadTypes });
|
||||
|
||||
const directory = mkdtempSync(join(tmpdir(), 'autofirmer-vest-client-'));
|
||||
process.env.AUTOFIRMER_DB = join(directory, 'test.sqlite');
|
||||
delete process.env.NEXT_PHASE;
|
||||
after(() => rmSync(directory, { recursive: true, force: true }));
|
||||
|
||||
// No request leaves this process: axios refuses every one, and each client's
|
||||
// gateway is a function in this file — from construction on, since the
|
||||
// constructor starts a sync.
|
||||
const { default: axios } = await import('../lib/http-client.ts');
|
||||
axios.defaults.adapter = (config) => Promise.reject(new Error(`no network in this test: ${config.method} ${config.url}`));
|
||||
const { VestClient } = await import('../lib/vest-class.ts');
|
||||
// The client's own transport, for the checks of what it does with an answer.
|
||||
const realRequest = VestClient.prototype.request;
|
||||
let gateway = null;
|
||||
VestClient.prototype.request = async function (bucket, method, path, opts = {}) {
|
||||
// Calls on an account's token reach the gateway here without one, so its
|
||||
// mint is answered here too.
|
||||
if (opts.mint) return { data: { accessToken: `eyJ.${opts.body.accountId}.sig`, accessExpiresAtMs: Date.now() + 600_000 } };
|
||||
if (!gateway) throw new Error(`no gateway in this test: ${method} ${path}`);
|
||||
return gateway(method, path, opts);
|
||||
};
|
||||
const { putVestSession, vestSessionFor, dropVestSession } = await import('../lib/vest-session.ts');
|
||||
const {
|
||||
botOpenedPositions, loadAccountMeta, saveAccountMeta, saveFundTransactions, wipeAccountData, loadClaimedCycles, saveClaimedCycles,
|
||||
} = await import('../lib/db.ts');
|
||||
const { vestDayOf, vestAccountId, VestApiError, VEST_STATUS_ACTIVE } = await import('../lib/vest-helpers.ts');
|
||||
const { encodeContractName } = await import('../lib/dxtrade-helpers.ts');
|
||||
const { tradeDateFor, stageInputsFor, resolveEffectiveConfig, claimedCycle, endedCycleCompletion } = await import('../lib/trading-logic.ts');
|
||||
const { isClientReady, isOrderNotPlaced } = await import('../lib/platform-client.ts');
|
||||
|
||||
const iso = (s) => Date.parse(s);
|
||||
const WALLET = '0x7ce10771819f80130e204ddb5ca1522e48a9b451';
|
||||
|
||||
// The clock the client reads (Date.now), set to an instant and running on.
|
||||
const realNow = Date.now;
|
||||
after(() => { Date.now = realNow; });
|
||||
let tokens = 0;
|
||||
function clockAt(when) {
|
||||
const start = realNow();
|
||||
const t0 = iso(when);
|
||||
Date.now = () => t0 + (realNow() - start);
|
||||
tokens += 1;
|
||||
assert.equal(putVestSession({ accessToken: `eyJ.test${tokens}.sig`, expiresAt: Date.now() + 3_600_000, primaryAddr: WALLET, userId: 'u' }), null);
|
||||
}
|
||||
|
||||
// The week of NDX hours from check-vest.mjs: maintenance at 21:00 UTC
|
||||
// (17:00 ET) Thursday, the weekly close at 21:00 UTC Friday.
|
||||
const open = (from, to) => ({ fromMs: iso(from), toMs: iso(to), state: { session: 'overnight', closure: 'unspecified', tradingStatus: 'OPEN', price: { allow: true } } });
|
||||
const shut = (from, to, closure) => ({ fromMs: iso(from), toMs: iso(to), state: { session: 'unspecified', closure, tradingStatus: closure === 'weekly' ? 'CLOSE' : 'OPEN', price: { allow: closure !== 'weekly' } } });
|
||||
const week = [
|
||||
open('2026-10-28T22:00:00Z', '2026-10-29T21:00:00Z'),
|
||||
shut('2026-10-29T21:00:00Z', '2026-10-29T22:00:00Z', 'maintenance'),
|
||||
open('2026-10-29T22:00:00Z', '2026-10-30T21:00:00Z'),
|
||||
shut('2026-10-30T21:00:00Z', '2026-11-01T23:00:00Z', 'weekly'),
|
||||
open('2026-11-01T23:00:00Z', '2026-11-02T22:00:00Z'),
|
||||
];
|
||||
const spec = (symbol, assetId) => ({
|
||||
symbol, assetId, sizeDecimals: 3, priceDecimals: 2, tick: 0.01, takerFee: 0.0001, marginRate: 0.05, tradingStatus: 'TRADING', capitalTradingStatus: 'TRADING',
|
||||
});
|
||||
const specs = new Map([['NDX-USD', spec('NDX-USD', 7)], ['BTC-PERP', spec('BTC-PERP', 9)]]);
|
||||
|
||||
/** A client with no sync running, its gateway answered by `answer`, holding
|
||||
* acc-1 and acc-2 under `ids` (the ids a sync gives them: A and B). */
|
||||
function testClient(answer, ids = [1, 2]) {
|
||||
gateway = null;
|
||||
const client = new VestClient({ firmId: 'vest-test', firmName: 'Vest test', wallet: WALLET }, async () => {});
|
||||
clearInterval(client.syncInterval);
|
||||
clearInterval(client.watchdogInterval);
|
||||
gateway = answer;
|
||||
client.specs = specs;
|
||||
client.symbolSpecs = async () => specs;
|
||||
client.marketHours = async (assetId) => (assetId === 7 ? week : []);
|
||||
client.accountList = ids.map((id, i) => ({ id, name: `V50K-F-D3-${i + 1}`, active: true }));
|
||||
ids.forEach((id, i) => {
|
||||
client.uuidById.set(id, `acc-${i + 1}`);
|
||||
client.capital.set(id, { status: VEST_STATUS_ACTIVE });
|
||||
});
|
||||
return client;
|
||||
}
|
||||
|
||||
const A = vestAccountId('acc-1');
|
||||
const B = vestAccountId('acc-2');
|
||||
const NDX = encodeContractName('NDX-USD', 0.001);
|
||||
const rawPosition = (positionId, accountId, openDate, extra = {}) => ({
|
||||
positionId, accountId, symbol: 'NDX-USD', side: 'LONG', quantity: '0.500', openPrice: '20000', collateral: '500', leverage: '20', openDate,
|
||||
stopLosses: [], takeProfits: [], ...extra,
|
||||
});
|
||||
|
||||
/** What a sync reads (acc-1 and acc-2 funded, $50k each, and the login's
|
||||
* main account; `positions` open), and the history reads after it. */
|
||||
function syncReads(method, path, positions) {
|
||||
if (method !== 'GET') return null;
|
||||
if (path === '/v3/capital/accounts/active') {
|
||||
return { data: { accounts: ['acc-1', 'acc-2'].map((id, i) => ({
|
||||
id, status: VEST_STATUS_ACTIVE, attempt_index: i + 1, initial_capital: '50000', max_daily_loss_pct: '0.03', stage: 1, stages: [{ ordinal: 1, kind: 'funded' }],
|
||||
})) } };
|
||||
}
|
||||
if (path === '/v3/accounts') {
|
||||
return { data: { accounts: [
|
||||
{ account_id: 'acc-1', amount: '50000', account_type: 2 }, { account_id: 'acc-2', amount: '50000', account_type: 2 }, { account_id: 'main', amount: '0', account_type: 1 },
|
||||
] } };
|
||||
}
|
||||
if (path === '/v3/positions/opened') return { data: { positions } };
|
||||
if (path === '/v3/positions/opened-orders') return { data: { orders: [] } };
|
||||
if (path === '/v3/ticker/latest') return { data: { tickers: [] } };
|
||||
if (path === '/v3/positions') return { data: { positions: [] } };
|
||||
return null;
|
||||
}
|
||||
|
||||
/** testClient on the ids a sync gives, once the constructor's own sync
|
||||
* (which reaches no gateway) is over, with its history taken as read — so
|
||||
* each requestSync here is one good sync, and starts nothing else. */
|
||||
async function syncingClient(answer) {
|
||||
const client = testClient(answer, [A, B]);
|
||||
await client.syncRunning;
|
||||
client.historyLoaded = true;
|
||||
client.historyLoadedIds = new Set([A, B]);
|
||||
client.lastDaysFetchStartedAt = Date.now();
|
||||
client.lastDaysFetchSucceededAt = Infinity;
|
||||
return client;
|
||||
}
|
||||
|
||||
const unanswered = (method, path) => { throw new Error(`no gateway in this test: ${method} ${path}`); };
|
||||
|
||||
test('the market-close guard closes a bot leg whose open came back unknown, not a hand trade', async () => {
|
||||
let positions = [];
|
||||
const closes = [];
|
||||
const client = testClient((method, path, opts) => {
|
||||
if (method === 'GET' && path === '/v3/positions/opened') return { data: { positions } };
|
||||
if (method === 'POST' && path === '/v3/positions/open') throw new Error('socket hang up');
|
||||
if (method === 'POST' && path === '/v3/positions/close') { closes.push({ account: opts.account, ...opts.body }); return { data: {} }; }
|
||||
throw new Error(`no gateway in this test: ${method} ${path}`);
|
||||
});
|
||||
try {
|
||||
// Friday 8:30 AM ET: the entry's send fails in transit, but it filled.
|
||||
clockAt('2026-10-30T12:30:00Z');
|
||||
await assert.rejects(client.sendOrder(1, encodeContractName('NDX-USD', 0.001), 500, 'Buy', 'Market'), /outcome unknown/);
|
||||
const raw = (positionId, accountId, openDate) => ({
|
||||
positionId, accountId, symbol: 'NDX-USD', side: 'LONG', quantity: '0.500', openPrice: '20000', collateral: '500', leverage: '20', openDate,
|
||||
stopLosses: [], takeProfits: [],
|
||||
});
|
||||
positions = [raw('9001', 'acc-1', iso('2026-10-30T12:30:01Z')), raw('9002', 'acc-2', iso('2026-10-30T11:00:00Z'))];
|
||||
client.positionsDetail.set(1, []);
|
||||
client.positionsDetail.set(2, [{ positionId: '9002', accountId: 'acc-2', symbol: 'NDX-USD', side: 'long', quantity: 0.5 }]);
|
||||
client.syncComplete = true;
|
||||
|
||||
// 4:56 PM ET: four minutes to the weekly close.
|
||||
clockAt('2026-10-30T20:56:00Z');
|
||||
await client.guardClose();
|
||||
assert.deepEqual(closes, [{ account: 'acc-1', positionId: '9001', symbol: 'NDX-USD', orderType: 'market' }]);
|
||||
assert.deepEqual([...botOpenedPositions(['9001', '9002'])], ['9001']);
|
||||
// A close just sent isn't sent again while a read may not show it.
|
||||
await client.guardClose();
|
||||
assert.equal(closes.length, 1);
|
||||
} finally {
|
||||
client.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
test('a symbol the close gate holds back stays on the board, and isn\'t picked; the maintenance hour is ridden', async () => {
|
||||
const client = testClient((method, path) => {
|
||||
throw new Error(`no gateway in this test: ${method} ${path}`);
|
||||
});
|
||||
client.refreshMarks = async () => {};
|
||||
client.volatility = async (symbols) => new Map(symbols.map((s) => [s, { vol: 0.004, candles: 168 }]));
|
||||
const marked = () => { for (const s of ['NDX-USD', 'BTC-PERP']) client.marks.set(s, { mark: s === 'NDX-USD' ? 20000 : 70000, at: Date.now() }); };
|
||||
try {
|
||||
// Thursday 2 PM ET: NDX's maintenance hour is 3 h off, and reopens
|
||||
// before the reset — no gate.
|
||||
clockAt('2026-10-29T18:00:00Z');
|
||||
marked();
|
||||
await client.pickCandidate('Auto', ['NDX-USD', 'BTC-PERP'], false);
|
||||
const thursday = client.lastCandidates.find((c) => c.id === 'NDX-USD');
|
||||
assert.equal(thursday.closesAt, undefined);
|
||||
|
||||
// Friday 2 PM ET: the weekly close is 3 h off; crypto never closes.
|
||||
clockAt('2026-10-30T18:00:00Z');
|
||||
marked();
|
||||
const pick = await client.pickCandidate('Auto', ['NDX-USD', 'BTC-PERP'], false);
|
||||
assert.equal(pick.id, 'BTC-PERP');
|
||||
assert.equal(pick.closesAt, undefined);
|
||||
assert.deepEqual(client.lastCandidates.map((c) => c.id).sort(), ['BTC-PERP', 'NDX-USD']);
|
||||
const ndx = client.lastCandidates.find((c) => c.id === 'NDX-USD');
|
||||
assert.equal(ndx.closesAt, iso('2026-10-30T21:00:00Z'));
|
||||
// The survey keeps it up for the dashboard, though nothing is picked.
|
||||
assert.equal(await client.pickCandidate('survey', ['NDX-USD'], false), null);
|
||||
assert.deepEqual(client.lastCandidates.map((c) => c.id), ['NDX-USD']);
|
||||
// And the client's day is Vest's.
|
||||
assert.equal(client.tradeDate(iso('2026-10-27T22:30:00Z')), vestDayOf(iso('2026-10-27T22:30:00Z')));
|
||||
} finally {
|
||||
client.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
test("a sync whose positions read predates the bot's own fill leaves the fill's record alone", async () => {
|
||||
let positions = [];
|
||||
let holdRead = false;
|
||||
let releaseRead = null;
|
||||
clockAt('2026-10-28T14:00:00Z');
|
||||
const client = await syncingClient((method, path) => {
|
||||
if (method === 'GET' && path === '/v3/positions/opened' && holdRead) {
|
||||
// Read now, flat — answered once released.
|
||||
holdRead = false;
|
||||
const served = positions;
|
||||
return new Promise((resolve) => { releaseRead = () => resolve({ data: { positions: served } }); });
|
||||
}
|
||||
const read = syncReads(method, path, positions);
|
||||
if (read) return read;
|
||||
if (method === 'POST' && path === '/v3/positions/open') {
|
||||
positions = [rawPosition('9101', 'acc-1', Date.now() + 300)];
|
||||
return { data: { orderId: 'o-9101', positionId: '9101' } };
|
||||
}
|
||||
return unanswered(method, path);
|
||||
});
|
||||
try {
|
||||
await client.requestSync();
|
||||
// A sync reads acc-1 flat; the entry fills while it is still out.
|
||||
holdRead = true;
|
||||
const sync = client.requestSync();
|
||||
await client.sendOrder(A, NDX, 500, 'Buy', 'Market', undefined, 'open');
|
||||
// Filled: the note kept against a restart goes.
|
||||
assert.equal(loadAccountMeta(A, 'vest_pending_open'), 'null');
|
||||
releaseRead();
|
||||
await sync;
|
||||
assert.deepEqual(client.positionsDetail.get(A).map((p) => p.positionId), ['9101']);
|
||||
assert.equal(client.positions[A].netPos, 0.5);
|
||||
// A read lagging the fill: the leg's flatten is refused as a position
|
||||
// still out there (FLATTEN FAILED), not taken as nothing to flatten.
|
||||
positions = [];
|
||||
await assert.rejects(client.sendOrder(A, NDX, 500, 'Sell', 'Market', undefined, 'close'), /last saw a long one open/);
|
||||
// A sync that went out after the fill writes what it reads.
|
||||
await client.requestSync();
|
||||
assert.deepEqual(client.positionsDetail.get(A), []);
|
||||
assert.equal(client.positions[A], undefined);
|
||||
} finally {
|
||||
client.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
test("an open a restart cut off is marked the bot's once a read after the restart shows it", async () => {
|
||||
let positions = [];
|
||||
let posted;
|
||||
const postedAt = new Promise((resolve) => { posted = resolve; });
|
||||
clockAt('2026-10-28T15:00:00Z');
|
||||
const before = await syncingClient((method, path) => {
|
||||
const read = syncReads(method, path, positions);
|
||||
if (read) return read;
|
||||
// The app stops with the open out.
|
||||
if (method === 'POST' && path === '/v3/positions/open') { posted(Date.now()); return new Promise(() => {}); }
|
||||
return unanswered(method, path);
|
||||
});
|
||||
void before.sendOrder(A, NDX, 500, 'Buy', 'Market', undefined, 'open').catch(() => {});
|
||||
const sentAt = await postedAt;
|
||||
before.disconnect();
|
||||
|
||||
// Vest filled it, and an outside long opened on acc-2 with it. acc-2 has
|
||||
// an older note, of a short that never showed.
|
||||
positions = [rawPosition('9201', 'acc-1', sentAt + 400), rawPosition('9202', 'acc-2', sentAt + 400)];
|
||||
saveAccountMeta(B, 'vest_pending_open', JSON.stringify({ accountId: 'acc-2', symbol: 'NDX-USD', side: 'short', sentAt: sentAt - 10 * 60_000 }));
|
||||
clockAt('2026-10-28T15:02:00Z');
|
||||
let refuse = false;
|
||||
const after = await syncingClient((method, path) => {
|
||||
const read = syncReads(method, path, positions);
|
||||
if (read) return read;
|
||||
if (method === 'POST' && path === '/v3/positions/open' && refuse) throw new VestApiError('/v3/positions/open: refused (HTTP 400)', 400, 'REFUSED');
|
||||
return unanswered(method, path);
|
||||
});
|
||||
try {
|
||||
await after.requestSync();
|
||||
assert.deepEqual([...botOpenedPositions(['9201', '9202'])], ['9201']);
|
||||
assert.equal(loadAccountMeta(A, 'vest_pending_open'), 'null');
|
||||
assert.equal(loadAccountMeta(B, 'vest_pending_open'), 'null');
|
||||
// An open Vest refuses leaves no note behind.
|
||||
positions = [];
|
||||
refuse = true;
|
||||
await assert.rejects(after.sendOrder(A, NDX, 500, 'Buy', 'Market', undefined, 'open'), /refused/);
|
||||
assert.equal(loadAccountMeta(A, 'vest_pending_open'), 'null');
|
||||
} finally {
|
||||
after.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
test("an open of unknown outcome isn't matched to an outside trade after its error, nor to one of several", async () => {
|
||||
let positions = [];
|
||||
let open = null;
|
||||
let onRead = () => {};
|
||||
clockAt('2026-10-28T16:00:00Z');
|
||||
const client = await syncingClient((method, path) => {
|
||||
if (method === 'GET' && path === '/v3/positions/opened') onRead();
|
||||
const read = syncReads(method, path, positions);
|
||||
if (read) return read;
|
||||
if (method === 'POST' && path === '/v3/positions/open') return open();
|
||||
return unanswered(method, path);
|
||||
});
|
||||
try {
|
||||
await client.requestSync();
|
||||
// The entry's send fails in transit, and it never went in. Half a
|
||||
// minute on, an outside copy-trader opens NDX long on every account.
|
||||
open = () => { throw new Error('socket hang up'); };
|
||||
await assert.rejects(client.sendOrder(A, NDX, 500, 'Buy', 'Market', undefined, 'open'), /outcome unknown/);
|
||||
const errorAt = Date.now();
|
||||
positions = [rawPosition('9301', 'acc-1', errorAt + 30_000), rawPosition('9302', 'acc-2', errorAt + 30_000)];
|
||||
clockAt('2026-10-28T16:01:00Z');
|
||||
await client.requestSync();
|
||||
assert.deepEqual([...botOpenedPositions(['9301', '9302'])], []);
|
||||
assert.equal(loadAccountMeta(A, 'vest_pending_open'), 'null');
|
||||
|
||||
// Another on acc-2, and two positions show from that moment: either
|
||||
// could be the bot's, so neither is marked.
|
||||
positions = [];
|
||||
clockAt('2026-10-28T16:05:00Z');
|
||||
open = () => {
|
||||
positions = [rawPosition('9303', 'acc-2', Date.now() + 100), rawPosition('9304', 'acc-2', Date.now() + 150)];
|
||||
throw new Error('socket hang up');
|
||||
};
|
||||
await assert.rejects(client.sendOrder(B, NDX, 500, 'Buy', 'Market', undefined, 'open'), /outcome unknown/);
|
||||
await client.requestSync();
|
||||
assert.deepEqual([...botOpenedPositions(['9303', '9304'])], []);
|
||||
|
||||
// An open whose answer named its position, though no read showed it
|
||||
// within the wait: that position is the bot's, even with a stop a
|
||||
// hand has put on it since.
|
||||
positions = [];
|
||||
clockAt('2026-10-28T16:10:00Z');
|
||||
let answered = false;
|
||||
open = () => { answered = true; return { data: { orderId: 'o-9305', positionId: '9305' } }; };
|
||||
// The wait's first read comes back past its deadline.
|
||||
onRead = () => { if (answered) { answered = false; clockAt('2026-10-28T16:10:45Z'); } };
|
||||
await assert.rejects(client.sendOrder(A, NDX, 500, 'Buy', 'Market', undefined, 'open'), /not seen filled/);
|
||||
const stop = { id: 'sl-9305', triggerPrice: '19900', executionType: 'market', status: 'awaiting' };
|
||||
positions = [rawPosition('9305', 'acc-1', iso('2026-10-28T16:10:00Z') + 500, { stopLosses: [stop] })];
|
||||
await client.requestSync();
|
||||
assert.deepEqual([...botOpenedPositions(['9305'])], ['9305']);
|
||||
} finally {
|
||||
client.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
test("the client's Vest day turns with its numbers, and a sync that stalls stops the trading it holds back", async () => {
|
||||
clockAt('2026-10-27T23:58:00Z');
|
||||
const client = await syncingClient((method, path) => syncReads(method, path, []) ?? unanswered(method, path));
|
||||
// The day the engine plans the lane on, at this test's clock.
|
||||
const engineDay = () => tradeDateFor(client, new Date(Date.now()));
|
||||
try {
|
||||
// 7:58 PM ET, before any sync: the day now.
|
||||
assert.equal(engineDay(), '2026-10-27');
|
||||
await client.requestSync();
|
||||
// 8:00:20 PM ET: Vest's day has turned, but the numbers the last sync
|
||||
// worked out are still the ended day's — and so is the engine's day.
|
||||
clockAt('2026-10-28T00:00:20Z');
|
||||
assert.equal(vestDayOf(Date.now()), '2026-10-28');
|
||||
assert.equal(engineDay(), '2026-10-27');
|
||||
assert.equal(isClientReady(client), true);
|
||||
// Nothing opens on the ended day's numbers.
|
||||
const opening = await client.sendOrder(A, encodeContractName('NDX-USD', 0.001), 500, 'Buy', 'Market', undefined, 'open').catch((err) => err);
|
||||
assert.equal(isOrderNotPlaced(opening), true);
|
||||
assert.match(opening.message, /no sync has read it yet/);
|
||||
await client.requestSync();
|
||||
assert.equal(engineDay(), '2026-10-28');
|
||||
// No sync after the next reset: the day holds back only while the
|
||||
// scheduler still trades on the last sync's numbers.
|
||||
clockAt('2026-10-28T23:59:00Z');
|
||||
await client.requestSync();
|
||||
clockAt('2026-10-29T00:03:00Z');
|
||||
assert.equal(engineDay(), '2026-10-28');
|
||||
assert.equal(isClientReady(client), true);
|
||||
clockAt('2026-10-29T00:05:00Z');
|
||||
assert.equal(engineDay(), '2026-10-28');
|
||||
assert.equal(isClientReady(client), false);
|
||||
} finally {
|
||||
client.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
// A timeout: an ask let through twice leaves the first one waiting forever.
|
||||
test("a passed evaluation doesn't hold the sync, and its funded account is asked for once", { timeout: 10_000 }, async () => {
|
||||
clockAt('2026-10-29T15:31:00Z');
|
||||
// Vest's status for each account (acc-2 passed), and the balance Vest's
|
||||
// active list leaves out — a passed one's.
|
||||
const status = { 'acc-1': 2, 'acc-2': 3 };
|
||||
let missingBalance = 'acc-2';
|
||||
let activation = null;
|
||||
const sent = [];
|
||||
const client = await syncingClient((method, path, opts) => {
|
||||
if (method === 'GET' && path === '/v3/capital/accounts/active') {
|
||||
return { data: { accounts: Object.keys(status).map((id, i) => ({
|
||||
id, status: status[id], account_type: 2, stage: 1, step: 1, steps_total: 1, plan_product_type: 'eval',
|
||||
attempt_index: i + 80, initial_capital: '50000', max_daily_loss_pct: '0.03',
|
||||
stages: [{ ordinal: 1, kind: 'eval', profit_target: '5000' }, { ordinal: 2, kind: 'funded', profit_target: '' }],
|
||||
})) } };
|
||||
}
|
||||
if (method === 'GET' && path === '/v3/accounts') {
|
||||
return { data: { accounts: [
|
||||
{ account_id: 'acc-1', amount: '50000', account_type: 2 }, { account_id: 'acc-2', amount: '55100', account_type: 2 }, { account_id: 'main', amount: '0', account_type: 1 },
|
||||
].filter((b) => b.account_id !== missingBalance) } };
|
||||
}
|
||||
if (method === 'POST' && path === '/v3/capital/activate-funded') {
|
||||
sent.push({ account: opts.account, body: opts.body });
|
||||
return activation();
|
||||
}
|
||||
return syncReads(method, path, []) ?? unanswered(method, path);
|
||||
});
|
||||
const funded = { id: 'acc-2-funded', status: 2, account_type: 3, stage: 2, attempt_index: 81, initial_capital: '50000', max_daily_loss_pct: '0.03', stages: [{ ordinal: 1, kind: 'eval' }, { ordinal: 2, kind: 'funded' }] };
|
||||
// The sync an ask starts behind it, run to its end.
|
||||
const settled = async () => {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
await client.syncRunning;
|
||||
};
|
||||
try {
|
||||
await client.requestSync();
|
||||
assert.ok(client.lastSyncAt > 0, "the passed account's missing balance didn't make the sync short of good");
|
||||
assert.equal(client.accountList.find((a) => a.id === B).active, false);
|
||||
assert.deepEqual(client.activatable(), [B]);
|
||||
await assert.rejects(client.activateFunded(A), /only a passed evaluation/);
|
||||
|
||||
// While the ask is out, a second caller is refused, and a sync reads
|
||||
// a list from before Vest's answer: it isn't asked again on it.
|
||||
let answer;
|
||||
activation = () => new Promise((resolve) => { answer = resolve; });
|
||||
const asking = client.activateFunded(B);
|
||||
await assert.rejects(client.activateFunded(B), /hasn't been read back/);
|
||||
clockAt('2026-10-29T15:31:30Z');
|
||||
await client.requestSync();
|
||||
assert.deepEqual(client.activatable(), []);
|
||||
answer({ data: { account: funded } });
|
||||
assert.deepEqual(await asking, { funded: 'V50K-F-D3-81' });
|
||||
assert.deepEqual(sent, [{ account: 'acc-2', body: { eval_account_id: 'acc-2' } }]);
|
||||
|
||||
// Answered: never asked again, while Vest's list is slow to move it
|
||||
// or once it's claimed.
|
||||
await settled();
|
||||
clockAt('2026-10-29T15:40:00Z');
|
||||
await client.requestSync();
|
||||
assert.deepEqual(client.activatable(), []);
|
||||
status['acc-2'] = 6;
|
||||
await client.requestSync();
|
||||
assert.deepEqual(client.activatable(), []);
|
||||
assert.equal(sent.length, 1);
|
||||
|
||||
// acc-1 passes too. A timeout may have gone in: held until a list
|
||||
// read a minute after it. A refusal went nowhere: free at once.
|
||||
status['acc-1'] = 3;
|
||||
missingBalance = 'acc-1';
|
||||
await client.requestSync();
|
||||
assert.deepEqual(client.activatable(), [A]);
|
||||
activation = () => { throw new Error('/v3/capital/activate-funded timed out after 15s'); };
|
||||
await assert.rejects(client.activateFunded(A), /timed out/);
|
||||
await settled();
|
||||
assert.deepEqual(client.activatable(), []);
|
||||
clockAt('2026-10-29T15:40:30Z');
|
||||
await client.requestSync();
|
||||
assert.deepEqual(client.activatable(), []);
|
||||
clockAt('2026-10-29T15:41:10Z');
|
||||
await client.requestSync();
|
||||
assert.deepEqual(client.activatable(), [A]);
|
||||
activation = () => { throw new VestApiError('/v3/capital/activate-funded: funded limit reached (HTTP 400)', 400); };
|
||||
await assert.rejects(client.activateFunded(A), /funded limit/);
|
||||
assert.deepEqual(client.activatable(), [A]);
|
||||
assert.equal(sent.length, 3);
|
||||
|
||||
// A live account's missing balance still holds the sync.
|
||||
status['acc-1'] = 2;
|
||||
const good = client.lastSyncAt;
|
||||
clockAt('2026-10-29T15:45:00Z');
|
||||
await client.requestSync();
|
||||
assert.equal(client.lastSyncAt, good);
|
||||
} finally {
|
||||
client.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
test('two payouts on one Vest day share its fund row, and still count two toward the stage', async () => {
|
||||
clockAt('2026-10-27T23:00:00Z');
|
||||
const client = await syncingClient(unanswered);
|
||||
try {
|
||||
// $1,500 then $3,000 taken on Vest day 10-27: Vest's two WITHDRAWs, one row.
|
||||
client.withdrawals.set(A, [{ at: iso('2026-10-27T15:00:00Z'), amount: -1500 }, { at: iso('2026-10-27T19:00:00Z'), amount: -3000 }]);
|
||||
client.fundTransactions[A] = [{ date: '2026-10-20', amount: 50000 }, { date: '2026-10-27', amount: -4500 }];
|
||||
assert.deepEqual(client.payoutDays(A), ['2026-10-27', '2026-10-27']);
|
||||
// A type whose stages don't reset each day trades stage 3's $2,000, not stage 2's again.
|
||||
const stages = [{ profit: 3000, consistency: 0, minTradingDays: 0 }, { profit: 2000, consistency: 0, minTradingDays: 0 }];
|
||||
const inputs = stageInputsFor(client, A, { stagesSameDay: false });
|
||||
assert.equal(inputs.payouts, 2);
|
||||
assert.equal(resolveEffectiveConfig(1500, 0, 0, false, stages, 0, client.fundTransactions[A], 0, inputs).profitTarget, 2000);
|
||||
// Before its history is read: one per negative fund row.
|
||||
client.historyLoadedIds.delete(A);
|
||||
assert.equal(stageInputsFor(client, A, {}).payouts, undefined);
|
||||
} finally {
|
||||
client.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
test('each claim of a Vest day names the completed cycle it ends, the second and the one after the reset too', async () => {
|
||||
// acc-1, funded $50k, Vest's own numbers: its balance, and its reset balance since 20:00 ET.
|
||||
let balance = 51500;
|
||||
let reset = { at: '2026-10-27T00:00:00Z', balance: 50000 };
|
||||
clockAt('2026-10-27T19:00:00Z'); // 3 PM ET, Vest day 10-27
|
||||
wipeAccountData(A);
|
||||
const client = await syncingClient((method, path, opts) => {
|
||||
const read = syncReads(method, path, []);
|
||||
if (read && path === '/v3/capital/accounts/active') {
|
||||
read.data.accounts[0] = { ...read.data.accounts[0], daily_reset_at: reset.at, daily_reset_balance: String(reset.balance) };
|
||||
}
|
||||
if (read && path === '/v3/accounts') read.data.accounts[0] = { ...read.data.accounts[0], amount: String(balance) };
|
||||
if (read) return read;
|
||||
if (method === 'POST' && path === '/v3/capital/withdraw' && opts.body.account_id === 'acc-1') {
|
||||
balance = Math.round((balance - Number(opts.body.amount)) * 100) / 100;
|
||||
return { data: { trader_amount: opts.body.amount } };
|
||||
}
|
||||
return unanswered(method, path);
|
||||
});
|
||||
// The history read a claim starts isn't this test's: what it books is set here.
|
||||
client.fetchDaysTraded = async () => ({ failedAccounts: 0 });
|
||||
const cfg = {
|
||||
profitTarget: 1500, consistency: 0, minDayPnL: 0, minTradingDays: 0, accountSize: 50000, targetSameEquity: false,
|
||||
splitEnabled: true, payoutable: true, tradesEveryDay: true, combineCmeHolidays: false, stagesSameDay: true, dailyLossLimit: 1500,
|
||||
withdrawalStages: [{ profit: 3000, consistency: 0, minTradingDays: 0, dailyLossLimit: 3000 }],
|
||||
};
|
||||
/** The day's trading so far, as the history read books it, and Vest's WITHDRAWs for the claims made. */
|
||||
const history = (dayPnl) => {
|
||||
client.fullDailyPnL[A] = [{ date: '2026-10-27', pnl: dayPnl }];
|
||||
client.applyCycleSlice(A);
|
||||
client.withdrawals.set(A, client.claimsOf(A).map((c) => ({ at: c.at, amount: -c.amount })));
|
||||
};
|
||||
/** The cycle a claim now would name, stamped with the claim's time. */
|
||||
const named = () => {
|
||||
const now = Date.now();
|
||||
const c = claimedCycle(client, A, cfg, new Date(now));
|
||||
if (c) { assert.equal(c.at, now); delete c.at; }
|
||||
return c;
|
||||
};
|
||||
try {
|
||||
saveFundTransactions(A, 'V50K-F-D3-1', [{ date: '2026-10-20', amount: 50000 }]);
|
||||
saveAccountMeta(A, 'last_fund_date', '2026-10-20');
|
||||
client.fundTransactions[A] = [{ date: '2026-10-20', amount: 50000 }];
|
||||
client.lastFundDates[A] = '2026-10-20';
|
||||
history(1500);
|
||||
await client.requestSync();
|
||||
|
||||
// Stage 1's $1,500, claimed: the account's first cycle.
|
||||
assert.deepEqual(named(), { date: '2026-10-27', stage: 1, drain: false, cycle: '2026-10-20', cycleSeq: 0 });
|
||||
await client.claimProfit(A, 1500);
|
||||
await client.syncRunning; // the sync the claim starts
|
||||
|
||||
// 6 PM ET: stage 2's $3,000 made, and claimed the same Vest day — its
|
||||
// payout books into 10-27's row, and the latest fund date stays.
|
||||
clockAt('2026-10-27T22:00:00Z');
|
||||
balance = 53000;
|
||||
history(4500);
|
||||
await client.requestSync();
|
||||
assert.deepEqual(named(), { date: '2026-10-27', stage: 2, drain: false, cycle: '2026-10-27', cycleSeq: 0 });
|
||||
await client.claimProfit(A, 3000);
|
||||
await client.syncRunning;
|
||||
assert.equal(client.lastFundDates[A], '2026-10-27');
|
||||
assert.deepEqual(client.fundTransactions[A].at(-1), { date: '2026-10-27', amount: -4500 });
|
||||
|
||||
// 6:30 PM ET: the day's third cycle, $1,500 of stage 2's $3,000 — not complete.
|
||||
clockAt('2026-10-27T22:30:00Z');
|
||||
balance = 51500;
|
||||
history(6000);
|
||||
await client.requestSync();
|
||||
assert.equal(named(), null);
|
||||
|
||||
// 8:00:30 PM ET: the reset puts it back on stage 1's $1,500, met —
|
||||
// claimed at the first tick.
|
||||
clockAt('2026-10-28T00:00:30Z');
|
||||
reset = { at: '2026-10-28T00:00:00Z', balance: 51500 };
|
||||
await client.requestSync();
|
||||
assert.equal(tradeDateFor(client, new Date(Date.now())), '2026-10-28');
|
||||
assert.deepEqual(named(), { date: '2026-10-28', stage: 3, drain: false, cycle: '2026-10-27', cycleSeq: 1 });
|
||||
await client.claimProfit(A, 1500);
|
||||
await client.syncRunning;
|
||||
assert.equal(client.lastFundDates[A], '2026-10-28');
|
||||
|
||||
// The catch-up names none of it: the cycle's P&L sits in 10-27's row,
|
||||
// which the cycle before owns.
|
||||
clockAt('2026-10-28T00:10:00Z');
|
||||
await client.requestSync();
|
||||
assert.equal(endedCycleCompletion({
|
||||
accountId: A, rawTradeDate: tradeDateFor(client, new Date(Date.now())), client,
|
||||
fullDailyPnL: client.fullDailyPnL[A], cycleDailyPnL: client.dailyPnL[A], fundTransactions: client.fundTransactions[A],
|
||||
lastFundDate: client.lastFundDates[A], endedCycleStart: JSON.parse(loadAccountMeta(A, 'ended_cycle_start')),
|
||||
rawRealizedPnL: client.accountCashBalances[A].realizedPnL, equityProfit: client.accountCashBalances[A].amount - 50000, cfg,
|
||||
now: new Date(Date.now()),
|
||||
}), null);
|
||||
|
||||
// Kept as the claims leave them; an entry that doesn't read is left out.
|
||||
const kept = [{ date: '2026-10-27', stage: 2, drain: false, cycle: '2026-10-27', cycleSeq: 0, at: iso('2026-10-27T22:00:00Z') }];
|
||||
saveClaimedCycles(A, [...kept, { date: '2026-10-28', stage: '3', drain: false, cycle: '2026-10-27', cycleSeq: 1 }]);
|
||||
assert.deepEqual(loadClaimedCycles(A), kept);
|
||||
saveAccountMeta(A, 'claimed_cycles', '{');
|
||||
assert.deepEqual(loadClaimedCycles(A), []);
|
||||
} finally {
|
||||
client.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
test("a refused account-token mint fails only that account: the login's session stays, and the next account's reads go out", async () => {
|
||||
clockAt('2026-10-29T05:41:00Z');
|
||||
const client = testClient(unanswered, [A, B]);
|
||||
await client.syncRunning;
|
||||
// The client's own transport, on a gateway that refuses acc-1's mint as
|
||||
// Vest did all night on 2026-10-28 (401, code 1002).
|
||||
client.request = realRequest;
|
||||
const adapter = axios.defaults.adapter;
|
||||
const sent = [];
|
||||
axios.defaults.adapter = async (config) => {
|
||||
const path = config.url.replace(/^https:\/\/[^/]+/, '');
|
||||
const body = typeof config.data === 'string' ? JSON.parse(config.data) : undefined;
|
||||
sent.push(`${config.method.toUpperCase()} ${path}${body?.accountId ? ` ${body.accountId}` : ''}`);
|
||||
const reply = (status, data) => ({ status, statusText: '', headers: {}, config, data });
|
||||
if (path === '/v3/auth/account-token') {
|
||||
return body.accountId === 'acc-1'
|
||||
? reply(401, { detail: { code: 1002, msg: 'Unauthorized' } })
|
||||
: reply(200, { accessToken: `eyJ.${body.accountId}.sig`, accessExpiresAtMs: Date.now() + 600_000 });
|
||||
}
|
||||
if (path === '/v3/transactions') return reply(200, { transactions: [] });
|
||||
if (path === '/v3/positions') return reply(200, { positions: [] });
|
||||
return reply(404, {});
|
||||
};
|
||||
try {
|
||||
const { failedAccounts } = await client.fetchDaysTraded([A, B]);
|
||||
assert.equal(failedAccounts, 1);
|
||||
assert.notEqual(vestSessionFor(WALLET), null);
|
||||
assert.equal(client.historyLoadedIds.has(A), false);
|
||||
assert.equal(client.historyLoadedIds.has(B), true);
|
||||
assert.match(client.lastFetchErrors['V50K-F-D3-1'], /code 1002/);
|
||||
assert.ok(sent.includes('POST /v3/auth/account-token acc-2'));
|
||||
assert.ok(sent.includes('GET /v3/transactions'));
|
||||
} finally {
|
||||
axios.defaults.adapter = adapter;
|
||||
client.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
test('an active account with no history is read again within a minute, while a whole pass waits out its retry', async () => {
|
||||
clockAt('2026-10-29T05:41:00Z');
|
||||
const C = vestAccountId('acc-3');
|
||||
const client = testClient(unanswered, [A, B, C]);
|
||||
await client.syncRunning;
|
||||
// acc-3 isn't active: it waits for a whole pass.
|
||||
client.capital.set(C, { status: 3 });
|
||||
client.accountList[2].active = false;
|
||||
client.historyLoaded = true;
|
||||
client.historyLoadedIds = new Set([A]);
|
||||
client.lastDaysFetchStartedAt = Date.now() - 120_000;
|
||||
// A whole pass failed: its retry is four minutes off.
|
||||
client.nextDaysFetchRetryAt = Date.now() + 4 * 60_000;
|
||||
const reads = [];
|
||||
client.fetchDaysTraded = async (ids) => {
|
||||
reads.push(ids);
|
||||
return { failedAccounts: ids ? ids.length : 0 };
|
||||
};
|
||||
const settled = () => new Promise((resolve) => setImmediate(resolve));
|
||||
try {
|
||||
client.maybeFetchDaysTraded();
|
||||
await settled();
|
||||
assert.deepEqual(reads, [[B]]);
|
||||
// It failed again: not before the minute is up, and the whole pass's
|
||||
// retry stays where it was.
|
||||
client.maybeFetchDaysTraded();
|
||||
await settled();
|
||||
assert.deepEqual(reads, [[B]]);
|
||||
clockAt('2026-10-29T05:42:01Z');
|
||||
client.maybeFetchDaysTraded();
|
||||
await settled();
|
||||
assert.deepEqual(reads, [[B], [B]]);
|
||||
clockAt('2026-10-29T05:45:01Z');
|
||||
client.maybeFetchDaysTraded();
|
||||
await settled();
|
||||
assert.deepEqual(reads, [[B], [B], undefined]);
|
||||
} finally {
|
||||
client.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
test("a pass reads the accounts with no history first and the inactive last, and an inactive one's failure isn't the pass's", async () => {
|
||||
clockAt('2026-10-29T05:41:00Z');
|
||||
const C = vestAccountId('acc-3');
|
||||
const client = testClient(unanswered, [A, B, C]);
|
||||
await client.syncRunning;
|
||||
client.capital.set(C, { status: 3 });
|
||||
client.accountList[2].active = false;
|
||||
client.historyLoadedIds = new Set([A]);
|
||||
const order = [];
|
||||
client.fetchTxns = async (uuid) => {
|
||||
order.push(uuid);
|
||||
if (uuid === 'acc-3') throw new Error('/v3/transactions not sent — no account token: Unauthorized (HTTP 401, code 1002)');
|
||||
return [];
|
||||
};
|
||||
client.fetchTrades = async () => {};
|
||||
try {
|
||||
const { failedAccounts } = await client.fetchDaysTraded([A, B, C]);
|
||||
assert.deepEqual(order, ['acc-2', 'acc-1', 'acc-3']);
|
||||
assert.equal(failedAccounts, 0);
|
||||
assert.match(client.lastFetchErrors['V50K-F-D3-3'], /code 1002/);
|
||||
} finally {
|
||||
client.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
test('exits wait for the login session to be relayed again; an open goes now or not at all', async () => {
|
||||
clockAt('2026-10-29T06:00:00Z');
|
||||
const legs = [];
|
||||
const client = testClient((method, path, opts) => {
|
||||
if (method === 'GET' && path === '/v3/positions/opened') return { data: { positions: [rawPosition('9101', 'acc-1', iso('2026-10-29T05:59:00Z'))] } };
|
||||
if (method === 'POST' && (path === '/v3/positions/stop-loss' || path === '/v3/positions/take-profit')) {
|
||||
legs.push(`${path.slice('/v3/positions/'.length)} @ ${opts.body.triggerPrice}`);
|
||||
return { data: {} };
|
||||
}
|
||||
throw new Error(`no gateway in this test: ${method} ${path}`);
|
||||
}, [A, B]);
|
||||
try {
|
||||
// The session lapses (Vest refused it) just after an entry filled.
|
||||
dropVestSession(WALLET, vestSessionFor(WALLET).accessToken);
|
||||
assert.equal(vestSessionFor(WALLET), null);
|
||||
const opening = await client.sendOrder(B, NDX, 500, 'Buy', 'Market', undefined, 'open').catch((err) => err);
|
||||
assert.equal(isOrderNotPlaced(opening), true);
|
||||
assert.match(opening.message, /no Vest session/);
|
||||
|
||||
const exits = client.placeOcoExit(A, NDX, 500, 'Sell', 20100, 19900);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1_200));
|
||||
assert.deepEqual(legs, []);
|
||||
// The extension relays it again: the exits go, the stop first.
|
||||
tokens += 1;
|
||||
assert.equal(putVestSession({ accessToken: `eyJ.test${tokens}.sig`, expiresAt: Date.now() + 3_600_000, primaryAddr: WALLET, userId: 'u' }), null);
|
||||
await exits;
|
||||
assert.deepEqual(legs, ['stop-loss @ 19900', 'take-profit @ 20100']);
|
||||
} finally {
|
||||
client.disconnect();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,651 @@
|
||||
// Checks for the Vest client's pure helpers (lib/vest-helpers.ts), the
|
||||
// relayed-session store (lib/vest-session.ts), and the trade date the engine
|
||||
// plans Vest accounts on (lib/trading-logic.ts).
|
||||
//
|
||||
// npm run check
|
||||
//
|
||||
// These files import only Node built-ins, so Node strips their types and loads
|
||||
// them directly; .mjs keeps this file out of Next's type check. The samples are
|
||||
// shaped like the gateway's real answers (captured 2026-10-26).
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
isVestWalletAddress, vestAccountId, num, toMs, formatDecimal, vestDayOf, lastVestResetMs, parseCapitalAccount,
|
||||
vestAccountName, parsePosition, awaitingLegs, parseOpenOrder, unrealizedPnl, parseTxn, bookTxns, txnKind, VEST_TXN,
|
||||
hourlyVolatility, marketOpen, MIN_HOURLY_RETURNS, nextVestResetMs, resetSafeStop, parseHistoryPosition, jwtClaimStrings,
|
||||
accountScopedToken, VestApiError, VestNotSentError, nothingWentIn, tokenForRequest, claimWithdrawal, settleClaims,
|
||||
claimFundRows, readClaims, payoutDaysOf, payoutsSince, longClosure, unknownOpenFill, awaitingActivation, vestStatusName,
|
||||
VEST_STATUS,
|
||||
} from '../lib/vest-helpers.ts';
|
||||
import { putVestSession, vestSessionFor, dropVestSession, listVestSessions, refuseVestSession } from '../lib/vest-session.ts';
|
||||
import { computeDayPlan, currentTradeDateCT, tradeDateFor, cycleCompletion } from '../lib/trading-logic.ts';
|
||||
|
||||
const iso = (s) => Date.parse(s);
|
||||
|
||||
test('wallet addresses and account ids', () => {
|
||||
assert.equal(isVestWalletAddress('0x7ce10771819f80130e204ddb5ca1522e48a9b451'), true);
|
||||
assert.equal(isVestWalletAddress('0x7CE10771819F80130E204DDB5CA1522E48A9B451'), true);
|
||||
assert.equal(isVestWalletAddress('0x7ce1'), false);
|
||||
assert.equal(isVestWalletAddress(''), false);
|
||||
const id = vestAccountId('843f60e2-e013-5a0e-8b6c-face84bc5509');
|
||||
assert.equal(id, vestAccountId('843F60E2-E013-5A0E-8B6C-FACE84BC5509'));
|
||||
assert.notEqual(id, vestAccountId('102566b2-515a-51c5-9ad7-4a7eae0a6a2a'));
|
||||
assert.ok(id >= 2 ** 50 && Number.isSafeInteger(id));
|
||||
});
|
||||
|
||||
test('numbers, times and decimals as the gateway writes them', () => {
|
||||
assert.equal(num('49968.511115'), 49968.511115);
|
||||
assert.equal(num(''), undefined);
|
||||
assert.equal(num(null), undefined);
|
||||
assert.equal(toMs(1793035089), 1793035089000);
|
||||
assert.equal(toMs(1793035089277), 1793035089277);
|
||||
assert.equal(toMs('2026-10-26T18:41:02.886Z'), iso('2026-10-26T18:41:02.886Z'));
|
||||
assert.ok(Number.isNaN(toMs(undefined)));
|
||||
assert.equal(formatDecimal(29554.5, 2), '29554.5');
|
||||
assert.equal(formatDecimal(50, 2), '50');
|
||||
assert.equal(formatDecimal(3 * 0.0001, 4), '0.0003');
|
||||
assert.equal(formatDecimal(42.65449999, 4), '42.6545');
|
||||
assert.equal(formatDecimal(1e-7, 4), '0');
|
||||
});
|
||||
|
||||
test("Vest's day turns at 20:00 New York, summer and winter", () => {
|
||||
// EDT (UTC−4): 20:00 ET is 00:00 UTC.
|
||||
assert.equal(vestDayOf(iso('2026-10-26T23:59:00Z')), '2026-10-26');
|
||||
assert.equal(vestDayOf(iso('2026-10-27T00:00:00Z')), '2026-10-27');
|
||||
assert.equal(vestDayOf(iso('2026-10-26T22:05:00Z')), '2026-10-26'); // the bots' 6:05 PM ET open
|
||||
// EST (UTC−5): 20:00 ET is 01:00 UTC.
|
||||
assert.equal(vestDayOf(iso('2026-12-01T00:30:00Z')), '2026-11-30');
|
||||
assert.equal(vestDayOf(iso('2026-12-01T01:00:00Z')), '2026-12-01');
|
||||
// Month and year ends.
|
||||
assert.equal(vestDayOf(iso('2026-12-31T23:00:00Z')), '2026-12-31');
|
||||
assert.equal(vestDayOf(iso('2027-01-01T01:30:00Z')), '2027-01-01');
|
||||
|
||||
assert.equal(lastVestResetMs(iso('2026-10-26T18:00:00Z')), iso('2026-10-26T00:00:00Z'));
|
||||
assert.equal(lastVestResetMs(iso('2026-10-27T00:00:00Z')), iso('2026-10-27T00:00:00Z'));
|
||||
assert.equal(lastVestResetMs(iso('2026-12-01T00:30:00Z')), iso('2026-11-30T01:00:00Z'));
|
||||
assert.equal(lastVestResetMs(iso('2026-12-01T01:00:01Z')), iso('2026-12-01T01:00:00Z'));
|
||||
// Across the November switch back (Nov 1 2026, 2 AM ET).
|
||||
assert.equal(lastVestResetMs(iso('2026-11-01T12:00:00Z')), iso('2026-11-01T00:00:00Z'));
|
||||
assert.equal(lastVestResetMs(iso('2026-11-02T12:00:00Z')), iso('2026-11-02T01:00:00Z'));
|
||||
});
|
||||
|
||||
const evalAccount = {
|
||||
payment_id: 'tailoredpay:vest-markets:production:12727557177',
|
||||
id: 'e7f69717-bd18-5cb7-9466-673cb283d0b2',
|
||||
plan_id: '86bb03f9-7422-513c-8d5c-c546cb8d221d',
|
||||
account_type: 2, stage: 1, status: 2,
|
||||
initial_capital: '50000.00000000', max_drawdown_pct: '0.060000', max_leverage: '5.0000', max_profit_split_pct: '0.8000',
|
||||
target_equity: '55000.00000000', max_drawdown_limit: '47000.00000000', max_daily_loss_pct: '0.030000',
|
||||
daily_reset_balance: '50000.00000000000000', daily_reset_equity: '50000.00000000000000',
|
||||
daily_reset_base_limit: '1500.000000000000000', daily_loss_floor: '48500.00000000000000', daily_reset_at: 1793035043842,
|
||||
origin: 'eval_purchase', plan_name: 'Vest Capital Evaluation Fee | 10% Goal | 3% Daily Loss | 80/20 | Emerald',
|
||||
plan_product_type: 'eval', step: 1, steps_total: 1, root_account_id: 'e7f69717-bd18-5cb7-9466-673cb283d0b2',
|
||||
stages: [
|
||||
{ ordinal: 1, kind: 'eval', initial_capital: '50000.00000000', profit_target: '5000.00000000', max_drawdown_pct: '0.060000' },
|
||||
{ ordinal: 2, kind: 'funded', initial_capital: '50000.00000000', profit_target: '', max_drawdown_pct: '0.060000' },
|
||||
],
|
||||
attempt_index: 34, activated_at: 1793035043834, created_at: 1793035043756, updated_at: 1793035043843,
|
||||
};
|
||||
|
||||
test('capital accounts parse, and are named for their rules', () => {
|
||||
const a = parseCapitalAccount(evalAccount);
|
||||
assert.equal(a.id, evalAccount.id);
|
||||
assert.equal(a.kind, 'eval');
|
||||
assert.equal(a.status, 2);
|
||||
assert.equal(a.initialCapital, 50000);
|
||||
assert.equal(a.profitTarget, 5000);
|
||||
assert.equal(a.maxDrawdownLimit, 47000);
|
||||
assert.equal(a.dailyLossFloor, 48500);
|
||||
assert.equal(a.maxDailyLossPct, 0.03);
|
||||
assert.equal(a.dailyResetAt, 1793035043842);
|
||||
assert.equal(vestAccountName(a), 'V50K-E-D3-T10-34');
|
||||
|
||||
assert.equal(vestAccountName(parseCapitalAccount({ ...evalAccount, stage: 2 })), 'V50K-F-D3-34');
|
||||
assert.equal(vestAccountName(parseCapitalAccount({ ...evalAccount, max_daily_loss_pct: '0' })), 'V50K-E-D0-T10-34');
|
||||
assert.equal(vestAccountName(parseCapitalAccount({
|
||||
...evalAccount, initial_capital: '5000.00000000', attempt_index: 12, stage: 2, steps_total: 2,
|
||||
stages: [
|
||||
{ ordinal: 1, kind: 'eval', profit_target: '500.00000000' },
|
||||
{ ordinal: 2, kind: 'eval', profit_target: '250.00000000' },
|
||||
{ ordinal: 3, kind: 'funded', profit_target: '' },
|
||||
],
|
||||
})), 'V5K-E-D3-T5-12');
|
||||
assert.equal(vestAccountName(parseCapitalAccount({
|
||||
...evalAccount, plan_product_type: 'instant_funded', initial_capital: '25000.00000000', max_daily_loss_pct: '0', attempt_index: 7,
|
||||
stages: [{ ordinal: 1, kind: 'funded', profit_target: '' }],
|
||||
})), 'V25K-I-7');
|
||||
assert.equal(vestAccountName(parseCapitalAccount({
|
||||
...evalAccount, plan_product_type: 'instant_funded', initial_capital: '500.00000000', attempt_index: 1,
|
||||
stages: [{ ordinal: 1, kind: 'funded', profit_target: '' }],
|
||||
})), 'V500-I-1');
|
||||
assert.equal(parseCapitalAccount({ nope: true }), null);
|
||||
});
|
||||
|
||||
test('a passed evaluation waits on its funded account, as the Vest app decides', () => {
|
||||
const passed = { ...evalAccount, status: VEST_STATUS.GOAL_PASSED };
|
||||
const a = parseCapitalAccount(passed);
|
||||
assert.equal(a.step, 1);
|
||||
assert.equal(a.stepsTotal, 1);
|
||||
assert.equal(a.accountType, 2);
|
||||
assert.equal(a.blocked, false);
|
||||
assert.equal(awaitingActivation(a), true);
|
||||
|
||||
// Still trading, already claimed, failed or expired: nothing to activate.
|
||||
for (const status of [1, 2, 4, 5, 6, 7]) assert.equal(awaitingActivation(parseCapitalAccount({ ...evalAccount, status })), false, `status ${status}`);
|
||||
// Blocked by Vest.
|
||||
assert.equal(awaitingActivation(parseCapitalAccount({ ...passed, blocked_at: 1793287000000 })), false);
|
||||
// A funded account that reached its goal isn't an evaluation — on the
|
||||
// last step's number too, so only the evaluation test can say so.
|
||||
assert.equal(awaitingActivation(parseCapitalAccount({ ...passed, account_type: 3, stage: 2, step: 1 })), false);
|
||||
// Either of Vest's marks makes it one.
|
||||
assert.equal(awaitingActivation(parseCapitalAccount({ ...passed, account_type: 3 })), true);
|
||||
assert.equal(awaitingActivation(parseCapitalAccount({ ...passed, stage: 2, step: 1 })), true);
|
||||
// A 2-Step's first step passes on to its second by itself; the last one waits.
|
||||
assert.equal(awaitingActivation(parseCapitalAccount({ ...passed, step: 1, steps_total: 2 })), false);
|
||||
assert.equal(awaitingActivation(parseCapitalAccount({ ...passed, step: 2, steps_total: 2 })), true);
|
||||
|
||||
// Without step or steps_total, the Vest app's own fallbacks.
|
||||
const { step, steps_total, ...bare } = passed;
|
||||
assert.equal(awaitingActivation(parseCapitalAccount(bare)), true);
|
||||
assert.equal(parseCapitalAccount({ ...bare, stage: 2 }).step, 2);
|
||||
assert.equal(parseCapitalAccount({ ...bare, plan_product_type: 'instant_funded' }).stepsTotal, 0);
|
||||
assert.equal(awaitingActivation(parseCapitalAccount({ ...bare, plan_product_type: 'instant_funded' })), false);
|
||||
|
||||
assert.equal(vestStatusName(3), 'goal passed (status 3)');
|
||||
assert.equal(vestStatusName(6), 'claimed (status 6)');
|
||||
assert.equal(vestStatusName(9), 'unknown (status 9)');
|
||||
});
|
||||
|
||||
const openPosition = {
|
||||
positionId: 'afe96d7e-f5ac-4b8a-aae8-f5b63b74e508', accountId: '843f60e2-e013-5a0e-8b6c-face84bc5509', accountType: 2,
|
||||
symbol: 'NDX-USD-PERP', side: 'short', quantity: '42.6545', openPrice: '29529.250000000', closePrice: '290.250000000',
|
||||
openDate: 1793035089277, pnl: '0', fee: '31.488885', funding: '-5.423271000000000000', collateral: '24944.027426', leverage: '50',
|
||||
createdAt: 1793035089244, updatedAt: 1793037621104,
|
||||
stopLosses: [{ id: 'e22311d2-fbd9-4ab8-b65f-c2921f58fefc', triggerPrice: '29554.500000000', sizeMode: 'full_position', executionType: 'market', status: 'awaiting' }],
|
||||
takeProfits: [],
|
||||
};
|
||||
|
||||
test('an open of unknown outcome is the position on its account, symbol and side opened around it', () => {
|
||||
const pos = (positionId, accountId, side, openedAt, symbol = 'NDX-USD', stopLosses = []) => ({
|
||||
positionId, accountId, symbol, side, quantity: 0.5, openPrice: 20000, collateral: 500, leverage: 20, openedAt, stopLosses, takeProfits: [],
|
||||
});
|
||||
// Sent at 12:30:00, its error at 12:30:15: the window is a few seconds of clock either side.
|
||||
const sent = { accountId: 'acc-1', symbol: 'NDX-USD', side: 'long', from: iso('2026-10-30T12:29:55Z'), until: iso('2026-10-30T12:30:20Z') };
|
||||
const filled = pos('9001', 'acc-1', 'long', iso('2026-10-30T12:30:01Z'));
|
||||
assert.deepEqual(unknownOpenFill(sent, [pos('9002', 'acc-2', 'long', iso('2026-10-30T12:30:01Z')), filled]), [filled]);
|
||||
// Another side or symbol, a position from before it, or one opened after
|
||||
// the window (a hand trade, or an outside copy-trader, half a minute on): not it.
|
||||
assert.deepEqual(unknownOpenFill(sent, [pos('9003', 'acc-1', 'short', iso('2026-10-30T12:30:01Z'))]), []);
|
||||
assert.deepEqual(unknownOpenFill(sent, [pos('9004', 'acc-1', 'long', iso('2026-10-30T12:30:01Z'), 'SPX-USD')]), []);
|
||||
assert.deepEqual(unknownOpenFill(sent, [pos('9005', 'acc-1', 'long', iso('2026-10-30T11:00:00Z'))]), []);
|
||||
assert.deepEqual(unknownOpenFill(sent, [pos('9006', 'acc-1', 'long', iso('2026-10-30T12:30:45Z'))]), []);
|
||||
// No open time: can't be told from a hand trade.
|
||||
assert.deepEqual(unknownOpenFill(sent, [pos('9007', 'acc-1', 'long', NaN)]), []);
|
||||
// One with exit legs isn't the bot's open — none went on it.
|
||||
const leg = { id: 'sl-1', triggerPrice: 19900, executionType: 'market', status: 'awaiting' };
|
||||
assert.deepEqual(unknownOpenFill(sent, [pos('9008', 'acc-1', 'long', iso('2026-10-30T12:30:01Z'), 'NDX-USD', [leg])]), []);
|
||||
// Two inside the window: both come back, for the caller to adopt neither.
|
||||
const copy = pos('9009', 'acc-1', 'long', iso('2026-10-30T12:30:02Z'));
|
||||
assert.deepEqual(unknownOpenFill(sent, [filled, copy]), [filled, copy]);
|
||||
// The position id the open's answer named settles it, legs and window aside.
|
||||
const named = pos('9010', 'acc-1', 'long', iso('2026-10-30T12:31:00Z'), 'NDX-USD', [leg]);
|
||||
assert.deepEqual(unknownOpenFill({ ...sent, positionId: '9010' }, [filled, copy, named]), [named]);
|
||||
assert.deepEqual(unknownOpenFill({ ...sent, positionId: '9010' }, [filled, copy]), []);
|
||||
assert.deepEqual(unknownOpenFill({ ...sent, positionId: '9010' }, [{ ...named, accountId: 'acc-2' }]), []);
|
||||
});
|
||||
|
||||
test('positions, their legs and unrealized P&L', () => {
|
||||
const p = parsePosition(openPosition);
|
||||
assert.equal(p.side, 'short');
|
||||
assert.equal(p.quantity, 42.6545);
|
||||
assert.equal(p.collateral, 24944.027426);
|
||||
assert.equal(p.openedAt, 1793035089277);
|
||||
assert.equal(awaitingLegs(p).length, 1);
|
||||
assert.equal(awaitingLegs({ ...p, stopLosses: [{ ...p.stopLosses[0], status: 'triggered' }] }).length, 0);
|
||||
// Short 42.6545 from 29529.25, marked at 29477.50: +51.75 points each.
|
||||
assert.ok(Math.abs(unrealizedPnl(p, 29477.5) - 42.6545 * 51.75) < 1e-6);
|
||||
assert.equal(unrealizedPnl(p, NaN), 0);
|
||||
assert.equal(parsePosition({ ...openPosition, quantity: '0' }), null);
|
||||
assert.equal(parseOpenOrder({ order_id: 'o1', account_id: 'a1', position_id: 'p1', symbol: 'GC-PERP', created_at: 1 }).positionId, 'p1');
|
||||
assert.equal(parseOpenOrder({ symbol: 'x' }), null);
|
||||
});
|
||||
|
||||
test('transactions book into daily P&L and fund rows by Vest day', () => {
|
||||
assert.equal(txnKind(VEST_TXN.FEE), 'trade');
|
||||
assert.equal(txnKind(VEST_TXN.REALIZED_PNL), 'trade');
|
||||
assert.equal(txnKind(VEST_TXN.FUNDING), 'trade');
|
||||
assert.equal(txnKind(VEST_TXN.WITHDRAW), 'fund');
|
||||
assert.equal(txnKind(VEST_TXN.FILL_MARGIN), 'margin');
|
||||
assert.equal(txnKind(VEST_TXN.ORDER_MARGIN_RELEASE), 'margin');
|
||||
|
||||
const w = parseTxn({ account_type: 3, action_id: 'a9', amount: '1200', created_at: iso('2026-10-26T15:00:00Z'), transaction_type: 2 });
|
||||
assert.equal(w.amount, -1200);
|
||||
assert.equal(w.kind, 'fund');
|
||||
assert.equal(parseTxn({ amount: 'x', transaction_type: 9, created_at: 1 }), null);
|
||||
|
||||
const rows = [
|
||||
{ action_id: 'd', amount: '50000', created_at: iso('2026-10-20T14:00:00Z'), transaction_type: 1 },
|
||||
{ action_id: 'o', amount: '-24944.02', created_at: iso('2026-10-26T17:18:00Z'), transaction_type: 6 },
|
||||
{ action_id: 'f1', amount: '-31.49', created_at: iso('2026-10-26T17:18:00Z'), transaction_type: 8 },
|
||||
// After 20:00 ET on the 26th: the 27th's day.
|
||||
{ action_id: 'r1', amount: '812.5', created_at: iso('2026-10-27T00:30:00Z'), transaction_type: 9 },
|
||||
{ action_id: 'f2', amount: '-31.2', created_at: iso('2026-10-27T00:30:00Z'), transaction_type: 8 },
|
||||
{ action_id: 'u1', amount: '-5.42', created_at: iso('2026-10-27T01:00:00Z'), transaction_type: 10 },
|
||||
].map(parseTxn);
|
||||
const { daily, funds } = bookTxns(rows);
|
||||
assert.deepEqual([...funds.entries()], [['2026-10-20', 50000]]);
|
||||
assert.ok(Math.abs(daily.get('2026-10-26').pnl - -31.49) < 1e-9);
|
||||
assert.ok(Math.abs(daily.get('2026-10-27').pnl - (812.5 - 31.2 - 5.42)) < 1e-9);
|
||||
assert.ok(Math.abs(daily.get('2026-10-27').fees - 31.2) < 1e-9);
|
||||
});
|
||||
|
||||
const withdrawalsOf = (txns) => txns.filter((t) => t.type === VEST_TXN.WITHDRAW).map((t) => ({ at: t.time, amount: t.amount }));
|
||||
const deposit = { action_id: 'd', amount: '50000', created_at: iso('2026-10-20T14:00:00Z'), transaction_type: 1 };
|
||||
|
||||
test('a claim of unknown outcome that never shows lapses an hour on; one Vest accepted never does', () => {
|
||||
const at = iso('2026-10-27T15:00:00Z');
|
||||
const lost = { at, amount: 2250, base: 50000, unknown: true };
|
||||
// Read just after the request failed: it may yet show, so it holds.
|
||||
assert.deepEqual(settleClaims([lost], [], at + 60_000), { pending: [lost], lapsed: [] });
|
||||
// Still not shown by a read an hour on: it never went in.
|
||||
assert.deepEqual(settleClaims([lost], [], at + 61 * 60_000), { pending: [], lapsed: [lost] });
|
||||
// It went in after all: a WITHDRAW of its amount near it settles it.
|
||||
assert.deepEqual(settleClaims([lost], [{ at: at + 3_000, amount: -2250 }], at + 61 * 60_000), { pending: [], lapsed: [] });
|
||||
assert.equal(claimWithdrawal([{ at: at + 3_000, amount: -2249 }], lost), undefined, 'another amount');
|
||||
assert.equal(claimWithdrawal([{ at: at + 31 * 60_000, amount: -2250 }], lost), undefined, 'too far off');
|
||||
assert.deepEqual(claimWithdrawal([{ at: at + 20 * 60_000, amount: -2250 }, { at: at - 2_000, amount: -2250 }], lost), { at: at - 2_000, amount: -2250 });
|
||||
// A claim Vest accepted holds for as long as it doesn't show.
|
||||
const accepted = { at, amount: 2250, base: 50000 };
|
||||
assert.deepEqual(settleClaims([accepted], [], at + 48 * 3_600_000), { pending: [accepted], lapsed: [] });
|
||||
});
|
||||
|
||||
test('the books stop counting a claim that never went in once it lapses, so it holds the account no longer', () => {
|
||||
const at = iso('2026-10-27T15:00:00Z');
|
||||
const lost = { at, sentAt: at - 20_000, amount: 2250, base: 50150, unknown: true };
|
||||
const txns = [deposit, { action_id: 'r1', amount: '2400', created_at: at - 3_600_000, transaction_type: 9 }].map(parseTxn);
|
||||
const read = (claims, readAt) => readClaims({ txns, funds: bookTxns(txns).funds, claims, stored: [], readAt, opening: 0 });
|
||||
// It never went in: the balance is what the transactions add up to.
|
||||
const cash = 52400;
|
||||
// Half an hour on it may yet show, so the books still take it out (and hold).
|
||||
const early = read([lost], at + 30 * 60_000);
|
||||
assert.deepEqual([early.lapsed, early.outstanding], [[], [lost]]);
|
||||
assert.equal(cash - early.expected, 2250);
|
||||
// A read an hour on lapses it, and the books agree again.
|
||||
const late = read([lost], at + 61 * 60_000);
|
||||
assert.deepEqual([late.lapsed, late.outstanding], [[lost], []]);
|
||||
assert.equal(late.expected, cash);
|
||||
assert.deepEqual(late.rows, [{ date: '2026-10-20', amount: 50000 }]);
|
||||
// One Vest accepted that doesn't show yet: out of the balance and on its
|
||||
// day's row, however long it takes.
|
||||
const accepted = { at, sentAt: at - 20_000, amount: 2250, base: 50150 };
|
||||
const waiting = read([accepted], at + 48 * 3_600_000);
|
||||
assert.deepEqual([waiting.lapsed, waiting.outstanding], [[], [accepted]]);
|
||||
assert.equal(waiting.expected, cash - 2250);
|
||||
assert.deepEqual(waiting.rows, [{ date: '2026-10-20', amount: 50000 }, { date: '2026-10-27', amount: -2250 }]);
|
||||
// Once it shows, the WITHDRAW itself is in the sum.
|
||||
const shown = [...txns, parseTxn({ action_id: 'w1', amount: '2250', created_at: at + 2_000, transaction_type: 2 })];
|
||||
const settled = readClaims({ txns: shown, funds: bookTxns(shown).funds, claims: [accepted], stored: [], readAt: at + 3_600_000, opening: 0 });
|
||||
assert.deepEqual(settled.withdrawals, [{ at: at + 2_000, amount: -2250 }]);
|
||||
assert.deepEqual(settled.outstanding, []);
|
||||
assert.equal(settled.expected, cash - 2250);
|
||||
// An account Vest funded without a deposit row: its capital counts in.
|
||||
assert.equal(readClaims({ txns: txns.slice(1), funds: new Map(), claims: [], stored: [], readAt: at, opening: 50000 }).expected, cash);
|
||||
});
|
||||
|
||||
test('an account token that can\'t be minted means the order or claim was never sent', async () => {
|
||||
const path = '/v3/capital/withdraw';
|
||||
assert.equal(await tokenForRequest(path, async () => 'tok'), 'tok');
|
||||
const failures = [
|
||||
new Error('/v3/auth/account-token timed out after 20s'),
|
||||
new VestApiError('/v3/auth/account-token: HTTP 502 (HTTP 502)', 502, undefined),
|
||||
new Error('/v3/auth/account-token answered without a token'),
|
||||
];
|
||||
for (const failure of failures) {
|
||||
// As the request's own answer each could have gone in...
|
||||
assert.equal(nothingWentIn(failure), false);
|
||||
// ...but from the mint, the request never went out.
|
||||
await assert.rejects(tokenForRequest(path, async () => { throw failure; }), (err) =>
|
||||
err instanceof VestNotSentError && nothingWentIn(err) && err.message.startsWith(`${path} not sent`));
|
||||
}
|
||||
// The request's own answer: only a refusal proves nothing went in.
|
||||
assert.equal(nothingWentIn(new VestApiError('x (HTTP 400, code 1001)', 400, '1001')), true);
|
||||
assert.equal(nothingWentIn(new VestApiError('x: no Vest session', 401, 'NO_SESSION')), true);
|
||||
assert.equal(nothingWentIn(new VestApiError('x (HTTP 408)', 408, undefined)), false);
|
||||
assert.equal(nothingWentIn(new VestApiError('x (HTTP 503)', 503, undefined)), false);
|
||||
assert.equal(nothingWentIn(new Error('x: socket hang up')), false);
|
||||
});
|
||||
|
||||
test('a claim Vest dates on the other side of 20:00 ET keeps no fund row of its own', () => {
|
||||
// Sent at 19:59:59.8 ET on the 26th and answered after 20:00: Vest dates
|
||||
// its WITHDRAW on the 26th's day, the client booked it on the 27th's.
|
||||
const claim = { at: iso('2026-10-27T00:00:00.300Z'), sentAt: iso('2026-10-26T23:59:59.800Z'), amount: 2250, base: 50000 };
|
||||
const txns = [deposit, { action_id: 'w1', amount: '2250', created_at: iso('2026-10-26T23:59:59.900Z'), transaction_type: 2 }].map(parseTxn);
|
||||
const stored = [{ date: '2026-10-20', amount: 50000 }, { date: '2026-10-27', amount: -2250 }];
|
||||
// Before Vest shows it, it stands where it was booked.
|
||||
assert.deepEqual(claimFundRows({ funds: bookTxns(txns.slice(0, 1)).funds, claims: [claim], withdrawals: [], stored }), {
|
||||
rows: [{ date: '2026-10-20', amount: 50000 }, { date: '2026-10-27', amount: -2250 }], dropped: [],
|
||||
});
|
||||
// Once it shows, it's on Vest's day, and the 27th's row goes.
|
||||
assert.deepEqual(claimFundRows({ funds: bookTxns(txns).funds, claims: [claim], withdrawals: withdrawalsOf(txns), stored }), {
|
||||
rows: [{ date: '2026-10-20', amount: 50000 }, { date: '2026-10-26', amount: -2250 }], dropped: ['2026-10-27'],
|
||||
});
|
||||
// The 27th starts with no payout on it, before Vest shows the claim too:
|
||||
// it went out on the 26th, and Vest can't date it earlier.
|
||||
const reset = lastVestResetMs(iso('2026-10-27T15:00:00Z'));
|
||||
assert.equal(payoutsSince(withdrawalsOf(txns), [claim], reset), 0);
|
||||
assert.equal(payoutsSince([], [claim], reset), 0);
|
||||
// Dated after the turn instead, it's the 27th's once Vest shows it.
|
||||
const after = [deposit, { action_id: 'w3', amount: '2250', created_at: iso('2026-10-27T00:00:00.100Z'), transaction_type: 2 }].map(parseTxn);
|
||||
assert.equal(payoutsSince(withdrawalsOf(after), [claim], reset), 1);
|
||||
// One saved before the send time was kept counts from its answer.
|
||||
assert.equal(payoutsSince([], [{ at: claim.at, amount: claim.amount, base: claim.base }], reset), 1);
|
||||
|
||||
// Dated the same day: its row stays. A day Vest has other fund rows on
|
||||
// is Vest's, with a claim it doesn't show yet added.
|
||||
const sameDay = { at: iso('2026-10-26T18:00:00Z'), amount: 1000, base: 49000 };
|
||||
const shown = [deposit, { action_id: 'w2', amount: '1000', created_at: sameDay.at + 2_000, transaction_type: 2 }].map(parseTxn);
|
||||
assert.deepEqual(claimFundRows({ funds: bookTxns(shown).funds, claims: [sameDay], withdrawals: withdrawalsOf(shown), stored: [{ date: '2026-10-26' }] }).dropped, []);
|
||||
const credit = parseTxn({ action_id: 'c1', amount: '40', created_at: sameDay.at - 3_600_000, transaction_type: 22 });
|
||||
assert.deepEqual(claimFundRows({ funds: bookTxns([credit]).funds, claims: [sameDay], withdrawals: [], stored: [] }).rows, [{ date: '2026-10-26', amount: -960 }]);
|
||||
// One of unknown outcome was never booked, so it's no day's.
|
||||
assert.deepEqual(claimFundRows({ funds: new Map(), claims: [{ ...claim, unknown: true }], withdrawals: [], stored }), { rows: [], dropped: [] });
|
||||
});
|
||||
|
||||
test('two claims on one Vest day share a fund row but count as two payouts', () => {
|
||||
const first = { at: iso('2026-10-27T14:00:00Z'), amount: 2250, base: 50000 };
|
||||
const second = { at: iso('2026-10-27T18:30:00Z'), amount: 1500, base: 50000 };
|
||||
const txns = [
|
||||
deposit,
|
||||
{ action_id: 'w1', amount: '2250', created_at: first.at + 2_000, transaction_type: 2 },
|
||||
{ action_id: 'w2', amount: '1500', created_at: second.at + 2_000, transaction_type: 2 },
|
||||
].map(parseTxn);
|
||||
const withdrawals = withdrawalsOf(txns);
|
||||
const { rows } = claimFundRows({ funds: bookTxns(txns).funds, claims: [first, second], withdrawals, stored: [] });
|
||||
assert.deepEqual(rows.filter((f) => f.amount < 0), [{ date: '2026-10-27', amount: -3750 }]);
|
||||
assert.deepEqual(payoutDaysOf(withdrawals, [first, second], rows), ['2026-10-27', '2026-10-27']);
|
||||
// The second not shown yet: still two, on the day it was booked.
|
||||
assert.deepEqual(payoutDaysOf(withdrawals.slice(0, 1), [first, second], rows), ['2026-10-27', '2026-10-27']);
|
||||
// Made by hand, they show only as WITHDRAWs.
|
||||
assert.equal(payoutDaysOf(withdrawals, [], rows).length, 2);
|
||||
// Of unknown outcome, one counts only once it shows.
|
||||
assert.deepEqual(payoutDaysOf([], [{ ...first, unknown: true }], []), []);
|
||||
assert.equal(payoutDaysOf(withdrawals.slice(0, 1), [{ ...first, unknown: true }], rows).length, 1);
|
||||
// A day that nets negative with no WITHDRAW still counts, as it did.
|
||||
assert.deepEqual(payoutDaysOf([], [], [{ date: '2026-10-21', amount: -75 }, { date: '2026-10-22', amount: 20 }]), ['2026-10-21']);
|
||||
assert.deepEqual(payoutDaysOf(withdrawals, [], [...rows, { date: '2026-10-21', amount: -75 }]), ['2026-10-21', '2026-10-27', '2026-10-27']);
|
||||
assert.equal(payoutsSince(withdrawals, [first, second], lastVestResetMs(second.at)), 2);
|
||||
});
|
||||
|
||||
test('hourly volatility leaves out the gaps', () => {
|
||||
const start = iso('2026-10-19T00:00:00Z');
|
||||
const kl = [];
|
||||
let price = 29000;
|
||||
for (let h = 0; h < 60; h++) {
|
||||
price *= h % 2 === 0 ? 1.001 : 1 / 1.001;
|
||||
kl.push([start + h * 3_600_000, '0', '0', '0', String(price), '1']);
|
||||
}
|
||||
const base = hourlyVolatility(kl);
|
||||
assert.ok(base > 0.0009 && base < 0.0011, `σ ${base}`);
|
||||
// A weekend gap with a 3% jump: not an hour's move, so σ doesn't change.
|
||||
const gapped = [...kl, [start + 120 * 3_600_000, '0', '0', '0', String(price * 1.03), '1']];
|
||||
assert.ok(Math.abs(hourlyVolatility(gapped) - base) < 1e-12);
|
||||
// Any order in, the same σ out.
|
||||
assert.equal(hourlyVolatility([...kl].reverse()), base);
|
||||
assert.equal(hourlyVolatility(kl.slice(0, MIN_HOURLY_RETURNS)), null);
|
||||
});
|
||||
|
||||
test('market hours: open stretches, closures and crypto', () => {
|
||||
const periods = [
|
||||
{ fromMs: 0, toMs: 100, state: { session: 'unspecified', closure: 'weekly', tradingStatus: 'CLOSE', price: { allow: false } } },
|
||||
{ fromMs: 100, toMs: 200, state: { session: 'overnight', closure: 'unspecified', tradingStatus: 'OPEN', price: { allow: true } } },
|
||||
{ fromMs: 200, toMs: 300, state: { session: 'regular', closure: 'unspecified', tradingStatus: 'OPEN', price: { allow: true } } },
|
||||
{ fromMs: 300, toMs: 400, state: { session: 'unspecified', closure: 'maintenance', tradingStatus: 'OPEN', price: { allow: true } } },
|
||||
{ fromMs: 400, toMs: 500, state: { session: 'overnight', closure: 'unspecified', tradingStatus: 'OPEN', price: { allow: true } } },
|
||||
];
|
||||
assert.deepEqual(marketOpen(periods, 50), { open: false, closesAt: null });
|
||||
assert.deepEqual(marketOpen(periods, 150), { open: true, closesAt: 300 });
|
||||
assert.deepEqual(marketOpen(periods, 350), { open: false, closesAt: null });
|
||||
assert.deepEqual(marketOpen(periods, 450), { open: true, closesAt: 500 });
|
||||
assert.deepEqual(marketOpen(periods, 600), { open: false, closesAt: null });
|
||||
assert.deepEqual(marketOpen([], 600), { open: true, closesAt: null });
|
||||
});
|
||||
|
||||
test('market hours: a closure past an 8 PM ET reset is a long one, the maintenance hour is not', () => {
|
||||
const open = (from, to) => ({ fromMs: iso(from), toMs: iso(to), state: { session: 'overnight', closure: 'unspecified', tradingStatus: 'OPEN', price: { allow: true } } });
|
||||
const shut = (from, to, closure) => ({ fromMs: iso(from), toMs: iso(to), state: { session: 'unspecified', closure, tradingStatus: closure === 'weekly' ? 'CLOSE' : 'OPEN', price: { allow: closure !== 'weekly' } } });
|
||||
const week = [
|
||||
open('2026-10-28T22:00:00Z', '2026-10-29T21:00:00Z'),
|
||||
shut('2026-10-29T21:00:00Z', '2026-10-29T22:00:00Z', 'maintenance'),
|
||||
open('2026-10-29T22:00:00Z', '2026-10-30T21:00:00Z'),
|
||||
shut('2026-10-30T21:00:00Z', '2026-11-01T23:00:00Z', 'weekly'),
|
||||
open('2026-11-01T23:00:00Z', '2026-11-02T22:00:00Z'),
|
||||
shut('2026-11-02T22:00:00Z', '2026-11-02T23:00:00Z', 'maintenance'),
|
||||
open('2026-11-02T23:00:00Z', '2026-11-03T22:00:00Z'),
|
||||
];
|
||||
// The weekend: shut through Friday's and Saturday's 8 PM ET resets.
|
||||
const friday = marketOpen(week, iso('2026-10-30T20:57:00Z'));
|
||||
assert.deepEqual(friday, { open: true, closesAt: iso('2026-10-30T21:00:00Z') });
|
||||
assert.equal(longClosure(week, friday.closesAt), true);
|
||||
// The maintenance hour reopens before the evening's reset, in summer and winter.
|
||||
const thursday = marketOpen(week, iso('2026-10-29T20:57:00Z'));
|
||||
assert.equal(thursday.closesAt, iso('2026-10-29T21:00:00Z'));
|
||||
assert.equal(longClosure(week, thursday.closesAt), false);
|
||||
assert.equal(longClosure(week, iso('2026-11-02T22:00:00Z')), false);
|
||||
// A gap in the periods is shut too (a holiday), and this one outlasts the reset.
|
||||
const early = [open('2026-11-25T14:00:00Z', '2026-11-25T18:00:00Z'), open('2026-11-26T23:00:00Z', '2026-11-27T18:00:00Z')];
|
||||
assert.equal(longClosure(early, iso('2026-11-25T18:00:00Z')), true);
|
||||
// Shut as far as the periods go: long. Periods that just end: no close known.
|
||||
assert.equal(longClosure(week.slice(0, 4), iso('2026-10-30T21:00:00Z')), true);
|
||||
assert.equal(longClosure(week.slice(0, 3), iso('2026-10-30T21:00:00Z')), false);
|
||||
assert.equal(longClosure(undefined, iso('2026-10-30T21:00:00Z')), false);
|
||||
});
|
||||
|
||||
test("a Vest account plans on Vest's day: the 5 PM CT roll doesn't count today's P&L twice", () => {
|
||||
const vest = { tradeDate: vestDayOf };
|
||||
// 3:30 and 5:30 PM CT (4:30 / 6:30 PM ET): the Central date rolls at
|
||||
// 5 PM CT, Vest's day only at 8 PM ET.
|
||||
const before = new Date('2026-10-27T20:30:00Z');
|
||||
const after = new Date('2026-10-27T22:30:00Z');
|
||||
assert.equal(currentTradeDateCT(after), '2026-10-28');
|
||||
assert.equal(tradeDateFor(vest, after), '2026-10-27');
|
||||
assert.equal(tradeDateFor(vest, new Date('2026-10-28T00:00:00Z')), '2026-10-28');
|
||||
// Every other platform keeps the Central date.
|
||||
assert.equal(tradeDateFor(undefined, after), '2026-10-28');
|
||||
assert.equal(tradeDateFor({}, after), '2026-10-28');
|
||||
// $3,000 to go, 3 min days of $150, $800 made today: Vest's row for its
|
||||
// day, and the realized P&L since its reset.
|
||||
const rows = [{ date: '2026-10-27', pnl: 800 }];
|
||||
const plan = (date) => computeDayPlan(7, date, 3000, 0, rows, 150, 3, 800, 800);
|
||||
assert.equal(plan(currentTradeDateCT(before)).remainingToday, 1900);
|
||||
assert.deepEqual(plan(tradeDateFor(vest, after)), plan(currentTradeDateCT(before)));
|
||||
// On the Central date the $800 is a prior min day and today's P&L both.
|
||||
assert.equal(plan(currentTradeDateCT(after)).remainingToday, 2050);
|
||||
});
|
||||
|
||||
test("a Vest account's completion is judged on Vest's day, and waits out Vest's own roll", () => {
|
||||
const vest = { tradeDate: vestDayOf };
|
||||
// $3,000 in 2 min days: day one made $3,100 and is flat. At 5:30 PM CT
|
||||
// its row is booked and its P&L is still today's realized P&L.
|
||||
const eff = { profitTarget: 3000, consistency: 0, minTradingDays: 2, minDayPnL: 0, drain: false, dailyLossLimit: 0 };
|
||||
const opts = {
|
||||
accountId: 7, cycleDailyPnL: [{ date: '2026-10-27', pnl: 3100 }], rawRealizedPnL: 3100, combineCmeHolidays: false,
|
||||
tradesEveryDay: true, effective: eff, equityProfit: 3100, splitEnabled: true, client: vest,
|
||||
};
|
||||
const evening = new Date('2026-10-27T22:30:00Z');
|
||||
// The Central date counts the one day twice and reports a pass.
|
||||
assert.equal(cycleCompletion({ ...opts, client: undefined, rawTradeDate: currentTradeDateCT(evening), now: evening }), 'today');
|
||||
assert.equal(cycleCompletion({ ...opts, rawTradeDate: tradeDateFor(vest, evening), now: evening }), 'none');
|
||||
// Two minutes past Vest's 8 PM ET roll the last sync's realized P&L can
|
||||
// still be the ended day's: no 'today' until the settle window passes.
|
||||
const justRolled = new Date('2026-10-28T00:02:00Z');
|
||||
assert.equal(tradeDateFor(vest, justRolled), '2026-10-28');
|
||||
assert.equal(cycleCompletion({ ...opts, rawTradeDate: '2026-10-28', now: justRolled }), 'none');
|
||||
// A real second day, once settled, finishes it.
|
||||
const settled = new Date('2026-10-28T00:10:00Z');
|
||||
assert.equal(cycleCompletion({ ...opts, rawTradeDate: '2026-10-28', rawRealizedPnL: 50, equityProfit: 3150, now: settled }), 'today');
|
||||
});
|
||||
|
||||
test('relayed sessions: taken, kept fresher, dropped, never listed with tokens', () => {
|
||||
const now = iso('2026-10-26T18:40:00Z');
|
||||
const jwt = (n) => `eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIke${n}In0.sig${n}`;
|
||||
const wallet = '0x7CE10771819F80130E204DDB5CA1522E48A9B451';
|
||||
assert.equal(putVestSession({ accessToken: 'nope', expiresAt: now + 900_000, primaryAddr: wallet, userId: 'u' }, now), 'accessToken is not a JWT');
|
||||
assert.equal(putVestSession({ accessToken: jwt(1), expiresAt: now + 10_000, primaryAddr: wallet, userId: 'u' }, now), 'token already expired');
|
||||
assert.equal(putVestSession({ accessToken: jwt(1), expiresAt: now + 900_000, primaryAddr: '0x12', userId: 'u' }, now), 'primaryAddr is not a wallet address');
|
||||
assert.equal(putVestSession({ accessToken: jwt(1), expiresAt: now + 900_000, primaryAddr: wallet, userId: 'u' }, now), null);
|
||||
// An older token relayed late doesn't replace the fresher one.
|
||||
assert.equal(putVestSession({ accessToken: jwt(0), expiresAt: now + 300_000, primaryAddr: wallet, userId: 'u' }, now), null);
|
||||
assert.equal(vestSessionFor(wallet.toLowerCase(), now).accessToken, jwt(1));
|
||||
assert.equal(vestSessionFor(wallet, now + 890_000), null); // under 30s left
|
||||
const listed = listVestSessions();
|
||||
assert.equal(listed.length, 1);
|
||||
assert.equal(listed[0].primaryAddr, wallet.toLowerCase());
|
||||
assert.equal('accessToken' in listed[0], false);
|
||||
dropVestSession(wallet, jwt(0)); // not the held one: kept
|
||||
assert.ok(vestSessionFor(wallet, now));
|
||||
dropVestSession(wallet, jwt(1));
|
||||
assert.equal(vestSessionFor(wallet, now), null);
|
||||
});
|
||||
|
||||
test('the next 8 PM ET reset, across both time changes', () => {
|
||||
assert.equal(nextVestResetMs(iso('2026-10-26T18:00:00Z')), iso('2026-10-27T00:00:00Z'));
|
||||
assert.equal(nextVestResetMs(iso('2026-10-27T00:00:00Z')), iso('2026-10-28T00:00:00Z'));
|
||||
assert.equal(nextVestResetMs(iso('2026-10-31T23:59:00Z')), iso('2026-11-01T00:00:00Z'));
|
||||
// Nov 1 2026, the clocks go back: that evening's reset is 01:00 UTC.
|
||||
assert.equal(nextVestResetMs(iso('2026-11-01T12:00:00Z')), iso('2026-11-02T01:00:00Z'));
|
||||
assert.equal(nextVestResetMs(iso('2026-12-01T00:30:00Z')), iso('2026-12-01T01:00:00Z'));
|
||||
});
|
||||
|
||||
test('resetSafeStop: fit an open position into the room the reset will leave', () => {
|
||||
// A stage-2 leg after a claim: balance $50,000, 3% → the reset floor is
|
||||
// $48,500 (above the $47k drawdown floor). $150 reserve, a $3 fee: $1,347 to lose.
|
||||
const base = {
|
||||
side: 'long', quantity: 4, openPrice: 29500, mark: 29480, balance: 50000, dailyLossPct: 0.03,
|
||||
maxDrawdownFloor: 47000, reserve: 150, exitFeeRate: 0.000025, tick: 0.25,
|
||||
};
|
||||
// No stop at all (a last stand): one comes in at open − 1347/4, rounded up to the tick.
|
||||
const fee = 0.000025 * 4 * 29480;
|
||||
const allowed = 50000 - 48500 - 150 - fee;
|
||||
const want = Math.ceil((29500 - allowed / 4) / 0.25) * 0.25;
|
||||
assert.deepEqual(resetSafeStop({ ...base, currentStop: null }), { action: 'stop', price: want });
|
||||
// A wider stop is pulled in; one already tighter is left alone.
|
||||
assert.deepEqual(resetSafeStop({ ...base, currentStop: 29000 }), { action: 'stop', price: want });
|
||||
assert.deepEqual(resetSafeStop({ ...base, currentStop: 29300 }), { action: 'none' });
|
||||
// The mark already past where the stop must go: close.
|
||||
assert.deepEqual(resetSafeStop({ ...base, mark: 29100, currentStop: null }), { action: 'close' });
|
||||
// Short: mirrored, rounded down.
|
||||
const short = resetSafeStop({ ...base, side: 'short', mark: 29510, currentStop: null });
|
||||
assert.equal(short.action, 'stop');
|
||||
assert.equal(short.price, Math.floor((29500 + (50000 - 48500 - 150 - 0.000025 * 4 * 29510) / 4) / 0.25) * 0.25);
|
||||
// No room past the reset at all: close.
|
||||
assert.deepEqual(resetSafeStop({ ...base, balance: 48600, maxDrawdownFloor: 48600 }), { action: 'close' });
|
||||
// The drawdown floor binds when it's above the daily one.
|
||||
const dd = resetSafeStop({ ...base, maxDrawdownFloor: 49000, currentStop: null });
|
||||
assert.equal(dd.action, 'stop');
|
||||
assert.ok(dd.price > want);
|
||||
// A second position on the account gets what the first leaves: $500
|
||||
// already committed at the first one's stop, from the same $48,500 floor.
|
||||
const second = resetSafeStop({ ...base, balance: 49500, floorBalance: 50000, currentStop: null });
|
||||
assert.equal(second.action, 'stop');
|
||||
assert.equal(second.price, Math.ceil((29500 - (allowed - 500) / 4) / 0.25) * 0.25);
|
||||
assert.ok(second.price > want);
|
||||
// ...and nothing when the first takes it all.
|
||||
assert.deepEqual(resetSafeStop({ ...base, balance: 48600, floorBalance: 50000, currentStop: null }), { action: 'close' });
|
||||
// With no room left, a position already up keeps going on a stop that
|
||||
// locks in the shortfall: one already locking in more is left alone...
|
||||
const up = { ...base, mark: 29560, balance: 48600, floorBalance: 50000 };
|
||||
assert.deepEqual(resetSafeStop({ ...up, currentStop: 29520 }), { action: 'none' });
|
||||
// ...one with none gets it, on the profit side, still under the mark...
|
||||
const shortfall = 48600 - 48500 - 150 - 0.000025 * 4 * 29560;
|
||||
assert.deepEqual(resetSafeStop({ ...up, currentStop: null }), { action: 'stop', price: Math.ceil((29500 - shortfall / 4) / 0.25) * 0.25 });
|
||||
assert.ok(resetSafeStop({ ...up, currentStop: null }).price > 29500);
|
||||
// ...and with no mark to tell, it closes.
|
||||
assert.deepEqual(resetSafeStop({ ...up, mark: NaN, currentStop: null }), { action: 'close' });
|
||||
});
|
||||
|
||||
test('parseHistoryPosition: a closed position from GET /v3/positions', () => {
|
||||
// Shaped like the app's History tab reads it (camelCase, decimal strings).
|
||||
const t = parseHistoryPosition({
|
||||
positionId: 'bdda9024-63a9-4abc-a5a8-f83df8ba030a', accountId: '843f60e2-e013-5a0e-8b6c-face84bc5509', symbol: 'NDX-USD-PERP',
|
||||
side: 'short', quantity: '4.2712', leverage: '50', openPrice: '29529.75', closePrice: '29556.50',
|
||||
pnl: '-114.25', fee: '6.31', funding: '-0.12', collateral: '2522.54', takeProfits: [], stopLosses: [],
|
||||
openDate: 1793065590123, closeDate: 1793066346456, createdAt: 1793065590123, updatedAt: 1793066346456, closeReason: 'user_requested',
|
||||
});
|
||||
assert.deepEqual(t, {
|
||||
positionId: 'bdda9024-63a9-4abc-a5a8-f83df8ba030a', symbol: 'NDX-USD-PERP', side: 'short', quantity: 4.2712, leverage: 50,
|
||||
openPrice: 29529.75, closePrice: 29556.5, pnl: -114.25, fee: 6.31, funding: -0.12,
|
||||
openedAt: 1793065590123, closedAt: 1793066346456, closeReason: 'user_requested',
|
||||
});
|
||||
});
|
||||
|
||||
test('parseHistoryPosition: open, snake_case, seconds, and junk', () => {
|
||||
const open = parseHistoryPosition({
|
||||
position_id: 'p1', symbol: 'GC-PERP', side: 'LONG', quantity: '0', open_price: '4208.9', close_price: '',
|
||||
pnl: '', open_date: 1793065590, close_date: null, close_reason: '',
|
||||
});
|
||||
assert.equal(open.side, 'long');
|
||||
assert.equal(open.quantity, 0, 'a size Vest leaves out reads 0 — the store keeps the one it knows');
|
||||
assert.equal(open.openPrice, 4208.9);
|
||||
assert.equal(open.closePrice, null);
|
||||
assert.equal(open.pnl, null);
|
||||
assert.equal(open.openedAt, 1793065590000);
|
||||
assert.equal(open.closedAt, null);
|
||||
assert.equal(open.closeReason, null);
|
||||
const held = parseHistoryPosition({
|
||||
positionId: 'p3', symbol: 'NDX-USD-PERP', side: 'short', quantity: '85.7363', openPrice: '29538.96', closePrice: '290.25',
|
||||
pnl: '0.0', fee: '63.314059', funding: '0.0', openDate: 1793065422000, closeDate: null,
|
||||
});
|
||||
assert.equal(held.closePrice, null, "an open position's placeholder close price is dropped");
|
||||
assert.equal(held.pnl, null, 'and its $0 P&L');
|
||||
assert.equal(held.fee, 63.314059, 'its entry fee is real');
|
||||
assert.equal(parseHistoryPosition({ symbol: 'GC-PERP', openDate: 1 }), null, 'no id');
|
||||
assert.equal(parseHistoryPosition({ positionId: 'p2', symbol: 'GC-PERP' }), null, 'no open time');
|
||||
assert.equal(parseHistoryPosition(null), null);
|
||||
});
|
||||
|
||||
test('an account\'s own token is told from the login\'s', () => {
|
||||
const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
|
||||
const acct = '275e765a-2d3d-5de3-8386-71aaa847c0e8';
|
||||
const accountToken = `${b64({ alg: 'ES256' })}.${b64({ sub: '3cab1794-47bc-523b-91e6-64a9ad26dcbf', acc: { id: acct.toUpperCase() } })}.sig`;
|
||||
assert.ok(jwtClaimStrings(accountToken).includes(acct));
|
||||
assert.deepEqual(jwtClaimStrings('not.a-jwt.at-all'), []);
|
||||
assert.deepEqual(jwtClaimStrings('nodots'), []);
|
||||
// The login sees every account and its primary one.
|
||||
assert.equal(accountScopedToken({ capitalIds: [acct, 'b', 'c'], hasPrimary: true, claims: [] }), false);
|
||||
// One account and no primary: an account's token, whatever its claims.
|
||||
assert.equal(accountScopedToken({ capitalIds: [acct], hasPrimary: false, claims: [] }), true);
|
||||
// One account with a primary: only when the token names that account.
|
||||
assert.equal(accountScopedToken({ capitalIds: [acct], hasPrimary: true, claims: jwtClaimStrings(accountToken) }), true);
|
||||
assert.equal(accountScopedToken({ capitalIds: [acct], hasPrimary: true, claims: ['3cab1794-47bc-523b-91e6-64a9ad26dcbf'] }), false);
|
||||
// A login with no capital accounts yet.
|
||||
assert.equal(accountScopedToken({ capitalIds: [], hasPrimary: true, claims: [] }), false);
|
||||
});
|
||||
|
||||
test('a refused account token falls back to the login\'s and stays refused', () => {
|
||||
const now = iso('2026-10-27T01:00:00Z');
|
||||
const wallet = '0x00000000000000000000000000000000000000aa';
|
||||
const login = 'eyJhbGciOiJFUzI1NiJ9.eyJzdWIiOiJsb2dpbiJ9.loginsignature-loginsignature-loginsig';
|
||||
const account = 'eyJhbGciOiJFUzI1NiJ9.eyJzdWIiOiJhY2N0In0.accountsignature-accountsignature-acct';
|
||||
assert.equal(putVestSession({ accessToken: login, expiresAt: now + 600_000, primaryAddr: wallet, userId: 'u' }, now), null);
|
||||
// The account's token lives longer, so it replaces the login's...
|
||||
assert.equal(putVestSession({ accessToken: account, expiresAt: now + 900_000, primaryAddr: wallet, userId: 'u' }, now), null);
|
||||
assert.equal(vestSessionFor(wallet, now).accessToken, account);
|
||||
// ...until the client finds it sees one account: back to the login's.
|
||||
refuseVestSession(wallet, account, now);
|
||||
assert.equal(vestSessionFor(wallet, now).accessToken, login);
|
||||
// Relayed again (the old extension posts it every minute): refused.
|
||||
assert.match(putVestSession({ accessToken: account, expiresAt: now + 900_000, primaryAddr: wallet, userId: 'u' }, now), /one Vest account's token/);
|
||||
assert.equal(vestSessionFor(wallet, now).accessToken, login);
|
||||
// The login's next renewal is taken as usual.
|
||||
const renewed = 'eyJhbGciOiJFUzI1NiJ9.eyJzdWIiOiJsb2dpbiJ9.renewedsignature-renewedsignature-rene';
|
||||
assert.equal(putVestSession({ accessToken: renewed, expiresAt: now + 1_200_000, primaryAddr: wallet, userId: 'u' }, now), null);
|
||||
assert.equal(vestSessionFor(wallet, now).accessToken, renewed);
|
||||
// Refusing a token that isn't held changes nothing held.
|
||||
refuseVestSession(wallet, 'x.y.zzz', now);
|
||||
assert.equal(vestSessionFor(wallet, now).accessToken, renewed);
|
||||
// No good token to fall back to: none at all.
|
||||
const wallet2 = '0x00000000000000000000000000000000000000bb';
|
||||
assert.equal(putVestSession({ accessToken: account.replace('acct', 'accu'), expiresAt: now + 900_000, primaryAddr: wallet2, userId: 'u' }, now), null);
|
||||
refuseVestSession(wallet2, account.replace('acct', 'accu'), now);
|
||||
assert.equal(vestSessionFor(wallet2, now), null);
|
||||
});
|
||||
+65
-10
@@ -67,11 +67,13 @@ db.exec(`
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS account_assignments (
|
||||
account_id INTEGER PRIMARY KEY,
|
||||
firm_id TEXT NOT NULL,
|
||||
account_type_id TEXT,
|
||||
manual INTEGER NOT NULL DEFAULT 0,
|
||||
resolved_at TEXT NOT NULL
|
||||
account_id INTEGER PRIMARY KEY,
|
||||
firm_id TEXT NOT NULL,
|
||||
account_type_id TEXT,
|
||||
manual INTEGER NOT NULL DEFAULT 0,
|
||||
resolved_at TEXT NOT NULL,
|
||||
rules_date TEXT,
|
||||
rules_date_manual INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
`);
|
||||
try {
|
||||
@@ -79,6 +81,36 @@ try {
|
||||
} catch {
|
||||
// Column already exists
|
||||
}
|
||||
// The rules-date migration, as lib/db.ts runs it — including its backfill:
|
||||
// adding the column here means lib/db.ts's own ALTER never runs on this file,
|
||||
// and rows the restore doesn't overwrite must not stay undated. Dated as
|
||||
// lib/db.ts undatedRowsDate dates them: now, never before the catalog's last
|
||||
// change (a clock running behind must not date a row into an old rule set).
|
||||
function undatedRowsDate() {
|
||||
const row = db.prepare("SELECT value FROM settings WHERE key = 'catalog_updated_at'").get();
|
||||
const floor = row && typeof row.value === 'string' ? Date.parse(row.value) : NaN;
|
||||
return new Date(Math.max(Date.now(), Number.isFinite(floor) ? floor : 0)).toISOString();
|
||||
}
|
||||
// lib/kept-rules.ts sortableIso: the only rules dates stored are plain
|
||||
// four-digit-year ISO strings from 2000 on, so string order is time order.
|
||||
function sortableIso(ms) {
|
||||
if (!Number.isFinite(ms) || ms < Date.UTC(2000, 0, 1) || Math.abs(ms) > 8.64e15) return null;
|
||||
const iso = new Date(ms).toISOString();
|
||||
return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(iso) ? iso : null;
|
||||
}
|
||||
try {
|
||||
db.transaction(() => {
|
||||
db.exec('ALTER TABLE account_assignments ADD COLUMN rules_date TEXT');
|
||||
db.prepare('UPDATE account_assignments SET rules_date = ? WHERE rules_date IS NULL').run(undatedRowsDate());
|
||||
}).immediate();
|
||||
} catch {
|
||||
// Column already exists
|
||||
}
|
||||
try {
|
||||
db.exec('ALTER TABLE account_assignments ADD COLUMN rules_date_manual INTEGER NOT NULL DEFAULT 0');
|
||||
} catch {
|
||||
// Column already exists
|
||||
}
|
||||
|
||||
const upsert = db.prepare(
|
||||
'INSERT INTO settings (key, value) VALUES (?, ?) ' +
|
||||
@@ -134,14 +166,29 @@ async function restore() {
|
||||
'INSERT INTO instruments (symbol, enabled) VALUES (?, ?) ' +
|
||||
'ON CONFLICT(symbol) DO UPDATE SET enabled = excluded.enabled'
|
||||
);
|
||||
const setAssign = db.prepare(`
|
||||
INSERT INTO account_assignments (account_id, firm_id, account_type_id, manual, resolved_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
// lib/db.ts restoreAssignment, by hand: the backup's rules date (and how
|
||||
// it was set) wins; a backup from an older build has none, so an existing
|
||||
// row keeps its own and a new one is dated at the restore.
|
||||
const setAssignDated = db.prepare(`
|
||||
INSERT INTO account_assignments (account_id, firm_id, account_type_id, manual, resolved_at, rules_date, rules_date_manual)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(account_id) DO UPDATE SET
|
||||
firm_id = excluded.firm_id,
|
||||
account_type_id = excluded.account_type_id,
|
||||
manual = excluded.manual,
|
||||
resolved_at = excluded.resolved_at
|
||||
resolved_at = excluded.resolved_at,
|
||||
rules_date = excluded.rules_date,
|
||||
rules_date_manual = excluded.rules_date_manual
|
||||
`);
|
||||
const setAssignUndated = db.prepare(`
|
||||
INSERT INTO account_assignments (account_id, firm_id, account_type_id, manual, resolved_at, rules_date)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(account_id) DO UPDATE SET
|
||||
firm_id = excluded.firm_id,
|
||||
account_type_id = excluded.account_type_id,
|
||||
manual = excluded.manual,
|
||||
resolved_at = excluded.resolved_at,
|
||||
rules_date = COALESCE(account_assignments.rules_date, excluded.rules_date)
|
||||
`);
|
||||
|
||||
db.transaction(() => {
|
||||
@@ -162,7 +209,15 @@ async function restore() {
|
||||
setInstr.run(i.symbol, i.enabled ? 1 : 0);
|
||||
}
|
||||
for (const a of payload.assignments ?? []) {
|
||||
setAssign.run(a.account_id, a.firm_id, a.account_type_id ?? null, a.manual ? 1 : 0, a.resolved_at ?? new Date().toISOString());
|
||||
const now = new Date().toISOString();
|
||||
const resolvedAt = a.resolved_at ?? now;
|
||||
const rulesDate = typeof a.rules_date === 'string' ? sortableIso(Date.parse(a.rules_date)) : null;
|
||||
if (rulesDate) {
|
||||
setAssignDated.run(a.account_id, a.firm_id, a.account_type_id ?? null, a.manual ? 1 : 0, resolvedAt,
|
||||
rulesDate, a.rules_date_manual === 1 ? 1 : 0);
|
||||
} else {
|
||||
setAssignUndated.run(a.account_id, a.firm_id, a.account_type_id ?? null, a.manual ? 1 : 0, resolvedAt, now);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
|
||||
+276
-22
@@ -33,9 +33,15 @@
|
||||
* the build fails, and a failure is retried on a backoff (scripts/.update-failed)
|
||||
* rather than every minute.
|
||||
*
|
||||
* A machine whose checker stopped firing can't notice by itself, so the app
|
||||
* asks for a heal (lib/updater-watchdog.ts) once the last check goes stale, and
|
||||
* the master's "Update now" asks for a heal plus an immediate check.
|
||||
*
|
||||
* node scripts/update-check.mjs # check; start a worker if needed
|
||||
* node scripts/update-check.mjs --apply # update now, here, in the foreground
|
||||
* node scripts/update-check.mjs --dry-run # report only, change nothing
|
||||
* node scripts/update-check.mjs --heal # re-register a checker that stopped, then check
|
||||
* node scripts/update-check.mjs --now # check without waiting out a failure's backoff
|
||||
*/
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import {
|
||||
@@ -65,6 +71,14 @@ const BRANCH = process.env.AUTOFIRMER_BRANCH ?? 'master';
|
||||
const INSTANCES = loadInstances();
|
||||
const APPLY = process.argv.includes('--apply');
|
||||
const DRY_RUN = process.argv.includes('--dry-run');
|
||||
const HEAL = process.argv.includes('--heal');
|
||||
const NOW = process.argv.includes('--now');
|
||||
const OPT_OUT_FILE = path.join(ROOT, 'scripts', '.no-auto-update');
|
||||
// The PM2 app install-autostart.* registers, and its schedule.
|
||||
const CHECKER = 'autofirmer-update';
|
||||
const DEFAULT_CRON = '*/1 * * * *';
|
||||
// The master's UPDATER_STALE_MS: past this its dashboard says "updater not running".
|
||||
const STALE_CHECK_MS = 10 * 60 * 1000;
|
||||
const STALE_LOCK_MS = 2 * 60 * 60 * 1000;
|
||||
const LEGACY_STALE_LOCK_MS = 10 * 60 * 1000;
|
||||
const LOCK_SETTLE_MS = 1000;
|
||||
@@ -76,7 +90,10 @@ const REPEAT_RETRY_MS = 60 * 60 * 1000;
|
||||
// it. npm and next build are left unbounded: on Windows a timeout would kill
|
||||
// only the cmd.exe in front of them, and an orphaned build still writing into
|
||||
// .next is worse than a slow one.
|
||||
const FETCH_TIMEOUT_MS = 2 * 60 * 1000;
|
||||
// The fetch also stays under the cron's minute: a check still running at the next
|
||||
// fire has to be killed by PM2, and that kill is where PM2 loses the cron
|
||||
// (see heal).
|
||||
const FETCH_TIMEOUT_MS = 45 * 1000;
|
||||
const PM2_TIMEOUT_MS = 3 * 60 * 1000;
|
||||
const LOG_MAX_BYTES = 256 * 1024;
|
||||
const LOG_KEEP_LINES = 500;
|
||||
@@ -120,6 +137,13 @@ function recordCheck(status) {
|
||||
} catch { /* never let bookkeeping break an update */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* An argument for run() as cmd.exe will see it. On Windows run() goes through
|
||||
* the shell, which joins the arguments with plain spaces, so a cron string or
|
||||
* a path with a space in it would arrive as several.
|
||||
*/
|
||||
const shellArg = (arg) => (process.platform === 'win32' && /[\s"]/.test(arg) ? `"${arg.replace(/"/g, '""')}"` : arg);
|
||||
|
||||
function run(cmd, args, { capture = false, timeout } = {}) {
|
||||
return spawnSync(cmd, args, {
|
||||
cwd: ROOT,
|
||||
@@ -370,7 +394,7 @@ function releaseLock() {
|
||||
}
|
||||
|
||||
// ── failure record ──────────────────────────────────────────────────────────
|
||||
// scripts/.update-failed = { target, step, at, count, restart, clicker }.
|
||||
// scripts/.update-failed = { target, step, at, count, restart, clicker, detail? }.
|
||||
//
|
||||
// Written when an attempt STARTS and deleted when it succeeds, so a worker
|
||||
// that dies without reporting (killed, rebooted) still counts as a failed
|
||||
@@ -393,6 +417,54 @@ function writeFailure(f) {
|
||||
function clearFailure() {
|
||||
try { rmSync(FAILED_FILE, { force: true }); } catch { /* bookkeeping */ }
|
||||
}
|
||||
/** Size of update-worker.log right now, so a step's own output can be told apart. */
|
||||
function workerLogSize() {
|
||||
try { return statSync(WORKER_LOG_FILE).size; } catch { return 0; }
|
||||
}
|
||||
|
||||
/** What update-worker.log gained since `from` ('' when a by-hand --apply
|
||||
* wrote to the terminal instead). */
|
||||
function workerLogSince(from) {
|
||||
try {
|
||||
const buf = readFileSync(WORKER_LOG_FILE);
|
||||
return buf.length > from ? buf.subarray(from).toString('utf8') : '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a step failed, from its output since `from`: the file this user can't
|
||||
* replace when a permission is what stopped it, else one line of the output.
|
||||
*/
|
||||
function explainFailure(from) {
|
||||
const text = workerLogSince(from);
|
||||
return (PERMISSION_DENIED.test(text) && ownershipProblem()) || failureReason(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* One line on why a step failed, from its output, or null.
|
||||
*
|
||||
* The full npm/next output stays on the machine. Without a reason the master
|
||||
* shows only "update failed at build", and a machine failing for a cause of
|
||||
* its own (a Node upgrade leaving better-sqlite3 on the old ABI, a file
|
||||
* Defender holds, a full disk) can't be told from a broken push. The line
|
||||
* rides on .last-check, which every app build and the master already carry.
|
||||
*/
|
||||
function failureReason(text) {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const lines = text.replace(/\x1b\[[0-9;]*m/g, '').split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
||||
// npm's own epilogue says only that the script failed, and where its log
|
||||
// is; and this script's own log lines (they go to the console too) aren't
|
||||
// the step's output.
|
||||
const boilerplate = /^npm (ERR!|error)( (code|path|command|errno|syscall|signal|workspace|location|A complete log|Lifecycle)\b|\s*$)|^> |^\d{4}-\d\d-\d\dT\S+ {2}/;
|
||||
const first = (re) => lines.find((l) => re.test(l) && !boilerplate.test(l));
|
||||
const line = first(/NODE_MODULE_VERSION|ERR_DLOPEN_FAILED|\bE(PERM|BUSY|ACCES|NOSPC)\b|heap out of memory|Type error:/)
|
||||
?? first(/\b[Ee]rror\b/)
|
||||
?? lines.filter((l) => !boilerplate.test(l)).pop();
|
||||
return line ? line.replace(/\s+/g, ' ').slice(0, 100) : null;
|
||||
}
|
||||
|
||||
/** 15 min after the first failure, an hour after repeated ones: long enough
|
||||
* that a deterministic failure (a broken push) doesn't rebuild every minute
|
||||
* on a trading PC, short enough that a transient one (network, EPERM) heals
|
||||
@@ -434,19 +506,28 @@ function headMovedAt() {
|
||||
}
|
||||
}
|
||||
|
||||
/** PM2's process list, or null when PM2 can't be asked. */
|
||||
function pm2List() {
|
||||
const r = run('pm2', ['jlist'], { capture: true, timeout: 30_000 });
|
||||
if (r.status !== 0) return null;
|
||||
// jlist prints the process list as one JSON line, possibly after "[PM2] ..." chatter.
|
||||
const line = (r.stdout ?? '').split('\n').find((l) => /^\s*\[\s*[{\]]/.test(l));
|
||||
try {
|
||||
const procs = JSON.parse(line);
|
||||
return Array.isArray(procs) ? procs : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Names of this checkout's PM2 instances that started before `ms`, i.e. still
|
||||
* running code from before the build on disk. Empty when PM2 can't be asked:
|
||||
* this only sharpens a heuristic and must never block an update.
|
||||
*/
|
||||
function instancesStartedBefore(ms) {
|
||||
const r = run('pm2', ['jlist'], { capture: true, timeout: 30_000 });
|
||||
if (r.status !== 0) return [];
|
||||
// jlist prints the process list as one JSON line, possibly after "[PM2] ..." chatter.
|
||||
const line = (r.stdout ?? '').split('\n').find((l) => /^\s*\[\s*[{\]]/.test(l));
|
||||
let procs;
|
||||
try { procs = JSON.parse(line); } catch { return []; }
|
||||
if (!Array.isArray(procs)) return [];
|
||||
const procs = pm2List();
|
||||
if (!procs) return [];
|
||||
const names = new Set(INSTANCES.map((i) => i.name));
|
||||
return procs
|
||||
.filter((p) => names.has(p?.name) && p.pm2_env?.status === 'online' && Number(p.pm2_env.pm_uptime) < ms)
|
||||
@@ -550,6 +631,65 @@ function workFor(plan) {
|
||||
};
|
||||
}
|
||||
|
||||
// ── stuck states ────────────────────────────────────────────────────────────
|
||||
// Two states that fail every update until a person gets onto the machine.
|
||||
|
||||
/** What a step prints when it may not touch a file. */
|
||||
const PERMISSION_DENIED = /permission denied|insufficient permission|\bEACCES\b|\bEPERM\b/i;
|
||||
|
||||
/**
|
||||
* A file in the checkout this user doesn't own, as the fix to run, or null.
|
||||
*
|
||||
* One `sudo git pull` or `sudo npm run build` leaves files this user cannot
|
||||
* replace, and from then on every update fails: the build can't clear .next,
|
||||
* and a fetch can't write into .git (Ziad's Pi, 2026-10-02: ~1,800 of them).
|
||||
* The failure itself only says EACCES somewhere in npm's output, so name the
|
||||
* file and the one command that fixes it — the master's updater chip shows
|
||||
* this line. Asked only once a step has failed on a permission: a stray
|
||||
* root-owned file that blocks nothing must not stop an update. POSIX only.
|
||||
*/
|
||||
function ownershipProblem() {
|
||||
if (typeof process.getuid !== 'function') return null;
|
||||
const r = run('find', [ROOT, '!', '-user', String(process.getuid()), '-print', '-quit'], { capture: true, timeout: 20_000 });
|
||||
const hit = (r.stdout ?? '').split('\n')[0].trim();
|
||||
if (!hit) return null;
|
||||
let owner = 'another user';
|
||||
try {
|
||||
const uid = lstatSync(hit).uid;
|
||||
owner = uid === 0 ? 'root' : `uid ${uid}`;
|
||||
} catch { /* gone since: the generic name will do */ }
|
||||
return `${path.relative(ROOT, hit) || '.'} is owned by ${owner}: run sudo chown -R ${os.userInfo().username} ${ROOT}`;
|
||||
}
|
||||
|
||||
/** inspect(), with a fetch refused by a .git this user can't write named as such. */
|
||||
function inspectNamingOwnership(opts) {
|
||||
try {
|
||||
return inspect(opts);
|
||||
} catch (err) {
|
||||
const why = PERMISSION_DENIED.test(err.message) ? ownershipProblem() : null;
|
||||
throw why ? new Error(why) : err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* npm install rewrites package-lock.json on some machines (another npm
|
||||
* version, platform-specific optional dependencies), and that edit then
|
||||
* refuses every fast-forward that touches the file. It is generated, so when
|
||||
* it is the only tracked edit, put it back. Any other edit is a person's and
|
||||
* stays, showing as "edited here" on the master.
|
||||
*/
|
||||
function restoreRewrittenLockfile() {
|
||||
let changed;
|
||||
try {
|
||||
changed = git('diff', '--name-only', 'HEAD').split('\n').filter(Boolean);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (changed.length !== 1 || changed[0] !== 'package-lock.json') return;
|
||||
git('checkout', 'HEAD', '--', 'package-lock.json');
|
||||
log('package-lock.json had been rewritten here (npm) — put it back before the pull');
|
||||
}
|
||||
|
||||
// ── build ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -630,6 +770,111 @@ function removePrevBuild() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── heal ────────────────────────────────────────────────────────────────────
|
||||
// The checker runs only while PM2 fires it, and PM2 loses it easily: a
|
||||
// `pm2 stop`, a `pm2 delete all`, a reboot after a list change nobody saved.
|
||||
|
||||
/** How long ago the last check ran, in ms; null = never on this checkout. */
|
||||
function lastCheckAge() {
|
||||
try {
|
||||
const at = Date.parse(readFileSync(LAST_CHECK_FILE, 'utf8').split(/\s/, 1)[0]);
|
||||
return Date.now() - (Number.isFinite(at) ? at : statSync(LAST_CHECK_FILE).mtimeMs);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const fmtAge = (ms) => (ms >= 90 * 60 * 1000 ? `${Math.round(ms / 3_600_000)} h` : `${Math.round(ms / 60_000)} min`);
|
||||
|
||||
/**
|
||||
* The fields PM2 adds to an app's environment.
|
||||
*
|
||||
* PM2 hands every app the scalar fields of its own record as environment
|
||||
* (name, pm_id, autorestart, …), and `pm2 start` captures the caller's
|
||||
* environment for the new app, then copies it over the new app's record. A
|
||||
* checker registered by a heal, which the dashboard app starts, would take
|
||||
* that app's name and id, and its autorestart would override
|
||||
* --no-autorestart. These are the fields PM2 put on real records, plus the
|
||||
* ones only some records carry. PORT and AUTOFIRMER_DB are the instance's
|
||||
* own, and the checker serves every instance.
|
||||
*/
|
||||
const INHERITED_FIELDS = new Set([
|
||||
'NODE_APP_INSTANCE', 'automation', 'autorestart', 'autostart', 'created_at', 'cron_restart',
|
||||
'exec_interpreter', 'exec_mode', 'exit_code', 'filter_env', 'instance_var', 'instances',
|
||||
'kill_retry_time', 'km_link', 'merge_logs', 'name', 'namespace', 'node_version', 'pmx',
|
||||
'prev_restart_delay', 'restart_time', 'status', 'stop_exit_codes', 'treekill', 'unique_id',
|
||||
'unstable_restarts', 'username', 'version', 'versioning', 'vizion', 'vizion_running', 'watch',
|
||||
'windowsHide', 'PORT', 'AUTOFIRMER_DB',
|
||||
]);
|
||||
|
||||
/** Drop what PM2 (and the instance) put in this process's environment, so
|
||||
* nothing started from here inherits it. */
|
||||
function scrubInheritedEnv() {
|
||||
// PM2 also files the app's own config under its name.
|
||||
if (process.env.name) delete process.env[process.env.name];
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (INHERITED_FIELDS.has(key) || key.startsWith('pm_') || key.startsWith('axm_')) delete process.env[key];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PM2 firing this script again once its checks have stopped. Returns
|
||||
* false when this checkout is opted out of updates altogether.
|
||||
*
|
||||
* What the fleet shows (2026-10, six Windows machines): the cron dies inside
|
||||
* a live PM2 daemon, nobody involved. Every fire runs restartProcessId,
|
||||
* whose first step drops the cron; only a restart that gets as far as
|
||||
* launching the app registers it again. croner sometimes fires twice in
|
||||
* one second, the second restart has to stop a check that is still
|
||||
* launching, and a refused or timed-out stop leaves the checker `stopped`
|
||||
* or `errored` with no cron at all.
|
||||
*
|
||||
* So a checker PM2 still lists is restarted, which registers its cron again
|
||||
* with the schedule and environment it already had, and nothing is deleted:
|
||||
* `pm2 delete` kills the pid an `errored` entry kept, and Windows hands pids
|
||||
* out again quickly, so that could be any program by now. If that pid is
|
||||
* alive, PM2 is left alone and this run checks instead. Only a checker PM2
|
||||
* has lost entirely is registered again, the way install-autostart.* does.
|
||||
*/
|
||||
function heal() {
|
||||
if (existsSync(OPT_OUT_FILE)) {
|
||||
console.log('scripts/.no-auto-update is here — this checkout is not auto-updated');
|
||||
return false;
|
||||
}
|
||||
// Before anything is started from here, the worker below included: the
|
||||
// app that asked for this heal is a PM2 app, and may be instance 2.
|
||||
scrubInheritedEnv();
|
||||
const age = lastCheckAge();
|
||||
if (age !== null && age <= STALE_CHECK_MS) return true; // checking on schedule
|
||||
const why = age === null ? 'the update checker had never run here' : `the update checker had stopped (last check ${fmtAge(age)} ago)`;
|
||||
|
||||
const pm2 = (...args) => run('pm2', args.map(shellArg), { capture: true, timeout: PM2_TIMEOUT_MS });
|
||||
const lastLine = (r) => (((r.stderr || r.stdout) ?? '').trim().split('\n').pop() || `exit ${r.status}`);
|
||||
const listed = pm2List()?.find((p) => p?.name === CHECKER);
|
||||
const pid = Number(listed?.pid);
|
||||
if (listed && pid > 0 && pidAlive(pid)) {
|
||||
log(`${why}, but PM2 still has it on pid ${pid}, which is alive — left PM2 alone (a restart would kill that pid); checking from here`);
|
||||
return true;
|
||||
}
|
||||
|
||||
let how;
|
||||
if (listed && pm2('restart', CHECKER).status === 0) {
|
||||
how = `restarted it in PM2 (${listed.pm2_env?.status ?? 'unknown'}), which registers its cron again`;
|
||||
} else {
|
||||
const cron = listed?.pm2_env?.cron_restart || DEFAULT_CRON;
|
||||
if (listed) pm2('delete', CHECKER); // its restart failed, and its pid is dead
|
||||
const r = pm2('start', SELF, '--name', CHECKER, '--no-autorestart', '--cron-restart', cron);
|
||||
if (r.status !== 0) {
|
||||
log(`${why} — ERROR: could not register it with PM2: ${lastLine(r)}`);
|
||||
return true; // the check below can still update this machine
|
||||
}
|
||||
how = `registered it with PM2 again (${cron})`;
|
||||
}
|
||||
const saved = pm2('save').status === 0;
|
||||
log(`${why} — ${how}${saved ? '' : ', but pm2 save failed: a reboot would lose it'}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── check ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -688,7 +933,7 @@ function check() {
|
||||
|
||||
let outcome = null;
|
||||
try {
|
||||
const plan = inspect({ adopt: !DRY_RUN });
|
||||
const plan = inspectNamingOwnership({ adopt: !DRY_RUN });
|
||||
if (!plan.needsPull && !plan.needsBuild && !plan.needsRestart) {
|
||||
outcome = `up to date at ${short(plan.target)}`; // silent in the log by design
|
||||
if (DRY_RUN) console.log(outcome);
|
||||
@@ -699,9 +944,11 @@ function check() {
|
||||
return 0;
|
||||
}
|
||||
// Silent in update.log too: the failure itself was logged once, and a
|
||||
// line a minute until the retry would bury it.
|
||||
if (plan.pending && Date.now() < retryAt(plan.pending)) {
|
||||
outcome = `waiting to retry ${short(plan.target)}: ${plan.pending.step} (attempt ${plan.pending.count}), next try after ${new Date(retryAt(plan.pending)).toISOString()}`;
|
||||
// line a minute until the retry would bury it. --now (the master's
|
||||
// "Update now") is a person asking for the retry.
|
||||
if (plan.pending && !NOW && Date.now() < retryAt(plan.pending)) {
|
||||
outcome = `waiting to retry ${short(plan.target)}: ${plan.pending.step} (attempt ${plan.pending.count}), next try after ${new Date(retryAt(plan.pending)).toISOString()}`
|
||||
+ (plan.pending.detail ? ` — ${plan.pending.detail}` : '');
|
||||
return 0;
|
||||
}
|
||||
const pid = startWorker();
|
||||
@@ -748,15 +995,15 @@ async function apply() {
|
||||
|
||||
let outcome = 'interrupted';
|
||||
let attempt = null;
|
||||
const abort = (step, why) => {
|
||||
log(`ABORTED: ${why}`);
|
||||
outcome = why;
|
||||
if (attempt) writeFailure({ ...attempt, step, at: Date.now() });
|
||||
const abort = (step, why, detail = null) => {
|
||||
log(`ABORTED: ${why}${detail ? ` (${detail})` : ''}`);
|
||||
outcome = detail ? `${why}: ${detail}` : why;
|
||||
if (attempt) writeFailure({ ...attempt, step, at: Date.now(), ...(detail ? { detail } : {}) });
|
||||
return 1;
|
||||
};
|
||||
|
||||
try {
|
||||
const plan = inspect({ adopt: true });
|
||||
const plan = inspectNamingOwnership({ adopt: true });
|
||||
if (!plan.needsPull && !plan.needsBuild && !plan.needsRestart) {
|
||||
outcome = `up to date at ${short(plan.target)}`;
|
||||
clearFailure();
|
||||
@@ -780,14 +1027,19 @@ async function apply() {
|
||||
else if (plan.needsBuild) log('last build unknown — treating every file as changed');
|
||||
|
||||
// The exact commit the check saw, not whatever `git pull` would fetch now.
|
||||
if (plan.needsPull && run('git', ['merge', '--ff-only', target]).status !== 0) {
|
||||
return abort('pull', `git pull --ff-only to ${short(target)} failed — see scripts/update-worker.log`);
|
||||
if (plan.needsPull) {
|
||||
restoreRewrittenLockfile();
|
||||
const from = workerLogSize();
|
||||
if (run('git', ['merge', '--ff-only', target]).status !== 0) {
|
||||
return abort('pull', `git pull --ff-only to ${short(target)} failed — see scripts/update-worker.log`, explainFailure(from));
|
||||
}
|
||||
}
|
||||
|
||||
if (plan.needsBuild) {
|
||||
if (work.npmInstall) {
|
||||
log(`${because('package-lock.json')} — npm install`);
|
||||
if (run('npm', ['install']).status !== 0) return abort('npm install', 'npm install failed — nothing was built or restarted');
|
||||
const from = workerLogSize();
|
||||
if (run('npm', ['install']).status !== 0) return abort('npm install', 'npm install failed — nothing was built or restarted', explainFailure(from));
|
||||
}
|
||||
if (work.pythonDeps) {
|
||||
log(`${because('clicker/requirements.txt')} — refreshing python deps`);
|
||||
@@ -796,7 +1048,8 @@ async function apply() {
|
||||
|
||||
// Build BEFORE restarting. A failed build leaves the running process
|
||||
// untouched, which is the whole point of doing it in this order.
|
||||
if (!build()) return abort('build', 'build failed — not deployed, nothing was restarted');
|
||||
const from = workerLogSize();
|
||||
if (!build()) return abort('build', 'build failed — not deployed, nothing was restarted', explainFailure(from));
|
||||
|
||||
// .built-commit says what .next holds, so it changes the moment .next
|
||||
// does. From here a failed restart must not rebuild, only restart,
|
||||
@@ -898,6 +1151,7 @@ async function logHealth(instance, base) {
|
||||
async function main() {
|
||||
mkdirSync(path.join(ROOT, 'scripts'), { recursive: true });
|
||||
trimLog();
|
||||
if (HEAL && !DRY_RUN && !heal()) return 0;
|
||||
return APPLY && !DRY_RUN ? apply() : check();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import type { FirmPlatform } from './lib/master-protocol';
|
||||
import type { KeptRulesWindow } from './lib/kept-rules';
|
||||
|
||||
/** When one of an account type's old rule sets applies: accounts whose rules
|
||||
* date is inside [openedAt, closedAt) keep those rules (openedAt null = no
|
||||
* lower bound). `id` is the kept set's own, never an account type id. */
|
||||
export type { KeptRulesWindow };
|
||||
|
||||
export interface AccountConfig {
|
||||
/** Master's account type id — what a manual assignment pins to. */
|
||||
@@ -32,10 +38,21 @@ export interface AccountConfig {
|
||||
/** Hard daily loss limit in dollars (0/absent = none): every leg carries a
|
||||
* stop that keeps the firm's day inside it, less a reserve and fees. */
|
||||
dailyLossLimit?: number;
|
||||
/** The stage is picked by the payouts since the firm's last daily reset,
|
||||
* and every new day starts back at the base stage (Vest). */
|
||||
stagesSameDay?: boolean;
|
||||
/** The firm trades every calendar day, weekends and CME holidays included. */
|
||||
tradesEveryDay?: boolean;
|
||||
}
|
||||
|
||||
/** An account type as the firm listing (/api/firms) carries it: the current
|
||||
* rules, plus the windows of the old rule sets it keeps, so the dashboard can
|
||||
* preview which rules a rules date lands on. */
|
||||
export interface FirmAccountType extends AccountConfig {
|
||||
/** Sorted by closedAt; empty when every account is on the current rules. */
|
||||
kept: KeptRulesWindow[];
|
||||
}
|
||||
|
||||
export interface FirmConfig {
|
||||
/** Master's firm id (uuid). */
|
||||
id: string;
|
||||
@@ -57,7 +74,7 @@ export interface FirmConfig {
|
||||
instrumentsSource: 'master' | 'legacy';
|
||||
/** The firm's account types, straight from the catalog. Doubles as the
|
||||
* option list for a manual account-type override. */
|
||||
accounts: AccountConfig[];
|
||||
accounts: FirmAccountType[];
|
||||
catalogUpdatedAt: string;
|
||||
}
|
||||
|
||||
@@ -67,6 +84,9 @@ export interface AccountState {
|
||||
active: boolean;
|
||||
amount: number;
|
||||
realizedPnL: number;
|
||||
/** The firm's raw trade date (tradeDateFor): Vest's turns at 20:00 ET,
|
||||
* every other firm's at 5 PM CT. */
|
||||
tradeDate: string;
|
||||
daysTraded: number;
|
||||
positionDirection: 'long' | 'short' | null;
|
||||
/** Balance floor from Tradovate's auto-liquidation profile (0 = not set) */
|
||||
@@ -82,7 +102,9 @@ export interface AccountState {
|
||||
* consistency and minimum days all met (cycleCompletion 'today'). Only
|
||||
* the final day; the firm may lock or remove the account afterwards. */
|
||||
completed: boolean;
|
||||
/** Current stage = 1 + number of withdrawals (negative fund transactions). Stage 1 = fresh account. */
|
||||
/** Current stage = 1 + number of withdrawals (negative fund transactions,
|
||||
* or the platform's own list where a row holds several; today's for a
|
||||
* type whose stages reset each day). Stage 1 = fresh account. */
|
||||
stage: number;
|
||||
/** True when the current stage is a drain stage (spam-withdraw mode). */
|
||||
stageDrain: boolean;
|
||||
@@ -122,6 +144,10 @@ export interface AccountState {
|
||||
lastStand: boolean;
|
||||
/** Actual profit target to hit — max(stage profit target, consistency realTarget = maxDay/consistency). */
|
||||
effectiveProfitTarget: number | null;
|
||||
/** The current stage's hard daily loss limit (0 = none) and minimum
|
||||
* trading days — a withdrawal stage may set its own. null = unassigned. */
|
||||
effectiveDailyLossLimit: number | null;
|
||||
effectiveMinTradingDays: number | null;
|
||||
/** Current cycle daily P&L — used for target calculations. */
|
||||
dailyPnL: { date: string; pnl: number }[];
|
||||
/** Full P&L history across all cycles — used for calendar and equity curve display. */
|
||||
@@ -138,6 +164,20 @@ export interface AccountState {
|
||||
inferredAccountSize: number | null;
|
||||
/** True when more than one account type matched and updatedAt decided it. */
|
||||
accountTypeAmbiguous: boolean;
|
||||
/** ISO. Decides which of the type's rule sets the account trades on: one
|
||||
* dated before a firm's rule change keeps the old rules. Defaults to when
|
||||
* the account started (broker's first deposit / registration), else when
|
||||
* this machine first saw it. null = no assignment row yet. */
|
||||
rulesDate: string | null;
|
||||
/** True when the rules date was set by hand — broker evidence never moves it. */
|
||||
rulesDateManual: boolean;
|
||||
/** The old rule set the account trades on; null = the type's current
|
||||
* rules. accountType above already carries these rules. */
|
||||
keptRules: (KeptRulesWindow & {
|
||||
/** Rule fields where the kept set differs from the type's current
|
||||
* rules — raw values; the dashboard formats them. */
|
||||
differs: { field: string; kept: unknown; now: unknown }[];
|
||||
}) | null;
|
||||
}
|
||||
|
||||
export interface FirmState {
|
||||
@@ -148,3 +188,30 @@ export interface FirmState {
|
||||
connected: boolean;
|
||||
accounts: AccountState[];
|
||||
}
|
||||
|
||||
/** One recorded position (GET /api/trades) — Vest's position history plus
|
||||
* what this instance did itself. */
|
||||
export interface TradeView {
|
||||
positionId: string;
|
||||
accountId: number;
|
||||
accountName: string;
|
||||
symbol: string;
|
||||
side: 'long' | 'short';
|
||||
quantity: number;
|
||||
openPrice: number | null;
|
||||
closePrice: number | null;
|
||||
/** Realized P&L before fees and funding. */
|
||||
pnl: number | null;
|
||||
fee: number | null;
|
||||
funding: number | null;
|
||||
/** pnl − fee + funding; null while open (or unknown). */
|
||||
net: number | null;
|
||||
openedAt: number;
|
||||
closedAt: number | null;
|
||||
/** null: opened before this instance began marking its own trades. */
|
||||
openedBy: 'bot' | 'outside' | null;
|
||||
/** null while open; else 'bot', 'outside' (closed by hand or another
|
||||
* tool), or the platform's own reason (stop_loss, take_profit,
|
||||
* liquidation, …; user_requested from before marking began). */
|
||||
closedBy: string | null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user