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). */