Auto-claim without the scheduler, and no claim twice
A payout locks in profit and opens nothing, so stopping the trading no longer stops it: a claim watcher runs the same claims once a minute while the scheduler is off (its tick still claims while it runs), and the dashboard's Auto-claim payouts box turns them off. From two independent reviews of the claim path: - A claim holds every further claim on the account until Vest's transactions show its WITHDRAW. A sync already out when the claim lands (by sync generation, not the clock) no longer writes the pre-claim balance back, and a fresh sync follows the claim. - A withdraw that fails in transit (timeout, reset, 5xx) may have gone in: it is recorded as of unknown outcome and holds claims the same way, until the transactions show it, or an hour of them shows it didn't. - claimProfit checks Vest's own account: funded (or instant), and no more than the balance above its starting capital. - A claiming account is marked in flight; runClaims sends the amount it re-checked under the mutex. - The watcher starts before the rest of the init can fail; settings are validated before any is written. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
016444239e
commit
6f6660fdcc
@@ -257,7 +257,11 @@ own traffic since Vest publishes no API):
|
|||||||
unmarked since marking began was someone else's. The **Trades** button on
|
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.
|
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
|
- 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
|
target — whether or not the scheduler is running (a claim opens nothing).
|
||||||
|
The dashboard's **Auto-claim payouts** box turns it off (`auto_claim`). A
|
||||||
|
claim holds every further claim on the account until Vest's transactions
|
||||||
|
show its WITHDRAW; one whose request failed in transit holds them too, until
|
||||||
|
the transactions show it went in, or an hour of them shows it didn't. The account page's **Claim
|
||||||
now** button claims the whole profit above the account size at once — any
|
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
|
amount from $1 — when the account is flat; it asks first, since it moves
|
||||||
money to the login's main account.
|
money to the login's main account.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { getSetting, setSetting } from '@/lib/db';
|
|||||||
import { scheduleBackupPush } from '@/lib/backup-sync';
|
import { scheduleBackupPush } from '@/lib/backup-sync';
|
||||||
import { getRequestUserAgent } from '@/lib/request-user-agent';
|
import { getRequestUserAgent } from '@/lib/request-user-agent';
|
||||||
|
|
||||||
const VALID_KEYS = ['max_concurrent_accounts', 'tick_interval_seconds', 'master_dashboard_url', 'instance_name', 'trading_hours', 'twocaptcha_api_key', 'twocaptcha_proxy', 'master_backup_token', 'proxy_url'] as const;
|
const VALID_KEYS = ['max_concurrent_accounts', 'tick_interval_seconds', 'master_dashboard_url', 'instance_name', 'trading_hours', 'twocaptcha_api_key', 'twocaptcha_proxy', 'master_backup_token', 'proxy_url', 'auto_claim'] as const;
|
||||||
type SettingKey = typeof VALID_KEYS[number];
|
type SettingKey = typeof VALID_KEYS[number];
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
@@ -25,6 +25,12 @@ export async function PATCH(req: NextRequest) {
|
|||||||
try {
|
try {
|
||||||
const body = await req.json() as Partial<Record<SettingKey, string | number>>;
|
const body = await req.json() as Partial<Record<SettingKey, string | number>>;
|
||||||
|
|
||||||
|
// Claims run unless it reads exactly 'off' — say so exactly, and
|
||||||
|
// before anything is written.
|
||||||
|
if (body.auto_claim !== undefined && body.auto_claim !== null && body.auto_claim !== 'on' && body.auto_claim !== 'off') {
|
||||||
|
return NextResponse.json({ error: "auto_claim is 'on' or 'off'" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
for (const key of VALID_KEYS) {
|
for (const key of VALID_KEYS) {
|
||||||
if (key in body) {
|
if (key in body) {
|
||||||
const raw = body[key];
|
const raw = body[key];
|
||||||
|
|||||||
+25
-1
@@ -743,6 +743,7 @@ export default function Home() {
|
|||||||
const [tickInterval, setTickInterval] = useState('60');
|
const [tickInterval, setTickInterval] = useState('60');
|
||||||
const [tradeLoading, setTradeLoading] = useState(false);
|
const [tradeLoading, setTradeLoading] = useState(false);
|
||||||
const [stopAfterAll, setStopAfterAll] = useState(false);
|
const [stopAfterAll, setStopAfterAll] = useState(false);
|
||||||
|
const [autoClaim, setAutoClaim] = useState(true);
|
||||||
|
|
||||||
const handleSort = (col: SortKey) => {
|
const handleSort = (col: SortKey) => {
|
||||||
if (sortKey === col) {
|
if (sortKey === col) {
|
||||||
@@ -812,8 +813,9 @@ export default function Home() {
|
|||||||
|
|
||||||
fetch('/api/settings')
|
fetch('/api/settings')
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((s: { tick_interval_seconds?: string | null }) => {
|
.then((s: { tick_interval_seconds?: string | null; auto_claim?: string | null }) => {
|
||||||
if (s.tick_interval_seconds != null) setTickInterval(s.tick_interval_seconds);
|
if (s.tick_interval_seconds != null) setTickInterval(s.tick_interval_seconds);
|
||||||
|
setAutoClaim(s.auto_claim !== 'off');
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
|
||||||
@@ -1000,6 +1002,28 @@ export default function Home() {
|
|||||||
/>
|
/>
|
||||||
<span className="text-xs text-slate-400 whitespace-nowrap">Stop after all eligible</span>
|
<span className="text-xs text-slate-400 whitespace-nowrap">Stop after all eligible</span>
|
||||||
</label>
|
</label>
|
||||||
|
{config.some((f) => f.platform === 'vest') && (
|
||||||
|
<label
|
||||||
|
className="flex items-center gap-1.5 ml-2 cursor-pointer select-none"
|
||||||
|
title="Claim a funded account's profit as soon as it reaches its stage's target (Vest) — whether or not the scheduler is running"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={autoClaim}
|
||||||
|
onChange={(e) => {
|
||||||
|
const on = e.target.checked;
|
||||||
|
setAutoClaim(on);
|
||||||
|
fetch('/api/settings', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ auto_claim: on ? 'on' : 'off' }),
|
||||||
|
}).then((r) => { if (!r.ok) setAutoClaim(!on); }).catch(() => setAutoClaim(!on));
|
||||||
|
}}
|
||||||
|
className="rounded border-slate-300 text-emerald-500 focus:ring-emerald-500"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-slate-400 whitespace-nowrap">Auto-claim payouts</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<div className="ml-auto flex items-center gap-2">
|
||||||
{hasAnyPosition && (
|
{hasAnyPosition && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
+50
-7
@@ -1947,6 +1947,7 @@ function claimDue(client: any, acc: { id: number; active: boolean }, now: number
|
|||||||
if (!cfg || cfg.payoutable !== true || !acc.active) return null;
|
if (!cfg || cfg.payoutable !== true || !acc.active) return null;
|
||||||
if (client.positions[acc.id] || client.hasWorkingOrders?.(acc.id) || runtime().accountsInFlight.has(acc.id)) return null;
|
if (client.positions[acc.id] || client.hasWorkingOrders?.(acc.id) || runtime().accountsInFlight.has(acc.id)) return null;
|
||||||
if (!client.historyReady?.(acc.id)) return null;
|
if (!client.historyReady?.(acc.id)) return null;
|
||||||
|
if (client.claimPending?.(acc.id)) return null;
|
||||||
if (now - (runtime().claimFailedAt?.get(acc.id) ?? 0) < CLAIM_RETRY_MS) return null;
|
if (now - (runtime().claimFailedAt?.get(acc.id) ?? 0) < CLAIM_RETRY_MS) return null;
|
||||||
const cash = client.accountCashBalances[acc.id];
|
const cash = client.accountCashBalances[acc.id];
|
||||||
if (!cash) return null;
|
if (!cash) return null;
|
||||||
@@ -1966,8 +1967,9 @@ function claimDue(client: any, acc: { id: number; active: boolean }, now: number
|
|||||||
* Profit). The account then starts its next stage the same day: its leg
|
* Profit). The account then starts its next stage the same day: its leg
|
||||||
* counts and entry outcome for the day are cleared, and a type whose stages
|
* counts and entry outcome for the day are cleared, and a type whose stages
|
||||||
* reset each day trades its next stage on the room the claim freed. Under the
|
* reset each day trades its next stage on the room the claim freed. Under the
|
||||||
* lane's mutex, so no batch reads an account mid-claim. Off with the
|
* lane's mutex, so no batch reads an account mid-claim. Run by the
|
||||||
* auto_claim setting at 'off'.
|
* scheduler's tick, or by the claim watcher while the scheduler is off. Off
|
||||||
|
* with the auto_claim setting at 'off'.
|
||||||
*/
|
*/
|
||||||
async function runClaims(lane: string, ready: FirmClient[]): Promise<void> {
|
async function runClaims(lane: string, ready: FirmClient[]): Promise<void> {
|
||||||
if (getSetting('auto_claim') === 'off') return;
|
if (getSetting('auto_claim') === 'off') return;
|
||||||
@@ -1983,12 +1985,15 @@ async function runClaims(lane: string, ready: FirmClient[]): Promise<void> {
|
|||||||
if (lease === null) return;
|
if (lease === null) return;
|
||||||
try {
|
try {
|
||||||
const tradeDate = currentTradeDateCT();
|
const tradeDate = currentTradeDateCT();
|
||||||
for (const { firm, client, acc, amount } of due) {
|
for (const { firm, client, acc } of due) {
|
||||||
if (!holdsTradeMutex(lease, lane)) return;
|
if (!holdsTradeMutex(lease, lane)) return;
|
||||||
if (claimDue(client, acc, Date.now()) === null) continue; // settled since
|
// Settled since, or a different amount now: claim what is due now.
|
||||||
|
const amount = claimDue(client, acc, Date.now());
|
||||||
|
if (amount === null) continue;
|
||||||
try {
|
try {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// In flight while it claims: a batch that takes over a wedged
|
||||||
const { claimed, received } = await (client as any).claimProfit(acc.id, amount);
|
// lane, or a Claim now, leaves the account alone.
|
||||||
|
const { claimed, received } = await withAccountInFlight(acc.id, () => client.claimProfit!(acc.id, amount));
|
||||||
afterClaim('[auto-claim]', firm, client, acc, claimed, received, tradeDate);
|
afterClaim('[auto-claim]', firm, client, acc, claimed, received, tradeDate);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
(runtime().claimFailedAt ??= new Map()).set(acc.id, Date.now());
|
(runtime().claimFailedAt ??= new Map()).set(acc.id, Date.now());
|
||||||
@@ -2000,6 +2005,43 @@ async function runClaims(lane: string, ready: FirmClient[]): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** How often auto-claims run while the scheduler is off — its tick's pace. */
|
||||||
|
const CLAIM_WATCH_MS = 60_000;
|
||||||
|
|
||||||
|
const _claimWatch = globalThis as typeof globalThis & { __claimWatcher?: ReturnType<typeof setInterval>; __claimTickBusy?: boolean };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-claims don't wait on the scheduler: a payout locks in profit and
|
||||||
|
* opens nothing, so stopping the trading doesn't stop it — a funded account
|
||||||
|
* at its target while the bot is off is claimed all the same, before
|
||||||
|
* whatever trades it next can take the profit back. While the scheduler
|
||||||
|
* runs, its own tick claims (first thing in each lane), and this stays out
|
||||||
|
* of the way. Off with the auto_claim setting at 'off'. Started once, with
|
||||||
|
* the clients.
|
||||||
|
*/
|
||||||
|
export function startClaimWatcher(): void {
|
||||||
|
if (_claimWatch.__claimWatcher) return;
|
||||||
|
_claimWatch.__claimWatcher = setInterval(() => { void claimTick(); }, CLAIM_WATCH_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function claimTick(): Promise<void> {
|
||||||
|
if (getState().running || getSetting('auto_claim') === 'off' || _claimWatch.__claimTickBusy) return;
|
||||||
|
_claimWatch.__claimTickBusy = true;
|
||||||
|
try {
|
||||||
|
// Ready clients only, as the tick: a stale snapshot may hide a
|
||||||
|
// position. runClaims takes each lane's mutex itself.
|
||||||
|
const { ready } = firmClients();
|
||||||
|
for (const lane of tradableLanes()) {
|
||||||
|
if (getState().running) return; // the scheduler took over
|
||||||
|
await runClaims(lane, ready);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[auto-claim] ${errorMessage(err)}`);
|
||||||
|
} finally {
|
||||||
|
_claimWatch.__claimTickBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** A claim went through: the account starts its next stage — the day's leg
|
/** 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. */
|
* count and entry outcome are cleared, as if it hadn't traded today. */
|
||||||
function afterClaim(
|
function afterClaim(
|
||||||
@@ -2043,6 +2085,7 @@ function manualClaim(accountId: number):
|
|||||||
return { reason: 'a position or order is open — the account must be flat' };
|
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' };
|
if (!client.historyReady(acc.id)) return { reason: 'its history is still loading' };
|
||||||
|
if (client.claimPending?.(acc.id)) return { reason: 'the last claim is still settling — wait for the next sync' };
|
||||||
const cash = client.accountCashBalances[acc.id];
|
const cash = client.accountCashBalances[acc.id];
|
||||||
if (!cash) return { reason: 'its balance has not been read yet' };
|
if (!cash) return { reason: 'its balance has not been read yet' };
|
||||||
const profit = Math.floor((cash.amount - cfg.accountSize) * 100) / 100;
|
const profit = Math.floor((cash.amount - cfg.accountSize) * 100) / 100;
|
||||||
@@ -2084,7 +2127,7 @@ export async function claimAccountNow(accountId: number, amount: number): Promis
|
|||||||
if ('reason' in c) throw new ClaimRefusedError(c.reason);
|
if ('reason' in c) throw new ClaimRefusedError(c.reason);
|
||||||
if (!(amount >= 1)) throw new ClaimRefusedError(`$${amount} is below the $1 minimum claim`);
|
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`);
|
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 { claimed, received } = await withAccountInFlight(c.acc.id, () => c.client.claimProfit!(c.acc.id, amount));
|
||||||
const stage = afterClaim('[claim-now]', c.firm, c.client, c.acc, claimed, received, currentTradeDateCT());
|
const stage = afterClaim('[claim-now]', c.firm, c.client, c.acc, claimed, received, currentTradeDateCT());
|
||||||
return { claimed, received, stage };
|
return { claimed, received, stage };
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
+4
-1
@@ -15,7 +15,7 @@ import { applyBrokerHttpDefaults, applyBrokerProxy } from './outbound-proxy';
|
|||||||
import axios from './http-client';
|
import axios from './http-client';
|
||||||
import { startMasterLink } from './master-link';
|
import { startMasterLink } from './master-link';
|
||||||
import { startUpdaterWatchdog } from './updater-watchdog';
|
import { startUpdaterWatchdog } from './updater-watchdog';
|
||||||
import { resumeSchedulerIfPersisted } from './auto-trade';
|
import { resumeSchedulerIfPersisted, startClaimWatcher } from './auto-trade';
|
||||||
import { superviseQuotes, SUPERVISE_INTERVAL_MS, type QuoteClient } from './live-quotes';
|
import { superviseQuotes, SUPERVISE_INTERVAL_MS, type QuoteClient } from './live-quotes';
|
||||||
|
|
||||||
// Use global to persist the client pool across HMR reloads in dev mode.
|
// Use global to persist the client pool across HMR reloads in dev mode.
|
||||||
@@ -189,6 +189,9 @@ export function getClients(): Map<string, PlatformClient> {
|
|||||||
|
|
||||||
if (!g.__tradovateClientsInitialized) {
|
if (!g.__tradovateClientsInitialized) {
|
||||||
g.__tradovateClientsInitialized = true;
|
g.__tradovateClientsInitialized = true;
|
||||||
|
// Payouts at target are claimed whether or not the scheduler runs —
|
||||||
|
// started first, so a later init step that throws can't leave it off.
|
||||||
|
startClaimWatcher();
|
||||||
try {
|
try {
|
||||||
// Before anything dials out: a timeout on every broker HTTP call,
|
// Before anything dials out: a timeout on every broker HTTP call,
|
||||||
// and the operator's proxy (if any) under the broker clients.
|
// and the operator's proxy (if any) under the broker clients.
|
||||||
|
|||||||
@@ -139,6 +139,9 @@ export interface PlatformClient {
|
|||||||
* Resolves once the platform accepted it and the books were updated;
|
* Resolves once the platform accepted it and the books were updated;
|
||||||
* rejects with the platform's reason otherwise. */
|
* rejects with the platform's reason otherwise. */
|
||||||
claimProfit?(accountId: number, amount: number): Promise<{ claimed: number; received: number | null }>;
|
claimProfit?(accountId: number, amount: number): Promise<{ claimed: number; received: number | null }>;
|
||||||
|
/** A claim this client made that no good sync has read past yet: the
|
||||||
|
* balance on hand may not show it, so no claim may be judged on it. */
|
||||||
|
claimPending?(accountId: number): boolean;
|
||||||
/** Drop the account's history from memory (the wipe routes), so it reads
|
/** Drop the account's history from memory (the wipe routes), so it reads
|
||||||
* unloaded until the next fetch rebuilds it. */
|
* unloaded until the next fetch rebuilds it. */
|
||||||
forgetHistory(accountId: number, accountName: string): void;
|
forgetHistory(accountId: number, accountName: string): void;
|
||||||
|
|||||||
+109
-13
@@ -107,6 +107,17 @@ const ASSUMED_SPREAD_TICKS = 1;
|
|||||||
const RESET_GUARD_MS = 5 * 60_000;
|
const RESET_GUARD_MS = 5 * 60_000;
|
||||||
/** account_meta key of the profit claims this client made, per account. */
|
/** account_meta key of the profit claims this client made, per account. */
|
||||||
const CLAIMS_META = 'vest_claims';
|
const CLAIMS_META = 'vest_claims';
|
||||||
|
/** A claim shows in Vest's transactions as a WITHDRAW of the same amount
|
||||||
|
* within this long of it. */
|
||||||
|
const CLAIM_MATCH_MS = 30 * 60_000;
|
||||||
|
/** A claim of unknown outcome (the request failed in transit) that no
|
||||||
|
* WITHDRAW matches in a history read this long after it never happened. */
|
||||||
|
const UNKNOWN_CLAIM_EXPIRES_MS = 60 * 60_000;
|
||||||
|
|
||||||
|
/** A claim this client sent: the amount, when, and the balance it left.
|
||||||
|
* unknown = the request failed in transit, so it may or may not have gone
|
||||||
|
* in — it holds further claims, but nothing is booked on it. */
|
||||||
|
interface OwnClaim { at: number; amount: number; base: number; unknown?: true }
|
||||||
|
|
||||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||||
const round2 = (n: number) => Math.round(n * 100) / 100;
|
const round2 = (n: number) => Math.round(n * 100) / 100;
|
||||||
@@ -179,7 +190,13 @@ export class VestClient implements PlatformClient {
|
|||||||
/** Profit claims this client made, per account: when, how much, and the
|
/** Profit claims this client made, per account: when, how much, and the
|
||||||
* balance right after — today's P&L counts from there (realizedSince).
|
* balance right after — today's P&L counts from there (realizedSince).
|
||||||
* Loaded from account_meta on first use, so a restart keeps them. */
|
* Loaded from account_meta on first use, so a restart keeps them. */
|
||||||
private claims = new Map<number, { at: number; amount: number; base: number }[]>();
|
private claims = new Map<number, OwnClaim[]>();
|
||||||
|
/** The sync generation current when each account last claimed: a sync
|
||||||
|
* of that generation or older read the balance before the claim. */
|
||||||
|
private claimSeq = new Map<number, number>();
|
||||||
|
/** When each account's transactions were last read. */
|
||||||
|
private historyReadAt = new Map<number, number>();
|
||||||
|
private unmatchedClaimLogged = new Set<string>();
|
||||||
/** Each account's WITHDRAW transactions at the last history read, so a
|
/** Each account's WITHDRAW transactions at the last history read, so a
|
||||||
* claim made by hand on Vest counts toward today's stage too. */
|
* claim made by hand on Vest counts toward today's stage too. */
|
||||||
private withdrawals = new Map<number, { at: number; amount: number }[]>();
|
private withdrawals = new Map<number, { at: number; amount: number }[]>();
|
||||||
@@ -577,6 +594,11 @@ export class VestClient implements PlatformClient {
|
|||||||
this.flatAmounts.delete(id);
|
this.flatAmounts.delete(id);
|
||||||
}
|
}
|
||||||
const upnl = mine.reduce((s, p) => s + unrealizedPnl(p, this.marks.get(p.symbol)?.mark ?? NaN), 0);
|
const upnl = mine.reduce((s, p) => s + unrealizedPnl(p, this.marks.get(p.symbol)?.mark ?? NaN), 0);
|
||||||
|
// A claim made while this sync was out: what it read predates
|
||||||
|
// the payout, and writing it back would show the profit again —
|
||||||
|
// a second claim of it. The claim's own booking stands until a
|
||||||
|
// sync that started after it.
|
||||||
|
if ((this.claimSeq.get(id) ?? -1) >= gen && this.accountCashBalances[id]) continue;
|
||||||
this.accountCashBalances[id] = {
|
this.accountCashBalances[id] = {
|
||||||
amount: round2(balance),
|
amount: round2(balance),
|
||||||
realizedPnL: this.realizedSince(id, a, balance, now),
|
realizedPnL: this.realizedSince(id, a, balance, now),
|
||||||
@@ -698,12 +720,15 @@ export class VestClient implements PlatformClient {
|
|||||||
return this.dailyFloorOf(a, cash.amount, Date.now()) ?? null;
|
return this.dailyFloorOf(a, cash.amount, Date.now()) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private claimsOf(accountId: number): { at: number; amount: number; base: number }[] {
|
private claimsOf(accountId: number): OwnClaim[] {
|
||||||
let list = this.claims.get(accountId);
|
let list = this.claims.get(accountId);
|
||||||
if (!list) {
|
if (!list) {
|
||||||
try {
|
try {
|
||||||
const raw = JSON.parse(loadAccountMeta(accountId, CLAIMS_META) ?? '[]');
|
const raw = JSON.parse(loadAccountMeta(accountId, CLAIMS_META) ?? '[]');
|
||||||
list = Array.isArray(raw) ? raw.filter((c) => Number.isFinite(c?.at) && Number.isFinite(c?.amount) && Number.isFinite(c?.base)) : [];
|
list = Array.isArray(raw)
|
||||||
|
? raw.filter((c) => Number.isFinite(c?.at) && Number.isFinite(c?.amount) && Number.isFinite(c?.base))
|
||||||
|
.map((c): OwnClaim => ({ at: c.at, amount: c.amount, base: c.base, ...(c.unknown === true ? { unknown: true as const } : {}) }))
|
||||||
|
: [];
|
||||||
} catch {
|
} catch {
|
||||||
list = [];
|
list = [];
|
||||||
}
|
}
|
||||||
@@ -712,6 +737,24 @@ export class VestClient implements PlatformClient {
|
|||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The claims this client knows went in (the platform said so). */
|
||||||
|
private madeClaims(accountId: number): OwnClaim[] {
|
||||||
|
return this.claimsOf(accountId).filter((c) => !c.unknown);
|
||||||
|
}
|
||||||
|
|
||||||
|
private saveClaims(accountId: number, list: OwnClaim[]): void {
|
||||||
|
const now = Date.now();
|
||||||
|
const kept = list.filter((c) => now - c.at < 7 * 86_400_000);
|
||||||
|
this.claims.set(accountId, kept);
|
||||||
|
saveAccountMeta(accountId, CLAIMS_META, JSON.stringify(kept));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vest's transactions show the claim: a WITHDRAW of its amount near it. */
|
||||||
|
private claimShown(accountId: number, c: OwnClaim): boolean {
|
||||||
|
return (this.withdrawals.get(accountId) ?? [])
|
||||||
|
.some((w) => Math.abs(Math.abs(w.amount) - c.amount) < 0.01 && Math.abs(w.at - c.at) < CLAIM_MATCH_MS);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Today's realized P&L as the day plan wants it: since the latest claim
|
* Today's realized P&L as the day plan wants it: since the latest claim
|
||||||
* this client made since the last reset — the plan's new cycle starts
|
* this client made since the last reset — the plan's new cycle starts
|
||||||
@@ -721,7 +764,7 @@ export class VestClient implements PlatformClient {
|
|||||||
*/
|
*/
|
||||||
private realizedSince(accountId: number, a: VestCapitalAccount, balance: number, now: number): number {
|
private realizedSince(accountId: number, a: VestCapitalAccount, balance: number, now: number): number {
|
||||||
const reset = lastVestResetMs(now);
|
const reset = lastVestResetMs(now);
|
||||||
const mine = this.claimsOf(accountId).filter((c) => c.at >= reset);
|
const mine = this.madeClaims(accountId).filter((c) => c.at >= reset);
|
||||||
if (mine.length > 0) return round2(balance - mine[mine.length - 1].base);
|
if (mine.length > 0) return round2(balance - mine[mine.length - 1].base);
|
||||||
const resetBalance = this.resetBalanceOf(a, balance, now);
|
const resetBalance = this.resetBalanceOf(a, balance, now);
|
||||||
if (resetBalance === undefined) return 0;
|
if (resetBalance === undefined) return 0;
|
||||||
@@ -734,7 +777,7 @@ export class VestClient implements PlatformClient {
|
|||||||
* more (a claim made by hand shows only there). */
|
* more (a claim made by hand shows only there). */
|
||||||
public sameDayClaims(accountId: number): number {
|
public sameDayClaims(accountId: number): number {
|
||||||
const reset = lastVestResetMs(Date.now());
|
const reset = lastVestResetMs(Date.now());
|
||||||
const mine = this.claimsOf(accountId).filter((c) => c.at >= reset).length;
|
const mine = this.madeClaims(accountId).filter((c) => c.at >= reset).length;
|
||||||
const seen = (this.withdrawals.get(accountId) ?? []).filter((w) => w.at >= reset).length;
|
const seen = (this.withdrawals.get(accountId) ?? []).filter((w) => w.at >= reset).length;
|
||||||
return Math.max(mine, seen);
|
return Math.max(mine, seen);
|
||||||
}
|
}
|
||||||
@@ -754,13 +797,25 @@ export class VestClient implements PlatformClient {
|
|||||||
const uuid = this.uuidById.get(accountId);
|
const uuid = this.uuidById.get(accountId);
|
||||||
const account = this.accountList.find((a) => a.id === accountId);
|
const account = this.accountList.find((a) => a.id === accountId);
|
||||||
const cash = this.accountCashBalances[accountId];
|
const cash = this.accountCashBalances[accountId];
|
||||||
if (!uuid || !account || !cash) throw new Error('account not read yet');
|
const meta = this.capital.get(accountId);
|
||||||
|
if (!uuid || !account || !cash || !meta) throw new Error('account not read yet');
|
||||||
if (!this.primaryAccountId) throw new Error('the main account is unknown — no sync has listed it');
|
if (!this.primaryAccountId) throw new Error('the main account is unknown — no sync has listed it');
|
||||||
if (this.positions[accountId] || (this.openOrders.get(accountId) ?? []).length > 0) throw new Error('positions or orders open');
|
if (this.positions[accountId] || (this.openOrders.get(accountId) ?? []).length > 0) throw new Error('positions or orders open');
|
||||||
|
if (this.claimPending(accountId)) throw new Error("the last claim hasn't shown in Vest's transactions yet");
|
||||||
|
// Vest's own word over the account type assigned here: only a funded
|
||||||
|
// (or instant) account pays out, and only above its starting capital.
|
||||||
|
if (meta.kind !== 'funded' && !/instant/i.test(meta.productType)) {
|
||||||
|
throw new Error(`Vest shows ${account.name} as ${meta.kind || 'not funded'} — only a funded account's profit can be claimed`);
|
||||||
|
}
|
||||||
const claimed = Math.floor(amount * 100) / 100;
|
const claimed = Math.floor(amount * 100) / 100;
|
||||||
if (!(claimed >= 1)) throw new Error(`$${claimed} is below Vest's $1 minimum claim`);
|
if (!(claimed >= 1)) throw new Error(`$${claimed} is below Vest's $1 minimum claim`);
|
||||||
|
if (meta.initialCapital > 0 && claimed > cash.amount - meta.initialCapital + 0.01) {
|
||||||
|
throw new Error(`$${claimed} is more than the $${round2(cash.amount - meta.initialCapital)} above Vest's starting capital of $${meta.initialCapital}`);
|
||||||
|
}
|
||||||
|
|
||||||
const { data } = await this.request<{ trader_amount?: unknown }>('trade', 'POST', '/v3/capital/withdraw', {
|
let data: { trader_amount?: unknown } | undefined;
|
||||||
|
try {
|
||||||
|
({ data } = await this.request<{ trader_amount?: unknown }>('trade', 'POST', '/v3/capital/withdraw', {
|
||||||
account: uuid,
|
account: uuid,
|
||||||
body: {
|
body: {
|
||||||
account_id: uuid,
|
account_id: uuid,
|
||||||
@@ -768,13 +823,23 @@ export class VestClient implements PlatformClient {
|
|||||||
target_account_id: this.primaryAccountId,
|
target_account_id: this.primaryAccountId,
|
||||||
idempotency_key: randomUUID(),
|
idempotency_key: randomUUID(),
|
||||||
},
|
},
|
||||||
});
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
// Only a refusal proves nothing went in. A timeout, a reset or a
|
||||||
|
// 5xx may have: hold every further claim on the account until
|
||||||
|
// Vest's transactions say either way (claimPending).
|
||||||
|
if (!(err instanceof VestApiError && err.status >= 400 && err.status < 500 && err.status !== 408)) {
|
||||||
|
this.saveClaims(accountId, [...this.claimsOf(accountId), { at: Date.now(), amount: claimed, base: round2(cash.amount - claimed), unknown: true }]);
|
||||||
|
this.claimSeq.set(accountId, this.syncGeneration);
|
||||||
|
console.error(`${this.tag} ${account.name}: claim of $${claimed} — outcome unknown (${errorMessage(err)}); no further claim on it until Vest's transactions settle it`);
|
||||||
|
void this.fetchDaysTraded([accountId]).catch(() => { /* the hourly read retries */ });
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
const received = num(data?.trader_amount) ?? null;
|
const received = num(data?.trader_amount) ?? null;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const list = [...this.claimsOf(accountId), { at: now, amount: claimed, base: round2(cash.amount - claimed) }]
|
this.saveClaims(accountId, [...this.claimsOf(accountId), { at: now, amount: claimed, base: round2(cash.amount - claimed) }]);
|
||||||
.filter((c) => now - c.at < 7 * 86_400_000);
|
this.claimSeq.set(accountId, this.syncGeneration);
|
||||||
this.claims.set(accountId, list);
|
|
||||||
saveAccountMeta(accountId, CLAIMS_META, JSON.stringify(list));
|
|
||||||
|
|
||||||
// Book it now: today's fund row (with whatever else that day holds),
|
// Book it now: today's fund row (with whatever else that day holds),
|
||||||
// the cycle cut, and the balance until the next sync reads Vest's.
|
// the cycle cut, and the balance until the next sync reads Vest's.
|
||||||
@@ -785,10 +850,40 @@ export class VestClient implements PlatformClient {
|
|||||||
console.log(`${this.tag} ${account.name}: claimed $${claimed}${received !== null ? ` — $${received} to the main account` : ''}`);
|
console.log(`${this.tag} ${account.name}: claimed $${claimed}${received !== null ? ` — $${received} to the main account` : ''}`);
|
||||||
|
|
||||||
void this.fetchDaysTraded([accountId]).catch((err) => console.warn(`${this.tag} history after the claim failed: ${errorMessage(err)}`));
|
void this.fetchDaysTraded([accountId]).catch((err) => console.warn(`${this.tag} history after the claim failed: ${errorMessage(err)}`));
|
||||||
void this.requestSync();
|
// A fresh sync — after any one already out, whose reads predate this.
|
||||||
|
void this.syncRunning.then(() => this.requestSync());
|
||||||
return { claimed, received };
|
return { claimed, received };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* See PlatformClient: a claim of this client's that Vest's transactions
|
||||||
|
* don't show yet — until they do, the balance on hand can't be trusted
|
||||||
|
* not to show the claimed profit again. One whose outcome was unknown,
|
||||||
|
* and that a history read an hour on still doesn't show, never went in:
|
||||||
|
* it's dropped. One Vest accepted holds for as long as it doesn't show
|
||||||
|
* (noted once in the log after an hour).
|
||||||
|
*/
|
||||||
|
public claimPending(accountId: number): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
const readAt = this.historyReadAt.get(accountId) ?? 0;
|
||||||
|
const list = this.claimsOf(accountId);
|
||||||
|
const lapsed = list.filter((c) => c.unknown && !this.claimShown(accountId, c) && readAt - c.at > UNKNOWN_CLAIM_EXPIRES_MS);
|
||||||
|
const name = this.accountList.find((a) => a.id === accountId)?.name ?? accountId;
|
||||||
|
if (lapsed.length > 0) {
|
||||||
|
this.saveClaims(accountId, list.filter((c) => !lapsed.includes(c)));
|
||||||
|
for (const c of lapsed) console.warn(`${this.tag} ${name}: the claim of $${c.amount} with an unknown outcome never showed on Vest — taken as not made`);
|
||||||
|
}
|
||||||
|
const pending = this.claimsOf(accountId).filter((c) => !this.claimShown(accountId, c));
|
||||||
|
for (const c of pending) {
|
||||||
|
const key = `${accountId}:${c.at}`;
|
||||||
|
if (!c.unknown && now - c.at > 60 * 60_000 && !this.unmatchedClaimLogged.has(key)) {
|
||||||
|
this.unmatchedClaimLogged.add(key);
|
||||||
|
console.error(`${this.tag} ${name}: the claim of $${c.amount} an hour ago still doesn't show in Vest's transactions — no further claim on this account until it does`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pending.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Before each 20:00 ET reset, fit every open position on an account with
|
* Before each 20:00 ET reset, fit every open position on an account with
|
||||||
* a daily limit into the room the reset will leave (resetSafeStop): a
|
* a daily limit into the room the reset will leave (resetSafeStop): a
|
||||||
@@ -1130,6 +1225,7 @@ export class VestClient implements PlatformClient {
|
|||||||
.sort((x, y) => x.date.localeCompare(y.date));
|
.sort((x, y) => x.date.localeCompare(y.date));
|
||||||
this.applyFundRows(account, fundRows);
|
this.applyFundRows(account, fundRows);
|
||||||
this.withdrawals.set(account.id, txns.filter((t) => t.type === VEST_TXN.WITHDRAW).map((t) => ({ at: t.time, amount: t.amount })));
|
this.withdrawals.set(account.id, txns.filter((t) => t.type === VEST_TXN.WITHDRAW).map((t) => ({ at: t.time, amount: t.amount })));
|
||||||
|
this.historyReadAt.set(account.id, Date.now());
|
||||||
|
|
||||||
// Books check: the balance must be what the transactions add up to
|
// Books check: the balance must be what the transactions add up to
|
||||||
// (the opening capital included). A payout or credit booked under a
|
// (the opening capital included). A payout or credit booked under a
|
||||||
|
|||||||
Reference in New Issue
Block a user