Merge branch 'vest-fix-timing' into vest-fixes
# Conflicts: # lib/reporter.ts # lib/vest-class.ts # scripts/check-vest.mjs
This commit is contained in:
@@ -9,7 +9,7 @@ import { rankNextLeg, dayLossRoom } from '@/lib/leg-estimate';
|
|||||||
import { obfuscationDayBooked } from '@/lib/auto-trade';
|
import { obfuscationDayBooked } from '@/lib/auto-trade';
|
||||||
import type { AccountState } from '@/types';
|
import type { AccountState } from '@/types';
|
||||||
import {
|
import {
|
||||||
combineCmeSessions, computeDayPlan, cycleCompletion, resolveEffectiveConfig, currentTradeDateCT,
|
combineCmeSessions, computeDayPlan, cycleCompletion, resolveEffectiveConfig, tradeDateFor,
|
||||||
obfuscationStopDollars, dllStopDollars, botStopsActive, stageInputsFor,
|
obfuscationStopDollars, dllStopDollars, botStopsActive, stageInputsFor,
|
||||||
maxLossRoom, lastStand as isLastStand, shortestNextLeg,
|
maxLossRoom, lastStand as isLastStand, shortestNextLeg,
|
||||||
LEG_COMPLETION_EPSILON,
|
LEG_COMPLETION_EPSILON,
|
||||||
@@ -19,7 +19,6 @@ export async function GET() {
|
|||||||
try {
|
try {
|
||||||
const firms = getCachedCatalog();
|
const firms = getCachedCatalog();
|
||||||
const clients = getClients();
|
const clients = getClients();
|
||||||
const tradeDate = currentTradeDateCT();
|
|
||||||
|
|
||||||
const state = firms.map((f) => {
|
const state = firms.map((f) => {
|
||||||
const client = clients.get(f.id);
|
const client = clients.get(f.id);
|
||||||
@@ -30,6 +29,9 @@ export async function GET() {
|
|||||||
if (!client || client.accountList.length === 0) {
|
if (!client || client.accountList.length === 0) {
|
||||||
return { firmId: f.id, firm: f.name, connected, accounts: [] };
|
return { firmId: f.id, firm: f.name, connected, accounts: [] };
|
||||||
}
|
}
|
||||||
|
// The day the firm's accounts trade on (tradeDateFor), as the
|
||||||
|
// engine plans them — Vest's turns at 20:00 ET, not 5 PM CT.
|
||||||
|
const tradeDate = tradeDateFor(client);
|
||||||
// Accounts whose rules date was set by hand — one read per firm
|
// Accounts whose rules date was set by hand — one read per firm
|
||||||
// rather than one per account.
|
// rather than one per account.
|
||||||
const rulesDateByHand = new Set(listAssignments(f.id)
|
const rulesDateByHand = new Set(listAssignments(f.id)
|
||||||
@@ -251,6 +253,7 @@ export async function GET() {
|
|||||||
completed = cycleCompletion({
|
completed = cycleCompletion({
|
||||||
accountId: acc.id,
|
accountId: acc.id,
|
||||||
rawTradeDate: tradeDate,
|
rawTradeDate: tradeDate,
|
||||||
|
client,
|
||||||
cycleDailyPnL: client.dailyPnL[acc.id],
|
cycleDailyPnL: client.dailyPnL[acc.id],
|
||||||
rawRealizedPnL: cash.realizedPnL,
|
rawRealizedPnL: cash.realizedPnL,
|
||||||
combineCmeHolidays: cfg.combineCmeHolidays,
|
combineCmeHolidays: cfg.combineCmeHolidays,
|
||||||
|
|||||||
+16
-10
@@ -11,9 +11,9 @@ import { getClients } from './clients';
|
|||||||
import { getCachedFirm, getTradableFirms } from './firm-catalog';
|
import { getCachedFirm, getTradableFirms } from './firm-catalog';
|
||||||
import { ensureRulesEvidence, getAccountConfig } from './account-type-resolver';
|
import { ensureRulesEvidence, getAccountConfig } from './account-type-resolver';
|
||||||
import {
|
import {
|
||||||
combineCmeSessions, computeDayPlan, groupLegFraction, pickLegAmount, resolveEffectiveConfig, currentTradeDateCT,
|
combineCmeSessions, computeDayPlan, groupLegFraction, pickLegAmount, resolveEffectiveConfig, tradeDateFor,
|
||||||
obfuscationStopDollars, takeProfitPlan, onTick, percentFee, dllRoom, dllStopDollars, dllViable, stopTicks,
|
obfuscationStopDollars, takeProfitPlan, onTick, percentFee, dllRoom, dllStopDollars, dllViable, stopTicks,
|
||||||
expectedSwapToWin, planLegSize, maxLossRoom, breachDistance,
|
expectedSwapToWin, planLegSize, maxLossRoom, breachDistance, legCloseNote,
|
||||||
isNoTradeTime, isPastSessionClose,
|
isNoTradeTime, isPastSessionClose,
|
||||||
BIG_DAY_THRESHOLD, LEG_COMPLETION_EPSILON, MAX_LEGS_PER_DAY,
|
BIG_DAY_THRESHOLD, LEG_COMPLETION_EPSILON, MAX_LEGS_PER_DAY,
|
||||||
FALLBACK_COMMISSION_PER_CONTRACT, MAX_COMMISSION_PER_CONTRACT, DLL_MIN_STOP,
|
FALLBACK_COMMISSION_PER_CONTRACT, MAX_COMMISSION_PER_CONTRACT, DLL_MIN_STOP,
|
||||||
@@ -1043,7 +1043,10 @@ async function runTradeLocked(
|
|||||||
// operator's pick; 'Auto' is drawn per batch from the batch firm's list.
|
// operator's pick; 'Auto' is drawn per batch from the batch firm's list.
|
||||||
const explicit = futuresLane && symbol !== 'Auto' ? symbol : null;
|
const explicit = futuresLane && symbol !== 'Auto' ? symbol : null;
|
||||||
|
|
||||||
const tradeDate = currentTradeDateCT();
|
// The lane's own day (tradeDateFor): a notional lane is one firm — a
|
||||||
|
// Vest lane's day turns at 20:00 ET, its rows and realized P&L with it —
|
||||||
|
// and the futures lane's clients all keep the Central trade date.
|
||||||
|
const tradeDate = tradeDateFor(open[0]?.client);
|
||||||
const maxConcurrent = maxConcurrentAccounts();
|
const maxConcurrent = maxConcurrentAccounts();
|
||||||
|
|
||||||
// ── Phase 1: the eligible accounts across the lane's firms ──
|
// ── Phase 1: the eligible accounts across the lane's firms ──
|
||||||
@@ -1488,6 +1491,10 @@ async function fireLeg(
|
|||||||
if (!plan.sizing) {
|
if (!plan.sizing) {
|
||||||
throw new Error(`no position size for ${legContract.name} — its recent volatility is unknown`);
|
throw new Error(`no position size for ${legContract.name} — its recent volatility is unknown`);
|
||||||
}
|
}
|
||||||
|
// The pick held the batch's lead leg to its market's close; this
|
||||||
|
// account's leg can size longer (another stage, a leverage cap).
|
||||||
|
const closeNote = legCloseNote(legContract.closesAt, plan.sizing.predictedHoldHours, Date.now());
|
||||||
|
if (closeNote) throw new Error(`${legContract.name}: ${closeNote} — not entering`);
|
||||||
contracts = Math.max(1, Math.round(plan.sizing.notional / stepNotional));
|
contracts = Math.max(1, Math.round(plan.sizing.notional / stepNotional));
|
||||||
if (plan.cap > 0) contracts = Math.min(contracts, Math.max(1, Math.floor(plan.cap / stepNotional)));
|
if (plan.cap > 0) contracts = Math.min(contracts, Math.max(1, Math.floor(plan.cap / stepNotional)));
|
||||||
if ((legContract.maxContracts ?? 0) > 0) contracts = Math.min(contracts, legContract.maxContracts!);
|
if ((legContract.maxContracts ?? 0) > 0) contracts = Math.min(contracts, legContract.maxContracts!);
|
||||||
@@ -1742,7 +1749,8 @@ async function copyTradeLocked(lease: number, lane: string): Promise<unknown[]>
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const tradeDate = currentTradeDateCT();
|
// The lane's own day, as in runTrade.
|
||||||
|
const tradeDate = tradeDateFor(ready[0]?.client);
|
||||||
|
|
||||||
// Collect eligible accounts (same logic as Phase 1 of runTrade)
|
// Collect eligible accounts (same logic as Phase 1 of runTrade)
|
||||||
const eligible: LegItem[] = [];
|
const eligible: LegItem[] = [];
|
||||||
@@ -1860,7 +1868,7 @@ export async function fireManualLeg(accountId: number, action: 'Buy' | 'Sell' |
|
|||||||
const lease = acquireTradeMutex('[manual-leg]', lane);
|
const lease = acquireTradeMutex('[manual-leg]', lane);
|
||||||
if (lease === null) throw new Error('another batch is in flight in this lane');
|
if (lease === null) throw new Error('another batch is in flight in this lane');
|
||||||
try {
|
try {
|
||||||
const tradeDate = currentTradeDateCT();
|
const tradeDate = tradeDateFor(client);
|
||||||
const ev = evaluateAccountForTrade(client, acc, tradeDate);
|
const ev = evaluateAccountForTrade(client, acc, tradeDate);
|
||||||
if (!ev || !ev.plan) throw new Error(ev?.reason || 'account has no account type');
|
if (!ev || !ev.plan) throw new Error(ev?.reason || 'account has no account type');
|
||||||
if (!ev.eligible && !ev.reason.startsWith('already traded today')) throw new Error(ev.reason);
|
if (!ev.eligible && !ev.reason.startsWith('already traded today')) throw new Error(ev.reason);
|
||||||
@@ -1986,7 +1994,6 @@ async function runClaims(lane: string, ready: FirmClient[]): Promise<void> {
|
|||||||
const lease = acquireTradeMutex('[auto-claim]', lane);
|
const lease = acquireTradeMutex('[auto-claim]', lane);
|
||||||
if (lease === null) return;
|
if (lease === null) return;
|
||||||
try {
|
try {
|
||||||
const tradeDate = currentTradeDateCT();
|
|
||||||
for (const { firm, client, acc } of due) {
|
for (const { firm, client, acc } of due) {
|
||||||
if (!holdsTradeMutex(lease, lane)) return;
|
if (!holdsTradeMutex(lease, lane)) return;
|
||||||
// Settled since, or a different amount now: claim what is due now.
|
// Settled since, or a different amount now: claim what is due now.
|
||||||
@@ -1996,7 +2003,7 @@ async function runClaims(lane: string, ready: FirmClient[]): Promise<void> {
|
|||||||
// In flight while it claims: a batch that takes over a wedged
|
// In flight while it claims: a batch that takes over a wedged
|
||||||
// lane, or a Claim now, leaves the account alone.
|
// lane, or a Claim now, leaves the account alone.
|
||||||
const { claimed, received } = await withAccountInFlight(acc.id, () => client.claimProfit!(acc.id, amount));
|
const { claimed, received } = await withAccountInFlight(acc.id, () => client.claimProfit!(acc.id, amount));
|
||||||
afterClaim('[auto-claim]', firm, client, acc, claimed, received, tradeDate);
|
afterClaim('[auto-claim]', firm, client, acc, claimed, received, tradeDateFor(client));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
(runtime().claimFailedAt ??= new Map()).set(acc.id, Date.now());
|
(runtime().claimFailedAt ??= new Map()).set(acc.id, Date.now());
|
||||||
console.error(`[auto-claim] ${firm.name} ${acc.name}: claim of $${Math.floor(amount * 100) / 100} failed — ${errorMessage(err)}; retrying in ${CLAIM_RETRY_MS / 60_000}m`);
|
console.error(`[auto-claim] ${firm.name} ${acc.name}: claim of $${Math.floor(amount * 100) / 100} failed — ${errorMessage(err)}; retrying in ${CLAIM_RETRY_MS / 60_000}m`);
|
||||||
@@ -2130,7 +2137,7 @@ export async function claimAccountNow(accountId: number, amount: number): Promis
|
|||||||
if (!(amount >= 1)) throw new ClaimRefusedError(`$${amount} is below the $1 minimum claim`);
|
if (!(amount >= 1)) throw new ClaimRefusedError(`$${amount} is below the $1 minimum claim`);
|
||||||
if (amount > c.amount + 0.005) throw new ClaimRefusedError(`only $${c.amount.toFixed(2)} is claimable now`);
|
if (amount > c.amount + 0.005) throw new ClaimRefusedError(`only $${c.amount.toFixed(2)} is claimable now`);
|
||||||
const { claimed, received } = await withAccountInFlight(c.acc.id, () => c.client.claimProfit!(c.acc.id, amount));
|
const { claimed, received } = await withAccountInFlight(c.acc.id, () => c.client.claimProfit!(c.acc.id, amount));
|
||||||
const stage = afterClaim('[claim-now]', c.firm, c.client, c.acc, claimed, received, currentTradeDateCT());
|
const stage = afterClaim('[claim-now]', c.firm, c.client, c.acc, claimed, received, tradeDateFor(c.client));
|
||||||
return { claimed, received, stage };
|
return { claimed, received, stage };
|
||||||
} finally {
|
} finally {
|
||||||
releaseTradeMutex(lease, lane);
|
releaseTradeMutex(lease, lane);
|
||||||
@@ -2142,7 +2149,6 @@ export async function claimAccountNow(accountId: number, amount: number): Promis
|
|||||||
/** Returns true if any configured account could still trade today (eligible for a next leg / extra-day, or has an open position). */
|
/** Returns true if any configured account could still trade today (eligible for a next leg / extra-day, or has an open position). */
|
||||||
function hasRemainingConfiguredAccounts(): boolean {
|
function hasRemainingConfiguredAccounts(): boolean {
|
||||||
const { ready, unready } = firmClients();
|
const { ready, unready } = firmClients();
|
||||||
const tradeDate = currentTradeDateCT();
|
|
||||||
|
|
||||||
// A client that isn't ready can't say whether its accounts are done — its
|
// A client that isn't ready can't say whether its accounts are done — its
|
||||||
// snapshot is missing or stale — so while one might hold any of ours, the
|
// snapshot is missing or stale — so while one might hold any of ours, the
|
||||||
@@ -2175,7 +2181,7 @@ function hasRemainingConfiguredAccounts(): boolean {
|
|||||||
// Order being placed right now = still in play (evaluation calls it ineligible)
|
// Order being placed right now = still in play (evaluation calls it ineligible)
|
||||||
if (runtime().accountsInFlight.has(acc.id)) return true;
|
if (runtime().accountsInFlight.has(acc.id)) return true;
|
||||||
|
|
||||||
const ev = evaluateAccountForTrade(client, acc, tradeDate);
|
const ev = evaluateAccountForTrade(client, acc, tradeDateFor(client));
|
||||||
if (ev?.eligible) return true;
|
if (ev?.eligible) return true;
|
||||||
// Waiting on its history (or its day-start balance), not done: it may
|
// Waiting on its history (or its day-start balance), not done: it may
|
||||||
// still owe today's trade — during the session and before it opens
|
// still owe today's trade — during the session and before it opens
|
||||||
|
|||||||
@@ -684,6 +684,15 @@ export function markTrade(
|
|||||||
`).run(t.positionId, t.accountId, t.accountName, t.symbol, t.side, t.quantity, t.openPrice, t.openedAt, Date.now());
|
`).run(t.positionId, t.accountId, t.accountName, t.symbol, t.side, t.quantity, t.openPrice, t.openedAt, Date.now());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Which of these positions this instance's own order opened (markTrade). */
|
||||||
|
export function botOpenedPositions(positionIds: string[]): Set<string> {
|
||||||
|
if (positionIds.length === 0) return new Set();
|
||||||
|
const rows = db.prepare(`SELECT position_id AS positionId FROM trades
|
||||||
|
WHERE opened_by = 'bot' AND position_id IN (${positionIds.map(() => '?').join(', ')})`)
|
||||||
|
.all(...positionIds) as { positionId: string }[];
|
||||||
|
return new Set(rows.map((r) => r.positionId));
|
||||||
|
}
|
||||||
|
|
||||||
/** Vest accounts with a trade the table still shows open (Vest's position
|
/** Vest accounts with a trade the table still shows open (Vest's position
|
||||||
* ids are bare numbers; the fill-built trades below carry a prefix). */
|
* ids are bare numbers; the fill-built trades below carry a prefix). */
|
||||||
export function openTradeAccounts(): { accountId: number; accountName: string }[] {
|
export function openTradeAccounts(): { accountId: number; accountName: string }[] {
|
||||||
|
|||||||
@@ -48,6 +48,11 @@ export interface PlatformContract {
|
|||||||
/** Margin posted per dollar of notional (0.2 = 5:1 leverage), where the
|
/** Margin posted per dollar of notional (0.2 = 5:1 leverage), where the
|
||||||
* broker says — caps a position at available funds ÷ this. */
|
* broker says — caps a position at available funds ÷ this. */
|
||||||
marginRate?: number;
|
marginRate?: number;
|
||||||
|
/** When the instrument's market next closes for long (epoch ms; Vest: a
|
||||||
|
* weekend or holiday, not the maintenance hour), where the platform
|
||||||
|
* says: no leg starts that wouldn't be done by then (clearsClose), each
|
||||||
|
* account's at its own size (fireLeg). */
|
||||||
|
closesAt?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlatformClient {
|
export interface PlatformClient {
|
||||||
@@ -145,6 +150,11 @@ export interface PlatformClient {
|
|||||||
* before the platform's daily reset (resetClearStop).
|
* before the platform's daily reset (resetClearStop).
|
||||||
*/
|
*/
|
||||||
dayGainForRoom?(accountId: number, o: { leg: number; limit: number }): number;
|
dayGainForRoom?(accountId: number, o: { leg: number; limit: number }): number;
|
||||||
|
/** The trade date at `now` (epoch ms) that this platform's daily rows and
|
||||||
|
* realized P&L follow, where its day turns at another hour than the
|
||||||
|
* 5 PM CT roll (Vest: 20:00 ET). The engine plans the accounts and
|
||||||
|
* counts their legs by it (tradeDateFor); absent = currentTradeDateCT. */
|
||||||
|
tradeDate?(now: number): string;
|
||||||
/** Payouts made since the firm's last daily reset — what picks the stage
|
/** Payouts made since the firm's last daily reset — what picks the stage
|
||||||
* of an account type whose stages reset each day. */
|
* of an account type whose stages reset each day. */
|
||||||
sameDayClaims?(accountId: number): number;
|
sameDayClaims?(accountId: number): number;
|
||||||
|
|||||||
+8
-4
@@ -20,7 +20,7 @@ import { getClients } from './clients';
|
|||||||
import { getAccountTypeById, getKeptRuleIdsForFirm, getTradableFirms } from './firm-catalog';
|
import { getAccountTypeById, getKeptRuleIdsForFirm, getTradableFirms } from './firm-catalog';
|
||||||
import { getAccountConfig } from './account-type-resolver';
|
import { getAccountConfig } from './account-type-resolver';
|
||||||
import { getSchedulerStatus } from './auto-trade';
|
import { getSchedulerStatus } from './auto-trade';
|
||||||
import { botStopsActive, combineCmeSessions, computeDayPlan, currentTradeDateCT, completionPlace, cycleCompletion, endedCycleCompletion, LEG_COMPLETION_EPSILON, projectPerfectPath, resolveEffectiveConfig, stageInputsFor } from './trading-logic';
|
import { botStopsActive, combineCmeSessions, computeDayPlan, completionPlace, cycleCompletion, endedCycleCompletion, LEG_COMPLETION_EPSILON, projectPerfectPath, resolveEffectiveConfig, stageInputsFor, tradeDateFor } from './trading-logic';
|
||||||
import type { AccountTypeStats, FirmStats, InstrumentStats, SymbolStats } from './master-protocol';
|
import type { AccountTypeStats, FirmStats, InstrumentStats, SymbolStats } from './master-protocol';
|
||||||
import { accountNextLeg, accountLegShape } from './leg-estimate';
|
import { accountNextLeg, accountLegShape } from './leg-estimate';
|
||||||
import { sizingHoldHours } from './person-size';
|
import { sizingHoldHours } from './person-size';
|
||||||
@@ -33,9 +33,6 @@ function collectFirmStats() {
|
|||||||
// the master's table with rows for firms it does not run.
|
// the master's table with rows for firms it does not run.
|
||||||
const firms = getTradableFirms();
|
const firms = getTradableFirms();
|
||||||
const clients = getClients();
|
const clients = getClients();
|
||||||
// One raw trade date for the whole report: the combined views below roll
|
|
||||||
// it forward, the completion check needs it as is.
|
|
||||||
const rawToday = currentTradeDateCT();
|
|
||||||
// projectPerfectPath's `loops` test: a type whose completed cycle keeps
|
// projectPerfectPath's `loops` test: a type whose completed cycle keeps
|
||||||
// going pays out (a funded account), one that stops just passes.
|
// going pays out (a funded account), one that stops just passes.
|
||||||
const loops = (cfg: { payoutable?: boolean; withdrawalStages: unknown[] }): boolean =>
|
const loops = (cfg: { payoutable?: boolean; withdrawalStages: unknown[] }): boolean =>
|
||||||
@@ -49,6 +46,11 @@ function collectFirmStats() {
|
|||||||
const keptRuleIds = getKeptRuleIdsForFirm(f.id);
|
const keptRuleIds = getKeptRuleIdsForFirm(f.id);
|
||||||
// accounts: [] only once synced (see emptyFirmStats).
|
// accounts: [] only once synced (see emptyFirmStats).
|
||||||
if (!client || client.accountList.length === 0) return emptyFirmStats(f, client, keptRuleIds);
|
if (!client || client.accountList.length === 0) return emptyFirmStats(f, client, keptRuleIds);
|
||||||
|
// One raw trade date per firm, the one its accounts trade on
|
||||||
|
// (tradeDateFor — Vest's turns at 20:00 ET, not 5 PM CT): the
|
||||||
|
// combined views below roll it forward, the completion check needs
|
||||||
|
// it as is.
|
||||||
|
const rawToday = tradeDateFor(client);
|
||||||
|
|
||||||
let totalAccounts = 0;
|
let totalAccounts = 0;
|
||||||
let accountsTraded = 0;
|
let accountsTraded = 0;
|
||||||
@@ -282,6 +284,7 @@ function collectFirmStats() {
|
|||||||
const ended = endedCycleCompletion({
|
const ended = endedCycleCompletion({
|
||||||
accountId: acc.id,
|
accountId: acc.id,
|
||||||
rawTradeDate: rawToday,
|
rawTradeDate: rawToday,
|
||||||
|
client,
|
||||||
fullDailyPnL: client.fullDailyPnL[acc.id] ?? [],
|
fullDailyPnL: client.fullDailyPnL[acc.id] ?? [],
|
||||||
cycleDailyPnL: client.dailyPnL[acc.id],
|
cycleDailyPnL: client.dailyPnL[acc.id],
|
||||||
fundTransactions: fundTxns,
|
fundTransactions: fundTxns,
|
||||||
@@ -325,6 +328,7 @@ function collectFirmStats() {
|
|||||||
const completion = cycleCompletion({
|
const completion = cycleCompletion({
|
||||||
accountId: acc.id,
|
accountId: acc.id,
|
||||||
rawTradeDate: rawToday,
|
rawTradeDate: rawToday,
|
||||||
|
client,
|
||||||
cycleDailyPnL: client.dailyPnL[acc.id],
|
cycleDailyPnL: client.dailyPnL[acc.id],
|
||||||
rawRealizedPnL: cash.realizedPnL,
|
rawRealizedPnL: cash.realizedPnL,
|
||||||
combineCmeHolidays: cfg.combineCmeHolidays,
|
combineCmeHolidays: cfg.combineCmeHolidays,
|
||||||
|
|||||||
+48
-11
@@ -165,6 +165,18 @@ export function currentTradeDateCT(now: Date = new Date()): string {
|
|||||||
return new Date(date).toISOString().slice(0, 10);
|
return new Date(date).toISOString().slice(0, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The trade date a client's accounts are planned on and count their legs by:
|
||||||
|
* the platform's own day where its daily rows and realized P&L follow one
|
||||||
|
* that turns at another hour (Vest: 20:00 ET, two hours after the 5 PM CT
|
||||||
|
* roll), else the Central trade date. Between the two rolls the Central date
|
||||||
|
* would count the platform's still-open day both as a prior day and as
|
||||||
|
* today's realized P&L, and start the day's leg count over mid-day.
|
||||||
|
*/
|
||||||
|
export function tradeDateFor(client: { tradeDate?: (now: number) => string } | null | undefined, now: Date = new Date()): string {
|
||||||
|
return typeof client?.tradeDate === 'function' ? client.tradeDate(now.getTime()) : currentTradeDateCT(now);
|
||||||
|
}
|
||||||
|
|
||||||
/** Drain-day target grid: $4–$15 in $0.50 steps (23 values, MNQ's
|
/** Drain-day target grid: $4–$15 in $0.50 steps (23 values, MNQ's
|
||||||
* per-contract tick), always traded on MNQ. Drain days qualify at ≥
|
* per-contract tick), always traded on MNQ. Drain days qualify at ≥
|
||||||
* DRAIN_DAY_MIN, so every draw counts. Seeded per (account, trade date) like
|
* DRAIN_DAY_MIN, so every draw counts. Seeded per (account, trade date) like
|
||||||
@@ -672,8 +684,9 @@ export type CycleCompletion = 'none' | 'today' | 'before';
|
|||||||
|
|
||||||
/** How long a new trade date waits before it can read 'today': the platforms
|
/** How long a new trade date waits before it can read 'today': the platforms
|
||||||
* reset realizedPnL on their own clock (a Tradovate cashBalance event, the
|
* reset realizedPnL on their own clock (a Tradovate cashBalance event, the
|
||||||
* TopstepX 60s sync), not at 5 PM sharp, and until then the last session's
|
* TopstepX 60s sync, Vest's next sync after 20:00 ET), not at the roll
|
||||||
* number — already booked under its own date — would count as a second day. */
|
* sharp, and until then the last session's 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
|
/** How long after a fund transaction starts a new cycle the cycle it ended is
|
||||||
@@ -704,13 +717,14 @@ function cycleOwesNothing(
|
|||||||
* weekend (it is the last session's stale number, already booked under its
|
* 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
|
* 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
|
* a weekend date, which would count it twice — and the first minutes after
|
||||||
* the 5 PM roll. An every-day firm's weekends are real sessions, so only the
|
* the roll: the client's own (tradeDateFor), else 5 PM CT. An every-day
|
||||||
* roll window applies to it. */
|
* firm's weekends are real sessions, so only the roll window applies to it. */
|
||||||
function sessionDoubtful(
|
function sessionDoubtful(
|
||||||
rawTradeDate: string,
|
rawTradeDate: string,
|
||||||
cycleDailyPnL: { date: string; pnl: number }[],
|
cycleDailyPnL: { date: string; pnl: number }[],
|
||||||
now: Date,
|
now: Date,
|
||||||
everyDay = false,
|
everyDay = false,
|
||||||
|
client?: { tradeDate?: (now: number) => string },
|
||||||
): boolean {
|
): boolean {
|
||||||
const day = new Date(`${rawTradeDate}T00:00:00Z`);
|
const day = new Date(`${rawTradeDate}T00:00:00Z`);
|
||||||
const dow = day.getUTCDay();
|
const dow = day.getUTCDay();
|
||||||
@@ -721,7 +735,7 @@ function sessionDoubtful(
|
|||||||
if (cycleDailyPnL.some((d) => d.date === saturday || d.date === sunday)) return true;
|
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.
|
// Settle window: a few minutes ago it was still an earlier trade date.
|
||||||
return currentTradeDateCT(new Date(now.getTime() - ROLL_SETTLE_MS)) < rawTradeDate;
|
return tradeDateFor(client, new Date(now.getTime() - ROLL_SETTLE_MS)) < rawTradeDate;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -734,14 +748,18 @@ function sessionDoubtful(
|
|||||||
* 'none' instead: a weekend (realizedPnL is the last session's stale number,
|
* 'none' instead: a weekend (realizedPnL is the last session's stale number,
|
||||||
* already booked under its own date), a Monday with a weekend-dated row — the
|
* 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
|
* live session filed under a weekend date, which would count it twice — and
|
||||||
* the first minutes after the 5 PM roll. 'before' skips those guards: it only
|
* the first minutes after the roll. 'before' skips those guards: it only
|
||||||
* keeps a locked, untraded pass out of the reporter's losses.
|
* keeps a locked, untraded pass out of the reporter's losses.
|
||||||
*/
|
*/
|
||||||
export function cycleCompletion(opts: {
|
export function cycleCompletion(opts: {
|
||||||
accountId: number;
|
accountId: number;
|
||||||
/** currentTradeDateCT() — NOT the combined date, which rolls a weekend
|
/** tradeDateFor(client) — NOT the combined date, which rolls a weekend
|
||||||
* forward to Monday. */
|
* forward to Monday. On a platform whose day turns later than 5 PM CT
|
||||||
|
* (Vest), the Central date would count its open day twice. */
|
||||||
rawTradeDate: string;
|
rawTradeDate: string;
|
||||||
|
/** The account's client: the settle window follows its day's roll
|
||||||
|
* (tradeDateFor). */
|
||||||
|
client?: { tradeDate?: (now: number) => string };
|
||||||
/** Current-cycle daily P&L as the client holds it (client.dailyPnL). */
|
/** Current-cycle daily P&L as the client holds it (client.dailyPnL). */
|
||||||
cycleDailyPnL: { date: string; pnl: number }[];
|
cycleDailyPnL: { date: string; pnl: number }[];
|
||||||
rawRealizedPnL: number;
|
rawRealizedPnL: number;
|
||||||
@@ -764,7 +782,7 @@ export function cycleCompletion(opts: {
|
|||||||
const prior = view.dailyPnL.filter((d) => d.date !== view.tradeDate);
|
const prior = view.dailyPnL.filter((d) => d.date !== view.tradeDate);
|
||||||
|
|
||||||
if (cycleOwesNothing(accountId, eff, prior, view.tradeDate, equityProfit, view.realizedPnL, splitEnabled)) return 'before';
|
if (cycleOwesNothing(accountId, eff, prior, view.tradeDate, equityProfit, view.realizedPnL, splitEnabled)) return 'before';
|
||||||
if (sessionDoubtful(rawTradeDate, opts.cycleDailyPnL, opts.now ?? new Date(), everyDay)) return 'none';
|
if (sessionDoubtful(rawTradeDate, opts.cycleDailyPnL, opts.now ?? new Date(), everyDay, opts.client)) return 'none';
|
||||||
|
|
||||||
const withToday = [...prior, { date: view.tradeDate, pnl: view.realizedPnL }];
|
const withToday = [...prior, { date: view.tradeDate, pnl: view.realizedPnL }];
|
||||||
const probe = nextTradingDate(view.tradeDate, everyDay);
|
const probe = nextTradingDate(view.tradeDate, everyDay);
|
||||||
@@ -825,8 +843,10 @@ export function completionPlace(o: {
|
|||||||
*/
|
*/
|
||||||
export function endedCycleCompletion(opts: {
|
export function endedCycleCompletion(opts: {
|
||||||
accountId: number;
|
accountId: number;
|
||||||
/** currentTradeDateCT(). */
|
/** tradeDateFor(client). */
|
||||||
rawTradeDate: string;
|
rawTradeDate: string;
|
||||||
|
/** As in cycleCompletion. */
|
||||||
|
client?: { tradeDate?: (now: number) => string };
|
||||||
/** Every cycle's daily P&L, raw (client.fullDailyPnL). */
|
/** Every cycle's daily P&L, raw (client.fullDailyPnL). */
|
||||||
fullDailyPnL: { date: string; pnl: number }[];
|
fullDailyPnL: { date: string; pnl: number }[];
|
||||||
/** Current-cycle daily P&L, raw (client.dailyPnL). */
|
/** Current-cycle daily P&L, raw (client.dailyPnL). */
|
||||||
@@ -855,7 +875,7 @@ export function endedCycleCompletion(opts: {
|
|||||||
if (lastFundDate === null || start === null) return null;
|
if (lastFundDate === null || start === null) return null;
|
||||||
const ageDays = (Date.parse(`${rawTradeDate}T00:00:00Z`) - Date.parse(`${lastFundDate}T00:00:00Z`)) / (24 * 60 * 60 * 1000);
|
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 (!(ageDays <= ENDED_CYCLE_WINDOW_DAYS)) return null; // NaN included: an unparseable date
|
||||||
if (sessionDoubtful(rawTradeDate, opts.cycleDailyPnL, opts.now ?? new Date(), everyDay)) return null;
|
if (sessionDoubtful(rawTradeDate, opts.cycleDailyPnL, opts.now ?? new Date(), everyDay, opts.client)) return null;
|
||||||
|
|
||||||
const latest = (a: string, b: string) => (b > a ? b : a);
|
const latest = (a: string, b: string) => (b > a ? b : a);
|
||||||
const earlierFunds = fundTransactions.filter((f) => f.date < lastFundDate);
|
const earlierFunds = fundTransactions.filter((f) => f.date < lastFundDate);
|
||||||
@@ -1413,6 +1433,23 @@ export function resetClearStop(o: { leg: number; resetInMs: number; holdHours: n
|
|||||||
return (o.leg * (c + Math.sqrt(c * c - 4))) / 2;
|
return (o.leg * (c + Math.sqrt(c * c - 4))) / 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether a leg of `holdHours` starting now is all but sure to be done
|
||||||
|
* before its market closes `closeInMs` from now: the RESET_CLEAR_HOLDS
|
||||||
|
* holds a daily reset is given (resetClearStop). A leg still open at the
|
||||||
|
* close can't be exited until the market reopens, and meets the reopen's
|
||||||
|
* gap with its stop. */
|
||||||
|
export function clearsClose(o: { closeInMs: number; holdHours: number }): boolean {
|
||||||
|
return o.closeInMs >= RESET_CLEAR_HOLDS * o.holdHours * 3_600_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Why a leg of `holdHours` starting at `now` doesn't open: its market
|
||||||
|
* closes at `closesAt` (epoch ms) before it clears (clearsClose). null when
|
||||||
|
* it clears, or no close is known (a futures contract). */
|
||||||
|
export function legCloseNote(closesAt: number | null | undefined, holdHours: number, now: number): string | null {
|
||||||
|
if (closesAt === null || closesAt === undefined || clearsClose({ closeInMs: closesAt - now, holdHours })) return null;
|
||||||
|
return `closes in ${((closesAt - now) / 3_600_000).toFixed(1)}h, under ${RESET_CLEAR_HOLDS} holds of ${holdHours.toFixed(1)}h`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* dllRoom where the platform states its daily-loss floor itself (Vest: the
|
* dllRoom where the platform states its daily-loss floor itself (Vest: the
|
||||||
* equity the account may not fall below today, already moved by the day's
|
* equity the account may not fall below today, already moved by the day's
|
||||||
|
|||||||
+179
-10
@@ -38,11 +38,11 @@
|
|||||||
|
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import axios from './http-client';
|
import axios from './http-client';
|
||||||
import { currentTradeDateCT, rankForTarget, dllReserve, resetClearStop } from './trading-logic';
|
import { currentTradeDateCT, rankForTarget, dllReserve, resetClearStop, legCloseNote } from './trading-logic';
|
||||||
import { sizingHoldHours } from './person-size';
|
import { sizingHoldHours } from './person-size';
|
||||||
import {
|
import {
|
||||||
saveDailyPnL, loadDailyPnL, loadAccountMeta, saveAccountMeta, loadFundTransactions, saveFundTransactions,
|
saveDailyPnL, loadDailyPnL, loadAccountMeta, saveAccountMeta, loadFundTransactions, saveFundTransactions,
|
||||||
saveEndedCycleStart, dropFundTransaction, saveTrades, markTrade, getOrCreateSetting, TRADES_SINCE_SETTING, openTradeAccounts,
|
saveEndedCycleStart, dropFundTransaction, saveTrades, markTrade, getOrCreateSetting, TRADES_SINCE_SETTING, openTradeAccounts, botOpenedPositions,
|
||||||
} from './db';
|
} from './db';
|
||||||
import { getCachedFirm } from './firm-catalog';
|
import { getCachedFirm } from './firm-catalog';
|
||||||
import { firmInstruments } from './instruments';
|
import { firmInstruments } from './instruments';
|
||||||
@@ -52,7 +52,7 @@ import { encodeContractName, decodeContractName, roundToStep, scoreCandidate } f
|
|||||||
import { vestSessionFor, dropVestSession, listVestSessions, refuseVestSession } from './vest-session';
|
import { vestSessionFor, dropVestSession, listVestSessions, refuseVestSession } from './vest-session';
|
||||||
import {
|
import {
|
||||||
vestAccountId, vestAccountName, vestDayOf, lastVestResetMs, nextVestResetMs, parseCapitalAccount, parsePosition, parseOpenOrder,
|
vestAccountId, vestAccountName, vestDayOf, lastVestResetMs, nextVestResetMs, parseCapitalAccount, parsePosition, parseOpenOrder,
|
||||||
parseTxn, bookTxns, awaitingLegs, unrealizedPnl, hourlyVolatility, marketOpen, num, toMs, formatDecimal, resetSafeStop,
|
parseTxn, bookTxns, awaitingLegs, unknownOpenFill, unrealizedPnl, hourlyVolatility, marketOpen, longClosure, num, toMs, formatDecimal, resetSafeStop,
|
||||||
parseHistoryPosition, jwtClaimStrings, accountScopedToken, VEST_STATUS_ACTIVE, VEST_STOP_SLIPPAGE_FRACTION,
|
parseHistoryPosition, jwtClaimStrings, accountScopedToken, VEST_STATUS_ACTIVE, VEST_STOP_SLIPPAGE_FRACTION,
|
||||||
VestApiError, VestNotSentError, nothingWentIn, tokenForRequest, settleClaims, readClaims, payoutDaysOf, payoutsSince,
|
VestApiError, VestNotSentError, nothingWentIn, tokenForRequest, settleClaims, readClaims, payoutDaysOf, payoutsSince,
|
||||||
} from './vest-helpers';
|
} from './vest-helpers';
|
||||||
@@ -104,7 +104,8 @@ const NAKED_AFTER_MS = 2 * 60_000;
|
|||||||
* candidate is costed as crossing this many ticks. */
|
* candidate is costed as crossing this many ticks. */
|
||||||
const ASSUMED_SPREAD_TICKS = 1;
|
const ASSUMED_SPREAD_TICKS = 1;
|
||||||
/** How long before the daily reset open positions are fitted to the room
|
/** How long before the daily reset open positions are fitted to the room
|
||||||
* the reset will leave (guardReset). */
|
* the reset will leave (guardReset) — and before a market closure that runs
|
||||||
|
* past one, the bot's own legs are closed (guardClose). */
|
||||||
const RESET_GUARD_MS = 5 * 60_000;
|
const RESET_GUARD_MS = 5 * 60_000;
|
||||||
/** No new position opens this close to the reset: the guard's window, and a
|
/** No new position opens this close to the reset: the guard's window, and a
|
||||||
* couple of minutes before it for an entry in flight to place its exits. */
|
* couple of minutes before it for an entry in flight to place its exits. */
|
||||||
@@ -112,6 +113,9 @@ const RESET_ENTRY_BLOCK_MS = RESET_GUARD_MS + 2 * 60_000;
|
|||||||
/** How long a stop or close the guard just sent counts before a read of
|
/** How long a stop or close the guard just sent counts before a read of
|
||||||
* open positions shows it. */
|
* open positions shows it. */
|
||||||
const GUARD_ECHO_MS = 30_000;
|
const GUARD_ECHO_MS = 30_000;
|
||||||
|
/** An open of unknown outcome is the position opened this close to its send
|
||||||
|
* and its error — the gateway's clock and lag (unknownOpenFill). */
|
||||||
|
const UNKNOWN_OPEN_SLACK_MS = 2 * 60_000;
|
||||||
/** account_meta key of the profit claims this client made, per account. */
|
/** account_meta key of the profit claims this client made, per account. */
|
||||||
const CLAIMS_META = 'vest_claims';
|
const CLAIMS_META = 'vest_claims';
|
||||||
|
|
||||||
@@ -197,6 +201,17 @@ export class VestClient implements PlatformClient {
|
|||||||
noSessionLogged: false, unfittable: new Set<string>(),
|
noSessionLogged: false, unfittable: new Set<string>(),
|
||||||
};
|
};
|
||||||
private resetGuardInFlight = false;
|
private resetGuardInFlight = false;
|
||||||
|
/** guardClose's closes just sent (position → when), the hand trades it
|
||||||
|
* has named (position → the close they were named for), and when it
|
||||||
|
* last said it couldn't read a symbol's hours or had no session. */
|
||||||
|
private closeGuard = {
|
||||||
|
closes: new Map<string, number>(), named: new Map<string, number>(), hoursWarnedAt: new Map<string, number>(), noSessionFor: 0,
|
||||||
|
};
|
||||||
|
private closeGuardInFlight = false;
|
||||||
|
/** Opens whose outcome came back unknown, by account: the position one
|
||||||
|
* made is the bot's once a read shows it (adoptUnknownOpen) — no exits
|
||||||
|
* went on it, so it's the leg guardClose most needs to see. */
|
||||||
|
private unknownOpens = new Map<number, { accountId: string; symbol: string; side: 'long' | 'short'; from: number; until: number }>();
|
||||||
private capital = new Map<number, VestCapitalAccount>();
|
private capital = new Map<number, VestCapitalAccount>();
|
||||||
private positionsDetail = new Map<number, VestPosition[]>();
|
private positionsDetail = new Map<number, VestPosition[]>();
|
||||||
private openOrders = new Map<number, VestOpenOrder[]>();
|
private openOrders = new Map<number, VestOpenOrder[]>();
|
||||||
@@ -409,6 +424,7 @@ export class VestClient implements PlatformClient {
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (this.syncInFlight && now - this.syncInFlightSince >= SYNC_WEDGED_MS) void this.requestSync();
|
if (this.syncInFlight && now - this.syncInFlightSince >= SYNC_WEDGED_MS) void this.requestSync();
|
||||||
void this.guardReset().catch((err) => console.error(`${this.tag} reset guard failed: ${errorMessage(err)}`));
|
void this.guardReset().catch((err) => console.error(`${this.tag} reset guard failed: ${errorMessage(err)}`));
|
||||||
|
void this.guardClose().catch((err) => console.error(`${this.tag} market-close guard failed: ${errorMessage(err)}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── public market data ───────────────────────────────────────────────────
|
// ── public market data ───────────────────────────────────────────────────
|
||||||
@@ -579,6 +595,7 @@ export class VestClient implements PlatformClient {
|
|||||||
this.positionsDetail.set(id, mine);
|
this.positionsDetail.set(id, mine);
|
||||||
this.openOrders.set(id, orders.filter((o) => o.accountId === a.id));
|
this.openOrders.set(id, orders.filter((o) => o.accountId === a.id));
|
||||||
this.applyPosition(acc, mine);
|
this.applyPosition(acc, mine);
|
||||||
|
this.adoptUnknownOpen(id, mine, startedAt);
|
||||||
|
|
||||||
const amount = free.get(a.id);
|
const amount = free.get(a.id);
|
||||||
if (amount === undefined) { complete = false; continue; }
|
if (amount === undefined) { complete = false; continue; }
|
||||||
@@ -718,6 +735,12 @@ export class VestClient implements PlatformClient {
|
|||||||
return this.dailyFloorOf(a, cash.amount, Date.now()) ?? null;
|
return this.dailyFloorOf(a, cash.amount, Date.now()) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** See PlatformClient: Vest's own day (vestDayOf, 20:00 ET to 20:00 ET),
|
||||||
|
* which its daily rows, realized P&L (realizedSince) and floors follow. */
|
||||||
|
public tradeDate(now: number): string {
|
||||||
|
return vestDayOf(now);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* See PlatformClient: the gain since the day's base that is still in the
|
* See PlatformClient: the gain since the day's base that is still in the
|
||||||
* account — since this client's own claim today, else since the reset,
|
* account — since this client's own claim today, else since the reset,
|
||||||
@@ -728,7 +751,7 @@ export class VestClient implements PlatformClient {
|
|||||||
*/
|
*/
|
||||||
public dayGainForRoom(accountId: number, o: { leg: number; limit: number }): number {
|
public dayGainForRoom(accountId: number, o: { leg: number; limit: number }): number {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const hold = sizingHoldHours(getCachedFirm(this.cfg.firmId));
|
const hold = sizingHoldHours(getCachedFirm(this.cfg.firmId), this.tradeDate(now));
|
||||||
const allowed = resetClearStop({ leg: o.leg, resetInMs: nextVestResetMs(now) - now, holdHours: hold }) - o.limit;
|
const allowed = resetClearStop({ leg: o.leg, resetInMs: nextVestResetMs(now) - now, holdHours: hold }) - o.limit;
|
||||||
if (!(allowed > 0)) return 0;
|
if (!(allowed > 0)) return 0;
|
||||||
const a = this.capital.get(accountId);
|
const a = this.capital.get(accountId);
|
||||||
@@ -1066,6 +1089,124 @@ export class VestClient implements PlatformClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Before a long market closure — one that keeps a symbol's market shut
|
||||||
|
* across an 8 PM ET reset: the weekend, a holiday (longClosure) — close
|
||||||
|
* the bot's own legs in it still open in the last RESET_GUARD_MS, while
|
||||||
|
* the market takes orders. Inside the closure nothing exits, the reset
|
||||||
|
* guard's stops and closes are refused, and the reopen's gap jumps a
|
||||||
|
* stop. No leg starts under RESET_CLEAR_HOLDS holds of a close
|
||||||
|
* (pickCandidate), so this only meets one that outlived them.
|
||||||
|
*
|
||||||
|
* Only positions the bot's own order opened (markOwn, or an open of
|
||||||
|
* unknown outcome that turned out filled: adoptUnknownOpen): one without
|
||||||
|
* that mark — a hand trade, or one from before marking began — is left
|
||||||
|
* as it is and named once in the log. The daily maintenance hour reopens
|
||||||
|
* before the reset, and is ridden.
|
||||||
|
*/
|
||||||
|
private async guardClose(): Promise<void> {
|
||||||
|
if (!this.syncComplete || this.intentionalDisconnect || this.closeGuardInFlight) return;
|
||||||
|
const held = [...new Set([...this.positionsDetail.values()].flat().map((p) => p.symbol))];
|
||||||
|
if (held.length === 0) return;
|
||||||
|
this.closeGuardInFlight = true;
|
||||||
|
try {
|
||||||
|
const guard = this.closeGuard;
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [id, at] of guard.closes) if (now - at >= GUARD_ECHO_MS) guard.closes.delete(id);
|
||||||
|
for (const [id, closesAt] of guard.named) if (closesAt < now) guard.named.delete(id);
|
||||||
|
// This runs all day while anything is open: hours it can't read
|
||||||
|
// are said once in a while, not every tick.
|
||||||
|
const unreadable = (what: string, err: unknown) => {
|
||||||
|
if (now - (guard.hoursWarnedAt.get(what) ?? 0) < MARKET_HOURS_TTL_MS) return;
|
||||||
|
guard.hoursWarnedAt.set(what, now);
|
||||||
|
console.warn(`${this.tag} ${what}: market hours unreadable — can't tell whether its market is about to close (${errorMessage(err)})`);
|
||||||
|
};
|
||||||
|
let specs: Map<string, SymbolSpec>;
|
||||||
|
try {
|
||||||
|
specs = await this.symbolSpecs();
|
||||||
|
} catch (err) {
|
||||||
|
unreadable(held.join(', '), err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Each held symbol whose long closure is this near, and when.
|
||||||
|
const closing = new Map<string, number>();
|
||||||
|
for (const symbol of held) {
|
||||||
|
const spec = specs.get(symbol);
|
||||||
|
if (!spec) continue;
|
||||||
|
let periods: unknown;
|
||||||
|
try {
|
||||||
|
periods = await this.marketHours(spec.assetId);
|
||||||
|
} catch (err) {
|
||||||
|
unreadable(symbol, err);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const hours = marketOpen(periods, now);
|
||||||
|
if (hours.open && hours.closesAt !== null && hours.closesAt - now <= RESET_GUARD_MS && longClosure(periods, hours.closesAt)) {
|
||||||
|
closing.set(symbol, hours.closesAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (closing.size === 0) return;
|
||||||
|
if (!vestSessionFor(this.wallet, now, USER_TOKEN_MIN_MS)) {
|
||||||
|
const first = Math.min(...closing.values());
|
||||||
|
if (guard.noSessionFor !== first) {
|
||||||
|
guard.noSessionFor = first;
|
||||||
|
console.error(`${this.tag} the market closes in ${Math.ceil((first - now) / 60_000)} min until past an 8 PM ET reset, and the bot's open legs can't be closed — no Vest session (open the Vest tab)`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const readAt = Date.now();
|
||||||
|
const { data } = await this.request<{ positions?: unknown[] }>('trade', 'GET', '/v3/positions/opened');
|
||||||
|
const all = (data?.positions ?? []).map(parsePosition).filter((p): p is VestPosition => p !== null);
|
||||||
|
for (const id of [...this.unknownOpens.keys()]) this.adoptUnknownOpen(id, all, readAt);
|
||||||
|
const open = all.filter((p) => closing.has(p.symbol));
|
||||||
|
const mine = botOpenedPositions(open.map((p) => p.positionId));
|
||||||
|
for (const acc of this.accountList) {
|
||||||
|
const uuid = this.uuidById.get(acc.id);
|
||||||
|
if (!uuid) continue;
|
||||||
|
for (const pos of open.filter((p) => p.accountId === uuid)) {
|
||||||
|
const closesAt = closing.get(pos.symbol)!;
|
||||||
|
const inMin = Math.max(0, Math.ceil((closesAt - Date.now()) / 60_000));
|
||||||
|
if (!mine.has(pos.positionId)) {
|
||||||
|
if (guard.named.get(pos.positionId) !== closesAt) {
|
||||||
|
guard.named.set(pos.positionId, closesAt);
|
||||||
|
console.error(`${this.tag} ${acc.name}: ${pos.side} ${pos.symbol} isn't marked as the bot's (a hand trade, or a bot leg from before marking or a restart mid-entry) and stays open — its market closes in ${inMin} min until past an 8 PM ET reset; close it on Vest if it shouldn't carry the closure and the reopen's gap`);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// A close just sent counts until a read of open positions shows it.
|
||||||
|
if (guard.closes.has(pos.positionId)) continue;
|
||||||
|
try {
|
||||||
|
await this.submit(uuid, '/v3/positions/close', { positionId: pos.positionId, symbol: pos.symbol, orderType: 'market' });
|
||||||
|
guard.closes.set(pos.positionId, Date.now());
|
||||||
|
this.markOwn('closed', acc.id, pos);
|
||||||
|
console.error(`${this.tag} ${acc.name}: CLOSED the bot's ${pos.side} ${pos.quantity} ${pos.symbol} at market — its market closes in ${inMin} min until past an 8 PM ET reset, and nothing could exit it there`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`${this.tag} ${acc.name}: couldn't close the bot's ${pos.symbol} before its market closes in ${inMin} min: ${errorMessage(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.closeGuardInFlight = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An open of unknown outcome that a read (started at `readAt`) shows
|
||||||
|
* filled after all: the position is the bot's (markOwn). One the read
|
||||||
|
* doesn't show by then never filled — a market open is IOC. */
|
||||||
|
private adoptUnknownOpen(accountId: number, positions: VestPosition[], readAt: number): void {
|
||||||
|
const sent = this.unknownOpens.get(accountId);
|
||||||
|
if (!sent) return;
|
||||||
|
const pos = unknownOpenFill(sent, positions);
|
||||||
|
if (pos) {
|
||||||
|
this.unknownOpens.delete(accountId);
|
||||||
|
this.markOwn('opened', accountId, pos);
|
||||||
|
const name = this.accountList.find((a) => a.id === accountId)?.name ?? String(accountId);
|
||||||
|
console.warn(`${this.tag} ${name}: the open of unknown outcome filled after all — ${pos.side} ${pos.quantity} ${pos.symbol} (${pos.positionId}) marked as the bot's`);
|
||||||
|
} else if (readAt > sent.until) {
|
||||||
|
this.unknownOpens.delete(accountId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private settleSuspects(): void {
|
private settleSuspects(): void {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
for (const [id, since] of this.suspectAccounts) {
|
for (const [id, since] of this.suspectAccounts) {
|
||||||
@@ -1505,14 +1646,23 @@ export class VestClient implements PlatformClient {
|
|||||||
|
|
||||||
private async pickCandidate(root: string, symbols: string[], sticky: boolean, ctx?: PickContext): Promise<PlatformContract | null> {
|
private async pickCandidate(root: string, symbols: string[], sticky: boolean, ctx?: PickContext): Promise<PlatformContract | null> {
|
||||||
const firm = getCachedFirm(this.cfg.firmId);
|
const firm = getCachedFirm(this.cfg.firmId);
|
||||||
const targetHold = sizingHoldHours(firm);
|
const targetHold = sizingHoldHours(firm, this.tradeDate(Date.now()));
|
||||||
const specs = await this.symbolSpecs();
|
const specs = await this.symbolSpecs();
|
||||||
const known = symbols.filter((s) => specs.has(s));
|
const known = symbols.filter((s) => specs.has(s));
|
||||||
if (known.length > 0) await this.refreshMarks(known);
|
if (known.length > 0) await this.refreshMarks(known);
|
||||||
const vols = await this.volatility(known);
|
const vols = await this.volatility(known);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
// A leg a long closure (a weekend, a holiday) catches open can't be
|
||||||
|
// exited until the market reopens, and meets the reopen's gap with
|
||||||
|
// its stop: none starts under RESET_CLEAR_HOLDS holds of one — the
|
||||||
|
// daily reset's clearance. The daily maintenance hour reopens before
|
||||||
|
// the reset and is ridden, as futures legs ride CME's daily break.
|
||||||
|
// Such a symbol stays on the board (the dashboard's candidates) but
|
||||||
|
// isn't picked (legCloseNote); fireLeg holds each account's own leg
|
||||||
|
// to it too.
|
||||||
|
const hold = ctx?.targetHoldHours ?? targetHold;
|
||||||
|
|
||||||
const board: { symbol: string; score: number | null; note: string; contract?: PlatformContract }[] = [];
|
const board: { symbol: string; score: number | null; note: string; contract?: PlatformContract; closing?: boolean }[] = [];
|
||||||
// No new position this close to the reset: nothing picked, and the
|
// No new position this close to the reset: nothing picked, and the
|
||||||
// last survey's candidates stay up for the dashboard.
|
// last survey's candidates stay up for the dashboard.
|
||||||
const toReset = nextVestResetMs(now) - now;
|
const toReset = nextVestResetMs(now) - now;
|
||||||
@@ -1526,8 +1676,11 @@ export class VestClient implements PlatformClient {
|
|||||||
const mark = this.marks.get(symbol);
|
const mark = this.marks.get(symbol);
|
||||||
const vol = vols.get(symbol);
|
const vol = vols.get(symbol);
|
||||||
let hours: { open: boolean; closesAt: number | null };
|
let hours: { open: boolean; closesAt: number | null };
|
||||||
|
let gateAt: number | null;
|
||||||
try {
|
try {
|
||||||
hours = marketOpen(await this.marketHours(spec.assetId), now);
|
const periods = await this.marketHours(spec.assetId);
|
||||||
|
hours = marketOpen(periods, now);
|
||||||
|
gateAt = hours.closesAt !== null && longClosure(periods, hours.closesAt) ? hours.closesAt : null;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
board.push({ symbol, score: null, note: `market hours unknown (${errorMessage(err)})` });
|
board.push({ symbol, score: null, note: `market hours unknown (${errorMessage(err)})` });
|
||||||
continue;
|
continue;
|
||||||
@@ -1551,12 +1704,14 @@ export class VestClient implements PlatformClient {
|
|||||||
volatilityPerHour: vol?.vol ?? null,
|
volatilityPerHour: vol?.vol ?? null,
|
||||||
});
|
});
|
||||||
if (score === null) { board.push({ symbol, score: null, note: 'no volatility' }); continue; }
|
if (score === null) { board.push({ symbol, score: null, note: 'no volatility' }); continue; }
|
||||||
|
const closing = legCloseNote(gateAt, hold, now);
|
||||||
board.push({
|
board.push({
|
||||||
symbol,
|
symbol,
|
||||||
score,
|
score,
|
||||||
note: `fee ${(pct * 100).toFixed(2)}bp${vol?.vol ? `, σ ${(vol.vol * Math.sqrt(24) * 100).toFixed(2)}%/day` : ''}`
|
note: `fee ${(pct * 100).toFixed(2)}bp${vol?.vol ? `, σ ${(vol.vol * Math.sqrt(24) * 100).toFixed(2)}%/day` : ''}`
|
||||||
+ (spec.marginRate ? `, ${+(1 / spec.marginRate).toFixed(1)}:1` : '')
|
+ (spec.marginRate ? `, ${+(1 / spec.marginRate).toFixed(1)}:1` : '')
|
||||||
+ (hours.closesAt ? `, open ${((hours.closesAt - now) / 3_600_000).toFixed(1)}h more` : ''),
|
+ (closing ? `, ${closing}` : hours.closesAt ? `, open ${((hours.closesAt - now) / 3_600_000).toFixed(1)}h more` : ''),
|
||||||
|
closing: closing !== null,
|
||||||
contract: {
|
contract: {
|
||||||
id: symbol,
|
id: symbol,
|
||||||
name: encodeContractName(symbol, step),
|
name: encodeContractName(symbol, step),
|
||||||
@@ -1568,16 +1723,21 @@ export class VestClient implements PlatformClient {
|
|||||||
spreadFraction: spread,
|
spreadFraction: spread,
|
||||||
minContracts: 1,
|
minContracts: 1,
|
||||||
marginRate: spec.marginRate || undefined,
|
marginRate: spec.marginRate || undefined,
|
||||||
|
closesAt: gateAt ?? undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let ranked = board.filter((b) => b.contract).sort((a, b) => a.score! - b.score!);
|
let ranked = board.filter((b) => b.contract && !b.closing).sort((a, b) => a.score! - b.score!);
|
||||||
if (ctx) {
|
if (ctx) {
|
||||||
const sized = ranked.flatMap((b) => {
|
const sized = ranked.flatMap((b) => {
|
||||||
const ev = ctx.evaluate(b.contract!);
|
const ev = ctx.evaluate(b.contract!);
|
||||||
if (!ev) { b.note += ', can\'t size'; return []; }
|
if (!ev) { b.note += ', can\'t size'; return []; }
|
||||||
b.note += `, $${Math.round(ev.notional / 1000)}k → ${ev.holdHours.toFixed(1)}h, ~$${Math.round(ev.cost)}`;
|
b.note += `, $${Math.round(ev.notional / 1000)}k → ${ev.holdHours.toFixed(1)}h, ~$${Math.round(ev.cost)}`;
|
||||||
|
// Held short of the target hold (a leverage cap), the leg
|
||||||
|
// runs longer: it has to clear the close as well.
|
||||||
|
const closing = legCloseNote(b.contract!.closesAt, ev.holdHours, now);
|
||||||
|
if (closing) { b.note += `, ${closing}`; return []; }
|
||||||
return [{ b, holdHours: ev.holdHours, cost: ev.cost }];
|
return [{ b, holdHours: ev.holdHours, cost: ev.cost }];
|
||||||
});
|
});
|
||||||
ranked = rankForTarget(sized, ctx.targetHoldHours).map((i) => sized[i].b);
|
ranked = rankForTarget(sized, ctx.targetHoldHours).map((i) => sized[i].b);
|
||||||
@@ -1755,6 +1915,15 @@ export class VestClient implements PlatformClient {
|
|||||||
for (const w of this.fillWaiters.filter((x) => x.orderId === orderNumber)) w.callback(result);
|
for (const w of this.fillWaiters.filter((x) => x.orderId === orderNumber)) w.callback(result);
|
||||||
this.fillWaiters = this.fillWaiters.filter((x) => x.orderId !== orderNumber);
|
this.fillWaiters = this.fillWaiters.filter((x) => x.orderId !== orderNumber);
|
||||||
return result;
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
// An open that may have gone in: no exits will go on it, so the
|
||||||
|
// position it made, once a read shows it, is marked the bot's.
|
||||||
|
if (opening && !isOrderNotPlaced(err)) {
|
||||||
|
this.unknownOpens.set(accountId, {
|
||||||
|
accountId: uuid, symbol, side, from: sentAt - UNKNOWN_OPEN_SLACK_MS, until: Date.now() + UNKNOWN_OPEN_SLACK_MS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
} finally {
|
} finally {
|
||||||
this.pendingFills--;
|
this.pendingFills--;
|
||||||
if (opening) {
|
if (opening) {
|
||||||
|
|||||||
+43
-7
@@ -433,6 +433,20 @@ export function awaitingLegs(p: VestPosition): VestLeg[] {
|
|||||||
return [...p.stopLosses, ...p.takeProfits].filter((l) => l.status === 'awaiting');
|
return [...p.stopLosses, ...p.takeProfits].filter((l) => l.status === 'awaiting');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The position an open of unknown outcome (a timeout, a reset, a 5xx after
|
||||||
|
* the send) made, once a read shows it: on its account, in its symbol and
|
||||||
|
* side, opened between `from` and `until` — the send and the error, widened
|
||||||
|
* for the gateway's clock. A market open is IOC, so a later one isn't it.
|
||||||
|
*/
|
||||||
|
export function unknownOpenFill(
|
||||||
|
sent: { accountId: string; symbol: string; side: 'long' | 'short'; from: number; until: number },
|
||||||
|
positions: VestPosition[],
|
||||||
|
): VestPosition | null {
|
||||||
|
return positions.find((p) => p.accountId === sent.accountId && p.symbol === sent.symbol && p.side === sent.side
|
||||||
|
&& p.openedAt >= sent.from && p.openedAt <= sent.until) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface VestOpenOrder {
|
export interface VestOpenOrder {
|
||||||
orderId: string;
|
orderId: string;
|
||||||
accountId: string;
|
accountId: string;
|
||||||
@@ -706,7 +720,19 @@ export function hourlyVolatility(klines: unknown[]): number | null {
|
|||||||
*/
|
*/
|
||||||
export function marketOpen(periods: unknown, now: number): { open: boolean; closesAt: number | null } {
|
export function marketOpen(periods: unknown, now: number): { open: boolean; closesAt: number | null } {
|
||||||
if (!Array.isArray(periods) || periods.length === 0) return { open: true, closesAt: null };
|
if (!Array.isArray(periods) || periods.length === 0) return { open: true, closesAt: null };
|
||||||
const list = periods.flatMap((p) => {
|
const list = hoursPeriods(periods);
|
||||||
|
const i = list.findIndex((p) => p.from <= now && now < p.to);
|
||||||
|
if (i < 0 || !list[i].open) return { open: false, closesAt: null };
|
||||||
|
// Consecutive open periods (overnight → pre-market → regular …) are one stretch.
|
||||||
|
let end = list[i].to;
|
||||||
|
for (let j = i + 1; j < list.length && list[j].open && list[j].from <= end + 1; j++) end = list[j].to;
|
||||||
|
return { open: true, closesAt: end };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The market-hours periods, read and in order; open = trading, with no
|
||||||
|
* closure (see marketOpen). */
|
||||||
|
function hoursPeriods(periods: unknown[]): { from: number; to: number; open: boolean }[] {
|
||||||
|
return periods.flatMap((p) => {
|
||||||
const r = p as {
|
const r = p as {
|
||||||
fromMs?: unknown; toMs?: unknown;
|
fromMs?: unknown; toMs?: unknown;
|
||||||
state?: { tradingStatus?: unknown; closure?: unknown; price?: { allow?: unknown } };
|
state?: { tradingStatus?: unknown; closure?: unknown; price?: { allow?: unknown } };
|
||||||
@@ -719,10 +745,20 @@ export function marketOpen(periods: unknown, now: number): { open: boolean; clos
|
|||||||
&& (closure === undefined || closure === 'unspecified');
|
&& (closure === undefined || closure === 'unspecified');
|
||||||
return [{ from, to, open }];
|
return [{ from, to, open }];
|
||||||
}).sort((a, b) => a.from - b.from);
|
}).sort((a, b) => a.from - b.from);
|
||||||
const i = list.findIndex((p) => p.from <= now && now < p.to);
|
}
|
||||||
if (i < 0 || !list[i].open) return { open: false, closesAt: null };
|
|
||||||
// Consecutive open periods (overnight → pre-market → regular …) are one stretch.
|
/**
|
||||||
let end = list[i].to;
|
* Whether the closure that starts at `closesAt` (marketOpen's) keeps the
|
||||||
for (let j = i + 1; j < list.length && list[j].open && list[j].from <= end + 1; j++) end = list[j].to;
|
* market shut across one of Vest's 8 PM ET resets — a weekend, a holiday —
|
||||||
return { open: true, closesAt: end };
|
* or for as far as the periods go. A position held into it can't be exited
|
||||||
|
* or fitted to that reset until the market reopens, and meets the reopen's
|
||||||
|
* gap with its stop; the daily maintenance hour reopens before the reset.
|
||||||
|
* A close the periods don't show (they just end there) isn't one.
|
||||||
|
*/
|
||||||
|
export function longClosure(periods: unknown, closesAt: number): boolean {
|
||||||
|
if (!Array.isArray(periods)) return false;
|
||||||
|
const list = hoursPeriods(periods);
|
||||||
|
if (!list.some((p) => p.to > closesAt)) return false;
|
||||||
|
const reopensAt = list.find((p) => p.open && p.from >= closesAt)?.from;
|
||||||
|
return reopensAt === undefined || nextVestResetMs(closesAt) < reopensAt;
|
||||||
}
|
}
|
||||||
|
|||||||
+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-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"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@microsoft/signalr": "^10.0.11",
|
"@microsoft/signalr": "^10.0.11",
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ import {
|
|||||||
projectPerfectPath, cycleCompletion, DLL_RESERVE_FRACTION, DLL_MIN_STOP, notionalCap, expectedSwapToWin, MARGIN_HEADROOM,
|
projectPerfectPath, cycleCompletion, 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,
|
resolveEffectiveConfig, stageInputsFor, floorRoom, resetClearStop, shortestNextLeg, computeDayPlan, clearsClose, RESET_CLEAR_HOLDS,
|
||||||
|
legCloseNote,
|
||||||
} from '../lib/trading-logic.ts';
|
} from '../lib/trading-logic.ts';
|
||||||
|
|
||||||
// Deterministic PRNG so a failure reproduces.
|
// Deterministic PRNG so a failure reproduces.
|
||||||
@@ -527,6 +528,39 @@ test('resetClearStop: the widest stop a leg may carry and still close before the
|
|||||||
assert.ok(resetClearStop({ leg: 500, resetInMs: 16 * h, holdHours: 1.5 }) - 1400 > 2604.24);
|
assert.ok(resetClearStop({ leg: 500, resetInMs: 16 * h, holdHours: 1.5 }) - 1400 > 2604.24);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('clearsClose: no leg starts under four holds of its market\'s close', () => {
|
||||||
|
const h = 3_600_000;
|
||||||
|
assert.equal(RESET_CLEAR_HOLDS, 4);
|
||||||
|
// The weekly close 7.5 h off, legs sized for 2 h: no entry.
|
||||||
|
assert.equal(clearsClose({ closeInMs: 7.5 * h, holdHours: 2 }), false);
|
||||||
|
// Exactly four holds off, or more: the clearance the daily reset gets.
|
||||||
|
assert.equal(clearsClose({ closeInMs: 8 * h, holdHours: 2 }), true);
|
||||||
|
assert.equal(clearsClose({ closeInMs: 8 * h - 1, holdHours: 2 }), false);
|
||||||
|
assert.equal(clearsClose({ closeInMs: 20 * h, holdHours: 2 }), true);
|
||||||
|
// A leg its leverage cap holds to 3.5 h needs 14 h, where a 2 h one is clear.
|
||||||
|
assert.equal(clearsClose({ closeInMs: 10 * h, holdHours: 3.5 }), false);
|
||||||
|
// It agrees with resetClearStop, which gives no stop at all under four holds.
|
||||||
|
for (const off of [5.9, 6, 6.1, 12]) {
|
||||||
|
assert.equal(clearsClose({ closeInMs: off * h, holdHours: 1.5 }), resetClearStop({ leg: 500, resetInMs: off * h, holdHours: 1.5 }) > 0);
|
||||||
|
}
|
||||||
|
// An unknown hold never clears.
|
||||||
|
assert.equal(clearsClose({ closeInMs: 20 * h, holdHours: NaN }), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('legCloseNote: each account\'s leg is held to its market\'s close at its own size', () => {
|
||||||
|
const h = 3_600_000;
|
||||||
|
const now = Date.parse('2026-10-30T12:30:00Z');
|
||||||
|
const close = now + 9 * h;
|
||||||
|
// The batch's lead leg, 2 h: clear. Another account's, sized to 3.5 h: not.
|
||||||
|
assert.equal(legCloseNote(close, 2, now), null);
|
||||||
|
assert.equal(legCloseNote(close, 3.5, now), 'closes in 9.0h, under 4 holds of 3.5h');
|
||||||
|
// A contract that names no close (futures, crypto) never holds a leg back.
|
||||||
|
assert.equal(legCloseNote(undefined, 3.5, now), null);
|
||||||
|
assert.equal(legCloseNote(null, 3.5, now), null);
|
||||||
|
// An unknown hold doesn't open into a known close.
|
||||||
|
assert.notEqual(legCloseNote(close, NaN, now), null);
|
||||||
|
});
|
||||||
|
|
||||||
test('planLegSize: a split day\'s next leg, with the day\'s gain on its stop, sizes up for the same hold', () => {
|
test('planLegSize: a split day\'s next leg, with the day\'s gain on its stop, sizes up for the same hold', () => {
|
||||||
// Dingo's 2026-10-27 evals: up $2,604.24 on a $1,400 day, $2,395.76 to
|
// Dingo's 2026-10-27 evals: up $2,604.24 on a $1,400 day, $2,395.76 to
|
||||||
// pass, NDX at ~$29.5k on Vest (1.31%/day), sized for 1.5 h.
|
// pass, NDX at ~$29.5k on Vest (1.31%/day), sized for 1.5 h.
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
// Checks for the Vest client's own wiring (lib/vest-class.ts), past what its
|
||||||
|
// pure helpers show: the market-close guard closing a leg whose open came back
|
||||||
|
// unknown, and the candidate board keeping a symbol the close gate holds back.
|
||||||
|
//
|
||||||
|
// npm run check
|
||||||
|
//
|
||||||
|
// A scratch database, a fake clock, and every gateway call answered here —
|
||||||
|
// nothing reaches Vest.
|
||||||
|
import { after, test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { registerHooks, stripTypeScriptTypes } from 'node:module';
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The client's classes use parameter properties, which plain type
|
||||||
|
// stripping refuses: transform them instead.
|
||||||
|
function loadTypes(url, context, nextLoad) {
|
||||||
|
if (!url.startsWith('file:') || !url.endsWith('.ts')) return nextLoad(url, context);
|
||||||
|
const source = stripTypeScriptTypes(readFileSync(fileURLToPath(url), 'utf8'), { mode: 'transform' });
|
||||||
|
return { format: 'module', source, shortCircuit: true };
|
||||||
|
}
|
||||||
|
registerHooks({ resolve: resolveTypes, load: loadTypes });
|
||||||
|
|
||||||
|
const directory = mkdtempSync(join(tmpdir(), 'autofirmer-vest-client-'));
|
||||||
|
process.env.AUTOFIRMER_DB = join(directory, 'test.sqlite');
|
||||||
|
delete process.env.NEXT_PHASE;
|
||||||
|
after(() => rmSync(directory, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
// No request leaves this process: axios refuses every one, and each client's
|
||||||
|
// gateway is a function in this file — from construction on, since the
|
||||||
|
// constructor starts a sync.
|
||||||
|
const { default: axios } = await import('../lib/http-client.ts');
|
||||||
|
axios.defaults.adapter = (config) => Promise.reject(new Error(`no network in this test: ${config.method} ${config.url}`));
|
||||||
|
const { VestClient } = await import('../lib/vest-class.ts');
|
||||||
|
let gateway = null;
|
||||||
|
VestClient.prototype.request = async function (bucket, method, path, opts = {}) {
|
||||||
|
if (!gateway) throw new Error(`no gateway in this test: ${method} ${path}`);
|
||||||
|
return gateway(method, path, opts);
|
||||||
|
};
|
||||||
|
const { putVestSession } = await import('../lib/vest-session.ts');
|
||||||
|
const { botOpenedPositions } = await import('../lib/db.ts');
|
||||||
|
const { vestDayOf, VEST_STATUS_ACTIVE } = await import('../lib/vest-helpers.ts');
|
||||||
|
const { encodeContractName } = await import('../lib/dxtrade-helpers.ts');
|
||||||
|
|
||||||
|
const iso = (s) => Date.parse(s);
|
||||||
|
const WALLET = '0x7ce10771819f80130e204ddb5ca1522e48a9b451';
|
||||||
|
|
||||||
|
// The clock the client reads (Date.now), set to an instant and running on.
|
||||||
|
const realNow = Date.now;
|
||||||
|
after(() => { Date.now = realNow; });
|
||||||
|
let tokens = 0;
|
||||||
|
function clockAt(when) {
|
||||||
|
const start = realNow();
|
||||||
|
const t0 = iso(when);
|
||||||
|
Date.now = () => t0 + (realNow() - start);
|
||||||
|
tokens += 1;
|
||||||
|
assert.equal(putVestSession({ accessToken: `eyJ.test${tokens}.sig`, expiresAt: Date.now() + 3_600_000, primaryAddr: WALLET, userId: 'u' }), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The week of NDX hours from check-vest.mjs: maintenance at 21:00 UTC
|
||||||
|
// (17:00 ET) Thursday, the weekly close at 21:00 UTC Friday.
|
||||||
|
const open = (from, to) => ({ fromMs: iso(from), toMs: iso(to), state: { session: 'overnight', closure: 'unspecified', tradingStatus: 'OPEN', price: { allow: true } } });
|
||||||
|
const shut = (from, to, closure) => ({ fromMs: iso(from), toMs: iso(to), state: { session: 'unspecified', closure, tradingStatus: closure === 'weekly' ? 'CLOSE' : 'OPEN', price: { allow: closure !== 'weekly' } } });
|
||||||
|
const week = [
|
||||||
|
open('2026-10-28T22:00:00Z', '2026-10-29T21:00:00Z'),
|
||||||
|
shut('2026-10-29T21:00:00Z', '2026-10-29T22:00:00Z', 'maintenance'),
|
||||||
|
open('2026-10-29T22:00:00Z', '2026-10-30T21:00:00Z'),
|
||||||
|
shut('2026-10-30T21:00:00Z', '2026-11-01T23:00:00Z', 'weekly'),
|
||||||
|
open('2026-11-01T23:00:00Z', '2026-11-02T22:00:00Z'),
|
||||||
|
];
|
||||||
|
const spec = (symbol, assetId) => ({
|
||||||
|
symbol, assetId, sizeDecimals: 3, priceDecimals: 2, tick: 0.01, takerFee: 0.0001, marginRate: 0.05, tradingStatus: 'TRADING', capitalTradingStatus: 'TRADING',
|
||||||
|
});
|
||||||
|
const specs = new Map([['NDX-USD', spec('NDX-USD', 7)], ['BTC-PERP', spec('BTC-PERP', 9)]]);
|
||||||
|
|
||||||
|
/** A client with no sync running, its gateway answered by `answer`. */
|
||||||
|
function testClient(answer) {
|
||||||
|
gateway = null;
|
||||||
|
const client = new VestClient({ firmId: 'vest-test', firmName: 'Vest test', wallet: WALLET }, async () => {});
|
||||||
|
clearInterval(client.syncInterval);
|
||||||
|
clearInterval(client.watchdogInterval);
|
||||||
|
gateway = answer;
|
||||||
|
client.specs = specs;
|
||||||
|
client.symbolSpecs = async () => specs;
|
||||||
|
client.marketHours = async (assetId) => (assetId === 7 ? week : []);
|
||||||
|
client.accountList = [{ id: 1, name: 'V50K-F-D3-1', active: true }, { id: 2, name: 'V50K-F-D3-2', active: true }];
|
||||||
|
client.uuidById.set(1, 'acc-1');
|
||||||
|
client.uuidById.set(2, 'acc-2');
|
||||||
|
client.capital.set(1, { status: VEST_STATUS_ACTIVE });
|
||||||
|
client.capital.set(2, { status: VEST_STATUS_ACTIVE });
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('the market-close guard closes a bot leg whose open came back unknown, not a hand trade', async () => {
|
||||||
|
let positions = [];
|
||||||
|
const closes = [];
|
||||||
|
const client = testClient((method, path, opts) => {
|
||||||
|
if (method === 'GET' && path === '/v3/positions/opened') return { data: { positions } };
|
||||||
|
if (method === 'POST' && path === '/v3/positions/open') throw new Error('socket hang up');
|
||||||
|
if (method === 'POST' && path === '/v3/positions/close') { closes.push({ account: opts.account, ...opts.body }); return { data: {} }; }
|
||||||
|
throw new Error(`no gateway in this test: ${method} ${path}`);
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
// Friday 8:30 AM ET: the entry's send fails in transit, but it filled.
|
||||||
|
clockAt('2026-10-30T12:30:00Z');
|
||||||
|
await assert.rejects(client.sendOrder(1, encodeContractName('NDX-USD', 0.001), 500, 'Buy', 'Market'), /outcome unknown/);
|
||||||
|
const raw = (positionId, accountId, openDate) => ({
|
||||||
|
positionId, accountId, symbol: 'NDX-USD', side: 'LONG', quantity: '0.500', openPrice: '20000', collateral: '500', leverage: '20', openDate,
|
||||||
|
stopLosses: [], takeProfits: [],
|
||||||
|
});
|
||||||
|
positions = [raw('9001', 'acc-1', iso('2026-10-30T12:30:01Z')), raw('9002', 'acc-2', iso('2026-10-30T11:00:00Z'))];
|
||||||
|
client.positionsDetail.set(1, []);
|
||||||
|
client.positionsDetail.set(2, [{ positionId: '9002', accountId: 'acc-2', symbol: 'NDX-USD', side: 'long', quantity: 0.5 }]);
|
||||||
|
client.syncComplete = true;
|
||||||
|
|
||||||
|
// 4:56 PM ET: four minutes to the weekly close.
|
||||||
|
clockAt('2026-10-30T20:56:00Z');
|
||||||
|
await client.guardClose();
|
||||||
|
assert.deepEqual(closes, [{ account: 'acc-1', positionId: '9001', symbol: 'NDX-USD', orderType: 'market' }]);
|
||||||
|
assert.deepEqual([...botOpenedPositions(['9001', '9002'])], ['9001']);
|
||||||
|
// A close just sent isn't sent again while a read may not show it.
|
||||||
|
await client.guardClose();
|
||||||
|
assert.equal(closes.length, 1);
|
||||||
|
} finally {
|
||||||
|
client.disconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a symbol the close gate holds back stays on the board, and isn\'t picked; the maintenance hour is ridden', async () => {
|
||||||
|
const client = testClient((method, path) => {
|
||||||
|
throw new Error(`no gateway in this test: ${method} ${path}`);
|
||||||
|
});
|
||||||
|
client.refreshMarks = async () => {};
|
||||||
|
client.volatility = async (symbols) => new Map(symbols.map((s) => [s, { vol: 0.004, candles: 168 }]));
|
||||||
|
const marked = () => { for (const s of ['NDX-USD', 'BTC-PERP']) client.marks.set(s, { mark: s === 'NDX-USD' ? 20000 : 70000, at: Date.now() }); };
|
||||||
|
try {
|
||||||
|
// Thursday 2 PM ET: NDX's maintenance hour is 3 h off, and reopens
|
||||||
|
// before the reset — no gate.
|
||||||
|
clockAt('2026-10-29T18:00:00Z');
|
||||||
|
marked();
|
||||||
|
await client.pickCandidate('Auto', ['NDX-USD', 'BTC-PERP'], false);
|
||||||
|
const thursday = client.lastCandidates.find((c) => c.id === 'NDX-USD');
|
||||||
|
assert.equal(thursday.closesAt, undefined);
|
||||||
|
|
||||||
|
// Friday 2 PM ET: the weekly close is 3 h off; crypto never closes.
|
||||||
|
clockAt('2026-10-30T18:00:00Z');
|
||||||
|
marked();
|
||||||
|
const pick = await client.pickCandidate('Auto', ['NDX-USD', 'BTC-PERP'], false);
|
||||||
|
assert.equal(pick.id, 'BTC-PERP');
|
||||||
|
assert.equal(pick.closesAt, undefined);
|
||||||
|
assert.deepEqual(client.lastCandidates.map((c) => c.id).sort(), ['BTC-PERP', 'NDX-USD']);
|
||||||
|
const ndx = client.lastCandidates.find((c) => c.id === 'NDX-USD');
|
||||||
|
assert.equal(ndx.closesAt, iso('2026-10-30T21:00:00Z'));
|
||||||
|
// The survey keeps it up for the dashboard, though nothing is picked.
|
||||||
|
assert.equal(await client.pickCandidate('survey', ['NDX-USD'], false), null);
|
||||||
|
assert.deepEqual(client.lastCandidates.map((c) => c.id), ['NDX-USD']);
|
||||||
|
// And the client's day is Vest's.
|
||||||
|
assert.equal(client.tradeDate(iso('2026-10-27T22:30:00Z')), vestDayOf(iso('2026-10-27T22:30:00Z')));
|
||||||
|
} finally {
|
||||||
|
client.disconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
+98
-4
@@ -1,9 +1,10 @@
|
|||||||
// Checks for the Vest client's pure helpers (lib/vest-helpers.ts) and the
|
// Checks for the Vest client's pure helpers (lib/vest-helpers.ts), the
|
||||||
// relayed-session store (lib/vest-session.ts).
|
// relayed-session store (lib/vest-session.ts), and the trade date the engine
|
||||||
|
// plans Vest accounts on (lib/trading-logic.ts).
|
||||||
//
|
//
|
||||||
// npm run check
|
// npm run check
|
||||||
//
|
//
|
||||||
// Both files import only Node built-ins, so Node strips their types and loads
|
// These files import only Node built-ins, so Node strips their types and loads
|
||||||
// them directly; .mjs keeps this file out of Next's type check. The samples are
|
// them directly; .mjs keeps this file out of Next's type check. The samples are
|
||||||
// shaped like the gateway's real answers (captured 2026-10-26).
|
// shaped like the gateway's real answers (captured 2026-10-26).
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
@@ -13,9 +14,10 @@ import {
|
|||||||
vestAccountName, parsePosition, awaitingLegs, parseOpenOrder, unrealizedPnl, parseTxn, bookTxns, txnKind, VEST_TXN,
|
vestAccountName, parsePosition, awaitingLegs, parseOpenOrder, unrealizedPnl, parseTxn, bookTxns, txnKind, VEST_TXN,
|
||||||
hourlyVolatility, marketOpen, MIN_HOURLY_RETURNS, nextVestResetMs, resetSafeStop, parseHistoryPosition, jwtClaimStrings,
|
hourlyVolatility, marketOpen, MIN_HOURLY_RETURNS, nextVestResetMs, resetSafeStop, parseHistoryPosition, jwtClaimStrings,
|
||||||
accountScopedToken, VestApiError, VestNotSentError, nothingWentIn, tokenForRequest, claimWithdrawal, settleClaims,
|
accountScopedToken, VestApiError, VestNotSentError, nothingWentIn, tokenForRequest, claimWithdrawal, settleClaims,
|
||||||
claimFundRows, readClaims, payoutDaysOf, payoutsSince,
|
claimFundRows, readClaims, payoutDaysOf, payoutsSince, longClosure, unknownOpenFill,
|
||||||
} from '../lib/vest-helpers.ts';
|
} from '../lib/vest-helpers.ts';
|
||||||
import { putVestSession, vestSessionFor, dropVestSession, listVestSessions, refuseVestSession } from '../lib/vest-session.ts';
|
import { putVestSession, vestSessionFor, dropVestSession, listVestSessions, refuseVestSession } from '../lib/vest-session.ts';
|
||||||
|
import { computeDayPlan, currentTradeDateCT, tradeDateFor, cycleCompletion } from '../lib/trading-logic.ts';
|
||||||
|
|
||||||
const iso = (s) => Date.parse(s);
|
const iso = (s) => Date.parse(s);
|
||||||
|
|
||||||
@@ -127,6 +129,23 @@ const openPosition = {
|
|||||||
takeProfits: [],
|
takeProfits: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
test('an open of unknown outcome is the position on its account, symbol and side opened around it', () => {
|
||||||
|
const pos = (positionId, accountId, side, openedAt, symbol = 'NDX-USD') => ({
|
||||||
|
positionId, accountId, symbol, side, quantity: 0.5, openPrice: 20000, collateral: 500, leverage: 20, openedAt, stopLosses: [], takeProfits: [],
|
||||||
|
});
|
||||||
|
const sent = { accountId: 'acc-1', symbol: 'NDX-USD', side: 'long', from: iso('2026-10-30T12:28:00Z'), until: iso('2026-10-30T12:32:15Z') };
|
||||||
|
const filled = pos('9001', 'acc-1', 'long', iso('2026-10-30T12:30:01Z'));
|
||||||
|
assert.equal(unknownOpenFill(sent, [pos('9002', 'acc-2', 'long', iso('2026-10-30T12:30:01Z')), filled]), filled);
|
||||||
|
// Another side or symbol, a position from before it, or one opened after
|
||||||
|
// the window (a hand trade later on): not it.
|
||||||
|
assert.equal(unknownOpenFill(sent, [pos('9003', 'acc-1', 'short', iso('2026-10-30T12:30:01Z'))]), null);
|
||||||
|
assert.equal(unknownOpenFill(sent, [pos('9004', 'acc-1', 'long', iso('2026-10-30T12:30:01Z'), 'SPX-USD')]), null);
|
||||||
|
assert.equal(unknownOpenFill(sent, [pos('9005', 'acc-1', 'long', iso('2026-10-30T11:00:00Z'))]), null);
|
||||||
|
assert.equal(unknownOpenFill(sent, [pos('9006', 'acc-1', 'long', iso('2026-10-30T14:00:00Z'))]), null);
|
||||||
|
// No open time: can't be told from a hand trade.
|
||||||
|
assert.equal(unknownOpenFill(sent, [pos('9007', 'acc-1', 'long', NaN)]), null);
|
||||||
|
});
|
||||||
|
|
||||||
test('positions, their legs and unrealized P&L', () => {
|
test('positions, their legs and unrealized P&L', () => {
|
||||||
const p = parsePosition(openPosition);
|
const p = parsePosition(openPosition);
|
||||||
assert.equal(p.side, 'short');
|
assert.equal(p.side, 'short');
|
||||||
@@ -343,6 +362,81 @@ test('market hours: open stretches, closures and crypto', () => {
|
|||||||
assert.deepEqual(marketOpen([], 600), { open: true, closesAt: null });
|
assert.deepEqual(marketOpen([], 600), { open: true, closesAt: null });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('market hours: a closure past an 8 PM ET reset is a long one, the maintenance hour is not', () => {
|
||||||
|
const open = (from, to) => ({ fromMs: iso(from), toMs: iso(to), state: { session: 'overnight', closure: 'unspecified', tradingStatus: 'OPEN', price: { allow: true } } });
|
||||||
|
const shut = (from, to, closure) => ({ fromMs: iso(from), toMs: iso(to), state: { session: 'unspecified', closure, tradingStatus: closure === 'weekly' ? 'CLOSE' : 'OPEN', price: { allow: closure !== 'weekly' } } });
|
||||||
|
const week = [
|
||||||
|
open('2026-10-28T22:00:00Z', '2026-10-29T21:00:00Z'),
|
||||||
|
shut('2026-10-29T21:00:00Z', '2026-10-29T22:00:00Z', 'maintenance'),
|
||||||
|
open('2026-10-29T22:00:00Z', '2026-10-30T21:00:00Z'),
|
||||||
|
shut('2026-10-30T21:00:00Z', '2026-11-01T23:00:00Z', 'weekly'),
|
||||||
|
open('2026-11-01T23:00:00Z', '2026-11-02T22:00:00Z'),
|
||||||
|
shut('2026-11-02T22:00:00Z', '2026-11-02T23:00:00Z', 'maintenance'),
|
||||||
|
open('2026-11-02T23:00:00Z', '2026-11-03T22:00:00Z'),
|
||||||
|
];
|
||||||
|
// The weekend: shut through Friday's and Saturday's 8 PM ET resets.
|
||||||
|
const friday = marketOpen(week, iso('2026-10-30T20:57:00Z'));
|
||||||
|
assert.deepEqual(friday, { open: true, closesAt: iso('2026-10-30T21:00:00Z') });
|
||||||
|
assert.equal(longClosure(week, friday.closesAt), true);
|
||||||
|
// The maintenance hour reopens before the evening's reset, in summer and winter.
|
||||||
|
const thursday = marketOpen(week, iso('2026-10-29T20:57:00Z'));
|
||||||
|
assert.equal(thursday.closesAt, iso('2026-10-29T21:00:00Z'));
|
||||||
|
assert.equal(longClosure(week, thursday.closesAt), false);
|
||||||
|
assert.equal(longClosure(week, iso('2026-11-02T22:00:00Z')), false);
|
||||||
|
// A gap in the periods is shut too (a holiday), and this one outlasts the reset.
|
||||||
|
const early = [open('2026-11-25T14:00:00Z', '2026-11-25T18:00:00Z'), open('2026-11-26T23:00:00Z', '2026-11-27T18:00:00Z')];
|
||||||
|
assert.equal(longClosure(early, iso('2026-11-25T18:00:00Z')), true);
|
||||||
|
// Shut as far as the periods go: long. Periods that just end: no close known.
|
||||||
|
assert.equal(longClosure(week.slice(0, 4), iso('2026-10-30T21:00:00Z')), true);
|
||||||
|
assert.equal(longClosure(week.slice(0, 3), iso('2026-10-30T21:00:00Z')), false);
|
||||||
|
assert.equal(longClosure(undefined, iso('2026-10-30T21:00:00Z')), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a Vest account plans on Vest's day: the 5 PM CT roll doesn't count today's P&L twice", () => {
|
||||||
|
const vest = { tradeDate: vestDayOf };
|
||||||
|
// 3:30 and 5:30 PM CT (4:30 / 6:30 PM ET): the Central date rolls at
|
||||||
|
// 5 PM CT, Vest's day only at 8 PM ET.
|
||||||
|
const before = new Date('2026-10-27T20:30:00Z');
|
||||||
|
const after = new Date('2026-10-27T22:30:00Z');
|
||||||
|
assert.equal(currentTradeDateCT(after), '2026-10-28');
|
||||||
|
assert.equal(tradeDateFor(vest, after), '2026-10-27');
|
||||||
|
assert.equal(tradeDateFor(vest, new Date('2026-10-28T00:00:00Z')), '2026-10-28');
|
||||||
|
// Every other platform keeps the Central date.
|
||||||
|
assert.equal(tradeDateFor(undefined, after), '2026-10-28');
|
||||||
|
assert.equal(tradeDateFor({}, after), '2026-10-28');
|
||||||
|
// $3,000 to go, 3 min days of $150, $800 made today: Vest's row for its
|
||||||
|
// day, and the realized P&L since its reset.
|
||||||
|
const rows = [{ date: '2026-10-27', pnl: 800 }];
|
||||||
|
const plan = (date) => computeDayPlan(7, date, 3000, 0, rows, 150, 3, 800, 800);
|
||||||
|
assert.equal(plan(currentTradeDateCT(before)).remainingToday, 1900);
|
||||||
|
assert.deepEqual(plan(tradeDateFor(vest, after)), plan(currentTradeDateCT(before)));
|
||||||
|
// On the Central date the $800 is a prior min day and today's P&L both.
|
||||||
|
assert.equal(plan(currentTradeDateCT(after)).remainingToday, 2050);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a Vest account's completion is judged on Vest's day, and waits out Vest's own roll", () => {
|
||||||
|
const vest = { tradeDate: vestDayOf };
|
||||||
|
// $3,000 in 2 min days: day one made $3,100 and is flat. At 5:30 PM CT
|
||||||
|
// its row is booked and its P&L is still today's realized P&L.
|
||||||
|
const eff = { profitTarget: 3000, consistency: 0, minTradingDays: 2, minDayPnL: 0, drain: false, dailyLossLimit: 0 };
|
||||||
|
const opts = {
|
||||||
|
accountId: 7, cycleDailyPnL: [{ date: '2026-10-27', pnl: 3100 }], rawRealizedPnL: 3100, combineCmeHolidays: false,
|
||||||
|
tradesEveryDay: true, effective: eff, equityProfit: 3100, splitEnabled: true, client: vest,
|
||||||
|
};
|
||||||
|
const evening = new Date('2026-10-27T22:30:00Z');
|
||||||
|
// The Central date counts the one day twice and reports a pass.
|
||||||
|
assert.equal(cycleCompletion({ ...opts, client: undefined, rawTradeDate: currentTradeDateCT(evening), now: evening }), 'today');
|
||||||
|
assert.equal(cycleCompletion({ ...opts, rawTradeDate: tradeDateFor(vest, evening), now: evening }), 'none');
|
||||||
|
// Two minutes past Vest's 8 PM ET roll the last sync's realized P&L can
|
||||||
|
// still be the ended day's: no 'today' until the settle window passes.
|
||||||
|
const justRolled = new Date('2026-10-28T00:02:00Z');
|
||||||
|
assert.equal(tradeDateFor(vest, justRolled), '2026-10-28');
|
||||||
|
assert.equal(cycleCompletion({ ...opts, rawTradeDate: '2026-10-28', now: justRolled }), 'none');
|
||||||
|
// A real second day, once settled, finishes it.
|
||||||
|
const settled = new Date('2026-10-28T00:10:00Z');
|
||||||
|
assert.equal(cycleCompletion({ ...opts, rawTradeDate: '2026-10-28', rawRealizedPnL: 50, equityProfit: 3150, now: settled }), 'today');
|
||||||
|
});
|
||||||
|
|
||||||
test('relayed sessions: taken, kept fresher, dropped, never listed with tokens', () => {
|
test('relayed sessions: taken, kept fresher, dropped, never listed with tokens', () => {
|
||||||
const now = iso('2026-10-26T18:40:00Z');
|
const now = iso('2026-10-26T18:40:00Z');
|
||||||
const jwt = (n) => `eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIke${n}In0.sig${n}`;
|
const jwt = (n) => `eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIke${n}In0.sig${n}`;
|
||||||
|
|||||||
Reference in New Issue
Block a user