| Accountable | The alert includes its own track record ("HIGH-conviction alerts: 8/12 positive 30d alpha") and is recorded in `signal_outcomes` for scoring | outcome tracker (Phase 0) |

Alert body: ticker, action, hours-since-disclosure, catalyst (with citation), edge factors,
conviction + historical calibration line, invalidation conditions, top risk factor.

Out of scope for this plan: automated trade execution. The system's terminal action is the
alert; James executes. (A future "Tier 4" would need a brokerage API and a separate risk
framework — deliberately not designed here.)

---

## 1. Current-state audit — the five preconditions

Per the monorepo Primary Directive, an agentic system needs all five. Status today:

| # | Precondition | Status | Gap |
|---|---|---|---|
| 1 | **Objective metric** | ⚠ Partial | `signal_outcomes` tracks raw 30/60d returns of fired alerts only. Raw return in a bull market is not edge. **Metric must be excess return vs benchmark, over a dense cohort.** |
| 2 | **Measurement mechanism** | ⚠ Flawed | Only ~1 alert/day max is recorded → too sparse to learn from. 30d price is approximated by indexing into a 65-day window ending *today* (not anchored at notify date). Suppressed candidates are never measured, so gate quality is unmeasurable. |
| 3 | **Action space** | ❌ Missing | Nothing adjusts anything. Weights in `scorer.py`, thresholds in `config.py`, and gates in `notification_policy.py` are all static hand-tuned values. |
| 4 | **Stopping condition** | ❌ Missing | No tuning loop exists, so none defined. |
--
- Ticker delisted / no price data at horizon → outcome = `unmeasurable`, excluded from the
  metric denominator, counted separately so silent data loss is visible.
- Market-wide drawdown → excess return vs SPY already handles it; never judge on raw return.

---

## 2. Gap analysis — the three words in the goal

### "Rigorously fresh"
- Pipeline runs once a day at 6 AM (`launchd/com.congresswatch.daily.plist`). Worst case a
  disclosure waits ~24h (72h over a weekend) before we even see it.
- `analysis/timeliness.py` calls < 21 days "fresh". The STOCK Act allows 45 days
  transaction→disclosure; adding up to 21 more days of our own latency means alerting on
  2-month-old information and calling it fresh.
- Nothing checks whether the stock already moved. A disclosure can be 1 day old and fully
  priced in if the ticker ran 30% since `tx_date`.

### "Strong"
- The score weights (`_cluster_score`, `_size_score`, `_small_cap_bonus` tiers, etc.) are
  plausible but have never been validated against forward returns. We have ~14k historical
  trades in the DB — enough to backtest which components actually predict alpha.
--
Ordering rationale: **fix the measurement before anything optimizes against it** (Phase 0),
then improve the product (Phases 1–2), then close the loop (Phases 3–4). Each phase ships
independently and is useful even if later phases never happen.

### Phase 0 — Fix the measurement *(prerequisite for everything; ~1–2 days)*

1. **Shadow outcomes.** Record a `signal_outcomes` row for *every* notify-tier candidate per
   run, not just fired alerts. New columns: `fired` (0/1), `suppression_reason`,
   `spy_price_at_notify`, `return_30d_excess_pct`, `return_60d_excess_pct`, plus
   `outcome = 'unmeasurable'` as a terminal state. (Schema change → both `db/schema.sql`
   and `db/migrate.py` per project convention.)
2. **Fix horizon pricing.** Anchor yfinance history at `notified_at` with explicit
   `start`/`end` dates so 30d/60d prices are calendar-accurate, and fetch SPY over the same
   window for excess return. This replaces the current index-into-recent-window approximation.
3. **Scorecard.** `tools/outcome_tracker.py --scorecard` prints: fired vs suppressed cohort
   excess returns (are the gates adding value?), conviction calibration (HIGH vs MEDIUM),


1	"""Dense candidate outcome ledger and excess-return scorecard."""
2	from __future__ import annotations
3	import argparse
4	from datetime import datetime, timedelta
5	import os
6	import sqlite3
7	import sys
8	from typing import Callable
9
10	sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
11	from config import DB_PATH
12
13
14	def excess_return_pct(entry: float, exit_price: float, benchmark_entry: float, benchmark_exit: float) -> float:
15	    if min(entry, exit_price, benchmark_entry, benchmark_exit) <= 0:
16	        raise ValueError("prices must be positive")
17	    return ((exit_price / entry - 1) - (benchmark_exit / benchmark_entry - 1)) * 100
18
19
20	def record_candidate(*, ticker: str, score: float, conviction: str,
21	                     recommended_action: str, price: float | None,
22	                     observed_at: str, fired: bool, suppression_reason: str | None,
23	                     verifier_verdict: str | None = None, citation_backed: bool = False,
24	                     db_path: str = DB_PATH) -> None:
25	    """Record every notify-tier candidate, including candidates suppressed by a gate."""
26	    with sqlite3.connect(db_path) as conn:
27	        version = conn.execute("SELECT id FROM config_versions WHERE active=1 ORDER BY id DESC LIMIT 1").fetchone()
28	        conn.execute("""
29	            INSERT INTO signal_outcomes
30	              (ticker, notified_at, score_at_notify, conviction, recommended_action,
31	               price_at_notify, fired, suppression_reason, verifier_verdict, citation_backed,
32	               config_version_id, outcome)
33	            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending')
34	            ON CONFLICT(ticker, notified_at) DO UPDATE SET
35	              score_at_notify=excluded.score_at_notify, conviction=excluded.conviction,
36	              recommended_action=excluded.recommended_action, price_at_notify=excluded.price_at_notify,
37	              fired=excluded.fired, suppression_reason=excluded.suppression_reason,
38	              verifier_verdict=excluded.verifier_verdict, citation_backed=excluded.citation_backed,
39	              config_version_id=excluded.config_version_id
40	        """, (ticker, observed_at, score, conviction, recommended_action, price, int(fired),
41	              suppression_reason, verifier_verdict, int(citation_backed), version[0] if version else None))
42
43
44	def record_notification(ticker: str, score: float, conviction: str,
45	                        recommended_action: str, price: float | None, notified_at: str) -> None:
46	    record_candidate(ticker=ticker, score=score, conviction=conviction,
47	                     recommended_action=recommended_action, price=price,
48	                     observed_at=notified_at, fired=True, suppression_reason=None)
49
50
51	def _close_on_or_after(history, target: datetime) -> float | None:
52	    if history is None or history.empty:
53	        return None
54	    index = history.index
55	    if getattr(index, "tz", None) is not None and target.tzinfo is None:
56	        target = target.replace(tzinfo=index.tz)
57	    eligible = history[index >= target]
58	    return None if eligible.empty else float(eligible["Close"].iloc[0])
59
60
61	def _default_history(ticker: str, start: datetime, end: datetime):
62	    import yfinance as yf
63	    return yf.Ticker(ticker).history(start=start.date().isoformat(), end=end.date().isoformat(), auto_adjust=False)
64
65
66	def update_pending_outcomes(*, db_path: str = DB_PATH, now: datetime | None = None,
67	                            history_fetcher: Callable = _default_history) -> dict:
68	    now = now or datetime.now()
69	    with sqlite3.connect(db_path) as conn:
70	        conn.row_factory = sqlite3.Row
71	        pending = conn.execute("SELECT * FROM signal_outcomes WHERE outcome='pending'").fetchall()
72	        updated = unmeasurable = 0
73	        for raw in pending:
74	            row = dict(raw)
75	            notified = datetime.fromisoformat(row["notified_at"].replace(" ", "T"))
76	            elapsed = (now - notified).days
77	            if elapsed < 30:
78	                continue
79	            horizon = 60 if elapsed >= 60 else 30
80	            end = notified + timedelta(days=horizon + 8)
81	            try:
82	                stock = history_fetcher(row["ticker"], notified, end)
83	                spy = history_fetcher("SPY", notified, end)
84	                entry = row["price_at_notify"] or _close_on_or_after(stock, notified)
85	                spy_entry = row["spy_price_at_notify"] or _close_on_or_after(spy, notified)
86	                p30 = _close_on_or_after(stock, notified + timedelta(days=30))
87	                s30 = _close_on_or_after(spy, notified + timedelta(days=30))
88	                if not all((entry, spy_entry, p30, s30)):
89	                    conn.execute("UPDATE signal_outcomes SET outcome='unmeasurable', checked_at=? WHERE id=?",
90	                                 (now.isoformat(timespec="seconds"), row["id"]))
91	                    unmeasurable += 1
92	                    continue
93	                values = {"spy_price_at_notify": spy_entry, "price_30d": p30, "spy_price_30d": s30,
94	                          "return_30d_pct": (p30 / entry - 1) * 100,
95	                          "return_30d_excess_pct": excess_return_pct(entry, p30, spy_entry, s30),
96	                          "checked_at": now.isoformat(timespec="seconds")}
97	                if horizon == 60:
98	                    p60 = _close_on_or_after(stock, notified + timedelta(days=60))
99	                    s60 = _close_on_or_after(spy, notified + timedelta(days=60))
100	                    if not p60 or not s60:
101	                        conn.execute("UPDATE signal_outcomes SET outcome='unmeasurable', checked_at=? WHERE id=?",
102	                                     (values["checked_at"], row["id"]))
103	                        unmeasurable += 1
104	                        continue
105	                    values.update({"price_60d": p60, "spy_price_60d": s60,
106	                                   "return_60d_pct": (p60 / entry - 1) * 100,
107	                                   "return_60d_excess_pct": excess_return_pct(entry, p60, spy_entry, s60)})
108	                    values["outcome"] = "win" if values["return_60d_excess_pct"] > 0 else "loss"
109	                sets = ", ".join(f"{key}=?" for key in values)
110	                conn.execute(f"UPDATE signal_outcomes SET {sets} WHERE id=?", (*values.values(), row["id"]))
111	                updated += 1
112	            except Exception as exc:
113	                print(f"  Outcome check failed for {row['ticker']}: {exc}")
114	        return {"updated": updated, "unmeasurable": unmeasurable, "pending_checked": len(pending)}
115
116
117	def _median(values):
118	    values = sorted(values)
119	    if not values: return None
120	    mid = len(values) // 2
121	    return values[mid] if len(values) % 2 else (values[mid - 1] + values[mid]) / 2
122
123
124	def _print_group(label: str, rows: list[dict]) -> None:
125	    values = [r["return_30d_excess_pct"] for r in rows if r["return_30d_excess_pct"] is not None]
126	    if not values:
127	        print(f"{label:<28} n={len(rows):>3} measured=  0 median30d=undefined")
128	        return
129	    print(f"{label:<28} n={len(rows):>3} measured={len(values):>3} "
130	          f"median30d={_median(values):+.2f}% precision={sum(v > 0 for v in values)/len(values):.0%}")
131
132
133	def print_scorecard(*, db_path: str = DB_PATH) -> None:
134	    with sqlite3.connect(db_path) as conn:
135	        conn.row_factory = sqlite3.Row
136	        rows = [dict(r) for r in conn.execute("SELECT * FROM signal_outcomes ORDER BY notified_at DESC")]
137	    if not rows:
138	        print("No notify-tier candidates recorded; metrics are undefined.")
139	        return
140	    print(f"Signal scorecard — {len(rows)} notify-tier candidates")
141	    _print_group("Fired", [r for r in rows if r["fired"]])
142	    _print_group("Suppressed", [r for r in rows if not r["fired"]])
143	    for field, label in (("conviction", "Conviction"), ("suppression_reason", "Suppression"),
144	                         ("verifier_verdict", "Verifier")):
145	        for value in sorted({r[field] for r in rows if r[field]}):
146	            _print_group(f"{label}: {value}", [r for r in rows if r[field] == value])
147	    _print_group("Citation-backed", [r for r in rows if r["citation_backed"]])
148
149
150	if __name__ == "__main__":
151	    parser = argparse.ArgumentParser()
152	    parser.add_argument("--scorecard", action="store_true")
153	    args = parser.parse_args()
154	    if not args.scorecard:
155	        result = update_pending_outcomes()
156	        print(f"Updated {result['updated']} outcomes; {result['unmeasurable']} unmeasurable")
157	    print_scorecard()
158

"""Dense candidate outcome ledger and excess-return scorecard."""
from __future__ import annotations
import argparse
from datetime import datetime, timedelta
import os
import sqlite3
import sys
from typing import Callable

sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
from config import DB_PATH

def excess_return_pct(entry: float, exit_price: float, benchmark_entry: float, benchmark_exit: float) -> float:
    if min(entry, exit_price, benchmark_entry, benchmark_exit) <= 0:
        raise ValueError("prices must be positive")
    return ((exit_price / entry - 1) - (benchmark_exit / benchmark_entry - 1)) * 100

def record_candidate(*, ticker: str, score: float, conviction: str,
                     recommended_action: str, price: float | None,
                     observed_at: str, fired: bool, suppression_reason: str | None,
                     verifier_verdict: str | None = None, citation_backed: bool = False,
                     db_path: str = DB_PATH) -> None:
    """Record every notify-tier candidate, including candidates suppressed by a gate."""
    with sqlite3.connect(db_path) as conn:
        version = conn.execute("SELECT id FROM config_versions WHERE active=1 ORDER BY id DESC LIMIT 1").fetchone()
        conn.execute("""
            INSERT INTO signal_outcomes
              (ticker, notified_at, score_at_notify, conviction, recommended_action,
               price_at_notify, fired, suppression_reason, verifier_verdict, citation_backed,
               config_version_id, outcome)
            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending')
            ON CONFLICT(ticker, notified_at) DO UPDATE SET
              score_at_notify=excluded.score_at_notify, conviction=excluded.conviction,
              recommended_action=excluded.recommended_action, price_at_notify=excluded.price_at_notify,
              fired=excluded.fired, suppression_reason=excluded.suppression_reason,
              verifier_verdict=excluded.verifier_verdict, citation_backed=excluded.citation_backed,
              config_version_id=excluded.config_version_id
        """, (ticker, observed_at, score, conviction, recommended_action, price, int(fired),
              suppression_reason, verifier_verdict, int(citation_backed), version[0] if version else None))

def record_notification(ticker: str, score: float, conviction: str,
                        recommended_action: str, price: float | None, notified_at: str) -> None:
    record_candidate(ticker=ticker, score=score, conviction=conviction,
                     recommended_action=recommended_action, price=price,
                     observed_at=notified_at, fired=True, suppression_reason=None)

def _close_on_or_after(history, target: datetime) -> float | None:
    if history is None or history.empty:
        return None
    index = history.index
    if getattr(index, "tz", None) is not None and target.tzinfo is None:
        target = target.replace(tzinfo=index.tz)
    eligible = history[index >= target]
    return None if eligible.empty else float(eligible["Close"].iloc[0])

def _default_history(ticker: str, start: datetime, end: datetime):
    import yfinance as yf
    return yf.Ticker(ticker).history(start=start.date().isoformat(), end=end.date().isoformat(), auto_adjust=False)

def update_pending_outcomes(*, db_path: str = DB_PATH, now: datetime | None = None,
                            history_fetcher: Callable = _default_history) -> dict:
    now = now or datetime.now()
    with sqlite3.connect(db_path) as conn:
        conn.row_factory = sqlite3.Row
        pending = conn.execute("SELECT * FROM signal_outcomes WHERE outcome='pending'").fetchall()
        updated = unmeasurable = 0
        for raw in pending:
            row = dict(raw)
            notified = datetime.fromisoformat(row["notified_at"].replace(" ", "T"))
            elapsed = (now - notified).days
            if elapsed < 30:
                continue
            horizon = 60 if elapsed >= 60 else 30
            end = notified + timedelta(days=horizon + 8)
            try:
                stock = history_fetcher(row["ticker"], notified, end)
                spy = history_fetcher("SPY", notified, end)
                entry = row["price_at_notify"] or _close_on_or_after(stock, notified)
                spy_entry = row["spy_price_at_notify"] or _close_on_or_after(spy, notified)
                p30 = _close_on_or_after(stock, notified + timedelta(days=30))
                s30 = _close_on_or_after(spy, notified + timedelta(days=30))
                if not all((entry, spy_entry, p30, s30)):
                    conn.execute("UPDATE signal_outcomes SET outcome='unmeasurable', checked_at=? WHERE id=?",
                                 (now.isoformat(timespec="seconds"), row["id"]))
                    unmeasurable += 1
                    continue
                values = {"spy_price_at_notify": spy_entry, "price_30d": p30, "spy_price_30d": s30,
                          "return_30d_pct": (p30 / entry - 1) * 100,
                          "return_30d_excess_pct": excess_return_pct(entry, p30, spy_entry, s30),
                          "checked_at": now.isoformat(timespec="seconds")}
                if horizon == 60:
                    p60 = _close_on_or_after(stock, notified + timedelta(days=60))
                    s60 = _close_on_or_after(spy, notified + timedelta(days=60))
                    if not p60 or not s60:
                        conn.execute("UPDATE signal_outcomes SET outcome='unmeasurable', checked_at=? WHERE id=?",
                                     (values["checked_at"], row["id"]))
                        unmeasurable += 1
                        continue
                    values.update({"price_60d": p60, "spy_price_60d": s60,
                                   "return_60d_pct": (p60 / entry - 1) * 100,
                                   "return_60d_excess_pct": excess_return_pct(entry, p60, spy_entry, s60)})
                    values["outcome"] = "win" if values["return_60d_excess_pct"] > 0 else "loss"
                sets = ", ".join(f"{key}=?" for key in values)
                conn.execute(f"UPDATE signal_outcomes SET {sets} WHERE id=?", (*values.values(), row["id"]))
                updated += 1
            except Exception as exc:
                print(f"  Outcome check failed for {row['ticker']}: {exc}")
        return {"updated": updated, "unmeasurable": unmeasurable, "pending_checked": len(pending)}

def _median(values):
    values = sorted(values)
    if not values: return None
    mid = len(values) // 2
    return values[mid] if len(values) % 2 else (values[mid - 1] + values[mid]) / 2

def _print_group(label: str, rows: list[dict]) -> None:
    values = [r["return_30d_excess_pct"] for r in rows if r["return_30d_excess_pct"] is not None]
    if not values:
        print(f"{label:<28} n={len(rows):>3} measured=  0 median30d=undefined")
        return
    print(f"{label:<28} n={len(rows):>3} measured={len(values):>3} "
          f"median30d={_median(values):+.2f}% precision={sum(v > 0 for v in values)/len(values):.0%}")

def print_scorecard(*, db_path: str = DB_PATH) -> None:
    with sqlite3.connect(db_path) as conn:
        conn.row_factory = sqlite3.Row
        rows = [dict(r) for r in conn.execute("SELECT * FROM signal_outcomes ORDER BY notified_at DESC")]
    if not rows:
        print("No notify-tier candidates recorded; metrics are undefined.")
        return
    print(f"Signal scorecard — {len(rows)} notify-tier candidates")
    _print_group("Fired", [r for r in rows if r["fired"]])
    _print_group("Suppressed", [r for r in rows if not r["fired"]])
    for field, label in (("conviction", "Conviction"), ("suppression_reason", "Suppression"),
                         ("verifier_verdict", "Verifier")):
        for value in sorted({r[field] for r in rows if r[field]}):
            _print_group(f"{label}: {value}", [r for r in rows if r[field] == value])
    _print_group("Citation-backed", [r for r in rows if r["citation_backed"]])

if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument("--scorecard", action="store_true")
    args = parser.parse_args()
    if not args.scorecard:
        result = update_pending_outcomes()
        print(f"Updated {result['updated']} outcomes; {result['unmeasurable']} unmeasurable")
    print_scorecard()


Ingest Claude blog post on marketing operations automation


Looking at this code carefully:

The observation says: "`tools/outcome_tracker.py` approximates 30d/60d prices by indexing into a 65-day yfinance window ending *today* instead of anchoring at `notified_at` — horizon returns drift as days pass"

But WAIT — looking at the actual code in `update_pending_outcomes()`:
```python
end = notified + timedelta(days=horizon + 8)
try:
    stock = history_fetcher(row["ticker"], notified, end)
```

The code actually DOES anchor at `notified` and fetches from `notified` to `notified + (horizon + 8 days)`. This is the CORRECT behavior! The commit that logged this observation was "docs: log outcome_tracker.py horizon-return drift observation" (438c536), but the actual code appears to have already been fixed to use proper anchoring. I need to check the git history to understand whether the code was already corrected before that observation was logged, or if something else is going on.

Looking at the commit order, the most recent commit is the one that documented the drift observation, but it didn't actually fix the issue. However, examining the current `update_pending_outcomes` function shows it's using the correct anchoring with `end = notified + timedelta(days=horizon + 8)`.

This makes me wonder if the observation was logged when the code was still in its buggy state, or if the fix happened separately. I should check the git history specifically for the outcome_tracker.py file to see when the code was actually corrected.


Bash

git log --oneline -- tools/outcome_tracker.py

Check git history of outcome_tracker.py


909c0c8 Close the loop with bounded tuning guardrails
0288530 Measure fired and suppressed signal outcomes
141a6e8 Smarter notifications, lighter HTML, outcome tracking, executive trades


What did the observation commit change


commit 438c536d7a2e8983e629b2147cbc5f11d1b5a9aa
Author: James Lopez <james.j.lopez@gmail.com>
Date:   Wed Jul 15 20:37:16 2026 -0500

    docs: log outcome_tracker.py horizon-return drift observation

    Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01CbGqfMq3N2UGkKGhDBAUQK

 observations.md | 9 +++++++++
 1 file changed, 9 insertions(+)


git show 909c0c8 --stat | head -20 && echo "---" && git show 0288530 --stat | head -20

Check if outcome_tracker was fixed in recent commits


commit 909c0c8532151dcc118c3de35ea110b3efd389fb
Author: James Lopez <james.j.lopez@gmail.com>
Date:   Mon Jul 6 18:19:40 2026 -0500

    Close the loop with bounded tuning guardrails

 analysis/guardrails.py                  | 53 +++++++++++++++++++++
 config.py                               |  4 ++
 db/migrate.py                           |  7 +++
 db/schema.sql                           | 13 ++++++
 launchd/com.congresswatch.monthly.plist | 10 ++++
 launchd/com.congresswatch.tuner.plist   | 10 ++++
 run_daily.py                            | 33 ++++++++++++--
 tests/test_guardrails.py                | 19 ++++++++
 tools/outcome_tracker.py                | 11 +++--
 tools/tuner.py                          | 81 +++++++++++++++++++++++++++++++++
 10 files changed, 232 insertions(+), 9 deletions(-)
---
commit 0288530f7f8da55f1421d6433f7011e3fb95a0de
Author: James Lopez <james.j.lopez@gmail.com>
Date:   Mon Jul 6 18:11:14 2026 -0500

    Measure fired and suppressed signal outcomes

 db/migrate.py                 |  13 +++
 db/schema.sql                 |  12 +-
 run_daily.py                  |  36 ++++--
 tests/test_outcome_tracker.py |  53 +++++++++
 tools/outcome_tracker.py      | 248 ++++++++++++++++++++++--------------------
 5 files changed, 234 insertions(+), 128 deletions(-)


So looking at the commit history for `outcome_tracker.py`:
1. `141a6e8` - Initial implementation
2. `0288530` - Major rewrite (Measure fired and suppressed signal outcomes) - July 6
3. `909c0c8` - Further changes (Close the loop with bounded tuning guardrails) - July 6

And then `438c536` (July 15) just logged the observation to `observations.md` without changing `outcome_tracker.py`.

Looking at the current code, it appears the code is already using the correct anchoring approach:
```python
end = notified + timedelta(days=horizon + 8)
```

I'm fetching the historical data for both the stock ticker and SPY using the same time window. But the observation mentions approximating 30d/60d prices by indexing into a 65-day yfinance window ending today, which suggests a different approach was being used. Since the observation was logged on July 15 but the last code change was July 6, I'm wondering if they reverted to an older indexing method between those dates, or if the current code is actually what the observation is describing.

Let me trace through `update_pending_outcomes` more carefully to see how the end date is calculated and whether it truly anchors to today or to the notified date. So the horizon gets set to either 60 or 30 days based on elapsed time, and then the end date is calculated as `notified + (horizon + 8)` days, giving us either a 38-day or 68-day window from the notification date. The observation mentions using today's date as the endpoint, which suggests it might be describing an older version of the code before the July 6 changes. I should look into when `_close_on_or_after` was introduced to understand if this is a recent fix.


1	#!/usr/bin/env python3
2	"""Weekly precision monitor for congressional trading signals.
3
4	Queries signal_outcomes, computes rolling 30d win rate (excess vs SPY)
5	for last 10 / last 20 / all-time settled signals, and outputs JSON for
6	the Nano task trigger:
7	  { "wakeAgent": true/false, "data": { ... } }
8
9	wakeAgent=true when the active precision window drops below 40% (floor).
10	Always logs a human-readable summary to stderr for the run log.
11	"""
12	import json
13	import sqlite3
14	import sys
15	import urllib.request
16	import urllib.error
17
18	DB_PATH = "/workspace/extra/projects/congressional-trading/data/congress_trades.db"
19	NTFY_URL = "https://ntfy.sh/congressWatchSignals"
20	WIN_RATE_FLOOR = 0.40  # BREAKER_MIN_WIN_RATE from config.py
21
22
23	def precision(vals: list) -> float | None:
24	    if not vals:
25	        return None
26	    wins = sum(1 for v in vals if (v or 0) > 0)
27	    return wins / len(vals)
28
29
30	def ntfy_alert(title: str, body: str) -> None:
31	    try:
32	        req = urllib.request.Request(
33	            NTFY_URL,
34	            data=body.encode(),
35	            headers={"Title": title, "Priority": "high", "Tags": "chart_with_downwards_trend,warning"},
36	            method="POST",
37	        )
38	        urllib.request.urlopen(req, timeout=10)
39	    except Exception as e:
40	        print(f"[congressional-monitor] ntfy failed: {e}", file=sys.stderr)
41
42
43	def main() -> None:
44	    try:
45	        conn = sqlite3.connect(DB_PATH)
46	        rows = conn.execute(
47	            """SELECT return_30d_excess_pct, ticker, notified_at, conviction
48	               FROM signal_outcomes
49	               WHERE fired=1 AND return_30d_pct IS NOT NULL
50	               ORDER BY notified_at DESC"""
51	        ).fetchall()
52	        conn.close()
53	    except Exception as e:
54	        print(json.dumps({"wakeAgent": False, "data": {"error": str(e)}}))
55	        return
56
57	    n = len(rows)
58	    if n == 0:
59	        print(json.dumps({"wakeAgent": False, "data": {"note": "no settled signals yet"}}))
60	        return
61
62	    excess_vals = [r[0] for r in rows]
63	    p_all = precision(excess_vals)
64	    p_20 = precision(excess_vals[:20]) if n >= 20 else None
65	    p_10 = precision(excess_vals[:10]) if n >= 10 else None
66
67	    # Active window = tightest settled window with ≥ 10 signals
68	    active_p = p_10 if p_10 is not None else p_all
69	    active_label = "last 10" if p_10 is not None else f"all {n}"
70
71	    summary = {
72	        "n_settled": n,
73	        "precision_all": round(p_all * 100, 1),
74	        "precision_last_20": round(p_20 * 100, 1) if p_20 is not None else None,
75	        "precision_last_10": round(p_10 * 100, 1) if p_10 is not None else None,
76	        "active_window": active_label,
77	        "active_precision": round(active_p * 100, 1),
78	        "floor_pct": WIN_RATE_FLOOR * 100,
79	        "alert": active_p < WIN_RATE_FLOOR,
80	    }
81
82	    print(
83	        f"[congressional-monitor] {n} settled | all={p_all*100:.1f}%"
84	        + (f" | last20={p_20*100:.1f}%" if p_20 else "")
85	        + (f" | last10={p_10*100:.1f}%" if p_10 else "")
86	        + f" | floor={WIN_RATE_FLOOR*100:.0f}%",
87	        file=sys.stderr,
88	    )
89
90	    if active_p < WIN_RATE_FLOOR:
91	        alert_msg = (
92	            f"CongressWatch precision alert: {active_label} win rate "
93	            f"{active_p*100:.1f}% is below the {WIN_RATE_FLOOR*100:.0f}% floor. "
94	            f"Consider tuning threshold or conviction gate."
95	        )
96	        print(f"[congressional-monitor] ALERT: {alert_msg}", file=sys.stderr)
97	        ntfy_alert("CongressWatch: precision below floor", alert_msg)
98	        print(json.dumps({"wakeAgent": True, "data": {**summary, "alert_message": alert_msg}}))
99	    else:
100	        print(
101	            f"[congressional-monitor] Precision holding ({active_label}: {active_p*100:.1f}%). No action needed.",
102	            file=sys.stderr,
103	        )
104	        print(json.dumps({"wakeAgent": False, "data": summary}))
105
106
107	if __name__ == "__main__":
108	    main()
109

#!/usr/bin/env python3
"""Weekly precision monitor for congressional trading signals.

Queries signal_outcomes, computes rolling 30d win rate (excess vs SPY)
for last 10 / last 20 / all-time settled signals, and outputs JSON for
the Nano task trigger:
  { "wakeAgent": true/false, "data": { ... } }

wakeAgent=true when the active precision window drops below 40% (floor).
Always logs a human-readable summary to stderr for the run log.
"""
import json
import sqlite3
import sys
import urllib.request
import urllib.error

DB_PATH = "/workspace/extra/projects/congressional-trading/data/congress_trades.db"
NTFY_URL = "https://ntfy.sh/congressWatchSignals"
WIN_RATE_FLOOR = 0.40  # BREAKER_MIN_WIN_RATE from config.py

def precision(vals: list) -> float | None:
    if not vals:
        return None
    wins = sum(1 for v in vals if (v or 0) > 0)
    return wins / len(vals)

def ntfy_alert(title: str, body: str) -> None:
    try:
        req = urllib.request.Request(
            NTFY_URL,
            data=body.encode(),
            headers={"Title": title, "Priority": "high", "Tags": "chart_with_downwards_trend,warning"},
            method="POST",
        )
        urllib.request.urlopen(req, timeout=10)
    except Exception as e:
        print(f"[congressional-monitor] ntfy failed: {e}", file=sys.stderr)

def main() -> None:
    try:
        conn = sqlite3.connect(DB_PATH)
        rows = conn.execute(
            """SELECT return_30d_excess_pct, ticker, notified_at, conviction
               FROM signal_outcomes
               WHERE fired=1 AND return_30d_pct IS NOT NULL
               ORDER BY notified_at DESC"""
        ).fetchall()
        conn.close()
    except Exception as e:
        print(json.dumps({"wakeAgent": False, "data": {"error": str(e)}}))
        return

    n = len(rows)
    if n == 0:
        print(json.dumps({"wakeAgent": False, "data": {"note": "no settled signals yet"}}))
        return

    excess_vals = [r[0] for r in rows]
    p_all = precision(excess_vals)
    p_20 = precision(excess_vals[:20]) if n >= 20 else None
    p_10 = precision(excess_vals[:10]) if n >= 10 else None

    # Active window = tightest settled window with ≥ 10 signals
    active_p = p_10 if p_10 is not None else p_all
    active_label = "last 10" if p_10 is not None else f"all {n}"

    summary = {
        "n_settled": n,
        "precision_all": round(p_all * 100, 1),
        "precision_last_20": round(p_20 * 100, 1) if p_20 is not None else None,
        "precision_last_10": round(p_10 * 100, 1) if p_10 is not None else None,
        "active_window": active_label,
        "active_precision": round(active_p * 100, 1),
        "floor_pct": WIN_RATE_FLOOR * 100,
        "alert": active_p < WIN_RATE_FLOOR,
    }

    print(
        f"[congressional-monitor] {n} settled | all={p_all*100:.1f}%"
        + (f" | last20={p_20*100:.1f}%" if p_20 else "")
        + (f" | last10={p_10*100:.1f}%" if p_10 else "")
        + f" | floor={WIN_RATE_FLOOR*100:.0f}%",
        file=sys.stderr,
    )

    if active_p < WIN_RATE_FLOOR:
        alert_msg = (
            f"CongressWatch precision alert: {active_label} win rate "
            f"{active_p*100:.1f}% is below the {WIN_RATE_FLOOR*100:.0f}% floor. "
            f"Consider tuning threshold or conviction gate."
        )
        print(f"[congressional-monitor] ALERT: {alert_msg}", file=sys.stderr)
        ntfy_alert("CongressWatch: precision below floor", alert_msg)
        print(json.dumps({"wakeAgent": True, "data": {**summary, "alert_message": alert_msg}}))
    else:
        print(
            f"[congressional-monitor] Precision holding ({active_label}: {active_p*100:.1f}%). No action needed.",
            file=sys.stderr,
        )
        print(json.dumps({"wakeAgent": False, "data": summary}))

if __name__ == "__main__":
    main()


The congressional-monitor.py looks fine. It uses the database's `return_30d_excess_pct` column which is already computed by `update_pending_outcomes`.

Now let me check the outcome_tracker.py test to understand what the observation is about.

Actually, wait. Let me re-read the observation more carefully:

"[observations.md] Open: `tools/outcome_tracker.py` approximates 30d/60d prices by indexing into a 65-day yfinance window ending *today* instead of anchoring at `notified_at` — horizon returns drift as days pass"

Looking at the actual code, I see it anchors at `notified` and calculates the end date as `notified + (horizon + 8)` days, which should be correct. But let me check the `_default_history` function to see if there's something I'm missing there.

The function uses `start.date()` and `end.date()` to convert the datetime objects. For the 30-day horizon case, the window would be 38 days long (30 + 8), but the observation mentions needing a 65-day window ending today. When `elapsed >= 60`, the horizon becomes 60 days, giving us a 68-day window, which seems right. But for the shorter 30-day case, I'm wondering if that 38-day window is actually sufficient for what we need.

Looking at this more carefully, the code appears correct now. The observation might be from an older version before the July 6 rewrite, or it could have been logged when the old code path was still active. Let me check what the tests show to verify this is working as intended.


1	from datetime import datetime
2	import os
3	import sqlite3
4	import tempfile
5	import unittest
6
7	from tools.outcome_tracker import excess_return_pct, print_scorecard, update_pending_outcomes
8
9
10	class OutcomeTrackerTest(unittest.TestCase):
11	    def test_excess_return_subtracts_spy_return(self):
12	        self.assertAlmostEqual(excess_return_pct(100, 120, 400, 440), 10.0)
13
14	    def test_rejects_invalid_prices(self):
15	        with self.assertRaises(ValueError):
16	            excess_return_pct(0, 100, 100, 100)
17
18	    def test_empty_scorecard_handles_zero_candidates(self):
19	        with tempfile.TemporaryDirectory() as tmp:
20	            path = os.path.join(tmp, "db.sqlite")
21	            sqlite3.connect(path).execute("CREATE TABLE signal_outcomes (notified_at TEXT)").close()
22	            print_scorecard(db_path=path)
23
24	    def test_missing_horizon_data_becomes_unmeasurable(self):
25	        with tempfile.TemporaryDirectory() as tmp:
26	            path = os.path.join(tmp, "db.sqlite")
27	            conn = sqlite3.connect(path)
28	            conn.executescript("""
29	                CREATE TABLE signal_outcomes (
30	                  id INTEGER PRIMARY KEY, ticker TEXT, notified_at TEXT,
31	                  price_at_notify REAL, spy_price_at_notify REAL,
32	                  price_30d REAL, price_60d REAL, return_30d_pct REAL,
33	                  return_60d_pct REAL, spy_price_30d REAL, spy_price_60d REAL,
34	                  return_30d_excess_pct REAL, return_60d_excess_pct REAL,
35	                  outcome TEXT, checked_at TEXT);
36	                INSERT INTO signal_outcomes(ticker, notified_at, price_at_notify, outcome)
37	                VALUES ('GONE', '2026-01-01T12:00:00', 10, 'pending');
38	            """)
39	            conn.close()
40
41	            class Empty:
42	                empty = True
43
44	            result = update_pending_outcomes(
45	                db_path=path, now=datetime(2026, 3, 5), history_fetcher=lambda *_: Empty()
46	            )
47	            outcome = sqlite3.connect(path).execute("SELECT outcome FROM signal_outcomes").fetchone()[0]
48	            self.assertEqual(result["unmeasurable"], 1)
49	            self.assertEqual(outcome, "unmeasurable")
50
51
52	if __name__ == "__main__":
53	    unittest.main()
54

from datetime import datetime
import os
import sqlite3
import tempfile
import unittest

from tools.outcome_tracker import excess_return_pct, print_scorecard, update_pending_outcomes

class OutcomeTrackerTest(unittest.TestCase):
    def test_excess_return_subtracts_spy_return(self):
        self.assertAlmostEqual(excess_return_pct(100, 120, 400, 440), 10.0)

    def test_rejects_invalid_prices(self):
        with self.assertRaises(ValueError):
            excess_return_pct(0, 100, 100, 100)

    def test_empty_scorecard_handles_zero_candidates(self):
        with tempfile.TemporaryDirectory() as tmp:
            path = os.path.join(tmp, "db.sqlite")
            sqlite3.connect(path).execute("CREATE TABLE signal_outcomes (notified_at TEXT)").close()
            print_scorecard(db_path=path)

    def test_missing_horizon_data_becomes_unmeasurable(self):
        with tempfile.TemporaryDirectory() as tmp:
            path = os.path.join(tmp, "db.sqlite")
            conn = sqlite3.connect(path)
            conn.executescript("""
                CREATE TABLE signal_outcomes (
                  id INTEGER PRIMARY KEY, ticker TEXT, notified_at TEXT,
                  price_at_notify REAL, spy_price_at_notify REAL,
                  price_30d REAL, price_60d REAL, return_30d_pct REAL,
                  return_60d_pct REAL, spy_price_30d REAL, spy_price_60d REAL,
                  return_30d_excess_pct REAL, return_60d_excess_pct REAL,
                  outcome TEXT, checked_at TEXT);
                INSERT INTO signal_outcomes(ticker, notified_at, price_at_notify, outcome)
                VALUES ('GONE', '2026-01-01T12:00:00', 10, 'pending');
            """)
            conn.close()

            class Empty:
                empty = True

            result = update_pending_outcomes(
                db_path=path, now=datetime(2026, 3, 5), history_fetcher=lambda *_: Empty()
            )
            outcome = sqlite3.connect(path).execute("SELECT outcome FROM signal_outcomes").fetchone()[0]
            self.assertEqual(result["unmeasurable"], 1)
            self.assertEqual(outcome, "unmeasurable")

if __name__ == "__main__":
    unittest.main()


I see - the tests for `outcome_tracker.py` are minimal. They don't test the specific horizon anchoring behavior. Let me look at whether the observation about "65-day window ending *today*" is actually describing OLD code. Let me check git blame or diff.

Actually, let me check the git diff for `tools/outcome_tracker.py` between the initial and later versions to understand what changed:

```
141a6e8 - Initial implementation (Smarter notifications, lighter HTML, outcome tracking, executive trades)
0288530 - Major rewrite (Measure fired and suppressed signal outcomes)
909c0c8 - Further changes (Close the loop with bounded tuning guardrails)
```

Let me see what the 141a6e8 version of outcome_tracker.py looked like (specifically the horizon-calculation logic). If it used `today - 65d` as the start, that would match the observation.

Actually wait - the observation says "65-day yfinance window ending *today*". If the window ends TODAY, then the start would be today minus 65 days, and then it indexes into that window to find the 30-day and 60-day prices by calculating their positions relative to that window start.

This is wrong because if `notified_at` was 90 days ago, the 30-day price should be at `notified_at + 30 days`, which would be 60 days ago, not at the calculated position. The issue is that if the code fetches a 65-day window and tries to find the first price >= `notified_at + 30 days`, it only works correctly if `notified_at` falls within that window. But if a notification is older than 35 days when checking 60-day prices, then `notified_at + 60d` might fall outside the window entirely.

Actually, let me reconsider the edge cases. For notifications between 30 and 35 days old, the 30-day price target would be 0-5 days ago, which is still within the 65-day window. And for notifications between 55 and 65 days old, the 60-day price target would also be within range. The bug only really manifests for very recent notifications where the target date would be in the future, but those get skipped anyway by the elapsed time check.

The real issue is that the window is always relative to today, not to when the notification was sent. If a signal is older than 65 days, the price lookup fails because the target dates fall outside the window. And since the window shifts every day while the target dates are fixed, running the tool on different days fetches different historical data, which could cause the `_close_on_or_after` function to return different results depending on when it's called.

The new approach anchors the window to the notification date itself, so the window always includes the 30-day and 60-day price points relative to when the signal was sent. This makes the lookups stable and correct regardless of how old the notification is.

Now I need to check what changed in that specific commit to see if the current code already has this fix or if there's still something broken.


git show 909c0c8 -- tools/outcome_tracker.py | head -50

Check what 909c0c8 changed in outcome_tracker


commit 909c0c8532151dcc118c3de35ea110b3efd389fb
Author: James Lopez <james.j.lopez@gmail.com>
Date:   Mon Jul 6 18:19:40 2026 -0500

    Close the loop with bounded tuning guardrails

diff --git a/tools/outcome_tracker.py b/tools/outcome_tracker.py
index 4894617..6602a98 100644
--- a/tools/outcome_tracker.py
+++ b/tools/outcome_tracker.py
@@ -24,18 +24,21 @@ def record_candidate(*, ticker: str, score: float, conviction: str,
                      db_path: str = DB_PATH) -> None:
     """Record every notify-tier candidate, including candidates suppressed by a gate."""
     with sqlite3.connect(db_path) as conn:
+        version = conn.execute("SELECT id FROM config_versions WHERE active=1 ORDER BY id DESC LIMIT 1").fetchone()
         conn.execute("""
             INSERT INTO signal_outcomes
               (ticker, notified_at, score_at_notify, conviction, recommended_action,
-               price_at_notify, fired, suppression_reason, verifier_verdict, citation_backed, outcome)
-            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending')
+               price_at_notify, fired, suppression_reason, verifier_verdict, citation_backed,
+               config_version_id, outcome)
+            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending')
             ON CONFLICT(ticker, notified_at) DO UPDATE SET
               score_at_notify=excluded.score_at_notify, conviction=excluded.conviction,
               recommended_action=excluded.recommended_action, price_at_notify=excluded.price_at_notify,
               fired=excluded.fired, suppression_reason=excluded.suppression_reason,
-              verifier_verdict=excluded.verifier_verdict, citation_backed=excluded.citation_backed
+              verifier_verdict=excluded.verifier_verdict, citation_backed=excluded.citation_backed,
+              config_version_id=excluded.config_version_id
         """, (ticker, observed_at, score, conviction, recommended_action, price, int(fired),
-              suppression_reason, verifier_verdict, int(citation_backed)))
+              suppression_reason, verifier_verdict, int(citation_backed), version[0] if version else None))

 def record_notification(ticker: str, score: float, conviction: str,


git show 0288530 -- tools/outcome_tracker.py | head -120

Check the July 6 major rewrite of outcome_tracker


commit 0288530f7f8da55f1421d6433f7011e3fb95a0de
Author: James Lopez <james.j.lopez@gmail.com>
Date:   Mon Jul 6 18:11:14 2026 -0500

    Measure fired and suppressed signal outcomes

diff --git a/tools/outcome_tracker.py b/tools/outcome_tracker.py
index 1cf5330..4894617 100644
--- a/tools/outcome_tracker.py
+++ b/tools/outcome_tracker.py
@@ -1,138 +1,154 @@
-"""
-Track outcomes of notified trade signals.
-
-Run daily after price_fetcher to update pending outcomes:
-  python tools/outcome_tracker.py
-"""
+"""Dense candidate outcome ledger and excess-return scorecard."""
 from __future__ import annotations
+import argparse
+from datetime import datetime, timedelta
+import os
 import sqlite3
 import sys
-import os
-from datetime import datetime, timedelta
+from typing import Callable

 sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
 from config import DB_PATH

+def excess_return_pct(entry: float, exit_price: float, benchmark_entry: float, benchmark_exit: float) -> float:
+    if min(entry, exit_price, benchmark_entry, benchmark_exit) <= 0:
+        raise ValueError("prices must be positive")
+    return ((exit_price / entry - 1) - (benchmark_exit / benchmark_entry - 1)) * 100
+
+
+def record_candidate(*, ticker: str, score: float, conviction: str,
+                     recommended_action: str, price: float | None,
+                     observed_at: str, fired: bool, suppression_reason: str | None,
+                     verifier_verdict: str | None = None, citation_backed: bool = False,
+                     db_path: str = DB_PATH) -> None:
+    """Record every notify-tier candidate, including candidates suppressed by a gate."""
+    with sqlite3.connect(db_path) as conn:
+        conn.execute("""
+            INSERT INTO signal_outcomes
+              (ticker, notified_at, score_at_notify, conviction, recommended_action,
+               price_at_notify, fired, suppression_reason, verifier_verdict, citation_backed, outcome)
+            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending')
+            ON CONFLICT(ticker, notified_at) DO UPDATE SET
+              score_at_notify=excluded.score_at_notify, conviction=excluded.conviction,
+              recommended_action=excluded.recommended_action, price_at_notify=excluded.price_at_notify,
+              fired=excluded.fired, suppression_reason=excluded.suppression_reason,
+              verifier_verdict=excluded.verifier_verdict, citation_backed=excluded.citation_backed
+        """, (ticker, observed_at, score, conviction, recommended_action, price, int(fired),
+              suppression_reason, verifier_verdict, int(citation_backed)))
+
+
 def record_notification(ticker: str, score: float, conviction: str,
-                        recommended_action: str, price: float | None,
-                        notified_at: str) -> None:
-    """Insert a pending outcome record when a notification fires."""
-    conn = sqlite3.connect(DB_PATH)
-    conn.execute(
-        """
-        INSERT INTO signal_outcomes
-            (ticker, notified_at, score_at_notify, conviction, recommended_action, price_at_notify, outcome)
-        VALUES (?, ?, ?, ?, ?, ?, 'pending')
-        """,
-        (ticker, notified_at, score, conviction, recommended_action, price),
-    )
-    conn.commit()
-    conn.close()
-
-
-def update_pending_outcomes() -> dict:
-    """
-    Check pending outcomes and fill in 30d/60d returns using current price data.
-    Returns a summary of how many were updated.
-    """
-    try:
-        import yfinance as yf
-    except ImportError:
-        print("yfinance not installed — skipping outcome tracking")
-        return {"updated": 0}
-
-    conn = sqlite3.connect(DB_PATH)
-    conn.row_factory = sqlite3.Row
-    now = datetime.now()
-
-    pending = conn.execute(
-        "SELECT * FROM signal_outcomes WHERE outcome = 'pending'",
-    ).fetchall()
-
-    updated = 0
-    for row in pending:
-        row = dict(row)
-        if not row["price_at_notify"]:
-            continue
-
-        notified_dt = datetime.fromisoformat(row["notified_at"])
-        days_elapsed = (now - notified_dt).days
-
-        if days_elapsed < 30:
-            continue  # too early to score
-
-        try:
-            ticker_data = yf.Ticker(row["ticker"])
-            hist = ticker_data.history(period="65d")
-            if hist.empty:
+                        recommended_action: str, price: float | None, notified_at: str) -> None:
+    record_candidate(ticker=ticker, score=score, conviction=conviction,
+                     recommended_action=recommended_action, price=price,
+                     observed_at=notified_at, fired=True, suppression_reason=None)
+
+
+def _close_on_or_after(history, target: datetime) -> float | None:
+    if history is None or history.empty:
+        return None


Now I can see the OLD code clearly! In the ORIGINAL `update_pending_outcomes()`:

```python
hist = ticker_data.history(period="65d")  # ← "65d window ending today" ← THE BUG
```

And in the NEW code (after July 6 rewrite):
```python
end = notified + timedelta(days=horizon + 8)
stock = history_fetcher(row["ticker"], notified, end)  # ← anchored at notified ← FIXED
```

So the fix was ALREADY made in `0288530` (July 6, 2026).

But then the observation was logged on July 15, which is after the fix was already in place. That's strange — why would someone document a bug that had already been resolved? Let me look more carefully at what the observation actually describes to understand if there's something I'm missing. I should check if there are other places in the code that call `yfinance` with a fixed period parameter instead of using anchored dates, which might be where the same bug still exists.


grep -r "period=" /workspace/extra/projects/congressional-trading/ --include="*.py" | grep -v "__pycache__\|test_"

Find any remaining yfinance period= usage (old pattern)


/workspace/extra/projects/congressional-trading/analysis/researcher.py:        hist = ticker.history(period="30d", auto_adjust=False)
/workspace/extra/projects/congressional-trading/.venv/lib/python3.9/site-packages/yfinance/tickers.py:    def history(self, period=None, interval="1d",
/workspace/extra/projects/congressional-trading/.venv/lib/python3.9/site-packages/yfinance/tickers.py:    def download(self, period=None, interval="1d",
/workspace/extra/projects/congressional-trading/.venv/lib/python3.9/site-packages/yfinance/tickers.py:                              period=period,
/workspace/extra/projects/congressional-trading/.venv/lib/python3.9/site-packages/yfinance/multi.py:             repair=False, keepna=False, progress=True, period=None, interval="1d",
/workspace/extra/projects/congressional-trading/.venv/lib/python3.9/site-packages/yfinance/multi.py:            _download_one_threaded(ticker, period=period, interval=interval,
/workspace/extra/projects/congressional-trading/.venv/lib/python3.9/site-packages/yfinance/multi.py:            data = _download_one(ticker, period=period, interval=interval,
/workspace/extra/projects/congressional-trading/.venv/lib/python3.9/site-packages/yfinance/multi.py:                           actions=False, progress=True, period=None,
/workspace/extra/projects/congressional-trading/.venv/lib/python3.9/site-packages/yfinance/multi.py:                  actions=False, period=None, interval="1d",
/workspace/extra/projects/congressional-trading/.venv/lib/python3.9/site-packages/yfinance/multi.py:                period=period, interval=interval,
/workspace/extra/projects/congressional-trading/.venv/lib/python3.9/site-packages/yfinance/scrapers/quote.py:            self._prices_1y = self._tkr.history(period="1y", auto_adjust=False, keepna=True)
/workspace/extra/projects/congressional-trading/.venv/lib/python3.9/site-packages/yfinance/scrapers/quote.py:            self._prices_1wk_1h_prepost = self._tkr.history(period="5d", interval="1h", auto_adjust=False, prepost=True)
/workspace/extra/projects/congressional-trading/.venv/lib/python3.9/site-packages/yfinance/scrapers/quote.py:            self._prices_1wk_1h_reg = self._tkr.history(period="5d", interval="1h", auto_adjust=False, prepost=False)
/workspace/extra/projects/congressional-trading/.venv/lib/python3.9/site-packages/yfinance/scrapers/history.py:    def history(self, period=None, interval="1d",
/workspace/extra/projects/congressional-trading/.venv/lib/python3.9/site-packages/yfinance/scrapers/history.py:            _price_data_debug += f' (period={period})'
/workspace/extra/projects/congressional-trading/.venv/lib/python3.9/site-packages/yfinance/scrapers/history.py:    def _get_history_cache(self, period="max", interval="1d") -> pd.DataFrame:
/workspace/extra/projects/congressional-trading/.venv/lib/python3.9/site-packages/yfinance/scrapers/history.py:        df = self.history(period=period, interval=interval, prepost=True)
/workspace/extra/projects/congressional-trading/.venv/lib/python3.9/site-packages/yfinance/scrapers/history.py:            self._get_history_cache(period="5d", interval="1h")
/workspace/extra/projects/congressional-trading/.venv/lib/python3.9/site-packages/yfinance/scrapers/history.py:    def get_dividends(self, period="max") -> pd.Series:
/workspace/extra/projects/congressional-trading/.venv/lib/python3.9/site-packages/yfinance/scrapers/history.py:        df = self._get_history_cache(period=period)
/workspace/extra/projects/congressional-trading/.venv/lib/python3.9/site-packages/yfinance/scrapers/history.py:    def get_capital_gains(self, period="max") -> pd.Series:
/workspace/extra/projects/congressional-trading/.venv/lib/python3.9/site-packages/yfinance/scrapers/history.py:        df = self._get_history_cache(period=period)
/workspace/extra/projects/congressional-trading/.venv/lib/python3.9/site-packages/yfinance/scrapers/history.py:    def get_splits(self, period="max") -> pd.Series:
/workspace/extra/projects/congressional-trading/.venv/lib/python3.9/site-packages/yfinance/scrapers/history.py:        df = self._get_history_cache(period=period)
/workspace/extra/projects/congressional-trading/.venv/lib/python3.9/site-packages/yfinance/scrapers/history.py:    def get_actions(self, period="max") -> pd.Series:
/workspace/extra/projects/congressional-trading/.venv/lib/python3.9/site-packages/yfinance/scrapers/history.py:        df = self._get_history_cache(period=period)
/workspace/extra/projects/congressional-trading/.venv/lib/python3.9/site-packages/yfinance/scrapers/history.py:    def _resample(self, df, df_interval, target_interval, period=None) -> pd.DataFrame:
/workspace/extra/projects/congressional-trading/.venv/lib/python3.9/site-packages/yfinance/scrapers/history.py:            fx_rate = fx_dat.history(period='1mo', repair=repair)['Close'].iloc[-1]
/workspace/extra/projects/congressional-trading/.venv/lib/python3.9/site-packages/yfinance/scrapers/history.py:                fx2_rate = fx2_dat.history(period='1mo', repair=repair)['Close'].iloc[-1]
/workspace/extra/projects/congressional-trading/.venv/lib/python3.9/site-packages/yfinance/base.py:    def get_dividends(self, period="max") -> pd.Series:
/workspace/extra/projects/congressional-trading/.venv/lib/python3.9/site-packages/yfinance/base.py:        return self._lazy_load_price_history().get_dividends(period=period)
/workspace/extra/projects/congressional-trading/.venv/lib/python3.9/site-packages/yfinance/base.py:    def get_capital_gains(self, period="max") -> pd.Series:
/workspace/extra/projects/congressional-trading/.venv/lib/python3.9/site-packages/yfinance/base.py:        return self._lazy_load_price_history().get_capital_gains(period=period)
/workspace/extra/projects/congressional-trading/.venv/lib/python3.9/site-packages/yfinance/base.py:    def get_splits(self, period="max") -> pd.Series:
/workspace/extra/projects/congressional-trading/.venv/lib/python3.9/site-packages/yfinance/base.py:        return self._lazy_load_price_history().get_splits(period=period)
/workspace/extra/projects/congressional-trading/.venv/lib/python3.9/site-packages/yfinance/base.py:    def get_actions(self, period="max") -> pd.Series:
/workspace/extra/projects/congressional-trading/.venv/lib/python3.9/site-packages/yfinance/base.py:        return self._lazy_load_price_history().get_actions(period=period)
/workspace/extra/projects/congressional-trading/.venv/lib/python3.9/site-packages/numpy/lib/_function_base_impl.py:def _interp_dispatcher(x, xp, fp, left=None, right=None, period=None):
/workspace/extra/projects/congressional-trading/.venv/lib/python3.9/site-packages/numpy/lib/_function_base_impl.py:def interp(x, xp, fp, left=None, right=None, period=None):
/workspace/extra/projects/congressional-trading/.venv/lib/python3.9/site-packages/numpy/lib/_function_base_impl.py:    >>> np.interp(x, xp, fp, period=360)
/workspace/extra/projects/congressional-trading/.venv/lib/python3.9/site-packages/numpy/lib/_function_base_impl.py:def _unwrap_dispatcher(p, discont=None, axis=None, *, period=None):
/workspace/extra/projects/congressional-trading/.venv/lib/python3.9/site-packages/numpy/lib/_function_base_impl.py:def unwrap(p, discont=None, axis=-1, *, period=2*pi):
/workspace/extra/projects/congressional-trading/.venv/lib/python3.9/site-packages/numpy/lib/_function_base_impl.py:    >>> np.unwrap([0, 1, 2, -1, 0], period=4)
/workspace/extra/projects/congressional-trading/.venv/lib/python3.9/site-packages/numpy/lib/_function_base_impl.py:    >>> np.unwrap([ 1, 2, 3, 4, 5, 6, 1, 2, 3], period=6)
/workspace/extra/projects/congressional-trading/.venv/lib/python3.9/site-packages/numpy/lib/_function_base_impl.py:    >>> np.unwrap([2, 3, 4, 5, 2, 3, 4, 5], period=4)
/workspace/extra/projects/congressional-trading/.venv/lib/python3.9/site-packages/numpy/lib/_function_base_impl.py:    >>> np.unwrap(phase_deg, period=360)
/workspace/extra/projects/congressional-trading/.venv/lib/python3.9/site-packages/pip/_vendor/rich/progress.py:            update_period=update_period,
/workspace/extra/projects/congressional-trading/.venv/lib/python3.9/site-packages/pandas/core/resample.py:            freq = to_offset(freq, is_period=True)
/workspace/extra/projects/congressional-trading/.venv/lib/python3.9/site-packages/pandas/core/generic.py:            freq = to_offset(freq, is_period=is_period)
/workspace/extra/projects/congressional-trading/.venv/lib/python3.9/site-packages/pandas/core/dtypes/dtypes.py:            freq_offset = to_offset(freq, is_period=True)
/workspace/extra/projects/congressional-trading/.venv/lib/python3.9/site-packages/pandas/core/arrays/period.py:        freq = to_offset(freq, is_period=True)
/workspace/extra/projects/congressional-trading/.venv/lib/python3.9/site-packages/pandas/core/arrays/period.py:        freq = to_offset(freq, is_period=True)
/workspace/extra/projects/congressional-trading/.venv/lib/python3.9/site-packages/pandas/core/arrays/period.py:            freq = to_offset("Q", is_period=True)
/workspace/extra/projects/congressional-trading/.venv/lib/python3.9/site-packages/pandas/core/arrays/period.py:            freq = to_offset(freq, is_period=True)
/workspace/extra/projects/congressional-trading/.venv/lib/python3.9/site-packages/pandas/core/arrays/period.py:        freq = to_offset(freq, is_period=True)
/workspace/extra/projects/congressional-trading/.venv/lib/python3.9/site-packages/pandas/plotting/_matplotlib/converter.py:        freq = to_offset(freq, is_period=True)
/workspace/extra/projects/congressional-trading/.venv/lib/python3.9/site-packages/pandas/plotting/_matplotlib/converter.py:        freq = to_offset(freq, is_period=True)
/workspace/extra/projects/congressional-trading/.venv/lib/python3.9/site-packages/pandas/plotting/_matplotlib/timeseries.py:        freqstr = to_offset(freq, is_period=True).rule_code
/workspace/extra/projects/congressional-trading/.venv/lib/python3.9/site-packages/pandas/plotting/_matplotlib/timeseries.py:        freq = to_offset(freq, is_period=True)
/workspace/extra/projects/congressional-trading/.venv/lib/python3.9/site-packages/pandas/plotting/_matplotlib/timeseries.py:            freq_str, is_period=True


Found it! `analysis/researcher.py` uses `period="30d"`:
```
/workspace/extra/projects/congressional-trading/analysis/researcher.py:        hist = ticker.history(period="30d", auto_adjust=False)
```

This is a different file from `outcome_tracker.py`. But the observation specifically mentions `outcome_tracker.py`. Let me check researcher.py to understand the context.

Actually wait - looking more carefully, the observation says the bug is in `outcome_tracker.py`. But the current `outcome_tracker.py` code already has the correct anchoring (anchored at `notified_at`, not today).
The old code in `outcome_tracker.py` had `hist = ticker_data.history(period="65d")` which was the actual bug, and that got fixed back in commit `0288530` on July 6. So the observation logged on July 15 is stale — it was already resolved. I should close that observation in `observations.md` with a note about the fix. But I'm also noticing `analysis/researcher.py` has a similar pattern with `period="30d"` that might need checking.


grep -B5 -A10 'period="30d"' /workspace/extra/projects/congressional-trading/analysis/researcher.py

Check researcher.py usage of period="30d"


    facts = {"runup_pct_vs_spy": sig.get("runup_pct"), "next_earnings_date": None,
             "earnings_days_away": None, "avg_daily_dollar_volume": None}
    try:
        import yfinance as yf
        ticker = yf.Ticker(sig["ticker"])
        hist = ticker.history(period="30d", auto_adjust=False)
        if not hist.empty:
            facts["avg_daily_dollar_volume"] = round(float((hist["Close"] * hist["Volume"]).mean()), 2)
        dates = ticker.get_earnings_dates(limit=1)
        if dates is not None and not dates.empty:
            dt = dates.index[0]
            dt = dt.to_pydatetime() if hasattr(dt, "to_pydatetime") else dt
            now = datetime.now(dt.tzinfo) if getattr(dt, "tzinfo", None) else datetime.now()
            facts["next_earnings_date"] = dt.date().isoformat()
            facts["earnings_days_away"] = (dt.date() - now.date()).days
    except Exception as exc:


The `period="30d"` in `researcher.py` is used for computing `avg_daily_dollar_volume` — the average daily dollar trading volume over the last 30 days. This is intentionally relative to today because it's measuring current liquidity, not historical price at a specific date. This is NOT the same bug as `outcome_tracker.py` — it's correct behavior for its purpose.

So the conclusion is:
1. `observations.md` has a stale Open observation about `outcome_tracker.py` horizon pricing
2. The bug described was ALREADY FIXED in `0288530` (July 6, 2026) — the July 6 rewrite introduced correct `notified_at`-anchored history fetching
3. The observation was logged 9 days after the fix, likely as a documentation artifact noting the OLD bug that was fixed
4. The observation should be moved to Closed

Looking back at the agentic_plan.md Phase 0, all three items appear to be complete: shadow outcomes are being recorded, horizon pricing was already fixed in `outcome_tracker.py`, and the scorecard flag is already implemented. The observation is stale and should be closed.

Beyond that, I'm wondering if there's more substantial work to tackle. Phase 1 would involve the committee_score_llm feature, but that's a significant undertaking requiring LLM integration and isn't clearly defined in the next action. Phase 2 through 4 are either done or blocked on other decisions. Let me check what the test suite looks like for congressional-trading to see if there are gaps there.


python3 -m pytest tests/ -q 2>&1 | tail -15

Run congressional-trading tests


...........................                                              [100%]
27 passed in 0.07s


Tests pass. Let me now look at what else might be autonomous in this codebase.

Actually, let me look more carefully at the observation. Looking at it again:

```
- [ ] `tools/outcome_tracker.py` approximates 30d/60d prices by indexing into a 65-day yfinance window ending *today* instead of anchoring at `notified_at` — horizon returns drift as days pass (fix planned in `agentic_plan.md` Phase 0)
```

The fix says "planned in `agentic_plan.md` Phase 0" - suggesting it wasn't done yet when the observation was written.

But checking the git history, commit `0288530` from July 6 already fixed `outcome_tracker.py` with the correct anchoring. The observation was logged 9 days later on July 15 in commit `438c536`, which means it was documented after the fix was already in place. Either the observation was being tracked separately and added to the file as documentation, or the code was somehow reverted—but the git log shows no changes to `outcome_tracker.py` after the July 6 fix, so the current code has the correct anchoring. I'm concluding the observation is stale and should be marked as Closed, with a note pointing to the commit that fixed it.

Now I'm thinking about adding a test to verify the anchoring behavior—specifically that `history_fetcher` is called with `notified_at` as the start date rather than today's date. Looking at the existing test structure, I could create a test that captures the arguments passed to `history_fetcher` and asserts they're anchored correctly. Writing the test assertions... I'm going to focus on the congressional-trading work since it's concrete and I can do it autonomously. But before I commit, let me double-check the Phase 0 checklist in the agentic plan—specifically whether "shadow outcomes" is actually recording all candidates or just the ones that fire alerts.


grep -n "record_candidate\|record_notification\|signal_outcomes" /workspace/extra/projects/congressional-trading/run_daily.py | head -30

Check if run_daily.py records suppressed candidates


85:            "FROM signal_outcomes WHERE fired=1 AND conviction=? AND return_30d_excess_pct IS NOT NULL",
387:        from tools.outcome_tracker import record_candidate
393:            record_candidate(


sed -n '380,430p' /workspace/extra/projects/congressional-trading/run_daily.py

Read run_daily.py outcome recording context


            candidate_decisions[ticker] = (True, None)
            notified += 1
        print()

    # Dense shadow ledger continues in report-only mode so gate recovery stays measurable.
    if not dry_run and notify_candidates:
        from tools.outcome_tracker import record_candidate
        from analysis.timeliness import assess_age
        observed_at = start.isoformat(timespec="seconds")
        for sig in notify_candidates:
            research = research_map.get(sig["ticker"]) or {}
            fired, reason = candidate_decisions.get(sig["ticker"], (False, "not_evaluated"))
            record_candidate(
                ticker=sig["ticker"], score=_timeliness_adjusted_score(
                    sig["total_score"], assess_age(sig["last_seen"], runup_pct=sig.get("runup_pct"))),
                conviction=research.get("conviction", ""),
                recommended_action=research.get("recommended_action", ""),
                price=sig.get("price_current"), observed_at=observed_at,
                fired=fired, suppression_reason=reason,
                verifier_verdict=research.get("verifier_verdict"),
                citation_backed=bool(research.get("sources")),
            )

    elapsed = (datetime.now() - start).total_seconds()
    print(f"Run complete in {elapsed:.1f}s — {notified} notifications sent"
          + (f", {skipped_tl} suppressed by timeliness" if skipped_tl else "")
          + (f", {skipped_gate} filtered by quality gates" if skipped_gate else "")
          + (f", {skipped_cooldown} suppressed by cooldown" if skipped_cooldown else "") + ".")
    print(f"Report: reports/signals_latest.md\n")

if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="CongressWatch daily runner")
    parser.add_argument("--dry-run",   action="store_true", help="no LLM calls, no notifications")
    parser.add_argument("--no-ingest", action="store_true", help="skip ingest step")
    parser.add_argument("--force",     action="store_true", help="re-notify even if notified today")
    args = parser.parse_args()
    run(dry_run=args.dry_run, no_ingest=args.no_ingest, force_notify=args.force)


Good. I can see that `run_daily.py` records ALL notify-tier candidates (both fired and suppressed) via `record_candidate`. This is the "shadow outcomes" feature from Phase 0 item 1. The code at line 393 records a candidate for EVERY signal in `notify_candidates`, with `fired=fired` where `fired` is from `candidate_decisions[ticker]`. So both fired and suppressed candidates are tracked. Phase 0 is complete.

Now, the observation says the bug is "fix planned in agentic_plan.md Phase 0" - and Phase 0 is done. The observation is definitely stale.

My plan for this cycle is to close that stale observation, add a regression test for the horizon anchoring to prevent sliding back into the rolling-window bug, and commit everything. I'll start by writing the regression test that validates the database setup with a signal.

The test needs to create a signal notified 35 days ago, mock the history fetcher to track what arguments it receives, and verify it's called with the correct start date (the notification date itself, not 65 days back) and end date (notification plus 38 days for the 30+8 day window). I'm checking the existing test structure to understand how to set up the database properly.

Setting up the test schema... With 63 days elapsed, the horizon is 60 days, so I need to verify the history fetcher is called with the correct date range starting from the notification time and extending 68 days forward. I'll write a test that checks the fetcher receives the right start and end dates, and return a fake DataFrame to ensure the code path executes properly. Writing test assertions... I need to verify that `timedelta` is already imported at the top of the test file so I can use it in the test assertions.


1	from datetime import datetime
2	import os
3	import sqlite3
4	import tempfile
5	import unittest
6
7	from tools.outcome_tracker import excess_return_pct, print_scorecard, update_pending_outcomes
8
9
10	class OutcomeTrackerTest(unittest.TestCase):

from datetime import datetime
import os
import sqlite3
import tempfile
import unittest

from tools.outcome_tracker import excess_return_pct, print_scorecard, update_pending_outcomes

class OutcomeTrackerTest(unittest.TestCase):


I see `datetime` is imported but not `timedelta`. I need to add `timedelta` to the import. Let me write the test now.

Actually, from the existing test I can see the format: `from datetime import datetime`. I'll need to change this to `from datetime import datetime, timedelta`.

Let me write the full test:


Edit

from datetime import datetime
import os
import sqlite3
import tempfile
import unittest

from tools.outcome_tracker import excess_return_pct, print_scorecard, update_pending_outcomes

from datetime import datetime, timedelta
import os
import sqlite3
import tempfile
import unittest

from tools.outcome_tracker import excess_return_pct, print_scorecard, update_pending_outcomes


The file /workspace/extra/projects/congressional-trading/tests/test_outcome_tracker.py has been updated successfully. (file state is current in your context — no need to Read it back)

from datetime import datetime, timedelta
import os
import sqlite3
import tempfile
import unittest

from tools.outcome_tracker import excess_return_pct, print_scorecard, update_pending_outcomes

from datetime import datetime
import os
import sqlite3
import tempfile
import unittest

from tools.outcome_tracker import excess_return_pct, print_scorecard, update_pending_outcomes

class OutcomeTrackerTest(unittest.TestCase):
    def test_excess_return_subtracts_spy_return(self):
        self.assertAlmostEqual(excess_return_pct(100, 120, 400, 440), 10.0)

    def test_rejects_invalid_prices(self):
        with self.assertRaises(ValueError):
            excess_return_pct(0, 100, 100, 100)

    def test_empty_scorecard_handles_zero_candidates(self):
        with tempfile.TemporaryDirectory() as tmp:
            path = os.path.join(tmp, "db.sqlite")
            sqlite3.connect(path).execute("CREATE TABLE signal_outcomes (notified_at TEXT)").close()
            print_scorecard(db_path=path)

    def test_missing_horizon_data_becomes_unmeasurable(self):
        with tempfile.TemporaryDirectory() as tmp:
            path = os.path.join(tmp, "db.sqlite")
            conn = sqlite3.connect(path)
            conn.executescript("""
                CREATE TABLE signal_outcomes (
                  id INTEGER PRIMARY KEY, ticker TEXT, notified_at TEXT,
                  price_at_notify REAL, spy_price_at_notify REAL,
                  price_30d REAL, price_60d REAL, return_30d_pct REAL,
                  return_60d_pct REAL, spy_price_30d REAL, spy_price_60d REAL,
                  return_30d_excess_pct REAL, return_60d_excess_pct REAL,
                  outcome TEXT, checked_at TEXT);
                INSERT INTO signal_outcomes(ticker, notified_at, price_at_notify, outcome)
                VALUES ('GONE', '2026-01-01T12:00:00', 10, 'pending');
            """)
            conn.close()

            class Empty:
                empty = True

            result = update_pending_outcomes(
                db_path=path, now=datetime(2026, 3, 5), history_fetcher=lambda *_: Empty()
            )
            outcome = sqlite3.connect(path).execute("SELECT outcome FROM signal_outcomes").fetchone()[0]
            self.assertEqual(result["unmeasurable"], 1)
            self.assertEqual(outcome, "unmeasurable")

if __name__ == "__main__":
    unittest.main()

+from datetime import datetime, timedelta


if __name__ == "__main__":
    unittest.main()

    def test_horizon_fetcher_anchors_at_notified_date_not_today(self):
        """History fetch must start at notified_at, not a rolling window from today.

        Regression for the pre-July-6-2026 bug where update_pending_outcomes called
        yf.Ticker(ticker).history(period="65d") — a window ending *today* — so the
        30d/60d prices drifted each day the script ran instead of being stable
        calendar-anchored values.  Fixed in commit 0288530; this test ensures it
        stays fixed.
        """
        _CREATE = """
            CREATE TABLE signal_outcomes (
              id INTEGER PRIMARY KEY, ticker TEXT, notified_at TEXT,
              price_at_notify REAL, spy_price_at_notify REAL,
              price_30d REAL, price_60d REAL, return_30d_pct REAL,
              return_60d_pct REAL, spy_price_30d REAL, spy_price_60d REAL,
              return_30d_excess_pct REAL, return_60d_excess_pct REAL,
              outcome TEXT, checked_at TEXT);
        """
        notified = datetime(2026, 1, 1, 12, 0, 0)
        now = datetime(2026, 3, 5)          # 63 days later → 60-day horizon
        expected_end = notified + timedelta(days=60 + 8)

        with tempfile.TemporaryDirectory() as tmp:
            path = os.path.join(tmp, "db.sqlite")
            conn = sqlite3.connect(path)
            conn.execute(_CREATE)
            conn.execute(
                "INSERT INTO signal_outcomes(ticker, notified_at, price_at_notify, outcome)"
                " VALUES ('AAPL', ?, 150.0, 'pending')",
                (notified.isoformat(),),
            )
            conn.commit()
            conn.close()

            class _Empty:
                empty = True

            calls: list = []

            def tracking_fetcher(ticker, start, end):
                calls.append((ticker, start, end))
                return _Empty()

            update_pending_outcomes(db_path=path, now=now, history_fetcher=tracking_fetcher)

        self.assertTrue(calls, "history_fetcher was never invoked")
        for ticker, start, end in calls:
            self.assertEqual(
                start, notified,
                f"{ticker}: fetch started at {start!r}, expected notified_at {notified!r}",
            )
        _, _, end0 = calls[0]
        self.assertEqual(
            end0, expected_end,
            f"fetch end {end0!r} != notified_at + 68d ({expected_end!r})",
        )

if __name__ == "__main__":
    unittest.main()


    def test_horizon_fetcher_anchors_at_notified_date_not_today(self):
        """History fetch must start at notified_at, not a rolling window from today.

        Regression for the pre-July-6-2026 bug where update_pending_outcomes called
        yf.Ticker(ticker).history(period="65d") — a window ending *today* — so the
        30d/60d prices drifted each day the script ran instead of being stable
        calendar-anchored values.  Fixed in commit 0288530; this test ensures it
        stays fixed.
        """
        _CREATE = """
            CREATE TABLE signal_outcomes (
              id INTEGER PRIMARY KEY, ticker TEXT, notified_at TEXT,
              price_at_notify REAL, spy_price_at_notify REAL,
              price_30d REAL, price_60d REAL, return_30d_pct REAL,
              return_60d_pct REAL, spy_price_30d REAL, spy_price_60d REAL,
              return_30d_excess_pct REAL, return_60d_excess_pct REAL,
              outcome TEXT, checked_at TEXT);
        """
        notified = datetime(2026, 1, 1, 12, 0, 0)
        now = datetime(2026, 3, 5)          # 63 days later → 60-day horizon
        expected_end = notified + timedelta(days=60 + 8)

        with tempfile.TemporaryDirectory() as tmp:
            path = os.path.join(tmp, "db.sqlite")
            conn = sqlite3.connect(path)
            conn.execute(_CREATE)
            conn.execute(
                "INSERT INTO signal_outcomes(ticker, notified_at, price_at_notify, outcome)"
                " VALUES ('AAPL', ?, 150.0, 'pending')",
                (notified.isoformat(),),
            )
            conn.commit()
            conn.close()

            class _Empty:
                empty = True

            calls: list = []

            def tracking_fetcher(ticker, start, end):
                calls.append((ticker, start, end))
                return _Empty()

            update_pending_outcomes(db_path=path, now=now, history_fetcher=tracking_fetcher)

        self.assertTrue(calls, "history_fetcher was never invoked")
        for ticker, start, end in calls:
            self.assertEqual(
                start, notified,
                f"{ticker}: fetch started at {start!r}, expected notified_at {notified!r}",
            )
        _, _, end0 = calls[0]
        self.assertEqual(
            end0, expected_end,
            f"fetch end {end0!r} != notified_at + 68d ({expected_end!r})",
        )

if __name__ == "__main__":
    unittest.main()

from datetime import datetime, timedelta
import os
import sqlite3
import tempfile
import unittest

from tools.outcome_tracker import excess_return_pct, print_scorecard, update_pending_outcomes

class OutcomeTrackerTest(unittest.TestCase):
    def test_excess_return_subtracts_spy_return(self):
        self.assertAlmostEqual(excess_return_pct(100, 120, 400, 440), 10.0)

    def test_rejects_invalid_prices(self):
        with self.assertRaises(ValueError):
            excess_return_pct(0, 100, 100, 100)

    def test_empty_scorecard_handles_zero_candidates(self):
        with tempfile.TemporaryDirectory() as tmp:
            path = os.path.join(tmp, "db.sqlite")
            sqlite3.connect(path).execute("CREATE TABLE signal_outcomes (notified_at TEXT)").close()
            print_scorecard(db_path=path)

    def test_missing_horizon_data_becomes_unmeasurable(self):
        with tempfile.TemporaryDirectory() as tmp:
            path = os.path.join(tmp, "db.sqlite")
            conn = sqlite3.connect(path)
            conn.executescript("""
                CREATE TABLE signal_outcomes (
                  id INTEGER PRIMARY KEY, ticker TEXT, notified_at TEXT,
                  price_at_notify REAL, spy_price_at_notify REAL,
                  price_30d REAL, price_60d REAL, return_30d_pct REAL,
                  return_60d_pct REAL, spy_price_30d REAL, spy_price_60d REAL,
                  return_30d_excess_pct REAL, return_60d_excess_pct REAL,
                  outcome TEXT, checked_at TEXT);
                INSERT INTO signal_outcomes(ticker, notified_at, price_at_notify, outcome)
                VALUES ('GONE', '2026-01-01T12:00:00', 10, 'pending');
            """)
            conn.close()

            class Empty:
                empty = True

            result = update_pending_outcomes(
                db_path=path, now=datetime(2026, 3, 5), history_fetcher=lambda *_: Empty()
            )
            outcome = sqlite3.connect(path).execute("SELECT outcome FROM signal_outcomes").fetchone()[0]
            self.assertEqual(result["unmeasurable"], 1)
            self.assertEqual(outcome, "unmeasurable")

if __name__ == "__main__":
    unittest.main()

+    def test_horizon_fetcher_anchors_at_notified_date_not_today(self):

+        """History fetch must start at notified_at, not a rolling window from today.

+        Regression for the pre-July-6-2026 bug where update_pending_outcomes called

+        yf.Ticker(ticker).history(period="65d") — a window ending *today* — so the

+        30d/60d prices drifted each day the script ran instead of being stable

+        calendar-anchored values.  Fixed in commit 0288530; this test ensures it

+            CREATE TABLE signal_outcomes (

+              id INTEGER PRIMARY KEY, ticker TEXT, notified_at TEXT,

+              price_at_notify REAL, spy_price_at_notify REAL,

+              price_30d REAL, price_60d REAL, return_30d_pct REAL,

+              return_60d_pct REAL, spy_price_30d REAL, spy_price_60d REAL,

+              return_30d_excess_pct REAL, return_60d_excess_pct REAL,

+              outcome TEXT, checked_at TEXT);

+        now = datetime(2026, 3, 5)          # 63 days later → 60-day horizon

+        expected_end = notified + timedelta(days=60 + 8)

+        with tempfile.TemporaryDirectory() as tmp:

+            path = os.path.join(tmp, "db.sqlite")

+            conn = sqlite3.connect(path)

+                "INSERT INTO signal_outcomes(ticker, notified_at, price_at_notify, outcome)"

+                (notified.isoformat(),),

+            def tracking_fetcher(ticker, start, end):

+                calls.append((ticker, start, end))

+            update_pending_outcomes(db_path=path, now=now, history_fetcher=tracking_fetcher)

+        self.assertTrue(calls, "history_fetcher was never invoked")

+        for ticker, start, end in calls:

+                f"{ticker}: fetch started at {start!r}, expected notified_at {notified!r}",

+            f"fetch end {end0!r} != notified_at + 68d ({expected_end!r})",


Bash

python3 -m pytest tests/test_outcome_tracker.py -v 2>&1 | tail -20

Run outcome_tracker tests including new regression test


============================= test session starts ==============================
platform linux -- Python 3.11.2, pytest-9.1.1, pluggy-1.6.0 -- /usr/bin/python3
cachedir: .pytest_cache
rootdir: /workspace/extra/projects/congressional-trading
plugins: anyio-4.14.2
collecting ... collected 5 items

tests/test_outcome_tracker.py::OutcomeTrackerTest::test_empty_scorecard_handles_zero_candidates PASSED [ 20%]
tests/test_outcome_tracker.py::OutcomeTrackerTest::test_excess_return_subtracts_spy_return PASSED [ 40%]
tests/test_outcome_tracker.py::OutcomeTrackerTest::test_horizon_fetcher_anchors_at_notified_date_not_today PASSED [ 60%]
tests/test_outcome_tracker.py::OutcomeTrackerTest::test_missing_horizon_data_becomes_unmeasurable PASSED [ 80%]
tests/test_outcome_tracker.py::OutcomeTrackerTest::test_rejects_invalid_prices PASSED [100%]

============================== 5 passed in 0.02s ===============================


All 5 tests pass including the new regression test. Now let me:
1. Close the observation in `observations.md`
2. Run all tests to verify nothing broke
3. Commit the changes
4. Update the project card and BOARD.md
5. Log the run
6. Schedule the reset-wake
7. Clear the running lock
8. Send the slack-infrastructure note


5/5 pass. Now close the stale observation and run the full suite.


Edit

## Open
- [ ] `tools/outcome_tracker.py` approximates 30d/60d prices by indexing into a 65-day yfinance window ending *today* instead of anchoring at `notified_at` — horizon returns drift as days pass (fix planned in `agentic_plan.md` Phase 0)

## Closed

## Open

## Closed
- [x] `tools/outcome_tracker.py` horizon-return drift — was fetching `period="65d"` (window ending *today*) instead of anchoring at `notified_at`. Fixed in `0288530` (2026-07-06): `update_pending_outcomes` now uses `history_fetcher(ticker, notified, notified + timedelta(days=horizon+8))`. Regression test added in `tests/test_outcome_tracker.py::test_horizon_fetcher_anchors_at_notified_date_not_today`.


The file /workspace/extra/projects/congressional-trading/observations.md has been updated successfully. (file state is current in your context — no need to Read it back)

## Open

## Closed
- [x] `tools/outcome_tracker.py` horizon-return drift — was fetching `period="65d"` (window ending *today*) instead of anchoring at `notified_at`. Fixed in `0288530` (2026-07-06): `update_pending_outcomes` now uses `history_fetcher(ticker, notified, notified + timedelta(days=horizon+8))`. Regression test added in `tests/test_outcome_tracker.py::test_horizon_fetcher_anchors_at_notified_date_not_today`.

# Observations

## Open
- [ ] `tools/outcome_tracker.py` approximates 30d/60d prices by indexing into a 65-day yfinance window ending *today* instead of anchoring at `notified_at` — horizon returns drift as days pass (fix planned in `agentic_plan.md` Phase 0)

## Closed

## Notes
- context-only; never auto-processed

-- [ ] `tools/outcome_tracker.py` approximates 30d/60d prices by indexing into a 65-day yfinance window ending *today* instead of anchoring at `notified_at` — horizon returns drift as days pass (fix planned in `agentic_plan.md` Phase 0)

+- [x] `tools/outcome_tracker.py` horizon-return drift — was fetching `period="65d"` (window ending *today*) instead of anchoring at `notified_at`. Fixed in `0288530` (2026-07-06): `update_pending_outcomes` now uses `history_fetcher(ticker, notified, notified + timedelta(days=horizon+8))`. Regression test added in `tests/test_outcome_tracker.py::test_horizon_fetcher_anchors_at_notified_date_not_today`.


Bash

python3 -m pytest tests/ -q 2>&1 | tail -5

Run full congressional-trading test suite


............................                                             [100%]
28 passed in 0.07s


git add tests/test_outcome_tracker.py observations.md && git commit -m "$(cat <<'EOF'
test(outcome_tracker): regression test for notified_at anchor; close stale observation

The pre-July-6 implementation called yf.Ticker(ticker).history(period="65d"),
fetching a rolling window ending *today*, so 30d/60d horizon prices drifted each
day the script ran.  Fix was already in 0288530 (anchored fetch at notified_at);
this adds a regression test that would fail against the old code.

observation closed: outcome_tracker horizon-return drift (was Open since 2026-07-15,
fix already shipped in 0288530).

28/28 tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
EOF
)"

Commit regression test and closed observation


Error: Exit code 128
Author identity unknown

*** Please tell me who you are.

Run

  git config --global user.email "you@example.com"
  git config --global user.name "Your Name"

to set your account's default identity.
Omit --global to set the identity only in this repository.

fatal: unable to auto-detect email address (got 'unknown@44d1320f2f9f.(none)')


git -c user.name="James Lopez" -c user.email="james.j.lopez@gmail.com" commit -m "$(cat <<'EOF'
test(outcome_tracker): regression test for notified_at anchor; close stale observation

The pre-July-6 implementation called yf.Ticker(ticker).history(period="65d"),
fetching a rolling window ending *today*, so 30d/60d horizon prices drifted each
day the script ran.  Fix was already in 0288530 (anchored fetch at notified_at);
this adds a regression test that would fail against the old code.

observation closed: outcome_tracker horizon-return drift (was Open since 2026-07-15,
fix already shipped in 0288530).

28/28 tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
EOF
)"

Commit with git identity


[feat/agentic-alert-loop f2846af] test(outcome_tracker): regression test for notified_at anchor; close stale observation
 2 files changed, 60 insertions(+), 2 deletions(-)


Great! Committed f2846af. Now let me:
1. Update the congressional-trading project card
2. Update BOARD.md
3. Log to run-log
4. Schedule reset-wake
5. Clear running lock
6. Send slack note


Now update the project card and BOARD.md.


1	# congressional-trading
2
3	- Repo: `~/projects/congressional-trading` · https://github.com/jamesjlopez/congressional-trading
4	- Goal: Score congressional trade disclosures (0–13 scale) and push high-conviction buy signals via ntfy when score ≥ 9 + quality gates pass. In shadow/measurement mode — scoring and logging signals but not pushing live notifications yet.
5	- Stage: test

# congressional-trading

- Repo: `~/projects/congressional-trading` · https://github.com/jamesjlopez/congressional-trading
- Goal: Score congressional trade disclosures (0–13 scale) and push high-conviction buy signals via ntfy when score ≥ 9 + quality gates pass. In shadow/measurement mode — scoring and logging signals but not pushing live notifications yet.
- Stage: test


Edit

- 2026-07-19 — Autopilot cycle (high tier): `/workspace/agent/congressional-monitor.py` — stdlib-only script querying `signal_outcomes`, computing 30d excess precision (last-10 / last-20 / all-time), outputting `{"wakeAgent": bool, "data": {...}}` for Nano task trigger
  - Alert threshold: 40% floor (`BREAKER_MIN_WIN_RATE`); sends ntfy push to `congressWatchSignals` topic on breach
  - Current precision: 58.3% all-time / 65.0% last-20 / 60.0% last-10 (all well above floor)
  - Scheduled weekly: task-1784500441752-hqopzy, Mondays 9am CDT (`0 9 * * 1`)

- 2026-07-19 — Autopilot cycle (high tier): `/workspace/agent/congressional-monitor.py` — stdlib-only script querying `signal_outcomes`, computing 30d excess precision (last-10 / last-20 / all-time), outputting `{"wakeAgent": bool, "data": {...}}` for Nano task trigger
  - Alert threshold: 40% floor (`BREAKER_MIN_WIN_RATE`); sends ntfy push to `congressWatchSignals` topic on breach
  - Current precision: 58.3% all-time / 65.0% last-20 / 60.0% last-10 (all well above floor)
  - Scheduled weekly: task-1784500441752-hqopzy, Mondays 9am CDT (`0 9 * * 1`)
- 2026-07-21 — Autopilot cycle (high tier): closed stale observation — `outcome_tracker.py` horizon-return drift bug was already fixed in `0288530` (2026-07-06); observation had been logged Open on 2026-07-15 after the fix. Added regression test `test_horizon_fetcher_anchors_at_notified_date_not_today` to prevent regression. 28/28 tests pass. Committed f2846af.


<tool_use_error>String to replace not found in file.
String: - 2026-07-19 — Autopilot cycle (high tier): `/workspace/agent/congressional-monitor.py` — stdlib-only script querying `signal_outcomes`, computing 30d excess precision (last-10 / last-20 / all-time), outputting `{"wakeAgent": bool, "data": {...}}` for Nano task trigger
  - Alert threshold: 40% floor (`BREAKER_MIN_WIN_RATE`); sends ntfy push to `congressWatchSignals` topic on breach
  - Current precision: 58.3% all-time / 65.0% last-20 / 60.0% last-10 (all well above floor)
  - Scheduled weekly: task-1784500441752-hqopzy, Mondays 9am CDT (`0 9 * * 1`)
(note: Edit also tried swapping \uXXXX escapes and their characters; neither form matched, so the mismatch is likely elsewhere in old_string. Re-read the file and copy the exact surrounding text.)</tool_use_error>

Error: String to replace not found in file.
String: - 2026-07-19 — Autopilot cycle (high tier): `/workspace/agent/congressional-monitor.py` — stdlib-only script querying `signal_outcomes`, computing 30d excess precision (last-10 / last-20 / all-time), outputting `{"wakeAgent": bool, "data": {...}}` for Nano task trigger
  - Alert threshold: 40% floor (`BREAKER_MIN_WIN_RATE`); sends ntfy push to `congressWatchSignals` topic on breach
  - Current precision: 58.3% all-time / 65.0% last-20 / 60.0% last-10 (all well above floor)
  - Scheduled weekly: task-1784500441752-hqopzy, Mondays 9am CDT (`0 9 * * 1`)
(note: Edit also tried swapping \uXXXX escapes and their characters; neither form matched, so the mismatch is likely elsewhere in old_string. Re-read the file and copy the exact surrounding text.)


40	**Phase 4: Ongoing automated outcome monitoring** ✅ DONE 2026-07-19
41	- `/workspace/agent/congressional-monitor.py` — stdlib-only script querying `signal_outcomes`, computing 30d excess precision (last-10 / last-20 / all-time), outputting `{"wakeAgent": bool, "data": {...}}` for Nano task trigger
42	- Alert threshold: 40% floor (`BREAKER_MIN_WIN_RATE`); sends ntfy push to `congressWatchSignals` topic on breach
43	- Current precision: 58.3% all-time / 65.0% last-20 / 60.0% last-10 (all well above floor)
44	- Scheduled weekly: task-1784500441752-hqopzy, Mondays 9am CDT (`0 9 * * 1`)
45

**Phase 4: Ongoing automated outcome monitoring** ✅ DONE 2026-07-19
- `/workspace/agent/congressional-monitor.py` — stdlib-only script querying `signal_outcomes`, computing 30d excess precision (last-10 / last-20 / all-time), outputting `{"wakeAgent": bool, "data": {...}}` for Nano task trigger
- Alert threshold: 40% floor (`BREAKER_MIN_WIN_RATE`); sends ntfy push to `congressWatchSignals` topic on breach
- Current precision: 58.3% all-time / 65.0% last-20 / 60.0% last-10 (all well above floor)
- Scheduled weekly: task-1784500441752-hqopzy, Mondays 9am CDT (`0 9 * * 1`)


- Scheduled weekly: task-1784500441752-hqopzy, Mondays 9am CDT (`0 9 * * 1`)

- Scheduled weekly: task-1784500441752-hqopzy, Mondays 9am CDT (`0 9 * * 1`)
- 2026-07-21 — Autopilot cycle (high tier): closed stale observation — `outcome_tracker.py` horizon-return drift bug was already fixed in `0288530` (2026-07-06); observation had been logged Open on 2026-07-15 after the fix. Added regression test `test_horizon_fetcher_anchors_at_notified_date_not_today` to prevent regression. 28/28 tests pass. Committed f2846af on feat/agentic-alert-loop.