Merge branch 'vest-fix-claims' into vest-fixes
This commit is contained in:
@@ -261,7 +261,11 @@ own traffic since Vest publishes no API):
|
|||||||
The dashboard's **Auto-claim payouts** box turns it off (`auto_claim`). A
|
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
|
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
|
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
|
the transactions show it went in, or an hour of them shows it didn't (one
|
||||||
|
that never went out, for want of an account token, holds nothing). Each
|
||||||
|
claim counts as its own payout in the report, even two on one Vest day, and
|
||||||
|
the cycle a day's second claim starts is sent with its own `cycleSeq` (the
|
||||||
|
master needs to key on it to keep that cycle apart). 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.
|
||||||
|
|||||||
+11
-5
@@ -36,11 +36,16 @@ export interface AccountTypeStats {
|
|||||||
* cycle ready to withdraw, 'drain' = a drain stage's days booked;
|
* cycle ready to withdraw, 'drain' = a drain stage's days booked;
|
||||||
* cycle = the date of the fund transaction that started the cycle ('' for
|
* cycle = the date of the fund transaction that started the cycle ('' for
|
||||||
* the account's first cycle) — firm + name + cycle identify one
|
* the account's first cycle) — firm + name + cycle identify one
|
||||||
* completed cycle. The list also carries caught-up entries: for up to 7
|
* completed cycle, but where one day started several (Vest: claim,
|
||||||
|
* trade again, claim again — a fund row holds the day's payouts):
|
||||||
|
* cycleSeq = which of that day's cycles, 1 for the second and so on,
|
||||||
|
* sent only when above 0 (trading-logic completionPlace). A master that
|
||||||
|
* keys on it tells them apart; one that doesn't folds them into the
|
||||||
|
* day's first. The list also carries caught-up entries: for up to 7
|
||||||
* days after a fund transaction starts a new cycle, it includes the cycle
|
* days after a fund transaction starts a new cycle, it includes the cycle
|
||||||
* that just ended if that cycle had met the rule. Caught-up entries are
|
* that just ended if that cycle had met the rule. Caught-up entries are
|
||||||
* not counted in `completed`. Optional: older instances omit it. */
|
* not counted in `completed`. Optional: older instances omit it. */
|
||||||
completedAccounts?: { name: string; date: string; stage: number; kind: 'pass' | 'payout' | 'drain'; cycle: string }[];
|
completedAccounts?: { name: string; date: string; stage: number; kind: 'pass' | 'payout' | 'drain'; cycle: string; cycleSeq?: number }[];
|
||||||
/** Live accounts by trading days completed toward the current target:
|
/** Live accounts by trading days completed toward the current target:
|
||||||
* {daysTraded: accountCount}. Feeds the master's payout calendar, so
|
* {daysTraded: accountCount}. Feeds the master's payout calendar, so
|
||||||
* accounts that are dead, DLL-locked, or in the red are left out — the
|
* accounts that are dead, DLL-locked, or in the red are left out — the
|
||||||
@@ -109,9 +114,10 @@ export interface FirmStats {
|
|||||||
* instances omit it.
|
* instances omit it.
|
||||||
*
|
*
|
||||||
* withdrawals = how many times the balance has gone down for a payout
|
* withdrawals = how many times the balance has gone down for a payout
|
||||||
* (negative fund transactions), which tells the master a completed
|
* (negative fund transactions; on Vest, each claim, though a day's share
|
||||||
* cycle's money is out. Omitted while the account's fund history hasn't
|
* one fund row), which tells the master a completed cycle's money is
|
||||||
* loaded, so a restart can't read as zero withdrawals.
|
* out. Omitted while the account's fund history hasn't loaded, so a
|
||||||
|
* restart can't read as zero withdrawals.
|
||||||
*
|
*
|
||||||
* The rest come from statusVersion builds (platform-client.ts
|
* The rest come from statusVersion builds (platform-client.ts
|
||||||
* accountStatus), each left out when null or at its default. status:
|
* accountStatus), each left out when null or at its default. status:
|
||||||
|
|||||||
@@ -148,6 +148,12 @@ export interface PlatformClient {
|
|||||||
/** Payouts made since the firm's last daily reset — what picks the stage
|
/** Payouts made since the firm's last daily reset — what picks the stage
|
||||||
* of an account type whose stages reset each day. */
|
* of an account type whose stages reset each day. */
|
||||||
sameDayClaims?(accountId: number): number;
|
sameDayClaims?(accountId: number): number;
|
||||||
|
/** Each payout taken from the account, by the day it counts on — one
|
||||||
|
* entry per payout, where a fund row can hold several (Vest books a
|
||||||
|
* whole day's claims as one); undefined until the platform's own record
|
||||||
|
* of them has been read. Without it, each negative fund row is one, on
|
||||||
|
* its date. */
|
||||||
|
payoutDays?(accountId: number): string[] | undefined;
|
||||||
/** Request a payout of `amount` from the account to the login's own main
|
/** Request a payout of `amount` from the account to the login's own main
|
||||||
* account, where the platform takes one by API (Vest's Claim Profit).
|
* account, where the platform takes one by API (Vest's Claim Profit).
|
||||||
* Resolves once the platform accepted it and the books were updated;
|
* Resolves once the platform accepted it and the books were updated;
|
||||||
|
|||||||
+30
-8
@@ -20,7 +20,7 @@ import { getClients } from './clients';
|
|||||||
import { getAccountTypeById, getKeptRuleIdsForFirm, getTradableFirms } from './firm-catalog';
|
import { getAccountTypeById, getKeptRuleIdsForFirm, getTradableFirms } from './firm-catalog';
|
||||||
import { getAccountConfig } from './account-type-resolver';
|
import { getAccountConfig } from './account-type-resolver';
|
||||||
import { getSchedulerStatus } from './auto-trade';
|
import { getSchedulerStatus } from './auto-trade';
|
||||||
import { botStopsActive, combineCmeSessions, computeDayPlan, currentTradeDateCT, cycleCompletion, endedCycleCompletion, LEG_COMPLETION_EPSILON, projectPerfectPath, resolveEffectiveConfig, stageInputsFor } from './trading-logic';
|
import { botStopsActive, combineCmeSessions, computeDayPlan, currentTradeDateCT, completionPlace, cycleCompletion, endedCycleCompletion, LEG_COMPLETION_EPSILON, projectPerfectPath, resolveEffectiveConfig, stageInputsFor } from './trading-logic';
|
||||||
import type { AccountTypeStats, FirmStats, InstrumentStats, SymbolStats } from './master-protocol';
|
import type { AccountTypeStats, FirmStats, InstrumentStats, SymbolStats } from './master-protocol';
|
||||||
import { accountNextLeg, accountLegShape } from './leg-estimate';
|
import { accountNextLeg, accountLegShape } from './leg-estimate';
|
||||||
import { sizingHoldHours } from './person-size';
|
import { sizingHoldHours } from './person-size';
|
||||||
@@ -104,11 +104,22 @@ function collectFirmStats() {
|
|||||||
// standing, while a wrong zero would clear one early.
|
// standing, while a wrong zero would clear one early.
|
||||||
const loadedFundTxns = client.fundTransactions?.[acc.id];
|
const loadedFundTxns = client.fundTransactions?.[acc.id];
|
||||||
const fundTxns = loadedFundTxns ?? [];
|
const fundTxns = loadedFundTxns ?? [];
|
||||||
|
// One per payout, on its day: a client whose fund rows hold a
|
||||||
|
// whole day's (Vest: two claims the same day, one row) lists them
|
||||||
|
// itself, and says nothing until it has read them — falling back
|
||||||
|
// to the rows there would report fewer and then more again.
|
||||||
|
const rowPayoutDays = (rows: { date: string; amount: number }[]) => rows.filter((f) => f.amount < 0).map((f) => f.date);
|
||||||
|
const payoutDays = client.payoutDays
|
||||||
|
? client.payoutDays(acc.id)
|
||||||
|
: loadedFundTxns && rowPayoutDays(loadedFundTxns);
|
||||||
|
const payoutDaysTaken = payoutDays ?? rowPayoutDays(fundTxns);
|
||||||
|
const withdrawals = payoutDays?.length;
|
||||||
|
const withdrawalsTaken = payoutDaysTaken.length;
|
||||||
accounts.push({
|
accounts.push({
|
||||||
name: acc.name,
|
name: acc.name,
|
||||||
size: cfg?.accountSize ?? 0,
|
size: cfg?.accountSize ?? 0,
|
||||||
payoutable: cfg ? loops(cfg) : null,
|
payoutable: cfg ? loops(cfg) : null,
|
||||||
...(loadedFundTxns ? { withdrawals: loadedFundTxns.filter((f) => f.amount < 0).length } : {}),
|
...(withdrawals !== undefined ? { withdrawals } : {}),
|
||||||
// For the master's account ledger. Data only: nothing here
|
// For the master's account ledger. Data only: nothing here
|
||||||
// trades on it, and the totals below keep their own test.
|
// trades on it, and the totals below keep their own test.
|
||||||
...statusFieldsOrUnknown(() => accountStatusFields({
|
...statusFieldsOrUnknown(() => accountStatusFields({
|
||||||
@@ -247,7 +258,7 @@ function collectFirmStats() {
|
|||||||
maxDay: qualifying.length > 0 ? Math.max(...qualifying.map((d) => d.pnl)) : 0,
|
maxDay: qualifying.length > 0 ? Math.max(...qualifying.map((d) => d.pnl)) : 0,
|
||||||
sum,
|
sum,
|
||||||
high: highWater,
|
high: highWater,
|
||||||
withdrawals: fundTxns.filter((f) => f.amount < 0).length,
|
withdrawals: withdrawalsTaken,
|
||||||
target: effective.profitTarget,
|
target: effective.profitTarget,
|
||||||
consistency: effective.consistency,
|
consistency: effective.consistency,
|
||||||
minDays: effective.minTradingDays,
|
minDays: effective.minTradingDays,
|
||||||
@@ -281,12 +292,19 @@ function collectFirmStats() {
|
|||||||
cfg,
|
cfg,
|
||||||
});
|
});
|
||||||
if (ended) {
|
if (ended) {
|
||||||
|
// Counted one per payout, as the final day counts this
|
||||||
|
// cycle, so the two name it alike.
|
||||||
|
const place = completionPlace({
|
||||||
|
payoutDays: payoutDaysTaken, fundTransactions: fundTxns, cycle: ended.cycle,
|
||||||
|
end: client.lastFundDates?.[acc.id] ?? undefined,
|
||||||
|
});
|
||||||
(slice.completedAccounts ??= []).push({
|
(slice.completedAccounts ??= []).push({
|
||||||
name: acc.name,
|
name: acc.name,
|
||||||
date: ended.date,
|
date: ended.date,
|
||||||
stage: ended.stage,
|
stage: place.stage,
|
||||||
kind: completionKind(cfg, ended.drain),
|
kind: completionKind(cfg, ended.drain),
|
||||||
cycle: ended.cycle,
|
cycle: ended.cycle,
|
||||||
|
...(place.cycleSeq > 0 ? { cycleSeq: place.cycleSeq } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -326,14 +344,18 @@ function collectFirmStats() {
|
|||||||
dayEntry.targetHit++;
|
dayEntry.targetHit++;
|
||||||
dayEntry.completed = (dayEntry.completed ?? 0) + 1;
|
dayEntry.completed = (dayEntry.completed ?? 0) + 1;
|
||||||
}
|
}
|
||||||
|
// The fund transaction that started this cycle, and its
|
||||||
|
// place among that day's payouts — the catch-up names the
|
||||||
|
// same cycle by the same key.
|
||||||
|
const cycle = client.lastFundDates?.[acc.id] ?? '';
|
||||||
|
const place = completionPlace({ payoutDays: payoutDaysTaken, fundTransactions: fundTxns, cycle });
|
||||||
(slice.completedAccounts ??= []).push({
|
(slice.completedAccounts ??= []).push({
|
||||||
name: acc.name,
|
name: acc.name,
|
||||||
date: combined.tradeDate,
|
date: combined.tradeDate,
|
||||||
stage: 1 + fundTxns.filter((f) => f.amount < 0).length,
|
stage: place.stage,
|
||||||
kind: completionKind(cfg, effective.drain),
|
kind: completionKind(cfg, effective.drain),
|
||||||
// The fund transaction that started this cycle — the
|
cycle,
|
||||||
// catch-up names the same cycle by the same key.
|
...(place.cycleSeq > 0 ? { cycleSeq: place.cycleSeq } : {}),
|
||||||
cycle: client.lastFundDates?.[acc.id] ?? '',
|
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -772,6 +772,32 @@ export function cycleCompletion(opts: {
|
|||||||
return 'none';
|
return 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A completed cycle's place among the account's payouts, counted one per
|
||||||
|
* payout: `payoutDays` holds each payout's day once per payout
|
||||||
|
* (PlatformClient.payoutDays — a Vest day's two claims share a fund row —
|
||||||
|
* else the negative fund rows' dates). stage = 1 + the payouts before the
|
||||||
|
* cycle's own: those before `end`, the fund date that ended it (the
|
||||||
|
* catch-up), else every one (today's final day). cycleSeq = the payouts on
|
||||||
|
* the cycle's start day (`cycle`, '' = the account's first) that came
|
||||||
|
* before it, beyond the one whose fund row names that day — 0 but for a
|
||||||
|
* cycle that a second payout the same day started, which `cycle` alone
|
||||||
|
* can't tell from that day's first.
|
||||||
|
*/
|
||||||
|
export function completionPlace(o: {
|
||||||
|
payoutDays: string[];
|
||||||
|
fundTransactions: { date: string; amount: number }[];
|
||||||
|
cycle: string;
|
||||||
|
end?: string;
|
||||||
|
}): { stage: number; cycleSeq: number } {
|
||||||
|
const stage = 1 + o.payoutDays.filter((d) => o.end === undefined || d < o.end).length;
|
||||||
|
if (o.cycle === '') return { stage, cycleSeq: 0 };
|
||||||
|
// A deposit's day (Vest's activation) names the cycle it started; each
|
||||||
|
// payout that day starts another.
|
||||||
|
const named = o.fundTransactions.some((f) => f.date === o.cycle && f.amount < 0) ? 1 : 0;
|
||||||
|
return { stage, cycleSeq: Math.max(0, o.payoutDays.filter((d) => d === o.cycle).length - named) };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Catch-up for a missed final day: did the cycle the latest fund transaction
|
* Catch-up for a missed final day: did the cycle the latest fund transaction
|
||||||
* just ended stand complete — profit target, consistency and minimum days all
|
* just ended stand complete — profit target, consistency and minimum days all
|
||||||
|
|||||||
+97
-70
@@ -42,7 +42,7 @@ import { currentTradeDateCT, rankForTarget, dllReserve, resetClearStop } from '.
|
|||||||
import { sizingHoldHours } from './person-size';
|
import { sizingHoldHours } from './person-size';
|
||||||
import {
|
import {
|
||||||
saveDailyPnL, loadDailyPnL, loadAccountMeta, saveAccountMeta, loadFundTransactions, saveFundTransactions,
|
saveDailyPnL, loadDailyPnL, loadAccountMeta, saveAccountMeta, loadFundTransactions, saveFundTransactions,
|
||||||
saveEndedCycleStart, saveTrades, markTrade, getOrCreateSetting, TRADES_SINCE_SETTING, openTradeAccounts,
|
saveEndedCycleStart, dropFundTransaction, saveTrades, markTrade, getOrCreateSetting, TRADES_SINCE_SETTING, openTradeAccounts,
|
||||||
} from './db';
|
} from './db';
|
||||||
import { getCachedFirm } from './firm-catalog';
|
import { getCachedFirm } from './firm-catalog';
|
||||||
import { firmInstruments } from './instruments';
|
import { firmInstruments } from './instruments';
|
||||||
@@ -53,9 +53,10 @@ import { vestSessionFor, dropVestSession, listVestSessions, refuseVestSession }
|
|||||||
import {
|
import {
|
||||||
vestAccountId, vestAccountName, vestDayOf, lastVestResetMs, nextVestResetMs, parseCapitalAccount, parsePosition, parseOpenOrder,
|
vestAccountId, vestAccountName, vestDayOf, lastVestResetMs, nextVestResetMs, parseCapitalAccount, parsePosition, parseOpenOrder,
|
||||||
parseTxn, bookTxns, awaitingLegs, unrealizedPnl, hourlyVolatility, marketOpen, num, toMs, formatDecimal, resetSafeStop,
|
parseTxn, bookTxns, awaitingLegs, unrealizedPnl, hourlyVolatility, marketOpen, num, toMs, formatDecimal, resetSafeStop,
|
||||||
parseHistoryPosition, jwtClaimStrings, accountScopedToken, VEST_STATUS_ACTIVE, VEST_TXN, VEST_STOP_SLIPPAGE_FRACTION,
|
parseHistoryPosition, jwtClaimStrings, accountScopedToken, VEST_STATUS_ACTIVE, VEST_STOP_SLIPPAGE_FRACTION,
|
||||||
|
VestApiError, VestNotSentError, nothingWentIn, tokenForRequest, settleClaims, readClaims, payoutDaysOf, payoutsSince,
|
||||||
} from './vest-helpers';
|
} from './vest-helpers';
|
||||||
import type { VestCapitalAccount, VestPosition, VestOpenOrder, VestTxn, VestTrade } from './vest-helpers';
|
import type { VestCapitalAccount, VestPosition, VestOpenOrder, VestTxn, VestTrade, OwnClaim } from './vest-helpers';
|
||||||
|
|
||||||
const BASE = 'https://api-gateway.hz.vestmarkets.com';
|
const BASE = 'https://api-gateway.hz.vestmarkets.com';
|
||||||
/** The web app's origin: the gateway serves it, so requests say they are its. */
|
/** The web app's origin: the gateway serves it, so requests say they are its. */
|
||||||
@@ -113,29 +114,10 @@ const RESET_ENTRY_BLOCK_MS = RESET_GUARD_MS + 2 * 60_000;
|
|||||||
const GUARD_ECHO_MS = 30_000;
|
const GUARD_ECHO_MS = 30_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;
|
||||||
|
|
||||||
/** The gateway answered with an error status. */
|
|
||||||
class VestApiError extends Error {
|
|
||||||
constructor(message: string, readonly status: number, readonly code: string | undefined) {
|
|
||||||
super(message);
|
|
||||||
this.name = 'VestApiError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SymbolSpec {
|
interface SymbolSpec {
|
||||||
symbol: string;
|
symbol: string;
|
||||||
assetId: number;
|
assetId: number;
|
||||||
@@ -350,8 +332,10 @@ export class VestClient implements PlatformClient {
|
|||||||
* none (`auth: false`, public data). Non-2xx answers become VestApiError
|
* none (`auth: false`, public data). Non-2xx answers become VestApiError
|
||||||
* with the gateway's own code; transport failures a plain Error carrying
|
* with the gateway's own code; transport failures a plain Error carrying
|
||||||
* only the message — never the axios error, whose config holds the token.
|
* only the message — never the axios error, whose config holds the token.
|
||||||
* A refused account token is re-minted once; a refused user token is
|
* An account token that can't be minted is VestNotSentError: the call
|
||||||
* dropped, so nothing keeps knocking with it until a fresh one arrives.
|
* itself never went out. A refused account token is re-minted once; a
|
||||||
|
* refused user token is dropped, so nothing keeps knocking with it until
|
||||||
|
* a fresh one arrives.
|
||||||
*/
|
*/
|
||||||
private async request<T>(
|
private async request<T>(
|
||||||
bucket: Bucket,
|
bucket: Bucket,
|
||||||
@@ -361,7 +345,8 @@ export class VestClient implements PlatformClient {
|
|||||||
): Promise<{ data: T }> {
|
): Promise<{ data: T }> {
|
||||||
let token: string | null = null;
|
let token: string | null = null;
|
||||||
if (opts.auth !== false) {
|
if (opts.auth !== false) {
|
||||||
token = opts.account ? await this.accountToken(opts.account) : this.userToken();
|
const account = opts.account;
|
||||||
|
token = account ? await tokenForRequest(path, () => this.accountToken(account)) : this.userToken();
|
||||||
if (!token) throw new VestApiError(`${path}: no Vest session`, 401, 'NO_SESSION');
|
if (!token) throw new VestApiError(`${path}: no Vest session`, 401, 'NO_SESSION');
|
||||||
}
|
}
|
||||||
await this.gate(bucket);
|
await this.gate(bucket);
|
||||||
@@ -762,7 +747,11 @@ export class VestClient implements PlatformClient {
|
|||||||
const raw = JSON.parse(loadAccountMeta(accountId, CLAIMS_META) ?? '[]');
|
const raw = JSON.parse(loadAccountMeta(accountId, CLAIMS_META) ?? '[]');
|
||||||
list = Array.isArray(raw)
|
list = Array.isArray(raw)
|
||||||
? raw.filter((c) => Number.isFinite(c?.at) && Number.isFinite(c?.amount) && Number.isFinite(c?.base))
|
? 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 } : {}) }))
|
.map((c): OwnClaim => ({
|
||||||
|
at: c.at, amount: c.amount, base: c.base,
|
||||||
|
...(Number.isFinite(c.sentAt) ? { sentAt: c.sentAt } : {}),
|
||||||
|
...(c.unknown === true ? { unknown: true as const } : {}),
|
||||||
|
}))
|
||||||
: [];
|
: [];
|
||||||
} catch {
|
} catch {
|
||||||
list = [];
|
list = [];
|
||||||
@@ -784,10 +773,21 @@ export class VestClient implements PlatformClient {
|
|||||||
saveAccountMeta(accountId, CLAIMS_META, JSON.stringify(kept));
|
saveAccountMeta(accountId, CLAIMS_META, JSON.stringify(kept));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Vest's transactions show the claim: a WITHDRAW of its amount near it. */
|
/** The claims Vest's transactions, as last read (at `readAt`), don't
|
||||||
private claimShown(accountId: number, c: OwnClaim): boolean {
|
* show yet. One of unknown outcome still not shown an hour on never went
|
||||||
return (this.withdrawals.get(accountId) ?? [])
|
* in: it's dropped here (settleClaims). */
|
||||||
.some((w) => Math.abs(Math.abs(w.amount) - c.amount) < 0.01 && Math.abs(w.at - c.at) < CLAIM_MATCH_MS);
|
private outstandingClaims(accountId: number, readAt: number): OwnClaim[] {
|
||||||
|
const { pending, lapsed } = settleClaims(this.claimsOf(accountId), this.withdrawals.get(accountId) ?? [], readAt);
|
||||||
|
this.forgetLapsed(accountId, lapsed);
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop claims of unknown outcome that never went in (settleClaims). */
|
||||||
|
private forgetLapsed(accountId: number, lapsed: OwnClaim[]): void {
|
||||||
|
if (lapsed.length === 0) return;
|
||||||
|
const name = this.accountList.find((a) => a.id === accountId)?.name ?? accountId;
|
||||||
|
this.saveClaims(accountId, this.claimsOf(accountId).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`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -808,13 +808,22 @@ export class VestClient implements PlatformClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** See PlatformClient: the payouts since the last 20:00 ET — this client's
|
/** See PlatformClient: the payouts since the last 20:00 ET — this client's
|
||||||
* own claims, or the WITHDRAW transactions Vest shows, whichever counts
|
* own claims, on Vest's day once its transactions show them, or the
|
||||||
* more (a claim made by hand shows only there). */
|
* WITHDRAW transactions Vest shows, whichever counts more (a claim made
|
||||||
|
* by hand shows only there). */
|
||||||
public sameDayClaims(accountId: number): number {
|
public sameDayClaims(accountId: number): number {
|
||||||
const reset = lastVestResetMs(Date.now());
|
return payoutsSince(this.withdrawals.get(accountId) ?? [], this.claimsOf(accountId), lastVestResetMs(Date.now()));
|
||||||
const mine = this.madeClaims(accountId).filter((c) => c.at >= reset).length;
|
}
|
||||||
const seen = (this.withdrawals.get(accountId) ?? []).filter((w) => w.at >= reset).length;
|
|
||||||
return Math.max(mine, seen);
|
/** See PlatformClient: one per payout, on its Vest day — Vest's WITHDRAW
|
||||||
|
* transactions, and this client's claims they don't show yet
|
||||||
|
* (payoutDaysOf) — where a fund row holds a whole Vest day's. Undefined
|
||||||
|
* until the account's history is read. */
|
||||||
|
public payoutDays(accountId: number): string[] | undefined {
|
||||||
|
const withdrawals = this.withdrawals.get(accountId);
|
||||||
|
const fundRows = this.fundTransactions[accountId];
|
||||||
|
if (!withdrawals || !fundRows || !this.historyLoadedIds.has(accountId)) return undefined;
|
||||||
|
return payoutDaysOf(withdrawals, this.claimsOf(accountId), fundRows);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -826,7 +835,8 @@ export class VestClient implements PlatformClient {
|
|||||||
* The payout is booked here at once — today's fund row, the cycle cut,
|
* The payout is booked here at once — today's fund row, the cycle cut,
|
||||||
* the P&L base — so the account can trade its next stage without waiting
|
* the P&L base — so the account can trade its next stage without waiting
|
||||||
* for the hourly history read, which then rebooks it from Vest's own
|
* for the hourly history read, which then rebooks it from Vest's own
|
||||||
* transactions (the same day's row, the same amount).
|
* transactions (the same day's row, the same amount — or the day Vest
|
||||||
|
* dates it, when a claim at 20:00 ET lands on the other side).
|
||||||
*/
|
*/
|
||||||
public async claimProfit(accountId: number, amount: number): Promise<{ claimed: number; received: number | null }> {
|
public async claimProfit(accountId: number, amount: number): Promise<{ claimed: number; received: number | null }> {
|
||||||
const uuid = this.uuidById.get(accountId);
|
const uuid = this.uuidById.get(accountId);
|
||||||
@@ -848,6 +858,10 @@ export class VestClient implements PlatformClient {
|
|||||||
throw new Error(`$${claimed} is more than the $${round2(cash.amount - meta.initialCapital)} above Vest's starting capital of $${meta.initialCapital}`);
|
throw new Error(`$${claimed} is more than the $${round2(cash.amount - meta.initialCapital)} above Vest's starting capital of $${meta.initialCapital}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Taken before it goes out, which Vest dates it no earlier than: a
|
||||||
|
// claim answered after 20:00 ET isn't the fresh day's payout until
|
||||||
|
// Vest says so (payoutsSince).
|
||||||
|
const sentAt = Date.now();
|
||||||
let data: { trader_amount?: unknown } | undefined;
|
let data: { trader_amount?: unknown } | undefined;
|
||||||
try {
|
try {
|
||||||
({ data } = await this.request<{ trader_amount?: unknown }>('trade', 'POST', '/v3/capital/withdraw', {
|
({ data } = await this.request<{ trader_amount?: unknown }>('trade', 'POST', '/v3/capital/withdraw', {
|
||||||
@@ -860,11 +874,12 @@ export class VestClient implements PlatformClient {
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Only a refusal proves nothing went in. A timeout, a reset or a
|
// Only a refusal, or no account token to send it with, proves
|
||||||
// 5xx may have: hold every further claim on the account until
|
// nothing went in. A timeout, a reset or a 5xx may have: hold
|
||||||
// Vest's transactions say either way (claimPending).
|
// every further claim on the account until Vest's transactions
|
||||||
if (!(err instanceof VestApiError && err.status >= 400 && err.status < 500 && err.status !== 408)) {
|
// say either way (claimPending).
|
||||||
this.saveClaims(accountId, [...this.claimsOf(accountId), { at: Date.now(), amount: claimed, base: round2(cash.amount - claimed), unknown: true }]);
|
if (!nothingWentIn(err)) {
|
||||||
|
this.saveClaims(accountId, [...this.claimsOf(accountId), { at: Date.now(), sentAt, amount: claimed, base: round2(cash.amount - claimed), unknown: true }]);
|
||||||
this.claimSeq.set(accountId, this.syncGeneration);
|
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`);
|
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 */ });
|
void this.fetchDaysTraded([accountId]).catch(() => { /* the hourly read retries */ });
|
||||||
@@ -873,7 +888,7 @@ export class VestClient implements PlatformClient {
|
|||||||
}
|
}
|
||||||
const received = num(data?.trader_amount) ?? null;
|
const received = num(data?.trader_amount) ?? null;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
this.saveClaims(accountId, [...this.claimsOf(accountId), { at: now, amount: claimed, base: round2(cash.amount - claimed) }]);
|
this.saveClaims(accountId, [...this.claimsOf(accountId), { at: now, sentAt, amount: claimed, base: round2(cash.amount - claimed) }]);
|
||||||
this.claimSeq.set(accountId, this.syncGeneration);
|
this.claimSeq.set(accountId, this.syncGeneration);
|
||||||
|
|
||||||
// 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),
|
||||||
@@ -900,15 +915,8 @@ export class VestClient implements PlatformClient {
|
|||||||
*/
|
*/
|
||||||
public claimPending(accountId: number): boolean {
|
public claimPending(accountId: number): boolean {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const readAt = this.historyReadAt.get(accountId) ?? 0;
|
const pending = this.outstandingClaims(accountId, 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;
|
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) {
|
for (const c of pending) {
|
||||||
const key = `${accountId}:${c.at}`;
|
const key = `${accountId}:${c.at}`;
|
||||||
if (!c.unknown && now - c.at > 60 * 60_000 && !this.unmatchedClaimLogged.has(key)) {
|
if (!c.unknown && now - c.at > 60 * 60_000 && !this.unmatchedClaimLogged.has(key)) {
|
||||||
@@ -1331,13 +1339,23 @@ export class VestClient implements PlatformClient {
|
|||||||
.sort((x, y) => x.date.localeCompare(y.date));
|
.sort((x, y) => x.date.localeCompare(y.date));
|
||||||
saveDailyPnL(account.id, account.name, fresh);
|
saveDailyPnL(account.id, account.name, fresh);
|
||||||
|
|
||||||
const fundRows = [...funds.entries()]
|
const readAt = Date.now();
|
||||||
.map(([date, amount]) => ({ date, amount: round2(amount) }))
|
const read = readClaims({
|
||||||
.filter((f) => f.amount !== 0)
|
txns, funds, claims: this.claimsOf(account.id), stored: loadFundTransactions(account.id), readAt,
|
||||||
.sort((x, y) => x.date.localeCompare(y.date));
|
opening: hasDeposit ? 0 : a?.initialCapital ?? 0,
|
||||||
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, read.withdrawals);
|
||||||
this.historyReadAt.set(account.id, Date.now());
|
this.historyReadAt.set(account.id, readAt);
|
||||||
|
// Settled here, not only in claimPending: its callers wait on
|
||||||
|
// historyReady, which a claim that never went in would hold below
|
||||||
|
// for good.
|
||||||
|
this.forgetLapsed(account.id, read.lapsed);
|
||||||
|
|
||||||
|
// A claim Vest dates on the other side of 20:00 ET than it was booked
|
||||||
|
// here leaves that day's row to this client alone: taken back, so the
|
||||||
|
// payout counts once, on Vest's day.
|
||||||
|
if (read.dropped.length > 0) this.dropClaimDays(account, read.dropped);
|
||||||
|
this.applyFundRows(account, read.rows);
|
||||||
|
|
||||||
// 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
|
||||||
@@ -1345,12 +1363,9 @@ export class VestClient implements PlatformClient {
|
|||||||
// cycle as if nothing happened.
|
// cycle as if nothing happened.
|
||||||
const cash = this.accountCashBalances[account.id];
|
const cash = this.accountCashBalances[account.id];
|
||||||
// A claim this client just made may not show in Vest's transactions
|
// A claim this client just made may not show in Vest's transactions
|
||||||
// yet: count it until its WITHDRAW row does (same amount, near time).
|
// yet: readClaims counts it until its WITHDRAW row does (same amount,
|
||||||
const shown = txns.filter((t) => t.type === VEST_TXN.WITHDRAW);
|
// near time).
|
||||||
const unbooked = this.claimsOf(account.id)
|
const expected = read.expected;
|
||||||
.filter((c) => !shown.some((w) => Math.abs(Math.abs(w.amount) - c.amount) < 0.01 && Math.abs(w.time - c.at) < 30 * 60_000))
|
|
||||||
.reduce((s, c) => s + c.amount, 0);
|
|
||||||
const expected = txns.reduce((s, t) => s + (t.kind === 'margin' ? 0 : t.amount), 0) + (hasDeposit ? 0 : a?.initialCapital ?? 0) - unbooked;
|
|
||||||
if (account.active && cash && !this.positions[account.id] && Math.abs(cash.amount - expected) >= BOOKS_TOLERANCE) {
|
if (account.active && cash && !this.positions[account.id] && Math.abs(cash.amount - expected) >= BOOKS_TOLERANCE) {
|
||||||
this.booksHeld.add(account.id);
|
this.booksHeld.add(account.id);
|
||||||
this.lastFetchErrors[account.name] = `balance ${round2(cash.amount)} vs transactions ${round2(expected)} (types ${types.join(',')}) — held until they agree`;
|
this.lastFetchErrors[account.name] = `balance ${round2(cash.amount)} vs transactions ${round2(expected)} (types ${types.join(',')}) — held until they agree`;
|
||||||
@@ -1379,6 +1394,18 @@ export class VestClient implements PlatformClient {
|
|||||||
this.applyCycleSlice(account.id);
|
this.applyCycleSlice(account.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Take back fund rows only a claim of this client's put there (see
|
||||||
|
* claimFundRows), and the latest fund date with them when it was one —
|
||||||
|
* back to the latest row left. The ended-cycle start that date saved
|
||||||
|
* stays: the next fund day saves the same one. */
|
||||||
|
private dropClaimDays(account: PlatformAccount, days: string[]): void {
|
||||||
|
const stored = loadAccountMeta(account.id, 'last_fund_date');
|
||||||
|
const left = loadFundTransactions(account.id).map((f) => f.date).filter((d) => !days.includes(d));
|
||||||
|
const last = stored !== null && !days.includes(stored) ? stored : left.reduce<string | null>((x, y) => (x === null || y > x ? y : x), null);
|
||||||
|
for (const day of days) dropFundTransaction(account.id, day, { last_fund_date: last });
|
||||||
|
console.warn(`${this.tag} ${account.name}: the claim booked on ${days.join(', ')} shows on another Vest day — that row taken back`);
|
||||||
|
}
|
||||||
|
|
||||||
private fallBackToCache(account: PlatformAccount, msg: string): void {
|
private fallBackToCache(account: PlatformAccount, msg: string): void {
|
||||||
const cached = loadDailyPnL(account.id);
|
const cached = loadDailyPnL(account.id);
|
||||||
this.fundTransactions[account.id] ??= loadFundTransactions(account.id);
|
this.fundTransactions[account.id] ??= loadFundTransactions(account.id);
|
||||||
@@ -1739,9 +1766,10 @@ export class VestClient implements PlatformClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST an order. Only an answer that proves nothing was placed becomes
|
* POST an order. Only a failure that proves nothing was placed becomes
|
||||||
* OrderNotPlacedError (a 4xx with the gateway's refusal); a transport
|
* OrderNotPlacedError (a 4xx with the gateway's refusal, or no account
|
||||||
* failure or a 5xx stays a plain Error — it may have gone in.
|
* token to send it with); a transport failure or a 5xx stays a plain
|
||||||
|
* Error — it may have gone in.
|
||||||
*/
|
*/
|
||||||
private async submit(uuid: string, path: string, body: Record<string, unknown>): Promise<{ orderId?: string; positionId?: string }> {
|
private async submit(uuid: string, path: string, body: Record<string, unknown>): Promise<{ orderId?: string; positionId?: string }> {
|
||||||
try {
|
try {
|
||||||
@@ -1751,9 +1779,8 @@ export class VestClient implements PlatformClient {
|
|||||||
positionId: typeof data?.positionId === 'string' ? data.positionId : undefined,
|
positionId: typeof data?.positionId === 'string' ? data.positionId : undefined,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof VestApiError && err.status >= 400 && err.status < 500 && err.status !== 408) {
|
if (err instanceof VestNotSentError) throw new OrderNotPlacedError(`Vest order not sent: ${err.message}`);
|
||||||
throw new OrderNotPlacedError(`Vest refused the order: ${err.message}`);
|
if (nothingWentIn(err)) throw new OrderNotPlacedError(`Vest refused the order: ${errorMessage(err)}`);
|
||||||
}
|
|
||||||
throw new Error(`Vest order outcome unknown (it may still have been placed): ${errorMessage(err)}`);
|
throw new Error(`Vest order outcome unknown (it may still have been placed): ${errorMessage(err)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+167
-1
@@ -1,6 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Pure helpers for the Vest client (lib/vest-class.ts) — parsing the gateway's
|
* Pure helpers for the Vest client (lib/vest-class.ts) — parsing the gateway's
|
||||||
* answers, naming accounts, Vest's trading day, market hours, volatility.
|
* answers and errors, naming accounts, Vest's trading day, profit-claim
|
||||||
|
* bookkeeping, market hours, volatility.
|
||||||
*
|
*
|
||||||
* Only Node built-ins are imported, so scripts/check-vest.mjs loads this file
|
* Only Node built-ins are imported, so scripts/check-vest.mjs loads this file
|
||||||
* directly (node --experimental-strip-types).
|
* directly (node --experimental-strip-types).
|
||||||
@@ -52,6 +53,47 @@ export function formatDecimal(value: number, decimals: number): string {
|
|||||||
return d > 0 ? fixed.replace(/\.?0+$/, '') || '0' : fixed;
|
return d > 0 ? fixed.replace(/\.?0+$/, '') || '0' : fixed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── gateway errors ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** The gateway answered with an error status. */
|
||||||
|
export class VestApiError extends Error {
|
||||||
|
readonly status: number;
|
||||||
|
readonly code: string | undefined;
|
||||||
|
constructor(message: string, status: number, code: string | undefined) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'VestApiError';
|
||||||
|
this.status = status;
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The request never went out: the account token it needed couldn't be had. */
|
||||||
|
export class VestNotSentError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'VestNotSentError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a failed request proves nothing it asked for happened: it never
|
||||||
|
* went out, or the gateway refused it (a 4xx, but not a timeout). A
|
||||||
|
* transport failure or a 5xx may have gone in. */
|
||||||
|
export function nothingWentIn(err: unknown): boolean {
|
||||||
|
return err instanceof VestNotSentError
|
||||||
|
|| (err instanceof VestApiError && err.status >= 400 && err.status < 500 && err.status !== 408);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The account token a request needs, from `mint`. Any failure to mint one —
|
||||||
|
* a timeout, a 5xx, an answer without a token — is the request not sent,
|
||||||
|
* never an outcome that could have gone in. */
|
||||||
|
export async function tokenForRequest(path: string, mint: () => Promise<string>): Promise<string> {
|
||||||
|
try {
|
||||||
|
return await mint();
|
||||||
|
} catch (err) {
|
||||||
|
throw new VestNotSentError(`${path} not sent — no account token: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Vest's trading day ──────────────────────────────────────────────────────
|
// ── Vest's trading day ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
const ET_PARTS = new Intl.DateTimeFormat('en-US', {
|
const ET_PARTS = new Intl.DateTimeFormat('en-US', {
|
||||||
@@ -502,6 +544,130 @@ export function bookTxns(txns: VestTxn[]): { daily: Map<string, { pnl: number; f
|
|||||||
return { daily, funds };
|
return { daily, funds };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── profit claims ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** A claim shows in Vest's transactions as a WITHDRAW of the same amount
|
||||||
|
* within this long of it. */
|
||||||
|
export 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. */
|
||||||
|
export const UNKNOWN_CLAIM_EXPIRES_MS = 60 * 60_000;
|
||||||
|
|
||||||
|
/** A claim this client sent: the amount, when (at = once it was answered;
|
||||||
|
* sentAt = before it went out, absent on claims saved before it was kept),
|
||||||
|
* 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. */
|
||||||
|
export interface OwnClaim { at: number; sentAt?: number; amount: number; base: number; unknown?: true }
|
||||||
|
|
||||||
|
/** A WITHDRAW transaction: when, and its (negative) amount. */
|
||||||
|
export interface VestWithdrawal { at: number; amount: number }
|
||||||
|
|
||||||
|
/** The WITHDRAW that shows the claim — its amount, within CLAIM_MATCH_MS,
|
||||||
|
* the nearest in time — or undefined while Vest's transactions don't. */
|
||||||
|
export function claimWithdrawal(withdrawals: VestWithdrawal[], c: OwnClaim): VestWithdrawal | undefined {
|
||||||
|
let best: VestWithdrawal | undefined;
|
||||||
|
for (const w of withdrawals) {
|
||||||
|
if (Math.abs(Math.abs(w.amount) - c.amount) >= 0.01 || Math.abs(w.at - c.at) >= CLAIM_MATCH_MS) continue;
|
||||||
|
if (!best || Math.abs(w.at - c.at) < Math.abs(best.at - c.at)) best = w;
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The claims Vest's transactions, as read at `readAt`, don't show: those
|
||||||
|
* still pending, and those that lapse — of unknown outcome and still not
|
||||||
|
* shown an hour on, so never made. One Vest accepted never lapses. */
|
||||||
|
export function settleClaims(claims: OwnClaim[], withdrawals: VestWithdrawal[], readAt: number): { pending: OwnClaim[]; lapsed: OwnClaim[] } {
|
||||||
|
const pending: OwnClaim[] = [];
|
||||||
|
const lapsed: OwnClaim[] = [];
|
||||||
|
for (const c of claims) {
|
||||||
|
if (claimWithdrawal(withdrawals, c)) continue;
|
||||||
|
if (c.unknown && readAt - c.at > UNKNOWN_CLAIM_EXPIRES_MS) lapsed.push(c);
|
||||||
|
else pending.push(c);
|
||||||
|
}
|
||||||
|
return { pending, lapsed };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The account's fund rows, one per Vest day: Vest's own (bookTxns' funds),
|
||||||
|
* plus each claim this client made that the transactions don't show yet, on
|
||||||
|
* the day it was booked. And the stored days to take back: a day a claim
|
||||||
|
* was booked on that Vest's transactions leave empty, its WITHDRAW being
|
||||||
|
* dated another day (the request spanned 20:00 ET, or the clocks differ) —
|
||||||
|
* kept, the payout would count twice.
|
||||||
|
*/
|
||||||
|
export function claimFundRows(o: {
|
||||||
|
funds: Map<string, number>; claims: OwnClaim[]; withdrawals: VestWithdrawal[]; stored: { date: string }[];
|
||||||
|
}): { rows: { date: string; amount: number }[]; dropped: string[] } {
|
||||||
|
const funds = new Map(o.funds);
|
||||||
|
const made = o.claims.filter((c) => !c.unknown);
|
||||||
|
for (const c of made) {
|
||||||
|
if (claimWithdrawal(o.withdrawals, c)) continue;
|
||||||
|
const day = vestDayOf(c.at);
|
||||||
|
funds.set(day, (funds.get(day) ?? 0) - c.amount);
|
||||||
|
}
|
||||||
|
const rows = [...funds.entries()]
|
||||||
|
.map(([date, amount]) => ({ date, amount: Math.round(amount * 100) / 100 }))
|
||||||
|
.filter((f) => f.amount !== 0)
|
||||||
|
.sort((x, y) => x.date.localeCompare(y.date));
|
||||||
|
const claimDays = new Set(made.map((c) => vestDayOf(c.at)));
|
||||||
|
const dropped = o.stored.map((f) => f.date).filter((d) => claimDays.has(d) && !rows.some((r) => r.date === d));
|
||||||
|
return { rows, dropped };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a history read makes of this client's claims (VestClient.bookHistory):
|
||||||
|
* Vest's WITHDRAWs; the claims that lapse and those still outstanding
|
||||||
|
* (settleClaims); the fund rows and the stored days to take back
|
||||||
|
* (claimFundRows); and the balance the transactions add up to — `opening`
|
||||||
|
* (the capital of an account Vest funded without a deposit row) included,
|
||||||
|
* the claims they don't show yet taken out — that the books check holds the
|
||||||
|
* account to. A lapsed claim is out of it: counted, one that never went in
|
||||||
|
* would hold the account for good.
|
||||||
|
*/
|
||||||
|
export function readClaims(o: {
|
||||||
|
txns: VestTxn[]; funds: Map<string, number>; claims: OwnClaim[]; stored: { date: string }[]; readAt: number; opening: number;
|
||||||
|
}): {
|
||||||
|
withdrawals: VestWithdrawal[]; lapsed: OwnClaim[]; outstanding: OwnClaim[];
|
||||||
|
rows: { date: string; amount: number }[]; dropped: string[]; expected: number;
|
||||||
|
} {
|
||||||
|
const withdrawals = o.txns.filter((t) => t.type === VEST_TXN.WITHDRAW).map((t) => ({ at: t.time, amount: t.amount }));
|
||||||
|
const { pending, lapsed } = settleClaims(o.claims, withdrawals, o.readAt);
|
||||||
|
const { rows, dropped } = claimFundRows({ funds: o.funds, claims: o.claims.filter((c) => !lapsed.includes(c)), withdrawals, stored: o.stored });
|
||||||
|
const unbooked = pending.reduce((s, c) => s + c.amount, 0);
|
||||||
|
const expected = o.txns.reduce((s, t) => s + (t.kind === 'margin' ? 0 : t.amount), 0) + o.opening - unbooked;
|
||||||
|
return { withdrawals, lapsed, outstanding: pending, rows, dropped, expected };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Each payout taken from the account, by its Vest day, one entry per
|
||||||
|
* payout: every WITHDRAW Vest's transactions show (claims made by hand
|
||||||
|
* among them), and each claim of this client's they don't show yet, on the
|
||||||
|
* day it was booked — a fund row can't say how many, it holds a whole Vest
|
||||||
|
* day's. A day whose fund rows net negative with neither still counts once,
|
||||||
|
* as it did before. Sorted. */
|
||||||
|
export function payoutDaysOf(withdrawals: VestWithdrawal[], claims: OwnClaim[], fundRows: { date: string; amount: number }[]): string[] {
|
||||||
|
const days = [
|
||||||
|
...withdrawals.map((w) => vestDayOf(w.at)),
|
||||||
|
...claims.filter((c) => !c.unknown && !claimWithdrawal(withdrawals, c)).map((c) => vestDayOf(c.at)),
|
||||||
|
];
|
||||||
|
for (const f of fundRows) if (f.amount < 0 && !days.includes(f.date)) days.push(f.date);
|
||||||
|
return days.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Payouts since `since` (the last 20:00 ET): this client's claims — each at
|
||||||
|
* its WITHDRAW's time once Vest shows it, Vest's day being the one it
|
||||||
|
* counts on, and until then at when it was sent — or the WITHDRAWs Vest
|
||||||
|
* shows, whichever counts more (a claim made by hand shows only there).
|
||||||
|
* Sent, not answered: Vest dates it no earlier (clocks in sync), so a
|
||||||
|
* claim answered just after 20:00 ET can't put the fresh day on a payout
|
||||||
|
* stage before Vest says it's that day's; one Vest dates after the turn
|
||||||
|
* reads as the day before until then, the cautious way round. */
|
||||||
|
export function payoutsSince(withdrawals: VestWithdrawal[], claims: OwnClaim[], since: number): number {
|
||||||
|
const mine = claims.filter((c) => !c.unknown && (claimWithdrawal(withdrawals, c)?.at ?? c.sentAt ?? c.at) >= since).length;
|
||||||
|
const seen = withdrawals.filter((w) => w.at >= since).length;
|
||||||
|
return Math.max(mine, seen);
|
||||||
|
}
|
||||||
|
|
||||||
// ── market data ─────────────────────────────────────────────────────────────
|
// ── market data ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Fewest hourly returns a volatility is measured from. */
|
/** Fewest hourly returns a volatility is measured from. */
|
||||||
|
|||||||
@@ -715,6 +715,39 @@ test('an every-day firm can finish its cycle on a Saturday', () => {
|
|||||||
assert.equal(cycleCompletion(opts), 'none');
|
assert.equal(cycleCompletion(opts), 'none');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
import { completionPlace } from '../lib/trading-logic.ts';
|
||||||
|
|
||||||
|
test('completionPlace: each payout counts, and a day\'s second cycle has a key of its own', () => {
|
||||||
|
// Vest: activated on the 20th; claims c1 and c2 on the 27th share one
|
||||||
|
// fund row; c3 on the 28th.
|
||||||
|
const activated = { date: '2026-10-20', amount: 50000 };
|
||||||
|
// Cycle A, the first, completes on the 27th ahead of any claim.
|
||||||
|
assert.deepEqual(completionPlace({ payoutDays: [], fundTransactions: [activated], cycle: '2026-10-20' }), { stage: 1, cycleSeq: 0 });
|
||||||
|
// c1 starts cycle B, which completes the same day.
|
||||||
|
const b = completionPlace({ payoutDays: ['2026-10-27'], fundTransactions: [activated, { date: '2026-10-27', amount: -2250 }], cycle: '2026-10-27' });
|
||||||
|
assert.deepEqual(b, { stage: 2, cycleSeq: 0 });
|
||||||
|
// c2 starts cycle C on the same fund row, which completes on the 28th:
|
||||||
|
// payout 3, and not B's key — the master would fold C into B.
|
||||||
|
const both = [activated, { date: '2026-10-27', amount: -3750 }];
|
||||||
|
const c = completionPlace({ payoutDays: ['2026-10-27', '2026-10-27'], fundTransactions: both, cycle: '2026-10-27' });
|
||||||
|
assert.deepEqual(c, { stage: 3, cycleSeq: 1 });
|
||||||
|
// C caught up after c3 ended it: the same stage and key as its final day.
|
||||||
|
assert.deepEqual(completionPlace({
|
||||||
|
payoutDays: ['2026-10-27', '2026-10-27', '2026-10-28'], fundTransactions: [...both, { date: '2026-10-28', amount: -1500 }],
|
||||||
|
cycle: '2026-10-27', end: '2026-10-28',
|
||||||
|
}), c);
|
||||||
|
// One fund row per payout (every other platform): 1 + the rows before, no
|
||||||
|
// cycleSeq — as the stage always read.
|
||||||
|
const rows = [{ date: '2026-09-01', amount: -500 }, { date: '2026-09-15', amount: -800 }];
|
||||||
|
const days = rows.map((f) => f.date);
|
||||||
|
assert.deepEqual(completionPlace({ payoutDays: days, fundTransactions: rows, cycle: '2026-09-15' }), { stage: 3, cycleSeq: 0 });
|
||||||
|
assert.deepEqual(completionPlace({ payoutDays: days, fundTransactions: rows, cycle: '2026-09-01', end: '2026-09-15' }), { stage: 2, cycleSeq: 0 });
|
||||||
|
// The account's first cycle; and a claim on the day it was activated,
|
||||||
|
// whose deposit row names the cycle it started.
|
||||||
|
assert.deepEqual(completionPlace({ payoutDays: [], fundTransactions: [], cycle: '' }), { stage: 1, cycleSeq: 0 });
|
||||||
|
assert.deepEqual(completionPlace({ payoutDays: ['2026-10-20'], fundTransactions: [{ date: '2026-10-20', amount: 47750 }], cycle: '2026-10-20' }), { stage: 2, cycleSeq: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
// ── DXTrade helpers (lib/dxtrade-helpers.ts) ─────────────────────────────────
|
// ── DXTrade helpers (lib/dxtrade-helpers.ts) ─────────────────────────────────
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|||||||
+139
-1
@@ -12,7 +12,8 @@ import {
|
|||||||
isVestWalletAddress, vestAccountId, num, toMs, formatDecimal, vestDayOf, lastVestResetMs, parseCapitalAccount,
|
isVestWalletAddress, vestAccountId, num, toMs, formatDecimal, vestDayOf, lastVestResetMs, parseCapitalAccount,
|
||||||
vestAccountName, parsePosition, awaitingLegs, parseOpenOrder, unrealizedPnl, parseTxn, bookTxns, txnKind, VEST_TXN,
|
vestAccountName, parsePosition, awaitingLegs, parseOpenOrder, unrealizedPnl, parseTxn, bookTxns, txnKind, VEST_TXN,
|
||||||
hourlyVolatility, marketOpen, MIN_HOURLY_RETURNS, nextVestResetMs, resetSafeStop, parseHistoryPosition, jwtClaimStrings,
|
hourlyVolatility, marketOpen, MIN_HOURLY_RETURNS, nextVestResetMs, resetSafeStop, parseHistoryPosition, jwtClaimStrings,
|
||||||
accountScopedToken,
|
accountScopedToken, VestApiError, VestNotSentError, nothingWentIn, tokenForRequest, claimWithdrawal, settleClaims,
|
||||||
|
claimFundRows, readClaims, payoutDaysOf, payoutsSince,
|
||||||
} from '../lib/vest-helpers.ts';
|
} from '../lib/vest-helpers.ts';
|
||||||
import { putVestSession, vestSessionFor, dropVestSession, listVestSessions, refuseVestSession } from '../lib/vest-session.ts';
|
import { putVestSession, vestSessionFor, dropVestSession, listVestSessions, refuseVestSession } from '../lib/vest-session.ts';
|
||||||
|
|
||||||
@@ -171,6 +172,143 @@ test('transactions book into daily P&L and fund rows by Vest day', () => {
|
|||||||
assert.ok(Math.abs(daily.get('2026-10-27').fees - 31.2) < 1e-9);
|
assert.ok(Math.abs(daily.get('2026-10-27').fees - 31.2) < 1e-9);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const withdrawalsOf = (txns) => txns.filter((t) => t.type === VEST_TXN.WITHDRAW).map((t) => ({ at: t.time, amount: t.amount }));
|
||||||
|
const deposit = { action_id: 'd', amount: '50000', created_at: iso('2026-10-20T14:00:00Z'), transaction_type: 1 };
|
||||||
|
|
||||||
|
test('a claim of unknown outcome that never shows lapses an hour on; one Vest accepted never does', () => {
|
||||||
|
const at = iso('2026-10-27T15:00:00Z');
|
||||||
|
const lost = { at, amount: 2250, base: 50000, unknown: true };
|
||||||
|
// Read just after the request failed: it may yet show, so it holds.
|
||||||
|
assert.deepEqual(settleClaims([lost], [], at + 60_000), { pending: [lost], lapsed: [] });
|
||||||
|
// Still not shown by a read an hour on: it never went in.
|
||||||
|
assert.deepEqual(settleClaims([lost], [], at + 61 * 60_000), { pending: [], lapsed: [lost] });
|
||||||
|
// It went in after all: a WITHDRAW of its amount near it settles it.
|
||||||
|
assert.deepEqual(settleClaims([lost], [{ at: at + 3_000, amount: -2250 }], at + 61 * 60_000), { pending: [], lapsed: [] });
|
||||||
|
assert.equal(claimWithdrawal([{ at: at + 3_000, amount: -2249 }], lost), undefined, 'another amount');
|
||||||
|
assert.equal(claimWithdrawal([{ at: at + 31 * 60_000, amount: -2250 }], lost), undefined, 'too far off');
|
||||||
|
assert.deepEqual(claimWithdrawal([{ at: at + 20 * 60_000, amount: -2250 }, { at: at - 2_000, amount: -2250 }], lost), { at: at - 2_000, amount: -2250 });
|
||||||
|
// A claim Vest accepted holds for as long as it doesn't show.
|
||||||
|
const accepted = { at, amount: 2250, base: 50000 };
|
||||||
|
assert.deepEqual(settleClaims([accepted], [], at + 48 * 3_600_000), { pending: [accepted], lapsed: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the books stop counting a claim that never went in once it lapses, so it holds the account no longer', () => {
|
||||||
|
const at = iso('2026-10-27T15:00:00Z');
|
||||||
|
const lost = { at, sentAt: at - 20_000, amount: 2250, base: 50150, unknown: true };
|
||||||
|
const txns = [deposit, { action_id: 'r1', amount: '2400', created_at: at - 3_600_000, transaction_type: 9 }].map(parseTxn);
|
||||||
|
const read = (claims, readAt) => readClaims({ txns, funds: bookTxns(txns).funds, claims, stored: [], readAt, opening: 0 });
|
||||||
|
// It never went in: the balance is what the transactions add up to.
|
||||||
|
const cash = 52400;
|
||||||
|
// Half an hour on it may yet show, so the books still take it out (and hold).
|
||||||
|
const early = read([lost], at + 30 * 60_000);
|
||||||
|
assert.deepEqual([early.lapsed, early.outstanding], [[], [lost]]);
|
||||||
|
assert.equal(cash - early.expected, 2250);
|
||||||
|
// A read an hour on lapses it, and the books agree again.
|
||||||
|
const late = read([lost], at + 61 * 60_000);
|
||||||
|
assert.deepEqual([late.lapsed, late.outstanding], [[lost], []]);
|
||||||
|
assert.equal(late.expected, cash);
|
||||||
|
assert.deepEqual(late.rows, [{ date: '2026-10-20', amount: 50000 }]);
|
||||||
|
// One Vest accepted that doesn't show yet: out of the balance and on its
|
||||||
|
// day's row, however long it takes.
|
||||||
|
const accepted = { at, sentAt: at - 20_000, amount: 2250, base: 50150 };
|
||||||
|
const waiting = read([accepted], at + 48 * 3_600_000);
|
||||||
|
assert.deepEqual([waiting.lapsed, waiting.outstanding], [[], [accepted]]);
|
||||||
|
assert.equal(waiting.expected, cash - 2250);
|
||||||
|
assert.deepEqual(waiting.rows, [{ date: '2026-10-20', amount: 50000 }, { date: '2026-10-27', amount: -2250 }]);
|
||||||
|
// Once it shows, the WITHDRAW itself is in the sum.
|
||||||
|
const shown = [...txns, parseTxn({ action_id: 'w1', amount: '2250', created_at: at + 2_000, transaction_type: 2 })];
|
||||||
|
const settled = readClaims({ txns: shown, funds: bookTxns(shown).funds, claims: [accepted], stored: [], readAt: at + 3_600_000, opening: 0 });
|
||||||
|
assert.deepEqual(settled.withdrawals, [{ at: at + 2_000, amount: -2250 }]);
|
||||||
|
assert.deepEqual(settled.outstanding, []);
|
||||||
|
assert.equal(settled.expected, cash - 2250);
|
||||||
|
// An account Vest funded without a deposit row: its capital counts in.
|
||||||
|
assert.equal(readClaims({ txns: txns.slice(1), funds: new Map(), claims: [], stored: [], readAt: at, opening: 50000 }).expected, cash);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an account token that can\'t be minted means the order or claim was never sent', async () => {
|
||||||
|
const path = '/v3/capital/withdraw';
|
||||||
|
assert.equal(await tokenForRequest(path, async () => 'tok'), 'tok');
|
||||||
|
const failures = [
|
||||||
|
new Error('/v3/auth/account-token timed out after 20s'),
|
||||||
|
new VestApiError('/v3/auth/account-token: HTTP 502 (HTTP 502)', 502, undefined),
|
||||||
|
new Error('/v3/auth/account-token answered without a token'),
|
||||||
|
];
|
||||||
|
for (const failure of failures) {
|
||||||
|
// As the request's own answer each could have gone in...
|
||||||
|
assert.equal(nothingWentIn(failure), false);
|
||||||
|
// ...but from the mint, the request never went out.
|
||||||
|
await assert.rejects(tokenForRequest(path, async () => { throw failure; }), (err) =>
|
||||||
|
err instanceof VestNotSentError && nothingWentIn(err) && err.message.startsWith(`${path} not sent`));
|
||||||
|
}
|
||||||
|
// The request's own answer: only a refusal proves nothing went in.
|
||||||
|
assert.equal(nothingWentIn(new VestApiError('x (HTTP 400, code 1001)', 400, '1001')), true);
|
||||||
|
assert.equal(nothingWentIn(new VestApiError('x: no Vest session', 401, 'NO_SESSION')), true);
|
||||||
|
assert.equal(nothingWentIn(new VestApiError('x (HTTP 408)', 408, undefined)), false);
|
||||||
|
assert.equal(nothingWentIn(new VestApiError('x (HTTP 503)', 503, undefined)), false);
|
||||||
|
assert.equal(nothingWentIn(new Error('x: socket hang up')), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a claim Vest dates on the other side of 20:00 ET keeps no fund row of its own', () => {
|
||||||
|
// Sent at 19:59:59.8 ET on the 26th and answered after 20:00: Vest dates
|
||||||
|
// its WITHDRAW on the 26th's day, the client booked it on the 27th's.
|
||||||
|
const claim = { at: iso('2026-10-27T00:00:00.300Z'), sentAt: iso('2026-10-26T23:59:59.800Z'), amount: 2250, base: 50000 };
|
||||||
|
const txns = [deposit, { action_id: 'w1', amount: '2250', created_at: iso('2026-10-26T23:59:59.900Z'), transaction_type: 2 }].map(parseTxn);
|
||||||
|
const stored = [{ date: '2026-10-20', amount: 50000 }, { date: '2026-10-27', amount: -2250 }];
|
||||||
|
// Before Vest shows it, it stands where it was booked.
|
||||||
|
assert.deepEqual(claimFundRows({ funds: bookTxns(txns.slice(0, 1)).funds, claims: [claim], withdrawals: [], stored }), {
|
||||||
|
rows: [{ date: '2026-10-20', amount: 50000 }, { date: '2026-10-27', amount: -2250 }], dropped: [],
|
||||||
|
});
|
||||||
|
// Once it shows, it's on Vest's day, and the 27th's row goes.
|
||||||
|
assert.deepEqual(claimFundRows({ funds: bookTxns(txns).funds, claims: [claim], withdrawals: withdrawalsOf(txns), stored }), {
|
||||||
|
rows: [{ date: '2026-10-20', amount: 50000 }, { date: '2026-10-26', amount: -2250 }], dropped: ['2026-10-27'],
|
||||||
|
});
|
||||||
|
// The 27th starts with no payout on it, before Vest shows the claim too:
|
||||||
|
// it went out on the 26th, and Vest can't date it earlier.
|
||||||
|
const reset = lastVestResetMs(iso('2026-10-27T15:00:00Z'));
|
||||||
|
assert.equal(payoutsSince(withdrawalsOf(txns), [claim], reset), 0);
|
||||||
|
assert.equal(payoutsSince([], [claim], reset), 0);
|
||||||
|
// Dated after the turn instead, it's the 27th's once Vest shows it.
|
||||||
|
const after = [deposit, { action_id: 'w3', amount: '2250', created_at: iso('2026-10-27T00:00:00.100Z'), transaction_type: 2 }].map(parseTxn);
|
||||||
|
assert.equal(payoutsSince(withdrawalsOf(after), [claim], reset), 1);
|
||||||
|
// One saved before the send time was kept counts from its answer.
|
||||||
|
assert.equal(payoutsSince([], [{ at: claim.at, amount: claim.amount, base: claim.base }], reset), 1);
|
||||||
|
|
||||||
|
// Dated the same day: its row stays. A day Vest has other fund rows on
|
||||||
|
// is Vest's, with a claim it doesn't show yet added.
|
||||||
|
const sameDay = { at: iso('2026-10-26T18:00:00Z'), amount: 1000, base: 49000 };
|
||||||
|
const shown = [deposit, { action_id: 'w2', amount: '1000', created_at: sameDay.at + 2_000, transaction_type: 2 }].map(parseTxn);
|
||||||
|
assert.deepEqual(claimFundRows({ funds: bookTxns(shown).funds, claims: [sameDay], withdrawals: withdrawalsOf(shown), stored: [{ date: '2026-10-26' }] }).dropped, []);
|
||||||
|
const credit = parseTxn({ action_id: 'c1', amount: '40', created_at: sameDay.at - 3_600_000, transaction_type: 22 });
|
||||||
|
assert.deepEqual(claimFundRows({ funds: bookTxns([credit]).funds, claims: [sameDay], withdrawals: [], stored: [] }).rows, [{ date: '2026-10-26', amount: -960 }]);
|
||||||
|
// One of unknown outcome was never booked, so it's no day's.
|
||||||
|
assert.deepEqual(claimFundRows({ funds: new Map(), claims: [{ ...claim, unknown: true }], withdrawals: [], stored }), { rows: [], dropped: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('two claims on one Vest day share a fund row but count as two payouts', () => {
|
||||||
|
const first = { at: iso('2026-10-27T14:00:00Z'), amount: 2250, base: 50000 };
|
||||||
|
const second = { at: iso('2026-10-27T18:30:00Z'), amount: 1500, base: 50000 };
|
||||||
|
const txns = [
|
||||||
|
deposit,
|
||||||
|
{ action_id: 'w1', amount: '2250', created_at: first.at + 2_000, transaction_type: 2 },
|
||||||
|
{ action_id: 'w2', amount: '1500', created_at: second.at + 2_000, transaction_type: 2 },
|
||||||
|
].map(parseTxn);
|
||||||
|
const withdrawals = withdrawalsOf(txns);
|
||||||
|
const { rows } = claimFundRows({ funds: bookTxns(txns).funds, claims: [first, second], withdrawals, stored: [] });
|
||||||
|
assert.deepEqual(rows.filter((f) => f.amount < 0), [{ date: '2026-10-27', amount: -3750 }]);
|
||||||
|
assert.deepEqual(payoutDaysOf(withdrawals, [first, second], rows), ['2026-10-27', '2026-10-27']);
|
||||||
|
// The second not shown yet: still two, on the day it was booked.
|
||||||
|
assert.deepEqual(payoutDaysOf(withdrawals.slice(0, 1), [first, second], rows), ['2026-10-27', '2026-10-27']);
|
||||||
|
// Made by hand, they show only as WITHDRAWs.
|
||||||
|
assert.equal(payoutDaysOf(withdrawals, [], rows).length, 2);
|
||||||
|
// Of unknown outcome, one counts only once it shows.
|
||||||
|
assert.deepEqual(payoutDaysOf([], [{ ...first, unknown: true }], []), []);
|
||||||
|
assert.equal(payoutDaysOf(withdrawals.slice(0, 1), [{ ...first, unknown: true }], rows).length, 1);
|
||||||
|
// A day that nets negative with no WITHDRAW still counts, as it did.
|
||||||
|
assert.deepEqual(payoutDaysOf([], [], [{ date: '2026-10-21', amount: -75 }, { date: '2026-10-22', amount: 20 }]), ['2026-10-21']);
|
||||||
|
assert.deepEqual(payoutDaysOf(withdrawals, [], [...rows, { date: '2026-10-21', amount: -75 }]), ['2026-10-21', '2026-10-27', '2026-10-27']);
|
||||||
|
assert.equal(payoutsSince(withdrawals, [first, second], lastVestResetMs(second.at)), 2);
|
||||||
|
});
|
||||||
|
|
||||||
test('hourly volatility leaves out the gaps', () => {
|
test('hourly volatility leaves out the gaps', () => {
|
||||||
const start = iso('2026-10-19T00:00:00Z');
|
const start = iso('2026-10-19T00:00:00Z');
|
||||||
const kl = [];
|
const kl = [];
|
||||||
|
|||||||
Reference in New Issue
Block a user