Skip to content

Fix trading state: settle expired puts, alarm on blind runs, reconcile against broker - #3

Open
biswajeetdev wants to merge 10 commits into
mainfrom
worktree-trader-improvements
Open

Fix trading state: settle expired puts, alarm on blind runs, reconcile against broker#3
biswajeetdev wants to merge 10 commits into
mainfrom
worktree-trader-improvements

Conversation

@biswajeetdev

@biswajeetdev biswajeetdev commented Jul 21, 2026

Copy link
Copy Markdown
Owner

Why

The paper account looked like it was generating no profit. Diagnosis found the opposite, and a set of mechanical faults underneath it.

The Alpaca paper account is at $109,554 (+9.55%), not flat. The bot reports $98,929 because its equity tracking follows the ai4trade.ai sim account while it actually executes through Alpaca — two disjoint accounts.

That gain is not strategy alpha. Essentially all of it is MSFT 114 shares (+$10,527 unrealized) acquired by short-put assignment, which the bot has no record of holding. Net of it, the deliberate strategy is roughly flat-to-negative (realized −$150, tracked unrealized −$185).

What was broken

1. The short-put strategy was hard-blocked for six weeks.
check_exits() computed dte = expiry - today and tested dte <= GAMMA_DTE. A contract expired 40 days ago yields -40, which passes forever — so every run submitted a close order on a contract that no longer trades, failed, and retried. With MAX_OPEN = 3 and all three slots held by dead contracts, every new short-put entry was rejected since Jul 2.

2. 36% of runs were blind and reported as healthy.
209 of 572 runs got no price data, each summarised as a tidy Trades today: 0. Two independent causes, confirmed by bucketing the log: 102 runs DNS-down (Errno 8), 76 runs Yahoo-side empties with no network error, 31 both. Nothing ever surfaced it — which is how two months passed.

3. Local state had drifted completely from the broker.
scripts/reconcile.py reports 12 issues against the live account:

Invisible to the bot AAPL 10, AMZN −11, ARM 95, MSFT 114 (+$10,527), SOFI −284
Wrong in the bot META phantom (local qty 4, absent at broker); NVDA local 14 vs broker 29
Stuck 3 expired puts occupying all MAX_OPEN slots
Frozen equity_history = 30 identical values → the drawdown circuit breaker guards a constant

NVDA is a live risk, not cosmetic: check_stops does its arithmetic on qty 14 while the broker holds 29, so a stop that fires sells 14 and silently orphans 15.

What this changes

  • Expired puts settle instead of retrying — resolved from the underlying's close on the expiry date (worthless vs assigned), then released so the slot frees up.
  • Degraded-run alarm — a data-health line, a RUN DEGRADED banner past 50% blind, and a Telegram alert through the existing send_error_alert, so it reaches a human instead of dying in cron.log.
  • scripts/reconcile.py — read-only local-vs-Alpaca drift report, exits non-zero. Deliberately does not auto-repair; correcting positions is a human decision, not a side effect of a health check.

Correctness note on assignment accounting

The first version recorded an assigned put as credit-minus-intrinsic (MSFT as −$1,771). Assignment realises nothing — the credit is kept and the position converts to stock at a known basis, so that was a mark, not a realised loss.

It mattered because trades flow into trade_history.json, and update_from_trade_history() reads trade["pnl"] straight into regime stats while bootstrap_from_trade_history() feeds the strategy bandit. The old shape would have taught the learning layer that short puts lost ~$2,189 when they in fact collected $4,259 — confidently wrong, which is worse than the stale state being replaced. Fixed in d68facd.

Verification

  • All touched files compile; check_exits / settle_expired exercised against the real position file, with live state verified untouched afterwards.
  • Settlement cross-checks two ways: realized total $4,259.00 equals credit collected ($680 + $254 + $3,325); computed ARM basis $286.75 matches the broker's actual avg entry of $286.55 on the 95 assigned shares.
  • reconcile.py run against the live account: exit 1, 12 issues.

Deliberately out of scope

Trailing stops (checked — correctly held, not broken), the India short-put loop, and reducing the ~40–60 Yahoo calls per run. Also unresolved and left for a human: AMZN −11 and SOFI −284 are short equity positions the bot's schema (action: BUY only) cannot represent — origin unknown.

Also included (earlier commits on this branch)

Debate gate that short-circuits the ~6-call LLM debate on no-catalyst/flat-technical setups, and point-in-time EDGAR catalyst context for the offline eval harness (leak-safe; options-flow excluded for lack of free historical data).

backtest.py: model adverse slippage (0.05%/side, override via params[slippage])
at all fill points — buys fill higher, sells/stops/targets/EOD lower. Surface
commission+slippage in the run header. Backtests were commission-only and thus
optimistic on net fills.

eval_llm.py (new): offline harness that replays historical as-of dates, rebuilds
point-in-time price/technical indicators, calls the real debate_decide brain, and
scores each call against realized forward returns (per-action avg return,
directional hit-rate, BUY edge vs baseline). The LLM debate layer was previously
validated only by live online loops. Includes a look-ahead-bias guard
(--anonymize) and feeds only reconstructable context (live-only alt-data passed
empty).
GitHub Models / Ollama calls had no client-side timeout, so a throttled or
stalled request (e.g. 429 backoff) could hang the run indefinitely. Set
timeout=30s, max_retries=2 on both OpenAI clients so calls fail fast and the
caller's existing try/except degrades gracefully instead of stalling.
…al signals

Snapshot of the 6 files that were uncommitted in the live ~/ai-trader checkout,
committed here as the base so the A1+B+C work sits cleanly on top of it.
A1 — execution correctness:
  Add round_to_tick() and apply to all 4 option limit-price submissions in
  short_put_exec.py / covered_call_exec.py. A live-quoted premium like 2.8734
  was rejected by Alpaca (code 42210000, >2 decimals), so positions could not
  be closed programmatically. Rounds to 0.01 (<3) / 0.05 (>=3) ticks.

B1 — reliable eval/backtest data:
  New data/history.py get_daily(): on-disk cache -> yfinance retry+backoff
  -> Alpaca daily bars fallback. yfinance burst-throttling returned empty
  'possibly delisted' responses, so the look-ahead-bias eval collected ZERO
  decisions. Wired into eval_llm.py and backtest.run_asset (the burst callers).
  Removed now-dead 'import yfinance' from eval_llm.py.

C — measurable performance:
  trader.log_equity() appends a NAV point to equity_curve.csv each run.
  report_perf.py -> Sharpe/drawdown/CAGR/win-rate + SPY edge (HTML), plus a
  full QuantStats tear sheet when quantstats is installed.

gitignore equity_curve.csv, data/cache/, report_perf*.html, llm_eval_report.json.
Gate before debate: a name with no discrete catalyst (news/options/whale/
insider/poly/bb-pattern/social/sentiment) AND flat technicals resolves to
HOLD after the full debate anyway, so short-circuit there. Pure latency +
daily-quota savings, no edge loss. Ambient rank/fear-greed excluded so the
gate isn't neutralised; tight flat-technicals band + parse-failure-fails-open
guard mean only genuinely dormant names are skipped.

Also folds in prior uncommitted hardening: tolerant _extract_json (markdown
fences / prose), None-safe _call with reasoning-text fallback and max_tokens
512, per-future exception handling, and safe-HOLD on arbiter parse failure.
…-safe)

The eval previously fed the debate brain only technicals, so it measured a
deliberately-cautious technicals-only brain, not the catalyst-driven strategy.
Reconstruct three top-weighted catalysts from SEC EDGAR filings pinned to
filing-date <= as-of (leak-free):
  - 8-K material events (news/M&A)   via news_catalyst._edgar_8k
  - 13D/G/13F institutional/activist via whale_tracker._search_edgar_filings
  - Form 4 insider                   via EDGAR full-text search

Both signal-module EDGAR functions gain a backward-compatible as_of= param
(default None = today, live callers unchanged). Options-flow has no free
point-in-time history and is explicitly EXCLUDED (documented ceiling).

Catalysts name the real issuer, so they are mutually exclusive with
--anonymize (which would otherwise leak the ticker) -> anonymize disables
catalysts with a warning. On by default; --no-catalysts for technicals only.
Output enumerates catalyst coverage per source, gated count, and the options
exclusion; per-decision rows tag which catalysts fired.
@biswajeetdev

Copy link
Copy Markdown
Owner Author

Added: point-in-time catalyst context in the eval harness (ba5e446)

The eval previously fed the brain only technicals, so it measured a deliberately-cautious technicals-only brain — not the catalyst-driven strategy. Now it reconstructs three top-weighted catalysts from SEC EDGAR filings pinned to filing-date ≤ as-of (leak-free): 8-K events, 13D/G/13F institutional, Form 4 insider. Options-flow has no free point-in-time history → explicitly excluded. Incompatible with --anonymize (filings name the issuer) → anonymize disables catalysts.

Proof-run (NVDA/AAPL, 2 dates each, non-anonymized):

  • 4/4 decisions carried a real EDGAR catalyst, 0 gated — the debate gate correctly stood down when catalysts were present, and every date ran the full debate.
  • Behavior changed vs. the technicals-only run: the brain issued an actual BUY @75% conf (crossed the 70% threshold) where before it only ever produced low-conviction HOLDs.

Edge numbers themselves are noise at n=4 — this commit is about what gets tested, not a verdict. Leak-safety verified: EDGAR queries return only filings dated ≤ as-of.

…iation

The paper account showed no profit because the bot's state had drifted from
the broker and 36% of runs were blind. Three fixes, all diagnosis-driven:

1. Expired short puts were retried forever. check_exits() computed
   dte = expiry - today and matched dte <= GAMMA_DTE, so a contract expired
   40 days ago still matched and close_short_put() submitted an order on a
   contract that no longer trades — permanent failure. All 3 MAX_OPEN slots
   were held this way since Jul 2, blocking every new short-put entry for
   six weeks. Expired positions are now settled from the underlying's close
   on the expiry date (worthless vs assigned) and released.

2. Runs where most symbols returned no price data were summarised as a quiet
   "Trades today: 0". 209 of 572 runs were degraded and nothing surfaced it.
   Runs now report data health and, past 50% blind, print a FAILED-run banner
   and fire a Telegram alert.

3. scripts/reconcile.py compares local JSON against Alpaca. Read-only, exits
   non-zero on drift. Current output: 12 issues, including a $10,674 equity
   gap, 5 broker positions the bot cannot see, and 1 phantom local position.

Settlement cross-validates against the broker: MSFT and ARM resolve to
ASSIGNED and the broker holds exactly the resulting 114 and 95 shares.
…arate

settle_expired() reported an assigned put as credit minus intrinsic, e.g.
MSFT as -$1,771. Assignment realises nothing: the credit is kept and the
position converts to stock at a known basis, so that number was a mark, not
a realised loss.

It mattered because trades flow into trade_history.json, and
update_from_trade_history() reads trade["pnl"] straight into regime stats
while bootstrap_from_trade_history() feeds the strategy bandit. The old shape
taught the learning layer that short puts lost ~$2,189 when they in fact
collected $4,259 in credit — confidently wrong, which is worse than the stale
state being replaced.

Realized pnl is now the credit in both outcomes. Shares acquired, cost basis
and the mark-to-expiry gap are reported as separate fields.

Cross-checks: realized total $4,259.00 equals credit collected
($680 + $254 + $3,325). Computed ARM basis $286.75 matches the broker's
actual avg entry of $286.55 on the 95 assigned shares.
@biswajeetdev biswajeetdev changed the title Debate gate: skip ~6 LLM calls on dead setups (+ brain hardening) Fix trading state: settle expired puts, alarm on blind runs, reconcile against broker Aug 11, 2026
@biswajeetdev
biswajeetdev marked this pull request as ready for review August 11, 2026 13:24
…rites

Six requested fixes. The short-selling path turned out to be comprehensively
broken, in ways that compound.

SHORT was executed twice. execute_alpaca_trade maps any non-BUY/COVER action to
OrderSide.SELL (alpaca_exec.py:88), so it already opened the short; trader.py
then called execute_short_sell() for a second identical order — double the
intended size. record_open() only ran if that second call succeeded, so when it
failed the first fill stayed at the broker with no local record. That is the
origin of the untracked AMZN -11 and SOFI -284. Removed the duplicate call;
short_positions.json was written by nobody's reader, so nothing depended on it.

Shorts were managed backwards. record_open() stamped "action": "BUY" on every
position with a negative qty and long-side stops, and check_stops() only skipped
non-BUY rows — so shorts were evaluated with fully inverted semantics: falling
price (a short's profit) fired STOP_LOSS, rising price fired PROFIT_TARGET. Now
record_open takes an explicit action, puts a short's stop above entry and target
below, stores qty as a positive size, and rejects any action other than BUY or
SHORT. check_stops mirrors every comparison and ratchets the trail downward for
shorts; chandelier_exit gained a short mode. mark_partial_done was left alone —
stop_price = entry_price is breakeven in both directions already.

pnl_pct had no direction sign, so every profitable short was recorded as a loss.
Both consumers read that field (update_from_trade_history -> regime stats,
bootstrap_from_trade_history -> strategy bandit), so the learning layer was
being trained backwards on shorts.

Directional pre-flight guard in trader.py: a SELL aimed at closing a SHORT would
have increased the short while record_close removed it locally. Verbs are now
normalised to the held direction, COVER with no position is skipped, and closing
orders are clamped to the size actually held.

Atomic state writes (broker/state_io.py). Every store used
path.write_text(json.dumps(...)), which truncates before the new bytes land; a
crash between the two left a truncated file, and the loaders swallow the parse
error and return {} — meaning "no open positions". Applied to positions.json,
trade_history.json, portfolio_hwm.json, short_put_positions.json and the shorts
store. Signal caches were left alone; corruption there just forces a refetch.

reconcile.py: baseline now comes from Alpaca portfolio history base_value instead
of a hardcoded $100,000, and an unreadable baseline is labelled ASSUMED rather
than passed off as fact. Added --fix to adopt broker truth and --arm to promote a
row to managed. Adopted rows carry unmanaged: true, which check_stops honours —
the broker's avg_entry_price is real but the ATR-derived stop is a guess about
intent, and arming one on 114 MSFT shares could liquidate the position at an
arbitrary level. Corrections to already-tracked rows deliberately do NOT recompute
an existing stop; moving a live stop is a risk decision, and the log says so.

Verified: longs regression-tested unchanged (stop/target/partial/close all fire
as before); shorts assert stop > entry > target, STOP_LOSS on a rise,
PROFIT_TARGET on a fall, and a cover at 90 from a short at 100 records +$110 /
+10% / WIN; unmanaged rows are skipped; record_open rejects a bad action; a
failed serialise leaves the previous file intact with no temp files left behind;
default reconcile run leaves positions.json byte-identical.
The stop-exit path is a second call site that the directional guard did not
cover, and it hardcoded "SELL":

    execute_trade(token, symbol, market, "SELL", qty, reason, dry_run)

For a SHORT that is a sell of an unheld direction — it would have DOUBLED the
short while record_close marked the position flat. Exactly the double-exposure
bug fixed on the entry path, reachable through the other door as soon as an
adopted short is armed. `unmanaged: true` was the only thing holding it shut.

Both exit sites (PARTIAL_PROFIT and the STOP_LOSS/TRAIL_STOP/PROFIT_TARGET
branch) now derive the verb from the position's own action: COVER for a short,
SELL for a long. COVER maps to OrderSide.BUY in alpaca_exec, which reduces a
short.

Also sign-corrected _pnl_pct at both close sites (the stop exit and the LLM
SELL/COVER branch) — it fed record_signal_outcome unflipped, so a profitable
short was reported to signal calibration as a loss.

Verified end-to-end on a --fix-produced, --arm-ed SOFI short (the case that
would have caught this): check_stops(21.00) returns STOP_LOSS, the derived verb
is COVER, COVER maps to OrderSide.BUY, and the P&L reads -20.48% (correctly a
loss on a short that moved up). Long regression re-run: stop, target, partial
and close all unchanged.
@biswajeetdev

Copy link
Copy Markdown
Owner Author

Round 2 — short-side risk, execution guards, atomic state (2dab7f1, 1ce38e7)

Six requested fixes. The short-selling path turned out to be broken in ways that compound, so most of this is that.

SHORT was executing twice

execute_alpaca_trade maps any non-BUY/COVER action to OrderSide.SELL (alpaca_exec.py:88), so it already opened the short — then trader.py called execute_short_sell() for a second identical order. Double the intended size. And record_open() only ran if that second call succeeded, so when it failed the first fill stayed at the broker with no local record. That is the origin of the untracked AMZN -11 / SOFI -284. Removed the duplicate; short_positions.json had no reader, so nothing depended on it.

Shorts were managed backwards

record_open stamped "action": "BUY" on everything with a negative qty and long-side stops, and check_stops only skipped non-BUY rows — so shorts were evaluated with inverted semantics: falling price (a short's profit) fired STOP_LOSS; rising price fired PROFIT_TARGET. The bot closed winning shorts and held losing ones.

Now: record_open takes an explicit action, puts a short's stop above entry and target below, stores qty as a positive size, and rejects anything but BUY/SHORT. check_stops mirrors every comparison and ratchets the trail downward. chandelier_exit gained a short mode. mark_partial_done was left alonestop_price = entry_price is breakeven in both directions already.

The learning layer was trained backwards on shorts

pnl_pct carried no direction sign, and both consumers read that field (update_from_trade_history → regime stats, bootstrap_from_trade_history → strategy bandit). Fixed in record_close and at both record_signal_outcome call sites.

Execution guards (the fail-safe ask)

  • Verbs are normalised to the held direction before anything executes; COVER with no position is skipped; closing orders are clamped to the size actually held.
  • 1ce38e7 fixes a blocker found in review: the stop-exit path is a second call site that hardcoded "SELL". On an armed short that would have doubled the position while record_close marked it flat — the same bug through the other door, with unmanaged: true the only thing holding it shut. Both exit sites now derive COVER/SELL from the position's own action.

State integrity

broker/state_io.py: every store used write_text(json.dumps(...)), which truncates before the new bytes land. A crash between the two left a truncated file — and the loaders swallow the parse error and return {}, i.e. "no open positions."

Correction to my earlier commit message: this fixes torn reads, not concurrent interleaving. check_stops and reconcile --fix both do read-modify-write, so a lost update is still possible (cron's trail ratchet could be overwritten). The window is small and the consequence is a stale trail rather than a bad order, so I've left it — but the commit message overstated it.

reconcile.py

Baseline now comes from Alpaca portfolio/history.base_value instead of a hardcoded $100,000, and an unreadable baseline is labelled ASSUMED rather than passed off as fact. Added --fix (adopt broker truth) and --arm SYM (promote to managed). Adopted rows carry unmanaged: true, which check_stops honours: the broker's avg_entry_price is real, but the ATR-derived stop is a guess about intent, and arming one on 114 MSFT shares could liquidate the position at an arbitrary level. Corrections to already-tracked rows deliberately do not recompute an existing stop — moving a live stop is a risk decision, and the log says so explicitly.

Verification

  • Long regression re-run: stop, target, partial and close all fire exactly as before.
  • Shorts assert stop > entry > target, STOP_LOSS on a rise, PROFIT_TARGET on a fall, PARTIAL_PROFIT on a favourable move, and a cover at 90 from a short at 100 records +$110 / +10% / WIN.
  • Armed-short exit driven end-to-end on a --fix-produced SOFI row: verb is COVER, maps to OrderSide.BUY, P&L reads −20.48% on an adverse move.
  • unmanaged rows skipped; record_open rejects a bad action; a failed serialise leaves the previous file intact with no temp files left behind; a default reconcile run leaves positions.json byte-identical.

⚠️ Known gap — do not arm the adopted shorts yet

The stop-exit path calls only execute_trade (ai4trade); it never calls execute_alpaca_trade. Longs opened by the bot are covered because BUY gets a native Alpaca bracket, but:

  • Bot-opened shorts get no bracket (use_bracket requires side == BUY), so they have no server-side stop at all.
  • An armed short hitting its stop would close on ai4trade and locally, while the real Alpaca short stayed open — re-creating the drift this PR exists to fix.

unmanaged: true keeps the adopted shorts inert, which is why this isn't live risk today. Mirroring exits to Alpaca is the right next change, and it needs care so it doesn't double-close bracketed longs. Left out deliberately rather than rushed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant