Report each tick's outcome and the updater's state to the master

The master saw a scheduler's last run time and nothing of what it did. Each
tick now records, per lane, its outcome, a line on what happened (what
fired, or the spread, contract or hold cap that stopped it) and why the
lane's accounts sat it out, counted by reason with a few names each. It
rides the scheduler status as lastTicks.

Each report also carries the auto-updater's state from the files it keeps
and from git: its last check, a failed update waiting to retry, how far the
checkout is behind origin, and tracked files edited by hand, which stop the
fast-forward pull.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-09-27 18:31:21 -05:00
co-authored by Claude Opus 5.5
parent db0ad99a76
commit ab3132182a
7 changed files with 253 additions and 21 deletions
+106 -17
View File
@@ -18,10 +18,12 @@ import {
isNoTradeTime, isPastSessionClose,
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,
type DayPlan,
} from './trading-logic';
import { getSetting, setSetting } from './db';
import { POINT_VALUES, housekeepingRoot } from './instrument-specs';
import type { LaneTick, TickReason } from './master-protocol';
import { POINT_VALUES, housekeepingRoot, MAX_HOLD_HOURS } from './instrument-specs';
import { firmInstruments, drawFuturesInstrument, spreadBlock, type LegShape } from './instruments';
import { errorMessage, isClientReady, isOrderNotPlaced, type PlatformClient, type PlatformContract, type PickContext } from './platform-client';
@@ -69,6 +71,9 @@ interface TradeRuntime {
lastTickOutcome: TickOutcome;
/** The last tick's outcome per lane. */
laneOutcomes?: Map<string, TickOutcome>;
/** The last tick per lane as the master sees it: what happened and why
* accounts sat it out (getSchedulerStatus). */
laneTicks?: Map<string, LaneTick>;
lastFireAt: number;
holding: string[];
quarantined: string[];
@@ -780,14 +785,50 @@ export async function runTrade(action: 'Buy' | 'Sell' | 'Auto', symbol: string):
return results;
}
/** What a lane's batch decided: its outcome, what it traded, and for the
* master a line on what happened and why accounts sat it out. */
type BatchOutcome = { outcome: TradeOutcome; results: FirmTradeResults[]; detail?: string; reasons?: TickReason[] };
/** Accounts that sat a batch out, by reason. Dollar amounts are folded so
* like reasons group ("day done ($12 left of $300)" and "($40 left of $500)"). */
type ReasonTally = Map<string, { count: number; accounts: string[] }>;
function tally(reasons: ReasonTally, reason: string, who: string): void {
const key = foldReason(reason);
const entry = reasons.get(key) ?? { count: 0, accounts: [] };
entry.count++;
if (entry.accounts.length < 3) entry.accounts.push(who);
reasons.set(key, entry);
}
function reasonList(reasons: ReasonTally): TickReason[] {
return [...reasons.entries()]
.map(([reason, { count, accounts }]) => ({ reason, count, accounts }))
.sort((a, b) => b.count - a.count)
.slice(0, 8);
}
const fmtCentralMinutes = (m: number): string => {
const h = Math.floor(m / 60) % 24;
return `${h % 12 || 12}:${String(m % 60).padStart(2, '0')} ${h < 12 ? 'AM' : 'PM'} CT`;
};
/** Why a lane is closed, for the master: when today's session opens. */
function closedDetail(lane: string): string {
if (lane !== FUTURES_LANE) return 'standing down around the daily roll';
const delay = todaysSessionOpenDelay();
const open = getSetting('trading_hours') === 'equity_hours' ? 8 * 60 + 35 + delay : CME_EVENING_OPEN_MIN + delay;
return `outside trading hours — today's open is ${fmtCentralMinutes(open)}`;
}
/** One lane's batch plus what it decided, which the tick records for getSchedulerHealth. */
async function runTradeBatch(
action: 'Buy' | 'Sell' | 'Auto',
symbol: string,
lane: string,
): Promise<{ outcome: TradeOutcome; results: FirmTradeResults[] }> {
): Promise<BatchOutcome> {
const lease = acquireTradeMutex('[auto-trade]', lane);
if (lease === null) return { outcome: 'skipped-in-flight', results: [] };
if (lease === null) return { outcome: 'skipped-in-flight', results: [], detail: 'another batch is still in flight' };
try {
return await runTradeLocked(action, symbol, lease, lane);
} finally {
@@ -822,11 +863,11 @@ async function runTradeLocked(
symbol: string,
lease: number,
lane: string,
): Promise<{ outcome: TradeOutcome; results: FirmTradeResults[] }> {
): Promise<BatchOutcome> {
const laneFirms = getTradableFirms().filter((f) => laneOf(f) === lane);
if (laneFirms.every((f) => isInNoTradeWindow(f))) {
console.log(`[auto-trade] ${laneName(lane)}: market closed — skipping`);
return { outcome: 'closed', results: [] };
return { outcome: 'closed', results: [], detail: closedDetail(lane) };
}
// Block if any account in the lane still has an open position (same gate
@@ -836,7 +877,7 @@ async function runTradeLocked(
const openPositions = countOpenPositions(laneReady);
if (openPositions > 0) {
console.log(`[auto-trade] ${laneName(lane)}: ${openPositions} position(s) still open — skipping`);
return { outcome: 'positions-open', results: [] };
return { outcome: 'positions-open', results: [], detail: `${openPositions} position(s) still open` };
}
const open = laneReady.filter(({ firm }) => !isInNoTradeWindow(firm));
@@ -858,16 +899,22 @@ async function runTradeLocked(
type Candidate = SizingItem & { daysTraded: number; isExtraDay: boolean };
type EligibleItem = LegItem & { daysTraded: number; isExtraDay: boolean };
const allEligible: Candidate[] = [];
// Why accounts sat this batch out (accounts with no type aren't ours),
// and what stopped the batch itself — for the master.
const reasons: ReasonTally = new Map();
const notes = new Set<string>();
for (const { firm, client } of open) {
if (client.accountList.length === 0) continue;
const listed = firmInstruments(firm);
if (listed.length === 0) {
console.log(`[auto-trade] ${firm.name}: no instruments listed — skipping firm`);
tally(reasons, 'its firm lists no instruments', firm.name);
continue;
}
if (explicit && !listed.includes(explicit)) {
console.log(`[auto-trade] ${firm.name}: ${explicit} isn't on its instrument list — skipping firm`);
tally(reasons, `${explicit} isn't on its firm's list`, firm.name);
continue;
}
// One firm's trouble (a malformed snapshot) skips that firm, never
@@ -875,7 +922,11 @@ async function runTradeLocked(
try {
for (const acc of client.accountList) {
const ev = evaluateAccountForTrade(client, acc, tradeDate);
if (!ev || !ev.eligible || !ev.plan) continue;
if (!ev) continue;
if (!ev.eligible || !ev.plan) {
tally(reasons, ev.reason || 'no day plan', acc.name);
continue;
}
allEligible.push({
firmName: firm.name, firm, client, acc,
cash: ev.cash, dailyPnL: ev.dailyPnL, daysTraded: ev.daysTraded,
@@ -886,12 +937,13 @@ async function runTradeLocked(
}
} catch (err) {
console.error(`[auto-trade] ${firm.name}: skipped — ${errorMessage(err)}`);
tally(reasons, `firm skipped — ${errorMessage(err)}`, firm.name);
}
}
if (allEligible.length === 0) {
console.log(`[auto-trade] ${laneName(lane)}: no eligible accounts found`);
return { outcome: 'no-eligible', results: [] };
return { outcome: 'no-eligible', results: [], detail: 'no eligible accounts', reasons: reasonList(reasons) };
}
// Take only the first batch — all fired simultaneously, no rolling pool.
@@ -932,7 +984,10 @@ async function runTradeLocked(
const tried: string[] = [];
for (let attempt = 0; attempt < 3 && batch.length === 0; attempt++) {
const root = explicit ?? await drawFuturesInstrument(allowed.filter((r) => !tried.includes(r)), shape);
if (!root) break;
if (!root) {
notes.add(`nothing on ${prefix || 'its'} list fits right now (spread or ${MAX_HOLD_HOURS}h hold cap)`);
break;
}
tried.push(root);
// Paying more than the master's limit to cross the book:
// not this tick (the draw left it out already; an explicit
@@ -940,6 +995,7 @@ async function runTradeLocked(
const wide = spreadBlock(root);
if (wide) {
console.log(`[auto-trade] ${wide} — not trading it this tick`);
notes.add(wide);
if (explicit) break;
continue;
}
@@ -950,11 +1006,13 @@ async function runTradeLocked(
const pv = contract?.pointValue ?? POINT_VALUES[root];
if (!contract || !pv) {
console.log(`[auto-trade] ${firm.name}: no ${root} contract — leaving its accounts out`);
notes.add(`${firm.name}: no ${root} contract`);
continue;
}
resolved.push(...pool.filter((it) => it.firm.id === firm.id).map((it) => ({ ...it, contract, pv })));
} catch (err) {
console.error(`[auto-trade] ${firm.name}: ${root} contract lookup failed — ${errorMessage(err)}`);
notes.add(`${firm.name}: ${root} contract lookup failed`);
}
}
if (resolved.length > 0) {
@@ -974,9 +1032,11 @@ async function runTradeLocked(
resolvedSymbol = String(contract.id);
} else {
console.log(`[auto-trade] ${firm.name}: nothing on its list can trade right now`);
notes.add(`${firm.name}: nothing on its list can trade right now`);
}
} catch (err) {
console.error(`[auto-trade] ${firm.name}: instrument pick failed — ${errorMessage(err)}`);
notes.add(`${firm.name}: instrument pick failed — ${errorMessage(err)}`);
}
}
if (batch.length > 0) {
@@ -987,7 +1047,11 @@ async function runTradeLocked(
if (batch.length === 0) {
console.log(`[auto-trade] ${laneName(lane)}: nothing tradable for any eligible group`);
return { outcome: 'no-eligible', results: [] };
return {
outcome: 'no-eligible', results: [],
detail: `${allEligible.length} eligible, nothing tradable${notes.size ? ` — ${[...notes].join('; ')}` : ''}`,
reasons: reasonList(reasons),
};
}
// Choosing took so long that another batch presumed this one wedged and
@@ -995,7 +1059,7 @@ async function runTradeLocked(
// firing now could trade an account twice.
if (!holdsTradeMutex(lease, lane)) {
console.warn('[auto-trade] superseded while collecting accounts — abandoning this batch');
return { outcome: 'skipped-in-flight', results: [] };
return { outcome: 'skipped-in-flight', results: [], detail: 'superseded by another batch while choosing' };
}
console.log(`[auto-trade] ${laneName(lane)}: ${allEligible.length} eligible account(s) across ${groups.size} prefix group(s) — firing batch of ${batch.length} ${batchPrefix || '(no prefix)'} on ${resolvedSymbol}`);
@@ -1057,9 +1121,19 @@ async function runTradeLocked(
);
}
const all = [...firmResultsMap.values()].flat();
const filledCount = all.filter((r) => r.status === 'filled').length;
const failed = all.filter((r) => r.status === 'error');
// Accounts left for a later batch aren't benched: the next tick fires them.
for (const item of allEligible) {
if (!batch.some((b) => b.acc.id === item.acc.id)) tally(reasons, 'eligible — waits for a later batch', item.acc.name);
}
return {
outcome: 'fired',
results: Array.from(firmResultsMap.entries()).map(([firm, results]) => ({ firm, results })),
detail: `fired ${batch.length} × ${batchPrefix || '(no prefix)'} on ${resolvedSymbol}: ${filledCount} filled`
+ (failed.length ? `, ${failed.length} failed (${String(failed[0].reason ?? '').slice(0, 120)})` : ''),
reasons: reasonList(reasons),
};
}
@@ -1770,13 +1844,25 @@ export function startScheduler(action: 'Buy' | 'Sell' | 'Auto', symbol: string,
}
const outcomes = new Map<string, TickOutcome>();
const ticks = new Map<string, LaneTick>();
const record = (lane: string, outcome: TickOutcome, detail: string, reasons?: TickReason[]) => {
outcomes.set(lane, outcome);
ticks.set(lane, {
lane: laneName(lane),
at: new Date(now).toISOString(),
outcome,
detail: detail.slice(0, 300),
...(reasons && reasons.length > 0 ? { reasons } : {}),
});
};
let filled = 0;
for (const lane of tradableLanes()) {
try {
const laneHolding = holding.filter((f) => laneOf(f) === lane);
if (laneHolding.length > 0) {
outcomes.set(lane, 'holding-for-sync');
console.log(`[scheduler] ${laneName(lane)}: waiting for sync: ${laneHolding.map((f) => f.name).join(', ')}`);
const names = laneHolding.map((f) => f.name).join(', ');
record(lane, 'holding-for-sync', `waiting for sync: ${names}`);
console.log(`[scheduler] ${laneName(lane)}: waiting for sync: ${names}`);
continue;
}
// Skip this lane if any of its accounts still has an open
@@ -1784,19 +1870,20 @@ export function startScheduler(action: 'Buy' | 'Sell' | 'Auto', symbol: string,
// when every (ready) account in the lane is flat.
const openPositions = countOpenPositions(inLane(ready, lane));
if (openPositions > 0) {
outcomes.set(lane, 'positions-open');
record(lane, 'positions-open', `${openPositions} position(s) still open`);
console.log(`[scheduler] ${laneName(lane)}: ${openPositions} position(s) still open — skipping tick`);
continue;
}
const { outcome, results } = await runTradeBatch(state.action, state.symbol, lane);
outcomes.set(lane, outcome);
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;
} catch (err) {
outcomes.set(lane, `error: ${errorMessage(err)}`);
record(lane, `error: ${errorMessage(err)}`, errorMessage(err));
console.error(`[scheduler] ${laneName(lane)}: tick error: ${errorMessage(err)}`);
}
}
rt.laneOutcomes = outcomes;
rt.laneTicks = ticks;
rt.lastTickOutcome = overallOutcome([...outcomes.values()]);
if (filled > 0) console.log(`[scheduler] tick: ${filled} account(s) filled`);
@@ -1880,6 +1967,8 @@ export function getSchedulerStatus() {
intervalSeconds: parseInt(getSetting('tick_interval_seconds') ?? '60', 10),
stopAfterAll: state.stopAfterAll,
sessionOpenDelayMin: todaysSessionOpenDelay(),
// A stopped scheduler keeps its last ticks, each with its time.
lastTicks: [...(runtime().laneTicks?.values() ?? [])],
};
}