From 90d7eee2ac9269145779ee6fa693af8819064a4b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 21:51:41 +0000 Subject: [PATCH] [RULE-REVIEW] POS-KILL: a leg of a defined-risk structure is never killed on its own (v1.0.1252) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Human (2026-11-02): "fix the condor close … make sure it works so we don't have this issue again and the logic works." Second half of that repair (the first, v1.0.1251, makes options_manager pursue a group close until the legs are gone). The Tier-1 per-position kill in syncMonitoredPositions judged every option leg by its own premium P&L. On 10-28/29 it 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 — while options_manager's own group rules fired on the same legs in the same minutes. A defined-risk structure's worst case is bounded by its wings; a per-leg percent kill cannot see that and closed the legs at different times, past max loss. Scope change, no threshold change: 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) leaves the kill branch with one POS-KILL-STRUCTURE-HOLD line per ticker per UTC day and 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 branch opener, mark guard, two-tick confirmation, duplicate guard and POST are untouched (the hold is one more `continue` before the mark guard). Prior value, evidence and the rollback trigger (a defined-risk structure's realized loss beyond its max loss by > 10% while the manager could not act) are logged in research/experiments.md; KNOWN BROKEN #45 (b) closes. Tests: server/posKillStructureHold.test.ts (7) — the defined-risk set, the pure filter, wiring pins, and the real loop body run tick by tick against stubs (wing held, short leg held, standalone short put and a straddle leg still killed). All 117 kill-branch harness tests pass. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01BrrwY7BFkP8VNdwKPfaVn5 --- ci/counter_baseline.txt | 6 +- package.json | 2 +- research/experiments.md | 38 ++++++ research/open_questions.md | 7 + server/bot.ts | 74 ++++++++++- server/posKillStructureHold.test.ts | 197 ++++++++++++++++++++++++++++ shared/positionLevels.ts | 9 +- 7 files changed, 327 insertions(+), 6 deletions(-) create mode 100644 server/posKillStructureHold.test.ts diff --git a/ci/counter_baseline.txt b/ci/counter_baseline.txt index 2bd94df1..00c2a630 100644 --- a/ci/counter_baseline.txt +++ b/ci/counter_baseline.txt @@ -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 @@ -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 diff --git a/package.json b/package.json index 6a61ffe0..5ce8b09f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rest-express", - "version": "1.0.1251", + "version": "1.0.1252", "type": "module", "license": "MIT", "scripts": { diff --git a/research/experiments.md b/research/experiments.md index 387ba3a6..4c017326 100644 --- a/research/experiments.md +++ b/research/experiments.md @@ -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. diff --git a/research/open_questions.md b/research/open_questions.md index e0bf36e1..d1454c59 100644 --- a/research/open_questions.md +++ b/research/open_questions.md @@ -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. diff --git a/server/bot.ts b/server/bot.ts index 097c47bc..f3377e6e 100644 --- a/server/bot.ts +++ b/server/bot.ts @@ -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, @@ -673,6 +673,52 @@ function posKillObserveBreach(pending: Map, 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; 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 { + const legs = new Map(); + if (!raw || typeof raw !== "object") return legs; + for (const [symbol, st] of Object.entries(raw as Record)) { + const strategy = st && typeof st === "object" ? (st as Record).strategy : undefined; + if (typeof strategy === "string" && definedRisk.includes(strategy)) legs.set(symbol, strategy); + } + return legs; +} +function posKillOptionsStructures(nowMs: number = Date.now()): Map { + if (posKillStructuresCache && nowMs - posKillStructuresCache.at < POS_KILL_STRUCTURES_TTL_MS) return posKillStructuresCache.legs; + let legs = new Map(); + 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 @@ -7912,6 +7958,9 @@ print(json.dumps({'files': count})) // survives a restart, by design (a restart simply restarts the count). const posKillPendingBreach: Map = new Map(); const posKillNoMarkAuditDay: Map = new Map(); + // POS-KILL STRUCTURE HOLD 2026-11-02: once-per-day audit bookkeeping and the current sync's structure legs + const posKillStructureHoldDay: Map = new Map(); + let posKillStructuresCurrent: Map = new Map(); /** * Load positions + stop state from Alpaca + disk, build the monitoredPositions map. @@ -7937,6 +7986,8 @@ except: print('{}') } catch (_) {} const activeTickers = new Set(); + // 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 || ""; @@ -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, @@ -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 diff --git a/server/posKillStructureHold.test.ts b/server/posKillStructureHold.test.ts new file mode 100644 index 00000000..ef63784e --- /dev/null +++ b/server/posKillStructureHold.test.ts @@ -0,0 +1,197 @@ +// POS-KILL STRUCTURE HOLD (2026-11-02, [RULE-REVIEW]) — the per-position kill +// in syncMonitoredPositions() (server/bot.ts) judged every option leg by its +// own premium P&L. Live 10-28/29 it read the two LONG WINGS of the GOOGL +// Nov-27 iron condor as "down −99.8 %" (122 pre-market retries), bought the +// short 310P back on a "−309 %" reading, and submitted the 300P wing for a +// sell on a zero mark — on a DEFINED-RISK structure whose worst case its own +// wings bound, while options_manager's group rules fired on the same legs. +// +// The rule: a leg whose recorded strategy (voltrade_options_state.json) is in +// OPTIONS_DEFINED_RISK_STRATEGIES is never killed on its own — one +// POS-KILL-STRUCTURE-HOLD line per ticker per UTC day, no POST. Single legs +// and undefined-risk groups (short straddles) keep the backstop unchanged. +// +// Same harness as posKillMarkGuard.test.ts: the REAL per-position loop body +// is cut out of bot.ts, transpiled and run tick by tick against stubs. The +// structure map is what syncMonitoredPositions parks on `state` before the +// loop (`state._posKillStructures`), so the harness sets it directly. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { transformSync } from "esbuild"; +import { OPTIONS_DEFINED_RISK_STRATEGIES, OPTIONS_MULTI_LEG_STRATEGIES } from "../shared/positionLevels"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const bot = fs.readFileSync(path.join(here, "bot.ts"), "utf8"); + +function matchBrace(src: string, open: number): number { + assert.equal(src[open], "{"); + let depth = 0; + for (let i = open; i < src.length; i++) { + const c = src[i]; + if (c === '"' || c === "'" || c === "`") { + for (i++; i < src.length && src[i] !== c; i++) if (src[i] === "\\") i++; + continue; + } + if (c === "{") depth++; + else if (c === "}" && --depth === 0) return i + 1; + } + throw new Error("unbalanced braces"); +} +function functionSourceIfPresent(signaturePrefix: string): string { + const start = bot.indexOf(signaturePrefix); + if (start < 0) return ""; + return bot.slice(start, matchBrace(bot, bot.indexOf("{", start + signaturePrefix.length))); +} +const transpile = (ts: string): string => transformSync(ts, { loader: "ts" }).code; + +const syncStart = bot.indexOf("async function syncMonitoredPositions()"); +assert.ok(syncStart > 0); +const loopStart = bot.indexOf("for (const pos of positions) {", syncStart); +const loopEnd = bot.indexOf("// Read stop state for this ticker", loopStart); +assert.ok(loopStart > syncStart && loopEnd > loopStart, "the per-position loop was not found"); +const loopBody = bot.slice(loopStart, loopEnd) + "\n}"; +const HELPERS = [ + "function isOptionSymbol(ticker: string): boolean", + "function isPosKillExemptOption(symbol: string, assetClass?: string): boolean", + 'function posKillThresholdPct(isOption: boolean, side: "long" | "short", stockPct: number,', + "function posKillHasValidMark(currentPriceRaw: unknown, plpcRaw: unknown): boolean", + "function posKillObserveBreach(pending: Map, symbol: string,", +].map(functionSourceIfPresent).join("\n"); + +type Row = { symbol: string; asset_class?: string; side: string; qty: string; avg_entry_price?: string; current_price?: string; unrealized_plpc?: string; created_at?: string }; +function makeHarness(structures: Map, startIso = "2026-10-29T13:30:09Z") { + let nowMs = Date.parse(startIso); + class FakeDate extends Date { constructor(v?: number | string | Date) { if (v === undefined) super(nowMs); else super(v); } static now(): number { return nowMs; } } + const audits: Array<{ type: string; msg: string }> = []; + const posts: Array<{ path: string; body: Record }> = []; + const openOrders: Array> = []; + const env = { + FLOOR_AND_LEG_TICKERS: new Set(["QQQ", "SVXY", "SPY", "SMH", "KWEB", "VXUS", "GLD"]), + LEGACY_DEFENSIVE_CANDIDATE_TICKERS: new Set(["VTI", "IWM", "TLT", "IEF", "SCHP"]), + audit: (type: string, msg: string) => { audits.push({ type, msg }); }, + alpaca: async (p: string, init?: { method?: string; body?: string }) => { + if (init?.method === "POST") { posts.push({ path: p, body: JSON.parse(init.body ?? "{}") }); return { id: `ord-${posts.length}` }; } + return []; + }, + state: {} as Record, + structures, + openOrders, + getOrderParams: () => ({ type: "market", time_in_force: "day" }), + notify: () => undefined, + pkgVersion: "test", + posKillPendingBreach: new Map(), + posKillNoMarkAuditDay: new Map(), + }; + const js = transpile(` +${HELPERS} +let posKillStructuresCurrent = new Map(); +const posKillStructureHoldDay = new Map(); +async function runTick(positions, env) { + const { FLOOR_AND_LEG_TICKERS, LEGACY_DEFENSIVE_CANDIDATE_TICKERS, audit, alpaca, state, openOrders, + getOrderParams, notify, pkgVersion, posKillPendingBreach, posKillNoMarkAuditDay } = env; + posKillStructuresCurrent = env.structures; + const activeTickers = new Set(); + ${loopBody} +} +`); + const runTick = new Function("Date", `${js}\nreturn runTick;`)(FakeDate) as (positions: Row[], e: typeof env) => Promise; + const tick = async (positions: Row[]) => { await runTick(positions, env); nowMs += 45_000; }; + const audited = (type: string) => audits.filter((a) => a.type === type); + return { tick, audits, posts, audited, advanceDay: () => { nowMs += 86_400_000; } }; +} +function optionAt(symbol: string, side: "long" | "short", entry: number, pnlPct: number): Row { + const mark = Math.max(0.01, entry * (1 + (side === "long" ? pnlPct : -pnlPct) / 100)); + return { symbol, asset_class: "us_option", side, qty: side === "long" ? "2" : "-2", avg_entry_price: String(entry), current_price: mark.toFixed(2), unrealized_plpc: String(pnlPct / 100), created_at: "2026-10-20T15:12:00Z" }; +} +const CONDOR = new Map([ + ["GOOGL261127P00310000", "iron_condor"], ["GOOGL261127P00300000", "iron_condor"], + ["GOOGL261127C00345000", "iron_condor"], ["GOOGL261127C00355000", "iron_condor"], +]); + +test("the defined-risk set is a subset of the multi-leg set and excludes the undefined-risk short straddle", () => { + for (const s of OPTIONS_DEFINED_RISK_STRATEGIES) assert.ok(OPTIONS_MULTI_LEG_STRATEGIES.includes(s), `${s} must be a multi-leg strategy`); + assert.ok(!OPTIONS_DEFINED_RISK_STRATEGIES.includes("short_straddle")); + for (const s of ["iron_condor", "qqq_iron_condor", "bull_put_credit_spread", "bull_call_spread", "bear_put_spread", "buy_straddle"]) assert.ok(OPTIONS_DEFINED_RISK_STRATEGIES.includes(s), s); +}); + +test("posKillDefinedRiskLegs: only legs whose recorded strategy is defined-risk; unknown, single and short-straddle legs are not structures", () => { + const src = functionSourceIfPresent("function posKillDefinedRiskLegs(raw: unknown, definedRisk: readonly string[] = OPTIONS_DEFINED_RISK_STRATEGIES): Map"); + assert.ok(src, "posKillDefinedRiskLegs must exist in bot.ts"); + const fn = new Function(`const OPTIONS_DEFINED_RISK_STRATEGIES = ${JSON.stringify(OPTIONS_DEFINED_RISK_STRATEGIES)};\n${transpile(src)}\nreturn posKillDefinedRiskLegs;`)() as (raw: unknown) => Map; + const legs = fn({ + GOOGL261127P00310000: { strategy: "iron_condor" }, GOOGL261127P00300000: { strategy: "iron_condor", close_requested_at: "x" }, + CRWV261120P00055000: { strategy: "sell_cash_secured_put" }, TEM261120P00070000: { strategy: "unknown" }, XLK261219P00100000: {}, + KMI261219C00030000: { strategy: "short_straddle" }, GOOGL: { strategy: "iron_condor", options_strategy: "iron_condor" }, junk: "not an object", + }); + assert.deepEqual([...legs.entries()].sort(), [["GOOGL", "iron_condor"], ["GOOGL261127P00300000", "iron_condor"], ["GOOGL261127P00310000", "iron_condor"]]); + assert.equal(fn(null).size, 0); assert.equal(fn("x").size, 0); assert.equal(fn([]).size, 0); +}); + +test("wiring: the structure legs are read once per sync before the loop; the hold is the first exit inside the UNCHANGED kill-branch opener, before the mark guard", () => { + const syncAt = bot.indexOf("async function syncMonitoredPositions()"); + const sync = bot.slice(syncAt, bot.indexOf("for (const pos of positions) {", syncAt)); + assert.ok(sync.includes("posKillStructuresCurrent = posKillOptionsStructures();"), "the map is read once per sync, before the loop"); + assert.equal(bot.split("if (pnlPct <= POSITION_KILL_LOSS_PCT) {").length - 1, 1, "the kill-branch opener is untouched (sibling tests slice on it)"); + const kill = bot.slice(bot.indexOf("if (pnlPct <= POSITION_KILL_LOSS_PCT) {")); + const holdAt = kill.indexOf("if (structureStrategy) {"); + const nomarkAt = kill.indexOf('if (breach === "nomark") {'); + assert.ok(holdAt > 0 && holdAt < nomarkAt, "the structure hold leaves the branch before the mark guard"); + assert.ok(/continue;/.test(kill.slice(holdAt, nomarkAt)), "the hold leaves the branch, never falls through to the POST"); + assert.ok(bot.includes('audit("POS-KILL-STRUCTURE-HOLD"'), "the hold is audited"); + assert.ok(bot.includes('typeof posKillStructuresCurrent !== "undefined"'), "the loop body stays runnable in harnesses that do not define the map"); + assert.ok(bot.includes("OPTIONS_DEFINED_RISK_STRATEGIES } from \"../shared/positionLevels\""), "one source of truth for the set"); + assert.ok(bot.includes("if (isOptionPos && pnlPct <= STOCK_KILL_LOSS_PCT && pnlPct > POSITION_KILL_LOSS_PCT) {"), "the option-skip branch is untouched"); +}); + +test("(a) a condor's long wing at −99 % on a real mark is never killed: no POST, one POS-KILL-STRUCTURE-HOLD line per day", async () => { + const h = makeHarness(CONDOR); + const wing = optionAt("GOOGL261127C00355000", "long", 5.15, -98.8); + for (let i = 0; i < 4; i++) await h.tick([wing]); + assert.equal(h.posts.length, 0, "a defined-risk structure's wing must never reach the liquidation POST"); + assert.equal(h.audited("POS-KILL").length, 0); + assert.equal(h.audited("POS-KILL-PENDING").length, 0, "no breach bookkeeping for a structure leg"); + const hold = h.audited("POS-KILL-STRUCTURE-HOLD"); + assert.equal(hold.length, 1, "once per ticker per UTC day"); + assert.ok(hold[0].msg.startsWith("GOOGL261127C00355000: iron_condor leg down -98.8% vs its premium"), hold[0].msg); + h.advanceDay(); + await h.tick([wing]); + assert.equal(h.audited("POS-KILL-STRUCTURE-HOLD").length, 2, "a new UTC day logs once more"); + assert.equal(h.posts.length, 0); +}); + +test("(b) the condor's short leg at −309 % (past the −150 % backstop) is the group's loss: no POST either", async () => { + const h = makeHarness(CONDOR); + const shortPut = optionAt("GOOGL261127P00310000", "short", 6.95, -309.4); + await h.tick([shortPut]); await h.tick([shortPut]); await h.tick([shortPut]); + assert.equal(h.posts.length, 0); + assert.equal(h.audited("POS-KILL-STRUCTURE-HOLD").length, 1); +}); + +test("(c) a standalone sold put at −160 % and a short-straddle leg at −160 % still hit the backstop (two valid-mark ticks, then the POST)", async () => { + const h = makeHarness(CONDOR); + const naked = optionAt("XLF261127P00055500", "short", 0.72, -160); + await h.tick([naked]); + assert.equal(h.audited("POS-KILL-PENDING").length, 1, "first valid-mark sighting confirms on the next tick"); + await h.tick([naked]); + assert.equal(h.posts.length, 1, "the standalone short put is still killed"); + assert.equal(h.posts[0].body.symbol, "XLF261127P00055500"); + assert.equal(h.audited("POS-KILL-STRUCTURE-HOLD").length, 0); + // a short straddle is undefined-risk: posKillDefinedRiskLegs never admits it (pinned above), so the + // per-sync map a straddle-holding account produces is exactly what the harness passes here — empty + const h2 = makeHarness(new Map()); + const straddleLeg = optionAt("KMI261219C00030000", "short", 1.0, -160); + await h2.tick([straddleLeg]); await h2.tick([straddleLeg]); + assert.equal(h2.posts.length, 1, "an undefined-risk group's leg keeps the per-leg backstop"); +}); + +test("(d) a leg between the stock rule and its backstop keeps the informational POS-KILL-OPTION-HOLD line (unchanged); the structure hold logs only where a kill would have fired", async () => { + const h = makeHarness(CONDOR); + await h.tick([optionAt("XLK261219P00100000", "long", 4.0, -40)]); + assert.equal(h.audited("POS-KILL-OPTION-HOLD").length, 1); + await h.tick([optionAt("GOOGL261127P00300000", "long", 4.7, -40)]); + assert.equal(h.audited("POS-KILL-STRUCTURE-HOLD").length, 0, "the hold line is logged only where a kill would have fired"); + assert.equal(h.posts.length, 0); +}); diff --git a/shared/positionLevels.ts b/shared/positionLevels.ts index 56547b1b..1ae228d0 100644 --- a/shared/positionLevels.ts +++ b/shared/positionLevels.ts @@ -307,7 +307,14 @@ export const OPTIONS_MULTI_LEG_STRATEGIES: readonly string[] = ["buy_straddle", /** _manage_strategy_group's is_credit_strategy / is_debit_strategy sets */ export const OPTIONS_CREDIT_STRATEGIES: readonly string[] = ["short_straddle", "iron_condor", "csp_normal_market", "bull_put_credit_spread", "sell_cash_secured_put", "covered_call", "qqq_iron_condor"]; export const OPTIONS_DEBIT_STRATEGIES: readonly string[] = ["buy_straddle", "bull_call_spread", "bear_put_spread"]; -/** bot_engine._run_convexity_overlay owns these QQQ puts (the manager skips +/** The multi-leg strategies whose worst case is bounded by their own legs + * (wings cap a condor / vertical; a long straddle can lose only its debit). + * server/bot.ts POS-KILL (2026-11-02) never judges such a leg on its own: + * a wing at −99 % is the structure working, a short leg at −150 % of its + * credit is the GROUP's loss, which options_manager manages as a unit. A + * short straddle is undefined-risk and keeps the per-leg backstop. */ + export const OPTIONS_DEFINED_RISK_STRATEGIES: readonly string[] = ["iron_condor", "qqq_iron_condor", "bull_put_credit_spread", "bull_call_spread", "bear_put_spread", "buy_straddle"]; + /** bot_engine._run_convexity_overlay owns these QQQ puts (the manager skips * them): held as insurance, sold to close and re-bought ~60 DTE out once * under CONVEXITY_OVERLAY.roll_dte. No target, no stop. */ export const CONVEXITY_HEDGE_STRATEGY = "convexity_hedge";