Reporter: catch up completed cycles on reset, report each firm's account list
When a fund transaction starts a new cycle (the days revert to 0), the cycle it ended is checked against the completion rule for a week after and named if it stood complete — its final day may never have been reported. Only for a cycle this instance saw begin: the clients now note where the ended cycle started as they move last_fund_date forward. Completions carry the cycle they belong to, and each firm reports its whole account list so the master can tell erased accounts and spot new funded ones. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
acb8879575
commit
a7e01b0138
@@ -348,6 +348,31 @@ export function loadAccountMeta(accountId: number, key: string): string | null {
|
|||||||
return row?.value ?? null;
|
return row?.value ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Where the cycle the latest fund transaction ended began, saved by the
|
||||||
|
* platform client as it moves last_fund_date forward — and only from a cycle
|
||||||
|
* start this instance saw, so the reporter's catch-up never judges a cycle
|
||||||
|
* whose older rows it lacks. fundDate = that cycle's fund date ('' for the
|
||||||
|
* account's first cycle); includesDay = whether that date's own trades were
|
||||||
|
* the cycle's (a deposit's are; a Tradovate withdrawal's only when it came
|
||||||
|
* before the day's first trade, which the reporter can't work out again). */
|
||||||
|
export function saveEndedCycleStart(accountId: number, start: { fundDate: string; includesDay: boolean }): void {
|
||||||
|
saveAccountMeta(accountId, 'ended_cycle_start', JSON.stringify(start));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** null when this instance never saw the ended cycle start: a new instance
|
||||||
|
* or a wipe (which clears it with the rest of the meta), or TopstepX's
|
||||||
|
* first-check lump. */
|
||||||
|
export function loadEndedCycleStart(accountId: number): { fundDate: string; includesDay: boolean } | null {
|
||||||
|
const raw = loadAccountMeta(accountId, 'ended_cycle_start');
|
||||||
|
if (raw === null) return null;
|
||||||
|
try {
|
||||||
|
const { fundDate, includesDay } = JSON.parse(raw) as { fundDate?: unknown; includesDay?: unknown };
|
||||||
|
return typeof fundDate === 'string' && typeof includesDay === 'boolean' ? { fundDate, includesDay } : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 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
|
||||||
|
|||||||
+17
-3
@@ -33,9 +33,14 @@ export interface AccountTypeStats {
|
|||||||
* after a firm erases a passed account. date = the trade date it
|
* after a firm erases a passed account. date = the trade date it
|
||||||
* completed on; stage = 1 + withdrawals taken (its meaning depends on the
|
* completed on; stage = 1 + withdrawals taken (its meaning depends on the
|
||||||
* account type); kind: 'pass' = an eval passing, 'payout' = a funded
|
* account type); kind: 'pass' = an eval passing, 'payout' = a funded
|
||||||
* cycle ready to withdraw, 'drain' = a drain stage's days booked.
|
* cycle ready to withdraw, 'drain' = a drain stage's days booked;
|
||||||
* Optional: older instances omit it. */
|
* cycle = the date of the fund transaction that started the cycle ('' for
|
||||||
completedAccounts?: { name: string; date: string; stage: number; kind: 'pass' | 'payout' | 'drain' }[];
|
* the account's first cycle) — firm + name + cycle identify one
|
||||||
|
* completed cycle. The list also carries caught-up entries: for up to 7
|
||||||
|
* 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
|
||||||
|
* not counted in `completed`. Optional: older instances omit it. */
|
||||||
|
completedAccounts?: { name: string; date: string; stage: number; kind: 'pass' | 'payout' | 'drain'; cycle: string }[];
|
||||||
/** 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
|
||||||
@@ -89,6 +94,15 @@ export interface FirmStats {
|
|||||||
personalDll?: number;
|
personalDll?: number;
|
||||||
/** Of targetHit: accounts that finished their cycle today. Optional. */
|
/** Of targetHit: accounts that finished their cycle today. Optional. */
|
||||||
completed?: number;
|
completed?: number;
|
||||||
|
/** Every account in the firm's broker list right now, dead ones included,
|
||||||
|
* so the master can tell a saved account the firm erased from one still
|
||||||
|
* listed, and spot a newly arrived funded account. size = the account
|
||||||
|
* type's size (0 without one); payoutable = the type pays out — its
|
||||||
|
* payoutable flag or withdrawal stages, the test that reports its
|
||||||
|
* completions as 'payout' — null while the account has no type. Absent
|
||||||
|
* when the firm isn't synced (unknown, not empty). Optional: older
|
||||||
|
* instances omit it. */
|
||||||
|
accounts?: { name: string; size: number; payoutable: boolean | null }[];
|
||||||
/** Per account type, summing to the firm's own totals. Optional so a master
|
/** Per account type, summing to the firm's own totals. Optional so a master
|
||||||
* on an older build simply ignores it. */
|
* on an older build simply ignores it. */
|
||||||
accountTypes?: AccountTypeStats[];
|
accountTypes?: AccountTypeStats[];
|
||||||
|
|||||||
+65
-17
@@ -11,15 +11,15 @@
|
|||||||
* POST keeps those working unchanged.
|
* POST keeps those working unchanged.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { getSetting } from './db';
|
import { getSetting, loadEndedCycleStart } from './db';
|
||||||
import { pushReport } from './master-link';
|
import { pushReport } from './master-link';
|
||||||
import { getBuildVersion } from './version';
|
import { getBuildVersion } from './version';
|
||||||
import { getClients } from './clients';
|
import { getClients } from './clients';
|
||||||
import { getTradableFirms } from './firm-catalog';
|
import { 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 { combineCmeSessions, computeDayPlan, currentTradeDateCT, cycleCompletion, LEG_COMPLETION_EPSILON, projectPerfectPath, resolveEffectiveConfig } from './trading-logic';
|
import { combineCmeSessions, computeDayPlan, currentTradeDateCT, cycleCompletion, endedCycleCompletion, LEG_COMPLETION_EPSILON, projectPerfectPath, resolveEffectiveConfig } from './trading-logic';
|
||||||
import type { AccountTypeStats } from './master-protocol';
|
import type { AccountTypeStats, FirmStats } from './master-protocol';
|
||||||
import { historyFresh } from './platform-client';
|
import { historyFresh } from './platform-client';
|
||||||
|
|
||||||
function collectFirmStats() {
|
function collectFirmStats() {
|
||||||
@@ -31,13 +31,23 @@ function collectFirmStats() {
|
|||||||
// One raw trade date for the whole report: the combined views below roll
|
// One raw trade date for the whole report: the combined views below roll
|
||||||
// it forward, the completion check needs it as is.
|
// it forward, the completion check needs it as is.
|
||||||
const rawToday = currentTradeDateCT();
|
const rawToday = currentTradeDateCT();
|
||||||
|
// projectPerfectPath's `loops` test: a type whose completed cycle keeps
|
||||||
|
// going pays out (a funded account), one that stops just passes.
|
||||||
|
const loops = (cfg: { payoutable?: boolean; withdrawalStages: unknown[] }): boolean =>
|
||||||
|
cfg.payoutable === true || cfg.withdrawalStages.length > 0;
|
||||||
|
const completionKind = (cfg: Parameters<typeof loops>[0], drain: boolean): 'pass' | 'payout' | 'drain' =>
|
||||||
|
(drain ? 'drain' : loops(cfg) ? 'payout' : 'pass');
|
||||||
|
|
||||||
return firms.map((f) => {
|
return firms.map((f) => {
|
||||||
const client = clients.get(f.id);
|
const client = clients.get(f.id);
|
||||||
if (!client || client.accountList.length === 0) {
|
if (!client || client.accountList.length === 0) {
|
||||||
return {
|
return {
|
||||||
firm: f.name, totalAccounts: 0, accountsTraded: 0, inTrade: 0,
|
firm: f.name, totalAccounts: 0, accountsTraded: 0, inTrade: 0,
|
||||||
targetHit: 0, partial: 0, loss: 0, personalDll: 0, completed: 0, accountTypes: [],
|
targetHit: 0, partial: 0, loss: 0, personalDll: 0, completed: 0,
|
||||||
|
// Empty only once synced — before that, a firm whose accounts
|
||||||
|
// haven't arrived would read as every saved account erased.
|
||||||
|
...(client?.syncComplete ? { accounts: [] } : {}),
|
||||||
|
accountTypes: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,6 +59,10 @@ function collectFirmStats() {
|
|||||||
let lossCount = 0;
|
let lossCount = 0;
|
||||||
let personalDllCount = 0;
|
let personalDllCount = 0;
|
||||||
let completedCount = 0;
|
let completedCount = 0;
|
||||||
|
// The firm's whole broker list, dead accounts included — the master
|
||||||
|
// tells an account the firm erased from one still listed, and spots
|
||||||
|
// a newly arrived funded account.
|
||||||
|
const accounts: NonNullable<FirmStats['accounts']> = [];
|
||||||
|
|
||||||
// Per account type, so the master can say what the accounts still to
|
// Per account type, so the master can say what the accounts still to
|
||||||
// trade actually are. Keyed by account type id; unassigned accounts
|
// trade actually are. Keyed by account type id; unassigned accounts
|
||||||
@@ -78,11 +92,12 @@ function collectFirmStats() {
|
|||||||
for (const acc of client.accountList) {
|
for (const acc of client.accountList) {
|
||||||
const cash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 };
|
const cash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 };
|
||||||
const autoLiqThreshold = client.autoLiqThresholds[acc.id] ?? 0;
|
const autoLiqThreshold = client.autoLiqThresholds[acc.id] ?? 0;
|
||||||
|
const cfg = getAccountConfig(acc.id).accountType;
|
||||||
|
accounts.push({ name: acc.name, size: cfg?.accountSize ?? 0, payoutable: cfg ? loops(cfg) : null });
|
||||||
// 0 = no floor; negative floors are reconstructed TopstepX MLLs.
|
// 0 = no floor; negative floors are reconstructed TopstepX MLLs.
|
||||||
const isDead = autoLiqThreshold !== 0 && cash.amount <= autoLiqThreshold;
|
const isDead = autoLiqThreshold !== 0 && cash.amount <= autoLiqThreshold;
|
||||||
if (isDead) continue;
|
if (isDead) continue;
|
||||||
|
|
||||||
const cfg = getAccountConfig(acc.id).accountType;
|
|
||||||
const slice = bucket(cfg);
|
const slice = bucket(cfg);
|
||||||
|
|
||||||
totalAccounts++;
|
totalAccounts++;
|
||||||
@@ -205,6 +220,41 @@ function collectFirmStats() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only once the account's history has loaded — right after a
|
||||||
|
// restart the missing rows and withdrawals would read as a fresh
|
||||||
|
// stage-1 cycle.
|
||||||
|
const historyLoaded = client.dailyPnL[acc.id] !== undefined
|
||||||
|
&& client.fundTransactions?.[acc.id] !== undefined;
|
||||||
|
|
||||||
|
// Catch-up for the cycle a fund transaction just ended: if it stood
|
||||||
|
// complete, name it, since its final day may never have been
|
||||||
|
// reported (a guard held it back, or this instance was offline).
|
||||||
|
// Named only — it isn't today's outcome, so no counter moves. Ahead
|
||||||
|
// of the in-trade check: the new cycle trading doesn't hide it.
|
||||||
|
if (cfg && effective && historyLoaded && historyFresh(client, acc)) {
|
||||||
|
const ended = endedCycleCompletion({
|
||||||
|
accountId: acc.id,
|
||||||
|
rawTradeDate: rawToday,
|
||||||
|
fullDailyPnL: client.fullDailyPnL[acc.id] ?? [],
|
||||||
|
cycleDailyPnL: client.dailyPnL[acc.id],
|
||||||
|
fundTransactions: fundTxns,
|
||||||
|
lastFundDate: client.lastFundDates?.[acc.id] ?? null,
|
||||||
|
endedCycleStart: loadEndedCycleStart(acc.id),
|
||||||
|
rawRealizedPnL: cash.realizedPnL,
|
||||||
|
equityProfit: cash.amount - cfg.accountSize,
|
||||||
|
cfg,
|
||||||
|
});
|
||||||
|
if (ended) {
|
||||||
|
(slice.completedAccounts ??= []).push({
|
||||||
|
name: acc.name,
|
||||||
|
date: ended.date,
|
||||||
|
stage: ended.stage,
|
||||||
|
kind: completionKind(cfg, ended.drain),
|
||||||
|
cycle: ended.cycle,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (client.positions[acc.id]) {
|
if (client.positions[acc.id]) {
|
||||||
inTrade++;
|
inTrade++;
|
||||||
accountsTraded++;
|
accountsTraded++;
|
||||||
@@ -215,12 +265,8 @@ function collectFirmStats() {
|
|||||||
|
|
||||||
// Did today finish the cycle? Checked ahead of the lockout branch:
|
// Did today finish the cycle? Checked ahead of the lockout branch:
|
||||||
// firms lock a passed account (Tradovate liquidate-only, TopstepX
|
// firms lock a passed account (Tradovate liquidate-only, TopstepX
|
||||||
// canTrade=false), and a pass is no loss. Only once the account's
|
// canTrade=false), and a pass is no loss. A 'today' only on
|
||||||
// history has loaded — right after a restart the missing rows and
|
// freshly fetched rows, since the master keeps it.
|
||||||
// withdrawals would read as a fresh stage-1 cycle — and a 'today'
|
|
||||||
// only on freshly fetched rows, since the master keeps it.
|
|
||||||
const historyLoaded = client.dailyPnL[acc.id] !== undefined
|
|
||||||
&& client.fundTransactions?.[acc.id] !== undefined;
|
|
||||||
if (cfg && combined && effective && historyLoaded) {
|
if (cfg && combined && effective && historyLoaded) {
|
||||||
const completion = cycleCompletion({
|
const completion = cycleCompletion({
|
||||||
accountId: acc.id,
|
accountId: acc.id,
|
||||||
@@ -243,15 +289,14 @@ function collectFirmStats() {
|
|||||||
dayEntry.targetHit++;
|
dayEntry.targetHit++;
|
||||||
dayEntry.completed = (dayEntry.completed ?? 0) + 1;
|
dayEntry.completed = (dayEntry.completed ?? 0) + 1;
|
||||||
}
|
}
|
||||||
// Same test as projectPerfectPath's `loops`: a cycle that
|
|
||||||
// keeps going pays out, one that stops just passes.
|
|
||||||
const kind = effective.drain ? 'drain'
|
|
||||||
: (cfg.payoutable === true || cfg.withdrawalStages.length > 0) ? 'payout' : 'pass';
|
|
||||||
(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: 1 + fundTxns.filter((f) => f.amount < 0).length,
|
||||||
kind,
|
kind: completionKind(cfg, effective.drain),
|
||||||
|
// The fund transaction that started this cycle — the
|
||||||
|
// catch-up names the same cycle by the same key.
|
||||||
|
cycle: client.lastFundDates?.[acc.id] ?? '',
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -322,7 +367,10 @@ function collectFirmStats() {
|
|||||||
return {
|
return {
|
||||||
firm: f.name, totalAccounts, accountsTraded, inTrade,
|
firm: f.name, totalAccounts, accountsTraded, inTrade,
|
||||||
targetHit: targetHitCount, partial: partialCount, loss: lossCount,
|
targetHit: targetHitCount, partial: partialCount, loss: lossCount,
|
||||||
personalDll: personalDllCount, completed: completedCount, accountTypes,
|
personalDll: personalDllCount, completed: completedCount,
|
||||||
|
// Unknown until the firm has synced, not empty.
|
||||||
|
...(client.syncComplete ? { accounts } : {}),
|
||||||
|
accountTypes,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-1
@@ -25,7 +25,7 @@ import * as signalR from '@microsoft/signalr';
|
|||||||
import { currentTradeDateCT } from './trading-logic';
|
import { currentTradeDateCT } from './trading-logic';
|
||||||
import { getCachedContract } from './contract-resolver';
|
import { getCachedContract } from './contract-resolver';
|
||||||
import { signalrWebSocket } from './outbound-proxy';
|
import { signalrWebSocket } from './outbound-proxy';
|
||||||
import { saveDailyPnL, loadDailyPnL, loadAccountMeta, saveAccountMeta, loadFundTransactions, saveFundTransactions } from './db';
|
import { saveDailyPnL, loadDailyPnL, loadAccountMeta, saveAccountMeta, loadFundTransactions, saveFundTransactions, saveEndedCycleStart } from './db';
|
||||||
import { getAccountConfig } from './account-type-resolver';
|
import { getAccountConfig } from './account-type-resolver';
|
||||||
import type { PlatformClient, PlatformAccount, PlatformContract } from './platform-client';
|
import type { PlatformClient, PlatformAccount, PlatformContract } from './platform-client';
|
||||||
|
|
||||||
@@ -563,9 +563,22 @@ export class TopstepXClient implements PlatformClient {
|
|||||||
const txns = this.fundTransactions[account.id] ?? [];
|
const txns = this.fundTransactions[account.id] ?? [];
|
||||||
const funds = txns.reduce((s, f) => s + f.amount, 0);
|
const funds = txns.reduce((s, f) => s + f.amount, 0);
|
||||||
const delta = Math.round((cash.amount - startBal - tradePnL - funds) * 100) / 100;
|
const delta = Math.round((cash.amount - startBal - tradePnL - funds) * 100) / 100;
|
||||||
|
// The first check on this instance (or since a wipe) only has the
|
||||||
|
// fetch window to explain the balance with: a lump it books is unseen
|
||||||
|
// P&L and past payouts, not the end of a cycle this instance watched.
|
||||||
|
const firstCheck = loadAccountMeta(account.id, 'funds_reconciled') === null;
|
||||||
|
if (firstCheck) saveAccountMeta(account.id, 'funds_reconciled', today);
|
||||||
if (Math.abs(delta) < 200) return;
|
if (Math.abs(delta) < 200) return;
|
||||||
|
|
||||||
console.log(`[topstepx] ${account.name}: balance ${cash.amount} vs books ${Math.round((tradePnL + funds) * 100) / 100} — recording ${delta < 0 ? 'withdrawal' : 'deposit'} of ${delta}`);
|
console.log(`[topstepx] ${account.name}: balance ${cash.amount} vs books ${Math.round((tradePnL + funds) * 100) / 100} — recording ${delta < 0 ? 'withdrawal' : 'deposit'} of ${delta}`);
|
||||||
|
// A new cycle starts today: note where the one it ends began, by
|
||||||
|
// applyCycleSlice's rule for that date's own row, for the reporter's
|
||||||
|
// catch-up. A second event today doesn't start another cycle.
|
||||||
|
const prevFundDate = this.lastFundDates[account.id] ?? null;
|
||||||
|
if (!firstCheck && prevFundDate !== today) {
|
||||||
|
const prevAmt = prevFundDate ? (txns.find((f) => f.date === prevFundDate)?.amount ?? null) : null;
|
||||||
|
saveEndedCycleStart(account.id, { fundDate: prevFundDate ?? '', includesDay: !(prevAmt !== null && prevAmt < 0) });
|
||||||
|
}
|
||||||
// Same-day events merge into one row (the table keys on account+date),
|
// Same-day events merge into one row (the table keys on account+date),
|
||||||
// so add today's delta onto whatever is already booked for today.
|
// so add today's delta onto whatever is already booked for today.
|
||||||
const existingToday = txns.find((f) => f.date === today)?.amount ?? 0;
|
const existingToday = txns.find((f) => f.date === today)?.amount ?? 0;
|
||||||
|
|||||||
+144
-26
@@ -637,6 +637,48 @@ export type CycleCompletion = 'none' | 'today' | 'before';
|
|||||||
* number — already booked under its own date — would count as a second day. */
|
* number — already booked under its own date — would count as a second day. */
|
||||||
const ROLL_SETTLE_MS = 5 * 60 * 1000;
|
const ROLL_SETTLE_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
/** How long after a fund transaction starts a new cycle the cycle it ended is
|
||||||
|
* still checked for a missed completion. */
|
||||||
|
const ENDED_CYCLE_WINDOW_DAYS = 7;
|
||||||
|
|
||||||
|
/** 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. */
|
||||||
|
function cycleOwesNothing(
|
||||||
|
accountId: number,
|
||||||
|
eff: EffectiveConfig,
|
||||||
|
rows: { date: string; pnl: number }[],
|
||||||
|
date: string,
|
||||||
|
equityProfit: number,
|
||||||
|
realized: number,
|
||||||
|
splitEnabled: boolean,
|
||||||
|
): boolean {
|
||||||
|
const plan = computeDayPlan(
|
||||||
|
accountId, date, eff.profitTarget, eff.consistency, rows, eff.minDayPnL,
|
||||||
|
eff.minTradingDays, equityProfit, realized, splitEnabled, eff.drain,
|
||||||
|
);
|
||||||
|
const qualifying = rows.filter((d) => d.date !== date
|
||||||
|
&& (eff.minDayPnL > 0 ? d.pnl >= eff.minDayPnL : d.pnl !== 0)).length;
|
||||||
|
return plan !== null && plan.dayTarget <= 0 && qualifying >= eff.minTradingDays;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Is the live realizedPnL too doubtful to count as today's session? A
|
||||||
|
* weekend (it is the last session's stale number, already booked under its
|
||||||
|
* own date), a Monday with a weekend-dated row — the live session filed under
|
||||||
|
* a weekend date, which would count it twice — and the first minutes after
|
||||||
|
* the 5 PM roll. */
|
||||||
|
function sessionDoubtful(rawTradeDate: string, cycleDailyPnL: { date: string; pnl: number }[], now: Date): boolean {
|
||||||
|
const day = new Date(`${rawTradeDate}T00:00:00Z`);
|
||||||
|
const dow = day.getUTCDay();
|
||||||
|
if (dow === 0 || dow === 6) return true;
|
||||||
|
if (dow === 1) {
|
||||||
|
const sunday = fmtUTC(new Date(day.getTime() - 24 * 60 * 60 * 1000));
|
||||||
|
const saturday = fmtUTC(new Date(day.getTime() - 2 * 24 * 60 * 60 * 1000));
|
||||||
|
if (cycleDailyPnL.some((d) => d.date === saturday || d.date === sunday)) return true;
|
||||||
|
}
|
||||||
|
// Settle window: a few minutes ago it was still an earlier trade date.
|
||||||
|
return currentTradeDateCT(new Date(now.getTime() - ROLL_SETTLE_MS)) < rawTradeDate;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Did today's trading finish the account's current cycle — profit target,
|
* Did today's trading finish the account's current cycle — profit target,
|
||||||
* consistency and minimum days all met? projectPerfectPath's "already
|
* consistency and minimum days all met? projectPerfectPath's "already
|
||||||
@@ -666,40 +708,116 @@ export function cycleCompletion(opts: {
|
|||||||
/** Defaults to the current time. */
|
/** Defaults to the current time. */
|
||||||
now?: Date;
|
now?: Date;
|
||||||
}): CycleCompletion {
|
}): CycleCompletion {
|
||||||
const { accountId, rawTradeDate, effective: eff } = opts;
|
const { accountId, rawTradeDate, effective: eff, equityProfit, splitEnabled } = opts;
|
||||||
const view = combineCmeSessions(opts.combineCmeHolidays, opts.cycleDailyPnL, rawTradeDate, opts.rawRealizedPnL);
|
const view = combineCmeSessions(opts.combineCmeHolidays, opts.cycleDailyPnL, rawTradeDate, opts.rawRealizedPnL);
|
||||||
const prior = view.dailyPnL.filter((d) => d.date !== view.tradeDate);
|
const prior = view.dailyPnL.filter((d) => d.date !== view.tradeDate);
|
||||||
// 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.
|
|
||||||
const owesNothing = (rows: { date: string; pnl: number }[], date: string, realized: number): boolean => {
|
|
||||||
const plan = computeDayPlan(
|
|
||||||
accountId, date, eff.profitTarget, eff.consistency, rows, eff.minDayPnL,
|
|
||||||
eff.minTradingDays, opts.equityProfit, realized, opts.splitEnabled, eff.drain,
|
|
||||||
);
|
|
||||||
const qualifying = rows.filter((d) => d.date !== date
|
|
||||||
&& (eff.minDayPnL > 0 ? d.pnl >= eff.minDayPnL : d.pnl !== 0)).length;
|
|
||||||
return plan !== null && plan.dayTarget <= 0 && qualifying >= eff.minTradingDays;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (owesNothing(prior, view.tradeDate, view.realizedPnL)) return 'before';
|
if (cycleOwesNothing(accountId, eff, prior, view.tradeDate, equityProfit, view.realizedPnL, splitEnabled)) return 'before';
|
||||||
|
if (sessionDoubtful(rawTradeDate, opts.cycleDailyPnL, opts.now ?? new Date())) return 'none';
|
||||||
const day = new Date(`${rawTradeDate}T00:00:00Z`);
|
|
||||||
const dow = day.getUTCDay();
|
|
||||||
if (dow === 0 || dow === 6) return 'none';
|
|
||||||
if (dow === 1) {
|
|
||||||
const sunday = fmtUTC(new Date(day.getTime() - 24 * 60 * 60 * 1000));
|
|
||||||
const saturday = fmtUTC(new Date(day.getTime() - 2 * 24 * 60 * 60 * 1000));
|
|
||||||
if (opts.cycleDailyPnL.some((d) => d.date === saturday || d.date === sunday)) return 'none';
|
|
||||||
}
|
|
||||||
// Settle window: a few minutes ago it was still an earlier trade date.
|
|
||||||
const now = opts.now ?? new Date();
|
|
||||||
if (currentTradeDateCT(new Date(now.getTime() - ROLL_SETTLE_MS)) < rawTradeDate) return 'none';
|
|
||||||
|
|
||||||
const withToday = [...prior, { date: view.tradeDate, pnl: view.realizedPnL }];
|
const withToday = [...prior, { date: view.tradeDate, pnl: view.realizedPnL }];
|
||||||
if (view.realizedPnL !== 0 && owesNothing(withToday, nextTradingDate(view.tradeDate), 0)) return 'today';
|
const probe = nextTradingDate(view.tradeDate);
|
||||||
|
if (view.realizedPnL !== 0 && cycleOwesNothing(accountId, eff, withToday, probe, equityProfit, 0, splitEnabled)) return 'today';
|
||||||
return 'none';
|
return 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* met? cycleCompletion reads 'today' on the final day only, so a completion
|
||||||
|
* its guards held back (or that the instance was offline for) would vanish
|
||||||
|
* once the payout, erasure or reset starts a new cycle and the days revert
|
||||||
|
* to 0. Checked for a week after that fund transaction.
|
||||||
|
*
|
||||||
|
* The ended cycle is judged on its own rows — from the previous fund date
|
||||||
|
* (its own row too when the client counted it in, as it does for a deposit),
|
||||||
|
* up to the latest one, less any the current cycle holds — against the
|
||||||
|
* equity it ended on: live equity less the current cycle's P&L and the fund
|
||||||
|
* transaction itself. A failed account's reset (a positive adjustment after
|
||||||
|
* a cycle that fell short) correctly reads null: the ended cycle never met
|
||||||
|
* the rule. Null on a doubtful session too — on a weekend the live
|
||||||
|
* realizedPnL is stale and would come off equity twice — and when this
|
||||||
|
* instance never saw the ended cycle start (a new instance or a wipe, whose
|
||||||
|
* fetch window cuts it short; TopstepX's first-check lump, which isn't a
|
||||||
|
* cycle at all).
|
||||||
|
*
|
||||||
|
* `cycle` is the fund date that STARTED the ended cycle ('' for the
|
||||||
|
* account's first), which is what client.lastFundDates held while it was
|
||||||
|
* current — the same key the final-day path reported for this cycle, so the
|
||||||
|
* master dedupes the two.
|
||||||
|
*/
|
||||||
|
export function endedCycleCompletion(opts: {
|
||||||
|
accountId: number;
|
||||||
|
/** currentTradeDateCT(). */
|
||||||
|
rawTradeDate: string;
|
||||||
|
/** Every cycle's daily P&L, raw (client.fullDailyPnL). */
|
||||||
|
fullDailyPnL: { date: string; pnl: number }[];
|
||||||
|
/** Current-cycle daily P&L, raw (client.dailyPnL). */
|
||||||
|
cycleDailyPnL: { date: string; pnl: number }[];
|
||||||
|
fundTransactions: { date: string; amount: number }[];
|
||||||
|
/** client.lastFundDates — the fund transaction that started the current cycle. */
|
||||||
|
lastFundDate: string | null;
|
||||||
|
/** loadEndedCycleStart() — where the client saw the ended cycle begin,
|
||||||
|
* null when it never did. */
|
||||||
|
endedCycleStart: { fundDate: string; includesDay: boolean } | null;
|
||||||
|
rawRealizedPnL: number;
|
||||||
|
/** amount − accountSize — today's P&L included. */
|
||||||
|
equityProfit: number;
|
||||||
|
cfg: {
|
||||||
|
profitTarget: number; consistency: number; minTradingDays: number; minDayPnL: number;
|
||||||
|
targetSameEquity: boolean; withdrawalStages: WithdrawalStage[];
|
||||||
|
combineCmeHolidays: boolean; splitEnabled: boolean;
|
||||||
|
};
|
||||||
|
/** Defaults to the current time. */
|
||||||
|
now?: Date;
|
||||||
|
}): { date: string; stage: number; drain: boolean; cycle: string } | null {
|
||||||
|
const { accountId, rawTradeDate, lastFundDate, endedCycleStart: start, fundTransactions, cfg } = opts;
|
||||||
|
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 (!(ageDays <= ENDED_CYCLE_WINDOW_DAYS)) return null; // NaN included: an unparseable date
|
||||||
|
if (sessionDoubtful(rawTradeDate, opts.cycleDailyPnL, opts.now ?? new Date())) return null;
|
||||||
|
|
||||||
|
const latest = (a: string, b: string) => (b > a ? b : a);
|
||||||
|
const earlierFunds = fundTransactions.filter((f) => f.date < lastFundDate);
|
||||||
|
const prevFundDate = earlierFunds.length > 0 ? earlierFunds.map((f) => f.date).reduce(latest) : null;
|
||||||
|
// Saved for another transition — two fund dates since the client last
|
||||||
|
// looked — so the start it saw isn't this cycle's.
|
||||||
|
if (start.fundDate !== (prevFundDate ?? '')) return null;
|
||||||
|
const current = new Set(opts.cycleDailyPnL.map((d) => d.date));
|
||||||
|
const endedRaw = opts.fullDailyPnL.filter((d) => d.date <= lastFundDate
|
||||||
|
&& (prevFundDate === null || d.date > prevFundDate || (start.includesDay && d.date === prevFundDate))
|
||||||
|
&& !current.has(d.date));
|
||||||
|
if (!endedRaw.some((d) => d.pnl !== 0)) return null;
|
||||||
|
|
||||||
|
// Equity the ended cycle finished on, worked back from live equity: less
|
||||||
|
// everything the current cycle booked (today's live P&L included) and
|
||||||
|
// less the fund transaction between the two.
|
||||||
|
const cur = combineCmeSessions(cfg.combineCmeHolidays, opts.cycleDailyPnL, rawTradeDate, opts.rawRealizedPnL);
|
||||||
|
const curTotal = cur.dailyPnL.filter((d) => d.date !== cur.tradeDate).reduce((s, d) => s + d.pnl, 0)
|
||||||
|
+ cur.realizedPnL;
|
||||||
|
const fundAmt = fundTransactions.filter((f) => f.date === lastFundDate).reduce((s, f) => s + f.amount, 0);
|
||||||
|
const equityBefore = round2(opts.equityProfit - curTotal - fundAmt);
|
||||||
|
|
||||||
|
// The stage the ended cycle traded on: only the withdrawals before it.
|
||||||
|
const eff = resolveEffectiveConfig(
|
||||||
|
cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity,
|
||||||
|
cfg.withdrawalStages, 0, earlierFunds, cfg.minDayPnL,
|
||||||
|
);
|
||||||
|
const rows = combineCmeSessions(cfg.combineCmeHolidays, endedRaw, lastFundDate, 0).dailyPnL;
|
||||||
|
// A combining firm's folded day can net to 0 — nothing traded, as the firm counts it.
|
||||||
|
const traded = rows.filter((d) => d.pnl !== 0);
|
||||||
|
if (traded.length === 0) return null;
|
||||||
|
const probe = nextTradingDate(rows.map((d) => d.date).reduce(latest));
|
||||||
|
if (!cycleOwesNothing(accountId, eff, rows, probe, equityBefore, 0, cfg.splitEnabled)) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
date: traded.map((d) => d.date).reduce(latest),
|
||||||
|
stage: 1 + earlierFunds.filter((f) => f.amount < 0).length,
|
||||||
|
drain: eff.drain,
|
||||||
|
cycle: prevFundDate ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** Dollar-per-point value for common futures products. */
|
/** Dollar-per-point value for common futures products. */
|
||||||
export const POINT_VALUES: { [symbol: string]: number } = {
|
export const POINT_VALUES: { [symbol: string]: number } = {
|
||||||
NQ: 20, MNQ: 2, ES: 50, MES: 5,
|
NQ: 20, MNQ: 2, ES: 50, MES: 5,
|
||||||
|
|||||||
+14
-2
@@ -5,7 +5,7 @@ import type { AccountItem, AuthLoginResponse, Contract } from './tradovate-helpe
|
|||||||
import { computeSec, randomUUIDV4 } from './tradovate-helpers';
|
import { computeSec, randomUUIDV4 } from './tradovate-helpers';
|
||||||
import { POINT_VALUES } from './trading-logic';
|
import { POINT_VALUES } from './trading-logic';
|
||||||
import { getCachedContract, resolveContracts } from './contract-resolver';
|
import { getCachedContract, resolveContracts } from './contract-resolver';
|
||||||
import { saveDailyPnL, loadDailyPnL, saveAccountMeta, loadAccountMeta, saveFundTransactions, loadFundTransactions } from './db';
|
import { saveDailyPnL, loadDailyPnL, saveAccountMeta, loadAccountMeta, saveFundTransactions, loadFundTransactions, saveEndedCycleStart } from './db';
|
||||||
import { brokerWebSocket } from './outbound-proxy';
|
import { brokerWebSocket } from './outbound-proxy';
|
||||||
import { FILLS_FALLBACK_NOTE } from './platform-client';
|
import { FILLS_FALLBACK_NOTE } from './platform-client';
|
||||||
|
|
||||||
@@ -267,7 +267,6 @@ export class TradovateClient {
|
|||||||
console.error('[requestSync] Received null/undefined response — auth may have failed');
|
console.error('[requestSync] Received null/undefined response — auth may have failed');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// liquidateOnly flag lives in accountRiskStatuses
|
// liquidateOnly flag lives in accountRiskStatuses
|
||||||
const riskStatusById: { [accountId: number]: { liquidateOnly?: string } } = (
|
const riskStatusById: { [accountId: number]: { liquidateOnly?: string } } = (
|
||||||
response.accountRiskStatuses || []
|
response.accountRiskStatuses || []
|
||||||
@@ -595,6 +594,19 @@ export class TradovateClient {
|
|||||||
// Use the most recent fund transaction as the reset point — persist it so it survives beyond the 28-day window
|
// Use the most recent fund transaction as the reset point — persist it so it survives beyond the 28-day window
|
||||||
const lastFundDate = [...Object.keys(fundMap)].sort().pop() ?? null;
|
const lastFundDate = [...Object.keys(fundMap)].sort().pop() ?? null;
|
||||||
if (lastFundDate) {
|
if (lastFundDate) {
|
||||||
|
// A new cycle starts: note where the one it ends began —
|
||||||
|
// with filterActivePnL's call on that date's own row — for
|
||||||
|
// the reporter's catch-up. Only from a stored start: the
|
||||||
|
// first load after a wipe or on a new instance never saw
|
||||||
|
// the cycle before it begin.
|
||||||
|
if (storedFundDate && lastFundDate > storedFundDate) {
|
||||||
|
const prevAmt = this.fundTransactions[account.id]?.find((f) => f.date === storedFundDate)?.amount ?? null;
|
||||||
|
saveEndedCycleStart(account.id, {
|
||||||
|
fundDate: storedFundDate,
|
||||||
|
includesDay: !(prevAmt !== null && prevAmt < 0) || TradovateClient.isWithdrawalBeforeFirstTrade(
|
||||||
|
fundTimestampMap[storedFundDate] ?? storedFundTimestamp, firstTradeTsMap[storedFundDate] ?? null),
|
||||||
|
});
|
||||||
|
}
|
||||||
saveAccountMeta(account.id, 'last_fund_date', lastFundDate);
|
saveAccountMeta(account.id, 'last_fund_date', lastFundDate);
|
||||||
this.lastFundDates[account.id] = lastFundDate;
|
this.lastFundDates[account.id] = lastFundDate;
|
||||||
if (fundTimestampMap[lastFundDate]) {
|
if (fundTimestampMap[lastFundDate]) {
|
||||||
|
|||||||
Reference in New Issue
Block a user