Remove broker session rest
Reverts062ec07and5eb0ea7: 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:
co-authored by
Claude Opus 5.5
parent
062ec07f0a
commit
5ec5abde6e
@@ -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,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
@@ -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(),
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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}`);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 }[];
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
@@ -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[];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user