Compare commits

...
3 Commits
Author SHA1 Message Date
Brandon LiandClaude Opus 5.5 016444239e Trades: an open position shows no close price or P&L
Vest's position history gives an open position a placeholder close price
and a $0 P&L (290.25 on an NDX short at 29,538.96). Only a closed
position keeps them now — in the parser, in the stored row (a placeholder
already stored is cleared on the next read) and in the API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 20:02:11 -05:00
Brandon LiandClaude Opus 5.5 a03bfb5605 Vest: close out trades left open on an account Vest stopped listing
An account that leaves Vest's active list (breached, closed) was never
read again, so a position it had open stayed open in the trades table.
When the list shrinks, and on every full history pass, the client reads
such an account's position history once more per run — its id from
memory or, after a restart, from Vest's closed-account history
(GET /v3/capital/accounts/history) — falling back to the login's token
if the closed account gets none of its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 20:00:43 -05:00
Brandon LiandClaude Opus 5.5 71606ffc9e Claim now: a button on the account page claims a payout account's profit
For a payout account on a firm that takes claims by API (Vest), the
account page shows what it could claim right now — its whole profit above
the account size — or why not (not flat, history loading, below its
start). Pressing it asks first, then claims to the login's main account
under the lane's mutex, with the same checks and bookkeeping as the
bot's own claim at a stage's target: the account trades its next stage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 19:44:10 -05:00
9 changed files with 325 additions and 19 deletions
+5
View File
@@ -256,6 +256,11 @@ own traffic since Vest publishes no API):
so the bot marks its own opens and closes as it sends them; anything
unmarked since marking began was someone else's. The **Trades** button on
the Vest firm's dashboard row lists them, and the account page shows its own.
- Claims: the bot claims a funded account's profit by itself at each stage's
target (`auto_claim`, on unless set to `off`). The account page's **Claim
now** button claims the whole profit above the account size at once — any
amount from $1 — when the account is flat; it asks first, since it moves
money to the login's main account.
`npm run check` includes the Vest helpers (scripts/check-vest.mjs).
+104 -1
View File
@@ -83,6 +83,106 @@ function fmt(value: number) {
return value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
/** Claim now: the account's whole profit above its size, to the firm's main
* account (Vest's Claim Profit) — for payout accounts on a firm that takes
* claims by API. Confirmed inline, since it moves money. */
function ClaimNowButton({ accountId, onDone }: { accountId: number; onDone: () => void }) {
type Claimable = { claimable: number; target: number | null; firm: string } | { claimable: null; reason: string; unsupported: boolean };
const [info, setInfo] = useState<Claimable | null>(null);
const [confirming, setConfirming] = useState(false);
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null);
const check = useCallback(async () => {
try {
const res = await fetch(`/api/accounts/${accountId}/claim`, { cache: 'no-store' });
if (res.ok) setInfo(await res.json());
} catch {
/* keep the last answer */
}
}, [accountId]);
useEffect(() => {
void check();
const t = setInterval(() => { void check(); }, 10_000);
return () => clearInterval(t);
}, [check]);
async function claim(amount: number) {
setBusy(true);
setMessage(null);
try {
const res = await fetch(`/api/accounts/${accountId}/claim`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount }),
});
const body = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`);
setMessage({
ok: true,
text: `Claimed $${fmt(body.claimed)}${body.received != null ? ` — $${fmt(body.received)} to your main account` : ''} · stage ${body.stage} next`,
});
onDone();
} catch (err) {
setMessage({ ok: false, text: err instanceof Error ? err.message : 'Claim failed' });
} finally {
setBusy(false);
setConfirming(false);
void check();
}
}
if (!info || (info.claimable === null && info.unsupported)) return null;
if (confirming && info.claimable !== null) {
const amount = info.claimable;
return (
<div className="flex items-center gap-2">
<span className="text-xs text-slate-600">
Claim ${fmt(amount)} to your {info.firm} main account? The firm pays your split of it.
</span>
<button
onClick={() => { void claim(amount); }}
disabled={busy}
className="px-2.5 py-1 bg-emerald-600 hover:bg-emerald-700 disabled:opacity-50 text-white text-xs font-semibold rounded-md transition-colors"
>
{busy ? 'Claiming…' : `Claim $${fmt(amount)}`}
</button>
<button
onClick={() => setConfirming(false)}
disabled={busy}
className="px-2.5 py-1 bg-slate-100 hover:bg-slate-200 disabled:opacity-50 text-slate-600 text-xs font-semibold rounded-md transition-colors"
>
Cancel
</button>
</div>
);
}
return (
<div className="flex items-center gap-2">
{message && <span className={`text-xs ${message.ok ? 'text-emerald-600' : 'text-red-500'}`}>{message.text}</span>}
{info.claimable !== null ? (
<button
onClick={() => { setMessage(null); setConfirming(true); }}
className="px-2.5 py-1 bg-emerald-50 hover:bg-emerald-100 text-emerald-700 text-xs font-semibold rounded-md transition-colors"
title={`Claim the whole profit above the account size now${info.target !== null ? ` (the bot claims by itself at the stage's $${fmt(info.target)} target)` : ''}`}
>
Claim now · ${fmt(info.claimable)}
</button>
) : (
<span
className="px-2.5 py-1 bg-slate-100 text-slate-400 text-xs font-semibold rounded-md cursor-not-allowed"
title={info.reason}
>
Claim now — {info.reason}
</span>
)}
</div>
);
}
/** Wipes the account's cached P&L / fund / meta rows and refetches from
* Tradovate. Confirmed inline because rows older than the report's 28-day
* window cannot come back. */
@@ -588,7 +688,10 @@ export default function AccountPage() {
☠ DEAD
</span>
)}
<WipeDataButton accountId={account.id} onDone={() => { void load(); }} />
<div className="ml-auto flex items-center gap-2">
<ClaimNowButton accountId={account.id} onDone={() => { void load(); }} />
<WipeDataButton accountId={account.id} onDone={() => { void load(); }} />
</div>
</div>
{isDead && (
+34
View File
@@ -0,0 +1,34 @@
import { NextResponse } from 'next/server';
import { claimableNow, claimAccountNow, ClaimRefusedError } from '@/lib/auto-trade';
import { errorMessage } from '@/lib/platform-client';
/**
* Claim now (the account page's button). GET: how much the account could
* claim right now — its whole profit above the account size — or why not.
* POST { amount }: claim it, to the login's main account (Vest's Claim
* Profit). Only a person pressing the button calls this; the bot claims on
* its own at each stage's target (runClaims).
*/
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return NextResponse.json(claimableNow(Number(id)));
}
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
// JSON only: a cross-site form or no-cors fetch can't send it without a
// preflight this route never answers.
if (!(req.headers.get('content-type') ?? '').includes('application/json')) {
return NextResponse.json({ error: 'JSON body required' }, { status: 415 });
}
const { id } = await params;
const body = await req.json().catch(() => ({})) as { amount?: unknown };
const amount = Number(body.amount);
if (!Number.isFinite(amount)) return NextResponse.json({ error: 'amount required' }, { status: 400 });
try {
return NextResponse.json({ ok: true, ...await claimAccountNow(Number(id), amount) });
} catch (err) {
if (err instanceof ClaimRefusedError) return NextResponse.json({ error: err.message }, { status: 400 });
console.error(`[claim-now] account ${id}: ${errorMessage(err)}`);
return NextResponse.json({ error: `the claim failed: ${errorMessage(err)}` }, { status: 502 });
}
}
+2 -2
View File
@@ -41,8 +41,8 @@ export async function GET(req: Request) {
side: t.side,
quantity: t.quantity,
openPrice: t.openPrice,
closePrice: t.closePrice,
pnl: t.pnl,
closePrice: open ? null : t.closePrice,
pnl: open ? null : t.pnl,
fee: t.fee,
funding: t.funding,
net,
+93 -7
View File
@@ -1989,13 +1989,7 @@ async function runClaims(lane: string, ready: FirmClient[]): Promise<void> {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { claimed, received } = await (client as any).claimProfit(acc.id, amount);
runtime().legCounts.delete(acc.id);
runtime().entryOutcomes?.delete(acc.id);
runtime().claimFailedAt?.delete(acc.id);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const stage = 1 + ((client as any).sameDayClaims?.(acc.id) ?? 0);
console.log(`[auto-claim] ${firm.name} ${acc.name}: claimed $${claimed}${received !== null ? ` (you get $${received})` : ''}`
+ ` — trading stage ${stage} next (${tradeDate})`);
afterClaim('[auto-claim]', firm, client, acc, claimed, received, tradeDate);
} catch (err) {
(runtime().claimFailedAt ??= new Map()).set(acc.id, Date.now());
console.error(`[auto-claim] ${firm.name} ${acc.name}: claim of $${Math.floor(amount * 100) / 100} failed — ${errorMessage(err)}; retrying in ${CLAIM_RETRY_MS / 60_000}m`);
@@ -2006,6 +2000,98 @@ async function runClaims(lane: string, ready: FirmClient[]): Promise<void> {
}
}
/** 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. */
function afterClaim(
tag: string, firm: TradableFirm, client: PlatformClient, acc: { id: number; name: string },
claimed: number, received: number | null, tradeDate: string,
): number {
runtime().legCounts.delete(acc.id);
runtime().entryOutcomes?.delete(acc.id);
runtime().claimFailedAt?.delete(acc.id);
const stage = 1 + (client.sameDayClaims?.(acc.id) ?? 0);
console.log(`${tag} ${firm.name} ${acc.name}: claimed $${claimed}${received !== null ? ` (you get $${received})` : ''}`
+ ` — trading stage ${stage} next (${tradeDate})`);
return stage;
}
/** A Claim now the account can't take as asked — said back to the person
* as is (the claim never reached the platform). */
export class ClaimRefusedError extends Error {}
/** Whether an account can be claimed by hand right now, and how much: its
* whole profit above the account size, whatever its stage's target. */
function manualClaim(accountId: number):
| { firm: TradableFirm; client: PlatformClient; acc: { id: number; name: string }; amount: number; target: number | null }
| { reason: string; unsupported?: true } {
const { ready, unready } = firmClients();
const holds = ({ client }: FirmClient) => client.accountList.some((a) => a.id === accountId);
const item = ready.find(holds);
if (!item) {
const stale = unready.find(holds);
if (stale && typeof stale.client.claimProfit !== 'function') return { reason: `${stale.firm.name} takes no claims through its API`, unsupported: true };
return { reason: stale ? `${stale.firm.name} hasn't synced recently — wait for its next sync` : 'the account is not on a connected firm' };
}
const { firm, client } = item;
if (typeof client.claimProfit !== 'function') return { reason: `${firm.name} takes no claims through its API`, unsupported: true };
const acc = client.accountList.find((a) => a.id === accountId)!;
const cfg = getAccountConfig(acc.id).accountType;
if (!cfg) return { reason: 'no account type is assigned' };
if (cfg.payoutable !== true) return { reason: `${cfg.label} doesn't take payouts`, unsupported: true };
if (!acc.active) return { reason: `${firm.name} shows the account inactive` };
if (client.positions[acc.id] || client.hasWorkingOrders?.(acc.id) || runtime().accountsInFlight.has(acc.id)) {
return { reason: 'a position or order is open — the account must be flat' };
}
if (!client.historyReady(acc.id)) return { reason: 'its history is still loading' };
const cash = client.accountCashBalances[acc.id];
if (!cash) return { reason: 'its balance has not been read yet' };
const profit = Math.floor((cash.amount - cfg.accountSize) * 100) / 100;
if (!(profit >= 1)) {
return { reason: `$${Math.abs(profit).toFixed(2)} ${profit < 0 ? 'below' : 'above'} its $${cfg.accountSize.toLocaleString()} start — nothing to claim (the least is $1)` };
}
const effective = resolveEffectiveConfig(
cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity, cfg.withdrawalStages,
client.priorProfit?.[acc.id] ?? 0, client.fundTransactions?.[acc.id] ?? [], cfg.minDayPnL,
stageInputsFor(client, acc.id, cfg),
);
return { firm, client, acc, amount: profit, target: effective.profitTarget > 0 ? effective.profitTarget : null };
}
/** The dashboard's view of manualClaim. */
export function claimableNow(accountId: number):
| { claimable: number; target: number | null; firm: string }
| { claimable: null; reason: string; unsupported: boolean } {
const c = manualClaim(accountId);
if ('reason' in c) return { claimable: null, reason: c.reason, unsupported: c.unsupported === true };
return { claimable: c.amount, target: c.target, firm: c.firm.name };
}
/**
* Claim now, from the dashboard: the person pressed it, so the stage's
* target doesn't matter — any profit from $1 up to all of it above the
* account size. The same checks as an automatic claim, made again under the
* lane's mutex (no batch reads the account mid-claim), and the same
* bookkeeping after: the account trades its next stage.
*/
export async function claimAccountNow(accountId: number, amount: number): Promise<{ claimed: number; received: number | null; stage: number }> {
const first = manualClaim(accountId);
if ('reason' in first) throw new ClaimRefusedError(first.reason);
const lane = laneOf(first.firm);
const lease = acquireTradeMutex('[claim-now]', lane);
if (lease === null) throw new ClaimRefusedError(`the bot is placing a trade on ${first.firm.name} right now — try again in a minute`);
try {
const c = manualClaim(accountId);
if ('reason' in c) throw new ClaimRefusedError(c.reason);
if (!(amount >= 1)) throw new ClaimRefusedError(`$${amount} is below the $1 minimum claim`);
if (amount > c.amount + 0.005) throw new ClaimRefusedError(`only $${c.amount.toFixed(2)} is claimable now`);
const { claimed, received } = await c.client.claimProfit!(c.acc.id, amount);
const stage = afterClaim('[claim-now]', c.firm, c.client, c.acc, claimed, received, currentTradeDateCT());
return { claimed, received, stage };
} finally {
releaseTradeMutex(lease, lane);
}
}
// ── eligibility check ────────────────────────────────────────────────────────
/** Returns true if any configured account could still trade today (eligible for a next leg / extra-day, or has an open position). */
+10 -2
View File
@@ -652,8 +652,10 @@ export function saveTrades(rows: Omit<TradeRow, 'openedBy' | 'closedBy'>[]): voi
quantity = CASE WHEN excluded.quantity > 0 THEN excluded.quantity ELSE trades.quantity END,
leverage = COALESCE(excluded.leverage, trades.leverage),
open_price = COALESCE(excluded.open_price, trades.open_price),
close_price = COALESCE(excluded.close_price, trades.close_price),
pnl = COALESCE(excluded.pnl, trades.pnl),
close_price = CASE WHEN COALESCE(excluded.closed_at, trades.closed_at) IS NULL THEN NULL
ELSE COALESCE(excluded.close_price, trades.close_price) END,
pnl = CASE WHEN COALESCE(excluded.closed_at, trades.closed_at) IS NULL THEN NULL
ELSE COALESCE(excluded.pnl, trades.pnl) END,
fee = COALESCE(excluded.fee, trades.fee),
funding = COALESCE(excluded.funding, trades.funding),
opened_at = excluded.opened_at,
@@ -682,6 +684,12 @@ export function markTrade(
`).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. */
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')
.all() as { accountId: number; accountName: string }[];
}
/** Newest first (by close, else open); every account when accountIds is
* undefined. */
export function loadTrades(o: { accountIds?: number[]; limit: number }): TradeRow[] {
+63 -3
View File
@@ -42,7 +42,7 @@ import { currentTradeDateCT, rankForTarget, dllReserve } from './trading-logic';
import { sizingHoldHours } from './person-size';
import {
saveDailyPnL, loadDailyPnL, loadAccountMeta, saveAccountMeta, loadFundTransactions, saveFundTransactions,
saveEndedCycleStart, saveTrades, markTrade, getOrCreateSetting, TRADES_SINCE_SETTING,
saveEndedCycleStart, saveTrades, markTrade, getOrCreateSetting, TRADES_SINCE_SETTING, openTradeAccounts,
} from './db';
import { getCachedFirm } from './firm-catalog';
import { firmInstruments } from './instruments';
@@ -90,6 +90,10 @@ const TXN_MAX_PAGES = 200;
/** Position history (GET /v3/positions) pages, for the trades table. */
const TRADES_PAGE = 50;
const TRADES_MAX_PAGES = 20;
/** Closed-account history (GET /v3/capital/accounts/history) pages: the
* app's own size, and how far back to look for one. */
const CLOSED_PAGE = 8;
const CLOSED_MAX_PAGES = 30;
/** Balance the books can't explain by this much holds the account. */
const BOOKS_TOLERANCE = 200;
const SUSPECT_SETTLE_MS = 90_000;
@@ -218,6 +222,10 @@ export class VestClient implements PlatformClient {
/** Open positions already in the trades table, by id → size stored. */
private recordedOpen = new Map<string, number>();
private flatAmounts = new Map<number, number>();
/** Accounts off Vest's active list whose leftover open trades were
* read once more this run (closeOutDropped). */
private closeOutTried = new Set<number>();
private closeOutInFlight = false;
/** When a relayed token last turned out to be one account's own. */
private scopedRefusedAt = 0;
@@ -579,7 +587,9 @@ export class VestClient implements PlatformClient {
this.lastFetchRaw[`floors:${acc.name}`] = `dd ${a.maxDrawdownLimit ?? '?'}, daily ${this.dailyFloorOf(a, balance, now) ?? 'none'}`
+ `${a.targetEquity ? `, target ${a.targetEquity}` : ''}`;
}
const shrank = this.accountList.some((a) => !list.some((b) => b.id === a.id));
this.accountList = list;
if (shrank) void this.closeOutDropped();
// An account gone from Vest's list (breached, closed) takes its
// position with it — a stale one here would hold the lane.
for (const key of Object.keys(this.positions)) {
@@ -940,9 +950,59 @@ export class VestClient implements PlatformClient {
for (const id of ids) this.fetchingIds.delete(id);
this.fetchDaysComplete = true;
}
if (!onlyAccountIds) await this.closeOutDropped();
return { failedAccounts: failed };
}
/**
* Trades the table still shows open on accounts Vest no longer lists
* (breached, closed): read those accounts' position history once more,
* so they close out with how they ended. Their ids come from memory, or
* — after a restart — from Vest's closed-account history. Once per
* account per run; a failure is logged and left.
*/
private async closeOutDropped(): Promise<void> {
if (this.closeOutInFlight || this.intentionalDisconnect || !this.userToken()) return;
const listed = new Set(this.accountList.map((a) => a.id));
const stale = openTradeAccounts().filter((a) => !listed.has(a.accountId) && !this.closeOutTried.has(a.accountId));
if (stale.length === 0) return;
this.closeOutInFlight = true;
try {
let cursor: string | null = null;
for (let page = 0; page < CLOSED_MAX_PAGES && stale.some((a) => !this.uuidById.has(a.accountId)); page++) {
const { data } = await this.request<{ accounts?: unknown[] | null; next?: unknown }>('history', 'GET', '/v3/capital/accounts/history', {
params: { limit: CLOSED_PAGE, ...(cursor ? { cursor } : {}) },
});
for (const raw of data?.accounts ?? []) {
const a = parseCapitalAccount(raw);
if (a && !this.uuidById.has(vestAccountId(a.id))) this.uuidById.set(vestAccountId(a.id), a.id);
}
cursor = typeof data?.next === 'string' && data.next ? data.next : null;
if (!cursor) break;
}
for (const a of stale) {
this.closeOutTried.add(a.accountId);
if (!this.uuidById.has(a.accountId)) {
console.warn(`${this.tag} ${a.accountName}: trades left open, but Vest lists no such account, active or closed — left as they are`);
continue;
}
try {
const acc = { id: a.accountId, name: a.accountName, active: false };
// A closed account may get no token of its own: the
// login's reads it as the app's history tab does.
await this.fetchTrades(acc).catch(() => this.fetchTrades(acc, true));
console.log(`${this.tag} ${a.accountName}: off Vest's active list — read its position history one last time`);
} catch (err) {
console.warn(`${this.tag} ${a.accountName}: off Vest's active list, and its position history read failed: ${errorMessage(err)}`);
}
}
} catch (err) {
console.warn(`${this.tag} closed-account history read failed: ${errorMessage(err)}`);
} finally {
this.closeOutInFlight = false;
}
}
/** Every transaction on the account, newest page first, deduplicated. */
private async fetchTxns(uuid: string): Promise<VestTxn[]> {
const all = new Map<string, VestTxn>();
@@ -963,13 +1023,13 @@ export class VestClient implements PlatformClient {
/** The account's position history — GET /v3/positions, the app's History
* tab — into the trades table: every position, whoever opened it, with
* how it closed. */
private async fetchTrades(account: PlatformAccount): Promise<void> {
private async fetchTrades(account: PlatformAccount, viaLogin = false): Promise<void> {
const uuid = this.uuidById.get(account.id);
if (!uuid) return;
const trades: VestTrade[] = [];
for (let page = 0; page < TRADES_MAX_PAGES; page++) {
const { data } = await this.request<{ positions?: unknown[] | null; total?: unknown }>('history', 'GET', '/v3/positions', {
account: uuid, params: { account_id: uuid, limit: TRADES_PAGE, offset: page * TRADES_PAGE },
...(viaLogin ? {} : { account: uuid }), params: { account_id: uuid, limit: TRADES_PAGE, offset: page * TRADES_PAGE },
});
const rows = data?.positions ?? [];
for (const r of rows) {
+7 -4
View File
@@ -313,9 +313,12 @@ export function parseHistoryPosition(raw: unknown): VestTrade | null {
if (!p || typeof id !== 'string' || typeof p.symbol !== 'string') return null;
const openedAt = toMs(p.openDate ?? p.open_date ?? p.createdAt ?? p.created_at);
if (!Number.isFinite(openedAt)) return null;
const closedAt = toMs(p.closeDate ?? p.close_date);
const closedMs = toMs(p.closeDate ?? p.close_date);
const closedAt = Number.isFinite(closedMs) && closedMs > 0 ? closedMs : null;
const openPrice = num(p.openPrice ?? p.open_price);
const closePrice = num(p.closePrice ?? p.close_price);
// An open position comes with a placeholder close price and a $0 P&L
// (seen: 290.25 on an NDX short at 29,538.96): only a close has them.
const closePrice = closedAt === null ? undefined : num(p.closePrice ?? p.close_price);
const reason = p.closeReason ?? p.close_reason;
return {
positionId: id,
@@ -325,11 +328,11 @@ export function parseHistoryPosition(raw: unknown): VestTrade | null {
leverage: num(p.leverage) ?? null,
openPrice: openPrice !== undefined && openPrice > 0 ? openPrice : null,
closePrice: closePrice !== undefined && closePrice > 0 ? closePrice : null,
pnl: num(p.pnl) ?? null,
pnl: closedAt === null ? null : num(p.pnl) ?? null,
fee: num(p.fee) ?? null,
funding: num(p.funding) ?? null,
openedAt,
closedAt: Number.isFinite(closedAt) && closedAt > 0 ? closedAt : null,
closedAt,
closeReason: typeof reason === 'string' && reason !== '' ? reason : null,
};
}
+7
View File
@@ -293,6 +293,13 @@ test('parseHistoryPosition: open, snake_case, seconds, and junk', () => {
assert.equal(open.openedAt, 1793065590000);
assert.equal(open.closedAt, null);
assert.equal(open.closeReason, null);
const held = parseHistoryPosition({
positionId: 'p3', symbol: 'NDX-USD-PERP', side: 'short', quantity: '85.7363', openPrice: '29538.96', closePrice: '290.25',
pnl: '0.0', fee: '63.314059', funding: '0.0', openDate: 1793065422000, closeDate: null,
});
assert.equal(held.closePrice, null, "an open position's placeholder close price is dropped");
assert.equal(held.pnl, null, 'and its $0 P&L');
assert.equal(held.fee, 63.314059, 'its entry fee is real');
assert.equal(parseHistoryPosition({ symbol: 'GC-PERP', openDate: 1 }), null, 'no id');
assert.equal(parseHistoryPosition({ positionId: 'p2', symbol: 'GC-PERP' }), null, 'no open time');
assert.equal(parseHistoryPosition(null), null);