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>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
c109aff3e5
commit
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).
|
||||
|
||||
|
||||
@@ -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,8 +688,11 @@ export default function AccountPage() {
|
||||
☠ DEAD
|
||||
</span>
|
||||
)}
|
||||
<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 && (
|
||||
<div className="mb-5 flex items-center gap-3 bg-slate-900 text-white rounded-xl px-5 py-4">
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
+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). */
|
||||
|
||||
Reference in New Issue
Block a user