Merge branch 'vest-fix-display' into vest-fixes
# Conflicts: # package.json # scripts/check-trading-logic.mjs
This commit is contained in:
+49
-29
@@ -282,6 +282,7 @@ function ObjRow({ passed, label, value }: { passed: boolean; label: ReactNode; v
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
function EquityTooltip({ active, payload, label }: any) {
|
function EquityTooltip({ active, payload, label }: any) {
|
||||||
if (!active || !payload?.length) return null;
|
if (!active || !payload?.length) return null;
|
||||||
// Always read from the raw data point so fill-only series don't interfere
|
// Always read from the raw data point so fill-only series don't interfere
|
||||||
@@ -487,9 +488,11 @@ export default function AccountPage() {
|
|||||||
}, [accountId]);
|
}, [accountId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void load();
|
// The first load from a timer too, like the polls: not setState
|
||||||
|
// from the effect's own body.
|
||||||
|
const first = setTimeout(() => { void load(); }, 0);
|
||||||
const interval = setInterval(() => { void load(); }, 5000);
|
const interval = setInterval(() => { void load(); }, 5000);
|
||||||
return () => clearInterval(interval);
|
return () => { clearTimeout(first); clearInterval(interval); };
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
if (!account) {
|
if (!account) {
|
||||||
@@ -513,9 +516,12 @@ export default function AccountPage() {
|
|||||||
|
|
||||||
// The firm's hard daily loss limit, and the least a day must make to count
|
// The firm's hard daily loss limit, and the least a day must make to count
|
||||||
// as a trading day — two different numbers; neither is set on every type.
|
// as a trading day — two different numbers; neither is set on every type.
|
||||||
const dailyLossLimit = (cfg?.dailyLossLimit ?? 0) > 0 ? cfg!.dailyLossLimit! : null;
|
// The limit and the min days are the current stage's, which may set its own.
|
||||||
|
const stageLossLimit = account.effectiveDailyLossLimit ?? cfg?.dailyLossLimit ?? 0;
|
||||||
|
const dailyLossLimit = stageLossLimit > 0 ? stageLossLimit : null;
|
||||||
const minDayPnL = (cfg?.minDayPnL ?? 0) > 0 ? cfg!.minDayPnL : null;
|
const minDayPnL = (cfg?.minDayPnL ?? 0) > 0 ? cfg!.minDayPnL : null;
|
||||||
const daysPassed = cfg != null && account.daysTraded >= cfg.minTradingDays;
|
const minTradingDays = account.effectiveMinTradingDays ?? cfg?.minTradingDays ?? 0;
|
||||||
|
const daysPassed = cfg != null && account.daysTraded >= minTradingDays;
|
||||||
// Dead when balance hits the liquidation floor — Tradovate reports its
|
// Dead when balance hits the liquidation floor — Tradovate reports its
|
||||||
// (positive) floor; TopstepX floors are reconstructed MLLs, negative or
|
// (positive) floor; TopstepX floors are reconstructed MLLs, negative or
|
||||||
// +$0.01 once locked at breakeven. 0 = no floor known.
|
// +$0.01 once locked at breakeven. 0 = no floor known.
|
||||||
@@ -552,13 +558,19 @@ export default function AccountPage() {
|
|||||||
}
|
}
|
||||||
const sortedEvents = [...allDates].sort().map((d) => eventsByDate.get(d)!);
|
const sortedEvents = [...allDates].sort().map((d) => eventsByDate.get(d)!);
|
||||||
|
|
||||||
|
// Running equity after each event, summed here rather than inside the
|
||||||
|
// map below: a render callback mustn't reassign an outer variable.
|
||||||
|
const runningAfter: number[] = [];
|
||||||
let running = 0;
|
let running = 0;
|
||||||
const equityData = [
|
for (const ev of sortedEvents) {
|
||||||
{ label: '', equity: 0, pnl: 0, origin: true, pos: 0, neg: 0, isWithdrawal: false },
|
|
||||||
...sortedEvents.map((ev) => {
|
|
||||||
if (ev.fundAmt !== undefined) running += ev.fundAmt; // withdrawals reduce equity
|
if (ev.fundAmt !== undefined) running += ev.fundAmt; // withdrawals reduce equity
|
||||||
if (ev.pnl !== undefined) running += ev.pnl;
|
if (ev.pnl !== undefined) running += ev.pnl;
|
||||||
const equity = Math.round(running * 100) / 100;
|
runningAfter.push(running);
|
||||||
|
}
|
||||||
|
const equityData = [
|
||||||
|
{ label: '', equity: 0, pnl: 0, origin: true, pos: 0, neg: 0, isWithdrawal: false },
|
||||||
|
...sortedEvents.map((ev, i) => {
|
||||||
|
const equity = Math.round(runningAfter[i] * 100) / 100;
|
||||||
return {
|
return {
|
||||||
label: fmtDate(ev.date),
|
label: fmtDate(ev.date),
|
||||||
equity,
|
equity,
|
||||||
@@ -597,7 +609,7 @@ export default function AccountPage() {
|
|||||||
// books exactly its plan, cycles complete, and the configured withdrawal
|
// books exactly its plan, cycles complete, and the configured withdrawal
|
||||||
// fires the day a cycle finishes. Same planner the trade path runs, so the
|
// fires the day a cycle finishes. Same planner the trade path runs, so the
|
||||||
// numbers (bumps and drain amounts included) are what would actually be
|
// numbers (bumps and drain amounts included) are what would actually be
|
||||||
// aimed for. Shown up to TWO payouts ahead.
|
// aimed for. Shown up to TWO payout days ahead.
|
||||||
const rawProjection = (cfg && !isDead) ? projectPerfectPath({
|
const rawProjection = (cfg && !isDead) ? projectPerfectPath({
|
||||||
accountId: account.id,
|
accountId: account.id,
|
||||||
cfg,
|
cfg,
|
||||||
@@ -605,7 +617,10 @@ export default function AccountPage() {
|
|||||||
fundTransactions,
|
fundTransactions,
|
||||||
balance: account.amount,
|
balance: account.amount,
|
||||||
realizedPnL: account.realizedPnL,
|
realizedPnL: account.realizedPnL,
|
||||||
maxWithdrawals: 2,
|
// A type whose stages reset each day: its state stage counts today's payouts.
|
||||||
|
stageInputs: { stagesSameDay: cfg.stagesSameDay === true, sameDayWithdrawals: account.stage - 1 },
|
||||||
|
// Such a type pays out once per stage on each of those days.
|
||||||
|
maxWithdrawals: cfg.stagesSameDay === true ? 2 * (1 + cfg.withdrawalStages.length) : 2,
|
||||||
payout: {
|
payout: {
|
||||||
withdrawalPct: cfg.withdrawalPct,
|
withdrawalPct: cfg.withdrawalPct,
|
||||||
payoutCap: cfg.payoutCap,
|
payoutCap: cfg.payoutCap,
|
||||||
@@ -621,23 +636,21 @@ export default function AccountPage() {
|
|||||||
: undefined,
|
: undefined,
|
||||||
},
|
},
|
||||||
}) : [];
|
}) : [];
|
||||||
// Cut the projection at the second payout — days beyond it are noise here.
|
// Cut the projection after the second payout day — days beyond it are
|
||||||
const secondPayoutIdx = (() => {
|
// noise here. The path's dates never go back, and a payoutReady entry is
|
||||||
let seen = 0;
|
// always its last.
|
||||||
for (let i = 0; i < rawProjection.length; i++) {
|
const payoutDays = [...new Set(rawProjection.filter((p) => p.withdrawal !== undefined || p.payoutReady).map((p) => p.date))];
|
||||||
const p = rawProjection[i];
|
const projection = payoutDays.length >= 2 ? rawProjection.filter((p) => p.date <= payoutDays[1]) : rawProjection;
|
||||||
if (p.withdrawal !== undefined || p.payoutReady) {
|
|
||||||
seen++;
|
|
||||||
if (seen >= 2 || p.payoutReady) return i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return -1;
|
|
||||||
})();
|
|
||||||
const projection = secondPayoutIdx >= 0 ? rawProjection.slice(0, secondPayoutIdx + 1) : rawProjection;
|
|
||||||
// A payout day is the cycle's FINAL trading day — it can carry both the
|
// A payout day is the cycle's FINAL trading day — it can carry both the
|
||||||
// day's booked P&L and the withdrawal.
|
// day's booked P&L and the withdrawal. A same-day type's day can hold
|
||||||
const projPnlMap = new Map(projection.filter((p) => p.pnl !== 0).map((p) => [p.date, p.pnl]));
|
// several cycles: their P&L and withdrawals add up, and the day shows
|
||||||
const projFundMap = new Map(projection.filter((p) => p.withdrawal !== undefined).map((p) => [p.date, p.withdrawal!]));
|
// the last stage it traded.
|
||||||
|
const projPnlMap = new Map<string, number>();
|
||||||
|
const projFundMap = new Map<string, number>();
|
||||||
|
for (const p of projection) {
|
||||||
|
if (p.pnl !== 0) projPnlMap.set(p.date, Math.round(((projPnlMap.get(p.date) ?? 0) + p.pnl) * 100) / 100);
|
||||||
|
if (p.withdrawal !== undefined) projFundMap.set(p.date, Math.round(((projFundMap.get(p.date) ?? 0) + p.withdrawal) * 100) / 100);
|
||||||
|
}
|
||||||
const projStageMap = new Map(projection.map((p) => [p.date, p.stage]));
|
const projStageMap = new Map(projection.map((p) => [p.date, p.stage]));
|
||||||
/** Cumulative probability of the perfect path surviving through each projected day. */
|
/** Cumulative probability of the perfect path surviving through each projected day. */
|
||||||
const projProbMap = new Map(projection.map((p) => [p.date, p.prob]));
|
const projProbMap = new Map(projection.map((p) => [p.date, p.prob]));
|
||||||
@@ -652,14 +665,20 @@ export default function AccountPage() {
|
|||||||
];
|
];
|
||||||
const calendarMonths = [...new Set(allCalendarDates.map((d) => d.slice(0, 7)))].sort();
|
const calendarMonths = [...new Set(allCalendarDates.map((d) => d.slice(0, 7)))].sort();
|
||||||
|
|
||||||
// Build stage map: stage increments after each withdrawal (negative fund txn)
|
// Build stage map: stage increments after each withdrawal (negative fund txn).
|
||||||
|
// A type whose stages reset each day starts every day on stage 1 and
|
||||||
|
// trades its next stage after a payout that day, as the projected days
|
||||||
|
// show — one fund row a day holds all of that day's payouts, so a day
|
||||||
|
// with one reads stage 2.
|
||||||
const sortedWithdrawalDates = fundTransactions
|
const sortedWithdrawalDates = fundTransactions
|
||||||
.filter((f) => f.amount < 0)
|
.filter((f) => f.amount < 0)
|
||||||
.map((f) => f.date)
|
.map((f) => f.date)
|
||||||
.sort();
|
.sort();
|
||||||
const stageMap = new Map<string, number>();
|
const stageMap = new Map<string, number>();
|
||||||
for (const d of displayPnL) {
|
for (const d of displayPnL) {
|
||||||
const stage = sortedWithdrawalDates.filter((wd) => wd < d.date).length + 1;
|
const stage = cfg?.stagesSameDay === true
|
||||||
|
? sortedWithdrawalDates.filter((wd) => wd === d.date).length + 1
|
||||||
|
: sortedWithdrawalDates.filter((wd) => wd < d.date).length + 1;
|
||||||
stageMap.set(d.date, stage);
|
stageMap.set(d.date, stage);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -739,7 +758,7 @@ export default function AccountPage() {
|
|||||||
<ObjRow
|
<ObjRow
|
||||||
passed={daysPassed}
|
passed={daysPassed}
|
||||||
label="Trading Days"
|
label="Trading Days"
|
||||||
value={cfg ? `${account.daysTraded} of ${cfg.minTradingDays}` : '—'}
|
value={cfg ? `${account.daysTraded} of ${minTradingDays}` : '—'}
|
||||||
/>
|
/>
|
||||||
{dailyTarget != null && (
|
{dailyTarget != null && (
|
||||||
<div className="flex items-center justify-between py-3 border-b border-slate-100 last:border-0">
|
<div className="flex items-center justify-between py-3 border-b border-slate-100 last:border-0">
|
||||||
@@ -892,6 +911,7 @@ export default function AccountPage() {
|
|||||||
stroke={`url(#${strokeGradId})`}
|
stroke={`url(#${strokeGradId})`}
|
||||||
strokeWidth={2.5}
|
strokeWidth={2.5}
|
||||||
fill="none"
|
fill="none"
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
dot={(props: any) => {
|
dot={(props: any) => {
|
||||||
if (props.payload?.origin) return <g key={props.key} />;
|
if (props.payload?.origin) return <g key={props.key} />;
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ export async function GET() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (plan && !dayBooked && botStopsActive(cfg)) {
|
if (plan && !dayBooked && botStopsActive(cfg, effective)) {
|
||||||
const obf = obfuscationStopDollars(cfg, dailyPnL, plan, combined.tradeDate, plan.remainingToday);
|
const obf = obfuscationStopDollars(cfg, dailyPnL, plan, combined.tradeDate, plan.remainingToday);
|
||||||
// The reserve grows with the position (dllReserve): at
|
// The reserve grows with the position (dllReserve): at
|
||||||
// the size the next leg would trade, where it's known.
|
// the size the next leg would trade, where it's known.
|
||||||
@@ -276,7 +276,7 @@ export async function GET() {
|
|||||||
// An account the bot puts stops on whose day booked red via
|
// An account the bot puts stops on whose day booked red via
|
||||||
// its own stop — the personal DLL. Firm DLL lockouts show
|
// its own stop — the personal DLL. Firm DLL lockouts show
|
||||||
// via !active instead.
|
// via !active instead.
|
||||||
personalDll: !!cfg && botStopsActive(cfg)
|
personalDll: !!cfg && !!effective && botStopsActive(cfg, effective)
|
||||||
&& acc.active && !client.positions[acc.id]
|
&& acc.active && !client.positions[acc.id]
|
||||||
&& combined.realizedPnL <= -25,
|
&& combined.realizedPnL <= -25,
|
||||||
positionDirection: client.positions[acc.id]
|
positionDirection: client.positions[acc.id]
|
||||||
@@ -296,6 +296,8 @@ export async function GET() {
|
|||||||
lastStand: lastStandNext,
|
lastStand: lastStandNext,
|
||||||
remainingToday,
|
remainingToday,
|
||||||
effectiveProfitTarget,
|
effectiveProfitTarget,
|
||||||
|
effectiveDailyLossLimit: effective?.dailyLossLimit ?? null,
|
||||||
|
effectiveMinTradingDays: effective?.minTradingDays ?? null,
|
||||||
dailyPnL,
|
dailyPnL,
|
||||||
fullDailyPnL: combineCmeSessions(
|
fullDailyPnL: combineCmeSessions(
|
||||||
cfg?.combineCmeHolidays === true,
|
cfg?.combineCmeHolidays === true,
|
||||||
|
|||||||
+1
-1
@@ -453,7 +453,7 @@ function AccountRow({ account, firm, hideDead, privacy, onAssigned, onStateChang
|
|||||||
<AccountTypeCell account={account} firm={firm} onAssigned={onAssigned} />
|
<AccountTypeCell account={account} firm={firm} onAssigned={onAssigned} />
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-slate-600 tabular-nums">
|
<td className="px-4 py-3 text-slate-600 tabular-nums">
|
||||||
{account.daysTraded} / {cfg?.minTradingDays ?? '—'}
|
{account.daysTraded} / {account.effectiveMinTradingDays ?? cfg?.minTradingDays ?? '—'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-slate-600 tabular-nums">
|
<td className="px-4 py-3 text-slate-600 tabular-nums">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
|
|||||||
+18
-4
@@ -49,8 +49,11 @@ const COLD_RETRY_MAX_MS = 60_000;
|
|||||||
* first sync on a newer parser fetches the whole catalog.
|
* first sync on a newer parser fetches the whole catalog.
|
||||||
* 2: kept rule sets (keptAccountRules) and the firm's maxConcurrentAccounts.
|
* 2: kept rule sets (keptAccountRules) and the firm's maxConcurrentAccounts.
|
||||||
* Stamped only on a payload from a master that sends keptAccountRules: one
|
* Stamped only on a payload from a master that sends keptAccountRules: one
|
||||||
* from an older master can't vouch for kept sets it never sent. */
|
* from an older master can't vouch for kept sets it never sent.
|
||||||
export const CATALOG_PARSER_VERSION = 2;
|
* 3: Vest firms, which a build from before the platform skipped. A platform
|
||||||
|
* learned later needs no bump: the cache names the platforms it skipped
|
||||||
|
* (skippedPlatforms), and a build that knows one fetches in full. */
|
||||||
|
export const CATALOG_PARSER_VERSION = 3;
|
||||||
|
|
||||||
/** A Date header further off than this is somebody else's clock, not the master's. */
|
/** A Date header further off than this is somebody else's clock, not the master's. */
|
||||||
const MAX_MASTER_OFFSET_MS = 365 * 24 * 60 * 60 * 1000;
|
const MAX_MASTER_OFFSET_MS = 365 * 24 * 60 * 60 * 1000;
|
||||||
@@ -103,6 +106,10 @@ function cache(): CatalogCache {
|
|||||||
const parser = (stored as { parser?: unknown }).parser;
|
const parser = (stored as { parser?: unknown }).parser;
|
||||||
if (typeof parser === 'number') payload.parser = parser;
|
if (typeof parser === 'number') payload.parser = parser;
|
||||||
else delete payload.parser;
|
else delete payload.parser;
|
||||||
|
// Likewise the platforms it skipped: their firms aren't in the blob.
|
||||||
|
const skipped = (stored as { skippedPlatforms?: unknown }).skippedPlatforms;
|
||||||
|
if (Array.isArray(skipped)) payload.skippedPlatforms = skipped.filter((p): p is string => typeof p === 'string');
|
||||||
|
else delete payload.skippedPlatforms;
|
||||||
} catch {
|
} catch {
|
||||||
console.error('[catalog] cached payload is unreadable — ignoring it');
|
console.error('[catalog] cached payload is unreadable — ignoring it');
|
||||||
}
|
}
|
||||||
@@ -283,6 +290,8 @@ function validatePayload(raw: unknown): FirmCatalogPayload {
|
|||||||
if (!Array.isArray(body.firms)) throw new Error('missing firms');
|
if (!Array.isArray(body.firms)) throw new Error('missing firms');
|
||||||
|
|
||||||
const firms: CatalogFirm[] = [];
|
const firms: CatalogFirm[] = [];
|
||||||
|
// Stamped on the payload, so a build that learns one refetches its firms.
|
||||||
|
const skippedPlatforms = new Set<string>();
|
||||||
body.firms.forEach((f: unknown, i: number) => {
|
body.firms.forEach((f: unknown, i: number) => {
|
||||||
if (typeof f !== 'object' || f === null) throw new Error(`firm ${i} is not an object`);
|
if (typeof f !== 'object' || f === null) throw new Error(`firm ${i} is not an object`);
|
||||||
const firm = f as Record<string, unknown>;
|
const firm = f as Record<string, unknown>;
|
||||||
@@ -297,6 +306,7 @@ function validatePayload(raw: unknown): FirmCatalogPayload {
|
|||||||
const platform = firm.platform === undefined ? 'tradovate' : firm.platform;
|
const platform = firm.platform === undefined ? 'tradovate' : firm.platform;
|
||||||
if (!isFirmPlatform(platform)) {
|
if (!isFirmPlatform(platform)) {
|
||||||
warnUnknownPlatform(firm.id, firm.name, platform);
|
warnUnknownPlatform(firm.id, firm.name, platform);
|
||||||
|
skippedPlatforms.add(String(platform));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Percent commission and every-day trading are settings of the
|
// Percent commission and every-day trading are settings of the
|
||||||
@@ -349,6 +359,7 @@ function validatePayload(raw: unknown): FirmCatalogPayload {
|
|||||||
return {
|
return {
|
||||||
version: body.version, updatedAt: body.updatedAt, firms, ...(trading ? { trading } : {}),
|
version: body.version, updatedAt: body.updatedAt, firms, ...(trading ? { trading } : {}),
|
||||||
...(keptAware ? { parser: CATALOG_PARSER_VERSION } : {}),
|
...(keptAware ? { parser: CATALOG_PARSER_VERSION } : {}),
|
||||||
|
...(skippedPlatforms.size > 0 ? { skippedPlatforms: [...skippedPlatforms] } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -424,10 +435,13 @@ export async function syncCatalog(): Promise<{ ok: boolean; changed: boolean; ve
|
|||||||
// `since` only against the master the cache came from: two masters'
|
// `since` only against the master the cache came from: two masters'
|
||||||
// version numbers can collide, and an 'unchanged' from the wrong one
|
// version numbers can collide, and an 'unchanged' from the wrong one
|
||||||
// would keep the other's catalog. Nor for a cache an older parser wrote:
|
// would keep the other's catalog. Nor for a cache an older parser wrote:
|
||||||
// it lacks what that parser dropped, and 'unchanged' would keep it so.
|
// it lacks what that parser dropped, and 'unchanged' would keep it so —
|
||||||
|
// fields, or firms on a platform it didn't know and this build does.
|
||||||
const hasCache = getCachedCatalog().length > 0;
|
const hasCache = getCachedCatalog().length > 0;
|
||||||
const source = url.origin;
|
const source = url.origin;
|
||||||
const cacheCurrent = (cache().payload?.parser ?? 0) >= CATALOG_PARSER_VERSION;
|
const cached = cache().payload;
|
||||||
|
const cacheCurrent = (cached?.parser ?? 0) >= CATALOG_PARSER_VERSION
|
||||||
|
&& !(cached?.skippedPlatforms ?? []).some((p) => isFirmPlatform(p));
|
||||||
if (hasCache && cacheCurrent && getSetting('catalog_source_url') === source) url.searchParams.set('since', String(version));
|
if (hasCache && cacheCurrent && getSetting('catalog_source_url') === source) url.searchParams.set('since', String(version));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -514,6 +514,9 @@ export interface FirmCatalogPayload {
|
|||||||
* payload. An older parser dropped fields it didn't know, so a cache
|
* payload. An older parser dropped fields it didn't know, so a cache
|
||||||
* without it, or below the current one, isn't trusted to hold them. */
|
* without it, or below the current one, isn't trusted to hold them. */
|
||||||
parser?: number;
|
parser?: number;
|
||||||
|
/** Instance-local, like parser: the platforms of the firms that parser
|
||||||
|
* skipped as unknown to it. A build that knows one fetches in full. */
|
||||||
|
skippedPlatforms?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FirmCatalogUnchanged {
|
export interface FirmCatalogUnchanged {
|
||||||
|
|||||||
+3
-2
@@ -221,6 +221,7 @@ function collectFirmStats() {
|
|||||||
fundTransactions: fundTxns,
|
fundTransactions: fundTxns,
|
||||||
balance: cash.amount,
|
balance: cash.amount,
|
||||||
realizedPnL: cash.realizedPnL,
|
realizedPnL: cash.realizedPnL,
|
||||||
|
stageInputs: stageInputsFor(client, acc.id, cfg),
|
||||||
payout: {
|
payout: {
|
||||||
withdrawalPct: cfg.withdrawalPct,
|
withdrawalPct: cfg.withdrawalPct,
|
||||||
payoutCap: cfg.payoutCap,
|
payoutCap: cfg.payoutCap,
|
||||||
@@ -399,11 +400,11 @@ function collectFirmStats() {
|
|||||||
// A grind-day fill (dayTarget 0) can flag targetHit on a
|
// A grind-day fill (dayTarget 0) can flag targetHit on a
|
||||||
// negative day — a loss is still a loss, so red wins.
|
// negative day — a loss is still a loss, so red wins.
|
||||||
// A red day on an account the bot puts stops on (the
|
// A red day on an account the bot puts stops on (the
|
||||||
// obfuscation stops, or a hard daily loss limit) is its own
|
// obfuscation stops, or its stage's hard daily loss limit) is its own
|
||||||
// personal DLL, booked on purpose — not a genuine loss. (A
|
// personal DLL, booked on purpose — not a genuine loss. (A
|
||||||
// firm DLL lockout took the !acc.active branch above and
|
// firm DLL lockout took the !acc.active branch above and
|
||||||
// stays a loss.)
|
// stays a loss.)
|
||||||
if (botStopsActive(cfg) && combined.realizedPnL <= -25) {
|
if (botStopsActive(cfg, effective) && combined.realizedPnL <= -25) {
|
||||||
personalDllCount++;
|
personalDllCount++;
|
||||||
slice.personalDll = (slice.personalDll ?? 0) + 1;
|
slice.personalDll = (slice.personalDll ?? 0) + 1;
|
||||||
} else if (combined.realizedPnL < 0) {
|
} else if (combined.realizedPnL < 0) {
|
||||||
|
|||||||
+66
-20
@@ -454,6 +454,11 @@ export function projectPerfectPath(opts: {
|
|||||||
fundTransactions: { date: string; amount: number }[];
|
fundTransactions: { date: string; amount: number }[];
|
||||||
balance: number;
|
balance: number;
|
||||||
realizedPnL: number;
|
realizedPnL: number;
|
||||||
|
/** What picks the stage beyond the fund rows (stageInputsFor). A type
|
||||||
|
* whose stages reset each day trades today on today's payouts and
|
||||||
|
* every projected day from the base stage. Absent = every withdrawal
|
||||||
|
* counts. */
|
||||||
|
stageInputs?: StageInputs;
|
||||||
/** Trading days to look ahead. */
|
/** Trading days to look ahead. */
|
||||||
horizonDays?: number;
|
horizonDays?: number;
|
||||||
maxWithdrawals?: number;
|
maxWithdrawals?: number;
|
||||||
@@ -540,17 +545,36 @@ export function projectPerfectPath(opts: {
|
|||||||
return room / (room + dayTarget);
|
return room / (room + dayTarget);
|
||||||
};
|
};
|
||||||
|
|
||||||
const effectiveNow = () => resolveEffectiveConfig(
|
// A type whose stages reset each day (Vest) counts only the payouts made
|
||||||
|
// since its day began: today's real ones plus the path's own on that
|
||||||
|
// date, so every projected day starts on the base stage.
|
||||||
|
const sameDay = opts.stageInputs?.stagesSameDay === true;
|
||||||
|
/** The path's own payouts, by the date they are claimed on. */
|
||||||
|
const paidOn = new Map<string, number>();
|
||||||
|
/** Payouts before the stage traded on `day`: that day's own for a
|
||||||
|
* same-day type, else every withdrawal so far. */
|
||||||
|
const paidBefore = (day: string): number => (sameDay
|
||||||
|
? (day === today ? Math.max(0, Math.floor(opts.stageInputs?.sameDayWithdrawals ?? 0)) : 0) + (paidOn.get(day) ?? 0)
|
||||||
|
: funds.filter((f) => f.amount < 0).length);
|
||||||
|
/** A same-day type trades its next stage on the date a payout lands
|
||||||
|
* (runClaims claims, then trades again on the room the claim freed) —
|
||||||
|
* once through its stages a day: the last one repeats, and the path
|
||||||
|
* stops there. */
|
||||||
|
const tradesAgain = (day: string): boolean => sameDay
|
||||||
|
&& (paidOn.get(day) ?? 0) > 0
|
||||||
|
&& paidBefore(day) <= cfg.withdrawalStages.length;
|
||||||
|
const effectiveOn = (day: string) => resolveEffectiveConfig(
|
||||||
cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity,
|
cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity,
|
||||||
cfg.withdrawalStages, 0, funds, cfg.minDayPnL,
|
cfg.withdrawalStages, 0, funds, cfg.minDayPnL,
|
||||||
|
sameDay ? { stagesSameDay: true, sameDayWithdrawals: paidBefore(day) } : {},
|
||||||
);
|
);
|
||||||
/** The payout parameters of the stage currently being traded: per-stage
|
/** The payout parameters of the stage being completed: per-stage
|
||||||
* overrides when set, else the type defaults. */
|
* overrides when set, else the type defaults. */
|
||||||
const stagePayout = (): { pct: number; cap: number } => {
|
const stagePayout = (stage: number): { pct: number; cap: number } => {
|
||||||
const count = funds.filter((f) => f.amount < 0).length;
|
const count = stage - 1;
|
||||||
if (count === 0 || cfg.withdrawalStages.length === 0) return { pct: basePct, cap: baseCap };
|
if (count === 0 || cfg.withdrawalStages.length === 0) return { pct: basePct, cap: baseCap };
|
||||||
const stage = cfg.withdrawalStages[Math.min(count - 1, cfg.withdrawalStages.length - 1)];
|
const s = cfg.withdrawalStages[Math.min(count - 1, cfg.withdrawalStages.length - 1)];
|
||||||
return { pct: stage.withdrawalPct ?? basePct, cap: stage.payoutCap ?? baseCap };
|
return { pct: s.withdrawalPct ?? basePct, cap: s.payoutCap ?? baseCap };
|
||||||
};
|
};
|
||||||
const qualifyingDays = (minDay: number, excludeDate: string) => (
|
const qualifyingDays = (minDay: number, excludeDate: string) => (
|
||||||
minDay > 0
|
minDay > 0
|
||||||
@@ -562,7 +586,7 @@ export function projectPerfectPath(opts: {
|
|||||||
// Skipped when "today" is a weekend date (Saturday, or Friday evening
|
// Skipped when "today" is a weekend date (Saturday, or Friday evening
|
||||||
// after the roll): there is no session to finish.
|
// after the roll): there is no session to finish.
|
||||||
if (todayHasSession) {
|
if (todayHasSession) {
|
||||||
const eff = effectiveNow();
|
const eff = effectiveOn(today);
|
||||||
const plan = computeDayPlan(
|
const plan = computeDayPlan(
|
||||||
accountId, today, eff.profitTarget, eff.consistency, sim, eff.minDayPnL,
|
accountId, today, eff.profitTarget, eff.consistency, sim, eff.minDayPnL,
|
||||||
eff.minTradingDays, balance - cfg.accountSize, opts.realizedPnL, cfg.splitEnabled, eff.drain,
|
eff.minTradingDays, balance - cfg.accountSize, opts.realizedPnL, cfg.splitEnabled, eff.drain,
|
||||||
@@ -591,7 +615,7 @@ export function projectPerfectPath(opts: {
|
|||||||
// over and over. Only a non-payoutable eval stops after one completion.
|
// over and over. Only a non-payoutable eval stops after one completion.
|
||||||
const loops = cfg.withdrawalStages.length > 0 || cfg.payoutable === true;
|
const loops = cfg.withdrawalStages.length > 0 || cfg.payoutable === true;
|
||||||
const finishCycle = (onDate: string, dayPnl: number, stage: number, drain: boolean): boolean => {
|
const finishCycle = (onDate: string, dayPnl: number, stage: number, drain: boolean): boolean => {
|
||||||
const { pct, cap } = stagePayout();
|
const { pct, cap } = stagePayout(stage);
|
||||||
const rawGross = round2(Math.max(0, balance - cfg.accountSize) * pct);
|
const rawGross = round2(Math.max(0, balance - cfg.accountSize) * pct);
|
||||||
const gross = cap > 0 ? Math.min(rawGross, round2(cap)) : rawGross;
|
const gross = cap > 0 ? Math.min(rawGross, round2(cap)) : rawGross;
|
||||||
const net = round2(gross * profitSplit);
|
const net = round2(gross * profitSplit);
|
||||||
@@ -600,6 +624,9 @@ export function projectPerfectPath(opts: {
|
|||||||
balance = round2(balance - gross);
|
balance = round2(balance - gross);
|
||||||
sim = []; // withdrawal excludes its day — fresh cycle
|
sim = []; // withdrawal excludes its day — fresh cycle
|
||||||
withdrawals++;
|
withdrawals++;
|
||||||
|
// A cycle that stood complete before today is claimed today.
|
||||||
|
const claimDay = onDate < today ? today : onDate;
|
||||||
|
paidOn.set(claimDay, (paidOn.get(claimDay) ?? 0) + 1);
|
||||||
high = Math.max(high, dd); // a payout locks the trail at breakeven
|
high = Math.max(high, dd); // a payout locks the trail at breakeven
|
||||||
out.push({ date: onDate, pnl: dayPnl, stage, drain, withdrawal: -gross, prob, gross, net });
|
out.push({ date: onDate, pnl: dayPnl, stage, drain, withdrawal: -gross, prob, gross, net });
|
||||||
return false;
|
return false;
|
||||||
@@ -613,14 +640,14 @@ export function projectPerfectPath(opts: {
|
|||||||
// requestable after that session's close. Never "today" — a projection
|
// requestable after that session's close. Never "today" — a projection
|
||||||
// computed over the weekend must not slide a Friday finish to Sunday.
|
// computed over the weekend must not slide a Friday finish to Sunday.
|
||||||
{
|
{
|
||||||
const eff = effectiveNow();
|
const eff = effectiveOn(today);
|
||||||
const probe = nextDay(today);
|
const probe = nextDay(today);
|
||||||
const plan = computeDayPlan(
|
const plan = computeDayPlan(
|
||||||
accountId, probe, eff.profitTarget, eff.consistency, sim, eff.minDayPnL,
|
accountId, probe, eff.profitTarget, eff.consistency, sim, eff.minDayPnL,
|
||||||
eff.minTradingDays, balance - cfg.accountSize, 0, cfg.splitEnabled, eff.drain,
|
eff.minTradingDays, balance - cfg.accountSize, 0, cfg.splitEnabled, eff.drain,
|
||||||
);
|
);
|
||||||
if (plan && plan.dayTarget <= 0 && eff.minTradingDays <= qualifyingDays(eff.minDayPnL, probe)) {
|
if (plan && plan.dayTarget <= 0 && eff.minTradingDays <= qualifyingDays(eff.minDayPnL, probe)) {
|
||||||
const stage = 1 + funds.filter((f) => f.amount < 0).length;
|
const stage = 1 + paidBefore(today);
|
||||||
const traded = sim.filter((d) => d.pnl !== 0);
|
const traded = sim.filter((d) => d.pnl !== 0);
|
||||||
const doneOn = traded.length > 0
|
const doneOn = traded.length > 0
|
||||||
? traded.reduce((a, b) => (a.date > b.date ? a : b)).date
|
? traded.reduce((a, b) => (a.date > b.date ? a : b)).date
|
||||||
@@ -629,11 +656,15 @@ export function projectPerfectPath(opts: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A same-day type's next stage trades on the date its payout landed,
|
||||||
|
// today's included; anything else moves on a day.
|
||||||
let date = today;
|
let date = today;
|
||||||
for (let step = 0; step < horizon; step++) {
|
let again = todayHasSession && tradesAgain(today);
|
||||||
date = nextDay(date);
|
for (let step = 0; again || step < horizon;) {
|
||||||
const eff = effectiveNow();
|
if (again) again = false;
|
||||||
const stage = 1 + funds.filter((f) => f.amount < 0).length;
|
else { date = nextDay(date); step++; }
|
||||||
|
const eff = effectiveOn(date);
|
||||||
|
const stage = 1 + paidBefore(date);
|
||||||
const plan = computeDayPlan(
|
const plan = computeDayPlan(
|
||||||
accountId, date, eff.profitTarget, eff.consistency, sim, eff.minDayPnL,
|
accountId, date, eff.profitTarget, eff.consistency, sim, eff.minDayPnL,
|
||||||
eff.minTradingDays, balance - cfg.accountSize, 0, cfg.splitEnabled, eff.drain,
|
eff.minTradingDays, balance - cfg.accountSize, 0, cfg.splitEnabled, eff.drain,
|
||||||
@@ -646,6 +677,7 @@ export function projectPerfectPath(opts: {
|
|||||||
// The cycle completed mid-projection with nothing left to trade —
|
// The cycle completed mid-projection with nothing left to trade —
|
||||||
// the pending payout lands on this day.
|
// the pending payout lands on this day.
|
||||||
if (finishCycle(date, 0, stage, eff.drain)) break;
|
if (finishCycle(date, 0, stage, eff.drain)) break;
|
||||||
|
again = tradesAgain(date);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -659,7 +691,7 @@ export function projectPerfectPath(opts: {
|
|||||||
|
|
||||||
// Did this day complete the cycle? Then it IS the payout day.
|
// Did this day complete the cycle? Then it IS the payout day.
|
||||||
const nd = nextDay(date);
|
const nd = nextDay(date);
|
||||||
const eff2 = effectiveNow();
|
const eff2 = effectiveOn(date);
|
||||||
const plan2 = computeDayPlan(
|
const plan2 = computeDayPlan(
|
||||||
accountId, nd, eff2.profitTarget, eff2.consistency, sim, eff2.minDayPnL,
|
accountId, nd, eff2.profitTarget, eff2.consistency, sim, eff2.minDayPnL,
|
||||||
eff2.minTradingDays, balance - cfg.accountSize, 0, cfg.splitEnabled, eff2.drain,
|
eff2.minTradingDays, balance - cfg.accountSize, 0, cfg.splitEnabled, eff2.drain,
|
||||||
@@ -669,6 +701,7 @@ export function projectPerfectPath(opts: {
|
|||||||
&& eff2.minTradingDays <= qualifyingDays(eff2.minDayPnL, nd);
|
&& eff2.minTradingDays <= qualifyingDays(eff2.minDayPnL, nd);
|
||||||
if (doneAfter) {
|
if (doneAfter) {
|
||||||
if (finishCycle(date, pnl, stage, eff.drain)) break;
|
if (finishCycle(date, pnl, stage, eff.drain)) break;
|
||||||
|
again = tradesAgain(date);
|
||||||
} else {
|
} else {
|
||||||
out.push({ date, pnl, stage, drain: eff.drain, prob });
|
out.push({ date, pnl, stage, drain: eff.drain, prob });
|
||||||
}
|
}
|
||||||
@@ -865,6 +898,8 @@ export function endedCycleCompletion(opts: {
|
|||||||
targetSameEquity: boolean; withdrawalStages: WithdrawalStage[];
|
targetSameEquity: boolean; withdrawalStages: WithdrawalStage[];
|
||||||
combineCmeHolidays: boolean; splitEnabled: boolean;
|
combineCmeHolidays: boolean; splitEnabled: boolean;
|
||||||
tradesEveryDay?: boolean;
|
tradesEveryDay?: boolean;
|
||||||
|
/** Stages reset each day (StageInputs). */
|
||||||
|
stagesSameDay?: boolean;
|
||||||
};
|
};
|
||||||
/** Defaults to the current time. */
|
/** Defaults to the current time. */
|
||||||
now?: Date;
|
now?: Date;
|
||||||
@@ -898,10 +933,14 @@ export function endedCycleCompletion(opts: {
|
|||||||
const fundAmt = fundTransactions.filter((f) => f.date === lastFundDate).reduce((s, f) => s + f.amount, 0);
|
const fundAmt = fundTransactions.filter((f) => f.date === lastFundDate).reduce((s, f) => s + f.amount, 0);
|
||||||
const equityBefore = round2(opts.equityProfit - curTotal - fundAmt);
|
const equityBefore = round2(opts.equityProfit - curTotal - fundAmt);
|
||||||
|
|
||||||
// The stage the ended cycle traded on: only the withdrawals before it.
|
// The stage the ended cycle traded on: only the withdrawals before it —
|
||||||
|
// for a type whose stages reset each day, only that day's. One fund row
|
||||||
|
// a day shows none before it, and the rows here hold that whole day,
|
||||||
|
// which began on the base stage: it is judged there.
|
||||||
const eff = resolveEffectiveConfig(
|
const eff = resolveEffectiveConfig(
|
||||||
cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity,
|
cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity,
|
||||||
cfg.withdrawalStages, 0, earlierFunds, cfg.minDayPnL,
|
cfg.withdrawalStages, 0, earlierFunds, cfg.minDayPnL,
|
||||||
|
cfg.stagesSameDay === true ? { stagesSameDay: true, sameDayWithdrawals: 0 } : {},
|
||||||
);
|
);
|
||||||
const rows = combineCmeSessions(combine, endedRaw, lastFundDate, 0).dailyPnL;
|
const rows = combineCmeSessions(combine, endedRaw, lastFundDate, 0).dailyPnL;
|
||||||
// A combining firm's folded day can net to 0 — nothing traded, as the firm counts it.
|
// A combining firm's folded day can net to 0 — nothing traded, as the firm counts it.
|
||||||
@@ -912,6 +951,8 @@ export function endedCycleCompletion(opts: {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
date: traded.map((d) => d.date).reduce(latest),
|
date: traded.map((d) => d.date).reduce(latest),
|
||||||
|
// The payout's number among the account's withdrawals, a same-day
|
||||||
|
// type's too: the master clears the entry once that many have booked.
|
||||||
stage: 1 + earlierFunds.filter((f) => f.amount < 0).length,
|
stage: 1 + earlierFunds.filter((f) => f.amount < 0).length,
|
||||||
drain: eff.drain,
|
drain: eff.drain,
|
||||||
cycle: prevFundDate ?? '',
|
cycle: prevFundDate ?? '',
|
||||||
@@ -1599,10 +1640,15 @@ export function stopTicks(o: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Whether the bot places its own stops for this account type — the
|
/** Whether the bot places its own stops for this account type — the
|
||||||
* obfuscation options or a hard daily loss limit. A red day on such an
|
* obfuscation options or a hard daily loss limit: the current stage's
|
||||||
* account is the bot's own stop (the personal DLL), not a firm lockout. */
|
* (resolveEffectiveConfig) when given, which a stage may set on a type with
|
||||||
export function botStopsActive(cfg: { firstDayStop: boolean; minDayStop: boolean; dailyLossLimit?: number }): boolean {
|
* none. A red day on such an account is the bot's own stop (the personal
|
||||||
return cfg.firstDayStop || cfg.minDayStop || (cfg.dailyLossLimit ?? 0) > 0;
|
* DLL), not a firm lockout. */
|
||||||
|
export function botStopsActive(
|
||||||
|
cfg: { firstDayStop: boolean; minDayStop: boolean; dailyLossLimit?: number },
|
||||||
|
effective?: { dailyLossLimit: number },
|
||||||
|
): boolean {
|
||||||
|
return cfg.firstDayStop || cfg.minDayStop || (effective ? effective.dailyLossLimit : cfg.dailyLossLimit ?? 0) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@
|
|||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
"check": "node --experimental-strip-types --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --test scripts/check-trading-logic.mjs scripts/check-instruments.mjs scripts/check-account-status.mjs scripts/check-kept-rules.mjs scripts/check-request-user-agent.mjs scripts/check-vest.mjs scripts/check-vest-client.mjs scripts/check-trades.mjs"
|
"check": "node --experimental-strip-types --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --test scripts/check-trading-logic.mjs scripts/check-instruments.mjs scripts/check-account-status.mjs scripts/check-kept-rules.mjs scripts/check-request-user-agent.mjs scripts/check-vest.mjs scripts/check-vest-client.mjs scripts/check-trades.mjs scripts/check-catalog.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@microsoft/signalr": "^10.0.11",
|
"@microsoft/signalr": "^10.0.11",
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
// Checks for the firm catalog sync in lib/firm-catalog.ts, against a stand-in
|
||||||
|
// master on a local port and a throwaway database — never the live DB or the
|
||||||
|
// real master.
|
||||||
|
//
|
||||||
|
// npm run check
|
||||||
|
import { after, test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { mkdtempSync, rmSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { registerHooks } from 'node:module';
|
||||||
|
import { createServer } from 'node:http';
|
||||||
|
import { once } from 'node:events';
|
||||||
|
|
||||||
|
// Resolve the extensionless local imports used by Next, without a build.
|
||||||
|
function resolveTypes(specifier, context, nextResolve) {
|
||||||
|
try {
|
||||||
|
return nextResolve(specifier, context);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code !== 'ERR_MODULE_NOT_FOUND' || !specifier.startsWith('.')) throw err;
|
||||||
|
return nextResolve(`${specifier}.ts`, context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
registerHooks({ resolve: resolveTypes });
|
||||||
|
|
||||||
|
const directory = mkdtempSync(join(tmpdir(), 'autofirmer-catalog-'));
|
||||||
|
process.env.AUTOFIRMER_DB = join(directory, 'test.sqlite');
|
||||||
|
after(() => rmSync(directory, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
const { getSetting, setSetting } = await import('../lib/db.ts');
|
||||||
|
const { syncCatalog, getCachedCatalog, getKeptRuleIdsForFirm, CATALOG_PARSER_VERSION } = await import('../lib/firm-catalog.ts');
|
||||||
|
|
||||||
|
const firm = (id, name, platform) => ({
|
||||||
|
id, name, platform, keptAccountRules: [], updatedAt: '2026-10-26T19:00:00.000Z',
|
||||||
|
accountTypes: [{
|
||||||
|
id: `${id}-50k`, firmId: id, label: '50k', prefix: `${id.toUpperCase()}-`, updatedAt: '2026-10-26T19:00:00.000Z',
|
||||||
|
accountSize: 50000, profitTarget: 3000, consistency: 0, minDayPnL: 0, minTradingDays: 0, maxLoss: 3000,
|
||||||
|
maxPositionSize: 0, withdrawalStages: [],
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
const apex = firm('apex', 'Apex', 'tradovate');
|
||||||
|
const vest = firm('vest', 'VestMarkets', 'vest');
|
||||||
|
|
||||||
|
// The master's catalog, answering ?since= as app/api/firms/catalog does, and
|
||||||
|
// the `since` each sync sent (null = a full fetch).
|
||||||
|
let served = { version: 41, updatedAt: '2026-10-26T19:20:00.000Z', firms: [apex, vest] };
|
||||||
|
const sinceSent = [];
|
||||||
|
const server = createServer((req, res) => {
|
||||||
|
const since = new URL(req.url, 'http://master').searchParams.get('since');
|
||||||
|
sinceSent.push(since);
|
||||||
|
res.setHeader('Content-Type', 'application/json');
|
||||||
|
res.end(JSON.stringify(since !== null && Number(since) === served.version
|
||||||
|
? { version: served.version, updatedAt: served.updatedAt, unchanged: true }
|
||||||
|
: served));
|
||||||
|
});
|
||||||
|
server.listen(0, '127.0.0.1');
|
||||||
|
await once(server, 'listening');
|
||||||
|
after(() => server.close());
|
||||||
|
const master = `http://127.0.0.1:${server.address().port}`;
|
||||||
|
|
||||||
|
/** This machine's cache as some build stored it, read afresh as after a restart. */
|
||||||
|
function storedCache(payload) {
|
||||||
|
setSetting('master_dashboard_url', master);
|
||||||
|
setSetting('catalog_source_url', master);
|
||||||
|
setSetting('catalog_version', String(payload.version));
|
||||||
|
setSetting('catalog_payload', JSON.stringify(payload));
|
||||||
|
globalThis.__firmCatalog = undefined;
|
||||||
|
sinceSent.length = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const names = () => getCachedCatalog().map((f) => f.name);
|
||||||
|
|
||||||
|
test('a cache a pre-Vest build stored is fetched in full by a build that knows Vest', async (t) => {
|
||||||
|
t.mock.method(console, 'log', () => {});
|
||||||
|
// Parser 2 skipped the Vest firm and said nothing of it; the master's
|
||||||
|
// catalog hasn't changed since, so ?since= would answer "unchanged".
|
||||||
|
storedCache({ version: 41, updatedAt: served.updatedAt, firms: [apex], parser: 2 });
|
||||||
|
assert.equal((await syncCatalog()).ok, true);
|
||||||
|
assert.deepEqual(sinceSent, [null]);
|
||||||
|
assert.deepEqual(names(), ['Apex', 'VestMarkets']);
|
||||||
|
// Current from then on: the next sync asks ?since= again.
|
||||||
|
await syncCatalog();
|
||||||
|
assert.deepEqual(sinceSent, [null, '41']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a skipped platform is named in the cache, and a build that knows it fetches in full', async (t) => {
|
||||||
|
t.mock.method(console, 'log', () => {});
|
||||||
|
t.mock.method(console, 'warn', () => {});
|
||||||
|
// The master adds a firm on a platform this build doesn't know.
|
||||||
|
served = { version: 42, updatedAt: '2026-10-27T15:00:00.000Z', firms: [apex, vest, firm('px', 'PX Firm', 'projectx')] };
|
||||||
|
storedCache({ version: 41, updatedAt: '2026-10-26T19:20:00.000Z', firms: [apex, vest], parser: CATALOG_PARSER_VERSION });
|
||||||
|
await syncCatalog();
|
||||||
|
assert.deepEqual(names(), ['Apex', 'VestMarkets']);
|
||||||
|
assert.deepEqual(JSON.parse(getSetting('catalog_payload')).skippedPlatforms, ['projectx']);
|
||||||
|
// While this build still doesn't know it, syncs stay incremental, and the
|
||||||
|
// cache still vouches for the kept rule sets of the firms it holds.
|
||||||
|
globalThis.__firmCatalog = undefined;
|
||||||
|
sinceSent.length = 0;
|
||||||
|
await syncCatalog();
|
||||||
|
assert.deepEqual(sinceSent, ['42']);
|
||||||
|
assert.deepEqual(getKeptRuleIdsForFirm('apex'), []);
|
||||||
|
// A build that knows the platform it skipped (Vest here) fetches in full.
|
||||||
|
storedCache({ version: 42, updatedAt: served.updatedAt, firms: [apex], parser: CATALOG_PARSER_VERSION, skippedPlatforms: ['vest'] });
|
||||||
|
await syncCatalog();
|
||||||
|
assert.deepEqual(sinceSent, [null]);
|
||||||
|
assert.ok(names().includes('VestMarkets'));
|
||||||
|
});
|
||||||
@@ -11,7 +11,7 @@ import assert from 'node:assert/strict';
|
|||||||
import {
|
import {
|
||||||
percentFee, takeProfitPlan, onTick, obfuscationStopDollars, dllRoom, dllStopDollars, dllViable, stopTicks,
|
percentFee, takeProfitPlan, onTick, obfuscationStopDollars, dllRoom, dllStopDollars, dllViable, stopTicks,
|
||||||
botStopsActive, sizeNotional, isNoTradeTime, isPastSessionClose, nextTradingDate, effectiveCombine, DEFAULT_TARGET_HOLD_HOURS,
|
botStopsActive, sizeNotional, isNoTradeTime, isPastSessionClose, nextTradingDate, effectiveCombine, DEFAULT_TARGET_HOLD_HOURS,
|
||||||
projectPerfectPath, cycleCompletion, DLL_RESERVE_FRACTION, DLL_MIN_STOP, notionalCap, expectedSwapToWin, MARGIN_HEADROOM,
|
projectPerfectPath, cycleCompletion, endedCycleCompletion, DLL_RESERVE_FRACTION, DLL_MIN_STOP, notionalCap, expectedSwapToWin, MARGIN_HEADROOM,
|
||||||
dllReserve, DLL_SLIPPAGE_FRACTION, maxLossRoom, lastStand, breachDistance, LAST_STAND_FRACTION, planLegSize, rankForTarget,
|
dllReserve, DLL_SLIPPAGE_FRACTION, maxLossRoom, lastStand, breachDistance, LAST_STAND_FRACTION, planLegSize, rankForTarget,
|
||||||
currentTradeDateCT, foldReason, withinFirmCaps, personSizeFactor, personHoldHours, PERSON_SIZE_SPREAD, machineSide,
|
currentTradeDateCT, foldReason, withinFirmCaps, personSizeFactor, personHoldHours, PERSON_SIZE_SPREAD, machineSide,
|
||||||
resolveEffectiveConfig, stageInputsFor, floorRoom, resetClearStop, shortestNextLeg, computeDayPlan, clearsClose, RESET_CLEAR_HOLDS,
|
resolveEffectiveConfig, stageInputsFor, floorRoom, resetClearStop, shortestNextLeg, computeDayPlan, clearsClose, RESET_CLEAR_HOLDS,
|
||||||
@@ -306,6 +306,22 @@ test('dllViable and botStopsActive', () => {
|
|||||||
assert.equal(botStopsActive({ firstDayStop: true, minDayStop: false, dailyLossLimit: 0 }), true);
|
assert.equal(botStopsActive({ firstDayStop: true, minDayStop: false, dailyLossLimit: 0 }), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('botStopsActive: the current stage\'s daily loss limit, which a stage may set on a type with none', () => {
|
||||||
|
const cfg = { firstDayStop: false, minDayStop: false, dailyLossLimit: 0 };
|
||||||
|
const stages = [{ profit: 3000, consistency: 0, minTradingDays: 0, dailyLossLimit: 3000 }];
|
||||||
|
const paidOnce = [{ date: '2026-10-25', amount: -1500 }];
|
||||||
|
// Stage 2 places its $3,000 stop: a red day there is the personal DLL.
|
||||||
|
const stage2 = resolveEffectiveConfig(1500, 0, 0, false, stages, 0, paidOnce, 0, { dailyLossLimit: 0 });
|
||||||
|
assert.equal(botStopsActive(cfg, stage2), true);
|
||||||
|
// Stage 1 of the same type places none.
|
||||||
|
assert.equal(botStopsActive(cfg, resolveEffectiveConfig(1500, 0, 0, false, stages, 0, [], 0, { dailyLossLimit: 0 })), false);
|
||||||
|
// A stage that sets 0 lifts the type's limit.
|
||||||
|
const lifted = resolveEffectiveConfig(1500, 0, 0, false, [{ ...stages[0], dailyLossLimit: 0 }], 0, paidOnce, 0, { dailyLossLimit: 1229 });
|
||||||
|
assert.equal(botStopsActive({ ...cfg, dailyLossLimit: 1229 }, lifted), false);
|
||||||
|
// The obfuscation stops count whatever the stage.
|
||||||
|
assert.equal(botStopsActive({ ...cfg, minDayStop: true }, lifted), true);
|
||||||
|
});
|
||||||
|
|
||||||
// ── sizing ───────────────────────────────────────────────────────────────────
|
// ── sizing ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const btcSigma = 0.03 / Math.sqrt(24); // 3%/day
|
const btcSigma = 0.03 / Math.sqrt(24); // 3%/day
|
||||||
@@ -1268,3 +1284,77 @@ test('the engine says what each order is for, and both position-based clients go
|
|||||||
assert.match(src, /= orderEffect\(\{[^}]*\bintent\b[^}]*\}\) === 'close' \? held : undefined;/, `${file}: orderEffect doesn't pick the route`);
|
assert.match(src, /= orderEffect\(\{[^}]*\bintent\b[^}]*\}\) === 'close' \? held : undefined;/, `${file}: orderEffect doesn't pick the route`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The Vest 50k Funded type: $1,500 and claim it all, then $3,000 on the room
|
||||||
|
// the claim freed — and every new day back at $1,500.
|
||||||
|
const vestFunded = {
|
||||||
|
profitTarget: 1500, consistency: 0, minDayPnL: 0, minTradingDays: 0, accountSize: 50000,
|
||||||
|
targetSameEquity: false, splitEnabled: true, payoutable: true, tradesEveryDay: true, combineCmeHolidays: false,
|
||||||
|
withdrawalStages: [{ profit: 3000, consistency: 0, minTradingDays: 0, dailyLossLimit: 3000 }],
|
||||||
|
};
|
||||||
|
const claimedBefore = [{ date: '2026-10-20', amount: 50000 }, { date: '2026-10-25', amount: -1500 }];
|
||||||
|
|
||||||
|
test('projectPerfectPath: a same-day type trades each projected day from the base stage, then its next stages that day', () => {
|
||||||
|
const opts = {
|
||||||
|
accountId: 7, cfg: vestFunded, cycleDailyPnL: [], fundTransactions: claimedBefore,
|
||||||
|
balance: 50000, realizedPnL: 0, payout: { withdrawalPct: 1 }, now: cdt('2026-10-27T12:00:00'),
|
||||||
|
};
|
||||||
|
const firstOf = (path, n) => path.slice(0, n).map((d) => [d.date, d.stage, d.withdrawal]);
|
||||||
|
|
||||||
|
// An earlier day's claim doesn't count: today and every day after it
|
||||||
|
// make $1,500 on stage 1 and claim it, then $3,000 on stage 2 and claim
|
||||||
|
// that — once through the stages a day.
|
||||||
|
const fresh = projectPerfectPath({ ...opts, stageInputs: { dailyLossLimit: 1229, stagesSameDay: true, sameDayWithdrawals: 0 } });
|
||||||
|
assert.deepEqual(firstOf(fresh, 5), [
|
||||||
|
['2026-10-27', 1, -1500], ['2026-10-27', 2, -3000],
|
||||||
|
['2026-10-28', 1, -1500], ['2026-10-28', 2, -3000],
|
||||||
|
['2026-10-29', 1, -1500],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Claimed once already today: today's cycle is stage 2's $3,000, tomorrow $1,500 and $3,000 again.
|
||||||
|
const claimedToday = projectPerfectPath({ ...opts, stageInputs: { dailyLossLimit: 1229, stagesSameDay: true, sameDayWithdrawals: 1 } });
|
||||||
|
assert.deepEqual(firstOf(claimedToday, 3), [['2026-10-27', 2, -3000], ['2026-10-28', 1, -1500], ['2026-10-28', 2, -3000]]);
|
||||||
|
|
||||||
|
// A cycle that stood complete yesterday is claimed today, so today trades stage 2.
|
||||||
|
const unclaimed = projectPerfectPath({
|
||||||
|
...opts, cycleDailyPnL: [{ date: '2026-10-26', pnl: 1500 }], balance: 51500,
|
||||||
|
stageInputs: { dailyLossLimit: 1229, stagesSameDay: true, sameDayWithdrawals: 0 },
|
||||||
|
});
|
||||||
|
assert.deepEqual(firstOf(unclaimed, 3), [['2026-10-26', 1, -1500], ['2026-10-27', 2, -3000], ['2026-10-28', 1, -1500]]);
|
||||||
|
|
||||||
|
// Two listed stages: three payouts a day, and the day after starts over.
|
||||||
|
const twoStages = projectPerfectPath({
|
||||||
|
...opts, cfg: { ...vestFunded, withdrawalStages: [...vestFunded.withdrawalStages, { profit: 2000, consistency: 0, minTradingDays: 0 }] },
|
||||||
|
maxWithdrawals: 10, stageInputs: { stagesSameDay: true, sameDayWithdrawals: 0 },
|
||||||
|
});
|
||||||
|
assert.deepEqual(firstOf(twoStages, 4).map(([date, stage]) => [date, stage]), [
|
||||||
|
['2026-10-27', 1], ['2026-10-27', 2], ['2026-10-27', 3], ['2026-10-28', 1],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Any other type counts every withdrawal, as it always has.
|
||||||
|
const lifetime = projectPerfectPath(opts);
|
||||||
|
assert.deepEqual(firstOf(lifetime, 3), [['2026-10-27', 2, -3000], ['2026-10-28', 3, -3000], ['2026-10-29', 4, -3000]]);
|
||||||
|
assert.deepEqual(projectPerfectPath({ ...opts, stageInputs: { dailyLossLimit: 1229, stagesSameDay: false, sameDayWithdrawals: 0 } }), lifetime);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('endedCycleCompletion: a same-day type\'s ended cycle is judged on its day\'s base stage', () => {
|
||||||
|
// 2026-10-26 made $1,500 and the auto-claim took it before the reporter
|
||||||
|
// saw the final day; the account had claimed the day before too. The
|
||||||
|
// claim ended that day's stage 1, not a $3,000 stage 2.
|
||||||
|
const opts = {
|
||||||
|
accountId: 7, rawTradeDate: '2026-10-26',
|
||||||
|
fullDailyPnL: [{ date: '2026-10-25', pnl: 1500 }, { date: '2026-10-26', pnl: 1500 }],
|
||||||
|
cycleDailyPnL: [],
|
||||||
|
fundTransactions: [...claimedBefore, { date: '2026-10-26', amount: -1500 }],
|
||||||
|
lastFundDate: '2026-10-26',
|
||||||
|
endedCycleStart: { fundDate: '2026-10-25', includesDay: false },
|
||||||
|
rawRealizedPnL: 0, equityProfit: 0,
|
||||||
|
cfg: { ...vestFunded, stagesSameDay: true },
|
||||||
|
now: cdt('2026-10-26T12:00:00'),
|
||||||
|
};
|
||||||
|
// Its stage stays the payout's number among the account's withdrawals,
|
||||||
|
// which the master clears the entry by.
|
||||||
|
assert.deepEqual(endedCycleCompletion(opts), { date: '2026-10-26', stage: 2, drain: false, cycle: '2026-10-25' });
|
||||||
|
// Any other type: judged on stage 2's $3,000, as before.
|
||||||
|
assert.equal(endedCycleCompletion({ ...opts, cfg: vestFunded }), null);
|
||||||
|
});
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ export interface AccountConfig {
|
|||||||
/** Hard daily loss limit in dollars (0/absent = none): every leg carries a
|
/** Hard daily loss limit in dollars (0/absent = none): every leg carries a
|
||||||
* stop that keeps the firm's day inside it, less a reserve and fees. */
|
* stop that keeps the firm's day inside it, less a reserve and fees. */
|
||||||
dailyLossLimit?: number;
|
dailyLossLimit?: number;
|
||||||
|
/** The stage is picked by the payouts since the firm's last daily reset,
|
||||||
|
* and every new day starts back at the base stage (Vest). */
|
||||||
|
stagesSameDay?: boolean;
|
||||||
/** The firm trades every calendar day, weekends and CME holidays included. */
|
/** The firm trades every calendar day, weekends and CME holidays included. */
|
||||||
tradesEveryDay?: boolean;
|
tradesEveryDay?: boolean;
|
||||||
}
|
}
|
||||||
@@ -136,6 +139,10 @@ export interface AccountState {
|
|||||||
lastStand: boolean;
|
lastStand: boolean;
|
||||||
/** Actual profit target to hit — max(stage profit target, consistency realTarget = maxDay/consistency). */
|
/** Actual profit target to hit — max(stage profit target, consistency realTarget = maxDay/consistency). */
|
||||||
effectiveProfitTarget: number | null;
|
effectiveProfitTarget: number | null;
|
||||||
|
/** The current stage's hard daily loss limit (0 = none) and minimum
|
||||||
|
* trading days — a withdrawal stage may set its own. null = unassigned. */
|
||||||
|
effectiveDailyLossLimit: number | null;
|
||||||
|
effectiveMinTradingDays: number | null;
|
||||||
/** Current cycle daily P&L — used for target calculations. */
|
/** Current cycle daily P&L — used for target calculations. */
|
||||||
dailyPnL: { date: string; pnl: number }[];
|
dailyPnL: { date: string; pnl: number }[];
|
||||||
/** Full P&L history across all cycles — used for calendar and equity curve display. */
|
/** Full P&L history across all cycles — used for calendar and equity curve display. */
|
||||||
|
|||||||
Reference in New Issue
Block a user