Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0c1569d96 | ||
|
|
1e844178de | ||
|
|
6f6660fdcc |
@@ -257,7 +257,11 @@ own traffic since Vest publishes no API):
|
|||||||
unmarked since marking began was someone else's. The **Trades** button on
|
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.
|
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
|
- Claims: the bot claims a funded account's profit by itself at each stage's
|
||||||
target (`auto_claim`, on unless set to `off`). The account page's **Claim
|
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. The account page's **Claim
|
||||||
now** button claims the whole profit above the account size at once — any
|
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
|
amount from $1 — when the account is flat; it asks first, since it moves
|
||||||
money to the login's main account.
|
money to the login's main account.
|
||||||
|
|||||||
@@ -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'] 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,12 @@ 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 run unless it reads exactly 'off' — say so exactly, and
|
||||||
|
// before anything is written.
|
||||||
|
if (body.auto_claim !== undefined && body.auto_claim !== null && body.auto_claim !== 'on' && body.auto_claim !== 'off') {
|
||||||
|
return NextResponse.json({ error: "auto_claim 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];
|
||||||
|
|||||||
+12
-10
@@ -11,7 +11,7 @@ import type { AccountState } from '@/types';
|
|||||||
import {
|
import {
|
||||||
combineCmeSessions, computeDayPlan, cycleCompletion, resolveEffectiveConfig, currentTradeDateCT,
|
combineCmeSessions, computeDayPlan, cycleCompletion, resolveEffectiveConfig, currentTradeDateCT,
|
||||||
obfuscationStopDollars, dllStopDollars, botStopsActive, stageInputsFor,
|
obfuscationStopDollars, dllStopDollars, botStopsActive, stageInputsFor,
|
||||||
maxLossRoom, lastStand as isLastStand,
|
maxLossRoom, lastStand as isLastStand, shortestNextLeg,
|
||||||
LEG_COMPLETION_EPSILON,
|
LEG_COMPLETION_EPSILON,
|
||||||
} from '@/lib/trading-logic';
|
} from '@/lib/trading-logic';
|
||||||
|
|
||||||
@@ -133,7 +133,7 @@ export async function GET() {
|
|||||||
// 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 = effective?.dailyLossLimit ?? 0;
|
const dll = effective?.dailyLossLimit ?? 0;
|
||||||
dllRoom = dayLossRoom(client, acc.id, dll, cash, combined.realizedPnL);
|
dllRoom = dayLossRoom(client, acc.id, dll, cash, combined.realizedPnL, plan ? shortestNextLeg(plan) : 0);
|
||||||
// The max-drawdown floor, and whether the next leg is a last
|
// 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({
|
||||||
@@ -147,14 +147,6 @@ 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.
|
||||||
@@ -179,6 +171,16 @@ export async function GET() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (plan && !dayBooked && botStopsActive(cfg)) {
|
||||||
|
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 })
|
||||||
|
: 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
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ import type { TradeView } from '@/types';
|
|||||||
* Who opened and closed each one: the bot marks its own orders as it sends
|
* 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
|
* 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
|
* someone else's. A close Vest calls "user_requested" is an order on the
|
||||||
* account — the bot's if it marked it, else outside.
|
* 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) {
|
export async function GET(req: Request) {
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
@@ -30,7 +33,7 @@ export async function GET(req: Request) {
|
|||||||
const closedBy = open ? null
|
const closedBy = open ? null
|
||||||
: t.closedBy === 'bot' ? 'bot'
|
: t.closedBy === 'bot' ? 'bot'
|
||||||
: t.closeReason === 'user_requested' ? (t.closedBy === 'outside' || tracked(t.closedAt ?? t.openedAt) ? 'outside' : 'user_requested')
|
: t.closeReason === 'user_requested' ? (t.closedBy === 'outside' || tracked(t.closedAt ?? t.openedAt) ? 'outside' : 'user_requested')
|
||||||
: t.closeReason ?? 'unknown';
|
: t.closeReason ?? (t.closedBy === 'outside' ? 'outside' : t.closedBy === 'unknown' ? 'user_requested' : 'unknown');
|
||||||
const net = open || t.pnl === null ? null
|
const net = open || t.pnl === null ? null
|
||||||
: Math.round((t.pnl - Math.abs(t.fee ?? 0) + (t.funding ?? 0)) * 100) / 100;
|
: Math.round((t.pnl - Math.abs(t.fee ?? 0) + (t.funding ?? 0)) * 100) / 100;
|
||||||
return {
|
return {
|
||||||
@@ -48,7 +51,7 @@ export async function GET(req: Request) {
|
|||||||
net,
|
net,
|
||||||
openedAt: t.openedAt,
|
openedAt: t.openedAt,
|
||||||
closedAt: t.closedAt,
|
closedAt: t.closedAt,
|
||||||
openedBy: t.openedBy === 'bot' ? 'bot' : t.openedBy === 'outside' || tracked(t.openedAt) ? 'outside' : null,
|
openedBy: t.openedBy === 'bot' ? 'bot' : t.openedBy === 'unknown' ? null : t.openedBy === 'outside' || tracked(t.openedAt) ? 'outside' : null,
|
||||||
closedBy,
|
closedBy,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type { TradeView } from '@/types';
|
|||||||
/**
|
/**
|
||||||
* Recorded positions from /api/trades — every trade on the accounts, the
|
* 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
|
* bot's and anyone else's, with who opened it and how it closed. On the
|
||||||
* dashboard under a Vest firm (inline) and on the account page (as a card,
|
* dashboard under each firm (inline) and on the account page (as a card,
|
||||||
* hidden while there's nothing to show).
|
* hidden while there's nothing to show).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ const PILL = 'inline-block px-1.5 py-0.5 rounded text-[10px] font-semibold white
|
|||||||
|
|
||||||
const CLOSED_BY: Record<string, { label: string; cls: string; title: string }> = {
|
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" },
|
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 Vest, or another tool' },
|
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' },
|
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' },
|
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' },
|
liquidation: { label: 'Liquidation', cls: 'bg-red-200 text-red-800', title: 'Liquidated by Vest' },
|
||||||
@@ -24,6 +24,7 @@ const CLOSED_BY: Record<string, { label: string; cls: string; title: string }> =
|
|||||||
temporary_leverage_limit: { label: 'Leverage limit', cls: 'bg-orange-100 text-orange-700', title: 'Reduced by Vest’s temporary (weekend) leverage limit' },
|
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' },
|
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' },
|
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 {
|
function money(v: number): string {
|
||||||
@@ -44,7 +45,7 @@ function when(ms: number): string {
|
|||||||
|
|
||||||
function OpenedBy({ by }: { by: TradeView['openedBy'] }) {
|
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 === '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 Vest, or another tool">Outside</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>;
|
return <span className="text-slate-400" title="Opened before the bot began marking its own trades">—</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,7 +106,7 @@ export function TradesTable({ query, title, showAccount = false, formatAccount =
|
|||||||
const table = !trades
|
const table = !trades
|
||||||
? <div className="text-sm text-slate-400 italic py-2">{failed ? 'Couldn’t load trades' : 'Loading trades…'}</div>
|
? <div className="text-sm text-slate-400 italic py-2">{failed ? 'Couldn’t load trades' : 'Loading trades…'}</div>
|
||||||
: trades.length === 0
|
: trades.length === 0
|
||||||
? <div className="text-sm text-slate-400 italic py-2">No trades recorded yet — they appear after the next history read (within the hour, or a minute after a position closes)</div>
|
? <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">
|
<div className="overflow-x-auto max-h-[28rem] overflow-y-auto">
|
||||||
<table className="w-full text-xs">
|
<table className="w-full text-xs">
|
||||||
|
|||||||
+26
-2
@@ -652,7 +652,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 justify-end gap-1">
|
<div className="flex items-center justify-end gap-1">
|
||||||
{firm.platform === 'vest' && hasCredentials && (
|
{hasCredentials && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); setShowTrades((v) => !v); }}
|
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'}`}
|
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'}`}
|
||||||
@@ -743,6 +743,7 @@ 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 handleSort = (col: SortKey) => {
|
const handleSort = (col: SortKey) => {
|
||||||
if (sortKey === col) {
|
if (sortKey === col) {
|
||||||
@@ -812,8 +813,9 @@ 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 }) => {
|
||||||
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');
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
|
||||||
@@ -1000,6 +1002,28 @@ 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>
|
||||||
|
)}
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<div className="ml-auto flex items-center gap-2">
|
||||||
{hasAnyPosition && (
|
{hasAnyPosition && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
+56
-12
@@ -18,7 +18,7 @@ import {
|
|||||||
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, machineSide, stageInputsFor, floorRoom,
|
fundRowsKey, markFinishedCycle, unbookedPayoutDrop, machineSide, stageInputsFor, floorRoom, shortestNextLeg,
|
||||||
type DayPlan, type FinishedCycleMark,
|
type DayPlan, type FinishedCycleMark,
|
||||||
} from './trading-logic';
|
} from './trading-logic';
|
||||||
import { getSetting, setSetting, loadAccountMeta, saveAccountMeta } from './db';
|
import { getSetting, setSetting, loadAccountMeta, saveAccountMeta } from './db';
|
||||||
@@ -621,7 +621,7 @@ function dailyLossGate(
|
|||||||
cfg: { maxPositionSize: number; firmId: string },
|
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 } {
|
||||||
if (!(dll > 0)) return { room: Infinity };
|
if (!(dll > 0)) return { room: Infinity };
|
||||||
@@ -634,6 +634,7 @@ function dailyLossGate(
|
|||||||
room = floorRoom({
|
room = floorRoom({
|
||||||
dailyLossLimit: dll, closedBalance: cash.amount, floor,
|
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 {
|
} else {
|
||||||
const dayStart: number | null | undefined = client.dayStartBalance?.(accountId);
|
const dayStart: number | null | undefined = client.dayStartBalance?.(accountId);
|
||||||
@@ -651,7 +652,7 @@ function dailyLossGate(
|
|||||||
// 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)) {
|
||||||
@@ -837,7 +838,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, effective.dailyLossLimit, 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 };
|
||||||
|
|
||||||
@@ -1868,7 +1869,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, ev.dll, 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;
|
||||||
}
|
}
|
||||||
@@ -1947,6 +1948,7 @@ function claimDue(client: any, acc: { id: number; active: boolean }, now: number
|
|||||||
if (!cfg || cfg.payoutable !== true || !acc.active) return null;
|
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.positions[acc.id] || client.hasWorkingOrders?.(acc.id) || runtime().accountsInFlight.has(acc.id)) return null;
|
||||||
if (!client.historyReady?.(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;
|
if (now - (runtime().claimFailedAt?.get(acc.id) ?? 0) < CLAIM_RETRY_MS) return null;
|
||||||
const cash = client.accountCashBalances[acc.id];
|
const cash = client.accountCashBalances[acc.id];
|
||||||
if (!cash) return null;
|
if (!cash) return null;
|
||||||
@@ -1966,8 +1968,9 @@ function claimDue(client: any, acc: { id: number; active: boolean }, now: number
|
|||||||
* Profit). The account then starts its next stage the same day: its leg
|
* 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
|
* 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
|
* 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. Off with the
|
* lane's mutex, so no batch reads an account mid-claim. Run by the
|
||||||
* auto_claim setting at 'off'.
|
* 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> {
|
async function runClaims(lane: string, ready: FirmClient[]): Promise<void> {
|
||||||
if (getSetting('auto_claim') === 'off') return;
|
if (getSetting('auto_claim') === 'off') return;
|
||||||
@@ -1983,12 +1986,15 @@ async function runClaims(lane: string, ready: FirmClient[]): Promise<void> {
|
|||||||
if (lease === null) return;
|
if (lease === null) return;
|
||||||
try {
|
try {
|
||||||
const tradeDate = currentTradeDateCT();
|
const tradeDate = currentTradeDateCT();
|
||||||
for (const { firm, client, acc, amount } of due) {
|
for (const { firm, client, acc } of due) {
|
||||||
if (!holdsTradeMutex(lease, lane)) return;
|
if (!holdsTradeMutex(lease, lane)) return;
|
||||||
if (claimDue(client, acc, Date.now()) === null) continue; // settled since
|
// Settled since, or a different amount now: claim what is due now.
|
||||||
|
const amount = claimDue(client, acc, Date.now());
|
||||||
|
if (amount === null) continue;
|
||||||
try {
|
try {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// In flight while it claims: a batch that takes over a wedged
|
||||||
const { claimed, received } = await (client as any).claimProfit(acc.id, amount);
|
// 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, tradeDate);
|
afterClaim('[auto-claim]', firm, client, acc, claimed, received, tradeDate);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
(runtime().claimFailedAt ??= new Map()).set(acc.id, Date.now());
|
(runtime().claimFailedAt ??= new Map()).set(acc.id, Date.now());
|
||||||
@@ -2000,6 +2006,43 @@ async function runClaims(lane: string, ready: FirmClient[]): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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'. 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 || getSetting('auto_claim') === 'off' || _claimWatch.__claimTickBusy) 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.
|
||||||
|
const { ready } = firmClients();
|
||||||
|
for (const lane of tradableLanes()) {
|
||||||
|
if (getState().running) return; // the scheduler took over
|
||||||
|
await runClaims(lane, ready);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[auto-claim] ${errorMessage(err)}`);
|
||||||
|
} finally {
|
||||||
|
_claimWatch.__claimTickBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** A claim went through: the account starts its next stage — the day's leg
|
/** 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. */
|
* count and entry outcome are cleared, as if it hadn't traded today. */
|
||||||
function afterClaim(
|
function afterClaim(
|
||||||
@@ -2043,6 +2086,7 @@ function manualClaim(accountId: number):
|
|||||||
return { reason: 'a position or order is open — the account must be flat' };
|
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.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];
|
const cash = client.accountCashBalances[acc.id];
|
||||||
if (!cash) return { reason: 'its balance has not been read yet' };
|
if (!cash) return { reason: 'its balance has not been read yet' };
|
||||||
const profit = Math.floor((cash.amount - cfg.accountSize) * 100) / 100;
|
const profit = Math.floor((cash.amount - cfg.accountSize) * 100) / 100;
|
||||||
@@ -2084,7 +2128,7 @@ export async function claimAccountNow(accountId: number, amount: number): Promis
|
|||||||
if ('reason' in c) throw new ClaimRefusedError(c.reason);
|
if ('reason' in c) throw new ClaimRefusedError(c.reason);
|
||||||
if (!(amount >= 1)) throw new ClaimRefusedError(`$${amount} is below the $1 minimum claim`);
|
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`);
|
if (amount > c.amount + 0.005) throw new ClaimRefusedError(`only $${c.amount.toFixed(2)} is claimable now`);
|
||||||
const { claimed, received } = await c.client.claimProfit!(c.acc.id, amount);
|
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, currentTradeDateCT());
|
const stage = afterClaim('[claim-now]', c.firm, c.client, c.acc, claimed, received, currentTradeDateCT());
|
||||||
return { claimed, received, stage };
|
return { claimed, received, stage };
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
+4
-1
@@ -15,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.
|
||||||
@@ -189,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.
|
||||||
|
|||||||
@@ -684,12 +684,116 @@ export function markTrade(
|
|||||||
`).run(t.positionId, t.accountId, t.accountName, t.symbol, t.side, t.quantity, t.openPrice, t.openedAt, Date.now());
|
`).run(t.positionId, t.accountId, t.accountName, t.symbol, t.side, t.quantity, t.openPrice, t.openedAt, Date.now());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Accounts with a trade the table still shows open. */
|
/** 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 }[] {
|
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 GROUP BY account_id')
|
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 }[];
|
.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
|
/** Newest first (by close, else open); every account when accountIds is
|
||||||
* undefined. */
|
* undefined. */
|
||||||
export function loadTrades(o: { accountIds?: number[]; limit: number }): TradeRow[] {
|
export function loadTrades(o: { accountIds?: number[]; limit: number }): TradeRow[] {
|
||||||
|
|||||||
+131
-2
@@ -36,7 +36,7 @@ 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';
|
||||||
@@ -48,13 +48,19 @@ 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[]> {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
+5
-2
@@ -11,7 +11,7 @@ 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, stageInputsFor, floorRoom,
|
planLegSize, rankForTarget, maxLossRoom, lastStand, dllStopDollars, stageInputsFor, floorRoom, shortestNextLeg,
|
||||||
FALLBACK_COMMISSION_PER_CONTRACT,
|
FALLBACK_COMMISSION_PER_CONTRACT,
|
||||||
} from './trading-logic';
|
} from './trading-logic';
|
||||||
|
|
||||||
@@ -27,6 +27,8 @@ export function dayLossRoom(
|
|||||||
dll: number,
|
dll: number,
|
||||||
cash: { amount: number; realizedPnL: number },
|
cash: { amount: number; realizedPnL: number },
|
||||||
combinedRealized: number,
|
combinedRealized: number,
|
||||||
|
/** The shortest take-profit the next leg may have (shortestNextLeg). */
|
||||||
|
nextLeg: number,
|
||||||
): number | null {
|
): number | null {
|
||||||
if (!(dll > 0)) return null;
|
if (!(dll > 0)) return null;
|
||||||
const floor: number | null | undefined = client.dailyLossFloor?.(accountId);
|
const floor: number | null | undefined = client.dailyLossFloor?.(accountId);
|
||||||
@@ -35,6 +37,7 @@ export function dayLossRoom(
|
|||||||
return floorRoom({
|
return floorRoom({
|
||||||
dailyLossLimit: dll, closedBalance: cash.amount, floor,
|
dailyLossLimit: dll, closedBalance: cash.amount, floor,
|
||||||
realizedToday: Math.min(cash.realizedPnL, combinedRealized),
|
realizedToday: Math.min(cash.realizedPnL, combinedRealized),
|
||||||
|
gain: client.dayGainForRoom?.(accountId, { leg: nextLeg, limit: dll }) ?? 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const dayStart = client.dayStartBalance?.(accountId);
|
const dayStart = client.dayStartBalance?.(accountId);
|
||||||
@@ -143,7 +146,7 @@ function accountPlan(
|
|||||||
);
|
);
|
||||||
if (!plan) return null;
|
if (!plan) return null;
|
||||||
const dll = effective.dailyLossLimit;
|
const dll = effective.dailyLossLimit;
|
||||||
const room = dayLossRoom(client, acc.id, dll, cash, combined.realizedPnL);
|
const room = dayLossRoom(client, acc.id, dll, cash, combined.realizedPnL, shortestNextLeg(plan));
|
||||||
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 };
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-1
@@ -125,12 +125,21 @@ export interface PlatformClient {
|
|||||||
* The equity the account may not fall below today, where the platform
|
* 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
|
* states its daily-loss floor itself and moves it with the day's
|
||||||
* transfers (Vest: reset balance × (1 − rate), lowered by a payout). The
|
* transfers (Vest: reset balance × (1 − rate), lowered by a payout). The
|
||||||
* engine's room is then min(the stage's limit, balance − floor), so a
|
* 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 =
|
* 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
|
* the platform has one but it isn't known yet (hold); undefined = none
|
||||||
* here (the engine falls back to dayStartBalance / realized P&L).
|
* here (the engine falls back to dayStartBalance / realized P&L).
|
||||||
*/
|
*/
|
||||||
dailyLossFloor?(accountId: number): number | null | undefined;
|
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;
|
||||||
/** Payouts made since the firm's last daily reset — what picks the stage
|
/** Payouts made since the firm's last daily reset — what picks the stage
|
||||||
* of an account type whose stages reset each day. */
|
* of an account type whose stages reset each day. */
|
||||||
sameDayClaims?(accountId: number): number;
|
sameDayClaims?(accountId: number): number;
|
||||||
@@ -139,6 +148,9 @@ export interface PlatformClient {
|
|||||||
* Resolves once the platform accepted it and the books were updated;
|
* Resolves once the platform accepted it and the books were updated;
|
||||||
* rejects with the platform's reason otherwise. */
|
* rejects with the platform's reason otherwise. */
|
||||||
claimProfit?(accountId: number, amount: number): Promise<{ claimed: number; received: number | null }>;
|
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;
|
||||||
/** 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;
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+57
-13
@@ -1339,17 +1339,6 @@ export function dllReserve(dailyLossLimit: number, notional = 0): number {
|
|||||||
* 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;
|
||||||
|
|
||||||
/**
|
|
||||||
* How much the firm's day may still lose before a hard daily-loss breach.
|
|
||||||
* Infinity when the account type has no limit.
|
|
||||||
*
|
|
||||||
* The day's P&L is the closed balance against the platform's snapshot at the
|
|
||||||
* firm's daily reset when the client knows it (DXTrade: 22:00 UTC), else the
|
|
||||||
* broker's realized P&L for the session. Only a red day counts: a green day
|
|
||||||
* does NOT widen the room past the limit, because a position still open at
|
|
||||||
* the next reset has its whole move counted against the NEW day — room
|
|
||||||
* borrowed from today's profit would be a breach tomorrow.
|
|
||||||
*/
|
|
||||||
/** The stage inputs for one account: the type's daily loss limit, and for a
|
/** 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. */
|
* type whose stages reset each day, the client's count of today's payouts. */
|
||||||
export function stageInputsFor(
|
export function stageInputsFor(
|
||||||
@@ -1365,6 +1354,37 @@ export function stageInputsFor(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* dllRoom where the platform states its daily-loss floor itself (Vest: the
|
* 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
|
* equity the account may not fall below today, already moved by the day's
|
||||||
@@ -1373,6 +1393,14 @@ export function stageInputsFor(
|
|||||||
* limit set below the firm's (Vest's 3% is $1,500; the bot's day stops at
|
* 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
|
* $1,229) would let a day that already lost $1,111 trade the $389 Vest still
|
||||||
* allows. Infinity when there is no limit.
|
* 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: {
|
export function floorRoom(o: {
|
||||||
dailyLossLimit: number;
|
dailyLossLimit: number;
|
||||||
@@ -1381,11 +1409,25 @@ export function floorRoom(o: {
|
|||||||
/** The day's realized P&L since its base (Vest: since this client's
|
/** The day's realized P&L since its base (Vest: since this client's
|
||||||
* latest claim today, else since the reset). */
|
* latest claim today, else since the reset). */
|
||||||
realizedToday: number;
|
realizedToday: number;
|
||||||
|
/** What of the day's gain the room may grow by; 0 (default) = none. */
|
||||||
|
gain?: number;
|
||||||
}): number {
|
}): number {
|
||||||
if (!(o.dailyLossLimit > 0)) return Infinity;
|
if (!(o.dailyLossLimit > 0)) return Infinity;
|
||||||
return round2(Math.min(o.dailyLossLimit + Math.min(0, o.realizedToday), o.closedBalance - o.floor));
|
const widen = Math.min(Math.max(0, o.realizedToday), Math.max(0, o.gain ?? 0));
|
||||||
|
return round2(Math.min(o.dailyLossLimit + Math.min(0, o.realizedToday) + widen, o.closedBalance - o.floor));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How much the firm's day may still lose before a hard daily-loss breach.
|
||||||
|
* Infinity when the account type has no limit.
|
||||||
|
*
|
||||||
|
* The day's P&L is the closed balance against the platform's snapshot at the
|
||||||
|
* firm's daily reset when the client knows it (DXTrade: 22:00 UTC), else the
|
||||||
|
* broker's realized P&L for the session. Only a red day counts: a green day
|
||||||
|
* does NOT widen the room past the limit, because a position still open at
|
||||||
|
* the next reset has its whole move counted against the NEW day — room
|
||||||
|
* borrowed from today's profit would be a breach tomorrow.
|
||||||
|
*/
|
||||||
export function dllRoom(o: {
|
export function dllRoom(o: {
|
||||||
dailyLossLimit: number;
|
dailyLossLimit: number;
|
||||||
closedBalance: number;
|
closedBalance: number;
|
||||||
@@ -1447,7 +1489,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
|
||||||
|
|||||||
+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 };
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
+252
-34
@@ -38,7 +38,7 @@
|
|||||||
|
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import axios from './http-client';
|
import axios from './http-client';
|
||||||
import { currentTradeDateCT, rankForTarget, dllReserve } from './trading-logic';
|
import { currentTradeDateCT, rankForTarget, dllReserve, resetClearStop } from './trading-logic';
|
||||||
import { sizingHoldHours } from './person-size';
|
import { sizingHoldHours } from './person-size';
|
||||||
import {
|
import {
|
||||||
saveDailyPnL, loadDailyPnL, loadAccountMeta, saveAccountMeta, loadFundTransactions, saveFundTransactions,
|
saveDailyPnL, loadDailyPnL, loadAccountMeta, saveAccountMeta, loadFundTransactions, saveFundTransactions,
|
||||||
@@ -105,8 +105,25 @@ const ASSUMED_SPREAD_TICKS = 1;
|
|||||||
/** How long before the daily reset open positions are fitted to the room
|
/** How long before the daily reset open positions are fitted to the room
|
||||||
* the reset will leave (guardReset). */
|
* the reset will leave (guardReset). */
|
||||||
const RESET_GUARD_MS = 5 * 60_000;
|
const RESET_GUARD_MS = 5 * 60_000;
|
||||||
|
/** No new position opens this close to the reset: the guard's window, and a
|
||||||
|
* couple of minutes before it for an entry in flight to place its exits. */
|
||||||
|
const RESET_ENTRY_BLOCK_MS = RESET_GUARD_MS + 2 * 60_000;
|
||||||
|
/** How long a stop or close the guard just sent counts before a read of
|
||||||
|
* open positions shows it. */
|
||||||
|
const GUARD_ECHO_MS = 30_000;
|
||||||
/** account_meta key of the profit claims this client made, per account. */
|
/** account_meta key of the profit claims this client made, per account. */
|
||||||
const CLAIMS_META = 'vest_claims';
|
const CLAIMS_META = 'vest_claims';
|
||||||
|
/** A claim shows in Vest's transactions as a WITHDRAW of the same amount
|
||||||
|
* within this long of it. */
|
||||||
|
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. */
|
||||||
|
const UNKNOWN_CLAIM_EXPIRES_MS = 60 * 60_000;
|
||||||
|
|
||||||
|
/** A claim this client sent: the amount, when, 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. */
|
||||||
|
interface OwnClaim { at: number; amount: number; base: number; unknown?: true }
|
||||||
|
|
||||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||||
const round2 = (n: number) => Math.round(n * 100) / 100;
|
const round2 = (n: number) => Math.round(n * 100) / 100;
|
||||||
@@ -179,12 +196,24 @@ export class VestClient implements PlatformClient {
|
|||||||
/** Profit claims this client made, per account: when, how much, and the
|
/** Profit claims this client made, per account: when, how much, and the
|
||||||
* balance right after — today's P&L counts from there (realizedSince).
|
* balance right after — today's P&L counts from there (realizedSince).
|
||||||
* Loaded from account_meta on first use, so a restart keeps them. */
|
* Loaded from account_meta on first use, so a restart keeps them. */
|
||||||
private claims = new Map<number, { at: number; amount: number; base: number }[]>();
|
private claims = new Map<number, OwnClaim[]>();
|
||||||
|
/** The sync generation current when each account last claimed: a sync
|
||||||
|
* of that generation or older read the balance before the claim. */
|
||||||
|
private claimSeq = new Map<number, number>();
|
||||||
|
/** When each account's transactions were last read. */
|
||||||
|
private historyReadAt = new Map<number, number>();
|
||||||
|
private unmatchedClaimLogged = new Set<string>();
|
||||||
/** Each account's WITHDRAW transactions at the last history read, so a
|
/** Each account's WITHDRAW transactions at the last history read, so a
|
||||||
* claim made by hand on Vest counts toward today's stage too. */
|
* claim made by hand on Vest counts toward today's stage too. */
|
||||||
private withdrawals = new Map<number, { at: number; amount: number }[]>();
|
private withdrawals = new Map<number, { at: number; amount: number }[]>();
|
||||||
/** The reset guardReset last finished for (epoch ms). */
|
/** The reset guardReset is fitting positions for (epoch ms), and the
|
||||||
private resetGuardedFor = 0;
|
* stops it placed and closes it sent, by position, with when — a read
|
||||||
|
* just after may not show them yet. */
|
||||||
|
private resetGuard = {
|
||||||
|
reset: 0, stops: new Map<string, { price: number; at: number }>(), closes: new Map<string, number>(),
|
||||||
|
noSessionLogged: false, unfittable: new Set<string>(),
|
||||||
|
};
|
||||||
|
private resetGuardInFlight = false;
|
||||||
private capital = new Map<number, VestCapitalAccount>();
|
private capital = new Map<number, VestCapitalAccount>();
|
||||||
private positionsDetail = new Map<number, VestPosition[]>();
|
private positionsDetail = new Map<number, VestPosition[]>();
|
||||||
private openOrders = new Map<number, VestOpenOrder[]>();
|
private openOrders = new Map<number, VestOpenOrder[]>();
|
||||||
@@ -577,6 +606,11 @@ export class VestClient implements PlatformClient {
|
|||||||
this.flatAmounts.delete(id);
|
this.flatAmounts.delete(id);
|
||||||
}
|
}
|
||||||
const upnl = mine.reduce((s, p) => s + unrealizedPnl(p, this.marks.get(p.symbol)?.mark ?? NaN), 0);
|
const upnl = mine.reduce((s, p) => s + unrealizedPnl(p, this.marks.get(p.symbol)?.mark ?? NaN), 0);
|
||||||
|
// A claim made while this sync was out: what it read predates
|
||||||
|
// the payout, and writing it back would show the profit again —
|
||||||
|
// a second claim of it. The claim's own booking stands until a
|
||||||
|
// sync that started after it.
|
||||||
|
if ((this.claimSeq.get(id) ?? -1) >= gen && this.accountCashBalances[id]) continue;
|
||||||
this.accountCashBalances[id] = {
|
this.accountCashBalances[id] = {
|
||||||
amount: round2(balance),
|
amount: round2(balance),
|
||||||
realizedPnL: this.realizedSince(id, a, balance, now),
|
realizedPnL: this.realizedSince(id, a, balance, now),
|
||||||
@@ -686,8 +720,8 @@ export class VestClient implements PlatformClient {
|
|||||||
/**
|
/**
|
||||||
* See PlatformClient: Vest states the day's floor itself — reset balance
|
* See PlatformClient: Vest states the day's floor itself — reset balance
|
||||||
* × (1 − rate), lowered by a payout the same day — so the engine's room
|
* × (1 − rate), lowered by a payout the same day — so the engine's room
|
||||||
* is min(the stage's limit, balance − floor), never more than Vest
|
* is what's left of the stage's limit, widened by dayGainForRoom, never
|
||||||
* leaves. null until the account has been read; undefined for an account
|
* more than balance − floor (floorRoom). null until the account has been read; undefined for an account
|
||||||
* Vest gives no daily limit.
|
* Vest gives no daily limit.
|
||||||
*/
|
*/
|
||||||
public dailyLossFloor(accountId: number): number | null | undefined {
|
public dailyLossFloor(accountId: number): number | null | undefined {
|
||||||
@@ -698,12 +732,37 @@ export class VestClient implements PlatformClient {
|
|||||||
return this.dailyFloorOf(a, cash.amount, Date.now()) ?? null;
|
return this.dailyFloorOf(a, cash.amount, Date.now()) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private claimsOf(accountId: number): { at: number; amount: number; base: number }[] {
|
/**
|
||||||
|
* See PlatformClient: the gain since the day's base that is still in the
|
||||||
|
* account — since this client's own claim today, else since the reset,
|
||||||
|
* with no add-back for a claim made by hand (that profit is gone) — up to
|
||||||
|
* the stop that keeps the leg all but sure to close before the 8 PM ET
|
||||||
|
* reset at the firm's target hold (resetClearStop); none before Vest has
|
||||||
|
* captured today's reset balance.
|
||||||
|
*/
|
||||||
|
public dayGainForRoom(accountId: number, o: { leg: number; limit: number }): number {
|
||||||
|
const now = Date.now();
|
||||||
|
const hold = sizingHoldHours(getCachedFirm(this.cfg.firmId));
|
||||||
|
const allowed = resetClearStop({ leg: o.leg, resetInMs: nextVestResetMs(now) - now, holdHours: hold }) - o.limit;
|
||||||
|
if (!(allowed > 0)) return 0;
|
||||||
|
const a = this.capital.get(accountId);
|
||||||
|
const cash = this.accountCashBalances[accountId];
|
||||||
|
if (!a || !cash) return 0;
|
||||||
|
const mine = this.madeClaims(accountId).filter((c) => c.at >= lastVestResetMs(now));
|
||||||
|
const base = mine.length > 0 ? mine[mine.length - 1].base
|
||||||
|
: Number.isFinite(a.dailyResetAt) && a.dailyResetAt >= lastVestResetMs(now) ? a.dailyResetBalance : undefined;
|
||||||
|
return base === undefined || !Number.isFinite(base) ? 0 : Math.max(0, Math.min(round2(cash.amount - base), round2(allowed)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private claimsOf(accountId: number): OwnClaim[] {
|
||||||
let list = this.claims.get(accountId);
|
let list = this.claims.get(accountId);
|
||||||
if (!list) {
|
if (!list) {
|
||||||
try {
|
try {
|
||||||
const raw = JSON.parse(loadAccountMeta(accountId, CLAIMS_META) ?? '[]');
|
const raw = JSON.parse(loadAccountMeta(accountId, CLAIMS_META) ?? '[]');
|
||||||
list = Array.isArray(raw) ? raw.filter((c) => Number.isFinite(c?.at) && Number.isFinite(c?.amount) && Number.isFinite(c?.base)) : [];
|
list = Array.isArray(raw)
|
||||||
|
? raw.filter((c) => Number.isFinite(c?.at) && Number.isFinite(c?.amount) && Number.isFinite(c?.base))
|
||||||
|
.map((c): OwnClaim => ({ at: c.at, amount: c.amount, base: c.base, ...(c.unknown === true ? { unknown: true as const } : {}) }))
|
||||||
|
: [];
|
||||||
} catch {
|
} catch {
|
||||||
list = [];
|
list = [];
|
||||||
}
|
}
|
||||||
@@ -712,6 +771,24 @@ export class VestClient implements PlatformClient {
|
|||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The claims this client knows went in (the platform said so). */
|
||||||
|
private madeClaims(accountId: number): OwnClaim[] {
|
||||||
|
return this.claimsOf(accountId).filter((c) => !c.unknown);
|
||||||
|
}
|
||||||
|
|
||||||
|
private saveClaims(accountId: number, list: OwnClaim[]): void {
|
||||||
|
const now = Date.now();
|
||||||
|
const kept = list.filter((c) => now - c.at < 7 * 86_400_000);
|
||||||
|
this.claims.set(accountId, kept);
|
||||||
|
saveAccountMeta(accountId, CLAIMS_META, JSON.stringify(kept));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vest's transactions show the claim: a WITHDRAW of its amount near it. */
|
||||||
|
private claimShown(accountId: number, c: OwnClaim): boolean {
|
||||||
|
return (this.withdrawals.get(accountId) ?? [])
|
||||||
|
.some((w) => Math.abs(Math.abs(w.amount) - c.amount) < 0.01 && Math.abs(w.at - c.at) < CLAIM_MATCH_MS);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Today's realized P&L as the day plan wants it: since the latest claim
|
* Today's realized P&L as the day plan wants it: since the latest claim
|
||||||
* this client made since the last reset — the plan's new cycle starts
|
* this client made since the last reset — the plan's new cycle starts
|
||||||
@@ -721,7 +798,7 @@ export class VestClient implements PlatformClient {
|
|||||||
*/
|
*/
|
||||||
private realizedSince(accountId: number, a: VestCapitalAccount, balance: number, now: number): number {
|
private realizedSince(accountId: number, a: VestCapitalAccount, balance: number, now: number): number {
|
||||||
const reset = lastVestResetMs(now);
|
const reset = lastVestResetMs(now);
|
||||||
const mine = this.claimsOf(accountId).filter((c) => c.at >= reset);
|
const mine = this.madeClaims(accountId).filter((c) => c.at >= reset);
|
||||||
if (mine.length > 0) return round2(balance - mine[mine.length - 1].base);
|
if (mine.length > 0) return round2(balance - mine[mine.length - 1].base);
|
||||||
const resetBalance = this.resetBalanceOf(a, balance, now);
|
const resetBalance = this.resetBalanceOf(a, balance, now);
|
||||||
if (resetBalance === undefined) return 0;
|
if (resetBalance === undefined) return 0;
|
||||||
@@ -734,7 +811,7 @@ export class VestClient implements PlatformClient {
|
|||||||
* more (a claim made by hand shows only there). */
|
* more (a claim made by hand shows only there). */
|
||||||
public sameDayClaims(accountId: number): number {
|
public sameDayClaims(accountId: number): number {
|
||||||
const reset = lastVestResetMs(Date.now());
|
const reset = lastVestResetMs(Date.now());
|
||||||
const mine = this.claimsOf(accountId).filter((c) => c.at >= reset).length;
|
const mine = this.madeClaims(accountId).filter((c) => c.at >= reset).length;
|
||||||
const seen = (this.withdrawals.get(accountId) ?? []).filter((w) => w.at >= reset).length;
|
const seen = (this.withdrawals.get(accountId) ?? []).filter((w) => w.at >= reset).length;
|
||||||
return Math.max(mine, seen);
|
return Math.max(mine, seen);
|
||||||
}
|
}
|
||||||
@@ -754,13 +831,25 @@ export class VestClient implements PlatformClient {
|
|||||||
const uuid = this.uuidById.get(accountId);
|
const uuid = this.uuidById.get(accountId);
|
||||||
const account = this.accountList.find((a) => a.id === accountId);
|
const account = this.accountList.find((a) => a.id === accountId);
|
||||||
const cash = this.accountCashBalances[accountId];
|
const cash = this.accountCashBalances[accountId];
|
||||||
if (!uuid || !account || !cash) throw new Error('account not read yet');
|
const meta = this.capital.get(accountId);
|
||||||
|
if (!uuid || !account || !cash || !meta) throw new Error('account not read yet');
|
||||||
if (!this.primaryAccountId) throw new Error('the main account is unknown — no sync has listed it');
|
if (!this.primaryAccountId) throw new Error('the main account is unknown — no sync has listed it');
|
||||||
if (this.positions[accountId] || (this.openOrders.get(accountId) ?? []).length > 0) throw new Error('positions or orders open');
|
if (this.positions[accountId] || (this.openOrders.get(accountId) ?? []).length > 0) throw new Error('positions or orders open');
|
||||||
|
if (this.claimPending(accountId)) throw new Error("the last claim hasn't shown in Vest's transactions yet");
|
||||||
|
// Vest's own word over the account type assigned here: only a funded
|
||||||
|
// (or instant) account pays out, and only above its starting capital.
|
||||||
|
if (meta.kind !== 'funded' && !/instant/i.test(meta.productType)) {
|
||||||
|
throw new Error(`Vest shows ${account.name} as ${meta.kind || 'not funded'} — only a funded account's profit can be claimed`);
|
||||||
|
}
|
||||||
const claimed = Math.floor(amount * 100) / 100;
|
const claimed = Math.floor(amount * 100) / 100;
|
||||||
if (!(claimed >= 1)) throw new Error(`$${claimed} is below Vest's $1 minimum claim`);
|
if (!(claimed >= 1)) throw new Error(`$${claimed} is below Vest's $1 minimum claim`);
|
||||||
|
if (meta.initialCapital > 0 && claimed > cash.amount - meta.initialCapital + 0.01) {
|
||||||
|
throw new Error(`$${claimed} is more than the $${round2(cash.amount - meta.initialCapital)} above Vest's starting capital of $${meta.initialCapital}`);
|
||||||
|
}
|
||||||
|
|
||||||
const { data } = await this.request<{ trader_amount?: unknown }>('trade', 'POST', '/v3/capital/withdraw', {
|
let data: { trader_amount?: unknown } | undefined;
|
||||||
|
try {
|
||||||
|
({ data } = await this.request<{ trader_amount?: unknown }>('trade', 'POST', '/v3/capital/withdraw', {
|
||||||
account: uuid,
|
account: uuid,
|
||||||
body: {
|
body: {
|
||||||
account_id: uuid,
|
account_id: uuid,
|
||||||
@@ -768,13 +857,23 @@ export class VestClient implements PlatformClient {
|
|||||||
target_account_id: this.primaryAccountId,
|
target_account_id: this.primaryAccountId,
|
||||||
idempotency_key: randomUUID(),
|
idempotency_key: randomUUID(),
|
||||||
},
|
},
|
||||||
});
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
// Only a refusal proves nothing went in. A timeout, a reset or a
|
||||||
|
// 5xx may have: hold every further claim on the account until
|
||||||
|
// Vest's transactions say either way (claimPending).
|
||||||
|
if (!(err instanceof VestApiError && err.status >= 400 && err.status < 500 && err.status !== 408)) {
|
||||||
|
this.saveClaims(accountId, [...this.claimsOf(accountId), { at: Date.now(), amount: claimed, base: round2(cash.amount - claimed), unknown: true }]);
|
||||||
|
this.claimSeq.set(accountId, this.syncGeneration);
|
||||||
|
console.error(`${this.tag} ${account.name}: claim of $${claimed} — outcome unknown (${errorMessage(err)}); no further claim on it until Vest's transactions settle it`);
|
||||||
|
void this.fetchDaysTraded([accountId]).catch(() => { /* the hourly read retries */ });
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
const received = num(data?.trader_amount) ?? null;
|
const received = num(data?.trader_amount) ?? null;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const list = [...this.claimsOf(accountId), { at: now, amount: claimed, base: round2(cash.amount - claimed) }]
|
this.saveClaims(accountId, [...this.claimsOf(accountId), { at: now, amount: claimed, base: round2(cash.amount - claimed) }]);
|
||||||
.filter((c) => now - c.at < 7 * 86_400_000);
|
this.claimSeq.set(accountId, this.syncGeneration);
|
||||||
this.claims.set(accountId, list);
|
|
||||||
saveAccountMeta(accountId, CLAIMS_META, JSON.stringify(list));
|
|
||||||
|
|
||||||
// Book it now: today's fund row (with whatever else that day holds),
|
// Book it now: today's fund row (with whatever else that day holds),
|
||||||
// the cycle cut, and the balance until the next sync reads Vest's.
|
// the cycle cut, and the balance until the next sync reads Vest's.
|
||||||
@@ -785,49 +884,142 @@ export class VestClient implements PlatformClient {
|
|||||||
console.log(`${this.tag} ${account.name}: claimed $${claimed}${received !== null ? ` — $${received} to the main account` : ''}`);
|
console.log(`${this.tag} ${account.name}: claimed $${claimed}${received !== null ? ` — $${received} to the main account` : ''}`);
|
||||||
|
|
||||||
void this.fetchDaysTraded([accountId]).catch((err) => console.warn(`${this.tag} history after the claim failed: ${errorMessage(err)}`));
|
void this.fetchDaysTraded([accountId]).catch((err) => console.warn(`${this.tag} history after the claim failed: ${errorMessage(err)}`));
|
||||||
void this.requestSync();
|
// A fresh sync — after any one already out, whose reads predate this.
|
||||||
|
void this.syncRunning.then(() => this.requestSync());
|
||||||
return { claimed, received };
|
return { claimed, received };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* See PlatformClient: a claim of this client's that Vest's transactions
|
||||||
|
* don't show yet — until they do, the balance on hand can't be trusted
|
||||||
|
* not to show the claimed profit again. One whose outcome was unknown,
|
||||||
|
* and that a history read an hour on still doesn't show, never went in:
|
||||||
|
* it's dropped. One Vest accepted holds for as long as it doesn't show
|
||||||
|
* (noted once in the log after an hour).
|
||||||
|
*/
|
||||||
|
public claimPending(accountId: number): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
const readAt = this.historyReadAt.get(accountId) ?? 0;
|
||||||
|
const list = this.claimsOf(accountId);
|
||||||
|
const lapsed = list.filter((c) => c.unknown && !this.claimShown(accountId, c) && readAt - c.at > UNKNOWN_CLAIM_EXPIRES_MS);
|
||||||
|
const name = this.accountList.find((a) => a.id === accountId)?.name ?? accountId;
|
||||||
|
if (lapsed.length > 0) {
|
||||||
|
this.saveClaims(accountId, list.filter((c) => !lapsed.includes(c)));
|
||||||
|
for (const c of lapsed) console.warn(`${this.tag} ${name}: the claim of $${c.amount} with an unknown outcome never showed on Vest — taken as not made`);
|
||||||
|
}
|
||||||
|
const pending = this.claimsOf(accountId).filter((c) => !this.claimShown(accountId, c));
|
||||||
|
for (const c of pending) {
|
||||||
|
const key = `${accountId}:${c.at}`;
|
||||||
|
if (!c.unknown && now - c.at > 60 * 60_000 && !this.unmatchedClaimLogged.has(key)) {
|
||||||
|
this.unmatchedClaimLogged.add(key);
|
||||||
|
console.error(`${this.tag} ${name}: the claim of $${c.amount} an hour ago still doesn't show in Vest's transactions — no further claim on this account until it does`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pending.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Before each 20:00 ET reset, fit every open position on an account with
|
* Before each 20:00 ET reset, fit every open position on an account with
|
||||||
* a daily limit into the room the reset will leave (resetSafeStop): a
|
* a daily limit into the room the reset will leave (resetSafeStop): a
|
||||||
* leg carrying more room than that — a same-day stage on the room a
|
* leg carrying more room than that — a same-day stage on the room a
|
||||||
* payout freed, a last stand — would otherwise breach the moment the
|
* payout freed, a last stand, a split day's leg widened by its gain, a
|
||||||
* floor moves up to 97% of the realized balance. Its stop comes in, or
|
* hand trade — would otherwise breach the moment the floor moves up to
|
||||||
* the position closes when there is no room or the mark is already past.
|
* 97% of the realized balance. Its stop comes in — on the profit side of
|
||||||
* Runs in the last RESET_GUARD_MS; a pass that fails retries next tick.
|
* the entry when the account's other positions took the room — or the
|
||||||
|
* position closes when the mark is already past that price. An account's
|
||||||
|
* positions share the room: those with a stop first, least at risk
|
||||||
|
* first, then those with none, oldest first.
|
||||||
|
*
|
||||||
|
* Every tick of the last RESET_GUARD_MS, on positions read afresh, so one
|
||||||
|
* opened by hand after the first pass is fitted too; no new position
|
||||||
|
* opens in that window (RESET_ENTRY_BLOCK_MS).
|
||||||
*/
|
*/
|
||||||
private async guardReset(): Promise<void> {
|
private async guardReset(): Promise<void> {
|
||||||
if (!this.syncComplete || this.intentionalDisconnect) return;
|
if (!this.syncComplete || this.intentionalDisconnect || this.resetGuardInFlight) return;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const next = nextVestResetMs(now);
|
const next = nextVestResetMs(now);
|
||||||
if (!Number.isFinite(next) || next - now > RESET_GUARD_MS || this.resetGuardedFor === next) return;
|
if (!Number.isFinite(next) || next - now > RESET_GUARD_MS) return;
|
||||||
if (!vestSessionFor(this.wallet, now, USER_TOKEN_MIN_MS)) return;
|
if (this.resetGuard.reset !== next) {
|
||||||
|
this.resetGuard = { reset: next, stops: new Map(), closes: new Map(), noSessionLogged: false, unfittable: new Set() };
|
||||||
|
}
|
||||||
|
const guard = this.resetGuard;
|
||||||
|
if (!vestSessionFor(this.wallet, now, USER_TOKEN_MIN_MS)) {
|
||||||
|
const open = [...this.positionsDetail.values()].flat().length;
|
||||||
|
if (open > 0 && !guard.noSessionLogged) {
|
||||||
|
guard.noSessionLogged = true;
|
||||||
|
console.error(`${this.tag} ${open} open position(s) can't be fitted to the 8 PM ET reset — no Vest session (open the Vest tab)`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.resetGuardInFlight = true;
|
||||||
|
try {
|
||||||
const specs = await this.symbolSpecs();
|
const specs = await this.symbolSpecs();
|
||||||
let failed = 0;
|
const { data } = await this.request<{ positions?: unknown[] }>('trade', 'GET', '/v3/positions/opened');
|
||||||
|
const open = (data?.positions ?? []).map(parsePosition).filter((p): p is VestPosition => p !== null);
|
||||||
|
const now2 = Date.now();
|
||||||
for (const acc of this.accountList) {
|
for (const acc of this.accountList) {
|
||||||
const a = this.capital.get(acc.id);
|
const a = this.capital.get(acc.id);
|
||||||
const cash = this.accountCashBalances[acc.id];
|
const cash = this.accountCashBalances[acc.id];
|
||||||
const uuid = this.uuidById.get(acc.id);
|
const uuid = this.uuidById.get(acc.id);
|
||||||
if (!a || !cash || !uuid || !(a.maxDailyLossPct > 0)) continue;
|
if (!a || !cash || !uuid || !(a.maxDailyLossPct > 0)) continue;
|
||||||
for (const pos of this.positionsDetail.get(acc.id) ?? []) {
|
// The account's positions share the room: each is fitted to
|
||||||
|
// what the ones before it leave — their loss at their stops,
|
||||||
|
// and a reserve for the slippage of all of them together.
|
||||||
|
// Those with a stop go first, the least at risk first, so one
|
||||||
|
// that already fits keeps it; those with none get the rest.
|
||||||
|
// The stop Vest shows wins (moved by hand after the guard, it
|
||||||
|
// is fitted again); one the guard just placed counts only
|
||||||
|
// while a read may not show it yet. A close just sent is left
|
||||||
|
// for the same while.
|
||||||
|
const stopOf = (p: VestPosition): number | null => {
|
||||||
|
const placed = guard.stops.get(p.positionId);
|
||||||
|
return p.stopLosses.find((l) => l.status === 'awaiting')?.triggerPrice
|
||||||
|
?? (placed && now2 - placed.at < GUARD_ECHO_MS ? placed.price : null);
|
||||||
|
};
|
||||||
|
// What the position loses at its stop — negative when the stop
|
||||||
|
// locks in a profit, which the others may then use.
|
||||||
|
const lossAt = (p: VestPosition, stop: number) => (p.side === 'long' ? p.openPrice - stop : stop - p.openPrice) * p.quantity;
|
||||||
|
const held = open
|
||||||
|
.filter((p) => p.accountId === uuid && now2 - (guard.closes.get(p.positionId) ?? 0) >= GUARD_ECHO_MS)
|
||||||
|
.map((p) => ({ pos: p, currentStop: stopOf(p) }))
|
||||||
|
.sort((x, y) => (x.currentStop === null ? 1 : 0) - (y.currentStop === null ? 1 : 0)
|
||||||
|
|| (x.currentStop !== null && y.currentStop !== null ? lossAt(x.pos, x.currentStop) - lossAt(y.pos, y.currentStop) : 0)
|
||||||
|
|| x.pos.openedAt - y.pos.openedAt || x.pos.positionId.localeCompare(y.pos.positionId));
|
||||||
|
let committed = 0;
|
||||||
|
let notionalSoFar = 0;
|
||||||
|
for (const { pos, currentStop } of held) {
|
||||||
const spec = specs.get(pos.symbol);
|
const spec = specs.get(pos.symbol);
|
||||||
if (!spec) continue;
|
if (!spec) {
|
||||||
|
// Can't be fitted without its tick, but it still holds
|
||||||
|
// its loss against the room the others share.
|
||||||
|
notionalSoFar += pos.quantity * pos.openPrice;
|
||||||
|
if (currentStop !== null) committed += lossAt(pos, currentStop);
|
||||||
|
else if (!guard.unfittable.has(pos.positionId)) {
|
||||||
|
guard.unfittable.add(pos.positionId);
|
||||||
|
console.error(`${this.tag} ${acc.name}: ${pos.symbol} has no stop and no symbol spec — it can't be fitted to the 8 PM ET reset; close it or add a stop on Vest`);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
await this.refreshMarks([pos.symbol]).catch(() => { /* the last mark stands */ });
|
await this.refreshMarks([pos.symbol]).catch(() => { /* the last mark stands */ });
|
||||||
const mark = this.marks.get(pos.symbol)?.mark ?? NaN;
|
const mark = this.marks.get(pos.symbol)?.mark ?? NaN;
|
||||||
const stopLeg = pos.stopLosses.find((l) => l.status === 'awaiting') ?? null;
|
const stopLeg = pos.stopLosses.find((l) => l.status === 'awaiting') ?? null;
|
||||||
const notional = pos.quantity * (Number.isFinite(mark) ? mark : pos.openPrice);
|
const notional = pos.quantity * (Number.isFinite(mark) ? mark : pos.openPrice);
|
||||||
|
notionalSoFar += notional;
|
||||||
|
const exitFee = spec.takerFee * notional;
|
||||||
const decision = resetSafeStop({
|
const decision = resetSafeStop({
|
||||||
side: pos.side, quantity: pos.quantity, openPrice: pos.openPrice, mark,
|
side: pos.side, quantity: pos.quantity, openPrice: pos.openPrice, mark,
|
||||||
balance: cash.amount, dailyLossPct: a.maxDailyLossPct, maxDrawdownFloor: a.maxDrawdownLimit,
|
balance: cash.amount - committed, dailyLossPct: a.maxDailyLossPct, maxDrawdownFloor: a.maxDrawdownLimit,
|
||||||
reserve: dllReserve(cash.amount * a.maxDailyLossPct, notional),
|
floorBalance: cash.amount,
|
||||||
exitFeeRate: spec.takerFee, currentStop: stopLeg?.triggerPrice ?? null, tick: spec.tick,
|
reserve: dllReserve(cash.amount * a.maxDailyLossPct, notionalSoFar),
|
||||||
|
exitFeeRate: spec.takerFee, currentStop, tick: spec.tick,
|
||||||
});
|
});
|
||||||
|
const stopAt = decision.action === 'stop' ? decision.price : decision.action === 'none' ? currentStop : null;
|
||||||
|
if (stopAt !== null) committed += lossAt(pos, stopAt) + exitFee;
|
||||||
if (decision.action === 'none') continue;
|
if (decision.action === 'none') continue;
|
||||||
try {
|
try {
|
||||||
if (decision.action === 'close') {
|
if (decision.action === 'close') {
|
||||||
await this.submit(uuid, '/v3/positions/close', { positionId: pos.positionId, symbol: pos.symbol, orderType: 'market' });
|
await this.submit(uuid, '/v3/positions/close', { positionId: pos.positionId, symbol: pos.symbol, orderType: 'market' });
|
||||||
|
guard.closes.set(pos.positionId, Date.now());
|
||||||
this.markOwn('closed', acc.id, pos);
|
this.markOwn('closed', acc.id, pos);
|
||||||
console.warn(`${this.tag} ${acc.name}: closed ${pos.symbol} before the 8 PM ET reset — no room for it past the reset`);
|
console.warn(`${this.tag} ${acc.name}: closed ${pos.symbol} before the 8 PM ET reset — no room for it past the reset`);
|
||||||
} else {
|
} else {
|
||||||
@@ -840,15 +1032,29 @@ export class VestClient implements PlatformClient {
|
|||||||
} else {
|
} else {
|
||||||
await this.submit(uuid, '/v3/positions/stop-loss', { positionId: pos.positionId, executionType: 'market', triggerPrice });
|
await this.submit(uuid, '/v3/positions/stop-loss', { positionId: pos.positionId, executionType: 'market', triggerPrice });
|
||||||
}
|
}
|
||||||
console.warn(`${this.tag} ${acc.name}: stop on ${pos.symbol} moved to ${triggerPrice} for the 8 PM ET reset (was ${stopLeg?.triggerPrice ?? 'none'})`);
|
guard.stops.set(pos.positionId, { price: decision.price, at: Date.now() });
|
||||||
|
console.warn(`${this.tag} ${acc.name}: stop on ${pos.symbol} moved to ${triggerPrice} for the 8 PM ET reset (was ${currentStop ?? 'none'})`);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
failed++;
|
|
||||||
console.error(`${this.tag} ${acc.name}: couldn't fit ${pos.symbol} to the reset: ${errorMessage(err)}`);
|
console.error(`${this.tag} ${acc.name}: couldn't fit ${pos.symbol} to the reset: ${errorMessage(err)}`);
|
||||||
|
// A stop on the profit side that Vest won't take: the
|
||||||
|
// position can't carry its old one past the reset.
|
||||||
|
if (decision.action === 'stop' && lossAt(pos, decision.price) < 0) {
|
||||||
|
try {
|
||||||
|
await this.submit(uuid, '/v3/positions/close', { positionId: pos.positionId, symbol: pos.symbol, orderType: 'market' });
|
||||||
|
guard.closes.set(pos.positionId, Date.now());
|
||||||
|
this.markOwn('closed', acc.id, pos);
|
||||||
|
console.warn(`${this.tag} ${acc.name}: closed ${pos.symbol} before the 8 PM ET reset — its profit-side stop was refused`);
|
||||||
|
} catch (closeErr) {
|
||||||
|
console.error(`${this.tag} ${acc.name}: couldn't close ${pos.symbol} either: ${errorMessage(closeErr)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (failed === 0) this.resetGuardedFor = next;
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.resetGuardInFlight = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private settleSuspects(): void {
|
private settleSuspects(): void {
|
||||||
@@ -1130,6 +1336,7 @@ export class VestClient implements PlatformClient {
|
|||||||
.sort((x, y) => x.date.localeCompare(y.date));
|
.sort((x, y) => x.date.localeCompare(y.date));
|
||||||
this.applyFundRows(account, fundRows);
|
this.applyFundRows(account, fundRows);
|
||||||
this.withdrawals.set(account.id, txns.filter((t) => t.type === VEST_TXN.WITHDRAW).map((t) => ({ at: t.time, amount: t.amount })));
|
this.withdrawals.set(account.id, txns.filter((t) => t.type === VEST_TXN.WITHDRAW).map((t) => ({ at: t.time, amount: t.amount })));
|
||||||
|
this.historyReadAt.set(account.id, Date.now());
|
||||||
|
|
||||||
// Books check: the balance must be what the transactions add up to
|
// Books check: the balance must be what the transactions add up to
|
||||||
// (the opening capital included). A payout or credit booked under a
|
// (the opening capital included). A payout or credit booked under a
|
||||||
@@ -1278,6 +1485,13 @@ export class VestClient implements PlatformClient {
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
const board: { symbol: string; score: number | null; note: string; contract?: PlatformContract }[] = [];
|
const board: { symbol: string; score: number | null; note: string; contract?: PlatformContract }[] = [];
|
||||||
|
// No new position this close to the reset: nothing picked, and the
|
||||||
|
// last survey's candidates stay up for the dashboard.
|
||||||
|
const toReset = nextVestResetMs(now) - now;
|
||||||
|
if (root !== 'survey' && toReset <= RESET_ENTRY_BLOCK_MS) {
|
||||||
|
console.log(`${this.tag} ${root} → nothing tradable — Vest's 8 PM ET reset in ${Math.ceil(toReset / 60_000)} min`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
for (const symbol of symbols) {
|
for (const symbol of symbols) {
|
||||||
const spec = specs.get(symbol);
|
const spec = specs.get(symbol);
|
||||||
if (!spec) { board.push({ symbol, score: null, note: 'unknown symbol' }); continue; }
|
if (!spec) { board.push({ symbol, score: null, note: 'unknown symbol' }); continue; }
|
||||||
@@ -1456,6 +1670,10 @@ export class VestClient implements PlatformClient {
|
|||||||
if (!meta || meta.status !== VEST_STATUS_ACTIVE) {
|
if (!meta || meta.status !== VEST_STATUS_ACTIVE) {
|
||||||
throw new OrderNotPlacedError(`account status is ${meta?.status ?? 'unknown'} — order not sent`);
|
throw new OrderNotPlacedError(`account status is ${meta?.status ?? 'unknown'} — order not sent`);
|
||||||
}
|
}
|
||||||
|
const toReset = nextVestResetMs(Date.now()) - Date.now();
|
||||||
|
if (toReset <= RESET_ENTRY_BLOCK_MS) {
|
||||||
|
throw new OrderNotPlacedError(`Vest's 8 PM ET reset is ${Math.ceil(toReset / 60_000)} min away — no new position until it passes`);
|
||||||
|
}
|
||||||
const opposite = side === 'long' ? 'short' : 'long';
|
const opposite = side === 'long' ? 'short' : 'long';
|
||||||
const hedged = [...this.positionsDetail.values()].flat().some((p) => p.symbol === symbol && p.side === opposite)
|
const hedged = [...this.positionsDetail.values()].flat().some((p) => p.symbol === symbol && p.side === opposite)
|
||||||
|| this.openingSides.get(symbol)?.side === opposite;
|
|| this.openingSides.get(symbol)?.side === opposite;
|
||||||
|
|||||||
+13
-4
@@ -112,15 +112,20 @@ export function nextVestResetMs(now: number): number {
|
|||||||
* 'none' — its stop is already at least that tight;
|
* 'none' — its stop is already at least that tight;
|
||||||
* 'stop' — move (or place) the stop at `price`, on the tick grid, rounded
|
* 'stop' — move (or place) the stop at `price`, on the tick grid, rounded
|
||||||
* toward the position's safe side;
|
* toward the position's safe side;
|
||||||
* 'close' — no room at all, or the mark is already past that price.
|
* '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: {
|
export function resetSafeStop(o: {
|
||||||
side: 'long' | 'short';
|
side: 'long' | 'short';
|
||||||
quantity: number;
|
quantity: number;
|
||||||
openPrice: number;
|
openPrice: number;
|
||||||
mark: number;
|
mark: number;
|
||||||
/** Realized balance, collateral included — what the reset captures. */
|
/** Realized balance, collateral included — what the reset captures —
|
||||||
|
* less what the account's other positions may lose at their stops. */
|
||||||
balance: number;
|
balance: number;
|
||||||
|
/** The realized balance the floor is set from; default `balance`. */
|
||||||
|
floorBalance?: number;
|
||||||
dailyLossPct: number;
|
dailyLossPct: number;
|
||||||
maxDrawdownFloor: number | undefined;
|
maxDrawdownFloor: number | undefined;
|
||||||
reserve: number;
|
reserve: number;
|
||||||
@@ -129,10 +134,14 @@ export function resetSafeStop(o: {
|
|||||||
currentStop: number | null;
|
currentStop: number | null;
|
||||||
tick: number;
|
tick: number;
|
||||||
}): { action: 'none' } | { action: 'stop'; price: number } | { action: 'close' } {
|
}): { action: 'none' } | { action: 'stop'; price: number } | { action: 'close' } {
|
||||||
const floor = Math.max(o.balance * (1 - o.dailyLossPct), o.maxDrawdownFloor ?? -Infinity);
|
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 exitFee = o.exitFeeRate * o.quantity * (Number.isFinite(o.mark) && o.mark > 0 ? o.mark : o.openPrice);
|
||||||
const allowed = o.balance - floor - o.reserve - exitFee;
|
const allowed = o.balance - floor - o.reserve - exitFee;
|
||||||
if (!(allowed > 0) || !(o.quantity > 0)) return { action: 'close' };
|
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 long = o.side === 'long';
|
||||||
const raw = long ? o.openPrice - allowed / o.quantity : o.openPrice + allowed / o.quantity;
|
const raw = long ? o.openPrice - allowed / o.quantity : o.openPrice + allowed / o.quantity;
|
||||||
const price = o.tick > 0
|
const price = o.tick > 0
|
||||||
|
|||||||
+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 scripts/check-vest.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-trades.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@microsoft/signalr": "^10.0.11",
|
"@microsoft/signalr": "^10.0.11",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
projectPerfectPath, cycleCompletion, DLL_RESERVE_FRACTION, DLL_MIN_STOP, notionalCap, expectedSwapToWin, MARGIN_HEADROOM,
|
projectPerfectPath, cycleCompletion, 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, machineSide,
|
currentTradeDateCT, foldReason, withinFirmCaps, personSizeFactor, personHoldHours, PERSON_SIZE_SPREAD, machineSide,
|
||||||
resolveEffectiveConfig, stageInputsFor, floorRoom, computeDayPlan,
|
resolveEffectiveConfig, stageInputsFor, floorRoom, resetClearStop, shortestNextLeg, computeDayPlan,
|
||||||
} from '../lib/trading-logic.ts';
|
} from '../lib/trading-logic.ts';
|
||||||
|
|
||||||
// Deterministic PRNG so a failure reproduces.
|
// Deterministic PRNG so a failure reproduces.
|
||||||
@@ -441,6 +441,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);
|
||||||
@@ -476,6 +483,54 @@ 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('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('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'));
|
||||||
@@ -999,14 +1054,26 @@ test('same-day stages: today\'s payouts pick the stage, and a stage may carry it
|
|||||||
assert.deepEqual(stageInputsFor({}, 7, { stagesSameDay: true }), { dailyLossLimit: 0, stagesSameDay: true, sameDayWithdrawals: 0 });
|
assert.deepEqual(stageInputsFor({}, 7, { stagesSameDay: true }), { dailyLossLimit: 0, stagesSameDay: true, sameDayWithdrawals: 0 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('floorRoom: the platform\'s own floor, never more than what\'s left of the stage\'s limit', () => {
|
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.
|
// Fresh day: $50k, Vest floor $48,500, the type's $1,229.
|
||||||
assert.equal(floorRoom({ dailyLossLimit: 1229, closedBalance: 50000, floor: 48500, realizedToday: 0 }), 1229);
|
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
|
// Lost $1,111.65 today: Vest still allows $388.35, but the bot's own
|
||||||
// $1,229 day has only $117.35 left.
|
// $1,229 day has only $117.35 left.
|
||||||
assert.equal(floorRoom({ dailyLossLimit: 1229, closedBalance: 48888.35, floor: 48500, realizedToday: -1111.65 }), 117.35);
|
assert.equal(floorRoom({ dailyLossLimit: 1229, closedBalance: 48888.35, floor: 48500, realizedToday: -1111.65 }), 117.35);
|
||||||
// A green day doesn't widen the limit.
|
// A green day alone doesn't widen the limit...
|
||||||
assert.equal(floorRoom({ dailyLossLimit: 1229, closedBalance: 50600, floor: 48500, realizedToday: 600 }), 1229);
|
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,
|
// 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.
|
// 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);
|
assert.equal(floorRoom({ dailyLossLimit: 3000, closedBalance: 50000, floor: 47000, realizedToday: 0 }), 3000);
|
||||||
|
|||||||
@@ -263,6 +263,24 @@ test('resetSafeStop: fit an open position into the room the reset will leave', (
|
|||||||
const dd = resetSafeStop({ ...base, maxDrawdownFloor: 49000, currentStop: null });
|
const dd = resetSafeStop({ ...base, maxDrawdownFloor: 49000, currentStop: null });
|
||||||
assert.equal(dd.action, 'stop');
|
assert.equal(dd.action, 'stop');
|
||||||
assert.ok(dd.price > want);
|
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', () => {
|
test('parseHistoryPosition: a closed position from GET /v3/positions', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user