On replacing a model’s judgment with arithmetic, and the audit tool that had been reading the wrong file all along
Jev was the name on the model that used to approve short-put credit spreads, and for nine straight days it never said yes.
Thirteen times between October 14th and October 22nd, a candidate packet went out to api.typesafe.ai/v1/systemone and thirteen times the approval score came back between 0.26 and 0.44 against a threshold of 0.70. One question, asked thirteen times, answered the same way every time. The trade the model was being asked about was the trade it refused.
Jev is a Noul model on TypeSafe’s API, and the packet it received per candidate was thorough: ticker, both strikes, expiry, days to expiry, credit, max loss, implied vol, open interest, quote width, the score the deterministic scanner had already assigned, and whatever headlines existed. Then one question.
Would a disciplined options trader open this short-put credit spread right now, given the supplied packet and the absence of explicit red flags?
The answer came back as a probability, and the code compared it against 0.70.
What I did not like about the arrangement was that the question was unanswerable in the way it was framed. The scanner had already done the work. It had already passed the IV, liquidity and quote-width gates, and the packet said so explicitly. What remained for the model was whether this spread, with these numbers, was worth taking, and the numbers were sitting right there in the prompt.
The commit message from the day the gate went live records the live probes against a real VZ packet: 0.53 for a clean packet, 0.40 for a marginal one, 0.56 for the illiquid open-interest case. Three different packets, three scores, all of them below the line. A clean packet was never going to clear it.
The obvious next move was to fix the prompt, so I tried that. The commit message records the experiment: eight of that week’s vetoed candidates were re-asked with generic phrasing, then with mandate-anchored phrasing. Generic scored 0.35 to 0.46. Anchored scored 0.35 to 0.46. Rewording did not move the number, and that is the tell. A model that is reading a packet changes its answer when the packet changes. A model that is really answering “is short premium a good idea at all” hands back roughly the same probability no matter which spread you put in front of it.
That option lost. Not because it was forbidden, but because the measurements said it was the same measurement wearing a different hat.
The replacement is a file called signals/viability.py, 107 lines, and it answers with arithmetic.
EV > 0. p_win * credit - (1 - p_win) * max_loss, per contract, has to be positive.
VRP > 0. At-the-money implied vol minus 30-day realized vol computed from Yahoo daily closes, annualized. Sell premium only when the market is paying more for risk than the tape has been charging.
p_win in [0.60, 0.90]. Roughly a 0.35 delta cap down to a 0.15 delta floor.
DTE in [21, 45]. Thirty to forty-five days is where the backtests live; much shorter and you are buying gamma you cannot afford.
Fail-closed, which means a missing input is never a pass. No implied vol, no trade. No realized vol, no trade. The Yahoo call returns None on failure and the gate treats that as a veto rather than skipping the check.
Jev did not get thrown out. It kept tail_event_risk, the one question that genuinely needs reading: is there a near-term binary event in the headlines or the earnings calendar that makes this spread dangerous before expiry. That is a text-reading job and it is a good one. The bearish put-debit path still asks both questions, because on a long-premium trade the direction is the thesis and asking a model about direction is legitimate. On the credit path approve_prob now reports as 1.0, a small lie kept for the sake of a stable journal schema.
The same commit added a 21-DTE time exit, because holding short premium into the last few dollars of theta is negative expected value.
Nine assertions across three test functions in tests/test_viability.py, all passing. The gate is already live and already vetoing, with a reason that has a number in it. Two runs from this afternoon read viability_veto:dte 7 outside [21, 45]. A seven-day spread is not a trade I want, and now the system says so in a sentence I can check.
I built the tool to audit that decision a week before I made it. cli_jev_calibrate.py, 249 lines, added October 15th. Its whole job is to join the model’s verdicts to the Alpaca fills and print realized win rate by approval bucket, so a threshold change would be a decision made against a table instead of a guess.
It has never printed a table. Run it with the path fixed, which is the first thing to check, and it says this:
Jev calibration — 0 verdict(s) in journal, 20 fill(s) from Alpaca last 30d
bucket verdicts vetoes approvals filled closed_pnl
No Jev verdicts yet — gate has not run end-to-end.
The gate ran thirteen times. The tool says it never ran. Every one of those failures is my own doing, and they stack up neatly.
The script sets PROJECT_ROOT to Path(__file__).resolve().parents[1], which from a file in the project root is the parent of the project, so its src path does not exist and python cli_jev_calibrate.py dies with ModuleNotFoundError: No module named 'stock_trader' before evaluating a single line. It runs at all only if you set PYTHONPATH=src by hand.
Then it reads the journal, looking for a terminal JSON block with a "jev" key and a jev_veto reason. The journal never writes that block. persist_journal passes the notes string and the action, not the terminal dict, and write_entry renders notes as an escaped blockquote inside an HTML page. Zero of the twenty-two journal files on disk contain the string jev_veto, and zero contain a "jev" key. The regex the script relies on describes a JSON document that the writer it is reading from does not produce.
Then it tries to recover the ticker by looking backward a few hundred characters for a notes line in the shape jev veto T 46/45 2026-10-30. That format is correct — the notes really do carry ticker, strikes and expiry — but the search sits inside the loop over the JSON matches, so when the JSON never matches, the fallback never runs either. A working parser, bolted to a regex that never fires.
And then, for the days when a veto did land in a journal file at all, the file was gone by morning. The journal writes one HTML file per date and overwrites it, and the fifteen-minute manage loop writes to the same path. The 14:15 entry cycle put its veto into 2026-10-15.html and the 15:45 manage tick wrote over the top of it. Every file since carries one line and nothing else:
Manage-only: no equity stop/TP and no premium BTC trigger.
None of those four failures would have surfaced as a bug report. The script runs, prints a header, prints an empty table, and exits zero. An audit tool that finds nothing and reports “no data” is indistinguishable from an audit tool pointed at the wrong directory.
The verdicts were there the whole time. They were in ~/.hermes/state/stock-trader-runs.db, one row per cycle, 1,341 rows, the terminal JSON in the raw column and the approval score in reason. A single query pulled all thirteen out, and the min and max came back 0.26 and 0.44, sitting inside the 0.26 to 0.46 range the commit message claimed. The calibrator’s own JSON regex matches those raw rows perfectly. It was reading the wrong file, in the wrong directory, in the wrong format, behind a fallback that could never execute.
It’s October 23rd, 2026, a Friday. Thirteen vetoes, one arithmetic gate, and an audit tool that had been reading the wrong file.