Compare commits
41
Commits
6dd2528fe2
..
master
@@ -167,7 +167,7 @@ per-contract fees and the CME calendar.
|
|||||||
Per account type, in the **Hard Daily Loss** column: the **daily loss limit**
|
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
|
(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
|
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
|
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
|
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
|
*last stand*: no daily-loss stop, running to its take-profit or the firm's
|
||||||
@@ -203,6 +203,75 @@ Use `brokerWebSocket` or `signalrWebSocket` from `lib/outbound-proxy.ts` for bro
|
|||||||
sockets, and `directWebSocket` for control sockets. All share the same user agent;
|
sockets, and `directWebSocket` for control sockets. All share the same user agent;
|
||||||
the broker proxy still applies only to broker traffic.
|
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).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## The master dashboard
|
## The master dashboard
|
||||||
|
|||||||
+161
-30
@@ -18,6 +18,7 @@ import type { FirmConfig, FirmState, AccountState, AccountConfig } from '@/types
|
|||||||
import { stagePillClasses } from '@/lib/stage-colors';
|
import { stagePillClasses } from '@/lib/stage-colors';
|
||||||
import { projectPerfectPath } from '@/lib/trading-logic';
|
import { projectPerfectPath } from '@/lib/trading-logic';
|
||||||
import { RulesDate } from '@/app/components/RulesDate';
|
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
|
/** 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. */
|
* back to the auto-resolver. Mirrors the control on the dashboard. */
|
||||||
@@ -82,6 +83,106 @@ function fmt(value: number) {
|
|||||||
return value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
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
|
/** 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
|
* Tradovate. Confirmed inline because rows older than the report's 28-day
|
||||||
* window cannot come back. */
|
* window cannot come back. */
|
||||||
@@ -181,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) {
|
function EquityTooltip({ active, payload, label }: any) {
|
||||||
if (!active || !payload?.length) return null;
|
if (!active || !payload?.length) return null;
|
||||||
// Always read from the raw data point so fill-only series don't interfere
|
// Always read from the raw data point so fill-only series don't interfere
|
||||||
@@ -386,9 +488,11 @@ export default function AccountPage() {
|
|||||||
}, [accountId]);
|
}, [accountId]);
|
||||||
|
|
||||||
useEffect(() => {
|
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);
|
const interval = setInterval(() => { void load(); }, 5000);
|
||||||
return () => clearInterval(interval);
|
return () => { clearTimeout(first); clearInterval(interval); };
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
if (!account) {
|
if (!account) {
|
||||||
@@ -412,9 +516,12 @@ export default function AccountPage() {
|
|||||||
|
|
||||||
// The firm's hard daily loss limit, and the least a day must make to count
|
// 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.
|
// as a trading day — two different numbers; neither is set on every type.
|
||||||
const dailyLossLimit = (cfg?.dailyLossLimit ?? 0) > 0 ? cfg!.dailyLossLimit! : null;
|
// 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 minDayPnL = (cfg?.minDayPnL ?? 0) > 0 ? cfg!.minDayPnL : null;
|
||||||
const daysPassed = cfg != null && account.daysTraded >= cfg.minTradingDays;
|
const minTradingDays = account.effectiveMinTradingDays ?? cfg?.minTradingDays ?? 0;
|
||||||
|
const daysPassed = cfg != null && account.daysTraded >= minTradingDays;
|
||||||
// Dead when balance hits the liquidation floor — Tradovate reports its
|
// Dead when balance hits the liquidation floor — Tradovate reports its
|
||||||
// (positive) floor; TopstepX floors are reconstructed MLLs, negative or
|
// (positive) floor; TopstepX floors are reconstructed MLLs, negative or
|
||||||
// +$0.01 once locked at breakeven. 0 = no floor known.
|
// +$0.01 once locked at breakeven. 0 = no floor known.
|
||||||
@@ -451,13 +558,19 @@ export default function AccountPage() {
|
|||||||
}
|
}
|
||||||
const sortedEvents = [...allDates].sort().map((d) => eventsByDate.get(d)!);
|
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;
|
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 = [
|
const equityData = [
|
||||||
{ label: '', equity: 0, pnl: 0, origin: true, pos: 0, neg: 0, isWithdrawal: false },
|
{ label: '', equity: 0, pnl: 0, origin: true, pos: 0, neg: 0, isWithdrawal: false },
|
||||||
...sortedEvents.map((ev) => {
|
...sortedEvents.map((ev, i) => {
|
||||||
if (ev.fundAmt !== undefined) running += ev.fundAmt; // withdrawals reduce equity
|
const equity = Math.round(runningAfter[i] * 100) / 100;
|
||||||
if (ev.pnl !== undefined) running += ev.pnl;
|
|
||||||
const equity = Math.round(running * 100) / 100;
|
|
||||||
return {
|
return {
|
||||||
label: fmtDate(ev.date),
|
label: fmtDate(ev.date),
|
||||||
equity,
|
equity,
|
||||||
@@ -496,7 +609,7 @@ export default function AccountPage() {
|
|||||||
// books exactly its plan, cycles complete, and the configured withdrawal
|
// books exactly its plan, cycles complete, and the configured withdrawal
|
||||||
// fires the day a cycle finishes. Same planner the trade path runs, so the
|
// 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
|
// 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({
|
const rawProjection = (cfg && !isDead) ? projectPerfectPath({
|
||||||
accountId: account.id,
|
accountId: account.id,
|
||||||
cfg,
|
cfg,
|
||||||
@@ -504,7 +617,15 @@ export default function AccountPage() {
|
|||||||
fundTransactions,
|
fundTransactions,
|
||||||
balance: account.amount,
|
balance: account.amount,
|
||||||
realizedPnL: account.realizedPnL,
|
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: {
|
payout: {
|
||||||
withdrawalPct: cfg.withdrawalPct,
|
withdrawalPct: cfg.withdrawalPct,
|
||||||
payoutCap: cfg.payoutCap,
|
payoutCap: cfg.payoutCap,
|
||||||
@@ -520,23 +641,21 @@ export default function AccountPage() {
|
|||||||
: undefined,
|
: undefined,
|
||||||
},
|
},
|
||||||
}) : [];
|
}) : [];
|
||||||
// Cut the projection at the second payout — days beyond it are noise here.
|
// Cut the projection after the second payout day — days beyond it are
|
||||||
const secondPayoutIdx = (() => {
|
// noise here. The path's dates never go back, and a payoutReady entry is
|
||||||
let seen = 0;
|
// always its last.
|
||||||
for (let i = 0; i < rawProjection.length; i++) {
|
const payoutDays = [...new Set(rawProjection.filter((p) => p.withdrawal !== undefined || p.payoutReady).map((p) => p.date))];
|
||||||
const p = rawProjection[i];
|
const projection = payoutDays.length >= 2 ? rawProjection.filter((p) => p.date <= payoutDays[1]) : rawProjection;
|
||||||
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;
|
|
||||||
// A payout day is the cycle's FINAL trading day — it can carry both the
|
// A payout day is the cycle's FINAL trading day — it can carry both the
|
||||||
// day's booked P&L and the withdrawal.
|
// day's booked P&L and the withdrawal. A same-day type's day can hold
|
||||||
const projPnlMap = new Map(projection.filter((p) => p.pnl !== 0).map((p) => [p.date, p.pnl]));
|
// several cycles: their P&L and withdrawals add up, and the day shows
|
||||||
const projFundMap = new Map(projection.filter((p) => p.withdrawal !== undefined).map((p) => [p.date, p.withdrawal!]));
|
// 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]));
|
const projStageMap = new Map(projection.map((p) => [p.date, p.stage]));
|
||||||
/** Cumulative probability of the perfect path surviving through each projected day. */
|
/** Cumulative probability of the perfect path surviving through each projected day. */
|
||||||
const projProbMap = new Map(projection.map((p) => [p.date, p.prob]));
|
const projProbMap = new Map(projection.map((p) => [p.date, p.prob]));
|
||||||
@@ -551,14 +670,20 @@ export default function AccountPage() {
|
|||||||
];
|
];
|
||||||
const calendarMonths = [...new Set(allCalendarDates.map((d) => d.slice(0, 7)))].sort();
|
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
|
const sortedWithdrawalDates = fundTransactions
|
||||||
.filter((f) => f.amount < 0)
|
.filter((f) => f.amount < 0)
|
||||||
.map((f) => f.date)
|
.map((f) => f.date)
|
||||||
.sort();
|
.sort();
|
||||||
const stageMap = new Map<string, number>();
|
const stageMap = new Map<string, number>();
|
||||||
for (const d of displayPnL) {
|
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);
|
stageMap.set(d.date, stage);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -587,7 +712,10 @@ export default function AccountPage() {
|
|||||||
☠ DEAD
|
☠ DEAD
|
||||||
</span>
|
</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>
|
</div>
|
||||||
|
|
||||||
{isDead && (
|
{isDead && (
|
||||||
@@ -635,7 +763,7 @@ export default function AccountPage() {
|
|||||||
<ObjRow
|
<ObjRow
|
||||||
passed={daysPassed}
|
passed={daysPassed}
|
||||||
label="Trading Days"
|
label="Trading Days"
|
||||||
value={cfg ? `${account.daysTraded} of ${cfg.minTradingDays}` : '—'}
|
value={cfg ? `${account.daysTraded} of ${minTradingDays}` : '—'}
|
||||||
/>
|
/>
|
||||||
{dailyTarget != null && (
|
{dailyTarget != null && (
|
||||||
<div className="flex items-center justify-between py-3 border-b border-slate-100 last:border-0">
|
<div className="flex items-center justify-between py-3 border-b border-slate-100 last:border-0">
|
||||||
@@ -788,6 +916,7 @@ export default function AccountPage() {
|
|||||||
stroke={`url(#${strokeGradId})`}
|
stroke={`url(#${strokeGradId})`}
|
||||||
strokeWidth={2.5}
|
strokeWidth={2.5}
|
||||||
fill="none"
|
fill="none"
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
dot={(props: any) => {
|
dot={(props: any) => {
|
||||||
if (props.payload?.origin) return <g key={props.key} />;
|
if (props.payload?.origin) return <g key={props.key} />;
|
||||||
return (
|
return (
|
||||||
@@ -838,6 +967,8 @@ export default function AccountPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<TradesTable query={`accountId=${accountId}`} title="Trades" />
|
||||||
|
|
||||||
{/* Cash History Table */}
|
{/* Cash History Table */}
|
||||||
{(displayPnL.length > 0 || fundTransactions.length > 0) && (
|
{(displayPnL.length > 0 || fundTransactions.length > 0) && (
|
||||||
<div className="bg-white border border-slate-200 rounded-xl shadow-sm p-6 mt-5">
|
<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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,7 +16,9 @@ type Params = { params: Promise<{ id: string }> };
|
|||||||
* and login domain are set on the master, per firm). TopstepX firms need the
|
* 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,
|
* 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
|
* 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) {
|
export async function PUT(req: NextRequest, { params }: Params) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
|
||||||
@@ -33,7 +35,9 @@ export async function PUT(req: NextRequest, { params }: Params) {
|
|||||||
? 'apiKey is required for a TopstepX firm'
|
? 'apiKey is required for a TopstepX firm'
|
||||||
: firm.platform === 'dxtrade'
|
: firm.platform === 'dxtrade'
|
||||||
? 'username and password are required for a DXtrade firm'
|
? '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 });
|
return NextResponse.json({ error }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { getSetting, setSetting } from '@/lib/db';
|
|||||||
import { scheduleBackupPush } from '@/lib/backup-sync';
|
import { scheduleBackupPush } from '@/lib/backup-sync';
|
||||||
import { getRequestUserAgent } from '@/lib/request-user-agent';
|
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];
|
type SettingKey = typeof VALID_KEYS[number];
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
@@ -25,6 +25,15 @@ export async function PATCH(req: NextRequest) {
|
|||||||
try {
|
try {
|
||||||
const body = await req.json() as Partial<Record<SettingKey, string | number>>;
|
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) {
|
for (const key of VALID_KEYS) {
|
||||||
if (key in body) {
|
if (key in body) {
|
||||||
const raw = body[key];
|
const raw = body[key];
|
||||||
|
|||||||
+34
-28
@@ -5,13 +5,13 @@ import { getAccountTypeById, getCachedCatalog } from '@/lib/firm-catalog';
|
|||||||
import { ruleDiffs } from '@/lib/kept-rules';
|
import { ruleDiffs } from '@/lib/kept-rules';
|
||||||
import { getAccountConfig, getInferredSize, matchAccountType } from '@/lib/account-type-resolver';
|
import { getAccountConfig, getInferredSize, matchAccountType } from '@/lib/account-type-resolver';
|
||||||
import { historyFresh } from '@/lib/platform-client';
|
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 { obfuscationDayBooked } from '@/lib/auto-trade';
|
||||||
import type { AccountState } from '@/types';
|
import type { AccountState } from '@/types';
|
||||||
import {
|
import {
|
||||||
combineCmeSessions, computeDayPlan, cycleCompletion, resolveEffectiveConfig, currentTradeDateCT,
|
combineCmeSessions, computeDayPlan, cycleCompletion, resolveEffectiveConfig, tradeDateFor,
|
||||||
obfuscationStopDollars, dllRoom as computeDllRoom, dllStopDollars, botStopsActive,
|
obfuscationStopDollars, dllStopDollars, botStopsActive, stageInputsFor, stagePayouts,
|
||||||
maxLossRoom, lastStand as isLastStand,
|
maxLossRoom, lastStand as isLastStand, shortestNextLeg,
|
||||||
LEG_COMPLETION_EPSILON,
|
LEG_COMPLETION_EPSILON,
|
||||||
} from '@/lib/trading-logic';
|
} from '@/lib/trading-logic';
|
||||||
|
|
||||||
@@ -19,7 +19,6 @@ export async function GET() {
|
|||||||
try {
|
try {
|
||||||
const firms = getCachedCatalog();
|
const firms = getCachedCatalog();
|
||||||
const clients = getClients();
|
const clients = getClients();
|
||||||
const tradeDate = currentTradeDateCT();
|
|
||||||
|
|
||||||
const state = firms.map((f) => {
|
const state = firms.map((f) => {
|
||||||
const client = clients.get(f.id);
|
const client = clients.get(f.id);
|
||||||
@@ -30,6 +29,9 @@ export async function GET() {
|
|||||||
if (!client || client.accountList.length === 0) {
|
if (!client || client.accountList.length === 0) {
|
||||||
return { firmId: f.id, firm: f.name, connected, accounts: [] };
|
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
|
// Accounts whose rules date was set by hand — one read per firm
|
||||||
// rather than one per account.
|
// rather than one per account.
|
||||||
const rulesDateByHand = new Set(listAssignments(f.id)
|
const rulesDateByHand = new Set(listAssignments(f.id)
|
||||||
@@ -61,8 +63,11 @@ export async function GET() {
|
|||||||
const isDead = autoLiqThreshold !== 0 && cash.amount <= autoLiqThreshold;
|
const isDead = autoLiqThreshold !== 0 && cash.amount <= autoLiqThreshold;
|
||||||
const allFundTxns = client.fundTransactions?.[acc.id] ?? [];
|
const allFundTxns = client.fundTransactions?.[acc.id] ?? [];
|
||||||
const priorProfit = client.priorProfit?.[acc.id] ?? 0;
|
const priorProfit = client.priorProfit?.[acc.id] ?? 0;
|
||||||
// Stage = 1 + number of withdrawals (negative fund transactions)
|
// Stage = 1 + number of payouts (negative fund transactions, or
|
||||||
const stage = 1 + allFundTxns.filter((f) => f.amount < 0).length;
|
// 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
|
// Stage-resolved config: a drain stage swaps in its own min-day
|
||||||
// (default $5) and no profit target.
|
// (default $5) and no profit target.
|
||||||
const effective = cfg ? resolveEffectiveConfig(
|
const effective = cfg ? resolveEffectiveConfig(
|
||||||
@@ -73,7 +78,8 @@ export async function GET() {
|
|||||||
cfg.withdrawalStages,
|
cfg.withdrawalStages,
|
||||||
priorProfit,
|
priorProfit,
|
||||||
allFundTxns,
|
allFundTxns,
|
||||||
cfg.minDayPnL
|
cfg.minDayPnL,
|
||||||
|
stageInputs,
|
||||||
) : null;
|
) : null;
|
||||||
// Days traded counts only days that hit minDayPnL (when set) — that's what
|
// Days traded counts only days that hit minDayPnL (when set) — that's what
|
||||||
// the firm requires toward the min trading day rule.
|
// the firm requires toward the min trading day rule.
|
||||||
@@ -128,16 +134,8 @@ export async function GET() {
|
|||||||
// the day after a loss rides stopless) against the hard
|
// the day after a loss rides stopless) against the hard
|
||||||
// daily-loss stop, before fees. A min day trades its whole
|
// daily-loss stop, before fees. A min day trades its whole
|
||||||
// remaining target as one leg.
|
// remaining target as one leg.
|
||||||
const dll = cfg.dailyLossLimit ?? 0;
|
const dll = effective?.dailyLossLimit ?? 0;
|
||||||
if (dll > 0) {
|
dllRoom = dayLossRoom(client, acc.id, dll, cash, combined.realizedPnL, plan ? shortestNextLeg(plan) : 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),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// The max-drawdown floor, and whether the next leg is a last
|
// The max-drawdown floor, and whether the next leg is a last
|
||||||
// stand — no daily-loss stop — as fireLeg decides it.
|
// stand — no daily-loss stop — as fireLeg decides it.
|
||||||
const floorRoom = maxLossRoom({
|
const floorRoom = maxLossRoom({
|
||||||
@@ -151,20 +149,12 @@ export async function GET() {
|
|||||||
// "stop $912" for a leg that never fires.
|
// "stop $912" for a leg that never fires.
|
||||||
const dayBooked = obfuscationDayBooked(cfg, acc.id, tradeDate, combined.realizedPnL) !== null;
|
const dayBooked = obfuscationDayBooked(cfg, acc.id, tradeDate, combined.realizedPnL) !== null;
|
||||||
lastStandNext = !dayBooked && dllRoom !== null && isLastStand({ dllRoom, dailyLossLimit: dll, maxLossRoom: floorRoom });
|
lastStandNext = !dayBooked && dllRoom !== null && isLastStand({ dllRoom, dailyLossLimit: dll, maxLossRoom: floorRoom });
|
||||||
if (plan && !dayBooked && 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// DXtrade: the next leg sized on every surveyed instrument as
|
// DXtrade: the next leg sized on every surveyed instrument as
|
||||||
// fireLeg would, and the one a batch would pick.
|
// fireLeg would, and the one a batch would pick.
|
||||||
if (plan && !dayBooked && plan.remainingToday > 0) {
|
if (plan && !dayBooked && plan.remainingToday > 0) {
|
||||||
const best = rankNextLeg({
|
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),
|
obfuscationDollars: obfuscationStopDollars(cfg, dailyPnL, plan, combined.tradeDate, plan.remainingToday),
|
||||||
cash, tradeDate: combined.tradeDate,
|
cash, tradeDate: combined.tradeDate,
|
||||||
})[0];
|
})[0];
|
||||||
@@ -183,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).
|
// Effective profit target = max(stage target, consistency realTarget).
|
||||||
// If a big day forces the consistency rule, the account must reach maxDay/consistency
|
// If a big day forces the consistency rule, the account must reach maxDay/consistency
|
||||||
@@ -253,6 +253,7 @@ export async function GET() {
|
|||||||
completed = cycleCompletion({
|
completed = cycleCompletion({
|
||||||
accountId: acc.id,
|
accountId: acc.id,
|
||||||
rawTradeDate: tradeDate,
|
rawTradeDate: tradeDate,
|
||||||
|
client,
|
||||||
cycleDailyPnL: client.dailyPnL[acc.id],
|
cycleDailyPnL: client.dailyPnL[acc.id],
|
||||||
rawRealizedPnL: cash.realizedPnL,
|
rawRealizedPnL: cash.realizedPnL,
|
||||||
combineCmeHolidays: cfg.combineCmeHolidays,
|
combineCmeHolidays: cfg.combineCmeHolidays,
|
||||||
@@ -271,11 +272,14 @@ export async function GET() {
|
|||||||
// The combined day's P&L, not just the current session's —
|
// The combined day's P&L, not just the current session's —
|
||||||
// on the Tuesday after a holiday this includes Sun/Mon.
|
// on the Tuesday after a holiday this includes Sun/Mon.
|
||||||
realizedPnL: combined.realizedPnL,
|
realizedPnL: combined.realizedPnL,
|
||||||
|
// The firm's raw trade date, which the account page
|
||||||
|
// projects the payouts from.
|
||||||
|
tradeDate,
|
||||||
daysTraded,
|
daysTraded,
|
||||||
// An account the bot puts stops on whose day booked red via
|
// An account the bot puts stops on whose day booked red via
|
||||||
// its own stop — the personal DLL. Firm DLL lockouts show
|
// its own stop — the personal DLL. Firm DLL lockouts show
|
||||||
// via !active instead.
|
// via !active instead.
|
||||||
personalDll: !!cfg && botStopsActive(cfg)
|
personalDll: !!cfg && !!effective && botStopsActive(cfg, effective)
|
||||||
&& acc.active && !client.positions[acc.id]
|
&& acc.active && !client.positions[acc.id]
|
||||||
&& combined.realizedPnL <= -25,
|
&& combined.realizedPnL <= -25,
|
||||||
positionDirection: client.positions[acc.id]
|
positionDirection: client.positions[acc.id]
|
||||||
@@ -295,6 +299,8 @@ export async function GET() {
|
|||||||
lastStand: lastStandNext,
|
lastStand: lastStandNext,
|
||||||
remainingToday,
|
remainingToday,
|
||||||
effectiveProfitTarget,
|
effectiveProfitTarget,
|
||||||
|
effectiveDailyLossLimit: effective?.dailyLossLimit ?? null,
|
||||||
|
effectiveMinTradingDays: effective?.minTradingDays ?? null,
|
||||||
dailyPnL,
|
dailyPnL,
|
||||||
fullDailyPnL: combineCmeSessions(
|
fullDailyPnL: combineCmeSessions(
|
||||||
cfg?.combineCmeHolidays === true,
|
cfg?.combineCmeHolidays === true,
|
||||||
|
|||||||
@@ -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,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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+154
-5
@@ -5,6 +5,7 @@ import Link from 'next/link';
|
|||||||
import type { FirmConfig, FirmState, AccountState } from '@/types';
|
import type { FirmConfig, FirmState, AccountState } from '@/types';
|
||||||
import { stagePillClasses } from '@/lib/stage-colors';
|
import { stagePillClasses } from '@/lib/stage-colors';
|
||||||
import { RulesDate } from '@/app/components/RulesDate';
|
import { RulesDate } from '@/app/components/RulesDate';
|
||||||
|
import { TradesTable } from '@/app/components/TradesTable';
|
||||||
|
|
||||||
type SortKey = 'name' | 'balance' | 'dayPnL' | 'daysTraded' | 'target' | 'status';
|
type SortKey = 'name' | 'balance' | 'dayPnL' | 'daysTraded' | 'target' | 'status';
|
||||||
type SortDir = 'asc' | 'desc';
|
type SortDir = 'asc' | 'desc';
|
||||||
@@ -195,14 +196,91 @@ function credentialNoun(platform: FirmConfig['platform']): string {
|
|||||||
case 'topstepx': return 'TopstepX API key';
|
case 'topstepx': return 'TopstepX API key';
|
||||||
case 'dxtrade': return 'DXtrade login';
|
case 'dxtrade': return 'DXtrade login';
|
||||||
case 'tradovate': return 'Tradovate 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.
|
/** Inline credential entry, shown to the right of a firm that has none.
|
||||||
* Tradovate and DXtrade want username + password; TopstepX wants the API key
|
* Tradovate and DXtrade want username + password; TopstepX wants the API key
|
||||||
* (username optional for now, though login needs it eventually — password
|
* (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 }) {
|
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 isTopstepX = firm.platform === 'topstepx';
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
@@ -375,7 +453,7 @@ function AccountRow({ account, firm, hideDead, privacy, onAssigned, onStateChang
|
|||||||
<AccountTypeCell account={account} firm={firm} onAssigned={onAssigned} />
|
<AccountTypeCell account={account} firm={firm} onAssigned={onAssigned} />
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-slate-600 tabular-nums">
|
<td className="px-4 py-3 text-slate-600 tabular-nums">
|
||||||
{account.daysTraded} / {cfg?.minTradingDays ?? '—'}
|
{account.daysTraded} / {account.effectiveMinTradingDays ?? cfg?.minTradingDays ?? '—'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-slate-600 tabular-nums">
|
<td className="px-4 py-3 text-slate-600 tabular-nums">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
@@ -395,7 +473,7 @@ function AccountRow({ account, firm, hideDead, privacy, onAssigned, onStateChang
|
|||||||
<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>
|
<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' && (
|
{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 && (
|
{account.holdEstimate && (
|
||||||
<span
|
<span
|
||||||
@@ -456,6 +534,7 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo
|
|||||||
onStateChanged: () => void;
|
onStateChanged: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(true);
|
const [open, setOpen] = useState(true);
|
||||||
|
const [showTrades, setShowTrades] = useState(false);
|
||||||
const [confirmCreds, setConfirmCreds] = useState(false);
|
const [confirmCreds, setConfirmCreds] = useState(false);
|
||||||
const [removingCreds, setRemovingCreds] = useState(false);
|
const [removingCreds, setRemovingCreds] = useState(false);
|
||||||
const hasCredentials = firm.hasCredentials;
|
const hasCredentials = firm.hasCredentials;
|
||||||
@@ -503,7 +582,7 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo
|
|||||||
<td className="px-4 py-3.5">
|
<td className="px-4 py-3.5">
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<span className="font-bold text-sm text-slate-800">{firm.firm}</span>
|
<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">
|
<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
|
{tradeSymbol} not listed
|
||||||
</span>
|
</span>
|
||||||
@@ -518,6 +597,11 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo
|
|||||||
DXtrade
|
DXtrade
|
||||||
</span>
|
</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 && (
|
{firm.platform === 'topstepx' && hasCredentials && !firm.username && (
|
||||||
<span
|
<span
|
||||||
className="text-xs bg-amber-100 text-amber-700 px-1.5 py-0.5 rounded font-semibold"
|
className="text-xs bg-amber-100 text-amber-700 px-1.5 py-0.5 rounded font-semibold"
|
||||||
@@ -568,6 +652,15 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo
|
|||||||
)}
|
)}
|
||||||
<td className="px-4 py-3.5">
|
<td className="px-4 py-3.5">
|
||||||
<div className="flex items-center justify-end gap-1">
|
<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 && (
|
{hasCredentials && !confirmCreds && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); setConfirmCreds(true); }}
|
onClick={(e) => { e.stopPropagation(); setConfirmCreds(true); }}
|
||||||
@@ -590,6 +683,14 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</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 && (
|
{open && hasCredentials && (
|
||||||
state.accounts.length === 0 ? (
|
state.accounts.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
@@ -642,6 +743,8 @@ export default function Home() {
|
|||||||
const [tickInterval, setTickInterval] = useState('60');
|
const [tickInterval, setTickInterval] = useState('60');
|
||||||
const [tradeLoading, setTradeLoading] = useState(false);
|
const [tradeLoading, setTradeLoading] = useState(false);
|
||||||
const [stopAfterAll, setStopAfterAll] = useState(false);
|
const [stopAfterAll, setStopAfterAll] = useState(false);
|
||||||
|
const [autoClaim, setAutoClaim] = useState(true);
|
||||||
|
const [autoActivate, setAutoActivate] = useState(true);
|
||||||
|
|
||||||
const handleSort = (col: SortKey) => {
|
const handleSort = (col: SortKey) => {
|
||||||
if (sortKey === col) {
|
if (sortKey === col) {
|
||||||
@@ -711,8 +814,10 @@ export default function Home() {
|
|||||||
|
|
||||||
fetch('/api/settings')
|
fetch('/api/settings')
|
||||||
.then((r) => r.json())
|
.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);
|
if (s.tick_interval_seconds != null) setTickInterval(s.tick_interval_seconds);
|
||||||
|
setAutoClaim(s.auto_claim !== 'off');
|
||||||
|
setAutoActivate(s.auto_activate !== 'off');
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
|
||||||
@@ -899,6 +1004,50 @@ export default function Home() {
|
|||||||
/>
|
/>
|
||||||
<span className="text-xs text-slate-400 whitespace-nowrap">Stop after all eligible</span>
|
<span className="text-xs text-slate-400 whitespace-nowrap">Stop after all eligible</span>
|
||||||
</label>
|
</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">
|
<div className="ml-auto flex items-center gap-2">
|
||||||
{hasAnyPosition && (
|
{hasAnyPosition && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -79,6 +79,49 @@ 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
|
`content_scripts` list), a Site link on its firm on the master, and its codes
|
||||||
assigned on the master's Codes page.
|
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
|
## Notes
|
||||||
|
|
||||||
- The extension never captures the dashboard's own pages — otherwise it would
|
- The extension never captures the dashboard's own pages — otherwise it would
|
||||||
|
|||||||
@@ -12,6 +12,9 @@
|
|||||||
* ready at all times; a run happens because someone asked for one.
|
* 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.
|
* 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 = {
|
const DEFAULTS = {
|
||||||
@@ -1185,6 +1188,170 @@ async function tick() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
await setState({ connected: true, error: `2FA chip: ${err.message}` });
|
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 ───────
|
// ── 2FA: the person's authenticator code for the firm site you are on ───────
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "AutoFirmer Capture",
|
"name": "AutoFirmer Capture",
|
||||||
"version": "0.16.0",
|
"version": "0.18.0",
|
||||||
|
|
||||||
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxdbOMRR+58a9dHlbIKtlALtS3E4eltBGqfTnUc0lzJuWj5ZA4+5YFSdUnxv5N1ogxwpJUGkUV3MnKJuZnTekCmsNwjCy41a20Xtc7jQLHrIBZkesB6cB+d0l9Vo8M6EC3ZMD8Iv6MdAalU3GZh1Hn9vm24NjJ+xUT1qNIVHEcNUIb9evYobR/XHv+JAp6bOFnaLM+3wpyYKe4efMhsz0F86u9thRNhQnraTqUWfnKNpsHPK2zivaYHrziEEWX7Y3EsMHkf2FRhkUe7IDoxeJM49DBToTo9sAJUkcgLeGCGZLo7H3mcxyn/zERYR+rD3k5xdeBtjoZHXFx2+FanLb9wIDAQAB",
|
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxdbOMRR+58a9dHlbIKtlALtS3E4eltBGqfTnUc0lzJuWj5ZA4+5YFSdUnxv5N1ogxwpJUGkUV3MnKJuZnTekCmsNwjCy41a20Xtc7jQLHrIBZkesB6cB+d0l9Vo8M6EC3ZMD8Iv6MdAalU3GZh1Hn9vm24NjJ+xUT1qNIVHEcNUIb9evYobR/XHv+JAp6bOFnaLM+3wpyYKe4efMhsz0F86u9thRNhQnraTqUWfnKNpsHPK2zivaYHrziEEWX7Y3EsMHkf2FRhkUe7IDoxeJM49DBToTo9sAJUkcgLeGCGZLo7H3mcxyn/zERYR+rD3k5xdeBtjoZHXFx2+FanLb9wIDAQAB",
|
||||||
"update_url": "https://ext.juicerroom.com/updates.xml",
|
"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, and shows the 2FA code for the firm site you are on.",
|
"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"],
|
"permissions": ["scripting", "tabs", "storage", "alarms"],
|
||||||
"host_permissions": [
|
"host_permissions": [
|
||||||
"https://*.tradeify.co/*",
|
"https://*.tradeify.co/*",
|
||||||
"https://*.lucidtrading.com/*",
|
"https://*.lucidtrading.com/*",
|
||||||
"https://*.topstep.com/*",
|
"https://*.topstep.com/*",
|
||||||
"https://*.apextraderfunding.com/*",
|
"https://*.apextraderfunding.com/*",
|
||||||
|
"https://*.vestmarkets.com/*",
|
||||||
"http://localhost/*",
|
"http://localhost/*",
|
||||||
"http://127.0.0.1/*"
|
"http://127.0.0.1/*"
|
||||||
],
|
],
|
||||||
@@ -29,6 +30,12 @@
|
|||||||
"run_at": "document_start",
|
"run_at": "document_start",
|
||||||
"all_frames": true,
|
"all_frames": true,
|
||||||
"world": "MAIN"
|
"world": "MAIN"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matches": ["https://next.vestmarkets.com/*"],
|
||||||
|
"js": ["vest-relay.js"],
|
||||||
|
"run_at": "document_start",
|
||||||
|
"world": "MAIN"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"action": {
|
"action": {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ async function render() {
|
|||||||
document.getElementById('status').innerHTML =
|
document.getElementById('status').innerHTML =
|
||||||
`<span class="dot" style="background:${color}"></span><strong>${label}</strong>` +
|
`<span class="dot" style="background:${color}"></span><strong>${label}</strong>` +
|
||||||
detail +
|
detail +
|
||||||
|
(state.vest ? `<div class="muted">Vest: ${esc(state.vest)}</div>` : '') +
|
||||||
(state.error ? `<div class="err">${esc(state.error)}</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;
|
||||||
|
};
|
||||||
|
})();
|
||||||
+446
-56
@@ -11,23 +11,25 @@ import { getClients } from './clients';
|
|||||||
import { getCachedFirm, getTradableFirms } from './firm-catalog';
|
import { getCachedFirm, getTradableFirms } from './firm-catalog';
|
||||||
import { ensureRulesEvidence, getAccountConfig } from './account-type-resolver';
|
import { ensureRulesEvidence, getAccountConfig } from './account-type-resolver';
|
||||||
import {
|
import {
|
||||||
combineCmeSessions, computeDayPlan, groupLegFraction, pickLegAmount, resolveEffectiveConfig, currentTradeDateCT,
|
combineCmeSessions, computeDayPlan, groupLegFraction, pickLegAmount, resolveEffectiveConfig, tradeDateFor,
|
||||||
obfuscationStopDollars, takeProfitPlan, onTick, percentFee, dllRoom, dllStopDollars, dllViable, stopTicks,
|
obfuscationStopDollars, takeProfitPlan, onTick, percentFee, dllRoom, dllStopDollars, dllViable, stopTicks,
|
||||||
expectedSwapToWin, planLegSize, maxLossRoom, breachDistance,
|
expectedSwapToWin, planLegSize, maxLossRoom, breachDistance, legCloseNote,
|
||||||
isNoTradeTime, isPastSessionClose,
|
isNoTradeTime, isPastSessionClose,
|
||||||
BIG_DAY_THRESHOLD, LEG_COMPLETION_EPSILON, MAX_LEGS_PER_DAY,
|
BIG_DAY_THRESHOLD, LEG_COMPLETION_EPSILON, MAX_LEGS_PER_DAY,
|
||||||
FALLBACK_COMMISSION_PER_CONTRACT, MAX_COMMISSION_PER_CONTRACT, DLL_MIN_STOP,
|
FALLBACK_COMMISSION_PER_CONTRACT, MAX_COMMISSION_PER_CONTRACT, DLL_MIN_STOP,
|
||||||
CME_EVENING_OPEN_MIN, foldReason, withinFirmCaps,
|
CME_EVENING_OPEN_MIN, foldReason, withinFirmCaps,
|
||||||
fundRowsKey, markFinishedCycle, unbookedPayoutDrop,
|
fundRowsKey, markFinishedCycle, unbookedPayoutDrop, machineSide, stageInputsFor, stagePayouts, floorRoom, shortestNextLeg,
|
||||||
type DayPlan, type FinishedCycleMark,
|
claimedCycle, withinEndedCycleWindow,
|
||||||
|
type DayPlan, type FinishedCycleMark, type ClaimedCycle,
|
||||||
} from './trading-logic';
|
} from './trading-logic';
|
||||||
import { getSetting, setSetting, loadAccountMeta, saveAccountMeta } from './db';
|
import { getSetting, setSetting, loadAccountMeta, saveAccountMeta, loadClaimedCycles, saveClaimedCycles } from './db';
|
||||||
import type { LaneTick, TickReason } from './master-protocol';
|
import type { LaneTick, TickReason } from './master-protocol';
|
||||||
|
import { isNotionalPlatform } from './master-protocol';
|
||||||
import { POINT_VALUES, housekeepingRoot, HOUSEKEEPING_ROOT, MAX_HOLD_HOURS, futuresContracts } from './instrument-specs';
|
import { POINT_VALUES, housekeepingRoot, HOUSEKEEPING_ROOT, MAX_HOLD_HOURS, futuresContracts } from './instrument-specs';
|
||||||
import { firmInstruments, drawFuturesInstrument, spreadBlock, type LegShape } from './instruments';
|
import { firmInstruments, drawFuturesInstrument, spreadBlock, type LegShape } from './instruments';
|
||||||
import { recordEntry } from './trade-mix';
|
import { recordEntry } from './trade-mix';
|
||||||
import { sizingHoldHours } from './person-size';
|
import { sizingHoldHours } from './person-size';
|
||||||
import { errorMessage, isClientReady, isOrderNotPlaced, type PlatformClient, type PlatformContract, type PickContext } from './platform-client';
|
import { errorMessage, isClientReady, isNothingToClose, isOrderNotPlaced, type PlatformClient, type PlatformContract, type PickContext } from './platform-client';
|
||||||
|
|
||||||
// ── process-wide state ───────────────────────────────────────────────────────
|
// ── process-wide state ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -64,6 +66,17 @@ interface TradeRuntime {
|
|||||||
/** Lane → when the batch holding that lane's trade mutex took it (absent
|
/** Lane → when the batch holding that lane's trade mutex took it (absent
|
||||||
* = no batch in flight there). See laneOf. */
|
* = no batch in flight there). See laneOf. */
|
||||||
tradeInFlight?: Map<string, number>;
|
tradeInFlight?: Map<string, number>;
|
||||||
|
/** The side a lane's batch (or manual leg) chose with nothing held on the
|
||||||
|
* machine, while it places it: another lane deciding meanwhile takes the
|
||||||
|
* same side (see sideForEntry). Only counts while its lease holds. */
|
||||||
|
sideReservation?: { side: 'Buy' | 'Sell'; lane: string; lease: number };
|
||||||
|
/** Account id → when its last payout claim failed (runClaims backoff). */
|
||||||
|
claimFailedAt?: Map<number, number>;
|
||||||
|
/** Account id → when its last funded-account activation failed
|
||||||
|
* (runActivations backoff). */
|
||||||
|
activateFailedAt?: Map<number, number>;
|
||||||
|
/** Lanes whose runActivations is out (one at a time per lane). */
|
||||||
|
activationsRunning?: Set<string>;
|
||||||
/** Accounts whose exit the broker refused — a bracket while a hard daily
|
/** Accounts whose exit the broker refused — a bracket while a hard daily
|
||||||
* loss limit was on, or a lone take-profit: the position was flattened
|
* loss limit was on, or a lone take-profit: the position was flattened
|
||||||
* and the account sits out the trade date rather than bleed a round trip
|
* and the account sits out the trade date rather than bleed a round trip
|
||||||
@@ -253,7 +266,10 @@ function unrecordLeg(accountId: number, tradeDate: string, legAmount: number): v
|
|||||||
* (OrderNotPlacedError). Anything ambiguous, like an ack or fill timeout,
|
* (OrderNotPlacedError). Anything ambiguous, like an ack or fill timeout,
|
||||||
* keeps the leg: the order may well have filled, and a leg counted that never
|
* keeps the leg: the order may well have filled, and a leg counted that never
|
||||||
* happened only costs a leg, while one forgotten that did can trade the
|
* happened only costs a leg, while one forgotten that did can trade the
|
||||||
* account twice.
|
* account twice. Open-only: where the platform checks (Vest, DXtrade), an
|
||||||
|
* account that already holds the symbol — a hand trade the synced positions
|
||||||
|
* don't show yet — is refused rather than having that position closed as the
|
||||||
|
* "entry".
|
||||||
*/
|
*/
|
||||||
async function sendEntry(
|
async function sendEntry(
|
||||||
tag: string,
|
tag: string,
|
||||||
@@ -270,7 +286,7 @@ async function sendEntry(
|
|||||||
const at = Date.now();
|
const at = Date.now();
|
||||||
const realizedBefore = client.accountCashBalances[acc.id]?.realizedPnL ?? 0;
|
const realizedBefore = client.accountCashBalances[acc.id]?.realizedPnL ?? 0;
|
||||||
try {
|
try {
|
||||||
const fill = await client.sendOrder(acc.id, contractName, contracts, action, 'Market');
|
const fill = await client.sendOrder(acc.id, contractName, contracts, action, 'Market', undefined, 'open');
|
||||||
outcomes.set(acc.id, { tradeDate, state: 'filled', at, realizedBefore, legAmount });
|
outcomes.set(acc.id, { tradeDate, state: 'filled', at, realizedBefore, legAmount });
|
||||||
return fill;
|
return fill;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -310,10 +326,11 @@ interface FirmClient { firm: TradableFirm; client: PlatformClient }
|
|||||||
* multi-hour crypto position never holds up the futures open and a stuck
|
* multi-hour crypto position never holds up the futures open and a stuck
|
||||||
* futures position never freezes a 24/7 firm's weekend. Every CME-futures
|
* futures position never freezes a 24/7 firm's weekend. Every CME-futures
|
||||||
* firm (Tradovate, TopstepX) shares the one 'futures' lane — exactly how all
|
* firm (Tradovate, TopstepX) shares the one 'futures' lane — exactly how all
|
||||||
* firms traded before lanes existed; each DXTrade firm is a lane of its own.
|
* firms traded before lanes existed; each DXTrade or Vest firm is a lane of
|
||||||
|
* its own.
|
||||||
*/
|
*/
|
||||||
export function laneOf(firm: { id: string; platform: string }): string {
|
export function laneOf(firm: { id: string; platform: string }): string {
|
||||||
return firm.platform === 'dxtrade' ? firm.id : FUTURES_LANE;
|
return isNotionalPlatform(firm.platform) ? firm.id : FUTURES_LANE;
|
||||||
}
|
}
|
||||||
const FUTURES_LANE = 'futures';
|
const FUTURES_LANE = 'futures';
|
||||||
|
|
||||||
@@ -451,13 +468,39 @@ function holdsTradeMutex(lease: number, lane: string): boolean {
|
|||||||
* release the batch that took over from it. */
|
* release the batch that took over from it. */
|
||||||
function releaseTradeMutex(lease: number, lane: string): void {
|
function releaseTradeMutex(lease: number, lane: string): void {
|
||||||
if (holdsTradeMutex(lease, lane)) runtime().tradeInFlight?.delete(lane);
|
if (holdsTradeMutex(lease, lane)) runtime().tradeInFlight?.delete(lane);
|
||||||
|
const r = runtime().sideReservation;
|
||||||
|
if (r && r.lane === lane && r.lease === lease) runtime().sideReservation = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The side a new entry in `lane` must take (machineSide): the side of every
|
||||||
|
* position on this machine in any lane, else the side another lane's batch is
|
||||||
|
* placing right now. One person's machine must never be long in one firm and
|
||||||
|
* short in another, whatever the instruments.
|
||||||
|
*/
|
||||||
|
function sideForEntry(lane: string): 'Buy' | 'Sell' | 'mixed' | null {
|
||||||
|
const net = firmClients().ready.flatMap(({ client }) => client.accountList
|
||||||
|
.map((a) => (client.positions[a.id]?.netPos ?? 0) as number)
|
||||||
|
.filter((n) => n !== 0));
|
||||||
|
const r = runtime().sideReservation;
|
||||||
|
const reserved = r && r.lane !== lane && holdsTradeMutex(r.lease, r.lane) ? r.side : null;
|
||||||
|
return machineSide(net, reserved);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Why `action` can't be entered in `lane` without a hedge, or null. */
|
||||||
|
function hedgeBlock(side: ReturnType<typeof sideForEntry>, action: 'Buy' | 'Sell' | 'Auto'): string | null {
|
||||||
|
if (side === 'mixed') return 'this machine holds long and short positions — no side to join until one of them closes';
|
||||||
|
if (side && action !== 'Auto' && action !== side) {
|
||||||
|
return `this machine is ${side === 'Buy' ? 'long' : 'short'} elsewhere — a ${action} would hedge it`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What a firm's tiny legs (drain, extra-day) trade: a futures firm the
|
/** What a firm's tiny legs (drain, extra-day) trade: a futures firm the
|
||||||
* micro of what it lists (MNQ when NQ is listed, as always — see
|
* micro of what it lists (MNQ when NQ is listed, as always — see
|
||||||
* housekeepingRoot); a DXtrade firm its current pick. */
|
* housekeepingRoot); a DXtrade or Vest firm its current pick. */
|
||||||
function housekeepingFor(firm: { platform: string; instruments?: string[]; bannedSymbols?: string[]; symbolMap?: Record<string, { symbol: string }[]> }): string {
|
function housekeepingFor(firm: { platform: string; instruments?: string[]; bannedSymbols?: string[]; symbolMap?: Record<string, { symbol: string }[]> }): string {
|
||||||
if (firm.platform === 'dxtrade') return '*';
|
if (isNotionalPlatform(firm.platform)) return '*';
|
||||||
return housekeepingRoot(firmInstruments(firm)) ?? HOUSEKEEPING_ROOT;
|
return housekeepingRoot(firmInstruments(firm)) ?? HOUSEKEEPING_ROOT;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -506,7 +549,7 @@ function legShapeFor(item: SizingItem, tradeDate: string): LegShape {
|
|||||||
const cfg = getAccountConfig(item.acc.id).accountType!;
|
const cfg = getAccountConfig(item.acc.id).accountType!;
|
||||||
const leg = legAmountFor(item, tradeDate);
|
const leg = legAmountFor(item, tradeDate);
|
||||||
const obf = obfuscationStopDollars(cfg, item.dailyPnL, item.plan, tradeDate, leg);
|
const obf = obfuscationStopDollars(cfg, item.dailyPnL, item.plan, tradeDate, leg);
|
||||||
const dll = cfg.dailyLossLimit ?? 0;
|
const dll = item.dll;
|
||||||
const hard = Number.isFinite(item.dllRoom) && dll > 0
|
const hard = Number.isFinite(item.dllRoom) && dll > 0
|
||||||
? dllStopDollars({ room: item.dllRoom, dailyLossLimit: dll, entryFee: FALLBACK_COMMISSION_PER_CONTRACT, commissionPct: 0 })
|
? dllStopDollars({ room: item.dllRoom, dailyLossLimit: dll, entryFee: FALLBACK_COMMISSION_PER_CONTRACT, commissionPct: 0 })
|
||||||
: Infinity;
|
: Infinity;
|
||||||
@@ -570,6 +613,8 @@ interface AccountEvaluation {
|
|||||||
/** What the firm's day may still lose before a hard daily-loss breach
|
/** What the firm's day may still lose before a hard daily-loss breach
|
||||||
* (Infinity = no limit). */
|
* (Infinity = no limit). */
|
||||||
dllRoom: number;
|
dllRoom: number;
|
||||||
|
/** The hard daily loss limit at the account's current stage (0 = none). */
|
||||||
|
dll: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -580,29 +625,43 @@ function dailyLossGate(
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
client: any,
|
client: any,
|
||||||
accountId: number,
|
accountId: number,
|
||||||
cfg: { dailyLossLimit?: number; maxPositionSize: number; firmId: string },
|
/** The limit at the account's current stage (0 = none). */
|
||||||
|
dll: number,
|
||||||
|
cfg: { maxPositionSize: number; firmId: string },
|
||||||
cash: { amount: number; realizedPnL: number },
|
cash: { amount: number; realizedPnL: number },
|
||||||
combinedRealized: number,
|
combinedRealized: number,
|
||||||
remainingToday: number,
|
plan: { remainingToday: number; splitAllowed: boolean },
|
||||||
singleContract: boolean,
|
singleContract: boolean,
|
||||||
): { room: number } | { reason: string } {
|
): { room: number } | { reason: string } {
|
||||||
const dll = cfg.dailyLossLimit ?? 0;
|
|
||||||
if (!(dll > 0)) return { room: Infinity };
|
if (!(dll > 0)) return { room: Infinity };
|
||||||
const dayStart: number | null | undefined = client.dayStartBalance?.(accountId);
|
// A platform that states its own daily-loss floor (Vest) decides the
|
||||||
if (dayStart === null) return { reason: DLL_BASE_PENDING };
|
// room against it, transfers and all; the rest measure the day's P&L.
|
||||||
const room = dllRoom({
|
const floor: number | null | undefined = client.dailyLossFloor?.(accountId);
|
||||||
dailyLossLimit: dll,
|
if (floor === null) return { reason: DLL_BASE_PENDING };
|
||||||
closedBalance: cash.amount,
|
let room: number;
|
||||||
dayStartBalance: dayStart,
|
if (typeof floor === 'number') {
|
||||||
// The broker's day, raw — and on a combining firm the worse of it
|
room = floorRoom({
|
||||||
// and the combined figure, whichever the firm turns out to use.
|
dailyLossLimit: dll, closedBalance: cash.amount, floor,
|
||||||
realizedToday: Math.min(cash.realizedPnL, combinedRealized),
|
realizedToday: Math.min(cash.realizedPnL, combinedRealized),
|
||||||
});
|
gain: client.dayGainForRoom?.(accountId, { leg: shortestNextLeg(plan), limit: dll }) ?? 0,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const dayStart: number | null | undefined = client.dayStartBalance?.(accountId);
|
||||||
|
if (dayStart === null) return { reason: DLL_BASE_PENDING };
|
||||||
|
room = dllRoom({
|
||||||
|
dailyLossLimit: dll,
|
||||||
|
closedBalance: cash.amount,
|
||||||
|
dayStartBalance: dayStart,
|
||||||
|
// The broker's day, raw — and on a combining firm the worse of it
|
||||||
|
// and the combined figure, whichever the firm turns out to use.
|
||||||
|
realizedToday: Math.min(cash.realizedPnL, combinedRealized),
|
||||||
|
});
|
||||||
|
}
|
||||||
// Measured-fee firms: a typical round trip on the contracts this leg
|
// Measured-fee firms: a typical round trip on the contracts this leg
|
||||||
// could use. Percent-fee firms size by notional, which isn't known
|
// could use. Percent-fee firms size by notional, which isn't known
|
||||||
// here — fireLeg re-checks with the real size before any entry.
|
// here — fireLeg re-checks with the real size before any entry.
|
||||||
const contractsBound = singleContract ? 1
|
const contractsBound = singleContract ? 1
|
||||||
: Math.min(Math.max(1, Math.ceil(remainingToday / 1000)), cfg.maxPositionSize > 0 ? cfg.maxPositionSize : Infinity);
|
: Math.min(Math.max(1, Math.ceil(plan.remainingToday / 1000)), cfg.maxPositionSize > 0 ? cfg.maxPositionSize : Infinity);
|
||||||
const percentFees = (getCachedFirm(cfg.firmId)?.commissionPct ?? 0) > 0;
|
const percentFees = (getCachedFirm(cfg.firmId)?.commissionPct ?? 0) > 0;
|
||||||
const feeEstimate = percentFees ? 0 : 2 * FALLBACK_COMMISSION_PER_CONTRACT * contractsBound;
|
const feeEstimate = percentFees ? 0 : 2 * FALLBACK_COMMISSION_PER_CONTRACT * contractsBound;
|
||||||
if (!dllViable(room, dll, feeEstimate)) {
|
if (!dllViable(room, dll, feeEstimate)) {
|
||||||
@@ -689,7 +748,7 @@ function evaluateAccountForTrade(client: any, acc: { id: number; name: string; a
|
|||||||
const dailyPnL = combined.dailyPnL;
|
const dailyPnL = combined.dailyPnL;
|
||||||
const daysTraded = combined.daysTraded;
|
const daysTraded = combined.daysTraded;
|
||||||
|
|
||||||
const base = { plan: null, isExtraDay: false, drain: false, cash, dailyPnL, daysTraded, dllRoom: Infinity };
|
const base = { plan: null, isExtraDay: false, drain: false, cash, dailyPnL, daysTraded, dllRoom: Infinity, dll: 0 };
|
||||||
if (isAccountDead(cash.amount, autoLiqThreshold)) return { ...base, eligible: false, reason: 'dead' };
|
if (isAccountDead(cash.amount, autoLiqThreshold)) return { ...base, eligible: false, reason: 'dead' };
|
||||||
if (!acc.active) return { ...base, eligible: false, reason: 'inactive (DLL)' };
|
if (!acc.active) return { ...base, eligible: false, reason: 'inactive (DLL)' };
|
||||||
if (client.positions[acc.id]) return { ...base, eligible: false, reason: 'already in trade' };
|
if (client.positions[acc.id]) return { ...base, eligible: false, reason: 'already in trade' };
|
||||||
@@ -719,7 +778,7 @@ function evaluateAccountForTrade(client: any, acc: { id: number; name: string; a
|
|||||||
const allFundTxns = client.fundTransactions?.[acc.id] ?? [];
|
const allFundTxns = client.fundTransactions?.[acc.id] ?? [];
|
||||||
const effective = resolveEffectiveConfig(
|
const effective = resolveEffectiveConfig(
|
||||||
cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity, cfg.withdrawalStages, priorProfit, allFundTxns,
|
cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity, cfg.withdrawalStages, priorProfit, allFundTxns,
|
||||||
cfg.minDayPnL
|
cfg.minDayPnL, stageInputsFor(client, acc.id, cfg),
|
||||||
);
|
);
|
||||||
|
|
||||||
const equityProfit = cash.amount - cfg.accountSize;
|
const equityProfit = cash.amount - cfg.accountSize;
|
||||||
@@ -736,7 +795,7 @@ function evaluateAccountForTrade(client: any, acc: { id: number; name: string; a
|
|||||||
&& effective.minTradingDays > daysTraded
|
&& effective.minTradingDays > daysTraded
|
||||||
&& totalProfit >= effective.profitTarget;
|
&& totalProfit >= effective.profitTarget;
|
||||||
|
|
||||||
const evaluated = { plan, isExtraDay, drain: effective.drain, cash, dailyPnL, daysTraded, dllRoom: Infinity };
|
const evaluated = { plan, isExtraDay, drain: effective.drain, cash, dailyPnL, daysTraded, dllRoom: Infinity, dll: effective.dailyLossLimit };
|
||||||
|
|
||||||
// A finished cycle that a balance drop reopens, with no fill or fund row
|
// A finished cycle that a balance drop reopens, with no fill or fund row
|
||||||
// to explain it, is a payout not booked yet: hold it until the booking
|
// to explain it, is a payout not booked yet: hold it until the booking
|
||||||
@@ -788,7 +847,7 @@ function evaluateAccountForTrade(client: any, acc: { id: number; name: string; a
|
|||||||
// (day done, challenge complete, leg cap) win. A daily-loss stop-out
|
// (day done, challenge complete, leg cap) win. A daily-loss stop-out
|
||||||
// leaves about the reserve plus fees, which is below what a trade needs —
|
// leaves about the reserve plus fees, which is below what a trade needs —
|
||||||
// so a stop-out benches the account for the rest of the day by itself.
|
// so a stop-out benches the account for the rest of the day by itself.
|
||||||
const gate = dailyLossGate(client, acc.id, cfg, cash, combined.realizedPnL, plan.remainingToday, isExtraDay || effective.drain);
|
const gate = dailyLossGate(client, acc.id, effective.dailyLossLimit, cfg, cash, combined.realizedPnL, plan, isExtraDay || effective.drain);
|
||||||
if ('reason' in gate) return { ...evaluated, eligible: false, reason: gate.reason };
|
if ('reason' in gate) return { ...evaluated, eligible: false, reason: gate.reason };
|
||||||
if (Number.isFinite(gate.room)) return { ...evaluated, eligible: true, reason: '', dllRoom: gate.room };
|
if (Number.isFinite(gate.room)) return { ...evaluated, eligible: true, reason: '', dllRoom: gate.room };
|
||||||
|
|
||||||
@@ -943,6 +1002,8 @@ interface LegItem {
|
|||||||
prefix: string;
|
prefix: string;
|
||||||
/** Hard daily-loss room (Infinity = no limit). */
|
/** Hard daily-loss room (Infinity = no limit). */
|
||||||
dllRoom: number;
|
dllRoom: number;
|
||||||
|
/** The hard daily loss limit at the account's current stage (0 = none). */
|
||||||
|
dll: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What sizing a leg needs before its instrument is known. */
|
/** What sizing a leg needs before its instrument is known. */
|
||||||
@@ -971,16 +1032,30 @@ async function runTradeLocked(
|
|||||||
}
|
}
|
||||||
const open = laneReady.filter(({ firm }) => !isInNoTradeWindow(firm));
|
const open = laneReady.filter(({ firm }) => !isInNoTradeWindow(firm));
|
||||||
|
|
||||||
// One direction per batch: every account trades the same side.
|
// One direction per batch, and per machine: every account trades the side
|
||||||
const resolvedAction: 'Buy' | 'Sell' = action === 'Auto'
|
// of whatever this machine already holds in any lane (another firm, a hand
|
||||||
? (Math.random() < 0.5 ? 'Buy' : 'Sell')
|
// trade), so nothing the batch opens hedges the person against themselves.
|
||||||
: action;
|
// Free only when nothing is held or being placed; the choice is then held
|
||||||
|
// while the batch places it, so a lane deciding meanwhile follows it.
|
||||||
|
const side = sideForEntry(lane);
|
||||||
|
const blocked = hedgeBlock(side, action);
|
||||||
|
if (blocked) {
|
||||||
|
console.log(`[auto-trade] ${laneName(lane)}: ${blocked} — skipping`);
|
||||||
|
return { outcome: 'positions-open', results: [], detail: blocked };
|
||||||
|
}
|
||||||
|
const resolvedAction: 'Buy' | 'Sell' = side === 'Buy' || side === 'Sell'
|
||||||
|
? side
|
||||||
|
: action === 'Auto' ? (Math.random() < 0.5 ? 'Buy' : 'Sell') : action;
|
||||||
|
if (!side) runtime().sideReservation = { side: resolvedAction, lane, lease };
|
||||||
const futuresLane = lane === FUTURES_LANE;
|
const futuresLane = lane === FUTURES_LANE;
|
||||||
// An explicit symbol (the scheduler's, or a manual Trade) is the
|
// An explicit symbol (the scheduler's, or a manual Trade) is the
|
||||||
// operator's pick; 'Auto' is drawn per batch from the batch firm's list.
|
// operator's pick; 'Auto' is drawn per batch from the batch firm's list.
|
||||||
const explicit = futuresLane && symbol !== 'Auto' ? symbol : null;
|
const explicit = futuresLane && symbol !== 'Auto' ? symbol : null;
|
||||||
|
|
||||||
const tradeDate = currentTradeDateCT();
|
// The lane's own day (tradeDateFor): a notional lane is one firm — a
|
||||||
|
// Vest lane's day turns at 20:00 ET, its rows and realized P&L with it —
|
||||||
|
// and the futures lane's clients all keep the Central trade date.
|
||||||
|
const tradeDate = tradeDateFor(open[0]?.client);
|
||||||
const maxConcurrent = maxConcurrentAccounts();
|
const maxConcurrent = maxConcurrentAccounts();
|
||||||
|
|
||||||
// ── Phase 1: the eligible accounts across the lane's firms ──
|
// ── Phase 1: the eligible accounts across the lane's firms ──
|
||||||
@@ -1022,7 +1097,7 @@ async function runTradeLocked(
|
|||||||
cash: ev.cash, dailyPnL: ev.dailyPnL, daysTraded: ev.daysTraded,
|
cash: ev.cash, dailyPnL: ev.dailyPnL, daysTraded: ev.daysTraded,
|
||||||
plan: ev.plan, isExtraDay: ev.isExtraDay, drain: ev.drain,
|
plan: ev.plan, isExtraDay: ev.isExtraDay, drain: ev.drain,
|
||||||
prefix: getAccountConfig(acc.id).accountType?.prefix ?? '',
|
prefix: getAccountConfig(acc.id).accountType?.prefix ?? '',
|
||||||
dllRoom: ev.dllRoom,
|
dllRoom: ev.dllRoom, dll: ev.dll,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -1154,6 +1229,13 @@ async function runTradeLocked(
|
|||||||
console.warn('[auto-trade] superseded while collecting accounts — abandoning this batch');
|
console.warn('[auto-trade] superseded while collecting accounts — abandoning this batch');
|
||||||
return { outcome: 'skipped-in-flight', results: [], detail: 'superseded by another batch while choosing' };
|
return { outcome: 'skipped-in-flight', results: [], detail: 'superseded by another batch while choosing' };
|
||||||
}
|
}
|
||||||
|
// A Vest lane's day turns with its client's numbers (tradeDateFor). One
|
||||||
|
// that turned while choosing would size and count these legs on the day
|
||||||
|
// that ended — the next tick plans on the new one.
|
||||||
|
if (batch.some((item) => tradeDateFor(item.client) !== tradeDate)) {
|
||||||
|
console.warn(`[auto-trade] ${laneName(lane)}: the trade date turned while choosing — abandoning this batch`);
|
||||||
|
return { outcome: 'skipped-in-flight', results: [], detail: 'the trade date turned while choosing' };
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`[auto-trade] ${laneName(lane)}: ${allEligible.length} eligible account(s) across ${groups.size} prefix group(s) — firing batch of ${batch.length} ${batchPrefix || '(no prefix)'} on ${resolvedSymbol}`);
|
console.log(`[auto-trade] ${laneName(lane)}: ${allEligible.length} eligible account(s) across ${groups.size} prefix group(s) — firing batch of ${batch.length} ${batchPrefix || '(no prefix)'} on ${resolvedSymbol}`);
|
||||||
runtime().lastFireAt = Date.now();
|
runtime().lastFireAt = Date.now();
|
||||||
@@ -1174,10 +1256,10 @@ async function runTradeLocked(
|
|||||||
const mnqContract = await client.findFrontMonthContract(microRoot);
|
const mnqContract = await client.findFrontMonthContract(microRoot);
|
||||||
if (!mnqContract) throw new Error(`${microRoot} contract not found for extra-day trade`);
|
if (!mnqContract) throw new Error(`${microRoot} contract not found for extra-day trade`);
|
||||||
|
|
||||||
const fill = await client.sendOrder(acc.id, mnqContract.name, 1, resolvedAction, 'Market');
|
const fill = await client.sendOrder(acc.id, mnqContract.name, 1, resolvedAction, 'Market', undefined, 'open');
|
||||||
recordEntry(item.firmName, microRoot, resolvedAction, 1, mnqContract.id);
|
recordEntry(item.firmName, microRoot, resolvedAction, 1, mnqContract.id);
|
||||||
const exitAction: 'Buy' | 'Sell' = resolvedAction === 'Buy' ? 'Sell' : 'Buy';
|
const exitAction: 'Buy' | 'Sell' = resolvedAction === 'Buy' ? 'Sell' : 'Buy';
|
||||||
const exitFill = await client.sendOrder(acc.id, mnqContract.name, 1, exitAction, 'Market');
|
const exitFill = await client.sendOrder(acc.id, mnqContract.name, 1, exitAction, 'Market', undefined, 'close');
|
||||||
|
|
||||||
console.log(`[auto-trade] ${acc.name} (${item.firmName}) extra-day: ${resolvedAction} 1x${mnqContract.name} @ ${fill.price} | exited @ ${exitFill.price} (market)`);
|
console.log(`[auto-trade] ${acc.name} (${item.firmName}) extra-day: ${resolvedAction} 1x${mnqContract.name} @ ${fill.price} | exited @ ${exitFill.price} (market)`);
|
||||||
|
|
||||||
@@ -1237,7 +1319,11 @@ const round2 = (n: number) => Math.round(n * 100) / 100;
|
|||||||
* Close a just-filled position at market because it must not stay open — its
|
* Close a just-filled position at market because it must not stay open — its
|
||||||
* daily-loss stop has no room, or the broker refused its exit bracket — and
|
* daily-loss stop has no room, or the broker refused its exit bracket — and
|
||||||
* report the leg as failed. A flatten that fails too is the worst case: a
|
* report the leg as failed. A flatten that fails too is the worst case: a
|
||||||
* position with no stop, so it is shouted about.
|
* position with no stop, so it is shouted about. Close-only: on an account
|
||||||
|
* that turned out flat it must not open a fresh position with no exits, and
|
||||||
|
* where the platform checks (Vest, DXtrade) and its own record agrees, that
|
||||||
|
* flat account has nothing riding — the leg still fails, but without the
|
||||||
|
* alarm. A flat read the record contradicts still gets the alarm.
|
||||||
*/
|
*/
|
||||||
async function flattenAndFail(
|
async function flattenAndFail(
|
||||||
tag: string,
|
tag: string,
|
||||||
@@ -1251,9 +1337,13 @@ async function flattenAndFail(
|
|||||||
why: string,
|
why: string,
|
||||||
): Promise<never> {
|
): Promise<never> {
|
||||||
try {
|
try {
|
||||||
const exit = await client.sendOrder(acc.id, contractName, contracts, exitAction, 'Market');
|
const exit = await client.sendOrder(acc.id, contractName, contracts, exitAction, 'Market', undefined, 'close');
|
||||||
console.warn(`${tag} ${acc.name}: flattened @ ${exit.price} — ${why}`);
|
console.warn(`${tag} ${acc.name}: flattened @ ${exit.price} — ${why}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (isNothingToClose(err)) {
|
||||||
|
console.warn(`${tag} ${acc.name}: nothing to flatten — ${errorMessage(err)} (${why})`);
|
||||||
|
throw new Error(`entry filled @ ${entryPrice}; ${why}; already flat — nothing to flatten`);
|
||||||
|
}
|
||||||
console.error(`${tag} ${acc.name}: FLATTEN FAILED (${errorMessage(err)}) — the position may be open WITHOUT A STOP; close it at the broker (${why})`);
|
console.error(`${tag} ${acc.name}: FLATTEN FAILED (${errorMessage(err)}) — the position may be open WITHOUT A STOP; close it at the broker (${why})`);
|
||||||
throw new Error(`entry filled @ ${entryPrice}; ${why}; flatten failed — ${errorMessage(err)}`);
|
throw new Error(`entry filled @ ${entryPrice}; ${why}; flatten failed — ${errorMessage(err)}`);
|
||||||
}
|
}
|
||||||
@@ -1331,11 +1421,12 @@ function sizeLeg(item: SizingItem, contract: LegContract, legAmount: number, tra
|
|||||||
commissionPct: pct,
|
commissionPct: pct,
|
||||||
targetHoldHours: sizingHoldHours(item.firm, tradeDate),
|
targetHoldHours: sizingHoldHours(item.firm, tradeDate),
|
||||||
financingPctPerDay: item.firm.financingPctPerDay ?? 0,
|
financingPctPerDay: item.firm.financingPctPerDay ?? 0,
|
||||||
dailyLossLimit: cfg.dailyLossLimit ?? 0,
|
dailyLossLimit: item.dll,
|
||||||
dllRoom: item.dllRoom,
|
dllRoom: item.dllRoom,
|
||||||
maxLossRoom: floorRoom,
|
maxLossRoom: floorRoom,
|
||||||
obfuscationDollars: obfuscation,
|
obfuscationDollars: obfuscation,
|
||||||
availableFunds: live.availableFunds ?? live.amount,
|
availableFunds: live.availableFunds ?? live.amount,
|
||||||
|
slippageFraction: item.client.stopSlippageFraction,
|
||||||
});
|
});
|
||||||
return { ...plan, pct, obfuscation, maxLossRoom: floorRoom };
|
return { ...plan, pct, obfuscation, maxLossRoom: floorRoom };
|
||||||
}
|
}
|
||||||
@@ -1396,7 +1487,7 @@ async function fireLeg(
|
|||||||
// the broker (here, or in sendEntry).
|
// the broker (here, or in sendEntry).
|
||||||
recordLeg(acc.id, tradeDate, legAmount);
|
recordLeg(acc.id, tradeDate, legAmount);
|
||||||
|
|
||||||
const dll = cfg.dailyLossLimit ?? 0;
|
const dll = item.dll;
|
||||||
const dllOn = Number.isFinite(item.dllRoom) && dll > 0;
|
const dllOn = Number.isFinite(item.dllRoom) && dll > 0;
|
||||||
|
|
||||||
// Everything before the entry goes out: a failure here never reached the
|
// Everything before the entry goes out: a failure here never reached the
|
||||||
@@ -1424,6 +1515,10 @@ async function fireLeg(
|
|||||||
if (!plan.sizing) {
|
if (!plan.sizing) {
|
||||||
throw new Error(`no position size for ${legContract.name} — its recent volatility is unknown`);
|
throw new Error(`no position size for ${legContract.name} — its recent volatility is unknown`);
|
||||||
}
|
}
|
||||||
|
// The pick held the batch's lead leg to its market's close; this
|
||||||
|
// account's leg can size longer (another stage, a leverage cap).
|
||||||
|
const closeNote = legCloseNote(legContract.closesAt, plan.sizing.predictedHoldHours, Date.now());
|
||||||
|
if (closeNote) throw new Error(`${legContract.name}: ${closeNote} — not entering`);
|
||||||
contracts = Math.max(1, Math.round(plan.sizing.notional / stepNotional));
|
contracts = Math.max(1, Math.round(plan.sizing.notional / stepNotional));
|
||||||
if (plan.cap > 0) contracts = Math.min(contracts, Math.max(1, Math.floor(plan.cap / stepNotional)));
|
if (plan.cap > 0) contracts = Math.min(contracts, Math.max(1, Math.floor(plan.cap / stepNotional)));
|
||||||
if ((legContract.maxContracts ?? 0) > 0) contracts = Math.min(contracts, legContract.maxContracts!);
|
if ((legContract.maxContracts ?? 0) > 0) contracts = Math.min(contracts, legContract.maxContracts!);
|
||||||
@@ -1482,7 +1577,7 @@ async function fireLeg(
|
|||||||
const dllFee = pct > 0 ? entryFee : (measured ?? MAX_COMMISSION_PER_CONTRACT * contracts);
|
const dllFee = pct > 0 ? entryFee : (measured ?? MAX_COMMISSION_PER_CONTRACT * contracts);
|
||||||
const notional = fill.price * pv * contracts;
|
const notional = fill.price * pv * contracts;
|
||||||
const dllDollars = dllOn && !plan.lastStand
|
const dllDollars = dllOn && !plan.lastStand
|
||||||
? dllStopDollars({ room: item.dllRoom, dailyLossLimit: dll, entryFee: dllFee, commissionPct: pct, notional })
|
? dllStopDollars({ room: item.dllRoom, dailyLossLimit: dll, entryFee: dllFee, commissionPct: pct, notional, slippageFraction: client.stopSlippageFraction })
|
||||||
: Infinity;
|
: Infinity;
|
||||||
const ticks = stopTicks({ obfuscationDollars: obfuscation, dllDollars, pointValue: pv, contracts, tickSize: legContract.tickSize });
|
const ticks = stopTicks({ obfuscationDollars: obfuscation, dllDollars, pointValue: pv, contracts, tickSize: legContract.tickSize });
|
||||||
if (ticks === 0) {
|
if (ticks === 0) {
|
||||||
@@ -1678,7 +1773,8 @@ async function copyTradeLocked(lease: number, lane: string): Promise<unknown[]>
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const tradeDate = currentTradeDateCT();
|
// The lane's own day, as in runTrade.
|
||||||
|
const tradeDate = tradeDateFor(ready[0]?.client);
|
||||||
|
|
||||||
// Collect eligible accounts (same logic as Phase 1 of runTrade)
|
// Collect eligible accounts (same logic as Phase 1 of runTrade)
|
||||||
const eligible: LegItem[] = [];
|
const eligible: LegItem[] = [];
|
||||||
@@ -1718,7 +1814,7 @@ async function copyTradeLocked(lease: number, lane: string): Promise<unknown[]>
|
|||||||
firmName: firm.name, firm, client, acc, contract, pv, cash: ev.cash, dailyPnL: ev.dailyPnL, plan: ev.plan,
|
firmName: firm.name, firm, client, acc, contract, pv, cash: ev.cash, dailyPnL: ev.dailyPnL, plan: ev.plan,
|
||||||
drain: ev.drain,
|
drain: ev.drain,
|
||||||
prefix: getAccountConfig(acc.id).accountType?.prefix ?? '',
|
prefix: getAccountConfig(acc.id).accountType?.prefix ?? '',
|
||||||
dllRoom: ev.dllRoom,
|
dllRoom: ev.dllRoom, dll: ev.dll,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
eligible.push(...found);
|
eligible.push(...found);
|
||||||
@@ -1742,6 +1838,10 @@ async function copyTradeLocked(lease: number, lane: string): Promise<unknown[]>
|
|||||||
console.warn('[copy-trade] superseded while collecting accounts — abandoning this copy');
|
console.warn('[copy-trade] superseded while collecting accounts — abandoning this copy');
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
if (batch.some((item) => tradeDateFor(item.client) !== tradeDate)) {
|
||||||
|
console.warn('[copy-trade] the trade date turned while choosing — abandoning this copy');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`[copy-trade] copying ${resolvedAction} ${resolvedSymbol} to ${batch.length} account(s)`);
|
console.log(`[copy-trade] copying ${resolvedAction} ${resolvedSymbol} to ${batch.length} account(s)`);
|
||||||
runtime().lastFireAt = Date.now();
|
runtime().lastFireAt = Date.now();
|
||||||
@@ -1796,7 +1896,7 @@ export async function fireManualLeg(accountId: number, action: 'Buy' | 'Sell' |
|
|||||||
const lease = acquireTradeMutex('[manual-leg]', lane);
|
const lease = acquireTradeMutex('[manual-leg]', lane);
|
||||||
if (lease === null) throw new Error('another batch is in flight in this lane');
|
if (lease === null) throw new Error('another batch is in flight in this lane');
|
||||||
try {
|
try {
|
||||||
const tradeDate = currentTradeDateCT();
|
const tradeDate = tradeDateFor(client);
|
||||||
const ev = evaluateAccountForTrade(client, acc, tradeDate);
|
const ev = evaluateAccountForTrade(client, acc, tradeDate);
|
||||||
if (!ev || !ev.plan) throw new Error(ev?.reason || 'account has no account type');
|
if (!ev || !ev.plan) throw new Error(ev?.reason || 'account has no account type');
|
||||||
if (!ev.eligible && !ev.reason.startsWith('already traded today')) throw new Error(ev.reason);
|
if (!ev.eligible && !ev.reason.startsWith('already traded today')) throw new Error(ev.reason);
|
||||||
@@ -1806,7 +1906,7 @@ export async function fireManualLeg(accountId: number, action: 'Buy' | 'Sell' |
|
|||||||
// The once-a-day return comes before the daily-loss check: run it here.
|
// The once-a-day return comes before the daily-loss check: run it here.
|
||||||
let room = ev.dllRoom;
|
let room = ev.dllRoom;
|
||||||
if (!ev.eligible) {
|
if (!ev.eligible) {
|
||||||
const gate = dailyLossGate(client, acc.id, cfg, ev.cash, ev.cash.realizedPnL, ev.plan.remainingToday, ev.drain);
|
const gate = dailyLossGate(client, acc.id, ev.dll, cfg, ev.cash, ev.cash.realizedPnL, ev.plan, ev.drain);
|
||||||
if ('reason' in gate) throw new Error(gate.reason);
|
if ('reason' in gate) throw new Error(gate.reason);
|
||||||
room = gate.room;
|
room = gate.room;
|
||||||
}
|
}
|
||||||
@@ -1824,17 +1924,23 @@ export async function fireManualLeg(accountId: number, action: 'Buy' | 'Sell' |
|
|||||||
if (heldSide && action !== 'Auto' && action !== heldSide) {
|
if (heldSide && action !== 'Auto' && action !== heldSide) {
|
||||||
throw new Error(`the ${laneName(lane)} lane is ${heldSide === 'Buy' ? 'long' : 'short'} — a ${action} would hedge it`);
|
throw new Error(`the ${laneName(lane)} lane is ${heldSide === 'Buy' ? 'long' : 'short'} — a ${action} would hedge it`);
|
||||||
}
|
}
|
||||||
const resolvedAction: 'Buy' | 'Sell' = heldSide ?? (action === 'Auto' ? (Math.random() < 0.5 ? 'Buy' : 'Sell') : action);
|
// …nor against what the machine holds in any other lane.
|
||||||
|
const side = sideForEntry(lane);
|
||||||
|
const blocked = hedgeBlock(side, heldSide ?? action);
|
||||||
|
if (blocked) throw new Error(blocked);
|
||||||
|
const resolvedAction: 'Buy' | 'Sell' = heldSide
|
||||||
|
?? (side === 'Buy' || side === 'Sell' ? side : action === 'Auto' ? (Math.random() < 0.5 ? 'Buy' : 'Sell') : action);
|
||||||
|
if (!side) runtime().sideReservation = { side: resolvedAction, lane, lease };
|
||||||
const sizingItem: SizingItem = {
|
const sizingItem: SizingItem = {
|
||||||
firmName: firm.name, firm, client, acc,
|
firmName: firm.name, firm, client, acc,
|
||||||
cash: ev.cash, dailyPnL: ev.dailyPnL, plan: ev.plan, drain: ev.drain,
|
cash: ev.cash, dailyPnL: ev.dailyPnL, plan: ev.plan, drain: ev.drain,
|
||||||
prefix: cfg.prefix, dllRoom: room,
|
prefix: cfg.prefix, dllRoom: room, dll: ev.dll,
|
||||||
};
|
};
|
||||||
// The instrument: a DXtrade firm ranks its list; a futures firm joins the
|
// The instrument: a DXtrade or Vest firm ranks its list; a futures
|
||||||
// held root, else trades an explicit scheduler symbol it lists, else
|
// firm joins the held root, else trades an explicit scheduler symbol
|
||||||
// draws from its list.
|
// it lists, else draws from its list.
|
||||||
let symbol = '*';
|
let symbol = '*';
|
||||||
if (firm.platform !== 'dxtrade') {
|
if (!isNotionalPlatform(firm.platform)) {
|
||||||
const listed = firmInstruments(firm);
|
const listed = firmInstruments(firm);
|
||||||
const state = getState();
|
const state = getState();
|
||||||
const found = held.length > 0 ? await heldRoot(client, held[0].contractId, [...listed, housekeepingFor(firm)]) : null;
|
const found = held.length > 0 ? await heldRoot(client, held[0].contractId, [...listed, housekeepingFor(firm)]) : null;
|
||||||
@@ -1865,12 +1971,290 @@ export async function fireManualLeg(accountId: number, action: 'Buy' | 'Sell' |
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── payout claims ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** A failed claim is retried after this long. */
|
||||||
|
const CLAIM_RETRY_MS = 5 * 60_000;
|
||||||
|
|
||||||
|
/** How much an account should claim now, or null: a payoutable account whose
|
||||||
|
* platform takes payouts by API, flat and settled, that has reached its
|
||||||
|
* stage's target — all of its profit above the starting balance. */
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
function claimDue(client: any, acc: { id: number; active: boolean }, now: number): number | null {
|
||||||
|
const cfg = getAccountConfig(acc.id).accountType;
|
||||||
|
if (!cfg || cfg.payoutable !== true || !acc.active) return null;
|
||||||
|
if (client.positions[acc.id] || client.hasWorkingOrders?.(acc.id) || runtime().accountsInFlight.has(acc.id)) return null;
|
||||||
|
if (!client.historyReady?.(acc.id)) return null;
|
||||||
|
if (client.claimPending?.(acc.id)) return null;
|
||||||
|
if (now - (runtime().claimFailedAt?.get(acc.id) ?? 0) < CLAIM_RETRY_MS) return null;
|
||||||
|
const cash = client.accountCashBalances[acc.id];
|
||||||
|
if (!cash) return null;
|
||||||
|
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),
|
||||||
|
);
|
||||||
|
if (effective.drain || !(effective.profitTarget > 0)) return null;
|
||||||
|
const profit = cash.amount - cfg.accountSize;
|
||||||
|
return profit >= effective.profitTarget - LEG_COMPLETION_EPSILON ? profit : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Claim the profit of every payoutable account in the lane that reached its
|
||||||
|
* stage's target, where the platform takes payouts by API (Vest's Claim
|
||||||
|
* Profit). The account then starts its next stage the same day: its leg
|
||||||
|
* counts and entry outcome for the day are cleared, and a type whose stages
|
||||||
|
* reset each day trades its next stage on the room the claim freed. Under the
|
||||||
|
* lane's mutex, so no batch reads an account mid-claim. Run by the
|
||||||
|
* scheduler's tick, or by the claim watcher while the scheduler is off. Off
|
||||||
|
* with the auto_claim setting at 'off'.
|
||||||
|
*/
|
||||||
|
async function runClaims(lane: string, ready: FirmClient[]): Promise<void> {
|
||||||
|
if (getSetting('auto_claim') === 'off') return;
|
||||||
|
const items = inLane(ready, lane).filter(({ client }) => typeof (client as { claimProfit?: unknown }).claimProfit === 'function');
|
||||||
|
if (items.length === 0) return;
|
||||||
|
const now = Date.now();
|
||||||
|
const due = items.flatMap(({ firm, client }) => client.accountList.flatMap((acc) => {
|
||||||
|
const amount = claimDue(client, acc, now);
|
||||||
|
return amount === null ? [] : [{ firm, client, acc, amount }];
|
||||||
|
}));
|
||||||
|
if (due.length === 0) return;
|
||||||
|
const lease = acquireTradeMutex('[auto-claim]', lane);
|
||||||
|
if (lease === null) return;
|
||||||
|
try {
|
||||||
|
for (const { firm, client, acc } of due) {
|
||||||
|
if (!holdsTradeMutex(lease, lane)) return;
|
||||||
|
// Settled since, or a different amount now: claim what is due now.
|
||||||
|
const amount = claimDue(client, acc, Date.now());
|
||||||
|
if (amount === null) continue;
|
||||||
|
try {
|
||||||
|
const ends = claimEnds(client, acc);
|
||||||
|
// In flight while it claims: a batch that takes over a wedged
|
||||||
|
// lane, or a Claim now, leaves the account alone.
|
||||||
|
const { claimed, received } = await withAccountInFlight(acc.id, () => client.claimProfit!(acc.id, amount));
|
||||||
|
afterClaim('[auto-claim]', firm, client, acc, claimed, received, tradeDateFor(client), ends);
|
||||||
|
} catch (err) {
|
||||||
|
(runtime().claimFailedAt ??= new Map()).set(acc.id, Date.now());
|
||||||
|
console.error(`[auto-claim] ${firm.name} ${acc.name}: claim of $${Math.floor(amount * 100) / 100} failed — ${errorMessage(err)}; retrying in ${CLAIM_RETRY_MS / 60_000}m`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
releaseTradeMutex(lease, lane);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How long a failed activation waits before it's tried again — a refusal
|
||||||
|
* (Vest's cap on funded accounts, say) holds for a while. */
|
||||||
|
const ACTIVATE_RETRY_MS = 15 * 60_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Activate the funded account of every passed evaluation in the lane that
|
||||||
|
* the bot trades (one with an account type, as runClaims goes by — Vest caps
|
||||||
|
* funded accounts, and one kept by hand isn't the bot's to spend a slot on),
|
||||||
|
* where the platform does that by API (Vest: a passed evaluation waits until
|
||||||
|
* it's asked). It opens and closes nothing — the evaluation no longer
|
||||||
|
* trades, and the funded account shows on the next sync and trades from
|
||||||
|
* there — so it takes no lane mutex, and the scheduler doesn't wait on it.
|
||||||
|
* One run per lane at a time. Run by the scheduler's tick, or by the claim
|
||||||
|
* watcher while the scheduler is off. Off with the auto_activate setting at
|
||||||
|
* 'off'.
|
||||||
|
*/
|
||||||
|
async function runActivations(lane: string, ready: FirmClient[]): Promise<void> {
|
||||||
|
if (getSetting('auto_activate') === 'off') return;
|
||||||
|
const running = (runtime().activationsRunning ??= new Set());
|
||||||
|
if (running.has(lane)) return;
|
||||||
|
running.add(lane);
|
||||||
|
try {
|
||||||
|
for (const { firm, client } of inLane(ready, lane)) {
|
||||||
|
if (typeof client.activatable !== 'function' || typeof client.activateFunded !== 'function') continue;
|
||||||
|
for (const id of client.activatable()) {
|
||||||
|
const acc = client.accountList.find((a) => a.id === id);
|
||||||
|
if (!acc || !getAccountConfig(id).accountType) continue;
|
||||||
|
if (Date.now() - (runtime().activateFailedAt?.get(id) ?? 0) < ACTIVATE_RETRY_MS) continue;
|
||||||
|
try {
|
||||||
|
const { funded } = await client.activateFunded(id);
|
||||||
|
runtime().activateFailedAt?.delete(id);
|
||||||
|
console.log(`[auto-activate] ${firm.name} ${acc.name}: passed — funded account ${funded ?? '(listed on the next sync)'} activated`);
|
||||||
|
} catch (err) {
|
||||||
|
(runtime().activateFailedAt ??= new Map()).set(id, Date.now());
|
||||||
|
console.error(`[auto-activate] ${firm.name} ${acc.name}: activation failed — ${errorMessage(err)}; retrying in ${ACTIVATE_RETRY_MS / 60_000}m`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
running.delete(lane);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How often auto-claims run while the scheduler is off — its tick's pace. */
|
||||||
|
const CLAIM_WATCH_MS = 60_000;
|
||||||
|
|
||||||
|
const _claimWatch = globalThis as typeof globalThis & { __claimWatcher?: ReturnType<typeof setInterval>; __claimTickBusy?: boolean };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-claims don't wait on the scheduler: a payout locks in profit and
|
||||||
|
* opens nothing, so stopping the trading doesn't stop it — a funded account
|
||||||
|
* at its target while the bot is off is claimed all the same, before
|
||||||
|
* whatever trades it next can take the profit back. While the scheduler
|
||||||
|
* runs, its own tick claims (first thing in each lane), and this stays out
|
||||||
|
* of the way. Off with the auto_claim setting at 'off'. Activations of passed
|
||||||
|
* evaluations' funded accounts ride along the same way (auto_activate).
|
||||||
|
* Started once, with the clients.
|
||||||
|
*/
|
||||||
|
export function startClaimWatcher(): void {
|
||||||
|
if (_claimWatch.__claimWatcher) return;
|
||||||
|
_claimWatch.__claimWatcher = setInterval(() => { void claimTick(); }, CLAIM_WATCH_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function claimTick(): Promise<void> {
|
||||||
|
if (getState().running || _claimWatch.__claimTickBusy) return;
|
||||||
|
if (getSetting('auto_claim') === 'off' && getSetting('auto_activate') === 'off') return;
|
||||||
|
_claimWatch.__claimTickBusy = true;
|
||||||
|
try {
|
||||||
|
// Ready clients only, as the tick: a stale snapshot may hide a
|
||||||
|
// position. runClaims takes each lane's mutex itself; each step
|
||||||
|
// checks its own setting.
|
||||||
|
const { ready } = firmClients();
|
||||||
|
for (const lane of tradableLanes()) {
|
||||||
|
if (getState().running) return; // the scheduler took over
|
||||||
|
await runClaims(lane, ready);
|
||||||
|
await runActivations(lane, ready);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[auto-claim] ${errorMessage(err)}`);
|
||||||
|
} finally {
|
||||||
|
_claimWatch.__claimTickBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The completed cycle a claim about to go out ends (claimedCycle), taken
|
||||||
|
* before the claim books its payout. Not on rows a failed read left behind
|
||||||
|
* — the master keeps it — which is historyFresh's own test for the
|
||||||
|
* account; not its client-wide one, no read in flight, as each claim
|
||||||
|
* starts a read and the next claim of the tick would never name its
|
||||||
|
* cycle. The claim already stands on the account's read (historyReady).
|
||||||
|
* Never throws: a payout isn't held back for its record. */
|
||||||
|
function claimEnds(client: PlatformClient, acc: { id: number; name: string }): ClaimedCycle | null {
|
||||||
|
try {
|
||||||
|
const cfg = getAccountConfig(acc.id).accountType;
|
||||||
|
// Judged as claimDue claims: within LEG_COMPLETION_EPSILON of the target.
|
||||||
|
return cfg && !client.lastFetchErrors?.[acc.name] ? claimedCycle(client, acc.id, cfg, new Date(), LEG_COMPLETION_EPSILON) : null;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[claim] ${acc.name}: the cycle the claim ends couldn't be read — ${errorMessage(err)}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A claim went through: the account starts its next stage — the day's leg
|
||||||
|
* count and entry outcome are cleared, as if it hadn't traded today — and
|
||||||
|
* the completed cycle it ended (claimEnds) is kept for the reporter, which
|
||||||
|
* may never have seen it complete. */
|
||||||
|
function afterClaim(
|
||||||
|
tag: string, firm: TradableFirm, client: PlatformClient, acc: { id: number; name: string },
|
||||||
|
claimed: number, received: number | null, tradeDate: string, ends: ClaimedCycle | null,
|
||||||
|
): number {
|
||||||
|
runtime().legCounts.delete(acc.id);
|
||||||
|
runtime().entryOutcomes?.delete(acc.id);
|
||||||
|
runtime().claimFailedAt?.delete(acc.id);
|
||||||
|
if (ends) {
|
||||||
|
try {
|
||||||
|
saveClaimedCycles(acc.id, [...loadClaimedCycles(acc.id).filter((c) => withinEndedCycleWindow(c.date, ends.date)), ends]);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`${tag} ${firm.name} ${acc.name}: the completed cycle the claim ended wasn't saved — ${errorMessage(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The stage the engine trades next, counted as it counts it (stagePayouts).
|
||||||
|
const cfg = getAccountConfig(acc.id).accountType;
|
||||||
|
const stage = 1 + stagePayouts(client.fundTransactions?.[acc.id] ?? [], cfg ? stageInputsFor(client, acc.id, cfg) : {});
|
||||||
|
console.log(`${tag} ${firm.name} ${acc.name}: claimed $${claimed}${received !== null ? ` (you get $${received})` : ''}`
|
||||||
|
+ ` — trading stage ${stage} next (${tradeDate})`);
|
||||||
|
return stage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A Claim now the account can't take as asked — said back to the person
|
||||||
|
* as is (the claim never reached the platform). */
|
||||||
|
export class ClaimRefusedError extends Error {}
|
||||||
|
|
||||||
|
/** Whether an account can be claimed by hand right now, and how much: its
|
||||||
|
* whole profit above the account size, whatever its stage's target. */
|
||||||
|
function manualClaim(accountId: number):
|
||||||
|
| { firm: TradableFirm; client: PlatformClient; acc: { id: number; name: string }; amount: number; target: number | null }
|
||||||
|
| { reason: string; unsupported?: true } {
|
||||||
|
const { ready, unready } = firmClients();
|
||||||
|
const holds = ({ client }: FirmClient) => client.accountList.some((a) => a.id === accountId);
|
||||||
|
const item = ready.find(holds);
|
||||||
|
if (!item) {
|
||||||
|
const stale = unready.find(holds);
|
||||||
|
if (stale && typeof stale.client.claimProfit !== 'function') return { reason: `${stale.firm.name} takes no claims through its API`, unsupported: true };
|
||||||
|
return { reason: stale ? `${stale.firm.name} hasn't synced recently — wait for its next sync` : 'the account is not on a connected firm' };
|
||||||
|
}
|
||||||
|
const { firm, client } = item;
|
||||||
|
if (typeof client.claimProfit !== 'function') return { reason: `${firm.name} takes no claims through its API`, unsupported: true };
|
||||||
|
const acc = client.accountList.find((a) => a.id === accountId)!;
|
||||||
|
const cfg = getAccountConfig(acc.id).accountType;
|
||||||
|
if (!cfg) return { reason: 'no account type is assigned' };
|
||||||
|
if (cfg.payoutable !== true) return { reason: `${cfg.label} doesn't take payouts`, unsupported: true };
|
||||||
|
if (!acc.active) return { reason: `${firm.name} shows the account inactive` };
|
||||||
|
if (client.positions[acc.id] || client.hasWorkingOrders?.(acc.id) || runtime().accountsInFlight.has(acc.id)) {
|
||||||
|
return { reason: 'a position or order is open — the account must be flat' };
|
||||||
|
}
|
||||||
|
if (!client.historyReady(acc.id)) return { reason: 'its history is still loading' };
|
||||||
|
if (client.claimPending?.(acc.id)) return { reason: 'the last claim is still settling — wait for the next sync' };
|
||||||
|
const cash = client.accountCashBalances[acc.id];
|
||||||
|
if (!cash) return { reason: 'its balance has not been read yet' };
|
||||||
|
const profit = Math.floor((cash.amount - cfg.accountSize) * 100) / 100;
|
||||||
|
if (!(profit >= 1)) {
|
||||||
|
return { reason: `$${Math.abs(profit).toFixed(2)} ${profit < 0 ? 'below' : 'above'} its $${cfg.accountSize.toLocaleString()} start — nothing to claim (the least is $1)` };
|
||||||
|
}
|
||||||
|
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),
|
||||||
|
);
|
||||||
|
return { firm, client, acc, amount: profit, target: effective.profitTarget > 0 ? effective.profitTarget : null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The dashboard's view of manualClaim. */
|
||||||
|
export function claimableNow(accountId: number):
|
||||||
|
| { claimable: number; target: number | null; firm: string }
|
||||||
|
| { claimable: null; reason: string; unsupported: boolean } {
|
||||||
|
const c = manualClaim(accountId);
|
||||||
|
if ('reason' in c) return { claimable: null, reason: c.reason, unsupported: c.unsupported === true };
|
||||||
|
return { claimable: c.amount, target: c.target, firm: c.firm.name };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Claim now, from the dashboard: the person pressed it, so the stage's
|
||||||
|
* target doesn't matter — any profit from $1 up to all of it above the
|
||||||
|
* account size. The same checks as an automatic claim, made again under the
|
||||||
|
* lane's mutex (no batch reads the account mid-claim), and the same
|
||||||
|
* bookkeeping after: the account trades its next stage.
|
||||||
|
*/
|
||||||
|
export async function claimAccountNow(accountId: number, amount: number): Promise<{ claimed: number; received: number | null; stage: number }> {
|
||||||
|
const first = manualClaim(accountId);
|
||||||
|
if ('reason' in first) throw new ClaimRefusedError(first.reason);
|
||||||
|
const lane = laneOf(first.firm);
|
||||||
|
const lease = acquireTradeMutex('[claim-now]', lane);
|
||||||
|
if (lease === null) throw new ClaimRefusedError(`the bot is placing a trade on ${first.firm.name} right now — try again in a minute`);
|
||||||
|
try {
|
||||||
|
const c = manualClaim(accountId);
|
||||||
|
if ('reason' in c) throw new ClaimRefusedError(c.reason);
|
||||||
|
if (!(amount >= 1)) throw new ClaimRefusedError(`$${amount} is below the $1 minimum claim`);
|
||||||
|
if (amount > c.amount + 0.005) throw new ClaimRefusedError(`only $${c.amount.toFixed(2)} is claimable now`);
|
||||||
|
const ends = claimEnds(c.client, c.acc);
|
||||||
|
const { claimed, received } = await withAccountInFlight(c.acc.id, () => c.client.claimProfit!(c.acc.id, amount));
|
||||||
|
const stage = afterClaim('[claim-now]', c.firm, c.client, c.acc, claimed, received, tradeDateFor(c.client), ends);
|
||||||
|
return { claimed, received, stage };
|
||||||
|
} finally {
|
||||||
|
releaseTradeMutex(lease, lane);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── eligibility check ────────────────────────────────────────────────────────
|
// ── eligibility check ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Returns true if any configured account could still trade today (eligible for a next leg / extra-day, or has an open position). */
|
/** Returns true if any configured account could still trade today (eligible for a next leg / extra-day, or has an open position). */
|
||||||
function hasRemainingConfiguredAccounts(): boolean {
|
function hasRemainingConfiguredAccounts(): boolean {
|
||||||
const { ready, unready } = firmClients();
|
const { ready, unready } = firmClients();
|
||||||
const tradeDate = currentTradeDateCT();
|
|
||||||
|
|
||||||
// A client that isn't ready can't say whether its accounts are done — its
|
// A client that isn't ready can't say whether its accounts are done — its
|
||||||
// snapshot is missing or stale — so while one might hold any of ours, the
|
// snapshot is missing or stale — so while one might hold any of ours, the
|
||||||
@@ -1903,7 +2287,7 @@ function hasRemainingConfiguredAccounts(): boolean {
|
|||||||
// Order being placed right now = still in play (evaluation calls it ineligible)
|
// Order being placed right now = still in play (evaluation calls it ineligible)
|
||||||
if (runtime().accountsInFlight.has(acc.id)) return true;
|
if (runtime().accountsInFlight.has(acc.id)) return true;
|
||||||
|
|
||||||
const ev = evaluateAccountForTrade(client, acc, tradeDate);
|
const ev = evaluateAccountForTrade(client, acc, tradeDateFor(client));
|
||||||
if (ev?.eligible) return true;
|
if (ev?.eligible) return true;
|
||||||
// Waiting on its history (or its day-start balance), not done: it may
|
// Waiting on its history (or its day-start balance), not done: it may
|
||||||
// still owe today's trade — during the session and before it opens
|
// still owe today's trade — during the session and before it opens
|
||||||
@@ -2010,6 +2394,12 @@ export function startScheduler(action: 'Buy' | 'Sell' | 'Auto', symbol: string,
|
|||||||
console.log(`[scheduler] ${laneName(lane)}: waiting for sync: ${names}`);
|
console.log(`[scheduler] ${laneName(lane)}: waiting for sync: ${names}`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// Payouts first, account by account — a claim doesn't wait
|
||||||
|
// for the rest of the lane to go flat. Nor does a passed
|
||||||
|
// evaluation's funded account, and the batch doesn't wait
|
||||||
|
// on it: a slow one would hold back the lane's entry.
|
||||||
|
await runClaims(lane, ready);
|
||||||
|
void runActivations(lane, ready).catch((err) => console.error(`[auto-activate] ${laneName(lane)}: ${errorMessage(err)}`));
|
||||||
// Skip this lane if any of its accounts still has an open
|
// Skip this lane if any of its accounts still has an open
|
||||||
// position from its previous batch — a batch fires only
|
// position from its previous batch — a batch fires only
|
||||||
// when every (ready) account in the lane is flat.
|
// when every (ready) account in the lane is flat.
|
||||||
|
|||||||
+13
-2
@@ -1,6 +1,7 @@
|
|||||||
import { TradovateClient } from './tradovate-class';
|
import { TradovateClient } from './tradovate-class';
|
||||||
import { TopstepXClient } from './topstepx-class';
|
import { TopstepXClient } from './topstepx-class';
|
||||||
import { DXTradeClient } from './dxtrade-class';
|
import { DXTradeClient } from './dxtrade-class';
|
||||||
|
import { VestClient } from './vest-class';
|
||||||
import type { FirmPlatform } from './master-protocol';
|
import type { FirmPlatform } from './master-protocol';
|
||||||
import { errorMessage, isClientReady, type PlatformClient } from './platform-client';
|
import { errorMessage, isClientReady, type PlatformClient } from './platform-client';
|
||||||
import { getTradableFirms, startCatalogSync } from './firm-catalog';
|
import { getTradableFirms, startCatalogSync } from './firm-catalog';
|
||||||
@@ -14,7 +15,7 @@ import { applyBrokerHttpDefaults, applyBrokerProxy } from './outbound-proxy';
|
|||||||
import axios from './http-client';
|
import axios from './http-client';
|
||||||
import { startMasterLink } from './master-link';
|
import { startMasterLink } from './master-link';
|
||||||
import { startUpdaterWatchdog } from './updater-watchdog';
|
import { startUpdaterWatchdog } from './updater-watchdog';
|
||||||
import { resumeSchedulerIfPersisted } from './auto-trade';
|
import { resumeSchedulerIfPersisted, startClaimWatcher } from './auto-trade';
|
||||||
import { superviseQuotes, SUPERVISE_INTERVAL_MS, type QuoteClient } from './live-quotes';
|
import { superviseQuotes, SUPERVISE_INTERVAL_MS, type QuoteClient } from './live-quotes';
|
||||||
|
|
||||||
// Use global to persist the client pool across HMR reloads in dev mode.
|
// Use global to persist the client pool across HMR reloads in dev mode.
|
||||||
@@ -102,6 +103,11 @@ export function initClient(
|
|||||||
case 'tradovate':
|
case 'tradovate':
|
||||||
client = new TradovateClient(creds.username, creds.password, onSyncComplete);
|
client = new TradovateClient(creds.username, creds.password, onSyncComplete);
|
||||||
break;
|
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: {
|
default: {
|
||||||
const unknown: never = platform;
|
const unknown: never = platform;
|
||||||
throw new Error(`no client for platform ${String(unknown)}`);
|
throw new Error(`no client for platform ${String(unknown)}`);
|
||||||
@@ -183,6 +189,9 @@ export function getClients(): Map<string, PlatformClient> {
|
|||||||
|
|
||||||
if (!g.__tradovateClientsInitialized) {
|
if (!g.__tradovateClientsInitialized) {
|
||||||
g.__tradovateClientsInitialized = true;
|
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 {
|
try {
|
||||||
// Before anything dials out: a timeout on every broker HTTP call,
|
// Before anything dials out: a timeout on every broker HTTP call,
|
||||||
// and the operator's proxy (if any) under the broker clients.
|
// and the operator's proxy (if any) under the broker clients.
|
||||||
@@ -376,10 +385,12 @@ const REBUILD_COOLDOWN_MS = 20 * 60_000;
|
|||||||
* and fixing the credentials (or the firm's settings) rebuilds them through
|
* and fixing the credentials (or the firm's settings) rebuilds them through
|
||||||
* reconcileClients anyway. TopstepX with an API key but no username never
|
* 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
|
* 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 {
|
function needsAttention(platform: FirmPlatform, client: PlatformClient): boolean {
|
||||||
if (platform === 'topstepx') return /username missing/i.test(client.lastFetchErrors?.['login'] ?? '');
|
if (platform === 'topstepx') return /username missing/i.test(client.lastFetchErrors?.['login'] ?? '');
|
||||||
if (platform === 'dxtrade') return (client as DXTradeClient).needsAttention === true;
|
if (platform === 'dxtrade') return (client as DXTradeClient).needsAttention === true;
|
||||||
|
if (platform === 'vest') return (client as VestClient).needsAttention === true;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import Database from 'better-sqlite3';
|
|||||||
import { mkdirSync } from 'fs';
|
import { mkdirSync } from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { sortableIso, stampRulesDate } from './kept-rules';
|
import { sortableIso, stampRulesDate } from './kept-rules';
|
||||||
|
import type { ClaimedCycle } from './trading-logic';
|
||||||
|
|
||||||
// AUTOFIRMER_DB lets several instances share one checkout, each with its own
|
// 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
|
// database. It is read once, at module load — schema creation and seeding are
|
||||||
@@ -556,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
|
/** 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
|
* 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
|
* the report's 28-day window are gone for good; that is the point (stale
|
||||||
@@ -577,6 +601,247 @@ export function wipeAccountData(accountId: number): void {
|
|||||||
txn();
|
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 ───────────────────────────────────────────────────────
|
// ── AutoBuyer captures ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
db.exec(`
|
db.exec(`
|
||||||
|
|||||||
+147
-11
@@ -36,25 +36,31 @@ import { currentTradeDateCT, FIRM_RESET_UTC_HOUR, rankForTarget } from './tradin
|
|||||||
import { sizingHoldHours } from './person-size';
|
import { sizingHoldHours } from './person-size';
|
||||||
import {
|
import {
|
||||||
saveDailyPnL, loadDailyPnL, loadAccountMeta, saveAccountMeta, loadFundTransactions, saveFundTransactions,
|
saveDailyPnL, loadDailyPnL, loadAccountMeta, saveAccountMeta, loadFundTransactions, saveFundTransactions,
|
||||||
saveEndedCycleStart,
|
saveEndedCycleStart, loadFillTrades, saveFillTrades, closeTradesUnseen, type FillTradeRow,
|
||||||
} from './db';
|
} from './db';
|
||||||
import { getAccountConfig } from './account-type-resolver';
|
import { getAccountConfig } from './account-type-resolver';
|
||||||
import { getCachedFirm } from './firm-catalog';
|
import { getCachedFirm } from './firm-catalog';
|
||||||
import { firmInstruments } from './instruments';
|
import { firmInstruments } from './instruments';
|
||||||
import type { SymbolMapCandidate } from './master-protocol';
|
import type { SymbolMapCandidate } from './master-protocol';
|
||||||
import type { PlatformClient, PlatformAccount, PlatformContract, ClientHealth, PickContext } from './platform-client';
|
import type { PlatformClient, PlatformAccount, PlatformContract, ClientHealth, PickContext, OrderIntent } from './platform-client';
|
||||||
import { OrderNotPlacedError, isOrderNotPlaced, errorMessage } from './platform-client';
|
import { OrderNotPlacedError, isOrderNotPlaced, errorMessage, orderEffect } from './platform-client';
|
||||||
import {
|
import {
|
||||||
dxAccountId, accountDisplayName, accountCodeOf, encodeContractName, decodeContractName, roundToStep,
|
dxAccountId, accountDisplayName, accountCodeOf, encodeContractName, decodeContractName, roundToStep,
|
||||||
lastUtcReset, ctTradeDayStart, firmDayOf, timeOf, parseDurationMs, ciNum, isFundType, signedFundValue, newOrderCode,
|
lastUtcReset, ctTradeDayStart, firmDayOf, timeOf, parseDurationMs, ciNum, isFundType, signedFundValue, newOrderCode,
|
||||||
inTradingHours, realizedVolPerHour, scoreCandidate, candidateRejection, parseTransfers, marginRateOf,
|
inTradingHours, realizedVolPerHour, scoreCandidate, candidateRejection, parseTransfers, marginRateOf,
|
||||||
|
parseHistoryOrder, dxTrades,
|
||||||
} from './dxtrade-helpers';
|
} 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';
|
import { hyperliquidCoins, hyperliquidCoinFor, hyperliquidQuote, hyperliquidHourlyCloses } from './hyperliquid-feed';
|
||||||
|
|
||||||
// ── timings ──────────────────────────────────────────────────────────────────
|
// ── timings ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const SYNC_INTERVAL_MS = 60_000;
|
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 SYNC_WEDGED_MS = 3 * 60_000;
|
||||||
const WATCHDOG_INTERVAL_MS = 60_000;
|
const WATCHDOG_INTERVAL_MS = 60_000;
|
||||||
/** Logged in longer than this with no good sync for as long → re-login. */
|
/** Logged in longer than this with no good sync for as long → re-login. */
|
||||||
@@ -257,6 +263,12 @@ export class DXTradeClient implements PlatformClient {
|
|||||||
private recentTxns = new Map<number, Map<string, DxTxn>>();
|
private recentTxns = new Map<number, Map<string, DxTxn>>();
|
||||||
/** Accounts whose balance and day's transactions came back this pass. */
|
/** Accounts whose balance and day's transactions came back this pass. */
|
||||||
private dayFresh = new Set<number>();
|
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. */
|
/** When each open position was first seen, for hold-time logging. */
|
||||||
private seenOpenAt = new Map<number, { positionCode: string; at: number; naggedAt: number }>();
|
private seenOpenAt = new Map<number, { positionCode: string; at: number; naggedAt: number }>();
|
||||||
|
|
||||||
@@ -685,6 +697,7 @@ export class DXTradeClient implements PlatformClient {
|
|||||||
await this.reapStrayOrders();
|
await this.reapStrayOrders();
|
||||||
this.checkNakedPositions();
|
this.checkNakedPositions();
|
||||||
if (superseded()) return;
|
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)`);
|
console.log(`${this.tag} sync — ${list.length} account(s), ${Object.keys(this.positions).length} open position(s)`);
|
||||||
this.maybeFetchDaysTraded();
|
this.maybeFetchDaysTraded();
|
||||||
@@ -1330,6 +1343,122 @@ export class DXTradeClient implements PlatformClient {
|
|||||||
return {};
|
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 ───────────────────────────────────────────────────────────────
|
// ── orders ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private async freshPositions(account: string): Promise<DxPosition[]> {
|
private async freshPositions(account: string): Promise<DxPosition[]> {
|
||||||
@@ -1428,10 +1557,10 @@ export class DXTradeClient implements PlatformClient {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Place an order and wait for its fill: an OPEN when the account is flat,
|
* 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
|
* a CLOSE of the position when it holds the opposite side — only the one
|
||||||
* position is refused, and so is an OPEN against another account's
|
* `intent` names, when given (orderEffect). Adding to a position is
|
||||||
* opposite position in the same symbol (no hedging). Resolves with the
|
* refused, and so is an OPEN against another account's opposite position
|
||||||
* fill `price`.
|
* in the same symbol (no hedging). Resolves with the fill `price`.
|
||||||
*/
|
*/
|
||||||
public async sendOrder(
|
public async sendOrder(
|
||||||
accountId: number,
|
accountId: number,
|
||||||
@@ -1440,9 +1569,10 @@ export class DXTradeClient implements PlatformClient {
|
|||||||
action: 'Buy' | 'Sell',
|
action: 'Buy' | 'Sell',
|
||||||
orderType: 'Market' | 'Limit',
|
orderType: 'Market' | 'Limit',
|
||||||
price?: number,
|
price?: number,
|
||||||
|
intent?: OrderIntent,
|
||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
try {
|
try {
|
||||||
return await this.sendOrderAndWait(accountId, contractSymbol, quantity, action, orderType, price);
|
return await this.sendOrderAndWait(accountId, contractSymbol, quantity, action, orderType, price, intent);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!isOrderNotPlaced(err)) this.suspectAccounts.set(accountId, Date.now());
|
if (!isOrderNotPlaced(err)) this.suspectAccounts.set(accountId, Date.now());
|
||||||
throw err;
|
throw err;
|
||||||
@@ -1456,6 +1586,7 @@ export class DXTradeClient implements PlatformClient {
|
|||||||
action: 'Buy' | 'Sell',
|
action: 'Buy' | 'Sell',
|
||||||
orderType: 'Market' | 'Limit',
|
orderType: 'Market' | 'Limit',
|
||||||
price?: number,
|
price?: number,
|
||||||
|
intent?: OrderIntent,
|
||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
const { account, symbol, step } = this.orderContext(accountId, contractSymbol);
|
const { account, symbol, step } = this.orderContext(accountId, contractSymbol);
|
||||||
const side: 'BUY' | 'SELL' = action === 'Buy' ? 'BUY' : 'SELL';
|
const side: 'BUY' | 'SELL' = action === 'Buy' ? 'BUY' : 'SELL';
|
||||||
@@ -1465,8 +1596,13 @@ export class DXTradeClient implements PlatformClient {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new OrderNotPlacedError(`DXtrade position check failed — order not sent: ${errorMessage(err)}`);
|
throw new OrderNotPlacedError(`DXtrade position check failed — order not sent: ${errorMessage(err)}`);
|
||||||
}
|
}
|
||||||
const existing = positions.find((p) => p.symbol === symbol);
|
const held = positions.find((p) => p.symbol === symbol);
|
||||||
if (existing && existing.side === side) throw new OrderNotPlacedError(`adding to an open ${symbol} position is not supported`);
|
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');
|
const code = newOrderCode(existing ? 'x' : 'e');
|
||||||
let body: Record<string, unknown>;
|
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}`;
|
if (o.currency && o.currency !== o.accountCurrency) return `priced in ${o.currency}`;
|
||||||
return null;
|
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();
|
||||||
|
}
|
||||||
+28
-10
@@ -17,7 +17,8 @@
|
|||||||
* the two are not linked yet.)
|
* the two are not linked yet.)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { isFirmPlatform } from './master-protocol';
|
import { isFirmPlatform, isNotionalPlatform } from './master-protocol';
|
||||||
|
import { isVestWalletAddress } from './vest-helpers';
|
||||||
import { sanitizeInstruments, sanitizeInstrumentLimits, DEFAULT_MAX_ENTRY_SPREAD } from './instrument-specs';
|
import { sanitizeInstruments, sanitizeInstrumentLimits, DEFAULT_MAX_ENTRY_SPREAD } from './instrument-specs';
|
||||||
import { normalizeAccountType } from './kept-rules';
|
import { normalizeAccountType } from './kept-rules';
|
||||||
import type {
|
import type {
|
||||||
@@ -48,8 +49,11 @@ const COLD_RETRY_MAX_MS = 60_000;
|
|||||||
* first sync on a newer parser fetches the whole catalog.
|
* first sync on a newer parser fetches the whole catalog.
|
||||||
* 2: kept rule sets (keptAccountRules) and the firm's maxConcurrentAccounts.
|
* 2: kept rule sets (keptAccountRules) and the firm's maxConcurrentAccounts.
|
||||||
* Stamped only on a payload from a master that sends keptAccountRules: one
|
* 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. */
|
* from an older master can't vouch for kept sets it never sent.
|
||||||
export const CATALOG_PARSER_VERSION = 2;
|
* 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. */
|
/** 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;
|
const MAX_MASTER_OFFSET_MS = 365 * 24 * 60 * 60 * 1000;
|
||||||
@@ -102,6 +106,10 @@ function cache(): CatalogCache {
|
|||||||
const parser = (stored as { parser?: unknown }).parser;
|
const parser = (stored as { parser?: unknown }).parser;
|
||||||
if (typeof parser === 'number') payload.parser = parser;
|
if (typeof parser === 'number') payload.parser = parser;
|
||||||
else delete payload.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 {
|
} catch {
|
||||||
console.error('[catalog] cached payload is unreadable — ignoring it');
|
console.error('[catalog] cached payload is unreadable — ignoring it');
|
||||||
}
|
}
|
||||||
@@ -170,7 +178,8 @@ export function getCatalogMeta(): {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Whether stored credentials are what the firm's platform logs in with — a
|
/** 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(
|
export function hasPlatformCredentials(
|
||||||
platform: FirmPlatform,
|
platform: FirmPlatform,
|
||||||
creds: { username: string; password: string; apiKey: string } | null | undefined,
|
creds: { username: string; password: string; apiKey: string } | null | undefined,
|
||||||
@@ -178,6 +187,7 @@ export function hasPlatformCredentials(
|
|||||||
if (!creds) return false;
|
if (!creds) return false;
|
||||||
switch (platform) {
|
switch (platform) {
|
||||||
case 'topstepx': return !!creds.apiKey;
|
case 'topstepx': return !!creds.apiKey;
|
||||||
|
case 'vest': return isVestWalletAddress(creds.apiKey);
|
||||||
case 'tradovate':
|
case 'tradovate':
|
||||||
case 'dxtrade': return !!creds.username && !!creds.password;
|
case 'dxtrade': return !!creds.username && !!creds.password;
|
||||||
}
|
}
|
||||||
@@ -280,6 +290,8 @@ function validatePayload(raw: unknown): FirmCatalogPayload {
|
|||||||
if (!Array.isArray(body.firms)) throw new Error('missing firms');
|
if (!Array.isArray(body.firms)) throw new Error('missing firms');
|
||||||
|
|
||||||
const firms: CatalogFirm[] = [];
|
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) => {
|
body.firms.forEach((f: unknown, i: number) => {
|
||||||
if (typeof f !== 'object' || f === null) throw new Error(`firm ${i} is not an object`);
|
if (typeof f !== 'object' || f === null) throw new Error(`firm ${i} is not an object`);
|
||||||
const firm = f as Record<string, unknown>;
|
const firm = f as Record<string, unknown>;
|
||||||
@@ -294,11 +306,13 @@ function validatePayload(raw: unknown): FirmCatalogPayload {
|
|||||||
const platform = firm.platform === undefined ? 'tradovate' : firm.platform;
|
const platform = firm.platform === undefined ? 'tradovate' : firm.platform;
|
||||||
if (!isFirmPlatform(platform)) {
|
if (!isFirmPlatform(platform)) {
|
||||||
warnUnknownPlatform(firm.id, firm.name, platform);
|
warnUnknownPlatform(firm.id, firm.name, platform);
|
||||||
|
skippedPlatforms.add(String(platform));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Percent commission and every-day trading are DXtrade settings;
|
// Percent commission and every-day trading are settings of the
|
||||||
// futures firms keep measured per-contract fees and the CME calendar.
|
// notional platforms (DXtrade, Vest); futures firms keep measured
|
||||||
const dx = platform === 'dxtrade';
|
// per-contract fees and the CME calendar.
|
||||||
|
const dx = isNotionalPlatform(platform);
|
||||||
const tradesEveryDay = dx && firm.tradesEveryDay === true;
|
const tradesEveryDay = dx && firm.tradesEveryDay === true;
|
||||||
if (!Array.isArray(firm.accountTypes)) throw new Error(`firm ${firm.name} is missing accountTypes`);
|
if (!Array.isArray(firm.accountTypes)) throw new Error(`firm ${firm.name} is missing accountTypes`);
|
||||||
|
|
||||||
@@ -317,7 +331,7 @@ function validatePayload(raw: unknown): FirmCatalogPayload {
|
|||||||
// The master's list when it sends one; absent from an older
|
// The master's list when it sends one; absent from an older
|
||||||
// master, whose bannedSymbols / symbolMap then apply.
|
// master, whose bannedSymbols / symbolMap then apply.
|
||||||
...(Array.isArray(firm.instruments) ? { instruments: sanitizeInstruments(platform, firm.instruments) } : {}),
|
...(Array.isArray(firm.instruments) ? { instruments: sanitizeInstruments(platform, firm.instruments) } : {}),
|
||||||
...(platform !== 'dxtrade' ? { instrumentLimits: sanitizeInstrumentLimits(firm.instrumentLimits) } : {}),
|
...(!dx ? { instrumentLimits: sanitizeInstrumentLimits(firm.instrumentLimits) } : {}),
|
||||||
bannedSymbols: Array.isArray(firm.bannedSymbols) ? (firm.bannedSymbols as string[]) : [],
|
bannedSymbols: Array.isArray(firm.bannedSymbols) ? (firm.bannedSymbols as string[]) : [],
|
||||||
accountTypes,
|
accountTypes,
|
||||||
...(keptAccountRules ? { keptAccountRules } : {}),
|
...(keptAccountRules ? { keptAccountRules } : {}),
|
||||||
@@ -345,6 +359,7 @@ function validatePayload(raw: unknown): FirmCatalogPayload {
|
|||||||
return {
|
return {
|
||||||
version: body.version, updatedAt: body.updatedAt, firms, ...(trading ? { trading } : {}),
|
version: body.version, updatedAt: body.updatedAt, firms, ...(trading ? { trading } : {}),
|
||||||
...(keptAware ? { parser: CATALOG_PARSER_VERSION } : {}),
|
...(keptAware ? { parser: CATALOG_PARSER_VERSION } : {}),
|
||||||
|
...(skippedPlatforms.size > 0 ? { skippedPlatforms: [...skippedPlatforms] } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -420,10 +435,13 @@ export async function syncCatalog(): Promise<{ ok: boolean; changed: boolean; ve
|
|||||||
// `since` only against the master the cache came from: two masters'
|
// `since` only against the master the cache came from: two masters'
|
||||||
// version numbers can collide, and an 'unchanged' from the wrong one
|
// version numbers can collide, and an 'unchanged' from the wrong one
|
||||||
// would keep the other's catalog. Nor for a cache an older parser wrote:
|
// 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.
|
// 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 hasCache = getCachedCatalog().length > 0;
|
||||||
const source = url.origin;
|
const source = url.origin;
|
||||||
const cacheCurrent = (cache().payload?.parser ?? 0) >= CATALOG_PARSER_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));
|
if (hasCache && cacheCurrent && getSetting('catalog_source_url') === source) url.searchParams.set('since', String(version));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -259,7 +259,9 @@ const DXTRADE_SYMBOL = /^[A-Za-z0-9./_:-]{1,32}$/;
|
|||||||
* specs for, or valid DXtrade symbols. Deduplicated, in order. */
|
* specs for, or valid DXtrade symbols. Deduplicated, in order. */
|
||||||
export function sanitizeInstruments(platform: string, raw: unknown): string[] {
|
export function sanitizeInstruments(platform: string, raw: unknown): string[] {
|
||||||
if (!Array.isArray(raw)) return [];
|
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[] = [];
|
const out: string[] = [];
|
||||||
for (const value of raw) {
|
for (const value of raw) {
|
||||||
if (typeof value !== 'string') continue;
|
if (typeof value !== 'string') continue;
|
||||||
@@ -281,10 +283,10 @@ export function resolveAllowlist(
|
|||||||
localEnabled: readonly string[],
|
localEnabled: readonly string[],
|
||||||
): { roots: string[]; source: 'master' | 'legacy' } {
|
): { roots: string[]; source: 'master' | 'legacy' } {
|
||||||
if (Array.isArray(firm.instruments)) return { roots: sanitizeInstruments(firm.platform, firm.instruments), source: 'master' };
|
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 map = firm.symbolMap ?? {};
|
||||||
const keys = Object.keys(map).sort((a, b) => (a === '*' ? -1 : b === '*' ? 1 : a.localeCompare(b)));
|
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 ?? []);
|
const banned = new Set(firm.bannedSymbols ?? []);
|
||||||
return { roots: sanitizeInstruments(firm.platform, localEnabled.filter((r) => !banned.has(r))), source: 'legacy' };
|
return { roots: sanitizeInstruments(firm.platform, localEnabled.filter((r) => !banned.has(r))), source: 'legacy' };
|
||||||
|
|||||||
+2
-1
@@ -27,6 +27,7 @@ import { getCachedContract } from './contract-resolver';
|
|||||||
import { currentTradeDateCT } from './trading-logic';
|
import { currentTradeDateCT } from './trading-logic';
|
||||||
import { errorMessage } from './platform-client';
|
import { errorMessage } from './platform-client';
|
||||||
import type { InstrumentStats } from './master-protocol';
|
import type { InstrumentStats } from './master-protocol';
|
||||||
|
import { isNotionalPlatform } from './master-protocol';
|
||||||
import {
|
import {
|
||||||
CME_SPECS, CME_ROOTS, dailyMoveDollars, futuresContracts, expectedHoldHours, sessionProgress,
|
CME_SPECS, CME_ROOTS, dailyMoveDollars, futuresContracts, expectedHoldHours, sessionProgress,
|
||||||
drawChances, pickByChance, resolveAllowlist, spreadTooWide, MAX_HOLD_HOURS, type DrawCandidate,
|
drawChances, pickByChance, resolveAllowlist, spreadTooWide, MAX_HOLD_HOURS, type DrawCandidate,
|
||||||
@@ -56,7 +57,7 @@ export function instrumentsSource(firm: FirmLike): 'master' | 'legacy' {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isFuturesFirm(firm: { platform: string }): boolean {
|
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
|
/** Every root a futures firm on this machine lists, in CME order — what the
|
||||||
|
|||||||
+5
-1
@@ -22,7 +22,7 @@ import type { CatalogAccountType, KeptAccountRules } from './master-protocol';
|
|||||||
export const RULE_FIELDS = [
|
export const RULE_FIELDS = [
|
||||||
'profitTarget', 'consistency', 'minDayPnL', 'minTradingDays', 'maxLoss',
|
'profitTarget', 'consistency', 'minDayPnL', 'minTradingDays', 'maxLoss',
|
||||||
'maxPositionSize', 'targetSameEquity', 'splitEnabled', 'minLegs', 'firstDayStop', 'minDayStop',
|
'maxPositionSize', 'targetSameEquity', 'splitEnabled', 'minLegs', 'firstDayStop', 'minDayStop',
|
||||||
'combineCmeHolidays', 'withdrawalStages', 'payoutable', 'trailingDrawdown', 'dailyLossLimit',
|
'combineCmeHolidays', 'withdrawalStages', 'payoutable', 'trailingDrawdown', 'dailyLossLimit', 'stagesSameDay',
|
||||||
] as const satisfies readonly (keyof CatalogAccountType)[];
|
] as const satisfies readonly (keyof CatalogAccountType)[];
|
||||||
|
|
||||||
/** Always the live type's: accounts are matched to a type by them, so a kept
|
/** Always the live type's: accounts are matched to a type by them, so a kept
|
||||||
@@ -105,6 +105,8 @@ export function normalizeAccountType(
|
|||||||
minDayStop: type.minDayStop === true,
|
minDayStop: type.minDayStop === true,
|
||||||
// Lenient: no hard daily limit, no notional cap.
|
// Lenient: no hard daily limit, no notional cap.
|
||||||
dailyLossLimit: nonNegative(type.dailyLossLimit),
|
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
|
// The firm's calendar, stamped on every type so the cfg-based
|
||||||
// paths (projections, completion, the dashboard) all see it.
|
// paths (projections, completion, the dashboard) all see it.
|
||||||
tradesEveryDay,
|
tradesEveryDay,
|
||||||
@@ -168,6 +170,7 @@ function normalizeStage(stage: unknown): unknown {
|
|||||||
minDayPnL: typeof s.minDayPnL === 'number' ? s.minDayPnL : (drain ? 5 : 0),
|
minDayPnL: typeof s.minDayPnL === 'number' ? s.minDayPnL : (drain ? 5 : 0),
|
||||||
...(typeof s.withdrawalPct === 'number' ? { withdrawalPct: s.withdrawalPct } : {}),
|
...(typeof s.withdrawalPct === 'number' ? { withdrawalPct: s.withdrawalPct } : {}),
|
||||||
...(typeof s.payoutCap === 'number' ? { payoutCap: s.payoutCap } : {}),
|
...(typeof s.payoutCap === 'number' ? { payoutCap: s.payoutCap } : {}),
|
||||||
|
...(typeof s.dailyLossLimit === 'number' ? { dailyLossLimit: s.dailyLossLimit } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,6 +184,7 @@ function comparable(field: RuleField, value: unknown): unknown {
|
|||||||
case 'firstDayStop':
|
case 'firstDayStop':
|
||||||
case 'minDayStop':
|
case 'minDayStop':
|
||||||
case 'combineCmeHolidays':
|
case 'combineCmeHolidays':
|
||||||
|
case 'stagesSameDay':
|
||||||
case 'payoutable': return value === true;
|
case 'payoutable': return value === true;
|
||||||
case 'minLegs':
|
case 'minLegs':
|
||||||
case 'trailingDrawdown':
|
case 'trailingDrawdown':
|
||||||
|
|||||||
+38
-11
@@ -11,9 +11,41 @@ import type { PlatformContract } from './platform-client';
|
|||||||
import type { SymbolStats } from './master-protocol';
|
import type { SymbolStats } from './master-protocol';
|
||||||
import {
|
import {
|
||||||
combineCmeSessions, computeDayPlan, resolveEffectiveConfig, obfuscationStopDollars, dllRoom as computeDllRoom,
|
combineCmeSessions, computeDayPlan, resolveEffectiveConfig, obfuscationStopDollars, dllRoom as computeDllRoom,
|
||||||
planLegSize, rankForTarget, maxLossRoom, lastStand, dllStopDollars,
|
planLegSize, rankForTarget, maxLossRoom, lastStand, dllStopDollars, stageInputsFor, floorRoom, shortestNextLeg,
|
||||||
FALLBACK_COMMISSION_PER_CONTRACT,
|
FALLBACK_COMMISSION_PER_CONTRACT,
|
||||||
} from './trading-logic';
|
} 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';
|
import { sizingHoldHours } from './person-size';
|
||||||
|
|
||||||
export type CandidateLeg = SymbolStats['symbols'][number] & {
|
export type CandidateLeg = SymbolStats['symbols'][number] & {
|
||||||
@@ -68,6 +100,7 @@ export function rankNextLeg(o: {
|
|||||||
maxLossRoom: floorRoom,
|
maxLossRoom: floorRoom,
|
||||||
obfuscationDollars: o.obfuscationDollars,
|
obfuscationDollars: o.obfuscationDollars,
|
||||||
availableFunds: funds,
|
availableFunds: funds,
|
||||||
|
slippageFraction: o.client.stopSlippageFraction,
|
||||||
});
|
});
|
||||||
const p = plan(true);
|
const p = plan(true);
|
||||||
if (!p.sizing || p.cost === null) return [];
|
if (!p.sizing || p.cost === null) return [];
|
||||||
@@ -105,6 +138,7 @@ function accountPlan(
|
|||||||
const effective = resolveEffectiveConfig(
|
const effective = resolveEffectiveConfig(
|
||||||
cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity, cfg.withdrawalStages,
|
cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity, cfg.withdrawalStages,
|
||||||
client.priorProfit?.[acc.id] ?? 0, client.fundTransactions?.[acc.id] ?? [], cfg.minDayPnL,
|
client.priorProfit?.[acc.id] ?? 0, client.fundTransactions?.[acc.id] ?? [], cfg.minDayPnL,
|
||||||
|
stageInputsFor(client, acc.id, cfg),
|
||||||
);
|
);
|
||||||
const plan = computeDayPlan(
|
const plan = computeDayPlan(
|
||||||
acc.id, combined.tradeDate, effective.profitTarget, effective.consistency,
|
acc.id, combined.tradeDate, effective.profitTarget, effective.consistency,
|
||||||
@@ -112,15 +146,8 @@ function accountPlan(
|
|||||||
cfg.splitEnabled, effective.drain,
|
cfg.splitEnabled, effective.drain,
|
||||||
);
|
);
|
||||||
if (!plan) return null;
|
if (!plan) return null;
|
||||||
const dll = cfg.dailyLossLimit ?? 0;
|
const dll = effective.dailyLossLimit;
|
||||||
let room: number | null = null;
|
const room = dayLossRoom(client, acc.id, dll, cash, combined.realizedPnL, shortestNextLeg(plan));
|
||||||
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 obfuscation = obfuscationStopDollars(cfg, combined.dailyPnL, plan, combined.tradeDate, plan.remainingToday);
|
const obfuscation = obfuscationStopDollars(cfg, combined.dailyPnL, plan, combined.tradeDate, plan.remainingToday);
|
||||||
return { cfg, cash, plan, dll, room, obfuscation };
|
return { cfg, cash, plan, dll, room, obfuscation };
|
||||||
}
|
}
|
||||||
@@ -147,7 +174,7 @@ export function accountNextLeg(
|
|||||||
leg: p.plan.remainingToday,
|
leg: p.plan.remainingToday,
|
||||||
lastStand: p.room !== null && lastStand({ dllRoom: p.room, dailyLossLimit: p.dll, maxLossRoom: floorRoom }),
|
lastStand: p.room !== null && lastStand({ dllRoom: p.room, dailyLossLimit: p.dll, maxLossRoom: floorRoom }),
|
||||||
candidates: rankNextLeg({
|
candidates: rankNextLeg({
|
||||||
client, accountId: acc.id, cfg: p.cfg, firm, leg: p.plan.remainingToday, dllRoom: p.room,
|
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,
|
obfuscationDollars: p.obfuscation, cash: p.cash, tradeDate,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
+33
-9
@@ -36,11 +36,19 @@ export interface AccountTypeStats {
|
|||||||
* cycle ready to withdraw, 'drain' = a drain stage's days booked;
|
* cycle ready to withdraw, 'drain' = a drain stage's days booked;
|
||||||
* cycle = the date of the fund transaction that started the cycle ('' for
|
* cycle = the date of the fund transaction that started the cycle ('' for
|
||||||
* the account's first cycle) — firm + name + cycle identify one
|
* 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
|
* 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
|
* that just ended if that cycle had met the rule — and, for 7 days after
|
||||||
* not counted in `completed`. Optional: older instances omit it. */
|
* it, each cycle the instance's own payout claim ended complete, dated
|
||||||
completedAccounts?: { name: string; date: string; stage: number; kind: 'pass' | 'payout' | 'drain'; cycle: string }[];
|
* 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:
|
/** Live accounts by trading days completed toward the current target:
|
||||||
* {daysTraded: accountCount}. Feeds the master's payout calendar, so
|
* {daysTraded: accountCount}. Feeds the master's payout calendar, so
|
||||||
* accounts that are dead, DLL-locked, or in the red are left out — the
|
* accounts that are dead, DLL-locked, or in the red are left out — the
|
||||||
@@ -109,9 +117,10 @@ export interface FirmStats {
|
|||||||
* instances omit it.
|
* instances omit it.
|
||||||
*
|
*
|
||||||
* withdrawals = how many times the balance has gone down for a payout
|
* withdrawals = how many times the balance has gone down for a payout
|
||||||
* (negative fund transactions), which tells the master a completed
|
* (negative fund transactions; on Vest, each claim, though a day's share
|
||||||
* cycle's money is out. Omitted while the account's fund history hasn't
|
* one fund row), which tells the master a completed cycle's money is
|
||||||
* loaded, so a restart can't read as zero withdrawals.
|
* 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
|
* The rest come from statusVersion builds (platform-client.ts
|
||||||
* accountStatus), each left out when null or at its default. status:
|
* accountStatus), each left out when null or at its default. status:
|
||||||
@@ -365,7 +374,11 @@ export interface CatalogAccountType {
|
|||||||
* minTradingDays qualifying days of ~minDayPnL (default $5, randomized
|
* minTradingDays qualifying days of ~minDayPnL (default $5, randomized
|
||||||
* $5–$15, traded as tiny MNQ legs), then wait for the next withdrawal.
|
* $5–$15, traded as tiny MNQ legs), then wait for the next withdrawal.
|
||||||
* withdrawalPct/payoutCap override the type's payout defaults per stage. */
|
* 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
|
/** Payout-projection settings, owned by the master's payout calendar but
|
||||||
* consumed here to compute each account's future payout chain. Optional:
|
* consumed here to compute each account's future payout chain. Optional:
|
||||||
* an older master doesn't send them. */
|
* an older master doesn't send them. */
|
||||||
@@ -409,13 +422,21 @@ export interface KeptAccountRules extends CatalogAccountType {
|
|||||||
|
|
||||||
/** Where a firm's accounts trade. A build only knows the platforms listed
|
/** 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. */
|
* 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 type FirmPlatform = (typeof FIRM_PLATFORMS)[number];
|
||||||
|
|
||||||
export function isFirmPlatform(value: unknown): value is FirmPlatform {
|
export function isFirmPlatform(value: unknown): value is FirmPlatform {
|
||||||
return typeof value === 'string' && (FIRM_PLATFORMS as readonly string[]).includes(value);
|
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
|
/** 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
|
* from volatility and the firm's target hold, and the commission is the
|
||||||
* firm's (an older master's notionalPerContract or per-symbol commissionPct
|
* firm's (an older master's notionalPerContract or per-symbol commissionPct
|
||||||
@@ -496,6 +517,9 @@ export interface FirmCatalogPayload {
|
|||||||
* payload. An older parser dropped fields it didn't know, so a cache
|
* 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. */
|
* without it, or below the current one, isn't trusted to hold them. */
|
||||||
parser?: number;
|
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 {
|
export interface FirmCatalogUnchanged {
|
||||||
|
|||||||
+6
-6
@@ -1,9 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* The hold this machine sizes DXtrade legs for. Every person's machine would
|
* The hold this machine sizes DXtrade and Vest legs for. Every person's
|
||||||
* otherwise size a like account in a like state to the same position as
|
* machine would otherwise size a like account in a like state to the same
|
||||||
* everyone else's; each person's own draw (personHoldHours) puts their sizes
|
* position as everyone else's; each person's own draw (personHoldHours) puts
|
||||||
* within ±PERSON_SIZE_SPREAD of the plan instead, the same for all of their
|
* their sizes within ±PERSON_SIZE_SPREAD of the plan instead, the same for
|
||||||
* accounts and new each trade date.
|
* all of their accounts and new each trade date.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { getSetting } from './db';
|
import { getSetting } from './db';
|
||||||
@@ -15,7 +15,7 @@ function personKey(): string {
|
|||||||
return getSetting('instance_id') || getSetting('instance_name') || '';
|
return getSetting('instance_id') || getSetting('instance_name') || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The hold a DXtrade firm's legs are sized for on this machine today. */
|
/** The hold a DXtrade or Vest firm's legs are sized for on this machine today. */
|
||||||
export function sizingHoldHours(
|
export function sizingHoldHours(
|
||||||
firm: { targetHoldHours?: number } | null | undefined,
|
firm: { targetHoldHours?: number } | null | undefined,
|
||||||
tradeDate: string = currentTradeDateCT(),
|
tradeDate: string = currentTradeDateCT(),
|
||||||
|
|||||||
+138
-1
@@ -48,8 +48,16 @@ export interface PlatformContract {
|
|||||||
/** Margin posted per dollar of notional (0.2 = 5:1 leverage), where the
|
/** Margin posted per dollar of notional (0.2 = 5:1 leverage), where the
|
||||||
* broker says — caps a position at available funds ÷ this. */
|
* broker says — caps a position at available funds ÷ this. */
|
||||||
marginRate?: number;
|
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 {
|
export interface PlatformClient {
|
||||||
accountList: PlatformAccount[];
|
accountList: PlatformAccount[];
|
||||||
/** realizedPnL is today's. pendingRoll (Tradovate): the broker still shows
|
/** realizedPnL is today's. pendingRoll (Tradovate): the broker still shows
|
||||||
@@ -74,6 +82,11 @@ export interface PlatformClient {
|
|||||||
fundTransactions: { [accountId: number]: { date: string; amount: number }[] };
|
fundTransactions: { [accountId: number]: { date: string; amount: number }[] };
|
||||||
priorProfit: { [accountId: number]: number };
|
priorProfit: { [accountId: number]: number };
|
||||||
autoLiqThresholds: { [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;
|
fetchDaysComplete: boolean;
|
||||||
syncComplete: boolean;
|
syncComplete: boolean;
|
||||||
@@ -121,6 +134,55 @@ export interface PlatformClient {
|
|||||||
* account rather than size a stop on a guess.
|
* account rather than size a stop on a guess.
|
||||||
*/
|
*/
|
||||||
dayStartBalance?(accountId: number): number | null;
|
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
|
/** Drop the account's history from memory (the wipe routes), so it reads
|
||||||
* unloaded until the next fetch rebuilds it. */
|
* unloaded until the next fetch rebuilds it. */
|
||||||
forgetHistory(accountId: number, accountName: string): void;
|
forgetHistory(accountId: number, accountName: string): void;
|
||||||
@@ -130,13 +192,20 @@ export interface PlatformClient {
|
|||||||
findFrontMonthContract(productName: string, pick?: PickContext): Promise<PlatformContract | null>;
|
findFrontMonthContract(productName: string, pick?: PickContext): Promise<PlatformContract | null>;
|
||||||
fetchInstrumentFees(symbols: string[]): Promise<{ [symbol: string]: number }>;
|
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(
|
sendOrder(
|
||||||
accountId: number,
|
accountId: number,
|
||||||
contractSymbol: string,
|
contractSymbol: string,
|
||||||
quantity: number,
|
quantity: number,
|
||||||
action: 'Buy' | 'Sell',
|
action: 'Buy' | 'Sell',
|
||||||
orderType: 'Market' | 'Limit',
|
orderType: 'Market' | 'Limit',
|
||||||
price?: number
|
price?: number,
|
||||||
|
intent?: OrderIntent,
|
||||||
): Promise<Record<string, unknown>>;
|
): Promise<Record<string, unknown>>;
|
||||||
|
|
||||||
placeOrderNoWait(
|
placeOrderNoWait(
|
||||||
@@ -281,6 +350,17 @@ export function brokerFacts(
|
|||||||
if (platform === 'topstepx') {
|
if (platform === 'topstepx') {
|
||||||
return { inactiveReason: 'cannot_trade', ...(account.active ? {} : { brokerStatus: 'canTrade:false' }) };
|
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;
|
const status = meta?.status || undefined;
|
||||||
return {
|
return {
|
||||||
inactiveReason: status === 'CLOSE_ONLY' ? 'close_only' : status ? `status:${status}` : 'inactive',
|
inactiveReason: status === 'CLOSE_ONLY' ? 'close_only' : status ? `status:${status}` : 'inactive',
|
||||||
@@ -406,6 +486,63 @@ export function isOrderNotPlaced(err: unknown): boolean {
|
|||||||
return !!err && typeof err === 'object' && (err as { orderNotPlaced?: unknown }).orderNotPlaced === true;
|
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
|
/** 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
|
* config — Tradovate's login body (the password) and Bearer tokens — so they
|
||||||
* must never be logged whole. */
|
* must never be logged whole. */
|
||||||
|
|||||||
+92
-14
@@ -11,7 +11,7 @@
|
|||||||
* POST keeps those working unchanged.
|
* POST keeps those working unchanged.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { getSetting, loadEndedCycleStart } from './db';
|
import { getSetting, loadClaimedCycles, loadEndedCycleStart } from './db';
|
||||||
import { pushReport } from './master-link';
|
import { pushReport } from './master-link';
|
||||||
import { getBuildVersion } from './version';
|
import { getBuildVersion } from './version';
|
||||||
import { tradeMixFor } from './trade-mix';
|
import { tradeMixFor } from './trade-mix';
|
||||||
@@ -20,7 +20,10 @@ import { getClients } from './clients';
|
|||||||
import { getAccountTypeById, getKeptRuleIdsForFirm, getTradableFirms } from './firm-catalog';
|
import { getAccountTypeById, getKeptRuleIdsForFirm, getTradableFirms } from './firm-catalog';
|
||||||
import { getAccountConfig } from './account-type-resolver';
|
import { getAccountConfig } from './account-type-resolver';
|
||||||
import { getSchedulerStatus } from './auto-trade';
|
import { getSchedulerStatus } from './auto-trade';
|
||||||
import { botStopsActive, combineCmeSessions, computeDayPlan, currentTradeDateCT, cycleCompletion, endedCycleCompletion, LEG_COMPLETION_EPSILON, projectPerfectPath, resolveEffectiveConfig } 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 type { AccountTypeStats, FirmStats, InstrumentStats, SymbolStats } from './master-protocol';
|
||||||
import { accountNextLeg, accountLegShape } from './leg-estimate';
|
import { accountNextLeg, accountLegShape } from './leg-estimate';
|
||||||
import { sizingHoldHours } from './person-size';
|
import { sizingHoldHours } from './person-size';
|
||||||
@@ -33,9 +36,6 @@ function collectFirmStats() {
|
|||||||
// the master's table with rows for firms it does not run.
|
// the master's table with rows for firms it does not run.
|
||||||
const firms = getTradableFirms();
|
const firms = getTradableFirms();
|
||||||
const clients = getClients();
|
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
|
// projectPerfectPath's `loops` test: a type whose completed cycle keeps
|
||||||
// going pays out (a funded account), one that stops just passes.
|
// going pays out (a funded account), one that stops just passes.
|
||||||
const loops = (cfg: { payoutable?: boolean; withdrawalStages: unknown[] }): boolean =>
|
const loops = (cfg: { payoutable?: boolean; withdrawalStages: unknown[] }): boolean =>
|
||||||
@@ -49,6 +49,11 @@ function collectFirmStats() {
|
|||||||
const keptRuleIds = getKeptRuleIdsForFirm(f.id);
|
const keptRuleIds = getKeptRuleIdsForFirm(f.id);
|
||||||
// accounts: [] only once synced (see emptyFirmStats).
|
// accounts: [] only once synced (see emptyFirmStats).
|
||||||
if (!client || client.accountList.length === 0) return emptyFirmStats(f, client, keptRuleIds);
|
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 totalAccounts = 0;
|
||||||
let accountsTraded = 0;
|
let accountsTraded = 0;
|
||||||
@@ -90,6 +95,11 @@ function collectFirmStats() {
|
|||||||
}
|
}
|
||||||
return entry;
|
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) {
|
for (const acc of client.accountList) {
|
||||||
const reportedCash = client.accountCashBalances[acc.id];
|
const reportedCash = client.accountCashBalances[acc.id];
|
||||||
@@ -104,11 +114,22 @@ function collectFirmStats() {
|
|||||||
// standing, while a wrong zero would clear one early.
|
// standing, while a wrong zero would clear one early.
|
||||||
const loadedFundTxns = client.fundTransactions?.[acc.id];
|
const loadedFundTxns = client.fundTransactions?.[acc.id];
|
||||||
const fundTxns = loadedFundTxns ?? [];
|
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({
|
accounts.push({
|
||||||
name: acc.name,
|
name: acc.name,
|
||||||
size: cfg?.accountSize ?? 0,
|
size: cfg?.accountSize ?? 0,
|
||||||
payoutable: cfg ? loops(cfg) : null,
|
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
|
// For the master's account ledger. Data only: nothing here
|
||||||
// trades on it, and the totals below keep their own test.
|
// trades on it, and the totals below keep their own test.
|
||||||
...statusFieldsOrUnknown(() => accountStatusFields({
|
...statusFieldsOrUnknown(() => accountStatusFields({
|
||||||
@@ -154,6 +175,7 @@ function collectFirmStats() {
|
|||||||
cfg.profitTarget, cfg.consistency, cfg.minTradingDays,
|
cfg.profitTarget, cfg.consistency, cfg.minTradingDays,
|
||||||
cfg.targetSameEquity, cfg.withdrawalStages,
|
cfg.targetSameEquity, cfg.withdrawalStages,
|
||||||
client.priorProfit?.[acc.id] ?? 0, fundTxns, cfg.minDayPnL,
|
client.priorProfit?.[acc.id] ?? 0, fundTxns, cfg.minDayPnL,
|
||||||
|
stageInputsFor(client, acc.id, cfg),
|
||||||
) : null;
|
) : null;
|
||||||
// Days traded, for the master's per-day pass counts — the same
|
// Days traded, for the master's per-day pass counts — the same
|
||||||
// number as the local dashboard's Days Traded column
|
// number as the local dashboard's Days Traded column
|
||||||
@@ -207,6 +229,8 @@ function collectFirmStats() {
|
|||||||
fundTransactions: fundTxns,
|
fundTransactions: fundTxns,
|
||||||
balance: cash.amount,
|
balance: cash.amount,
|
||||||
realizedPnL: cash.realizedPnL,
|
realizedPnL: cash.realizedPnL,
|
||||||
|
rawTradeDate: rawToday,
|
||||||
|
stageInputs: stageInputsFor(client, acc.id, cfg),
|
||||||
payout: {
|
payout: {
|
||||||
withdrawalPct: cfg.withdrawalPct,
|
withdrawalPct: cfg.withdrawalPct,
|
||||||
payoutCap: cfg.payoutCap,
|
payoutCap: cfg.payoutCap,
|
||||||
@@ -246,7 +270,7 @@ function collectFirmStats() {
|
|||||||
maxDay: qualifying.length > 0 ? Math.max(...qualifying.map((d) => d.pnl)) : 0,
|
maxDay: qualifying.length > 0 ? Math.max(...qualifying.map((d) => d.pnl)) : 0,
|
||||||
sum,
|
sum,
|
||||||
high: highWater,
|
high: highWater,
|
||||||
withdrawals: fundTxns.filter((f) => f.amount < 0).length,
|
withdrawals: withdrawalsTaken,
|
||||||
target: effective.profitTarget,
|
target: effective.profitTarget,
|
||||||
consistency: effective.consistency,
|
consistency: effective.consistency,
|
||||||
minDays: effective.minTradingDays,
|
minDays: effective.minTradingDays,
|
||||||
@@ -270,6 +294,7 @@ function collectFirmStats() {
|
|||||||
const ended = endedCycleCompletion({
|
const ended = endedCycleCompletion({
|
||||||
accountId: acc.id,
|
accountId: acc.id,
|
||||||
rawTradeDate: rawToday,
|
rawTradeDate: rawToday,
|
||||||
|
client,
|
||||||
fullDailyPnL: client.fullDailyPnL[acc.id] ?? [],
|
fullDailyPnL: client.fullDailyPnL[acc.id] ?? [],
|
||||||
cycleDailyPnL: client.dailyPnL[acc.id],
|
cycleDailyPnL: client.dailyPnL[acc.id],
|
||||||
fundTransactions: fundTxns,
|
fundTransactions: fundTxns,
|
||||||
@@ -280,16 +305,47 @@ function collectFirmStats() {
|
|||||||
cfg,
|
cfg,
|
||||||
});
|
});
|
||||||
if (ended) {
|
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({
|
(slice.completedAccounts ??= []).push({
|
||||||
name: acc.name,
|
name: acc.name,
|
||||||
date: ended.date,
|
date: ended.date,
|
||||||
stage: ended.stage,
|
stage: place.stage,
|
||||||
kind: completionKind(cfg, ended.drain),
|
kind: completionKind(cfg, ended.drain),
|
||||||
cycle: ended.cycle,
|
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]) {
|
if (client.positions[acc.id]) {
|
||||||
inTrade++;
|
inTrade++;
|
||||||
accountsTraded++;
|
accountsTraded++;
|
||||||
@@ -306,6 +362,7 @@ function collectFirmStats() {
|
|||||||
const completion = cycleCompletion({
|
const completion = cycleCompletion({
|
||||||
accountId: acc.id,
|
accountId: acc.id,
|
||||||
rawTradeDate: rawToday,
|
rawTradeDate: rawToday,
|
||||||
|
client,
|
||||||
cycleDailyPnL: client.dailyPnL[acc.id],
|
cycleDailyPnL: client.dailyPnL[acc.id],
|
||||||
rawRealizedPnL: cash.realizedPnL,
|
rawRealizedPnL: cash.realizedPnL,
|
||||||
combineCmeHolidays: cfg.combineCmeHolidays,
|
combineCmeHolidays: cfg.combineCmeHolidays,
|
||||||
@@ -325,14 +382,18 @@ function collectFirmStats() {
|
|||||||
dayEntry.targetHit++;
|
dayEntry.targetHit++;
|
||||||
dayEntry.completed = (dayEntry.completed ?? 0) + 1;
|
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({
|
(slice.completedAccounts ??= []).push({
|
||||||
name: acc.name,
|
name: acc.name,
|
||||||
date: combined.tradeDate,
|
date: combined.tradeDate,
|
||||||
stage: 1 + fundTxns.filter((f) => f.amount < 0).length,
|
stage: place.stage,
|
||||||
kind: completionKind(cfg, effective.drain),
|
kind: completionKind(cfg, effective.drain),
|
||||||
// The fund transaction that started this cycle — the
|
cycle,
|
||||||
// catch-up names the same cycle by the same key.
|
...(place.cycleSeq > 0 ? { cycleSeq: place.cycleSeq } : {}),
|
||||||
cycle: client.lastFundDates?.[acc.id] ?? '',
|
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -372,11 +433,11 @@ function collectFirmStats() {
|
|||||||
// A grind-day fill (dayTarget 0) can flag targetHit on a
|
// A grind-day fill (dayTarget 0) can flag targetHit on a
|
||||||
// negative day — a loss is still a loss, so red wins.
|
// negative day — a loss is still a loss, so red wins.
|
||||||
// A red day on an account the bot puts stops on (the
|
// 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
|
// personal DLL, booked on purpose — not a genuine loss. (A
|
||||||
// firm DLL lockout took the !acc.active branch above and
|
// firm DLL lockout took the !acc.active branch above and
|
||||||
// stays a loss.)
|
// stays a loss.)
|
||||||
if (botStopsActive(cfg) && combined.realizedPnL <= -25) {
|
if (botStopsActive(cfg, effective) && combined.realizedPnL <= -25) {
|
||||||
personalDllCount++;
|
personalDllCount++;
|
||||||
slice.personalDll = (slice.personalDll ?? 0) + 1;
|
slice.personalDll = (slice.personalDll ?? 0) + 1;
|
||||||
} else if (combined.realizedPnL < 0) {
|
} else if (combined.realizedPnL < 0) {
|
||||||
@@ -394,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
|
// Biggest shortfall first — the account types with the most left to do
|
||||||
// are the ones worth reading, and Unassigned sinks to the bottom only
|
// are the ones worth reading, and Unassigned sinks to the bottom only
|
||||||
// when it is genuinely small.
|
// when it is genuinely small.
|
||||||
|
|||||||
@@ -61,3 +61,14 @@ export function topstepCommission(root: string | null, size: number): number {
|
|||||||
const perSide = (root !== null ? TOPSTEP_COMMISSION_PER_SIDE[root] : undefined) ?? TOPSTEP_COMMISSION_FALLBACK;
|
const perSide = (root !== null ? TOPSTEP_COMMISSION_PER_SIDE[root] : undefined) ?? TOPSTEP_COMMISSION_FALLBACK;
|
||||||
return Math.round(perSide * Math.abs(size || 0) * 100) / 100;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
+60
-2
@@ -27,9 +27,11 @@ import * as signalR from '@microsoft/signalr';
|
|||||||
import { currentTradeDateCT, FUND_EVENT_MIN, fundReconcileStep, type FundSighting } from './trading-logic';
|
import { currentTradeDateCT, FUND_EVENT_MIN, fundReconcileStep, type FundSighting } from './trading-logic';
|
||||||
import { getCachedContract } from './contract-resolver';
|
import { getCachedContract } from './contract-resolver';
|
||||||
import { signalrWebSocket } from './outbound-proxy';
|
import { signalrWebSocket } from './outbound-proxy';
|
||||||
import { saveDailyPnL, loadDailyPnL, loadAccountMeta, saveAccountMeta, loadFundTransactions, saveFundTransactions, saveEndedCycleStart, dropFundTransaction } from './db';
|
import { saveDailyPnL, loadDailyPnL, loadAccountMeta, saveAccountMeta, loadFundTransactions, saveFundTransactions, saveEndedCycleStart, dropFundTransaction, recordOwnOrder } from './db';
|
||||||
import { getAccountConfig } from './account-type-resolver';
|
import { getAccountConfig } from './account-type-resolver';
|
||||||
import { topstepxProductCode, topstepxRoot, topstepCommission, TOPSTEP_COMMISSION_FALLBACK } from './topstep-fees';
|
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 type { PlatformClient, PlatformAccount, PlatformContract, ClientHealth } from './platform-client';
|
||||||
import { OrderNotPlacedError, isOrderNotPlaced, errorMessage } from './platform-client';
|
import { OrderNotPlacedError, isOrderNotPlaced, errorMessage } from './platform-client';
|
||||||
|
|
||||||
@@ -844,6 +846,44 @@ export class TopstepXClient implements PlatformClient {
|
|||||||
return (t.fees ?? 0) + topstepCommission(root, t.size);
|
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
|
/** Product code → root, off the contracts resolved this run
|
||||||
* ("CON.F.US.GCE.Z26" resolved for GC → GCE is GC). */
|
* ("CON.F.US.GCE.Z26" resolved for GC → GCE is GC). */
|
||||||
private learnedProductRoots(): Map<string, string> {
|
private learnedProductRoots(): Map<string, string> {
|
||||||
@@ -900,12 +940,15 @@ export class TopstepXClient implements PlatformClient {
|
|||||||
for (const acc of this.accountList) {
|
for (const acc of this.accountList) {
|
||||||
const balance = (accRes.accounts ?? []).find((a) => a.id === acc.id)?.balance ?? 0;
|
const balance = (accRes.accounts ?? []).find((a) => a.id === acc.id)?.balance ?? 0;
|
||||||
let accountFresh = true;
|
let accountFresh = true;
|
||||||
|
/** This pass's open positions, for the trades record. */
|
||||||
|
let openNow: { contractId: string; type: number; size: number }[] | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const posRes = await this.post<{ positions: { accountId: number; contractId: string; type: number; size: number; averagePrice: number; creationTimestamp: string }[] }>(
|
const posRes = await this.post<{ positions: { accountId: number; contractId: string; type: number; size: number; averagePrice: number; creationTimestamp: string }[] }>(
|
||||||
'/api/Position/searchOpen', { accountId: acc.id });
|
'/api/Position/searchOpen', { accountId: acc.id });
|
||||||
if (superseded()) return;
|
if (superseded()) return;
|
||||||
const open = (posRes.positions ?? []).filter((p) => p.size > 0);
|
const open = (posRes.positions ?? []).filter((p) => p.size > 0);
|
||||||
|
openNow = open;
|
||||||
if (open.length > 0) {
|
if (open.length > 0) {
|
||||||
this.applyPosition(open[0]);
|
this.applyPosition(open[0]);
|
||||||
} else {
|
} else {
|
||||||
@@ -941,6 +984,7 @@ export class TopstepXClient implements PlatformClient {
|
|||||||
map.set(t.id, t);
|
map.set(t.id, t);
|
||||||
}
|
}
|
||||||
this.todayTrades[acc.id] = map;
|
this.todayTrades[acc.id] = map;
|
||||||
|
if (openNow !== null) this.recordTrades(acc, tradeRes.trades ?? [], openNow);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
accountFresh = false;
|
accountFresh = false;
|
||||||
console.error(`[topstepx] trade sync failed for ${acc.name}: ${errorMessage(err)}`);
|
console.error(`[topstepx] trade sync failed for ${acc.name}: ${errorMessage(err)}`);
|
||||||
@@ -1602,9 +1646,11 @@ export class TopstepXClient implements PlatformClient {
|
|||||||
trailPrice: null,
|
trailPrice: null,
|
||||||
customTag: null,
|
customTag: null,
|
||||||
});
|
});
|
||||||
|
this.noteOwnOrder(accountId, res.orderId, type);
|
||||||
return res.orderId;
|
return res.orderId;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof GatewayRefusedError) {
|
if (err instanceof GatewayRefusedError) {
|
||||||
|
if (err.orderId !== undefined) this.noteOwnOrder(accountId, err.orderId, type);
|
||||||
const ambiguous = err.orderId !== undefined
|
const ambiguous = err.orderId !== undefined
|
||||||
|| (err.errorCode !== undefined && AMBIGUOUS_PLACE_ERRORS.has(err.errorCode));
|
|| (err.errorCode !== undefined && AMBIGUOUS_PLACE_ERRORS.has(err.errorCode));
|
||||||
if (!ambiguous) throw new OrderNotPlacedError(`TopstepX refused the order: ${err.message}`);
|
if (!ambiguous) throw new OrderNotPlacedError(`TopstepX refused the order: ${err.message}`);
|
||||||
@@ -1614,6 +1660,7 @@ export class TopstepXClient implements PlatformClient {
|
|||||||
const status = response?.status;
|
const status = response?.status;
|
||||||
// Same rule as a refusal: an answer that names an order means it exists.
|
// 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;
|
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) {
|
if (typeof status === 'number' && REFUSED_BEFORE_HANDLER.has(status) && !namesOrder) {
|
||||||
throw new OrderNotPlacedError(`TopstepX rejected the order request: ${errorMessage(err)}`);
|
throw new OrderNotPlacedError(`TopstepX rejected the order request: ${errorMessage(err)}`);
|
||||||
}
|
}
|
||||||
@@ -1621,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
|
/** See PlatformClient: an account whose last order's outcome is unknown
|
||||||
* counts as holding a working order until a sync's Order/searchOpen shows
|
* counts as holding a working order until a sync's Order/searchOpen shows
|
||||||
* none. */
|
* none. */
|
||||||
|
|||||||
+2
-1
@@ -16,6 +16,7 @@ import { currentTradeDateCT } from './trading-logic';
|
|||||||
import { rootOfContract } from './contract-resolver';
|
import { rootOfContract } from './contract-resolver';
|
||||||
import { errorMessage } from './platform-client';
|
import { errorMessage } from './platform-client';
|
||||||
import type { InstrumentCount, TradeMix } from './master-protocol';
|
import type { InstrumentCount, TradeMix } from './master-protocol';
|
||||||
|
import { isNotionalPlatform } from './master-protocol';
|
||||||
|
|
||||||
const TODAY_KEY = 'trade_mix_today';
|
const TODAY_KEY = 'trade_mix_today';
|
||||||
const NAMES_KEY = 'trade_mix_contracts';
|
const NAMES_KEY = 'trade_mix_contracts';
|
||||||
@@ -78,7 +79,7 @@ export function tradeMixFor(
|
|||||||
const own = typeof client.contractRoots === 'function' ? (client.contractRoots() as Map<string, string>) : null;
|
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, root] of own ?? []) names.set(id, root);
|
||||||
for (const [id, instrument] of Object.entries(readJson<Record<string, string>>(NAMES_KEY) ?? {})) names.set(id, instrument);
|
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]
|
? String(contractId).split('|')[0]
|
||||||
: names.get(String(contractId)) ?? rootOfContract(contractId as number | string) ?? `#${String(contractId)}`);
|
: names.get(String(contractId)) ?? rootOfContract(contractId as number | string) ?? `#${String(contractId)}`);
|
||||||
|
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+378
-46
@@ -16,6 +16,8 @@ export interface WithdrawalStage {
|
|||||||
* fields fall back to the account type's defaults. */
|
* fields fall back to the account type's defaults. */
|
||||||
withdrawalPct?: number;
|
withdrawalPct?: number;
|
||||||
payoutCap?: number;
|
payoutCap?: number;
|
||||||
|
/** This stage's hard daily loss limit in dollars; absent = the type's. */
|
||||||
|
dailyLossLimit?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EffectiveConfig {
|
export interface EffectiveConfig {
|
||||||
@@ -24,6 +26,30 @@ export interface EffectiveConfig {
|
|||||||
minTradingDays: number;
|
minTradingDays: number;
|
||||||
minDayPnL: number;
|
minDayPnL: number;
|
||||||
drain: boolean;
|
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,
|
priorProfit: number,
|
||||||
fundTransactions: { date: string; amount: number }[],
|
fundTransactions: { date: string; amount: number }[],
|
||||||
minDayPnL = 0,
|
minDayPnL = 0,
|
||||||
|
inputs: StageInputs = {},
|
||||||
): EffectiveConfig {
|
): EffectiveConfig {
|
||||||
|
const dailyLossLimit = Math.max(0, inputs.dailyLossLimit ?? 0);
|
||||||
if (targetSameEquity) {
|
if (targetSameEquity) {
|
||||||
// Simple Mode A: effective target = profitTarget - equityProfit, computed in computeDailyTarget.
|
// Simple Mode A: effective target = profitTarget - equityProfit, computed in computeDailyTarget.
|
||||||
// Since computeDailyTarget already compares (amount - accountSize) against profitTarget,
|
// Since computeDailyTarget already compares (amount - accountSize) against profitTarget,
|
||||||
// no adjustment needed here — just return the base values.
|
// 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) {
|
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) {
|
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 idx = Math.min(withdrawalCount - 1, withdrawalStages.length - 1);
|
||||||
const stage = withdrawalStages[idx];
|
const stage = withdrawalStages[idx];
|
||||||
|
const stageLimit = typeof stage.dailyLossLimit === 'number' && stage.dailyLossLimit >= 0 ? stage.dailyLossLimit : dailyLossLimit;
|
||||||
if (stage.drain === true) {
|
if (stage.drain === true) {
|
||||||
return {
|
return {
|
||||||
profitTarget: 0,
|
profitTarget: 0,
|
||||||
@@ -73,12 +105,16 @@ export function resolveEffectiveConfig(
|
|||||||
// qualifying bar sits at the grid floor — every draw counts.
|
// qualifying bar sits at the grid floor — every draw counts.
|
||||||
minDayPnL: DRAIN_DAY_MIN,
|
minDayPnL: DRAIN_DAY_MIN,
|
||||||
drain: true,
|
drain: true,
|
||||||
|
dailyLossLimit: stageLimit,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
// Fall back to base minTradingDays when the stage doesn't specify one (0 or missing)
|
// 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 */
|
/** @deprecated Use resolveEffectiveConfig instead */
|
||||||
@@ -140,6 +176,18 @@ export function currentTradeDateCT(now: Date = new Date()): string {
|
|||||||
return new Date(date).toISOString().slice(0, 10);
|
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
|
/** 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 ≥
|
* per-contract tick), always traded on MNQ. Drain days qualify at ≥
|
||||||
* DRAIN_DAY_MIN, so every draw counts. Seeded per (account, trade date) like
|
* 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 }[];
|
fundTransactions: { date: string; amount: number }[];
|
||||||
balance: number;
|
balance: number;
|
||||||
realizedPnL: 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. */
|
/** Trading days to look ahead. */
|
||||||
horizonDays?: number;
|
horizonDays?: number;
|
||||||
maxWithdrawals?: number;
|
maxWithdrawals?: number;
|
||||||
@@ -438,6 +491,9 @@ export function projectPerfectPath(opts: {
|
|||||||
};
|
};
|
||||||
/** Defaults to the current time. */
|
/** Defaults to the current time. */
|
||||||
now?: Date;
|
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[] {
|
}): ProjectedDay[] {
|
||||||
const { accountId, cfg } = opts;
|
const { accountId, cfg } = opts;
|
||||||
const horizon = opts.horizonDays ?? 40;
|
const horizon = opts.horizonDays ?? 40;
|
||||||
@@ -448,7 +504,7 @@ export function projectPerfectPath(opts: {
|
|||||||
const dd = Math.max(0, opts.payout?.drawdown ?? 0);
|
const dd = Math.max(0, opts.payout?.drawdown ?? 0);
|
||||||
const everyDay = cfg.tradesEveryDay === true;
|
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
|
// The firm's own day view: a combining firm folds holiday-session P&L
|
||||||
// into its clearing date — a Labor-Day trade and the Tuesday after it are
|
// into its clearing date — a Labor-Day trade and the Tuesday after it are
|
||||||
// ONE trading day, exactly as the firm counts min days. Identity view
|
// ONE trading day, exactly as the firm counts min days. Identity view
|
||||||
@@ -503,17 +559,38 @@ export function projectPerfectPath(opts: {
|
|||||||
return room / (room + dayTarget);
|
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.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity,
|
||||||
cfg.withdrawalStages, 0, funds, cfg.minDayPnL,
|
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. */
|
* overrides when set, else the type defaults. */
|
||||||
const stagePayout = (): { pct: number; cap: number } => {
|
const stagePayout = (stage: number): { pct: number; cap: number } => {
|
||||||
const count = funds.filter((f) => f.amount < 0).length;
|
const count = stage - 1;
|
||||||
if (count === 0 || cfg.withdrawalStages.length === 0) return { pct: basePct, cap: baseCap };
|
if (count === 0 || cfg.withdrawalStages.length === 0) return { pct: basePct, cap: baseCap };
|
||||||
const stage = cfg.withdrawalStages[Math.min(count - 1, cfg.withdrawalStages.length - 1)];
|
const s = cfg.withdrawalStages[Math.min(count - 1, cfg.withdrawalStages.length - 1)];
|
||||||
return { pct: stage.withdrawalPct ?? basePct, cap: stage.payoutCap ?? baseCap };
|
return { pct: s.withdrawalPct ?? basePct, cap: s.payoutCap ?? baseCap };
|
||||||
};
|
};
|
||||||
const qualifyingDays = (minDay: number, excludeDate: string) => (
|
const qualifyingDays = (minDay: number, excludeDate: string) => (
|
||||||
minDay > 0
|
minDay > 0
|
||||||
@@ -525,7 +602,7 @@ export function projectPerfectPath(opts: {
|
|||||||
// Skipped when "today" is a weekend date (Saturday, or Friday evening
|
// Skipped when "today" is a weekend date (Saturday, or Friday evening
|
||||||
// after the roll): there is no session to finish.
|
// after the roll): there is no session to finish.
|
||||||
if (todayHasSession) {
|
if (todayHasSession) {
|
||||||
const eff = effectiveNow();
|
const eff = effectiveOn(today);
|
||||||
const plan = computeDayPlan(
|
const plan = computeDayPlan(
|
||||||
accountId, today, eff.profitTarget, eff.consistency, sim, eff.minDayPnL,
|
accountId, today, eff.profitTarget, eff.consistency, sim, eff.minDayPnL,
|
||||||
eff.minTradingDays, balance - cfg.accountSize, opts.realizedPnL, cfg.splitEnabled, eff.drain,
|
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.
|
// over and over. Only a non-payoutable eval stops after one completion.
|
||||||
const loops = cfg.withdrawalStages.length > 0 || cfg.payoutable === true;
|
const loops = cfg.withdrawalStages.length > 0 || cfg.payoutable === true;
|
||||||
const finishCycle = (onDate: string, dayPnl: number, stage: number, drain: boolean): boolean => {
|
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 rawGross = round2(Math.max(0, balance - cfg.accountSize) * pct);
|
||||||
const gross = cap > 0 ? Math.min(rawGross, round2(cap)) : rawGross;
|
const gross = cap > 0 ? Math.min(rawGross, round2(cap)) : rawGross;
|
||||||
const net = round2(gross * profitSplit);
|
const net = round2(gross * profitSplit);
|
||||||
@@ -563,6 +640,9 @@ export function projectPerfectPath(opts: {
|
|||||||
balance = round2(balance - gross);
|
balance = round2(balance - gross);
|
||||||
sim = []; // withdrawal excludes its day — fresh cycle
|
sim = []; // withdrawal excludes its day — fresh cycle
|
||||||
withdrawals++;
|
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
|
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 });
|
out.push({ date: onDate, pnl: dayPnl, stage, drain, withdrawal: -gross, prob, gross, net });
|
||||||
return false;
|
return false;
|
||||||
@@ -576,14 +656,14 @@ export function projectPerfectPath(opts: {
|
|||||||
// requestable after that session's close. Never "today" — a projection
|
// requestable after that session's close. Never "today" — a projection
|
||||||
// computed over the weekend must not slide a Friday finish to Sunday.
|
// computed over the weekend must not slide a Friday finish to Sunday.
|
||||||
{
|
{
|
||||||
const eff = effectiveNow();
|
const eff = effectiveOn(today);
|
||||||
const probe = nextDay(today);
|
const probe = nextDay(today);
|
||||||
const plan = computeDayPlan(
|
const plan = computeDayPlan(
|
||||||
accountId, probe, eff.profitTarget, eff.consistency, sim, eff.minDayPnL,
|
accountId, probe, eff.profitTarget, eff.consistency, sim, eff.minDayPnL,
|
||||||
eff.minTradingDays, balance - cfg.accountSize, 0, cfg.splitEnabled, eff.drain,
|
eff.minTradingDays, balance - cfg.accountSize, 0, cfg.splitEnabled, eff.drain,
|
||||||
);
|
);
|
||||||
if (plan && plan.dayTarget <= 0 && eff.minTradingDays <= qualifyingDays(eff.minDayPnL, probe)) {
|
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 traded = sim.filter((d) => d.pnl !== 0);
|
||||||
const doneOn = traded.length > 0
|
const doneOn = traded.length > 0
|
||||||
? traded.reduce((a, b) => (a.date > b.date ? a : b)).date
|
? 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;
|
let date = today;
|
||||||
for (let step = 0; step < horizon; step++) {
|
let again = todayHasSession && tradesAgain(today);
|
||||||
date = nextDay(date);
|
for (let step = 0; again || step < horizon;) {
|
||||||
const eff = effectiveNow();
|
if (again) again = false;
|
||||||
const stage = 1 + funds.filter((f) => f.amount < 0).length;
|
else { date = nextDay(date); step++; }
|
||||||
|
const eff = effectiveOn(date);
|
||||||
|
const stage = 1 + paidBefore(date);
|
||||||
const plan = computeDayPlan(
|
const plan = computeDayPlan(
|
||||||
accountId, date, eff.profitTarget, eff.consistency, sim, eff.minDayPnL,
|
accountId, date, eff.profitTarget, eff.consistency, sim, eff.minDayPnL,
|
||||||
eff.minTradingDays, balance - cfg.accountSize, 0, cfg.splitEnabled, eff.drain,
|
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 cycle completed mid-projection with nothing left to trade —
|
||||||
// the pending payout lands on this day.
|
// the pending payout lands on this day.
|
||||||
if (finishCycle(date, 0, stage, eff.drain)) break;
|
if (finishCycle(date, 0, stage, eff.drain)) break;
|
||||||
|
again = tradesAgain(date);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -622,7 +707,7 @@ export function projectPerfectPath(opts: {
|
|||||||
|
|
||||||
// Did this day complete the cycle? Then it IS the payout day.
|
// Did this day complete the cycle? Then it IS the payout day.
|
||||||
const nd = nextDay(date);
|
const nd = nextDay(date);
|
||||||
const eff2 = effectiveNow();
|
const eff2 = effectiveOn(date);
|
||||||
const plan2 = computeDayPlan(
|
const plan2 = computeDayPlan(
|
||||||
accountId, nd, eff2.profitTarget, eff2.consistency, sim, eff2.minDayPnL,
|
accountId, nd, eff2.profitTarget, eff2.consistency, sim, eff2.minDayPnL,
|
||||||
eff2.minTradingDays, balance - cfg.accountSize, 0, cfg.splitEnabled, eff2.drain,
|
eff2.minTradingDays, balance - cfg.accountSize, 0, cfg.splitEnabled, eff2.drain,
|
||||||
@@ -632,6 +717,7 @@ export function projectPerfectPath(opts: {
|
|||||||
&& eff2.minTradingDays <= qualifyingDays(eff2.minDayPnL, nd);
|
&& eff2.minTradingDays <= qualifyingDays(eff2.minDayPnL, nd);
|
||||||
if (doneAfter) {
|
if (doneAfter) {
|
||||||
if (finishCycle(date, pnl, stage, eff.drain)) break;
|
if (finishCycle(date, pnl, stage, eff.drain)) break;
|
||||||
|
again = tradesAgain(date);
|
||||||
} else {
|
} else {
|
||||||
out.push({ date, pnl, stage, drain: eff.drain, prob });
|
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
|
/** 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
|
* 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
|
* TopstepX 60s sync, Vest's next sync after 20:00 ET), not at the roll
|
||||||
* number — already booked under its own date — would count as a second day. */
|
* 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;
|
const ROLL_SETTLE_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
/** How long after a fund transaction starts a new cycle the cycle it ended is
|
/** How long after a fund transaction starts a new cycle the cycle it ended is
|
||||||
* still checked for a missed completion. */
|
* still checked for a missed completion. */
|
||||||
const ENDED_CYCLE_WINDOW_DAYS = 7;
|
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
|
/** 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. */
|
* plan coasts at 0 while days are still owed, so count them too. */
|
||||||
function cycleOwesNothing(
|
function cycleOwesNothing(
|
||||||
@@ -679,13 +773,14 @@ function cycleOwesNothing(
|
|||||||
* weekend (it is the last session's stale number, already booked under its
|
* weekend (it is the last session's stale number, already booked under its
|
||||||
* own date), a Monday with a weekend-dated row — the live session filed under
|
* own date), a Monday with a weekend-dated row — the live session filed under
|
||||||
* a weekend date, which would count it twice — and the first minutes after
|
* a weekend date, which would count it twice — and the first minutes after
|
||||||
* the 5 PM roll. An every-day firm's weekends are real sessions, so only the
|
* the roll: the client's own (tradeDateFor), else 5 PM CT. An every-day
|
||||||
* roll window applies to it. */
|
* firm's weekends are real sessions, so only the roll window applies to it. */
|
||||||
function sessionDoubtful(
|
function sessionDoubtful(
|
||||||
rawTradeDate: string,
|
rawTradeDate: string,
|
||||||
cycleDailyPnL: { date: string; pnl: number }[],
|
cycleDailyPnL: { date: string; pnl: number }[],
|
||||||
now: Date,
|
now: Date,
|
||||||
everyDay = false,
|
everyDay = false,
|
||||||
|
client?: { tradeDate?: (now: number) => string },
|
||||||
): boolean {
|
): boolean {
|
||||||
const day = new Date(`${rawTradeDate}T00:00:00Z`);
|
const day = new Date(`${rawTradeDate}T00:00:00Z`);
|
||||||
const dow = day.getUTCDay();
|
const dow = day.getUTCDay();
|
||||||
@@ -696,7 +791,7 @@ function sessionDoubtful(
|
|||||||
if (cycleDailyPnL.some((d) => d.date === saturday || d.date === sunday)) return true;
|
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.
|
// 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,
|
* '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
|
* 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
|
* 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.
|
* keeps a locked, untraded pass out of the reporter's losses.
|
||||||
*/
|
*/
|
||||||
export function cycleCompletion(opts: {
|
export function cycleCompletion(opts: {
|
||||||
accountId: number;
|
accountId: number;
|
||||||
/** currentTradeDateCT() — NOT the combined date, which rolls a weekend
|
/** tradeDateFor(client) — NOT the combined date, which rolls a weekend
|
||||||
* forward to Monday. */
|
* 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;
|
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). */
|
/** Current-cycle daily P&L as the client holds it (client.dailyPnL). */
|
||||||
cycleDailyPnL: { date: string; pnl: number }[];
|
cycleDailyPnL: { date: string; pnl: number }[];
|
||||||
rawRealizedPnL: number;
|
rawRealizedPnL: number;
|
||||||
@@ -739,7 +838,7 @@ export function cycleCompletion(opts: {
|
|||||||
const prior = view.dailyPnL.filter((d) => d.date !== view.tradeDate);
|
const prior = view.dailyPnL.filter((d) => d.date !== view.tradeDate);
|
||||||
|
|
||||||
if (cycleOwesNothing(accountId, eff, prior, view.tradeDate, equityProfit, view.realizedPnL, splitEnabled)) return 'before';
|
if (cycleOwesNothing(accountId, eff, prior, view.tradeDate, equityProfit, view.realizedPnL, splitEnabled)) return 'before';
|
||||||
if (sessionDoubtful(rawTradeDate, opts.cycleDailyPnL, opts.now ?? new Date(), 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 withToday = [...prior, { date: view.tradeDate, pnl: view.realizedPnL }];
|
||||||
const probe = nextTradingDate(view.tradeDate, everyDay);
|
const probe = nextTradingDate(view.tradeDate, everyDay);
|
||||||
@@ -747,6 +846,32 @@ export function cycleCompletion(opts: {
|
|||||||
return 'none';
|
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
|
* 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
|
* just ended stand complete — profit target, consistency and minimum days all
|
||||||
@@ -774,8 +899,10 @@ export function cycleCompletion(opts: {
|
|||||||
*/
|
*/
|
||||||
export function endedCycleCompletion(opts: {
|
export function endedCycleCompletion(opts: {
|
||||||
accountId: number;
|
accountId: number;
|
||||||
/** currentTradeDateCT(). */
|
/** tradeDateFor(client). */
|
||||||
rawTradeDate: string;
|
rawTradeDate: string;
|
||||||
|
/** As in cycleCompletion. */
|
||||||
|
client?: { tradeDate?: (now: number) => string };
|
||||||
/** Every cycle's daily P&L, raw (client.fullDailyPnL). */
|
/** Every cycle's daily P&L, raw (client.fullDailyPnL). */
|
||||||
fullDailyPnL: { date: string; pnl: number }[];
|
fullDailyPnL: { date: string; pnl: number }[];
|
||||||
/** Current-cycle daily P&L, raw (client.dailyPnL). */
|
/** Current-cycle daily P&L, raw (client.dailyPnL). */
|
||||||
@@ -794,6 +921,8 @@ export function endedCycleCompletion(opts: {
|
|||||||
targetSameEquity: boolean; withdrawalStages: WithdrawalStage[];
|
targetSameEquity: boolean; withdrawalStages: WithdrawalStage[];
|
||||||
combineCmeHolidays: boolean; splitEnabled: boolean;
|
combineCmeHolidays: boolean; splitEnabled: boolean;
|
||||||
tradesEveryDay?: boolean;
|
tradesEveryDay?: boolean;
|
||||||
|
/** Stages reset each day (StageInputs). */
|
||||||
|
stagesSameDay?: boolean;
|
||||||
};
|
};
|
||||||
/** Defaults to the current time. */
|
/** Defaults to the current time. */
|
||||||
now?: Date;
|
now?: Date;
|
||||||
@@ -802,9 +931,8 @@ export function endedCycleCompletion(opts: {
|
|||||||
const everyDay = cfg.tradesEveryDay === true;
|
const everyDay = cfg.tradesEveryDay === true;
|
||||||
const combine = effectiveCombine(cfg);
|
const combine = effectiveCombine(cfg);
|
||||||
if (lastFundDate === null || start === null) return null;
|
if (lastFundDate === null || start === null) return null;
|
||||||
const ageDays = (Date.parse(`${rawTradeDate}T00:00:00Z`) - Date.parse(`${lastFundDate}T00:00:00Z`)) / (24 * 60 * 60 * 1000);
|
if (!withinEndedCycleWindow(lastFundDate, rawTradeDate)) return null;
|
||||||
if (!(ageDays <= ENDED_CYCLE_WINDOW_DAYS)) return null; // NaN included: an unparseable date
|
if (sessionDoubtful(rawTradeDate, opts.cycleDailyPnL, opts.now ?? new Date(), everyDay, opts.client)) return null;
|
||||||
if (sessionDoubtful(rawTradeDate, opts.cycleDailyPnL, opts.now ?? new Date(), everyDay)) return null;
|
|
||||||
|
|
||||||
const latest = (a: string, b: string) => (b > a ? b : a);
|
const latest = (a: string, b: string) => (b > a ? b : a);
|
||||||
const earlierFunds = fundTransactions.filter((f) => f.date < lastFundDate);
|
const earlierFunds = fundTransactions.filter((f) => f.date < lastFundDate);
|
||||||
@@ -827,10 +955,14 @@ export function endedCycleCompletion(opts: {
|
|||||||
const fundAmt = fundTransactions.filter((f) => f.date === lastFundDate).reduce((s, f) => s + f.amount, 0);
|
const fundAmt = fundTransactions.filter((f) => f.date === lastFundDate).reduce((s, f) => s + f.amount, 0);
|
||||||
const equityBefore = round2(opts.equityProfit - curTotal - fundAmt);
|
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(
|
const eff = resolveEffectiveConfig(
|
||||||
cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity,
|
cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity,
|
||||||
cfg.withdrawalStages, 0, earlierFunds, cfg.minDayPnL,
|
cfg.withdrawalStages, 0, earlierFunds, cfg.minDayPnL,
|
||||||
|
cfg.stagesSameDay === true ? { stagesSameDay: true, sameDayWithdrawals: 0 } : {},
|
||||||
);
|
);
|
||||||
const rows = combineCmeSessions(combine, endedRaw, lastFundDate, 0).dailyPnL;
|
const rows = combineCmeSessions(combine, endedRaw, lastFundDate, 0).dailyPnL;
|
||||||
// A combining firm's folded day can net to 0 — nothing traded, as the firm counts it.
|
// A combining firm's folded day can net to 0 — nothing traded, as the firm counts it.
|
||||||
@@ -841,12 +973,83 @@ export function endedCycleCompletion(opts: {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
date: traded.map((d) => d.date).reduce(latest),
|
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,
|
stage: 1 + earlierFunds.filter((f) => f.amount < 0).length,
|
||||||
drain: eff.drain,
|
drain: eff.drain,
|
||||||
cycle: prevFundDate ?? '',
|
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.
|
* Compute the next trading day's profit target for an account.
|
||||||
*
|
*
|
||||||
@@ -1302,18 +1505,119 @@ export function obfuscationStopDollars(
|
|||||||
* that fills worse than its price (gaps, thin weekend books). */
|
* that fills worse than its price (gaps, thin weekend books). */
|
||||||
export const DLL_RESERVE_FRACTION = 0.10;
|
export const DLL_RESERVE_FRACTION = 0.10;
|
||||||
/** …or this share of the position's notional, when that is more: a stop's
|
/** …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;
|
export const DLL_SLIPPAGE_FRACTION = 0.001;
|
||||||
|
|
||||||
/** The part of the day's room a stop leaves unspent. */
|
/** The part of the day's room a stop leaves unspent. */
|
||||||
export function dllReserve(dailyLossLimit: number, notional = 0): number {
|
export function dllReserve(dailyLossLimit: number, notional = 0, slippageFraction = DLL_SLIPPAGE_FRACTION): number {
|
||||||
return Math.max(DLL_RESERVE_FRACTION * dailyLossLimit, DLL_SLIPPAGE_FRACTION * Math.max(0, notional));
|
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
|
/** 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
|
* 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. */
|
* anything tighter just spends the day's room on fees. */
|
||||||
export const DLL_MIN_STOP = 50;
|
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.
|
* How much the firm's day may still lose before a hard daily-loss breach.
|
||||||
* Infinity when the account type has no limit.
|
* Infinity when the account type has no limit.
|
||||||
@@ -1351,10 +1655,10 @@ export function dllRoom(o: {
|
|||||||
* reset has its entry fee on the old day's books, and the swap takes no more
|
* 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.
|
* 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;
|
if (!Number.isFinite(o.room)) return Infinity;
|
||||||
const r = Math.max(0, o.commissionPct) / 100;
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1386,7 +1690,9 @@ export const LAST_STAND_FRACTION = 0.15;
|
|||||||
*/
|
*/
|
||||||
export function lastStand(o: { dllRoom: number; dailyLossLimit: number; maxLossRoom: number | null }): boolean {
|
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;
|
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
|
/** On a last stand: how far price can move against the leg before the firm's
|
||||||
@@ -1429,10 +1735,34 @@ export function stopTicks(o: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Whether the bot places its own stops for this account type — the
|
/** 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
|
* obfuscation options or a hard daily loss limit: the current stage's
|
||||||
* account is the bot's own stop (the personal DLL), not a firm lockout. */
|
* (resolveEffectiveConfig) when given, which a stage may set on a type with
|
||||||
export function botStopsActive(cfg: { firstDayStop: boolean; minDayStop: boolean; dailyLossLimit?: number }): boolean {
|
* none. A red day on such an account is the bot's own stop (the personal
|
||||||
return cfg.firstDayStop || cfg.minDayStop || (cfg.dailyLossLimit ?? 0) > 0;
|
* 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 ────────────────────────────────────────────────────────────
|
// ── position size ────────────────────────────────────────────────────────────
|
||||||
@@ -1641,6 +1971,8 @@ export function planLegSize(o: {
|
|||||||
/** Infinity (or ≤ 0) = no obfuscation stop. */
|
/** Infinity (or ≤ 0) = no obfuscation stop. */
|
||||||
obfuscationDollars: number;
|
obfuscationDollars: number;
|
||||||
availableFunds?: number;
|
availableFunds?: number;
|
||||||
|
/** The platform's stopSlippageFraction; default DLL_SLIPPAGE_FRACTION. */
|
||||||
|
slippageFraction?: number;
|
||||||
}): {
|
}): {
|
||||||
lastStand: boolean;
|
lastStand: boolean;
|
||||||
cap: number;
|
cap: number;
|
||||||
@@ -1656,7 +1988,7 @@ export function planLegSize(o: {
|
|||||||
obf,
|
obf,
|
||||||
!dllOn ? Infinity
|
!dllOn ? Infinity
|
||||||
: stand ? breachDistance({ dllRoom: o.dllRoom, maxLossRoom: o.maxLossRoom, entryFee: r * notional })
|
: 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 cap = notionalCap({ marginRate: o.contract.marginRate, availableFunds: o.availableFunds });
|
||||||
const step = o.contract.stepNotional ?? 0;
|
const step = o.contract.stepNotional ?? 0;
|
||||||
|
|||||||
+141
-1
@@ -6,7 +6,9 @@ import { computeSec, randomUUIDV4 } from './tradovate-helpers';
|
|||||||
import { currentTradeDateCT } from './trading-logic';
|
import { currentTradeDateCT } from './trading-logic';
|
||||||
import { POINT_VALUES } from './instrument-specs';
|
import { POINT_VALUES } from './instrument-specs';
|
||||||
import { getCachedContract, resolveContracts } from './contract-resolver';
|
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 { brokerWebSocket } from './outbound-proxy';
|
||||||
import { FILLS_FALLBACK_NOTE, OrderNotPlacedError, SYNC_STALE_MS, errorMessage, type ClientHealth } from './platform-client';
|
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. */
|
* a payout is hundreds. */
|
||||||
const FUND_DRIFT_TOLERANCE = 25;
|
const FUND_DRIFT_TOLERANCE = 25;
|
||||||
const CONTRACT_FIND_TIMEOUT_MS = 15_000;
|
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). */
|
/** On-demand front-month resolve (several Yahoo lookups, then Tradovate). */
|
||||||
const CONTRACT_RESOLVE_DEADLINE_MS = 30_000;
|
const CONTRACT_RESOLVE_DEADLINE_MS = 30_000;
|
||||||
const READY_STATE_NAMES = ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED'];
|
const READY_STATE_NAMES = ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED'];
|
||||||
@@ -371,6 +375,13 @@ export class TradovateClient {
|
|||||||
* outcome clears it. */
|
* outcome clears it. */
|
||||||
private cashHistoryRefusedSince = new Map<number, number>();
|
private cashHistoryRefusedSince = new Map<number, number>();
|
||||||
/** Interval handles tracked so they can be cleared on reconnect */
|
/** 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 syncInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
private heartbeatInterval: ReturnType<typeof setInterval> | null = null;
|
private heartbeatInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
private tokenRenewalInterval: 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 } }
|
{} as { [accountId: number]: { contractId: number; netPos: number; netPrice: number; timestamp: Date } }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.recordTrades(response);
|
||||||
|
|
||||||
this.lastSyncAt = Date.now();
|
this.lastSyncAt = Date.now();
|
||||||
this.lastSyncStartedAt = requestedAt || this.lastSyncAt;
|
this.lastSyncStartedAt = requestedAt || this.lastSyncAt;
|
||||||
// A session counts as working once it has synced (abandonSocket).
|
// A session counts as working once it has synced (abandonSocket).
|
||||||
@@ -2028,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. */
|
/** 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 } {
|
private static acceptedAck(response: unknown, endpoint: string): { orderId?: number; id?: number; commandStatus?: string } {
|
||||||
if (typeof response === 'string') throw new OrderNotPlacedError(response || `${endpoint} refused`);
|
if (typeof response === 'string') throw new OrderNotPlacedError(response || `${endpoint} refused`);
|
||||||
@@ -2148,6 +2283,7 @@ export class TradovateClient {
|
|||||||
const ack = TradovateClient.acceptedAck(response, 'order/placeorder');
|
const ack = TradovateClient.acceptedAck(response, 'order/placeorder');
|
||||||
// Tradovate returns the command object: { id, commandStatus, orderId, ... }
|
// Tradovate returns the command object: { id, commandStatus, orderId, ... }
|
||||||
const orderId = ack.orderId ?? ack.id;
|
const orderId = ack.orderId ?? ack.id;
|
||||||
|
this.noteOwnOrder(accountId, orderId, orderType === 'Limit' ? 'limit' : 'market');
|
||||||
console.log(`${this.tag} sendOrder orderId=${orderId} status=${ack.commandStatus}`);
|
console.log(`${this.tag} sendOrder orderId=${orderId} status=${ack.commandStatus}`);
|
||||||
|
|
||||||
// Wait for the fill event — it carries the real execution price
|
// Wait for the fill event — it carries the real execution price
|
||||||
@@ -2180,6 +2316,7 @@ export class TradovateClient {
|
|||||||
ACK_TIMEOUT_MS,
|
ACK_TIMEOUT_MS,
|
||||||
(response) => {
|
(response) => {
|
||||||
const ack = TradovateClient.acceptedAck(response, 'order/placeorder');
|
const ack = TradovateClient.acceptedAck(response, 'order/placeorder');
|
||||||
|
this.noteOwnOrder(accountId, ack.orderId ?? ack.id, orderType === 'Limit' ? 'limit' : 'market');
|
||||||
return { orderId: ack.orderId ?? ack.id };
|
return { orderId: ack.orderId ?? ack.id };
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -2219,6 +2356,9 @@ export class TradovateClient {
|
|||||||
ACK_TIMEOUT_MS,
|
ACK_TIMEOUT_MS,
|
||||||
(response) => {
|
(response) => {
|
||||||
const ack = TradovateClient.acceptedAck(response, 'order/placeoco');
|
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 };
|
return { orderId: ack.orderId ?? ack.id };
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
+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",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
"check": "node --experimental-strip-types --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --test scripts/check-trading-logic.mjs scripts/check-instruments.mjs scripts/check-account-status.mjs scripts/check-kept-rules.mjs scripts/check-request-user-agent.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": {
|
"dependencies": {
|
||||||
"@microsoft/signalr": "^10.0.11",
|
"@microsoft/signalr": "^10.0.11",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -19,7 +19,7 @@ const LIVE = {
|
|||||||
targetSameEquity: false, splitEnabled: true, minLegs: 2, firstDayStop: true, minDayStop: false, combineCmeHolidays: false,
|
targetSameEquity: false, splitEnabled: true, minLegs: 2, firstDayStop: true, minDayStop: false, combineCmeHolidays: false,
|
||||||
withdrawalStages: [{ profit: 2000, consistency: 0.4, minTradingDays: 5, drain: false, minDayPnL: 0 }],
|
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,
|
payoutable: true, trailingDrawdown: 2000, expectedPayout: 1000, withdrawalPct: 0.5, payoutCap: 3000, profitSplit: 0.9,
|
||||||
dailyLossLimit: 1000, tradesEveryDay: false, updatedAt: '2026-10-18T14:05:00.000Z',
|
dailyLossLimit: 1000, stagesSameDay: false, tradesEveryDay: false, updatedAt: '2026-10-18T14:05:00.000Z',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** `obj` without `keys`. */
|
/** `obj` without `keys`. */
|
||||||
@@ -39,7 +39,7 @@ test('field classes: the spec\'s lists, identical to the master\'s, and disjoint
|
|||||||
assert.deepEqual([...RULE_FIELDS], [
|
assert.deepEqual([...RULE_FIELDS], [
|
||||||
'profitTarget', 'consistency', 'minDayPnL', 'minTradingDays', 'maxLoss', 'maxPositionSize', 'targetSameEquity',
|
'profitTarget', 'consistency', 'minDayPnL', 'minTradingDays', 'maxLoss', 'maxPositionSize', 'targetSameEquity',
|
||||||
'splitEnabled', 'minLegs', 'firstDayStop', 'minDayStop', 'combineCmeHolidays', 'withdrawalStages', 'payoutable',
|
'splitEnabled', 'minLegs', 'firstDayStop', 'minDayStop', 'combineCmeHolidays', 'withdrawalStages', 'payoutable',
|
||||||
'trailingDrawdown', 'dailyLossLimit',
|
'trailingDrawdown', 'dailyLossLimit', 'stagesSameDay',
|
||||||
]);
|
]);
|
||||||
assert.deepEqual([...IDENTITY_FIELDS], ['prefix', 'accountSize']);
|
assert.deepEqual([...IDENTITY_FIELDS], ['prefix', 'accountSize']);
|
||||||
assert.deepEqual([...PROJECTION_FIELDS], ['expectedPayout', 'withdrawalPct', 'payoutCap', 'profitSplit']);
|
assert.deepEqual([...PROJECTION_FIELDS], ['expectedPayout', 'withdrawalPct', 'payoutCap', 'profitSplit']);
|
||||||
@@ -82,6 +82,7 @@ function legacyNormalize(t, j, tradesEveryDay) {
|
|||||||
firstDayStop: type.firstDayStop === true,
|
firstDayStop: type.firstDayStop === true,
|
||||||
minDayStop: type.minDayStop === true,
|
minDayStop: type.minDayStop === true,
|
||||||
dailyLossLimit: nonNegative(type.dailyLossLimit),
|
dailyLossLimit: nonNegative(type.dailyLossLimit),
|
||||||
|
stagesSameDay: type.stagesSameDay === true,
|
||||||
tradesEveryDay,
|
tradesEveryDay,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -174,7 +175,7 @@ test('overlayKept: the kept rules on top; identity, label, projections and bookk
|
|||||||
const oldRules = {
|
const oldRules = {
|
||||||
profitTarget: 3000, consistency: 0.5, minDayPnL: 200, minTradingDays: 5, maxLoss: 2500, maxPositionSize: 3,
|
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,
|
targetSameEquity: true, splitEnabled: false, minLegs: 0, firstDayStop: false, minDayStop: true, combineCmeHolidays: true,
|
||||||
withdrawalStages: [], payoutable: false, trailingDrawdown: 2500, dailyLossLimit: 0,
|
withdrawalStages: [], payoutable: false, trailingDrawdown: 2500, dailyLossLimit: 0, stagesSameDay: false,
|
||||||
};
|
};
|
||||||
// A master that sent other identity/projection values on the kept set:
|
// A master that sent other identity/projection values on the kept set:
|
||||||
// they must not leak through.
|
// they must not leak through.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -11,9 +11,11 @@ import assert from 'node:assert/strict';
|
|||||||
import {
|
import {
|
||||||
percentFee, takeProfitPlan, onTick, obfuscationStopDollars, dllRoom, dllStopDollars, dllViable, stopTicks,
|
percentFee, takeProfitPlan, onTick, obfuscationStopDollars, dllRoom, dllStopDollars, dllViable, stopTicks,
|
||||||
botStopsActive, sizeNotional, isNoTradeTime, isPastSessionClose, nextTradingDate, effectiveCombine, DEFAULT_TARGET_HOLD_HOURS,
|
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,
|
dllReserve, DLL_SLIPPAGE_FRACTION, maxLossRoom, lastStand, breachDistance, LAST_STAND_FRACTION, planLegSize, rankForTarget,
|
||||||
currentTradeDateCT, foldReason, withinFirmCaps, personSizeFactor, personHoldHours, PERSON_SIZE_SPREAD,
|
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';
|
} from '../lib/trading-logic.ts';
|
||||||
|
|
||||||
// Deterministic PRNG so a failure reproduces.
|
// Deterministic PRNG so a failure reproduces.
|
||||||
@@ -304,6 +306,22 @@ test('dllViable and botStopsActive', () => {
|
|||||||
assert.equal(botStopsActive({ firstDayStop: true, minDayStop: false, dailyLossLimit: 0 }), true);
|
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 ───────────────────────────────────────────────────────────────────
|
// ── sizing ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const btcSigma = 0.03 / Math.sqrt(24); // 3%/day
|
const btcSigma = 0.03 / Math.sqrt(24); // 3%/day
|
||||||
@@ -418,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);
|
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', () => {
|
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);
|
||||||
assert.equal(maxLossRoom({ balance: 99757.39, accountSize: 100000, maxLoss: -6000 }), 5757.39);
|
assert.equal(maxLossRoom({ balance: 99757.39, accountSize: 100000, maxLoss: -6000 }), 5757.39);
|
||||||
@@ -440,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: 1200 }), false);
|
||||||
assert.equal(lastStand({ dllRoom: 1000, dailyLossLimit: 3000, maxLossRoom: 1150 }), true);
|
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');
|
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.
|
// 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: 3000, maxLossRoom: 2900, entryFee: 120 }), 2780);
|
||||||
assert.equal(breachDistance({ dllRoom: 2757.39, maxLossRoom: null, entryFee: 0 }), 2757.39);
|
assert.equal(breachDistance({ dllRoom: 2757.39, maxLossRoom: null, entryFee: 0 }), 2757.39);
|
||||||
@@ -475,6 +515,111 @@ test('planLegSize: the stop, a last stand, the margin cap and the cost, as fireL
|
|||||||
assert.equal(planLegSize({ ...base, maxLossRoom: null, contract: {} }).sizing, null);
|
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', () => {
|
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 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'));
|
const draws = people.map((p) => personSizeFactor(p, '2026-10-20'));
|
||||||
@@ -620,6 +765,39 @@ test('an every-day firm can finish its cycle on a Saturday', () => {
|
|||||||
assert.equal(cycleCompletion(opts), 'none');
|
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) ─────────────────────────────────
|
// ── DXTrade helpers (lib/dxtrade-helpers.ts) ─────────────────────────────────
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -952,3 +1130,360 @@ test('fund reconcile: a transient that outlasts the confirmation is taken back w
|
|||||||
assert.deepEqual(imported.kinds, ['unconfirmed', 'book']);
|
assert.deepEqual(imported.kinds, ['unconfirmed', 'book']);
|
||||||
assert.equal(imported.row, -1);
|
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);
|
||||||
|
});
|
||||||
@@ -38,6 +38,9 @@ export interface AccountConfig {
|
|||||||
/** Hard daily loss limit in dollars (0/absent = none): every leg carries a
|
/** 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. */
|
* stop that keeps the firm's day inside it, less a reserve and fees. */
|
||||||
dailyLossLimit?: number;
|
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. */
|
/** The firm trades every calendar day, weekends and CME holidays included. */
|
||||||
tradesEveryDay?: boolean;
|
tradesEveryDay?: boolean;
|
||||||
}
|
}
|
||||||
@@ -81,6 +84,9 @@ export interface AccountState {
|
|||||||
active: boolean;
|
active: boolean;
|
||||||
amount: number;
|
amount: number;
|
||||||
realizedPnL: 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;
|
daysTraded: number;
|
||||||
positionDirection: 'long' | 'short' | null;
|
positionDirection: 'long' | 'short' | null;
|
||||||
/** Balance floor from Tradovate's auto-liquidation profile (0 = not set) */
|
/** Balance floor from Tradovate's auto-liquidation profile (0 = not set) */
|
||||||
@@ -96,7 +102,9 @@ export interface AccountState {
|
|||||||
* consistency and minimum days all met (cycleCompletion 'today'). Only
|
* consistency and minimum days all met (cycleCompletion 'today'). Only
|
||||||
* the final day; the firm may lock or remove the account afterwards. */
|
* the final day; the firm may lock or remove the account afterwards. */
|
||||||
completed: boolean;
|
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;
|
stage: number;
|
||||||
/** True when the current stage is a drain stage (spam-withdraw mode). */
|
/** True when the current stage is a drain stage (spam-withdraw mode). */
|
||||||
stageDrain: boolean;
|
stageDrain: boolean;
|
||||||
@@ -136,6 +144,10 @@ export interface AccountState {
|
|||||||
lastStand: boolean;
|
lastStand: boolean;
|
||||||
/** Actual profit target to hit — max(stage profit target, consistency realTarget = maxDay/consistency). */
|
/** Actual profit target to hit — max(stage profit target, consistency realTarget = maxDay/consistency). */
|
||||||
effectiveProfitTarget: number | null;
|
effectiveProfitTarget: number | null;
|
||||||
|
/** 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. */
|
/** Current cycle daily P&L — used for target calculations. */
|
||||||
dailyPnL: { date: string; pnl: number }[];
|
dailyPnL: { date: string; pnl: number }[];
|
||||||
/** Full P&L history across all cycles — used for calendar and equity curve display. */
|
/** Full P&L history across all cycles — used for calendar and equity curve display. */
|
||||||
@@ -176,3 +188,30 @@ export interface FirmState {
|
|||||||
connected: boolean;
|
connected: boolean;
|
||||||
accounts: AccountState[];
|
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