Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions ci/counter_baseline.txt
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@
# gates them in node-build against ci/tsc_baseline.txt, and two pins for one
# number is how they drift apart.

tests_run_in_ci 709 non-decreasing
tests_gating_merge 709 non-decreasing
tests_run_in_ci 710 non-decreasing
tests_gating_merge 710 non-decreasing
silent_py_handlers 252 non-increasing
bare_except 3 non-increasing
empty_ts_catch 488 non-increasing
Expand All @@ -35,7 +35,7 @@ conflicting_const 3 non-increasing
undeclared_py_import 2 non-increasing
dead_workflow_env 0 non-increasing
uncapped_surface 0 non-increasing
assertions 25827 non-decreasing
assertions 25863 non-decreasing
harness_rules_checked 84 non-decreasing
baseline_divergence 0 non-increasing
dup_precise_literal 3 non-increasing
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "rest-express",
"version": "1.0.1251",
"version": "1.0.1252",
"type": "module",
"license": "MIT",
"scripts": {
Expand Down
38 changes: 38 additions & 0 deletions research/experiments.md
Original file line number Diff line number Diff line change
Expand Up @@ -112822,3 +112822,41 @@ NEXT (own PR, [RULE-REVIEW]): the Node per-leg POS-KILL must not act on a
defined-risk structure's legs (evidence above); the GOOGL 300P wing itself
is the human's manual sale today (no session order path exists by design;
/api/bot/close is owner-only and has no UI control yet). STARVED: no.

## 2026-11-02 (interactive session, ~22:00Z) [RULE-REVIEW] — T-BOT server/bot.ts POS-KILL: a leg of a defined-risk structure is never killed on its own (v1.0.1252)

Human: "fix the condor close … make sure it works so we don't have this issue
again and the logic works" (second half; the first half shipped as v1.0.1251).
RULE CHANGED (scope, not threshold): the Tier-1 per-position kill in
`syncMonitoredPositions` judged every option leg by its own premium P&L
(−75 % long / −150 % short, the 2026-10-13 scope fix). It now holds its hand
on a leg whose recorded strategy (voltrade_options_state.json, read once per
sync, 60 s cache) is in `OPTIONS_DEFINED_RISK_STRATEGIES`
(shared/positionLevels.ts: iron_condor, qqq_iron_condor,
bull_put_credit_spread, bull_call_spread, bear_put_spread, buy_straddle) —
one `POS-KILL-STRUCTURE-HOLD` audit line per ticker per UTC day, no order;
options_manager closes the group as a unit. Single legs and undefined-risk
groups (short straddles) keep the backstop byte-for-byte: the kill-branch
opener, the mark guard, the two-tick confirmation, the duplicate guard and
the POST are untouched (the structure hold is one more `continue` at the
top of the branch, before the mark guard).
EVIDENCE (RULE REVIEW): the 10-28/29 audit — this kill read the GOOGL
Nov-27 condor's two LONG WINGS as "down −99.8 %" and retried their
liquidation every minute pre-market (122 lines on the 355C), bought the
short 310P back on a "−309 %" reading at $27.60 while its 300P wing was
submitted for a sell on a zero mark, in the same minutes options_manager's
own rules fired on the same legs (`held_for_orders` rejections). A
defined-risk structure's worst case is bounded by its own wings; a per-leg
percent kill cannot see that and made the loss worse than the structure's
max loss by closing the legs at different times. PRIOR VALUE: every option
leg judged alone. ROLLBACK TRIGGER: a defined-risk structure's realized
loss exceeding its max loss (wing width × qty × 100 − net credit) by > 10 %
while the manager could not act — revert the exemption and file.
Tests: server/posKillStructureHold.test.ts (7: the defined-risk set ⊂
multi-leg minus short_straddle; the pure filter; wiring pins; the real loop
body run against stubs — wing at −99 % held with one line/day, short leg at
−309 % held, a standalone short put and a straddle leg still killed on the
second valid-mark tick, the informational OPTION-HOLD line unchanged). All
117 kill-branch harness tests pass (the sibling harnesses slice the branch
on its unchanged opener). Full server + client suites and the tsc ratchet
run before merge. STARVED: no.
7 changes: 7 additions & 0 deletions research/open_questions.md
Original file line number Diff line number Diff line change
Expand Up @@ -23308,3 +23308,10 @@ its 300P wing was itself submitted for a sell on a zero mark). Rollback
trigger for (b): a defined-risk structure's realized loss exceeding its
max loss (wing width × qty × 100 − net credit) by > 10 % while the manager
could not act.

ADDENDUM 2026-11-02 (same session, later): (b) SHIPPED v1.0.1252 — server/bot.ts
POS-KILL holds its hand on legs whose recorded strategy is in
OPTIONS_DEFINED_RISK_STRATEGIES (one POS-KILL-STRUCTURE-HOLD line per ticker
per UTC day, no order); single legs and short straddles keep the backstop.
KNOWN BROKEN #45 is closed on both halves; the rollback triggers stay as
written above and in experiments.md.
74 changes: 73 additions & 1 deletion server/bot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import path from "path";
import fs from "fs";
import WebSocket from "ws";
import { getDisplaySide } from "../shared/inverseEtfs";
import { positionLevels, levelConfigFromBaseConfig } from "../shared/positionLevels";
import { positionLevels, levelConfigFromBaseConfig, OPTIONS_DEFINED_RISK_STRATEGIES } from "../shared/positionLevels";
import { evaluateDrawdown, drawdownStatus, evaluateDailyPnl } from "./drawdownGuard";
import {
AUTO_RESUME, allPaperAlpacaUrls, autoResumeEligibility, autoResumeEnabled, classifyLatchReason, evaluateAutoResume, fillsWindowStartMs,
Expand Down Expand Up @@ -673,6 +673,52 @@ function posKillObserveBreach(pending: Map<string, PosKillBreach>, symbol: strin
return "pending";
}

// POS-KILL STRUCTURE HOLD 2026-11-02 ([RULE-REVIEW], human-directed "fix the
// condor close … make sure we don't have this issue again"). Live 10-28/29
// (research/experiments.md 2026-11-02): this per-leg kill read the two LONG
// WINGS of the GOOGL Nov-27 iron condor as "down −99.8 %" and retried their
// liquidation every minute pre-market (122 audit lines), bought the short
// 310P back on a "−309 %" reading at $27.60 while its 300P wing was itself
// submitted for a sell on a zero mark — and options_manager's own group
// rules were firing on the same legs in the same minutes (held_for_orders
// rejections). A leg of a DEFINED-RISK structure (shared/positionLevels
// OPTIONS_DEFINED_RISK_STRATEGIES, read from voltrade_options_state.json)
// is never killed on its own: a wing at −99 % is the structure working and
// a short at −150 % of its credit is the GROUP's loss, bounded by its wings
// and closed as a unit by options_manager (v1.0.1251 pursues that close
// until the legs are gone). Single legs and undefined-risk groups (short
// straddles) keep the per-leg backstop unchanged. Prior behaviour: every
// option leg judged alone (the 2026-10-13 scope fix). Rollback trigger: a
// defined-risk structure's realized loss exceeding its max loss (wing width
// × qty × 100 − net credit) by > 10 % while the manager could not act.
// The map is read once per sync, before the per-position loop, into
// posKillStructuresCurrent (so the loop body stays harness-runnable).
const POS_KILL_STRUCTURES_TTL_MS = 60_000;
let posKillStructuresCache: { at: number; legs: Map<string, string>; readError: string | null } | null = null;
/** symbol → strategy for every leg whose recorded strategy is defined-risk; pure. */
function posKillDefinedRiskLegs(raw: unknown, definedRisk: readonly string[] = OPTIONS_DEFINED_RISK_STRATEGIES): Map<string, string> {
const legs = new Map<string, string>();
if (!raw || typeof raw !== "object") return legs;
for (const [symbol, st] of Object.entries(raw as Record<string, unknown>)) {
const strategy = st && typeof st === "object" ? (st as Record<string, unknown>).strategy : undefined;
if (typeof strategy === "string" && definedRisk.includes(strategy)) legs.set(symbol, strategy);
}
return legs;
}
function posKillOptionsStructures(nowMs: number = Date.now()): Map<string, string> {
if (posKillStructuresCache && nowMs - posKillStructuresCache.at < POS_KILL_STRUCTURES_TTL_MS) return posKillStructuresCache.legs;
let legs = new Map<string, string>();
let readError: string | null = null;
try {
const dir = fs.existsSync("/data/voltrade") ? "/data/voltrade" : "/tmp";
legs = posKillDefinedRiskLegs(JSON.parse(fs.readFileSync(`${dir}/voltrade_options_state.json`, "utf8")));
} catch (err) {
readError = err instanceof Error ? err.message : String(err); // no state file yet: no leg is a structure's
}
posKillStructuresCache = { at: nowMs, legs, readError };
return legs;
}

// Shared by executeTrades() and the tier dispatcher's SELL_CSP gate so both
// options-opening paths count the same thing the same way (RACE FIX
// 2026-08-03 — see the tier dispatcher comment). Excludes QQQ convexity
Expand Down Expand Up @@ -7912,6 +7958,9 @@ print(json.dumps({'files': count}))
// survives a restart, by design (a restart simply restarts the count).
const posKillPendingBreach: Map<string, PosKillBreach> = new Map();
const posKillNoMarkAuditDay: Map<string, string> = new Map();
// POS-KILL STRUCTURE HOLD 2026-11-02: once-per-day audit bookkeeping and the current sync's structure legs
const posKillStructureHoldDay: Map<string, string> = new Map();
let posKillStructuresCurrent: Map<string, string> = new Map();

/**
* Load positions + stop state from Alpaca + disk, build the monitoredPositions map.
Expand All @@ -7937,6 +7986,8 @@ except: print('{}')
} catch (_) {}

const activeTickers = new Set<string>();
// POS-KILL STRUCTURE HOLD 2026-11-02: legs of a defined-risk structure, read once per sync
posKillStructuresCurrent = posKillOptionsStructures();

for (const pos of positions) {
const ticker = pos.symbol || "";
Expand Down Expand Up @@ -8015,6 +8066,10 @@ except: print('{}')
const OPTION_SHORT_KILL_LOSS_PCT = -150.0; // sold option: mark >= 2.5x the credit
const OPTION_LONG_KILL_LOSS_PCT = -75.0; // bought option: lost 75% of premium
const isOptionPos = isPosKillExemptOption(ticker, pos.asset_class);
// POS-KILL STRUCTURE HOLD 2026-11-02: a leg of a defined-risk multi-leg
// strategy is the group's to manage — see the hold inside the kill
// branch. (typeof guard: the loop body runs standalone in the test harnesses.)
const structureStrategy: string | null = isOptionPos && typeof posKillStructuresCurrent !== "undefined" ? (posKillStructuresCurrent.get(ticker) ?? null) : null;
// This position's kill threshold: STOCK_KILL_LOSS_PCT for a stock,
// the side's option backstop for an option.
const POSITION_KILL_LOSS_PCT = posKillThresholdPct(isOptionPos, side, STOCK_KILL_LOSS_PCT,
Expand Down Expand Up @@ -8042,6 +8097,23 @@ except: print('{}')
if (pnlPct <= POSITION_KILL_LOSS_PCT) {
const closeSide = side === "long" ? "sell" : "buy";

// ── STRUCTURE HOLD (2026-11-02, see posKillOptionsStructures) ─────
// A leg of a defined-risk multi-leg strategy leaves the branch here:
// options_manager closes the group as a unit (v1.0.1251 pursues that
// close until the legs are gone). One POS-KILL-STRUCTURE-HOLD line
// per ticker per UTC day; the position stays held, next tick re-decides.
if (structureStrategy) {
const holdDay = new Date().toISOString().split("T")[0];
if (posKillStructureHoldDay.get(ticker) !== holdDay) {
for (const k of Array.from(posKillStructureHoldDay.keys())) {
if (posKillStructureHoldDay.get(k) !== holdDay) posKillStructureHoldDay.delete(k);
}
posKillStructureHoldDay.set(ticker, holdDay);
audit("POS-KILL-STRUCTURE-HOLD", `${ticker}: ${structureStrategy} leg down ${pnlPct.toFixed(1)}% vs its premium — a defined-risk structure's leg is never killed on its own; options_manager closes the group as a unit`);
}
continue;
}

// ── MARK GUARD + TWO-TICK CONFIRMATION (near-miss 2026-10-29) ───
// Runs BEFORE the duplicate guard and the POST: a breach read off a
// zero/missing mark is never acted on (one POS-KILL-NOMARK line per
Expand Down
Loading
Loading