Vest: a claim names the completed cycle it ends, a day's second and one after the reset too
Two of the cycles a claim ends never got a completion entry. A Vest day's second cycle is one: the auto-claim at the next tick beats the 30 s report, and the payout books into that day's fund row, so the latest fund date never moves for the catch-up. A cycle claimed right after the 20:00 ET reset is the other: the reset puts it back on stage 1, and all of its P&L sits in the previous claim's day row, so endedCycleCompletion reads none of it. Before a claim goes out, claimedCycle (trading-logic) checks whether the cycle stands complete: cycleCompletion 'today' or 'before' on the client's numbers. It keys the cycle the way the final day would (completionPlace on the payout list), so the master dedupes the two. afterClaim keeps the result in account_meta. The reporter names each kept cycle for the catch-up's week. It lists them last in the slice, newest first, under the master's per-slice cap, and adds no counter. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
4a8e027056
commit
e02838409b
+35
-6
@@ -19,9 +19,10 @@ import {
|
|||||||
FALLBACK_COMMISSION_PER_CONTRACT, MAX_COMMISSION_PER_CONTRACT, DLL_MIN_STOP,
|
FALLBACK_COMMISSION_PER_CONTRACT, MAX_COMMISSION_PER_CONTRACT, DLL_MIN_STOP,
|
||||||
CME_EVENING_OPEN_MIN, foldReason, withinFirmCaps,
|
CME_EVENING_OPEN_MIN, foldReason, withinFirmCaps,
|
||||||
fundRowsKey, markFinishedCycle, unbookedPayoutDrop, machineSide, stageInputsFor, floorRoom, shortestNextLeg,
|
fundRowsKey, markFinishedCycle, unbookedPayoutDrop, machineSide, stageInputsFor, floorRoom, shortestNextLeg,
|
||||||
type DayPlan, type FinishedCycleMark,
|
claimedCycle, withinEndedCycleWindow,
|
||||||
|
type DayPlan, type FinishedCycleMark, type ClaimedCycle,
|
||||||
} from './trading-logic';
|
} from './trading-logic';
|
||||||
import { getSetting, setSetting, loadAccountMeta, saveAccountMeta } from './db';
|
import { getSetting, setSetting, loadAccountMeta, saveAccountMeta, loadClaimedCycles, saveClaimedCycles } from './db';
|
||||||
import type { LaneTick, TickReason } from './master-protocol';
|
import type { LaneTick, TickReason } from './master-protocol';
|
||||||
import { isNotionalPlatform } from './master-protocol';
|
import { isNotionalPlatform } from './master-protocol';
|
||||||
import { POINT_VALUES, housekeepingRoot, HOUSEKEEPING_ROOT, MAX_HOLD_HOURS, futuresContracts } from './instrument-specs';
|
import { POINT_VALUES, housekeepingRoot, HOUSEKEEPING_ROOT, MAX_HOLD_HOURS, futuresContracts } from './instrument-specs';
|
||||||
@@ -2011,10 +2012,11 @@ async function runClaims(lane: string, ready: FirmClient[]): Promise<void> {
|
|||||||
const amount = claimDue(client, acc, Date.now());
|
const amount = claimDue(client, acc, Date.now());
|
||||||
if (amount === null) continue;
|
if (amount === null) continue;
|
||||||
try {
|
try {
|
||||||
|
const ends = claimEnds(client, acc);
|
||||||
// In flight while it claims: a batch that takes over a wedged
|
// In flight while it claims: a batch that takes over a wedged
|
||||||
// lane, or a Claim now, leaves the account alone.
|
// lane, or a Claim now, leaves the account alone.
|
||||||
const { claimed, received } = await withAccountInFlight(acc.id, () => client.claimProfit!(acc.id, amount));
|
const { claimed, received } = await withAccountInFlight(acc.id, () => client.claimProfit!(acc.id, amount));
|
||||||
afterClaim('[auto-claim]', firm, client, acc, claimed, received, tradeDateFor(client));
|
afterClaim('[auto-claim]', firm, client, acc, claimed, received, tradeDateFor(client), ends);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
(runtime().claimFailedAt ??= new Map()).set(acc.id, Date.now());
|
(runtime().claimFailedAt ??= new Map()).set(acc.id, Date.now());
|
||||||
console.error(`[auto-claim] ${firm.name} ${acc.name}: claim of $${Math.floor(amount * 100) / 100} failed — ${errorMessage(err)}; retrying in ${CLAIM_RETRY_MS / 60_000}m`);
|
console.error(`[auto-claim] ${firm.name} ${acc.name}: claim of $${Math.floor(amount * 100) / 100} failed — ${errorMessage(err)}; retrying in ${CLAIM_RETRY_MS / 60_000}m`);
|
||||||
@@ -2062,15 +2064,41 @@ async function claimTick(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The completed cycle a claim about to go out ends (claimedCycle), taken
|
||||||
|
* before the claim books its payout. Not on rows a failed read left behind
|
||||||
|
* — the master keeps it — which is historyFresh's own test for the
|
||||||
|
* account; not its client-wide one, no read in flight, as each claim
|
||||||
|
* starts a read and the next claim of the tick would never name its
|
||||||
|
* cycle. The claim already stands on the account's read (historyReady).
|
||||||
|
* Never throws: a payout isn't held back for its record. */
|
||||||
|
function claimEnds(client: PlatformClient, acc: { id: number; name: string }): ClaimedCycle | null {
|
||||||
|
try {
|
||||||
|
const cfg = getAccountConfig(acc.id).accountType;
|
||||||
|
return cfg && !client.lastFetchErrors?.[acc.name] ? claimedCycle(client, acc.id, cfg) : null;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[claim] ${acc.name}: the cycle the claim ends couldn't be read — ${errorMessage(err)}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 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 — and
|
||||||
|
* the completed cycle it ended (claimEnds) is kept for the reporter, which
|
||||||
|
* may never have seen it complete. */
|
||||||
function afterClaim(
|
function afterClaim(
|
||||||
tag: string, firm: TradableFirm, client: PlatformClient, acc: { id: number; name: string },
|
tag: string, firm: TradableFirm, client: PlatformClient, acc: { id: number; name: string },
|
||||||
claimed: number, received: number | null, tradeDate: string,
|
claimed: number, received: number | null, tradeDate: string, ends: ClaimedCycle | null,
|
||||||
): number {
|
): number {
|
||||||
runtime().legCounts.delete(acc.id);
|
runtime().legCounts.delete(acc.id);
|
||||||
runtime().entryOutcomes?.delete(acc.id);
|
runtime().entryOutcomes?.delete(acc.id);
|
||||||
runtime().claimFailedAt?.delete(acc.id);
|
runtime().claimFailedAt?.delete(acc.id);
|
||||||
|
if (ends) {
|
||||||
|
try {
|
||||||
|
saveClaimedCycles(acc.id, [...loadClaimedCycles(acc.id).filter((c) => withinEndedCycleWindow(c.date, ends.date)), ends]);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`${tag} ${firm.name} ${acc.name}: the completed cycle the claim ended wasn't saved — ${errorMessage(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
const stage = 1 + (client.sameDayClaims?.(acc.id) ?? 0);
|
const stage = 1 + (client.sameDayClaims?.(acc.id) ?? 0);
|
||||||
console.log(`${tag} ${firm.name} ${acc.name}: claimed $${claimed}${received !== null ? ` (you get $${received})` : ''}`
|
console.log(`${tag} ${firm.name} ${acc.name}: claimed $${claimed}${received !== null ? ` (you get $${received})` : ''}`
|
||||||
+ ` — trading stage ${stage} next (${tradeDate})`);
|
+ ` — trading stage ${stage} next (${tradeDate})`);
|
||||||
@@ -2147,8 +2175,9 @@ 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 ends = claimEnds(c.client, c.acc);
|
||||||
const { claimed, received } = await withAccountInFlight(c.acc.id, () => 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, tradeDateFor(c.client));
|
const stage = afterClaim('[claim-now]', c.firm, c.client, c.acc, claimed, received, tradeDateFor(c.client), ends);
|
||||||
return { claimed, received, stage };
|
return { claimed, received, stage };
|
||||||
} finally {
|
} finally {
|
||||||
releaseTradeMutex(lease, lane);
|
releaseTradeMutex(lease, lane);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import Database from 'better-sqlite3';
|
|||||||
import { mkdirSync } from 'fs';
|
import { mkdirSync } from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { sortableIso, stampRulesDate } from './kept-rules';
|
import { sortableIso, stampRulesDate } from './kept-rules';
|
||||||
|
import type { ClaimedCycle } from './trading-logic';
|
||||||
|
|
||||||
// AUTOFIRMER_DB lets several instances share one checkout, each with its own
|
// AUTOFIRMER_DB lets several instances share one checkout, each with its own
|
||||||
// database. It is read once, at module load — schema creation and seeding are
|
// database. It is read once, at module load — schema creation and seeding are
|
||||||
@@ -556,6 +557,29 @@ export function loadEndedCycleStart(accountId: number): { fundDate: string; incl
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The completed cycles this instance's own claims ended (trading-logic
|
||||||
|
* claimedCycle), oldest first — saved by the claim (auto-trade afterClaim),
|
||||||
|
* since it can end one before any report sees it complete; the reporter
|
||||||
|
* names each for the catch-up's week. Gone with a wipe, as the claims
|
||||||
|
* themselves are. */
|
||||||
|
export function saveClaimedCycles(accountId: number, list: ClaimedCycle[]): void {
|
||||||
|
saveAccountMeta(accountId, 'claimed_cycles', JSON.stringify(list));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadClaimedCycles(accountId: number): ClaimedCycle[] {
|
||||||
|
const raw = loadAccountMeta(accountId, 'claimed_cycles');
|
||||||
|
if (raw === null) return [];
|
||||||
|
try {
|
||||||
|
const list = JSON.parse(raw) as unknown;
|
||||||
|
if (!Array.isArray(list)) return [];
|
||||||
|
return list.filter((c): c is ClaimedCycle => typeof c?.date === 'string' && typeof c.cycle === 'string'
|
||||||
|
&& Number.isInteger(c.stage) && typeof c.drain === 'boolean' && Number.isInteger(c.cycleSeq))
|
||||||
|
.map(({ date, stage, drain, cycle, cycleSeq }) => ({ date, stage, drain, cycle, cycleSeq }));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Deletes every cached row for an account — daily P&L, fund transactions and
|
/** Deletes every cached row for an account — daily P&L, fund transactions and
|
||||||
* meta — so the next report fetch rebuilds it from scratch. Rows older than
|
* meta — so the next report fetch rebuilds it from scratch. Rows older than
|
||||||
* the report's 28-day window are gone for good; that is the point (stale
|
* the report's 28-day window are gone for good; that is the point (stale
|
||||||
|
|||||||
@@ -43,8 +43,11 @@ export interface AccountTypeStats {
|
|||||||
* keys on it tells them apart; one that doesn't folds them into the
|
* 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
|
* 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 — and, for 7 days after
|
||||||
* not counted in `completed`. Optional: older instances omit it. */
|
* it, each cycle the instance's own payout claim ended complete, dated
|
||||||
|
* the claim's day and listed last, newest first (a claim can end one
|
||||||
|
* before any report sees it). Caught-up and claimed entries are not
|
||||||
|
* counted in `completed`. Optional: older instances omit it. */
|
||||||
completedAccounts?: { name: string; date: string; stage: number; kind: 'pass' | 'payout' | 'drain'; cycle: string; cycleSeq?: number }[];
|
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
|
||||||
|
|||||||
+46
-2
@@ -11,7 +11,7 @@
|
|||||||
* POST keeps those working unchanged.
|
* POST keeps those working unchanged.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { getSetting, loadEndedCycleStart } from './db';
|
import { getSetting, loadClaimedCycles, loadEndedCycleStart } from './db';
|
||||||
import { pushReport } from './master-link';
|
import { pushReport } from './master-link';
|
||||||
import { getBuildVersion } from './version';
|
import { getBuildVersion } from './version';
|
||||||
import { tradeMixFor } from './trade-mix';
|
import { tradeMixFor } from './trade-mix';
|
||||||
@@ -20,7 +20,10 @@ 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, completionPlace, cycleCompletion, endedCycleCompletion, LEG_COMPLETION_EPSILON, projectPerfectPath, resolveEffectiveConfig, stageInputsFor, tradeDateFor } from './trading-logic';
|
import {
|
||||||
|
botStopsActive, combineCmeSessions, computeDayPlan, completionPlace, cycleCompletion, endedCycleCompletion, LEG_COMPLETION_EPSILON,
|
||||||
|
projectPerfectPath, resolveEffectiveConfig, stageInputsFor, tradeDateFor, withinEndedCycleWindow,
|
||||||
|
} 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';
|
||||||
@@ -92,6 +95,9 @@ function collectFirmStats() {
|
|||||||
}
|
}
|
||||||
return entry;
|
return entry;
|
||||||
};
|
};
|
||||||
|
// The completed cycles this instance's own claims ended, by slice
|
||||||
|
// (listed after the accounts — see below).
|
||||||
|
const claimedBySlice = new Map<AccountTypeStats, NonNullable<AccountTypeStats['completedAccounts']>>();
|
||||||
|
|
||||||
for (const acc of client.accountList) {
|
for (const acc of client.accountList) {
|
||||||
const reportedCash = client.accountCashBalances[acc.id];
|
const reportedCash = client.accountCashBalances[acc.id];
|
||||||
@@ -314,6 +320,28 @@ function collectFirmStats() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The completed cycles this instance's own claims ended
|
||||||
|
// (afterClaim), named for the catch-up's week: a claim can end
|
||||||
|
// one before any report saw it complete, where the catch-up
|
||||||
|
// can't find it again (a Vest day's second claim, or one right
|
||||||
|
// after the reset — trading-logic claimedCycle). Named only, as
|
||||||
|
// the catch-up's: no counter moves.
|
||||||
|
if (cfg) {
|
||||||
|
for (const c of loadClaimedCycles(acc.id).reverse()) {
|
||||||
|
if (!withinEndedCycleWindow(c.date, rawToday)) continue;
|
||||||
|
let list = claimedBySlice.get(slice);
|
||||||
|
if (!list) claimedBySlice.set(slice, list = []);
|
||||||
|
list.push({
|
||||||
|
name: acc.name,
|
||||||
|
date: c.date,
|
||||||
|
stage: c.stage,
|
||||||
|
kind: completionKind(cfg, c.drain),
|
||||||
|
cycle: c.cycle,
|
||||||
|
...(c.cycleSeq > 0 ? { cycleSeq: c.cycleSeq } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (client.positions[acc.id]) {
|
if (client.positions[acc.id]) {
|
||||||
inTrade++;
|
inTrade++;
|
||||||
accountsTraded++;
|
accountsTraded++;
|
||||||
@@ -423,6 +451,22 @@ function collectFirmStats() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Claimed cycles go last in each slice's list, newest first: the
|
||||||
|
// master reads only so many entries a slice (twice its accounts), so
|
||||||
|
// what it has long had drops off before a final day, a catch-up or a
|
||||||
|
// fresh claim. One those already name goes once.
|
||||||
|
for (const [slice, claimed] of claimedBySlice) {
|
||||||
|
const list = slice.completedAccounts ?? [];
|
||||||
|
const key = (e: (typeof list)[number]) => `${e.name}\n${e.cycle}\n${e.cycleSeq ?? 0}`;
|
||||||
|
const named = new Set(list.map(key));
|
||||||
|
for (const e of claimed.sort((a, b) => b.date.localeCompare(a.date))) {
|
||||||
|
if (named.has(key(e))) continue;
|
||||||
|
named.add(key(e));
|
||||||
|
list.push(e);
|
||||||
|
}
|
||||||
|
if (list.length > 0) slice.completedAccounts = list;
|
||||||
|
}
|
||||||
|
|
||||||
// Biggest shortfall first — the account types with the most left to do
|
// Biggest shortfall first — the account types with the most left to do
|
||||||
// are the ones worth reading, and Unassigned sinks to the bottom only
|
// are the ones worth reading, and Unassigned sinks to the bottom only
|
||||||
// when it is genuinely small.
|
// when it is genuinely small.
|
||||||
|
|||||||
+72
-2
@@ -742,6 +742,13 @@ const ROLL_SETTLE_MS = 5 * 60 * 1000;
|
|||||||
* still checked for a missed completion. */
|
* still checked for a missed completion. */
|
||||||
const ENDED_CYCLE_WINDOW_DAYS = 7;
|
const ENDED_CYCLE_WINDOW_DAYS = 7;
|
||||||
|
|
||||||
|
/** Is `date` (a fund date, or a claimed cycle's — claimedCycle) within the
|
||||||
|
* week its ended cycle is still named? False for an unparseable date. */
|
||||||
|
export function withinEndedCycleWindow(date: string, rawTradeDate: string): boolean {
|
||||||
|
const ageDays = (Date.parse(`${rawTradeDate}T00:00:00Z`) - Date.parse(`${date}T00:00:00Z`)) / (24 * 60 * 60 * 1000);
|
||||||
|
return ageDays <= ENDED_CYCLE_WINDOW_DAYS; // NaN: false
|
||||||
|
}
|
||||||
|
|
||||||
/** Nothing left to trade AND the days booked: with no min-day amount the
|
/** Nothing left to trade AND the days booked: with no min-day amount the
|
||||||
* plan coasts at 0 while days are still owed, so count them too. */
|
* plan coasts at 0 while days are still owed, so count them too. */
|
||||||
function cycleOwesNothing(
|
function cycleOwesNothing(
|
||||||
@@ -924,8 +931,7 @@ export function endedCycleCompletion(opts: {
|
|||||||
const everyDay = cfg.tradesEveryDay === true;
|
const everyDay = cfg.tradesEveryDay === true;
|
||||||
const combine = effectiveCombine(cfg);
|
const combine = effectiveCombine(cfg);
|
||||||
if (lastFundDate === null || start === null) return null;
|
if (lastFundDate === null || start === null) return null;
|
||||||
const ageDays = (Date.parse(`${rawTradeDate}T00:00:00Z`) - Date.parse(`${lastFundDate}T00:00:00Z`)) / (24 * 60 * 60 * 1000);
|
if (!withinEndedCycleWindow(lastFundDate, rawTradeDate)) return null;
|
||||||
if (!(ageDays <= ENDED_CYCLE_WINDOW_DAYS)) return null; // NaN included: an unparseable date
|
|
||||||
if (sessionDoubtful(rawTradeDate, opts.cycleDailyPnL, opts.now ?? new Date(), everyDay, opts.client)) return null;
|
if (sessionDoubtful(rawTradeDate, opts.cycleDailyPnL, opts.now ?? new Date(), everyDay, opts.client)) return null;
|
||||||
|
|
||||||
const latest = (a: string, b: string) => (b > a ? b : a);
|
const latest = (a: string, b: string) => (b > a ? b : a);
|
||||||
@@ -975,6 +981,70 @@ export function endedCycleCompletion(opts: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A completed cycle a payout claim ended (claimedCycle). */
|
||||||
|
export interface ClaimedCycle { date: string; stage: number; drain: boolean; cycle: string; cycleSeq: number }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The cycle a payout claim about to go out ends (PlatformClient.claimProfit),
|
||||||
|
* when it stands complete — cycleCompletion's 'today' or 'before' on the
|
||||||
|
* client's numbers just before the claim — named and numbered as the final
|
||||||
|
* day names it (completionPlace), so the master dedupes the two. date = the
|
||||||
|
* claim's trade date. Null for a claim short of the rule (a Claim now below
|
||||||
|
* the target) or on numbers not read yet.
|
||||||
|
*
|
||||||
|
* Taken before the claim books its payout, and kept once it goes through
|
||||||
|
* (auto-trade afterClaim). The claim starts a new cycle at once, and two of
|
||||||
|
* the cycles it can end no report would name otherwise. A Vest day's second
|
||||||
|
* cycle: the auto-claim at the next tick beats the 30 s report, and its
|
||||||
|
* payout books into that day's fund row, so the latest fund date doesn't
|
||||||
|
* move for the catch-up. And one claimed right after the 20:00 ET reset,
|
||||||
|
* which puts it back on the first stage, whose target the day's leftover
|
||||||
|
* profit meets: all of its P&L sits in the day row of the cycle before it,
|
||||||
|
* so endedCycleCompletion reads none of it.
|
||||||
|
*/
|
||||||
|
export function claimedCycle(
|
||||||
|
client: {
|
||||||
|
accountCashBalances: { [accountId: number]: { amount: number; realizedPnL: number } | undefined };
|
||||||
|
dailyPnL: { [accountId: number]: { date: string; pnl: number }[] | undefined };
|
||||||
|
fundTransactions?: { [accountId: number]: { date: string; amount: number }[] | undefined };
|
||||||
|
lastFundDates?: { [accountId: number]: string | null | undefined };
|
||||||
|
priorProfit?: { [accountId: number]: number | undefined };
|
||||||
|
tradeDate?: (now: number) => string;
|
||||||
|
sameDayClaims?: (accountId: number) => number;
|
||||||
|
payoutDays?: (accountId: number) => string[] | undefined;
|
||||||
|
},
|
||||||
|
accountId: number,
|
||||||
|
cfg: {
|
||||||
|
profitTarget: number; consistency: number; minTradingDays: number; minDayPnL: number; accountSize: number;
|
||||||
|
targetSameEquity: boolean; withdrawalStages: WithdrawalStage[]; splitEnabled: boolean;
|
||||||
|
combineCmeHolidays?: boolean; tradesEveryDay?: boolean; dailyLossLimit?: number; stagesSameDay?: boolean;
|
||||||
|
},
|
||||||
|
now: Date = new Date(),
|
||||||
|
): ClaimedCycle | null {
|
||||||
|
const cash = client.accountCashBalances[accountId];
|
||||||
|
const cycleDailyPnL = client.dailyPnL[accountId];
|
||||||
|
const fundTransactions = client.fundTransactions?.[accountId];
|
||||||
|
if (!cash || !cycleDailyPnL || !fundTransactions) return null;
|
||||||
|
const effective = resolveEffectiveConfig(
|
||||||
|
cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity, cfg.withdrawalStages,
|
||||||
|
client.priorProfit?.[accountId] ?? 0, fundTransactions, cfg.minDayPnL, stageInputsFor(client, accountId, cfg),
|
||||||
|
);
|
||||||
|
const rawTradeDate = tradeDateFor(client, now);
|
||||||
|
const completion = cycleCompletion({
|
||||||
|
accountId, rawTradeDate, client, cycleDailyPnL, rawRealizedPnL: cash.realizedPnL,
|
||||||
|
combineCmeHolidays: cfg.combineCmeHolidays === true, tradesEveryDay: cfg.tradesEveryDay === true,
|
||||||
|
effective, equityProfit: cash.amount - cfg.accountSize, splitEnabled: cfg.splitEnabled, now,
|
||||||
|
});
|
||||||
|
if (completion === 'none') return null;
|
||||||
|
const cycle = client.lastFundDates?.[accountId] ?? '';
|
||||||
|
const payoutDays = client.payoutDays?.(accountId) ?? fundTransactions.filter((f) => f.amount < 0).map((f) => f.date);
|
||||||
|
const place = completionPlace({ payoutDays, fundTransactions, cycle });
|
||||||
|
return {
|
||||||
|
date: combineCmeSessions(effectiveCombine(cfg), [], rawTradeDate, 0).tradeDate,
|
||||||
|
stage: place.stage, drain: effective.drain, cycle, cycleSeq: place.cycleSeq,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compute the next trading day's profit target for an account.
|
* Compute the next trading day's profit target for an account.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1413,3 +1413,64 @@ test('endedCycleCompletion: a same-day type\'s ended cycle is judged on its day\
|
|||||||
// Any other type: judged on stage 2's $3,000, as before.
|
// Any other type: judged on stage 2's $3,000, as before.
|
||||||
assert.equal(endedCycleCompletion({ ...opts, cfg: vestFunded }), null);
|
assert.equal(endedCycleCompletion({ ...opts, cfg: vestFunded }), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
import { claimedCycle, withinEndedCycleWindow } from '../lib/trading-logic.ts';
|
||||||
|
|
||||||
|
test('claimedCycle: a claim names the complete cycle it ends, where the final day and the catch-up can\'t', () => {
|
||||||
|
const cfg = { ...vestFunded, stagesSameDay: true, dailyLossLimit: 1500 };
|
||||||
|
const activated = { date: '2026-10-20', amount: 50000 };
|
||||||
|
/** A Vest client's numbers for account 7, just before a claim. */
|
||||||
|
const client = (o) => ({
|
||||||
|
accountCashBalances: { 7: o.cash }, dailyPnL: { 7: o.cycle ?? [] }, fundTransactions: { 7: o.rows },
|
||||||
|
lastFundDates: { 7: o.last }, priorProfit: { 7: 0 },
|
||||||
|
tradeDate: () => o.day, sameDayClaims: () => o.today, payoutDays: o.payouts && (() => o.payouts),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Vest day 10-27, 6 PM ET: claim 1 took stage 1's $1,500 at 3 PM, and
|
||||||
|
// stage 2 has made its $3,000 since. The auto-claim at the next tick
|
||||||
|
// beats the 30 s report, and its payout books into 10-27's row: the
|
||||||
|
// latest fund date never moves for the catch-up.
|
||||||
|
const claimedOnce = [activated, { date: '2026-10-27', amount: -1500 }];
|
||||||
|
const second = { cash: { amount: 53000, realizedPnL: 3000 }, rows: claimedOnce, last: '2026-10-27', day: '2026-10-27', today: 1, payouts: ['2026-10-27'] };
|
||||||
|
assert.deepEqual(claimedCycle(client(second), 7, cfg, cdt('2026-10-27T17:00:00')),
|
||||||
|
{ date: '2026-10-27', stage: 2, drain: false, cycle: '2026-10-27', cycleSeq: 0 });
|
||||||
|
// A Claim now short of stage 2's $3,000 names nothing.
|
||||||
|
assert.equal(claimedCycle(client({ ...second, cash: { amount: 51000, realizedPnL: 1000 } }), 7, cfg, cdt('2026-10-27T17:00:00')), null);
|
||||||
|
// The day's third cycle, after that claim, has a key of its own.
|
||||||
|
const twice = [activated, { date: '2026-10-27', amount: -4500 }];
|
||||||
|
assert.deepEqual(claimedCycle(client({ ...second, rows: twice, today: 2, payouts: ['2026-10-27', '2026-10-27'] }), 7, cfg, cdt('2026-10-27T17:00:00')),
|
||||||
|
{ date: '2026-10-27', stage: 3, drain: false, cycle: '2026-10-27', cycleSeq: 1 });
|
||||||
|
|
||||||
|
// Stage 2 made only $1,500 of its $3,000 by the 20:00 ET reset, which
|
||||||
|
// puts the account back on stage 1's $1,500: claimed at the first tick,
|
||||||
|
// 8:00:30 PM ET.
|
||||||
|
const afterReset = { cash: { amount: 51500, realizedPnL: 0 }, rows: claimedOnce, last: '2026-10-27', day: '2026-10-28', today: 0, payouts: ['2026-10-27'] };
|
||||||
|
assert.deepEqual(claimedCycle(client(afterReset), 7, cfg, cdt('2026-10-27T19:00:30')),
|
||||||
|
{ date: '2026-10-28', stage: 2, drain: false, cycle: '2026-10-27', cycleSeq: 0 });
|
||||||
|
// The catch-up after it reads none of that cycle's P&L, all in 10-27's
|
||||||
|
// row, which the cycle before owns.
|
||||||
|
assert.equal(endedCycleCompletion({
|
||||||
|
accountId: 7, rawTradeDate: '2026-10-28', fullDailyPnL: [{ date: '2026-10-27', pnl: 3000 }], cycleDailyPnL: [],
|
||||||
|
fundTransactions: [...claimedOnce, { date: '2026-10-28', amount: -1500 }], lastFundDate: '2026-10-28',
|
||||||
|
endedCycleStart: { fundDate: '2026-10-27', includesDay: false }, rawRealizedPnL: 0, equityProfit: 0, cfg, now: cdt('2026-10-27T19:10:00'),
|
||||||
|
}), null);
|
||||||
|
|
||||||
|
// Where the catch-up does name the cycle — its P&L on a day of its own —
|
||||||
|
// the claim names it by the same key, so the master keeps one.
|
||||||
|
const crossDay = { cash: { amount: 51500, realizedPnL: 1500 }, rows: claimedBefore, last: '2026-10-25', day: '2026-10-26', today: 0, payouts: ['2026-10-25'] };
|
||||||
|
assert.deepEqual(claimedCycle(client(crossDay), 7, cfg, cdt('2026-10-26T12:00:00')),
|
||||||
|
{ date: '2026-10-26', stage: 2, drain: false, cycle: '2026-10-25', cycleSeq: 0 });
|
||||||
|
// Without a payout list, one payout per negative fund row.
|
||||||
|
assert.equal(claimedCycle(client({ ...crossDay, payouts: undefined }), 7, cfg, cdt('2026-10-26T12:00:00')).stage, 2);
|
||||||
|
// Numbers not read yet: nothing.
|
||||||
|
assert.equal(claimedCycle({ ...client(crossDay), accountCashBalances: {} }, 7, cfg, cdt('2026-10-26T12:00:00')), null);
|
||||||
|
assert.equal(claimedCycle({ ...client(crossDay), fundTransactions: {} }, 7, cfg, cdt('2026-10-26T12:00:00')), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('withinEndedCycleWindow: a week after the date, and never for one that doesn\'t parse', () => {
|
||||||
|
assert.equal(withinEndedCycleWindow('2026-10-21', '2026-10-28'), true);
|
||||||
|
assert.equal(withinEndedCycleWindow('2026-10-28', '2026-10-28'), true);
|
||||||
|
assert.equal(withinEndedCycleWindow('2026-10-20', '2026-10-28'), false);
|
||||||
|
assert.equal(withinEndedCycleWindow('', '2026-10-28'), false);
|
||||||
|
assert.equal(withinEndedCycleWindow('10/27', '2026-10-28'), false);
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
// pure helpers show: the market-close guard closing a leg whose open came back
|
// pure helpers show: the market-close guard closing a leg whose open came back
|
||||||
// unknown, and the candidate board keeping a symbol the close gate holds back;
|
// unknown, and the candidate board keeping a symbol the close gate holds back;
|
||||||
// a sync older than the bot's own fill, an open a restart cut off, an outside
|
// a sync older than the bot's own fill, an open a restart cut off, an outside
|
||||||
// trade beside an open of unknown outcome, and the client's day at 8 PM ET.
|
// trade beside an open of unknown outcome, and the client's day at 8 PM ET;
|
||||||
|
// the completed cycles a Vest day's claims end, named at each claim.
|
||||||
//
|
//
|
||||||
// npm run check
|
// npm run check
|
||||||
//
|
//
|
||||||
@@ -51,10 +52,12 @@ VestClient.prototype.request = async function (bucket, method, path, opts = {})
|
|||||||
return gateway(method, path, opts);
|
return gateway(method, path, opts);
|
||||||
};
|
};
|
||||||
const { putVestSession } = await import('../lib/vest-session.ts');
|
const { putVestSession } = await import('../lib/vest-session.ts');
|
||||||
const { botOpenedPositions, loadAccountMeta, saveAccountMeta } = await import('../lib/db.ts');
|
const {
|
||||||
|
botOpenedPositions, loadAccountMeta, saveAccountMeta, saveFundTransactions, wipeAccountData, loadClaimedCycles, saveClaimedCycles,
|
||||||
|
} = await import('../lib/db.ts');
|
||||||
const { vestDayOf, vestAccountId, VestApiError, VEST_STATUS_ACTIVE } = await import('../lib/vest-helpers.ts');
|
const { vestDayOf, vestAccountId, VestApiError, VEST_STATUS_ACTIVE } = await import('../lib/vest-helpers.ts');
|
||||||
const { encodeContractName } = await import('../lib/dxtrade-helpers.ts');
|
const { encodeContractName } = await import('../lib/dxtrade-helpers.ts');
|
||||||
const { tradeDateFor, stageInputsFor, resolveEffectiveConfig } = await import('../lib/trading-logic.ts');
|
const { tradeDateFor, stageInputsFor, resolveEffectiveConfig, claimedCycle, endedCycleCompletion } = await import('../lib/trading-logic.ts');
|
||||||
const { isClientReady } = await import('../lib/platform-client.ts');
|
const { isClientReady } = await import('../lib/platform-client.ts');
|
||||||
|
|
||||||
const iso = (s) => Date.parse(s);
|
const iso = (s) => Date.parse(s);
|
||||||
@@ -417,3 +420,102 @@ test('two payouts on one Vest day share its fund row, and still count two toward
|
|||||||
client.disconnect();
|
client.disconnect();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('each claim of a Vest day names the completed cycle it ends, the second and the one after the reset too', async () => {
|
||||||
|
// acc-1, funded $50k, Vest's own numbers: its balance, and its reset balance since 20:00 ET.
|
||||||
|
let balance = 51500;
|
||||||
|
let reset = { at: '2026-10-27T00:00:00Z', balance: 50000 };
|
||||||
|
clockAt('2026-10-27T19:00:00Z'); // 3 PM ET, Vest day 10-27
|
||||||
|
wipeAccountData(A);
|
||||||
|
const client = await syncingClient((method, path, opts) => {
|
||||||
|
const read = syncReads(method, path, []);
|
||||||
|
if (read && path === '/v3/capital/accounts/active') {
|
||||||
|
read.data.accounts[0] = { ...read.data.accounts[0], daily_reset_at: reset.at, daily_reset_balance: String(reset.balance) };
|
||||||
|
}
|
||||||
|
if (read && path === '/v3/accounts') read.data.accounts[0] = { ...read.data.accounts[0], amount: String(balance) };
|
||||||
|
if (read) return read;
|
||||||
|
if (method === 'POST' && path === '/v3/capital/withdraw' && opts.body.account_id === 'acc-1') {
|
||||||
|
balance = Math.round((balance - Number(opts.body.amount)) * 100) / 100;
|
||||||
|
return { data: { trader_amount: opts.body.amount } };
|
||||||
|
}
|
||||||
|
return unanswered(method, path);
|
||||||
|
});
|
||||||
|
// The history read a claim starts isn't this test's: what it books is set here.
|
||||||
|
client.fetchDaysTraded = async () => ({ failedAccounts: 0 });
|
||||||
|
const cfg = {
|
||||||
|
profitTarget: 1500, consistency: 0, minDayPnL: 0, minTradingDays: 0, accountSize: 50000, targetSameEquity: false,
|
||||||
|
splitEnabled: true, payoutable: true, tradesEveryDay: true, combineCmeHolidays: false, stagesSameDay: true, dailyLossLimit: 1500,
|
||||||
|
withdrawalStages: [{ profit: 3000, consistency: 0, minTradingDays: 0, dailyLossLimit: 3000 }],
|
||||||
|
};
|
||||||
|
/** The day's trading so far, as the history read books it, and Vest's WITHDRAWs for the claims made. */
|
||||||
|
const history = (dayPnl) => {
|
||||||
|
client.fullDailyPnL[A] = [{ date: '2026-10-27', pnl: dayPnl }];
|
||||||
|
client.applyCycleSlice(A);
|
||||||
|
client.withdrawals.set(A, client.claimsOf(A).map((c) => ({ at: c.at, amount: -c.amount })));
|
||||||
|
};
|
||||||
|
const named = () => claimedCycle(client, A, cfg, new Date(Date.now()));
|
||||||
|
try {
|
||||||
|
saveFundTransactions(A, 'V50K-F-D3-1', [{ date: '2026-10-20', amount: 50000 }]);
|
||||||
|
saveAccountMeta(A, 'last_fund_date', '2026-10-20');
|
||||||
|
client.fundTransactions[A] = [{ date: '2026-10-20', amount: 50000 }];
|
||||||
|
client.lastFundDates[A] = '2026-10-20';
|
||||||
|
history(1500);
|
||||||
|
await client.requestSync();
|
||||||
|
|
||||||
|
// Stage 1's $1,500, claimed: the account's first cycle.
|
||||||
|
assert.deepEqual(named(), { date: '2026-10-27', stage: 1, drain: false, cycle: '2026-10-20', cycleSeq: 0 });
|
||||||
|
await client.claimProfit(A, 1500);
|
||||||
|
await client.syncRunning; // the sync the claim starts
|
||||||
|
|
||||||
|
// 6 PM ET: stage 2's $3,000 made, and claimed the same Vest day — its
|
||||||
|
// payout books into 10-27's row, and the latest fund date stays.
|
||||||
|
clockAt('2026-10-27T22:00:00Z');
|
||||||
|
balance = 53000;
|
||||||
|
history(4500);
|
||||||
|
await client.requestSync();
|
||||||
|
assert.deepEqual(named(), { date: '2026-10-27', stage: 2, drain: false, cycle: '2026-10-27', cycleSeq: 0 });
|
||||||
|
await client.claimProfit(A, 3000);
|
||||||
|
await client.syncRunning;
|
||||||
|
assert.equal(client.lastFundDates[A], '2026-10-27');
|
||||||
|
assert.deepEqual(client.fundTransactions[A].at(-1), { date: '2026-10-27', amount: -4500 });
|
||||||
|
|
||||||
|
// 6:30 PM ET: the day's third cycle, $1,500 of stage 2's $3,000 — not complete.
|
||||||
|
clockAt('2026-10-27T22:30:00Z');
|
||||||
|
balance = 51500;
|
||||||
|
history(6000);
|
||||||
|
await client.requestSync();
|
||||||
|
assert.equal(named(), null);
|
||||||
|
|
||||||
|
// 8:00:30 PM ET: the reset puts it back on stage 1's $1,500, met —
|
||||||
|
// claimed at the first tick.
|
||||||
|
clockAt('2026-10-28T00:00:30Z');
|
||||||
|
reset = { at: '2026-10-28T00:00:00Z', balance: 51500 };
|
||||||
|
await client.requestSync();
|
||||||
|
assert.equal(tradeDateFor(client, new Date(Date.now())), '2026-10-28');
|
||||||
|
assert.deepEqual(named(), { date: '2026-10-28', stage: 3, drain: false, cycle: '2026-10-27', cycleSeq: 1 });
|
||||||
|
await client.claimProfit(A, 1500);
|
||||||
|
await client.syncRunning;
|
||||||
|
assert.equal(client.lastFundDates[A], '2026-10-28');
|
||||||
|
|
||||||
|
// The catch-up names none of it: the cycle's P&L sits in 10-27's row,
|
||||||
|
// which the cycle before owns.
|
||||||
|
clockAt('2026-10-28T00:10:00Z');
|
||||||
|
await client.requestSync();
|
||||||
|
assert.equal(endedCycleCompletion({
|
||||||
|
accountId: A, rawTradeDate: tradeDateFor(client, new Date(Date.now())), client,
|
||||||
|
fullDailyPnL: client.fullDailyPnL[A], cycleDailyPnL: client.dailyPnL[A], fundTransactions: client.fundTransactions[A],
|
||||||
|
lastFundDate: client.lastFundDates[A], endedCycleStart: JSON.parse(loadAccountMeta(A, 'ended_cycle_start')),
|
||||||
|
rawRealizedPnL: client.accountCashBalances[A].realizedPnL, equityProfit: client.accountCashBalances[A].amount - 50000, cfg,
|
||||||
|
now: new Date(Date.now()),
|
||||||
|
}), null);
|
||||||
|
|
||||||
|
// Kept as the claims leave them; an entry that doesn't read is left out.
|
||||||
|
const kept = [{ date: '2026-10-27', stage: 2, drain: false, cycle: '2026-10-27', cycleSeq: 0 }];
|
||||||
|
saveClaimedCycles(A, [...kept, { date: '2026-10-28', stage: '3', drain: false, cycle: '2026-10-27', cycleSeq: 1 }]);
|
||||||
|
assert.deepEqual(loadClaimedCycles(A), kept);
|
||||||
|
saveAccountMeta(A, 'claimed_cycles', '{');
|
||||||
|
assert.deepEqual(loadClaimedCycles(A), []);
|
||||||
|
} finally {
|
||||||
|
client.disconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user