Fix trading state: settle expired puts, alarm on blind runs, reconcile against broker - #3
Fix trading state: settle expired puts, alarm on blind runs, reconcile against broker#3biswajeetdev wants to merge 10 commits into
Conversation
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.
Added: point-in-time catalyst context in the eval harness (
|
…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.
…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.
Round 2 — short-side risk, execution guards, atomic state (
|
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()computeddte = expiry - todayand testeddte <= 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. WithMAX_OPEN = 3and 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.pyreports 12 issues against the live account:MAX_OPENslotsequity_history= 30 identical values → the drawdown circuit breaker guards a constantNVDA is a live risk, not cosmetic:
check_stopsdoes its arithmetic on qty 14 while the broker holds 29, so a stop that fires sells 14 and silently orphans 15.What this changes
RUN DEGRADEDbanner past 50% blind, and a Telegram alert through the existingsend_error_alert, so it reaches a human instead of dying incron.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, andupdate_from_trade_history()readstrade["pnl"]straight into regime stats whilebootstrap_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 ind68facd.Verification
check_exits/settle_expiredexercised against the real position file, with live state verified untouched afterwards.reconcile.pyrun 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: BUYonly) 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).