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:
co-authored by
Claude Opus 5.5
parent
db0ad99a76
commit
ab3132182a
+106
-17
@@ -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() ?? [])],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user