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:
co-authored by
Claude Opus 5.5
parent
6dd2528fe2
commit
5eb0ea7760
+127
-12
@@ -7,14 +7,14 @@
|
||||
* signal but have since exited and are now eligible.
|
||||
*/
|
||||
|
||||
import { getClients } from './clients';
|
||||
import { fmtCentralTime, getClients, isResting, isSessionAway, isWaking, restingWakeAt, wakeAllFirms, wakeFirm } from './clients';
|
||||
import { getCachedFirm, getTradableFirms } from './firm-catalog';
|
||||
import { ensureRulesEvidence, getAccountConfig } from './account-type-resolver';
|
||||
import {
|
||||
combineCmeSessions, computeDayPlan, groupLegFraction, pickLegAmount, resolveEffectiveConfig, currentTradeDateCT,
|
||||
obfuscationStopDollars, takeProfitPlan, onTick, percentFee, dllRoom, dllStopDollars, dllViable, stopTicks,
|
||||
expectedSwapToWin, planLegSize, maxLossRoom, breachDistance,
|
||||
isNoTradeTime, isPastSessionClose,
|
||||
isNoTradeTime, isPastSessionClose, nextSessionOpenAt,
|
||||
BIG_DAY_THRESHOLD, LEG_COMPLETION_EPSILON, MAX_LEGS_PER_DAY,
|
||||
FALLBACK_COMMISSION_PER_CONTRACT, MAX_COMMISSION_PER_CONTRACT, DLL_MIN_STOP,
|
||||
CME_EVENING_OPEN_MIN, foldReason, withinFirmCaps,
|
||||
@@ -345,6 +345,9 @@ function firmClients(now = Date.now()): { ready: FirmClient[]; unready: FirmClie
|
||||
for (const firm of getTradableFirms()) {
|
||||
const client = clients.get(firm.id);
|
||||
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 });
|
||||
}
|
||||
return { ready, unready };
|
||||
@@ -570,6 +573,9 @@ interface AccountEvaluation {
|
||||
/** What the firm's day may still lose before a hard daily-loss breach
|
||||
* (Infinity = no limit). */
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -690,8 +696,8 @@ function evaluateAccountForTrade(client: any, acc: { id: number; name: string; a
|
||||
const daysTraded = combined.daysTraded;
|
||||
|
||||
const base = { plan: null, isExtraDay: false, drain: false, cash, dailyPnL, daysTraded, dllRoom: Infinity };
|
||||
if (isAccountDead(cash.amount, autoLiqThreshold)) return { ...base, eligible: false, reason: 'dead' };
|
||||
if (!acc.active) return { ...base, eligible: false, reason: 'inactive (DLL)' };
|
||||
if (isAccountDead(cash.amount, autoLiqThreshold)) return { ...base, eligible: false, reason: 'dead', doneForDay: true };
|
||||
if (!acc.active) return { ...base, eligible: false, reason: 'inactive (DLL)', doneForDay: true };
|
||||
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
|
||||
// won't show until the fill comes back, so the check above can't see it.
|
||||
@@ -709,11 +715,11 @@ function evaluateAccountForTrade(client: any, acc: { id: number; name: string; a
|
||||
if (cash.pendingRoll) return { ...base, eligible: false, reason: ROLL_PENDING };
|
||||
if (unknownEntryHolds(client, acc.id, tradeDate)) return { ...base, eligible: false, reason: ENTRY_UNKNOWN };
|
||||
if (runtime().exitRefused?.get(acc.id) === tradeDate) {
|
||||
return { ...base, eligible: false, reason: 'exit refused — flattened, benched for the day' };
|
||||
return { ...base, eligible: false, reason: 'exit refused — flattened, benched for the day', doneForDay: true };
|
||||
}
|
||||
|
||||
const booked = obfuscationDayBooked(cfg, acc.id, tradeDate, combined.realizedPnL);
|
||||
if (booked) return { ...base, eligible: false, reason: booked };
|
||||
if (booked) return { ...base, eligible: false, reason: booked, doneForDay: true };
|
||||
|
||||
const priorProfit = client.priorProfit?.[acc.id] ?? 0;
|
||||
const allFundTxns = client.fundTransactions?.[acc.id] ?? [];
|
||||
@@ -755,19 +761,19 @@ function evaluateAccountForTrade(client: any, acc: { id: number; name: string; a
|
||||
// Legacy one-and-done gate: any realized PnL today means this account is
|
||||
// finished — for a combining account, "today" spans the holiday stretch.
|
||||
if (combined.realizedPnL !== 0) {
|
||||
return { ...evaluated, eligible: false, reason: `already traded today (realizedPnL=${combined.realizedPnL})` };
|
||||
return { ...evaluated, eligible: false, reason: `already traded today (realizedPnL=${combined.realizedPnL})`, doneForDay: true };
|
||||
}
|
||||
// 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
|
||||
// yet). One of unknown outcome is held above until settled.
|
||||
if (entryOutcomeToday(acc.id, tradeDate)?.state === 'filled') {
|
||||
return { ...evaluated, eligible: false, reason: 'already traded today (entry filled)' };
|
||||
return { ...evaluated, eligible: false, reason: 'already traded today (entry filled)', doneForDay: true };
|
||||
}
|
||||
if (!isExtraDay && plan.remainingToday <= 0) {
|
||||
const reason = effective.drain && plan.dayTarget === 0
|
||||
? 'drain days booked — waiting for withdrawal'
|
||||
: `target=$${plan.dayTarget} (challenge complete)`;
|
||||
return { ...evaluated, eligible: false, reason };
|
||||
return { ...evaluated, eligible: false, reason, doneForDay: true };
|
||||
}
|
||||
} else {
|
||||
// Split-leg mode: keep trading until the day's remaining target is used up.
|
||||
@@ -777,10 +783,10 @@ function evaluateAccountForTrade(client: any, acc: { id: number; name: string; a
|
||||
const reason = combined.realizedPnL === 0
|
||||
? `target=$${plan.dayTarget} (challenge complete)`
|
||||
: `day done ($${plan.remainingToday} left of $${plan.dayTarget})`;
|
||||
return { ...evaluated, eligible: false, reason };
|
||||
return { ...evaluated, eligible: false, reason, doneForDay: true };
|
||||
}
|
||||
if (legsToday(acc.id, tradeDate) >= MAX_LEGS_PER_DAY) {
|
||||
return { ...evaluated, eligible: false, reason: `leg cap reached (${MAX_LEGS_PER_DAY} today)` };
|
||||
return { ...evaluated, eligible: false, reason: `leg cap reached (${MAX_LEGS_PER_DAY} today)`, doneForDay: true };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -789,7 +795,12 @@ function evaluateAccountForTrade(client: any, acc: { id: number; name: string; a
|
||||
// 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.
|
||||
const gate = dailyLossGate(client, acc.id, cfg, cash, combined.realizedPnL, plan.remainingToday, isExtraDay || effective.drain);
|
||||
if ('reason' in gate) return { ...evaluated, eligible: false, reason: gate.reason };
|
||||
if ('reason' in gate) {
|
||||
// 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 };
|
||||
|
||||
return { ...evaluated, eligible: true, reason: '' };
|
||||
@@ -798,6 +809,86 @@ 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 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** The longest random delay added to the session open, in minutes. */
|
||||
@@ -1583,6 +1674,9 @@ async function fireLeg(
|
||||
*/
|
||||
export async function copyTrade() {
|
||||
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
|
||||
.find(({ client }) => client.accountList.some((acc) => !!client.positions[acc.id]));
|
||||
if (!positioned) {
|
||||
@@ -1782,6 +1876,11 @@ async function copyTradeLocked(lease: number, lane: string): Promise<unknown[]>
|
||||
* instrument and side.
|
||||
*/
|
||||
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));
|
||||
if (!found) throw new Error('account not found on a synced client');
|
||||
const { firm, client } = found;
|
||||
@@ -1885,6 +1984,10 @@ function hasRemainingConfiguredAccounts(): boolean {
|
||||
// the ready firms every session after — exactly what the operator opted
|
||||
// out of. (Before an open with every ready account already done, this
|
||||
// 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) {
|
||||
if (isInNoTradeWindow(firm)) continue;
|
||||
if (client.lastSyncAt === 0 && client.accountList.length === 0) return true;
|
||||
@@ -1966,6 +2069,10 @@ export function startScheduler(action: 'Buy' | 'Sell' | 'Auto', symbol: string,
|
||||
state.running = true;
|
||||
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 () => {
|
||||
if (!state.running) return;
|
||||
state.lastRun = new Date();
|
||||
@@ -2019,6 +2126,14 @@ export function startScheduler(action: 'Buy' | 'Sell' | 'Auto', symbol: string,
|
||||
console.log(`[scheduler] ${laneName(lane)}: ${openPositions} position(s) still open — skipping tick`);
|
||||
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);
|
||||
record(lane, outcome, detail ?? outcome, reasons);
|
||||
filled += results.flatMap((r) => r.results).filter((r) => r.status === 'filled').length;
|
||||
|
||||
Reference in New Issue
Block a user