From 71606ffc9ed235006be9e89137aec1aea7663a61 Mon Sep 17 00:00:00 2001 From: Brandon Li Date: Mon, 26 Oct 2026 19:44:10 -0500 Subject: [PATCH] Claim now: a button on the account page claims a payout account's profit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 5 ++ app/accounts/[id]/page.tsx | 105 ++++++++++++++++++++++++++- app/api/accounts/[id]/claim/route.ts | 34 +++++++++ lib/auto-trade.ts | 100 +++++++++++++++++++++++-- 4 files changed, 236 insertions(+), 8 deletions(-) create mode 100644 app/api/accounts/[id]/claim/route.ts diff --git a/README.md b/README.md index 2afe6a8..448699a 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/app/accounts/[id]/page.tsx b/app/accounts/[id]/page.tsx index 0d5e1ed..4f22694 100644 --- a/app/accounts/[id]/page.tsx +++ b/app/accounts/[id]/page.tsx @@ -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(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 ( +
+ + Claim ${fmt(amount)} to your {info.firm} main account? The firm pays your split of it. + + + +
+ ); + } + + return ( +
+ {message && {message.text}} + {info.claimable !== null ? ( + + ) : ( + + Claim now — {info.reason} + + )} +
+ ); +} + /** 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 )} - { void load(); }} /> +
+ { void load(); }} /> + { void load(); }} /> +
{isDead && ( diff --git a/app/api/accounts/[id]/claim/route.ts b/app/api/accounts/[id]/claim/route.ts new file mode 100644 index 0000000..eacb60f --- /dev/null +++ b/app/api/accounts/[id]/claim/route.ts @@ -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 }); + } +} diff --git a/lib/auto-trade.ts b/lib/auto-trade.ts index 33e3015..4019ee2 100644 --- a/lib/auto-trade.ts +++ b/lib/auto-trade.ts @@ -1989,13 +1989,7 @@ async function runClaims(lane: string, ready: FirmClient[]): Promise { 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 { } } +/** 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). */