Broker sessions rest while nothing needs them

A firm's client used to stay logged in around the clock, renewing on a fixed
timer overnight and at weekends. Now, once a firm has nothing open, nothing
pending and nothing left to trade before its next session for a random
15–45 minutes, its client disconnects and the dashboard shows it Resting.
It logs back in a random 20–50 minutes before the next session opens, at a
check-in every 3–7 Central daytime hours, or at once when trading starts, a
manual leg, copy trade, manual order or AutoBuyer run needs it. Token
renewals fall at random points (Tradovate 15–45 min before expiry, TopstepX
every 45–150 min). Settings → Trading → Rest idle broker sessions
(session_rest) turns it off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-10-21 13:28:47 -05:00
co-authored by Claude Opus 5.5
parent 6dd2528fe2
commit 5eb0ea7760
18 changed files with 872 additions and 41 deletions
+6 -1
View File
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { getCachedFirm } from '@/lib/firm-catalog';
import { firmInstruments, isFuturesFirm } from '@/lib/instruments';
import { getClients } from '@/lib/clients';
import { getClients, isWaking, wakeFirm } from '@/lib/clients';
export async function POST(
req: NextRequest,
@@ -27,6 +27,11 @@ export async function POST(
const clients = getClients();
const client = clients.get(firmId);
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
// page) names.
+9 -5
View File
@@ -1,7 +1,7 @@
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { NextResponse } from 'next/server';
import { getClients } from '@/lib/clients';
import { getClients, sessionRestEnabled, sessionRestInfo } from '@/lib/clients';
import { getTradableFirms } from '@/lib/firm-catalog';
import { getSchedulerHealth, getSchedulerStatus } from '@/lib/auto-trade';
import { getBuildVersion } from '@/lib/version';
@@ -41,20 +41,24 @@ export async function GET() {
const entries = getTradableFirms().map((firm) => {
const base = { firmId: firm.id, firm: firm.name, platform: firm.platform };
const client = clients.get(firm.id);
if (!client) return { ...base, hasClient: false, ready: false, accounts: 0 };
if (!client) return { ...base, hasClient: false, ready: false, resting: null, accounts: 0 };
const ready = isClientReady(client, now);
const accounts = client.accountList.length;
// Logged out on purpose while nothing needs it (lib/clients.ts):
// not ready, and nothing wrong.
const resting = sessionRestInfo(firm.id);
try {
return { ...base, hasClient: true, ready, accounts, ...client.health() };
return { ...base, hasClient: true, ready, resting, accounts, ...client.health() };
} catch (err) {
// One client's broken health() must not hide the rest.
return { ...base, hasClient: true, ready, accounts, healthError: errorMessage(err) };
return { ...base, hasClient: true, ready, resting, accounts, healthError: errorMessage(err) };
}
});
return NextResponse.json({
ok: entries.every((e) => !e.hasClient || e.ready),
ok: entries.every((e) => !e.hasClient || e.ready || !!e.resting),
sessionRest: sessionRestEnabled(),
now,
uptimeSec: Math.round(process.uptime()),
version: getBuildVersion(),
+1 -1
View File
@@ -3,7 +3,7 @@ import { getSetting, setSetting } from '@/lib/db';
import { scheduleBackupPush } from '@/lib/backup-sync';
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'] 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', 'session_rest'] as const;
type SettingKey = typeof VALID_KEYS[number];
export async function GET() {
+5 -3
View File
@@ -1,6 +1,6 @@
import { NextResponse } from 'next/server';
import { listAssignments, loadDailyPnL } from '@/lib/db';
import { getClients } from '@/lib/clients';
import { getClients, restingWakeAt, sessionRestInfo } from '@/lib/clients';
import { getAccountTypeById, getCachedCatalog } from '@/lib/firm-catalog';
import { ruleDiffs } from '@/lib/kept-rules';
import { getAccountConfig, getInferredSize, matchAccountType } from '@/lib/account-type-resolver';
@@ -27,8 +27,10 @@ export async function GET() {
// A firm with no accounts is a real, settled state and must not be
// reported the same way as one that is still authenticating.
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) {
return { firmId: f.id, firm: f.name, connected, accounts: [] };
return { firmId: f.id, firm: f.name, connected, ...resting, accounts: [] };
}
// Accounts whose rules date was set by hand — one read per firm
// rather than one per account.
@@ -314,7 +316,7 @@ export async function GET() {
keptRules,
};
});
return { firmId: f.id, firm: f.name, connected, accounts };
return { firmId: f.id, firm: f.name, connected, ...resting, accounts };
});
return NextResponse.json(state);
+12 -2
View File
@@ -526,7 +526,17 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo
Username missing
</span>
)}
{hasCredentials && !state.connected && (
{hasCredentials && state.resting && (
<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">
Connecting…
</span>
@@ -594,7 +604,7 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo
state.accounts.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 py-5 pl-12 text-sm text-slate-400 italic">
{state.connected ? 'No accounts at this firm' : 'Connecting…'}
{state.connected || state.resting ? 'No accounts at this firm' : 'Connecting…'}
</td>
</tr>
) : (
+21 -1
View File
@@ -45,6 +45,7 @@ interface AppSettings {
instance_name: string | null;
instance_id: string | null;
trading_hours: string | null;
session_rest: string | null;
master_backup_token: string | null;
proxy_url: string | null;
user_agent: string | null;
@@ -64,6 +65,7 @@ export default function SettingsPage() {
const [contracts, setContracts] = useState<Record<string, ResolvedContract | null>>({});
const [maxConcurrent, setMaxConcurrent] = useState<string>('5');
const [tradingHours, setTradingHours] = useState<string>('full_cme');
const [sessionRest, setSessionRest] = useState<boolean>(true);
const [masterUrl, setMasterUrl] = useState<string>('');
const [catalog, setCatalog] = useState<{
version: number;
@@ -114,6 +116,7 @@ export default function SettingsPage() {
if (s.max_concurrent_accounts != null) {
setMaxConcurrent(s.max_concurrent_accounts);
}
setSessionRest(s.session_rest !== '0');
if (s.trading_hours != null) {
setTradingHours(s.trading_hours);
}
@@ -161,7 +164,7 @@ export default function SettingsPage() {
await fetch('/api/settings', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ max_concurrent_accounts: maxConcurrent, trading_hours: tradingHours, master_dashboard_url: masterUrl, instance_name: instanceName, master_backup_token: backupToken, proxy_url: proxyUrl }),
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 }),
});
setSaving(false);
setSaved(true);
@@ -280,6 +283,23 @@ export default function SettingsPage() {
<option value="equity_hours">Equity Hours (8:30 AM – 3:00 PM CT)</option>
</select>
</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>
{/* ── Master Dashboard ── */}