Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
016444239e | ||
|
|
a03bfb5605 | ||
|
|
71606ffc9e |
@@ -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
@@ -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 && (
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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). */
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user