Remove broker session rest

Reverts 062ec07 and 5eb0ea7: every firm's broker session stays logged in
around the clock again, renewing its token on the fixed timer as before.
The tree is back to 6dd2528's. A session_rest row left in settings is
unused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-10-21 23:39:53 -05:00
co-authored by Claude Opus 5.5
parent 062ec07f0a
commit 5ec5abde6e
19 changed files with 43 additions and 901 deletions
+2 -19
View File
@@ -184,9 +184,8 @@ It is read-only unless `DX_CONFIRM=yes DX_ACCOUNT=<code>` are set too, which
adds one minimum-size live round trip. adds one minimum-size live round trip.
`npm run check` runs the pure-logic checks (fees, stops, sizing, trading `npm run check` runs the pure-logic checks (fees, stops, sizing, trading
hours, session rest times, the DXtrade selector, the futures draw and hours, the DXtrade selector, the futures draw and instrument lists), plus local
instrument lists), plus local HTTP/WebSocket checks against a temporary HTTP/WebSocket checks against a temporary database.
database.
### Outbound user agent ### Outbound user agent
@@ -204,22 +203,6 @@ Use `brokerWebSocket` or `signalrWebSocket` from `lib/outbound-proxy.ts` for bro
sockets, and `directWebSocket` for control sockets. All share the same user agent; sockets, and `directWebSocket` for control sockets. All share the same user agent;
the broker proxy still applies only to broker traffic. the broker proxy still applies only to broker traffic.
### Broker session rest
A firm's broker session is logged out while nothing needs it, rather than kept
up around the clock (`lib/clients.ts`, session rest). Once a firm has nothing
open, nothing pending and nothing left to trade before its next session (the
weekend, the daily break, or a trade date whose accounts are all done) for a
random 15–45 minutes, its client disconnects. The dashboard shows it as
*Resting* with its accounts as last synced. A fresh login comes a random 20–50
minutes before the firm's next session opens, and a long rest is broken by a
check-in every 3–7 hours of the Central daytime. Starting trading, a manual leg,
a copy trade, a manual order or an AutoBuyer run logs a resting firm back in at
once. While logged in, tokens are renewed at random points (Tradovate 15–45
minutes before expiry, TopstepX every 45–150 minutes) rather than on a fixed
timer. Settings → Trading → *Rest idle broker sessions* (`session_rest`) turns
it off; `/api/health` lists each resting firm and when it is back.
--- ---
## The master dashboard ## The master dashboard
+1 -6
View File
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { getCachedFirm } from '@/lib/firm-catalog'; import { getCachedFirm } from '@/lib/firm-catalog';
import { firmInstruments, isFuturesFirm } from '@/lib/instruments'; import { firmInstruments, isFuturesFirm } from '@/lib/instruments';
import { getClients, isWaking, wakeFirm } from '@/lib/clients'; import { getClients } from '@/lib/clients';
export async function POST( export async function POST(
req: NextRequest, req: NextRequest,
@@ -27,11 +27,6 @@ export async function POST(
const clients = getClients(); const clients = getClients();
const client = clients.get(firmId); const client = clients.get(firmId);
if (!client) return NextResponse.json({ error: 'Client not found for firm' }, { status: 404 }); if (!client) return NextResponse.json({ error: 'Client not found for firm' }, { status: 404 });
// A resting session (lib/clients.ts) holds no login: start one, and
// let the caller come back once it's up.
if (wakeFirm(firmId, 'manual order') || isWaking(firmId)) {
return NextResponse.json({ error: "the firm's session is logging back in — try again in a minute" }, { status: 503 });
}
// A futures firm trades only what its instrument list (master Firms // A futures firm trades only what its instrument list (master Firms
// page) names. // page) names.
+5 -11
View File
@@ -1,7 +1,7 @@
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { getClients, isWaking, sessionRestEnabled, sessionRestInfo } from '@/lib/clients'; import { getClients } from '@/lib/clients';
import { getTradableFirms } from '@/lib/firm-catalog'; import { getTradableFirms } from '@/lib/firm-catalog';
import { getSchedulerHealth, getSchedulerStatus } from '@/lib/auto-trade'; import { getSchedulerHealth, getSchedulerStatus } from '@/lib/auto-trade';
import { getBuildVersion } from '@/lib/version'; import { getBuildVersion } from '@/lib/version';
@@ -41,26 +41,20 @@ export async function GET() {
const entries = getTradableFirms().map((firm) => { const entries = getTradableFirms().map((firm) => {
const base = { firmId: firm.id, firm: firm.name, platform: firm.platform }; const base = { firmId: firm.id, firm: firm.name, platform: firm.platform };
const client = clients.get(firm.id); const client = clients.get(firm.id);
if (!client) return { ...base, hasClient: false, ready: false, resting: null, waking: false, accounts: 0 }; if (!client) return { ...base, hasClient: false, ready: false, accounts: 0 };
const ready = isClientReady(client, now); const ready = isClientReady(client, now);
const accounts = client.accountList.length; const accounts = client.accountList.length;
// Logged out on purpose while nothing needs it (lib/clients.ts):
// not ready, and nothing wrong. Nor is logging back in after it,
// within its grace — a login that fails stays not ready past that.
const resting = sessionRestInfo(firm.id);
const waking = isWaking(firm.id, now);
try { try {
return { ...base, hasClient: true, ready, resting, waking, accounts, ...client.health() }; return { ...base, hasClient: true, ready, accounts, ...client.health() };
} catch (err) { } catch (err) {
// One client's broken health() must not hide the rest. // One client's broken health() must not hide the rest.
return { ...base, hasClient: true, ready, resting, waking, accounts, healthError: errorMessage(err) }; return { ...base, hasClient: true, ready, accounts, healthError: errorMessage(err) };
} }
}); });
return NextResponse.json({ return NextResponse.json({
ok: entries.every((e) => !e.hasClient || e.ready || !!e.resting || e.waking), ok: entries.every((e) => !e.hasClient || e.ready),
sessionRest: sessionRestEnabled(),
now, now,
uptimeSec: Math.round(process.uptime()), uptimeSec: Math.round(process.uptime()),
version: getBuildVersion(), version: getBuildVersion(),
+1 -1
View File
@@ -3,7 +3,7 @@ import { getSetting, setSetting } from '@/lib/db';
import { scheduleBackupPush } from '@/lib/backup-sync'; import { scheduleBackupPush } from '@/lib/backup-sync';
import { getRequestUserAgent } from '@/lib/request-user-agent'; import { getRequestUserAgent } from '@/lib/request-user-agent';
const VALID_KEYS = ['max_concurrent_accounts', 'tick_interval_seconds', 'master_dashboard_url', 'instance_name', 'trading_hours', 'twocaptcha_api_key', 'twocaptcha_proxy', 'master_backup_token', 'proxy_url', 'session_rest'] as const; const VALID_KEYS = ['max_concurrent_accounts', 'tick_interval_seconds', 'master_dashboard_url', 'instance_name', 'trading_hours', 'twocaptcha_api_key', 'twocaptcha_proxy', 'master_backup_token', 'proxy_url'] as const;
type SettingKey = typeof VALID_KEYS[number]; type SettingKey = typeof VALID_KEYS[number];
export async function GET() { export async function GET() {
+3 -5
View File
@@ -1,6 +1,6 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { listAssignments, loadDailyPnL } from '@/lib/db'; import { listAssignments, loadDailyPnL } from '@/lib/db';
import { getClients, restingWakeAt, sessionRestInfo } from '@/lib/clients'; import { getClients } from '@/lib/clients';
import { getAccountTypeById, getCachedCatalog } from '@/lib/firm-catalog'; import { getAccountTypeById, getCachedCatalog } from '@/lib/firm-catalog';
import { ruleDiffs } from '@/lib/kept-rules'; import { ruleDiffs } from '@/lib/kept-rules';
import { getAccountConfig, getInferredSize, matchAccountType } from '@/lib/account-type-resolver'; import { getAccountConfig, getInferredSize, matchAccountType } from '@/lib/account-type-resolver';
@@ -27,10 +27,8 @@ export async function GET() {
// A firm with no accounts is a real, settled state and must not be // A firm with no accounts is a real, settled state and must not be
// reported the same way as one that is still authenticating. // reported the same way as one that is still authenticating.
const connected = !!client && client.syncComplete; const connected = !!client && client.syncComplete;
const rest = sessionRestInfo(f.id);
const resting = rest ? { resting: { since: rest.since, wakeAt: restingWakeAt(f.id) } } : {};
if (!client || client.accountList.length === 0) { if (!client || client.accountList.length === 0) {
return { firmId: f.id, firm: f.name, connected, ...resting, accounts: [] }; return { firmId: f.id, firm: f.name, connected, accounts: [] };
} }
// 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.
@@ -316,7 +314,7 @@ export async function GET() {
keptRules, keptRules,
}; };
}); });
return { firmId: f.id, firm: f.name, connected, ...resting, accounts }; return { firmId: f.id, firm: f.name, connected, accounts };
}); });
return NextResponse.json(state); return NextResponse.json(state);
+2 -12
View File
@@ -526,17 +526,7 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo
Username missing Username missing
</span> </span>
)} )}
{hasCredentials && state.resting && ( {hasCredentials && !state.connected && (
<span
className="text-xs bg-indigo-50 text-indigo-600 px-1.5 py-0.5 rounded font-semibold"
title="Logged out while nothing can trade here; the accounts show their last sync"
>
{state.resting.wakeAt !== null
? `Resting · back ${new Date(state.resting.wakeAt).toLocaleString([], { weekday: 'short', hour: 'numeric', minute: '2-digit' })}`
: 'Resting until trading starts'}
</span>
)}
{hasCredentials && !state.connected && !state.resting && (
<span className="text-xs bg-slate-200 text-slate-600 px-1.5 py-0.5 rounded font-semibold"> <span className="text-xs bg-slate-200 text-slate-600 px-1.5 py-0.5 rounded font-semibold">
Connecting… Connecting…
</span> </span>
@@ -604,7 +594,7 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo
state.accounts.length === 0 ? ( state.accounts.length === 0 ? (
<tr> <tr>
<td colSpan={7} className="px-4 py-5 pl-12 text-sm text-slate-400 italic"> <td colSpan={7} className="px-4 py-5 pl-12 text-sm text-slate-400 italic">
{state.connected || state.resting ? 'No accounts at this firm' : 'Connecting…'} {state.connected ? 'No accounts at this firm' : 'Connecting…'}
</td> </td>
</tr> </tr>
) : ( ) : (
+1 -21
View File
@@ -45,7 +45,6 @@ interface AppSettings {
instance_name: string | null; instance_name: string | null;
instance_id: string | null; instance_id: string | null;
trading_hours: string | null; trading_hours: string | null;
session_rest: string | null;
master_backup_token: string | null; master_backup_token: string | null;
proxy_url: string | null; proxy_url: string | null;
user_agent: string | null; user_agent: string | null;
@@ -65,7 +64,6 @@ export default function SettingsPage() {
const [contracts, setContracts] = useState<Record<string, ResolvedContract | null>>({}); const [contracts, setContracts] = useState<Record<string, ResolvedContract | null>>({});
const [maxConcurrent, setMaxConcurrent] = useState<string>('5'); const [maxConcurrent, setMaxConcurrent] = useState<string>('5');
const [tradingHours, setTradingHours] = useState<string>('full_cme'); const [tradingHours, setTradingHours] = useState<string>('full_cme');
const [sessionRest, setSessionRest] = useState<boolean>(true);
const [masterUrl, setMasterUrl] = useState<string>(''); const [masterUrl, setMasterUrl] = useState<string>('');
const [catalog, setCatalog] = useState<{ const [catalog, setCatalog] = useState<{
version: number; version: number;
@@ -116,7 +114,6 @@ export default function SettingsPage() {
if (s.max_concurrent_accounts != null) { if (s.max_concurrent_accounts != null) {
setMaxConcurrent(s.max_concurrent_accounts); setMaxConcurrent(s.max_concurrent_accounts);
} }
setSessionRest(s.session_rest !== '0');
if (s.trading_hours != null) { if (s.trading_hours != null) {
setTradingHours(s.trading_hours); setTradingHours(s.trading_hours);
} }
@@ -164,7 +161,7 @@ export default function SettingsPage() {
await fetch('/api/settings', { await fetch('/api/settings', {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ max_concurrent_accounts: maxConcurrent, trading_hours: tradingHours, session_rest: sessionRest ? '1' : '0', master_dashboard_url: masterUrl, instance_name: instanceName, master_backup_token: backupToken, proxy_url: proxyUrl }), body: JSON.stringify({ max_concurrent_accounts: maxConcurrent, trading_hours: tradingHours, master_dashboard_url: masterUrl, instance_name: instanceName, master_backup_token: backupToken, proxy_url: proxyUrl }),
}); });
setSaving(false); setSaving(false);
setSaved(true); setSaved(true);
@@ -283,23 +280,6 @@ export default function SettingsPage() {
<option value="equity_hours">Equity Hours (8:30 AM – 3:00 PM CT)</option> <option value="equity_hours">Equity Hours (8:30 AM – 3:00 PM CT)</option>
</select> </select>
</div> </div>
<div className="flex items-center justify-between gap-4 border-t border-slate-100 px-4 py-3">
<div>
<p className="text-sm font-medium text-slate-800">Rest idle broker sessions</p>
<p className="text-xs text-slate-400 mt-0.5">
Log a firm out once nothing is open and nothing is left to trade before its next
session (weekends, the daily break, a finished day), and back in 20–50 min before
that session opens, with a check-in every few daytime hours. Off keeps every
session logged in around the clock.
</p>
</div>
<input
type="checkbox"
checked={sessionRest}
onChange={(e) => setSessionRest(e.target.checked)}
className="h-4 w-4 shrink-0 accent-blue-500"
/>
</div>
</div> </div>
{/* ── Master Dashboard ── */} {/* ── Master Dashboard ── */}
+12 -127
View File
@@ -7,14 +7,14 @@
* signal but have since exited and are now eligible. * signal but have since exited and are now eligible.
*/ */
import { fmtCentralTime, getClients, isResting, isSessionAway, isWaking, restingWakeAt, wakeAllFirms, wakeFirm } from './clients'; 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, currentTradeDateCT,
obfuscationStopDollars, takeProfitPlan, onTick, percentFee, dllRoom, dllStopDollars, dllViable, stopTicks, obfuscationStopDollars, takeProfitPlan, onTick, percentFee, dllRoom, dllStopDollars, dllViable, stopTicks,
expectedSwapToWin, planLegSize, maxLossRoom, breachDistance, expectedSwapToWin, planLegSize, maxLossRoom, breachDistance,
isNoTradeTime, isPastSessionClose, nextSessionOpenAt, 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,
CME_EVENING_OPEN_MIN, foldReason, withinFirmCaps, CME_EVENING_OPEN_MIN, foldReason, withinFirmCaps,
@@ -345,9 +345,6 @@ function firmClients(now = Date.now()): { ready: FirmClient[]; unready: FirmClie
for (const firm of getTradableFirms()) { for (const firm of getTradableFirms()) {
const client = clients.get(firm.id); const client = clients.get(firm.id);
if (!client) continue; if (!client) continue;
// A session resting (or just back and not synced yet) holds nothing:
// it neither trades nor gates (lib/clients.ts, session rest).
if (isSessionAway(firm.id, now)) continue;
(isClientReady(client, now) ? ready : unready).push({ firm, client }); (isClientReady(client, now) ? ready : unready).push({ firm, client });
} }
return { ready, unready }; return { ready, unready };
@@ -573,9 +570,6 @@ interface AccountEvaluation {
/** What the firm's day may still lose before a hard daily-loss breach /** What the firm's day may still lose before a hard daily-loss breach
* (Infinity = no limit). */ * (Infinity = no limit). */
dllRoom: number; dllRoom: number;
/** Ineligible until the next trade date at the earliest, whatever the
* broker says meanwhile: the session can rest (lib/clients.ts). */
doneForDay?: true;
} }
/** /**
@@ -696,8 +690,8 @@ function evaluateAccountForTrade(client: any, acc: { id: number; name: string; a
const daysTraded = combined.daysTraded; const daysTraded = combined.daysTraded;
const base = { plan: null, isExtraDay: false, drain: false, cash, dailyPnL, daysTraded, dllRoom: Infinity }; const base = { plan: null, isExtraDay: false, drain: false, cash, dailyPnL, daysTraded, dllRoom: Infinity };
if (isAccountDead(cash.amount, autoLiqThreshold)) return { ...base, eligible: false, reason: 'dead', doneForDay: true }; if (isAccountDead(cash.amount, autoLiqThreshold)) return { ...base, eligible: false, reason: 'dead' };
if (!acc.active) return { ...base, eligible: false, reason: 'inactive (DLL)', doneForDay: true }; if (!acc.active) return { ...base, eligible: false, reason: 'inactive (DLL)' };
if (client.positions[acc.id]) return { ...base, eligible: false, reason: 'already in trade' }; if (client.positions[acc.id]) return { ...base, eligible: false, reason: 'already in trade' };
// Another batch is placing this account's order right now; its position // Another batch is placing this account's order right now; its position
// won't show until the fill comes back, so the check above can't see it. // won't show until the fill comes back, so the check above can't see it.
@@ -715,11 +709,11 @@ function evaluateAccountForTrade(client: any, acc: { id: number; name: string; a
if (cash.pendingRoll) return { ...base, eligible: false, reason: ROLL_PENDING }; if (cash.pendingRoll) return { ...base, eligible: false, reason: ROLL_PENDING };
if (unknownEntryHolds(client, acc.id, tradeDate)) return { ...base, eligible: false, reason: ENTRY_UNKNOWN }; if (unknownEntryHolds(client, acc.id, tradeDate)) return { ...base, eligible: false, reason: ENTRY_UNKNOWN };
if (runtime().exitRefused?.get(acc.id) === tradeDate) { if (runtime().exitRefused?.get(acc.id) === tradeDate) {
return { ...base, eligible: false, reason: 'exit refused — flattened, benched for the day', doneForDay: true }; return { ...base, eligible: false, reason: 'exit refused — flattened, benched for the day' };
} }
const booked = obfuscationDayBooked(cfg, acc.id, tradeDate, combined.realizedPnL); const booked = obfuscationDayBooked(cfg, acc.id, tradeDate, combined.realizedPnL);
if (booked) return { ...base, eligible: false, reason: booked, doneForDay: true }; if (booked) return { ...base, eligible: false, reason: booked };
const priorProfit = client.priorProfit?.[acc.id] ?? 0; const priorProfit = client.priorProfit?.[acc.id] ?? 0;
const allFundTxns = client.fundTransactions?.[acc.id] ?? []; const allFundTxns = client.fundTransactions?.[acc.id] ?? [];
@@ -761,19 +755,19 @@ function evaluateAccountForTrade(client: any, acc: { id: number; name: string; a
// Legacy one-and-done gate: any realized PnL today means this account is // Legacy one-and-done gate: any realized PnL today means this account is
// finished — for a combining account, "today" spans the holiday stretch. // finished — for a combining account, "today" spans the holiday stretch.
if (combined.realizedPnL !== 0) { if (combined.realizedPnL !== 0) {
return { ...evaluated, eligible: false, reason: `already traded today (realizedPnL=${combined.realizedPnL})`, doneForDay: true }; return { ...evaluated, eligible: false, reason: `already traded today (realizedPnL=${combined.realizedPnL})` };
} }
// And any entry of today's the broker confirmed filled, whatever its // And any entry of today's the broker confirmed filled, whatever its
// realized P&L says (a record replaced under it, a fill not booked // realized P&L says (a record replaced under it, a fill not booked
// yet). One of unknown outcome is held above until settled. // yet). One of unknown outcome is held above until settled.
if (entryOutcomeToday(acc.id, tradeDate)?.state === 'filled') { if (entryOutcomeToday(acc.id, tradeDate)?.state === 'filled') {
return { ...evaluated, eligible: false, reason: 'already traded today (entry filled)', doneForDay: true }; return { ...evaluated, eligible: false, reason: 'already traded today (entry filled)' };
} }
if (!isExtraDay && plan.remainingToday <= 0) { if (!isExtraDay && plan.remainingToday <= 0) {
const reason = effective.drain && plan.dayTarget === 0 const reason = effective.drain && plan.dayTarget === 0
? 'drain days booked — waiting for withdrawal' ? 'drain days booked — waiting for withdrawal'
: `target=$${plan.dayTarget} (challenge complete)`; : `target=$${plan.dayTarget} (challenge complete)`;
return { ...evaluated, eligible: false, reason, doneForDay: true }; return { ...evaluated, eligible: false, reason };
} }
} else { } else {
// Split-leg mode: keep trading until the day's remaining target is used up. // Split-leg mode: keep trading until the day's remaining target is used up.
@@ -783,10 +777,10 @@ function evaluateAccountForTrade(client: any, acc: { id: number; name: string; a
const reason = combined.realizedPnL === 0 const reason = combined.realizedPnL === 0
? `target=$${plan.dayTarget} (challenge complete)` ? `target=$${plan.dayTarget} (challenge complete)`
: `day done ($${plan.remainingToday} left of $${plan.dayTarget})`; : `day done ($${plan.remainingToday} left of $${plan.dayTarget})`;
return { ...evaluated, eligible: false, reason, doneForDay: true }; return { ...evaluated, eligible: false, reason };
} }
if (legsToday(acc.id, tradeDate) >= MAX_LEGS_PER_DAY) { if (legsToday(acc.id, tradeDate) >= MAX_LEGS_PER_DAY) {
return { ...evaluated, eligible: false, reason: `leg cap reached (${MAX_LEGS_PER_DAY} today)`, doneForDay: true }; return { ...evaluated, eligible: false, reason: `leg cap reached (${MAX_LEGS_PER_DAY} today)` };
} }
} }
@@ -795,12 +789,7 @@ function evaluateAccountForTrade(client: any, acc: { id: number; name: string; a
// leaves about the reserve plus fees, which is below what a trade needs — // leaves about the reserve plus fees, which is below what a trade needs —
// so a stop-out benches the account for the rest of the day by itself. // so a stop-out benches the account for the rest of the day by itself.
const gate = dailyLossGate(client, acc.id, cfg, cash, combined.realizedPnL, plan.remainingToday, isExtraDay || effective.drain); const gate = dailyLossGate(client, acc.id, cfg, cash, combined.realizedPnL, plan.remainingToday, isExtraDay || effective.drain);
if ('reason' in gate) { if ('reason' in gate) return { ...evaluated, eligible: false, reason: gate.reason };
// A booked day is done; a day-start balance still to come is not.
return gate.reason === DLL_BASE_PENDING
? { ...evaluated, eligible: false, reason: gate.reason }
: { ...evaluated, eligible: false, reason: gate.reason, doneForDay: true };
}
if (Number.isFinite(gate.room)) return { ...evaluated, eligible: true, reason: '', dllRoom: gate.room }; if (Number.isFinite(gate.room)) return { ...evaluated, eligible: true, reason: '', dllRoom: gate.room };
return { ...evaluated, eligible: true, reason: '' }; return { ...evaluated, eligible: true, reason: '' };
@@ -809,86 +798,6 @@ function evaluateAccountForTrade(client: any, acc: { id: number; name: string; a
// ── broker session demand (lib/clients.ts rests idle sessions) ──────────────
const CENTRAL_DAY = new Intl.DateTimeFormat('en-CA', { timeZone: 'America/Chicago' });
/**
* The first minute the firm may open a trade at or after `now` — on a trade
* date after `afterTradeDate`, when given. Today's open delay is the
* scheduler's own (openJitterMinutes); a later day's isn't drawn yet, so it
* counts from that day's earliest open.
*/
export function nextSessionOpenFor(firm: { tradesEveryDay?: boolean }, now: number, afterTradeDate: string | null): number | null {
const today = CENTRAL_DAY.format(now);
const delay = todaysSessionOpenDelay();
return nextSessionOpenAt(now, {
everyDay: firm.tradesEveryDay === true,
mode: getSetting('trading_hours') ?? 'full_cme',
jitterFor: (at) => (CENTRAL_DAY.format(at) === today ? delay : 0),
}, afterTradeDate);
}
export interface SessionDemand {
/** Why the firm's session must stay up, or null when nothing needs it. */
needed: string | null;
/** Not needed, but not to be logged out this minute either: something
* brief is underway — an answer awaited, a batch in its lane. */
wait: string | null;
/** The trade date whose accounts are all done — the session waits for a
* later one — or null when the market is closed. */
afterTradeDate: string | null;
/** The scheduler is running, so the next session wants the firm back. */
scheduled: boolean;
}
/**
* Whether the firm's broker session is needed now. Whatever the bot holds or
* is unsure of keeps it up: positions, working orders, orders in flight, an
* entry of unknown outcome. While the scheduler runs and the firm's market is
* open, so does every account that may still trade this trade date — only
* accounts done for the day (doneForDay) let it go. A client that isn't
* synced can't say what it holds, so it always counts as needed. What the
* client still waits on itself (restBlocker) and a batch running in its lane
* only put the logout off.
*/
export function sessionDemand(firm: TradableFirm, client: PlatformClient, now = Date.now()): SessionDemand {
const scheduled = getState().running;
const result = (needed: string | null, afterTradeDate: string | null = null): SessionDemand => {
if (needed) return { needed, wait: null, afterTradeDate: null, scheduled };
const wait = client.restBlocker?.()
?? (runtime().tradeInFlight?.has(laneOf(firm)) ? 'a batch is in flight in its lane' : null);
return { needed: null, wait, afterTradeDate, scheduled };
};
if (!isClientReady(client, now)) return result('not synced');
const rt = runtime();
const tradeDate = currentTradeDateCT(new Date(now));
if (Object.keys(client.positions ?? {}).length > 0) return result('a position is open');
for (const acc of client.accountList) {
if (client.hasWorkingOrders?.(acc.id)) return result(`${acc.name} has working orders`);
if (rt.accountsInFlight.has(acc.id)) return result(`${acc.name} has an order in flight`);
// Read only: settling it is the scheduler's job (unknownEntryHolds).
if (entryOutcomeToday(acc.id, tradeDate)?.state === 'unknown') return result(`${acc.name}: ${ENTRY_UNKNOWN}`);
}
if (!scheduled || isInNoTradeWindow(firm, new Date(now))) return result(null);
for (const acc of client.accountList) {
const ev = evaluateAccountForTrade(client, acc, tradeDate);
if (ev && !ev.doneForDay) return result(`${acc.name} ${ev.eligible ? 'can still trade' : `waits: ${ev.reason}`}`);
}
return result(null, tradeDate);
}
/** What the master reads for a lane whose every firm's session is resting. */
function laneRestingDetail(lane: string): string | null {
const firms = getTradableFirms().filter((f) => laneOf(f) === lane);
if (firms.length === 0 || !firms.every((f) => isResting(f.id))) return null;
const wakes = firms.map((f) => restingWakeAt(f.id)).filter((t): t is number => t !== null);
return wakes.length > 0
? `sessions resting, every account done — next login ${fmtCentralTime(Math.min(...wakes))}`
: 'sessions resting until trading starts';
}
// ── helpers ─────────────────────────────────────────────────────────────────── // ── helpers ───────────────────────────────────────────────────────────────────
/** The longest random delay added to the session open, in minutes. */ /** The longest random delay added to the session open, in minutes. */
@@ -1674,9 +1583,6 @@ async function fireLeg(
*/ */
export async function copyTrade() { export async function copyTrade() {
console.log('[copy-trade] called'); console.log('[copy-trade] called');
// Resting firms can't be copied to this time; they're back for the next.
const woken = wakeAllFirms('copy trade');
if (woken > 0) console.log(`[copy-trade] ${woken} resting session(s) logging back in — copy again once they sync`);
const positioned = firmClients().ready const positioned = firmClients().ready
.find(({ client }) => client.accountList.some((acc) => !!client.positions[acc.id])); .find(({ client }) => client.accountList.some((acc) => !!client.positions[acc.id]));
if (!positioned) { if (!positioned) {
@@ -1876,11 +1782,6 @@ async function copyTradeLocked(lease: number, lane: string): Promise<unknown[]>
* instrument and side. * instrument and side.
*/ */
export async function fireManualLeg(accountId: number, action: 'Buy' | 'Sell' | 'Auto'): Promise<LegResult> { export async function fireManualLeg(accountId: number, action: 'Buy' | 'Sell' | 'Auto'): Promise<LegResult> {
for (const [firmId, c] of getClients()) {
if (!c.accountList.some((a) => a.id === accountId)) continue;
if (wakeFirm(firmId, 'manual leg')) throw new Error("the firm's session was resting — logging in now; try again in a minute");
if (isWaking(firmId)) throw new Error("the firm's session is logging back in — try again in a minute");
}
const found = firmClients().ready.find(({ client }) => client.accountList.some((a) => a.id === accountId)); const found = firmClients().ready.find(({ client }) => client.accountList.some((a) => a.id === accountId));
if (!found) throw new Error('account not found on a synced client'); if (!found) throw new Error('account not found on a synced client');
const { firm, client } = found; const { firm, client } = found;
@@ -1984,10 +1885,6 @@ function hasRemainingConfiguredAccounts(): boolean {
// the ready firms every session after — exactly what the operator opted // the ready firms every session after — exactly what the operator opted
// out of. (Before an open with every ready account already done, this // out of. (Before an open with every ready account already done, this
// stops rather than wait on the unready firm: the safe side.) // stops rather than wait on the unready firm: the safe side.)
// A firm logging back in from a rest (a check-in may have found a new
// account) is unknown until it syncs, like an unready one.
if (getTradableFirms().some((firm) => isWaking(firm.id) && !isInNoTradeWindow(firm))) return true;
for (const { firm, client } of unready) { for (const { firm, client } of unready) {
if (isInNoTradeWindow(firm)) continue; if (isInNoTradeWindow(firm)) continue;
if (client.lastSyncAt === 0 && client.accountList.length === 0) return true; if (client.lastSyncAt === 0 && client.accountList.length === 0) return true;
@@ -2069,10 +1966,6 @@ export function startScheduler(action: 'Buy' | 'Sell' | 'Auto', symbol: string,
state.running = true; state.running = true;
state.stopAfterAll = stopAfterAll; state.stopAfterAll = stopAfterAll;
// Trading (re)started by hand: every resting session logs back in now.
// Idle ones log out again once they're seen to have nothing to do.
wakeAllFirms('trading started');
const tick = async () => { const tick = async () => {
if (!state.running) return; if (!state.running) return;
state.lastRun = new Date(); state.lastRun = new Date();
@@ -2126,14 +2019,6 @@ export function startScheduler(action: 'Buy' | 'Sell' | 'Auto', symbol: string,
console.log(`[scheduler] ${laneName(lane)}: ${openPositions} position(s) still open — skipping tick`); console.log(`[scheduler] ${laneName(lane)}: ${openPositions} position(s) still open — skipping tick`);
continue; continue;
} }
// Every firm here is done for the day and logged out —
// say so rather than "no eligible accounts". A closed
// market still reads as closed (runTradeBatch).
const resting = laneRestingDetail(lane);
if (resting && !getTradableFirms().filter((f) => laneOf(f) === lane).every((f) => isInNoTradeWindow(f))) {
record(lane, 'no-eligible', resting);
continue;
}
const { outcome, results, detail, reasons } = await runTradeBatch(state.action, state.symbol, lane); const { outcome, results, detail, reasons } = await runTradeBatch(state.action, state.symbol, lane);
record(lane, outcome, detail ?? outcome, reasons); record(lane, outcome, detail ?? outcome, reasons);
filled += results.flatMap((r) => r.results).filter((r) => r.status === 'filled').length; filled += results.flatMap((r) => r.results).filter((r) => r.status === 'filled').length;
+2 -306
View File
@@ -14,10 +14,8 @@ import { applyBrokerHttpDefaults, applyBrokerProxy } from './outbound-proxy';
import axios from './http-client'; import axios from './http-client';
import { startMasterLink } from './master-link'; import { startMasterLink } from './master-link';
import { startUpdaterWatchdog } from './updater-watchdog'; import { startUpdaterWatchdog } from './updater-watchdog';
import { getSchedulerStatus, nextSessionOpenFor, resumeSchedulerIfPersisted, sessionDemand, type SessionDemand } from './auto-trade'; import { resumeSchedulerIfPersisted } from './auto-trade';
import { superviseQuotes, SUPERVISE_INTERVAL_MS, type QuoteClient } from './live-quotes'; import { superviseQuotes, SUPERVISE_INTERVAL_MS, type QuoteClient } from './live-quotes';
import { getSetting } from './db';
import { drawBetween, planRest, REST_LINGER_MAX_MS, REST_LINGER_MIN_MS } from './trading-logic';
// Use global to persist the client pool across HMR reloads in dev mode. // Use global to persist the client pool across HMR reloads in dev mode.
// Keyed by the master's firm id, which is a uuid string. // Keyed by the master's firm id, which is a uuid string.
@@ -37,10 +35,6 @@ const g = global as typeof globalThis & {
__clientSupervisorTimer?: ReturnType<typeof setInterval>; __clientSupervisorTimer?: ReturnType<typeof setInterval>;
__clientSupervision?: Map<string, SupervisionEntry>; __clientSupervision?: Map<string, SupervisionEntry>;
__quoteSupervisorTimer?: ReturnType<typeof setInterval>; __quoteSupervisorTimer?: ReturnType<typeof setInterval>;
__sessionGovernorTimer?: ReturnType<typeof setInterval>;
__sessionRest?: Map<string, RestEntry>;
__sessionIdle?: Map<string, IdleEntry>;
__sessionWaking?: Map<string, WakeEntry>;
}; };
function ensureMap(): Map<string, PlatformClient> { function ensureMap(): Map<string, PlatformClient> {
@@ -228,9 +222,6 @@ export function getClients(): Map<string, PlatformClient> {
// Rebuild any client that has been wedged for too long. // Rebuild any client that has been wedged for too long.
startClientSupervisor(); startClientSupervisor();
// Log idle sessions out, and back in before they're needed.
startSessionGovernor();
// Live quotes off one futures client, for the spread every entry // Live quotes off one futures client, for the spread every entry
// pays and the Auto draw. // pays and the Auto draw.
startQuoteSupervisor(); startQuoteSupervisor();
@@ -300,13 +291,6 @@ function triggerContractResolve(): void {
try { authenticated = c.health().authenticated; } catch { /* no health: trust the token */ } try { authenticated = c.health().authenticated; } catch { /* no health: trust the token */ }
if (authenticated) { accessToken = token; break; } if (authenticated) { accessToken = token; break; }
} }
if (!accessToken && tradovateAllResting(map)) {
// Nothing to resolve with until one logs back in, and every wake
// resolves (wakeFirm) — no half hour of retries meanwhile.
console.log('[contract-resolver] Tradovate sessions are resting — resolving at the next login');
g.__contractResolveRetries = 0;
return;
}
if (!accessToken) { if (!accessToken) {
const retries = (g.__contractResolveRetries ?? 0) + 1; const retries = (g.__contractResolveRetries ?? 0) + 1;
if (retries > CONTRACT_RESOLVE_MAX_RETRIES) { if (retries > CONTRACT_RESOLVE_MAX_RETRIES) {
@@ -343,19 +327,6 @@ function triggerContractResolve(): void {
} }
} }
/** Every Tradovate client is a resting snapshot (and there is at least one). */
function tradovateAllResting(map: Map<string, PlatformClient>): boolean {
let resting = 0;
for (const [id, c] of map) {
let platform: string | undefined;
try { platform = c.health().platform; } catch { /* unknown: count it awake */ }
if (platform !== 'tradovate') continue;
if (!isResting(id)) return false;
resting++;
}
return resting > 0;
}
function scheduleDailyResolve(): void { function scheduleDailyResolve(): void {
// Clear any existing timer (HMR safety) // Clear any existing timer (HMR safety)
if (g.__contractResolverTimer) clearInterval(g.__contractResolverTimer); if (g.__contractResolverTimer) clearInterval(g.__contractResolverTimer);
@@ -436,8 +407,7 @@ function superviseLiveQuotes(): void {
const map = ensureMap(); const map = ensureMap();
const candidates = getTradableFirms().filter(isFuturesFirm).flatMap((firm) => { const candidates = getTradableFirms().filter(isFuturesFirm).flatMap((firm) => {
const client = map.get(firm.id); const client = map.get(firm.id);
// A resting session holds no token; its feed (if it had one) drops. return client ? [{ firmId: firm.id, firmName: firm.name, platform: firm.platform, client: client as unknown as QuoteClient }] : [];
return client && !isResting(firm.id) ? [{ firmId: firm.id, firmName: firm.name, platform: firm.platform, client: client as unknown as QuoteClient }] : [];
}); });
superviseQuotes(candidates, resolveRootsHere()) superviseQuotes(candidates, resolveRootsHere())
.catch((err) => console.error(`[quotes] supervision failed: ${errorMessage(err)}`)); .catch((err) => console.error(`[quotes] supervision failed: ${errorMessage(err)}`));
@@ -529,12 +499,6 @@ function superviseClients(): void {
entry.unhealthySince = 0; entry.unhealthySince = 0;
} }
// Logged out on purpose: rebuilding it would wake it.
if (isResting(id)) {
entry.unhealthySince = 0;
continue;
}
if (isClientReady(client, now)) { if (isClientReady(client, now)) {
entry.unhealthySince = 0; entry.unhealthySince = 0;
continue; continue;
@@ -577,271 +541,3 @@ function superviseClients(): void {
console.error(`[clients] supervisor tick failed: ${errorMessage(err)}`); console.error(`[clients] supervisor tick failed: ${errorMessage(err)}`);
} }
} }
// ── Session rest ─────────────────────────────────────────────────────────────
/**
* Logs a firm's broker session out while nothing needs it, and back in before
* something does.
*
* A session used to stay up from boot to shutdown: logged in all night and all
* weekend, renewing its token on a timer throughout. Now a firm that has been
* idle for a random 15–45 minutes — nothing held, nothing pending, nothing
* left to trade before its next session (sessionDemand in lib/auto-trade.ts)
* — has its client disconnected: the client keeps its last snapshot for the
* dashboards and the report, but holds no token and runs no timers. A fresh
* client logs in a random 20–50 minutes before the firm's next session opens
* (planRest), on the same path a restart takes. A long rest is broken by a
* check-in every few hours of the Central daytime, which refreshes balances
* and finds new accounts: one that can still trade keeps the session up. A
* command that needs the firm — trading started, a manual leg, a copy, an
* AutoBuyer run — wakes it at once.
*
* Trading never sees a resting firm (isSessionAway, in firmClients): it holds
* nothing, so it neither trades nor gates, and its lane doesn't wait for it.
* A woken one stays out of sight until its first good sync (WAKE_GRACE_MS at
* most), so a check-in never holds up the firms still trading.
*
* Setting session_rest = '0' keeps every session up, as before.
*/
const GOVERNOR_INTERVAL_MS = 60_000;
const WAKE_GRACE_MS = 15 * 60_000;
interface RestEntry {
/** The disconnected client, left in the pool as a snapshot. */
client: PlatformClient;
since: number;
/** When its next session wants it back (null: only a command or a check-in will). */
wakeAt: number | null;
checkInAt: number | null;
/** How long before its next session's open it logs back in (drawn per rest). */
leadMs: number;
/** The trade date whose accounts were all done, or null: the market was closed. */
afterTradeDate: string | null;
/** What wakeAt was planned from: the Central day (its open delay may be
* drawn later) and whether the scheduler was running. */
plannedDay: string;
plannedRunning: boolean;
}
interface IdleEntry {
client: PlatformClient;
since: number;
lingerMs: number;
}
interface WakeEntry {
client: PlatformClient;
since: number;
}
const CENTRAL_DAY = new Intl.DateTimeFormat('en-CA', { timeZone: 'America/Chicago' });
const CENTRAL_TIME = new Intl.DateTimeFormat('en-US', {
timeZone: 'America/Chicago', weekday: 'short', hour: 'numeric', minute: '2-digit',
});
/** "Mon 6:12 PM CT". */
export function fmtCentralTime(ms: number): string {
return `${CENTRAL_TIME.format(ms)} CT`;
}
function restMap(): Map<string, RestEntry> {
if (!g.__sessionRest) g.__sessionRest = new Map();
return g.__sessionRest;
}
function idleMap(): Map<string, IdleEntry> {
if (!g.__sessionIdle) g.__sessionIdle = new Map();
return g.__sessionIdle;
}
function wakingMap(): Map<string, WakeEntry> {
if (!g.__sessionWaking) g.__sessionWaking = new Map();
return g.__sessionWaking;
}
export function sessionRestEnabled(): boolean {
return getSetting('session_rest') !== '0';
}
/** The firm's session is resting: its client is a disconnected snapshot. */
export function isResting(firmId: string): boolean {
const entry = g.__sessionRest?.get(firmId);
return !!entry && g.__tradovateClients?.get(firmId) === entry.client;
}
/** Logging back in after a rest, and not synced yet. */
export function isWaking(firmId: string, now = Date.now()): boolean {
const entry = g.__sessionWaking?.get(firmId);
if (!entry) return false;
if (g.__tradovateClients?.get(firmId) !== entry.client
|| isClientReady(entry.client, now)
|| now - entry.since >= WAKE_GRACE_MS) {
g.__sessionWaking?.delete(firmId);
return false;
}
return true;
}
/** Out of trading's sight: resting, or not yet back from a rest. */
export function isSessionAway(firmId: string, now = Date.now()): boolean {
return isResting(firmId) || isWaking(firmId, now);
}
/** A resting firm's rest, for /api/health and the dashboards; null when awake. */
export function sessionRestInfo(firmId: string): { since: number; wakeAt: number | null; checkInAt: number | null } | null {
if (!isResting(firmId)) return null;
const entry = restMap().get(firmId)!;
return { since: entry.since, wakeAt: entry.wakeAt, checkInAt: entry.checkInAt };
}
/** When a resting firm logs back in by itself (its wake or its check-in,
* whichever comes first); null when awake or resting until a command. */
export function restingWakeAt(firmId: string): number | null {
const info = sessionRestInfo(firmId);
if (!info) return null;
const times = [info.wakeAt, info.checkInAt].filter((t): t is number => t !== null);
return times.length > 0 ? Math.min(...times) : null;
}
/** Log a resting firm back in now. True if it was resting. */
export function wakeFirm(firmId: string, why: string): boolean {
const resting = isResting(firmId);
restMap().delete(firmId);
if (!resting) return false;
idleMap().delete(firmId);
const firm = getTradableFirms().find((f) => f.id === firmId);
if (!firm) return false; // no longer tradable: reconcileClients drops it
console.log(`[sessions] ${firm.name}: logging back in — ${why}`);
removeClient(firmId);
const client = initClient(firm.id, firm.name, firm.platform, firm);
wakingMap().set(firmId, { client, since: Date.now() });
// A rest can span the midnight contract resolve; catch up once logged in.
if (firm.platform === 'tradovate') setTimeout(() => triggerContractResolve(), 15_000);
return true;
}
/** Log every resting firm back in now; how many there were. */
export function wakeAllFirms(why: string): number {
let woken = 0;
for (const id of [...restMap().keys()]) {
if (wakeFirm(id, why)) woken++;
}
return woken;
}
function startSessionGovernor(): void {
if (g.__sessionGovernorTimer) clearInterval(g.__sessionGovernorTimer);
g.__sessionGovernorTimer = setInterval(governSessions, GOVERNOR_INTERVAL_MS);
}
function governSessions(): void {
// Runs inside setInterval: nothing may escape.
try {
const map = ensureMap();
const now = Date.now();
// Clients replaced or dropped since (a credentials change, a firm gone).
for (const [id, entry] of [...restMap()]) if (map.get(id) !== entry.client) restMap().delete(id);
for (const [id, entry] of [...idleMap()]) if (map.get(id) !== entry.client) idleMap().delete(id);
if (!sessionRestEnabled()) {
idleMap().clear();
if (restMap().size > 0) wakeAllFirms('session rest switched off');
return;
}
for (const firm of getTradableFirms()) {
const client = map.get(firm.id);
if (!client) continue;
try {
const rest = restMap().get(firm.id);
if (rest) governResting(firm, rest, now);
else governAwake(firm, client, now);
} catch (err) {
console.error(`[sessions] ${firm.name}: check failed: ${errorMessage(err)}`);
}
}
} catch (err) {
console.error(`[sessions] governor tick failed: ${errorMessage(err)}`);
}
}
type GovernedFirm = ReturnType<typeof getTradableFirms>[number];
function governResting(firm: GovernedFirm, rest: RestEntry, now: number): void {
if (rest.wakeAt !== null && now >= rest.wakeAt) {
wakeFirm(firm.id, 'its next session opens soon');
return;
}
if (rest.checkInAt !== null && now >= rest.checkInAt) {
wakeFirm(firm.id, 'check-in');
return;
}
// Plan again once the Central day turns (its open delay is drawn then)
// or the scheduler starts or stops.
const running = getSchedulerStatus().running;
const day = CENTRAL_DAY.format(now);
if (day === rest.plannedDay && running === rest.plannedRunning) return;
rest.plannedDay = day;
rest.plannedRunning = running;
const opensAt = running ? nextSessionOpenFor(firm, now, rest.afterTradeDate) : null;
const wakeAt = opensAt === null ? null : Math.max(now, opensAt - rest.leadMs);
if (wakeAt === rest.wakeAt) return;
rest.wakeAt = wakeAt;
if (wakeAt !== null && rest.checkInAt !== null && rest.checkInAt >= wakeAt) rest.checkInAt = null;
console.log(`[sessions] ${firm.name}: next login ${wakeAt !== null ? `moved to ${fmtCentralTime(wakeAt)}` : 'when trading starts'}`);
}
function governAwake(firm: GovernedFirm, client: PlatformClient, now: number): void {
const demand = sessionDemand(firm, client, now);
if (demand.needed) {
idleMap().delete(firm.id);
return;
}
const idle = idleMap().get(firm.id);
if (!idle) {
idleMap().set(firm.id, {
client, since: now, lingerMs: Math.round(drawBetween(REST_LINGER_MIN_MS, REST_LINGER_MAX_MS)),
});
return;
}
if (now - idle.since < idle.lingerMs || demand.wait) return;
const opensAt = demand.scheduled ? nextSessionOpenFor(firm, now, demand.afterTradeDate) : null;
// Scheduled, with nothing opening in a week: something is off — stay up.
if (demand.scheduled && opensAt === null) return;
const plan = planRest(now, opensAt);
if (!plan) return; // its session opens too soon for a rest to be worth a login
idleMap().delete(firm.id);
restClient(firm, client, plan, demand, now);
}
function restClient(
firm: GovernedFirm,
client: PlatformClient,
plan: NonNullable<ReturnType<typeof planRest>>,
demand: SessionDemand,
now: number,
): void {
try {
client.disconnect();
} catch (err) {
// Half torn down is still not trading; the wake builds a fresh one.
console.error(`[sessions] ${firm.name}: disconnect failed: ${errorMessage(err)}`);
}
restMap().set(firm.id, {
client,
since: now,
wakeAt: plan.wakeAt,
checkInAt: plan.checkInAt,
leadMs: plan.leadMs,
afterTradeDate: demand.afterTradeDate,
plannedDay: CENTRAL_DAY.format(now),
plannedRunning: demand.scheduled,
});
const why = demand.afterTradeDate ? `every account is done for ${demand.afterTradeDate}`
: demand.scheduled ? 'its market is closed' : 'trading is stopped';
const back = plan.wakeAt !== null ? `back at ${fmtCentralTime(plan.wakeAt)}` : 'back when trading starts';
const checkIn = plan.checkInAt !== null ? `, check-in ${fmtCentralTime(plan.checkInAt)}` : '';
console.log(`[sessions] ${firm.name}: idle (${why}) — logged out; ${back}${checkIn}`);
}
-9
View File
@@ -1764,15 +1764,6 @@ export class DXTradeClient implements PlatformClient {
}; };
} }
/** See PlatformClient: what the session still waits on besides positions
* and working orders. */
public restBlocker(): string | null {
if (this.ocoPairs.some((p) => !p.done)) return 'an exit pair is still tracked';
if (this.pendingFills > 0) return `${this.pendingFills} fill(s) awaited`;
if (this.daysFetchInFlight) return 'a history fetch is underway';
return null;
}
/** Whether a login can't happen until someone acts — the supervisor /** Whether a login can't happen until someone acts — the supervisor
* doesn't rebuild those (a rebuild can't fix a URL or a password). */ * doesn't rebuild those (a rebuild can't fix a URL or a password). */
public get needsAttention(): boolean { public get needsAttention(): boolean {
-4
View File
@@ -85,10 +85,6 @@ export interface PlatformClient {
lastSyncStartedAt?: number; lastSyncStartedAt?: number;
/** Whether the broker still shows a working order on the account. */ /** Whether the broker still shows a working order on the account. */
hasWorkingOrders?(accountId: number): boolean; hasWorkingOrders?(accountId: number): boolean;
/** Why the session can't be logged out now beyond positions and working
* orders — exit pairs it watches, fills or answers it awaits, a report
* fetch underway — or null (session rest, lib/clients.ts). */
restBlocker?(): string | null;
lastFetchErrors: Record<string, string>; lastFetchErrors: Record<string, string>;
lastFetchRaw: Record<string, string>; lastFetchRaw: Record<string, string>;
recentEntityEvents: { entityType: string; eventType: string; entity: unknown; ts: number }[]; recentEntityEvents: { entityType: string; eventType: string; entity: unknown; ts: number }[];
-4
View File
@@ -13,7 +13,6 @@
import { createRun } from './db'; import { createRun } from './db';
import { findAutomation, resolveSteps } from './automations'; import { findAutomation, resolveSteps } from './automations';
import { wakeAllFirms } from './clients';
export class QueueRunError extends Error { export class QueueRunError extends Error {
constructor(message: string, readonly status: number) { constructor(message: string, readonly status: number) {
@@ -53,8 +52,5 @@ export function queueRun(automationId: string, supplied: Record<string, unknown>
} }
const run = createRun(automationId, resolved.length, inputs); const run = createRun(automationId, resolved.length, inputs);
// A purchase brings a new account: a resting session (lib/clients.ts)
// logs back in now so it is seen, and traded if it can be.
wakeAllFirms('AutoBuyer run');
return { runId: run.id, totalSteps: run.total_steps, inputs }; return { runId: run.id, totalSteps: run.total_steps, inputs };
} }
+2 -5
View File
@@ -16,11 +16,11 @@ import { pushReport } from './master-link';
import { getBuildVersion } from './version'; import { getBuildVersion } from './version';
import { tradeMixFor } from './trade-mix'; import { tradeMixFor } from './trade-mix';
import { updaterStatus } from './updater-status'; import { updaterStatus } from './updater-status';
import { getClients, isResting } from './clients'; import { getClients } from './clients';
import { getAccountTypeById, getKeptRuleIdsForFirm, getTradableFirms } from './firm-catalog'; import { getAccountTypeById, getKeptRuleIdsForFirm, getTradableFirms } from './firm-catalog';
import { getAccountConfig } from './account-type-resolver'; import { getAccountConfig } from './account-type-resolver';
import { getSchedulerStatus } from './auto-trade'; import { getSchedulerStatus } from './auto-trade';
import { botStopsActive, combineCmeSessions, computeDayPlan, currentTradeDateCT, cycleCompletion, endedCycleCompletion, LEG_COMPLETION_EPSILON, projectPerfectPath, resolveEffectiveConfig, restSnapshotStale } from './trading-logic'; import { botStopsActive, combineCmeSessions, computeDayPlan, currentTradeDateCT, cycleCompletion, endedCycleCompletion, LEG_COMPLETION_EPSILON, projectPerfectPath, resolveEffectiveConfig } 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';
@@ -49,9 +49,6 @@ 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);
// A rest that spans the 5 PM roll holds yesterday's snapshot: unsynced
// until it logs back in, not yesterday's trading counted as today's.
if (isResting(f.id) && restSnapshotStale(client.lastSyncAt)) return emptyFirmStats(f, undefined, keptRuleIds);
let totalAccounts = 0; let totalAccounts = 0;
let accountsTraded = 0; let accountsTraded = 0;
+5 -36
View File
@@ -65,10 +65,6 @@ const SESSION_STALE_MS = 10 * 60_000;
const LOGIN_TIMEOUT_MS = 30_000; const LOGIN_TIMEOUT_MS = 30_000;
const VALIDATE_TIMEOUT_MS = 15_000; const VALIDATE_TIMEOUT_MS = 15_000;
const TOKEN_LIFETIME_MS = 24 * 60 * 60 * 1000; const TOKEN_LIFETIME_MS = 24 * 60 * 60 * 1000;
/** The token is re-validated after a wait drawn between these each time, so
* the renewals keep no fixed rhythm; any of them is far inside its 24h. */
const TOKEN_RENEW_MIN_MS = 45 * 60_000;
const TOKEN_RENEW_MAX_MS = 150 * 60_000;
/** Hard cap on every other REST call. axios's own timeout is a socket-idle /** Hard cap on every other REST call. axios's own timeout is a socket-idle
* timer that only starts once the socket has connected, so a proxy CONNECT * timer that only starts once the socket has connected, so a proxy CONNECT
* that never answers hangs the call — and any single-flight flag it holds, * that never answers hangs the call — and any single-flight flag it holds,
@@ -214,7 +210,7 @@ export class TopstepXClient implements PlatformClient {
private lastInboundAt = 0; private lastInboundAt = 0;
private syncInterval: ReturnType<typeof setInterval> | null = null; private syncInterval: ReturnType<typeof setInterval> | null = null;
private tokenRenewalTimer: ReturnType<typeof setTimeout> | null = null; private tokenRenewalInterval: ReturnType<typeof setInterval> | null = null;
private ocoPollInterval: ReturnType<typeof setInterval> | null = null; private ocoPollInterval: ReturnType<typeof setInterval> | null = null;
private loginRetryTimer: ReturnType<typeof setTimeout> | null = null; private loginRetryTimer: ReturnType<typeof setTimeout> | null = null;
private watchdogInterval: ReturnType<typeof setInterval> | null = null; private watchdogInterval: ReturnType<typeof setInterval> | null = null;
@@ -382,8 +378,9 @@ export class TopstepXClient implements PlatformClient {
console.log(`[topstepx] logged in as ${this.name}`); console.log(`[topstepx] logged in as ${this.name}`);
delete this.lastFetchErrors['login']; delete this.lastFetchErrors['login'];
// Session tokens live 24h; re-validate every hour or two to stay fresh. // Session tokens live 24h; re-validate every hour to stay fresh.
this.scheduleRenewal(); if (this.tokenRenewalInterval) clearInterval(this.tokenRenewalInterval);
this.tokenRenewalInterval = setInterval(() => void this.renewToken(), 60 * 60 * 1000);
if (!this.syncInterval) { if (!this.syncInterval) {
// First login. The hub is never a hard dependency: REST polling // First login. The hub is never a hard dependency: REST polling
@@ -478,22 +475,6 @@ export class TopstepXClient implements PlatformClient {
return data.token; return data.token;
} }
/** Arms the one renewal timer at a fresh random wait (replacing any
* pending); each renewal arms the next. disconnect() stops the chain. */
private scheduleRenewal(): void {
if (this.tokenRenewalTimer) clearTimeout(this.tokenRenewalTimer);
this.tokenRenewalTimer = null;
if (this.intentionalDisconnect) return;
const wait = TOKEN_RENEW_MIN_MS + Math.random() * (TOKEN_RENEW_MAX_MS - TOKEN_RENEW_MIN_MS);
this.tokenRenewalTimer = setTimeout(() => {
this.tokenRenewalTimer = null;
// A re-login it starts arms its own timer; otherwise arm the next here.
void this.renewToken().finally(() => {
if (!this.tokenRenewalTimer) this.scheduleRenewal();
});
}, wait);
}
private async renewToken(): Promise<void> { private async renewToken(): Promise<void> {
if (this.intentionalDisconnect || this.loginInFlight) return; if (this.intentionalDisconnect || this.loginInFlight) return;
if (!this.token) { void this.connectAndAuth(); return; } if (!this.token) { void this.connectAndAuth(); return; }
@@ -1956,7 +1937,7 @@ export class TopstepXClient implements PlatformClient {
public disconnect(): void { public disconnect(): void {
this.intentionalDisconnect = true; this.intentionalDisconnect = true;
if (this.syncInterval) { clearInterval(this.syncInterval); this.syncInterval = null; } if (this.syncInterval) { clearInterval(this.syncInterval); this.syncInterval = null; }
if (this.tokenRenewalTimer) { clearTimeout(this.tokenRenewalTimer); this.tokenRenewalTimer = null; } if (this.tokenRenewalInterval) { clearInterval(this.tokenRenewalInterval); this.tokenRenewalInterval = null; }
if (this.ocoPollInterval) { clearInterval(this.ocoPollInterval); this.ocoPollInterval = null; } if (this.ocoPollInterval) { clearInterval(this.ocoPollInterval); this.ocoPollInterval = null; }
if (this.loginRetryTimer) { clearTimeout(this.loginRetryTimer); this.loginRetryTimer = null; } if (this.loginRetryTimer) { clearTimeout(this.loginRetryTimer); this.loginRetryTimer = null; }
if (this.watchdogInterval) { clearInterval(this.watchdogInterval); this.watchdogInterval = null; } if (this.watchdogInterval) { clearInterval(this.watchdogInterval); this.watchdogInterval = null; }
@@ -1965,17 +1946,5 @@ export class TopstepXClient implements PlatformClient {
this.daysFetchInFlightSince = 0; this.daysFetchInFlightSince = 0;
this.nextDaysFetchRetryAt = 0; this.nextDaysFetchRetryAt = 0;
this.discardHub(); this.discardHub();
// Logged out for good: a snapshot left in the pool (session rest)
// must not be able to reach the gateway.
this.token = null;
}
/** See PlatformClient: what the session still waits on besides positions
* and working orders. */
public restBlocker(): string | null {
if (this.ocoPairs.some((p) => !p.done)) return 'an exit pair is still tracked';
if (this.fillWaiters.length > 0) return `${this.fillWaiters.length} fill(s) awaited`;
if (this.daysFetchInFlight) return 'a history fetch is underway';
return null;
} }
} }
-111
View File
@@ -1789,117 +1789,6 @@ export function isPastSessionClose(now: Date, everyDay: boolean): boolean {
return minutes >= 14 * 60 + 55 && minutes < 17 * 60; return minutes >= 14 * 60 + 55 && minutes < 17 * 60;
} }
// ── broker session rest ──────────────────────────────────────────────────────
//
// A firm's broker session is logged out while nothing can trade on it (the
// weekend, the daily break, a trade date whose accounts are all done) and
// logged back in ahead of the next session. Every wait below is drawn at
// random, so neither the logins nor the renewals in between keep a clock.
// lib/clients.ts decides when a session is idle; these are the times.
/** A rested session logs back in this long before its next session opens. */
export const REST_LEAD_MIN_MS = 20 * 60_000;
export const REST_LEAD_MAX_MS = 50 * 60_000;
/** An idle session stays up this long before it rests. */
export const REST_LINGER_MIN_MS = 15 * 60_000;
export const REST_LINGER_MAX_MS = 45 * 60_000;
/** A rest shorter than this isn't worth a fresh login. */
export const REST_MIN_MS = 45 * 60_000;
/** A long rest is broken by a check-in this long after the last login, in
* the Central daytime: it refreshes balances and finds new accounts. */
export const CHECK_IN_GAP_MIN_MS = 3 * 60 * 60_000;
export const CHECK_IN_GAP_MAX_MS = 7 * 60 * 60_000;
const CHECK_IN_DAY_START_MIN = 8 * 60;
const CHECK_IN_DAY_END_MIN = 22 * 60;
/** A check-in that would fall this close to the wake is left out. */
const CHECK_IN_WAKE_MARGIN_MS = 2 * 60 * 60_000;
/** How far ahead nextSessionOpenAt looks. */
const NEXT_OPEN_HORIZON_MS = 8 * 24 * 60 * 60_000;
/** A uniform draw in [min, max). */
export function drawBetween(min: number, max: number, rand: () => number = Math.random): number {
return min + rand() * (max - min);
}
/**
* The first whole minute at or after `from` when the firm may open a new
* trade (isNoTradeTime false), or null if none within 8 days. With
* `afterTradeDate`, only a minute on a later trade date counts: that day's
* accounts are all done. `jitterFor` is the open delay for the Central day of
* a moment; give 0 for a day whose delay isn't drawn yet, the earliest it
* could open.
*/
export function nextSessionOpenAt(
from: number,
o: { everyDay: boolean; mode: string; jitterFor: (at: Date) => number },
afterTradeDate: string | null = null,
): number | null {
const opens = (t: number): boolean => {
const at = new Date(t);
if (afterTradeDate !== null && currentTradeDateCT(at) <= afterTradeDate) return false;
return !isNoTradeTime(at, { everyDay: o.everyDay, mode: o.mode, jitterMin: o.jitterFor(at) });
};
const STEP = 5 * 60_000;
let t = Math.ceil(from / 60_000) * 60_000;
if (opens(t)) return t;
const limit = t + NEXT_OPEN_HORIZON_MS;
// Sessions and breaks last hours, so a 5-minute stride can't step over
// one; the minute it opened is then found inside the stride.
for (; t < limit; t += STEP) {
if (!opens(t + STEP)) continue;
for (let m = t + 60_000; m < t + STEP; m += 60_000) if (opens(m)) return m;
return t + STEP;
}
return null;
}
/**
* When a check-in should break a rest that starts `now` and ends at `wakeAt`
* (null: no planned end), or null for none: 3–7 hours on, moved to 8–11 AM
* Central when that lands at night, and never within 2 hours of the wake.
*/
export function planCheckIn(now: number, wakeAt: number | null, rand: () => number = Math.random): number | null {
let at = now + drawBetween(CHECK_IN_GAP_MIN_MS, CHECK_IN_GAP_MAX_MS, rand);
const { minutes } = centralClock(new Date(at));
if (minutes < CHECK_IN_DAY_START_MIN || minutes >= CHECK_IN_DAY_END_MIN) {
const toMorning = ((CHECK_IN_DAY_START_MIN - minutes + 1440) % 1440) * 60_000;
at += toMorning + drawBetween(0, 3 * 60 * 60_000, rand);
// A clock change overnight puts the morning an hour off.
const landed = centralClock(new Date(at)).minutes;
if (landed < CHECK_IN_DAY_START_MIN) at += (CHECK_IN_DAY_START_MIN - landed) * 60_000;
}
if (wakeAt !== null && at > wakeAt - CHECK_IN_WAKE_MARGIN_MS) return null;
return Math.round(at);
}
/**
* A rest starting `now` for a session whose next trading opens at `opensAt`
* (null: nothing trades until someone starts it): when to log back in, a
* check-in on the way, and the lead drawn — or null when the open is too
* close for a rest to be worth a fresh login.
*/
export function planRest(
now: number,
opensAt: number | null,
rand: () => number = Math.random,
): { wakeAt: number | null; checkInAt: number | null; leadMs: number } | null {
const leadMs = Math.round(drawBetween(REST_LEAD_MIN_MS, REST_LEAD_MAX_MS, rand));
const wakeAt = opensAt === null ? null : opensAt - leadMs;
if (wakeAt !== null && wakeAt - now < REST_MIN_MS) return null;
return { wakeAt, checkInAt: planCheckIn(now, wakeAt, rand), leadMs };
}
/**
* A resting session's snapshot, last synced at `lastSyncAt`, is from an
* earlier trade date than `now`: its realized P&L, DLL lockouts and legs are
* the old day's, and counted as today's they would read as accounts already
* traded. It reports as unsynced until the session logs back in.
*/
export function restSnapshotStale(lastSyncAt: number, now: number = Date.now()): boolean {
if (!(lastSyncAt > 0)) return true;
return currentTradeDateCT(new Date(lastSyncAt)) !== currentTradeDateCT(new Date(now));
}
/** /**
* A firm's cap on how many of its accounts trade at once, on top of the * A firm's cap on how many of its accounts trade at once, on top of the
* machine's own max concurrent accounts: keeps `items` in order, at most * machine's own max concurrent accounts: keeps `items` in order, at most
+6 -38
View File
@@ -97,14 +97,6 @@ const INBOUND_SILENCE_MS = 150_000;
const STUCK_CLOSING_MS = 15_000; const STUCK_CLOSING_MS = 15_000;
const TOKEN_RENEW_CHECK_MS = 2 * 60_000; const TOKEN_RENEW_CHECK_MS = 2 * 60_000;
const TOKEN_RENEW_WINDOW_MS = 15 * 60_000; const TOKEN_RENEW_WINDOW_MS = 15 * 60_000;
/** Each token is renewed at a point drawn between these two lengths before
* its expiry, so renewals keep no fixed rhythm (TOKEN_RENEW_WINDOW_MS is the
* latest one may wait). */
const TOKEN_RENEW_EARLIEST_MS = 45 * 60_000;
/** With the expiry unknown or untrusted, renew this long after the token
* arrived instead (drawn) — well inside Tradovate's 90-minute tokens. */
const TOKEN_BLIND_RENEW_MIN_MS = 10 * 60_000;
const TOKEN_BLIND_RENEW_MAX_MS = 25 * 60_000;
/** A failed renewal this close to expiry logs in afresh instead of waiting. */ /** A failed renewal this close to expiry logs in afresh instead of waiting. */
const TOKEN_RELOGIN_WINDOW_MS = 2 * 60_000; const TOKEN_RELOGIN_WINDOW_MS = 2 * 60_000;
/** A reconnect reuses the current token only if it has at least this long left. */ /** A reconnect reuses the current token only if it has at least this long left. */
@@ -434,8 +426,6 @@ export class TradovateClient {
private workingOrders: { [accountId: number]: number } = {}; private workingOrders: { [accountId: number]: number } = {};
private livenessInterval: ReturnType<typeof setInterval> | null = null; private livenessInterval: ReturnType<typeof setInterval> | null = null;
private renewInFlight = false; private renewInFlight = false;
/** When the current token is to be renewed (drawn in noteTokenExpiry()). */
private renewAt = 0;
/** The current token's expirationTime was (nearly) past on arrival: this /** The current token's expirationTime was (nearly) past on arrival: this
* machine's clock is off, so the expiry is ignored (noteTokenExpiry()). */ * machine's clock is off, so the expiry is ignored (noteTokenExpiry()). */
private tokenExpiryUntrusted = false; private tokenExpiryUntrusted = false;
@@ -1853,26 +1843,17 @@ export class TradovateClient {
* Tradovate's, not that the token is dead. Trusting that expiry would have * Tradovate's, not that the token is dead. Trusting that expiry would have
* the watchdog "expire" every fresh token and log in again every 15s (a * the watchdog "expire" every fresh token and log in again every 15s (a
* rate-limit penalty, then a captcha, for every client on this IP). * rate-limit penalty, then a captcha, for every client on this IP).
* Treat the expiry as unknown instead: renew 10–25 minutes after each * Treat the expiry as unknown instead: renew every 2 minutes and log in on
* token arrives (this clock's differences still hold) and log in on * reconnect, as the code did before it read the expiry at all.
* reconnect.
*
* Also draws when this token is renewed: a random 15–45 minutes before it
* expires, so the renewals don't tick like a clock.
*/ */
private noteTokenExpiry(): void { private noteTokenExpiry(): void {
const raw = this.accessInfo?.expirationTime; const raw = this.accessInfo?.expirationTime;
const now = Date.now();
const t = raw ? new Date(raw).getTime() : NaN; const t = raw ? new Date(raw).getTime() : NaN;
const untrusted = Number.isFinite(t) && t - now < TOKEN_RENEW_WINDOW_MS; const untrusted = Number.isFinite(t) && t - Date.now() < TOKEN_RENEW_WINDOW_MS;
if (untrusted && !this.tokenExpiryUntrusted) { if (untrusted && !this.tokenExpiryUntrusted) {
console.warn(`${this.tag} new token expires ${raw}, which is (nearly) past by this machine's clock — check the system clock; ignoring the expiry`); console.warn(`${this.tag} new token expires ${raw}, which is (nearly) past by this machine's clock — check the system clock; ignoring the expiry`);
} }
this.tokenExpiryUntrusted = untrusted; this.tokenExpiryUntrusted = untrusted;
const expiresAt = this.tokenExpiresAt();
this.renewAt = expiresAt > 0
? expiresAt - TOKEN_RENEW_WINDOW_MS - Math.random() * (TOKEN_RENEW_EARLIEST_MS - TOKEN_RENEW_WINDOW_MS)
: now + TOKEN_BLIND_RENEW_MIN_MS + Math.random() * (TOKEN_BLIND_RENEW_MAX_MS - TOKEN_BLIND_RENEW_MIN_MS);
} }
/** Holding a token known to stay valid for at least `ms` more. */ /** Holding a token known to stay valid for at least `ms` more. */
@@ -1880,13 +1861,13 @@ export class TradovateClient {
return !!this.accessInfo?.accessToken && this.tokenExpiresAt() > Date.now() + ms; return !!this.accessInfo?.accessToken && this.tokenExpiresAt() > Date.now() + ms;
} }
/** The 2-minute renewal check: renews once the token's drawn renewal /** The 2-minute renewal check. Never rejects, never overlaps itself. */
* time (noteTokenExpiry) has passed. Never rejects, never overlaps itself. */
private maybeRenewToken(): void { private maybeRenewToken(): void {
if (this.intentionalDisconnect || this.connecting || this.renewInFlight || !this.accessInfo?.accessToken) return; if (this.intentionalDisconnect || this.connecting || this.renewInFlight || !this.accessInfo?.accessToken) return;
const now = Date.now(); const now = Date.now();
const expiresAt = this.tokenExpiresAt(); const expiresAt = this.tokenExpiresAt();
if (now < this.renewAt) return; // An unparseable expiry renews anyway — an extra renewal beats a silent expiry.
if (expiresAt > 0 && expiresAt - now > TOKEN_RENEW_WINDOW_MS) return;
// Already expired: renewal can't save it; the watchdog / next connect logs in. // Already expired: renewal can't save it; the watchdog / next connect logs in.
if (expiresAt > 0 && expiresAt <= now) return; if (expiresAt > 0 && expiresAt <= now) return;
@@ -2297,19 +2278,6 @@ export class TradovateClient {
for (const info of watchers) info.cancel?.('client disconnected'); for (const info of watchers) info.cancel?.('client disconnected');
this.daysFetchInFlight = false; this.daysFetchInFlight = false;
this.nextDaysFetchRetryAt = 0; this.nextDaysFetchRetryAt = 0;
// Logged out for good: a snapshot left in the pool (session rest)
// must not be able to reach the broker. Blank, not removed — late
// readers use accessInfo?.accessToken and see a logged-out client.
if (this.accessInfo) this.accessInfo = { ...this.accessInfo, accessToken: '', mdAccessToken: '' };
}
/** See PlatformClient: what the session still waits on besides positions
* and working orders. */
public restBlocker(): string | null {
const pending = Object.keys(this.directEventCallbacks).length + this.indirectEventCallbacks.length;
if (pending > 0) return `${pending} request(s) awaiting an answer`;
if (this.daysFetchInFlight) return 'a report fetch is underway';
return null;
} }
/** Register a one-time callback for when a fill arrives for a given orderId. */ /** Register a one-time callback for when a fill arrives for a given orderId. */
+1 -1
View File
@@ -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-session-rest.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"
}, },
"dependencies": { "dependencies": {
"@microsoft/signalr": "^10.0.11", "@microsoft/signalr": "^10.0.11",
-181
View File
@@ -1,181 +0,0 @@
// Checks for the broker session rest times in lib/trading-logic.ts: when a
// rested session's next session opens, when it logs back in, and its
// check-ins (lib/clients.ts decides when a session is idle).
//
// npm run check
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
nextSessionOpenAt, planCheckIn, planRest, restSnapshotStale, isNoTradeTime, currentTradeDateCT,
REST_LEAD_MIN_MS, REST_LEAD_MAX_MS, REST_MIN_MS, CHECK_IN_GAP_MIN_MS, CHECK_IN_GAP_MAX_MS,
} from '../lib/trading-logic.ts';
const MIN = 60_000;
const HOUR = 60 * MIN;
const at = (iso) => Date.parse(iso);
const iso = (ms) => new Date(ms).toISOString();
const fullCme = { everyDay: false, mode: 'full_cme', jitterFor: () => 0 };
const equity = { everyDay: false, mode: 'equity_hours', jitterFor: () => 0 };
const everyDay = { everyDay: true, mode: 'full_cme', jitterFor: () => 0 };
function centralMinutes(ms) {
const parts = new Intl.DateTimeFormat('en-US', {
hour: 'numeric', minute: 'numeric', hour12: false, timeZone: 'America/Chicago',
}).formatToParts(new Date(ms));
const get = (t) => Number(parts.find((p) => p.type === t)?.value);
return (get('hour') % 24) * 60 + get('minute');
}
// Deterministic PRNG so a failure reproduces.
function rng(seed) {
let s = seed >>> 0;
return () => {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// ── nextSessionOpenAt ───────────────────────────────────────────────────────
test('full CME: a Saturday rest opens Sunday 5:45 PM CT', () => {
// Sat 2026-10-24 12:00 CDT
assert.equal(iso(nextSessionOpenAt(at('2026-10-24T17:00:00Z'), fullCme)), '2026-10-25T22:45:00.000Z');
});
test("full CME: the day's open delay moves the open", () => {
const jitterFor = () => 37;
assert.equal(iso(nextSessionOpenAt(at('2026-10-24T17:00:00Z'), { ...fullCme, jitterFor })), '2026-10-25T23:22:00.000Z');
});
test('full CME: Friday after the close waits for Sunday', () => {
// Fri 2026-10-23 3:30 PM CDT
assert.equal(iso(nextSessionOpenAt(at('2026-10-23T20:30:00Z'), fullCme)), '2026-10-25T22:45:00.000Z');
});
test('full CME: the daily break opens at 5:45 PM the same evening', () => {
// Mon 2026-10-19 3:00 PM CDT
assert.equal(iso(nextSessionOpenAt(at('2026-10-19T20:00:00Z'), fullCme)), '2026-10-19T22:45:00.000Z');
});
test('full CME: an open market is open now (to the minute)', () => {
// Tue 2026-10-20 10:00:30 CDT
assert.equal(iso(nextSessionOpenAt(at('2026-10-20T15:00:30Z'), fullCme)), '2026-10-20T15:01:00.000Z');
});
test('full CME: every account done for the trade date waits for the next one', () => {
// Mon 2026-10-19 11 PM CDT is trade date 10-20; done → Tue 5:45 PM (trade date 10-21).
const from = at('2026-10-20T04:00:00Z');
assert.equal(currentTradeDateCT(new Date(from)), '2026-10-20');
const open = nextSessionOpenAt(from, fullCme, '2026-10-20');
assert.equal(iso(open), '2026-10-20T22:45:00.000Z');
assert.equal(currentTradeDateCT(new Date(open)), '2026-10-21');
});
test('full CME: a Friday trade date done waits for Sunday', () => {
// Fri 2026-10-23 9 AM CDT, trade date 10-23 done.
assert.equal(iso(nextSessionOpenAt(at('2026-10-23T14:00:00Z'), fullCme, '2026-10-23')), '2026-10-25T22:45:00.000Z');
});
test('full CME: across the DST change the open stays 5:45 PM Central', () => {
// Sat 2026-10-31 noon CDT → Sun 2026-11-01 5:45 PM CST (23:45Z).
assert.equal(iso(nextSessionOpenAt(at('2026-10-31T17:00:00Z'), fullCme)), '2026-11-01T23:45:00.000Z');
});
test('equity hours: Friday afternoon waits for Monday 8:35 AM', () => {
assert.equal(iso(nextSessionOpenAt(at('2026-10-23T20:30:00Z'), equity)), '2026-10-26T13:35:00.000Z');
});
test('equity hours: a done day waits for the next morning', () => {
// Tue 2026-10-20 10 AM CDT, trade date 10-20 done → Wed 8:35 AM.
assert.equal(iso(nextSessionOpenAt(at('2026-10-20T15:00:00Z'), equity, '2026-10-20')), '2026-10-21T13:35:00.000Z');
});
test('every-day firm: open on Saturday; done waits for 5:05 PM CT', () => {
const sat = at('2026-10-24T17:00:00Z');
assert.equal(nextSessionOpenAt(sat, everyDay), sat);
assert.equal(iso(nextSessionOpenAt(sat, everyDay, '2026-10-24')), '2026-10-24T22:05:00.000Z');
// In CST the firm resets at 4 PM, but entries still reopen at 5:05 PM.
assert.equal(iso(nextSessionOpenAt(at('2026-11-07T18:00:00Z'), everyDay, '2026-11-07')), '2026-11-07T23:05:00.000Z');
});
test('nextSessionOpenAt lands on the first open minute, on a later trade date when asked', () => {
const rand = rng(7);
const start = at('2026-10-18T00:00:00Z');
for (let i = 0; i < 120; i++) {
const from = start + Math.floor(rand() * 21 * 24 * HOUR);
for (const o of [fullCme, equity, everyDay]) {
const after = rand() < 0.5 ? currentTradeDateCT(new Date(from)) : null;
const open = nextSessionOpenAt(from, o, after);
assert.ok(open !== null && open >= from, `${iso(from)} → ${open}`);
const opens = (t) => !isNoTradeTime(new Date(t), { everyDay: o.everyDay, mode: o.mode, jitterMin: 0 })
&& (after === null || currentTradeDateCT(new Date(t)) > after);
assert.ok(opens(open), `${iso(from)} → ${iso(open)} isn't open`);
if (open - MIN >= from) assert.ok(!opens(open - MIN), `${iso(from)} → ${iso(open)} opened earlier`);
}
}
});
// ── planCheckIn / planRest ──────────────────────────────────────────────────
test('a check-in comes 3–7 hours on, in the Central daytime, never close to the wake', () => {
const rand = rng(11);
const start = at('2026-10-19T00:00:00Z');
for (let i = 0; i < 2000; i++) {
const now = start + Math.floor(rand() * 14 * 24 * HOUR);
const checkIn = planCheckIn(now, null, rand);
assert.ok(checkIn !== null);
assert.ok(checkIn >= now + CHECK_IN_GAP_MIN_MS, `${iso(now)} → ${iso(checkIn)} too soon`);
const minutes = centralMinutes(checkIn);
assert.ok(minutes >= 8 * 60 && minutes < 22 * 60, `${iso(checkIn)} is at night`);
// Within the gap, or moved to the next morning.
if (checkIn > now + CHECK_IN_GAP_MAX_MS) assert.ok(minutes < 11 * 60 + 1, `${iso(checkIn)} moved past the morning`);
}
const now = at('2026-10-19T15:00:00Z'); // Mon 10 AM CDT
assert.equal(planCheckIn(now, now + 4 * HOUR, () => 0), null); // 3h on is within 2h of the wake
assert.equal(planCheckIn(now, now + 6 * HOUR, () => 0), now + 3 * HOUR);
});
test('a check-in drawn at night moves to 8–11 AM Central', () => {
const now = at('2026-10-20T01:00:00Z'); // Mon 8 PM CDT
// +5h = 1 AM → next morning 8 AM + half of 3h = 9:30 AM CDT.
assert.equal(iso(planCheckIn(now, null, () => 0.5)), '2026-10-20T14:30:00.000Z');
});
test('planRest: logs back in 20–50 min before the open, or not at all when it is too close', () => {
const now = at('2026-10-24T17:00:00Z');
const opensAt = at('2026-10-25T22:45:00Z');
const early = planRest(now, opensAt, () => 0);
assert.equal(early.wakeAt, opensAt - REST_LEAD_MIN_MS);
assert.equal(early.leadMs, REST_LEAD_MIN_MS);
const late = planRest(now, opensAt, () => 0.999999);
assert.ok(Math.abs(late.wakeAt - (opensAt - REST_LEAD_MAX_MS)) < 1_000);
// An open within lead + REST_MIN_MS isn't worth a rest.
assert.equal(planRest(now, now + REST_LEAD_MIN_MS + REST_MIN_MS - MIN, () => 0), null);
assert.ok(planRest(now, now + REST_LEAD_MIN_MS + REST_MIN_MS, () => 0) !== null);
// Nothing scheduled: rests until a command, with a check-in.
const open = planRest(now, null, () => 0);
assert.equal(open.wakeAt, null);
assert.ok(open.checkInAt !== null);
});
test('rests drawn for the same open log back in at different times', () => {
const rand = rng(3);
const opensAt = at('2026-10-25T22:45:00Z');
const wakes = new Set();
for (let i = 0; i < 50; i++) wakes.add(Math.round(planRest(at('2026-10-24T17:00:00Z'), opensAt, rand).wakeAt / MIN));
assert.ok(wakes.size >= 20, `only ${wakes.size} distinct wake minutes`);
});
test("a resting snapshot from before the 5 PM roll is stale; one from today's trade date isn't", () => {
// 2026-10-21: Nik's Topstep last synced 3:29 PM CT, read at 5:31 PM CT.
assert.equal(restSnapshotStale(at('2026-10-21T20:29:00Z'), at('2026-10-21T22:31:00Z')), true);
// Either side of the roll, a minute apart.
assert.equal(restSnapshotStale(at('2026-10-21T21:59:00Z'), at('2026-10-21T22:01:00Z')), true);
// Rested after the evening open, read the next afternoon: the same trade date.
assert.equal(restSnapshotStale(at('2026-10-22T00:30:00Z'), at('2026-10-22T19:00:00Z')), false);
// Never synced.
assert.equal(restSnapshotStale(0, at('2026-10-22T19:00:00Z')), true);
});
-4
View File
@@ -174,9 +174,5 @@ export interface FirmState {
firmId: string; firmId: string;
firm: string; firm: string;
connected: boolean; connected: boolean;
/** The broker session is logged out while nothing needs it (session rest,
* lib/clients.ts): accounts show as last synced. wakeAt is when it logs
* back in by itself, null when only trading starting will. */
resting?: { since: number; wakeAt: number | null };
accounts: AccountState[]; accounts: AccountState[];
} }