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()


1	"""
2	Compute per-member historical win rate from signal price returns.
3
4	For each member, joins their buy trades against the signals table to get
5	the signal's current return_from_tx_pct (% since the first buyer's trade date).
6	This is a proxy — it uses the signal's entry date, not each member's individual
7	tx_date — but is a close approximation and avoids per-trade yfinance lookups.
8
9	Win rate = fraction of distinct tickers bought where the signal is currently
10	profitable (return_from_tx_pct > 0). Requires WIN_RATE_MIN_TRADES trades with
11	price data to avoid noise from members with few observations.
12
13	Results are stored in members.win_rate and picked up by scorer.py on the
14	next run. The scoring logic is:
15	  win_rate > 0.60 → track_record_score = 2.0
16	  win_rate ≥ 0.50 → track_record_score = 1.0
17	  win_rate < 0.50 → track_record_score = 0.5
18	  no data         → track_record_score = 1.0 (neutral default)
19
20	Usage:
21	  python tools/win_rate.py              # compute for all members
22	  python tools/win_rate.py --verbose    # print per-member breakdown
23	"""
24
25	import sqlite3
26	import sys
27	import os
28	import argparse
29	from collections import defaultdict
30	from datetime import datetime
31
32	sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
33	from config import DB_PATH, WIN_RATE_MIN_TRADES
34
35
36	def compute(min_trades: int = WIN_RATE_MIN_TRADES, verbose: bool = False) -> dict:
37	    """
38	    Compute and store win rates for all members with sufficient price data.
39	    Returns summary {updated, skipped}.
40	    """
41	    conn = sqlite3.connect(DB_PATH)
42	    conn.row_factory = sqlite3.Row
43
44	    # One row per (member, ticker) pair; use signal return as proxy for member return.
45	    # GROUP BY ensures one signal return per ticker per member even if they made
46	    # multiple buys of the same stock.
47	    rows = conn.execute(
48	        """
49	        SELECT t.member_id,
50	               m.first_name || ' ' || m.last_name AS member_name,
51	               t.issuer_ticker,
52	               s.return_from_tx_pct
53	        FROM trades t
54	        JOIN members m ON m.bioguide_id = t.member_id
55	        JOIN signals s ON s.ticker = t.issuer_ticker
56	        WHERE t.tx_type = 'buy'
57	          AND s.return_from_tx_pct IS NOT NULL
58	        GROUP BY t.member_id, t.issuer_ticker
59	        """
60	    ).fetchall()
61
62	    member_data: dict[str, dict] = defaultdict(lambda: {"name": "", "returns": []})
63	    for row in rows:
64	        mid = row["member_id"]
65	        member_data[mid]["name"] = row["member_name"]
66	        member_data[mid]["returns"].append(row["return_from_tx_pct"])
67
68	    updated = skipped = 0
69	    now = datetime.now().isoformat(timespec="seconds")
70	    results = []
71
72	    for member_id, data in member_data.items():
73	        returns = data["returns"]
74	        if len(returns) < min_trades:
75	            skipped += 1
76	            continue
77
78	        wins = sum(1 for r in returns if r > 0)
79	        win_rate = round(wins / len(returns), 3)
80
81	        conn.execute(
82	            "UPDATE members SET win_rate = ?, updated_at = ? WHERE bioguide_id = ?",
83	            (win_rate, now, member_id),
84	        )
85	        updated += 1
86	        results.append((data["name"], win_rate, wins, len(returns)))
87
88	    conn.commit()
89	    conn.close()
90
91	    results.sort(key=lambda x: x[1], reverse=True)
92
93	    if verbose and results:
94	        print(f"\n{'Member':<30} {'Win Rate':>9}  {'Wins':>5} / {'Total':>5}")
95	        print("-" * 55)
96	        for name, wr, wins, total in results:
97	            bar = "█" * int(wr * 20)
98	            print(f"{name:<30} {wr:>8.1%}  {wins:>5} / {total:>5}  {bar}")
99
100	    return {"updated": updated, "skipped": skipped}
101
102
103	if __name__ == "__main__":
104	    parser = argparse.ArgumentParser(description="Compute per-member win rates")
105	    parser.add_argument("--min-trades", type=int, default=WIN_RATE_MIN_TRADES)
106	    parser.add_argument("--verbose", action="store_true")
107	    args = parser.parse_args()
108
109	    result = compute(min_trades=args.min_trades, verbose=args.verbose)
110	    print(f"\nDone — {result['updated']} members updated, "
111	          f"{result['skipped']} skipped (< {args.min_trades} trades with price data)")
112

"""
Compute per-member historical win rate from signal price returns.

For each member, joins their buy trades against the signals table to get
the signal's current return_from_tx_pct (% since the first buyer's trade date).
This is a proxy — it uses the signal's entry date, not each member's individual
tx_date — but is a close approximation and avoids per-trade yfinance lookups.

Win rate = fraction of distinct tickers bought where the signal is currently
profitable (return_from_tx_pct > 0). Requires WIN_RATE_MIN_TRADES trades with
price data to avoid noise from members with few observations.

Results are stored in members.win_rate and picked up by scorer.py on the
next run. The scoring logic is:
  win_rate > 0.60 → track_record_score = 2.0
  win_rate ≥ 0.50 → track_record_score = 1.0
  win_rate < 0.50 → track_record_score = 0.5
  no data         → track_record_score = 1.0 (neutral default)

Usage:
  python tools/win_rate.py              # compute for all members
  python tools/win_rate.py --verbose    # print per-member breakdown
"""

import sqlite3
import sys
import os
import argparse
from collections import defaultdict
from datetime import datetime

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

def compute(min_trades: int = WIN_RATE_MIN_TRADES, verbose: bool = False) -> dict:
    """
    Compute and store win rates for all members with sufficient price data.
    Returns summary {updated, skipped}.
    """
    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row

    # One row per (member, ticker) pair; use signal return as proxy for member return.
    # GROUP BY ensures one signal return per ticker per member even if they made
    # multiple buys of the same stock.
    rows = conn.execute(
        """
        SELECT t.member_id,
               m.first_name || ' ' || m.last_name AS member_name,
               t.issuer_ticker,
               s.return_from_tx_pct
        FROM trades t
        JOIN members m ON m.bioguide_id = t.member_id
        JOIN signals s ON s.ticker = t.issuer_ticker
        WHERE t.tx_type = 'buy'
          AND s.return_from_tx_pct IS NOT NULL
        GROUP BY t.member_id, t.issuer_ticker
        """
    ).fetchall()

    member_data: dict[str, dict] = defaultdict(lambda: {"name": "", "returns": []})
    for row in rows:
        mid = row["member_id"]
        member_data[mid]["name"] = row["member_name"]
        member_data[mid]["returns"].append(row["return_from_tx_pct"])

    updated = skipped = 0
    now = datetime.now().isoformat(timespec="seconds")
    results = []

    for member_id, data in member_data.items():
        returns = data["returns"]
        if len(returns) < min_trades:
            skipped += 1
            continue

        wins = sum(1 for r in returns if r > 0)
        win_rate = round(wins / len(returns), 3)

        conn.execute(
            "UPDATE members SET win_rate = ?, updated_at = ? WHERE bioguide_id = ?",
            (win_rate, now, member_id),
        )
        updated += 1
        results.append((data["name"], win_rate, wins, len(returns)))

    conn.commit()
    conn.close()

    results.sort(key=lambda x: x[1], reverse=True)

    if verbose and results:
        print(f"\n{'Member':<30} {'Win Rate':>9}  {'Wins':>5} / {'Total':>5}")
        print("-" * 55)
        for name, wr, wins, total in results:
            bar = "█" * int(wr * 20)
            print(f"{name:<30} {wr:>8.1%}  {wins:>5} / {total:>5}  {bar}")

    return {"updated": updated, "skipped": skipped}

if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="Compute per-member win rates")
    parser.add_argument("--min-trades", type=int, default=WIN_RATE_MIN_TRADES)
    parser.add_argument("--verbose", action="store_true")
    args = parser.parse_args()

    result = compute(min_trades=args.min_trades, verbose=args.verbose)
    print(f"\nDone — {result['updated']} members updated, "
          f"{result['skipped']} skipped (< {args.min_trades} trades with price data)")


Ingest Claude blog post on marketing operations automation


Now I have a much clearer picture of the codebase. Let me re-assess Phase 1.

The project card says:
> **Phase 1: Fix win-rate calculation** (prerequisite for everything)
> - Currently `signal_outcomes` computes return using today's price, not a fixed post-signal horizon
> - Fix: for each historical signal, fetch price at tx_date + 30d and tx_date + 60d via yfinance (batch to avoid rate limits)
> - Store as `return_30d` and `return_60d` columns; define "win" = positive return at chosen horizon
Looking at the actual code though, `outcome_tracker.py` is already doing this correctly—it fetches prices at fixed horizons (30 and 60 days post-notification) and computes excess returns against SPY. But `win_rate.py` is pulling `return_from_tx_pct` from the signals table, which appears to be something different.

The real problem is that `return_from_tx_pct` reflects today's price relative to the trade date, so it's constantly shifting. To fix this, I'd need to base member win rates on `signal_outcomes` instead, which has the fixed-horizon returns. The catch is that `signal_outcomes` only contains data for signals that hit the notification threshold, whereas `win_rate.py` is computing across all buys with available price data.

There are actually two separate win-rate concepts at play here: the member win rate used for scoring signals in real-time, and the signal outcome win rate used for post-hoc evaluation. The Phase 1 fix seems focused on the member win rate calculation. Instead of using today's price, I should compute it against the price at a fixed point like 30 or 60 days after the trade date.

I need to check if the `price_history` table has the historical data I'd need for this, and `backfill.py` might be the tool that handles loading those historical prices.


1	"""
2	Historical backfill tool.
3
4	Loads up to N days of congressional trade history from capitoltrades.com
5	into the local SQLite database. Safe to re-run — existing records are
6	skipped via ON CONFLICT DO NOTHING.
7
8	Usage:
9	  python tools/backfill.py               # default: 365 days
10	  python tools/backfill.py --days 730    # 2 years
11	  python tools/backfill.py --days 90     # top-up last 90 days
12	  python tools/backfill.py --dry-run     # show what would be fetched, no writes
13	"""
14
15	import argparse
16	import sys
17	import os
18	from datetime import datetime
19
20	sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
21
22
23	def main(days: int = 365, dry_run: bool = False):
24	    if dry_run:
25	        from ingest.capitol_trades import _fetch_page
26	        import requests
27
28	        HEADERS = {
29	            "User-Agent": (
30	                "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
31	                "AppleWebKit/537.36 (KHTML, like Gecko) "
32	                "Chrome/120.0.0.0 Safari/537.36"
33	            ),
34	            "Accept": "text/html,application/xhtml+xml",
35	        }
36	        session = requests.Session()
37	        records, total_pages = _fetch_page(1, session)
38	        oldest = records[-1].get("pubDate", "")[:10] if records else "?"
39	        newest = records[0].get("pubDate", "")[:10] if records else "?"
40	        print(f"Dry run — {days}-day backfill would fetch up to {total_pages} pages")
41	        print(f"Page 1 sample: {len(records)} records, {newest} → {oldest}")
42	        print(f"Estimated pages needed: ~{max(1, days * len(records) // 30 // len(records))}")
43	        print("Run without --dry-run to execute.")
44	        return
45
46	    from ingest.capitol_trades import ingest
47
48	    print(f"{'='*55}")
49	    print(f"  CongressWatch backfill — {days} days")
50	    print(f"  Started: {datetime.now().strftime('%Y-%m-%d %H:%M')}")
51	    print(f"  Estimated time: {max(1, days // 30 * 2 // 60)} – {max(2, days // 30 * 3 // 60)} min")
52	    print(f"{'='*55}\n")
53
54	    summary = ingest(lookback_days=days)
55
56	    print(f"\n{'='*55}")
57	    print(f"  Backfill complete")
58	    print(f"  New trades ingested: {summary['new_trades']}")
59	    print(f"  Pages fetched:       {summary['pages_fetched']}")
60	    print(f"  Cutoff date:         {summary['cutoff']}")
61	    print(f"{'='*55}")
62	    print("\nNext: run 'python run_daily.py --no-ingest' to re-score with the full history.")
63
64
65	if __name__ == "__main__":
66	    parser = argparse.ArgumentParser(description="Backfill historical congressional trades")
67	    parser.add_argument(
68	        "--days", type=int, default=365,
69	        help="Number of days to backfill (default: 365)"
70	    )
71	    parser.add_argument(
72	        "--dry-run", action="store_true",
73	        help="Preview without writing to DB"
74	    )
75	    args = parser.parse_args()
76	    main(days=args.days, dry_run=args.dry_run)
77

"""
Historical backfill tool.

Loads up to N days of congressional trade history from capitoltrades.com
into the local SQLite database. Safe to re-run — existing records are
skipped via ON CONFLICT DO NOTHING.

Usage:
  python tools/backfill.py               # default: 365 days
  python tools/backfill.py --days 730    # 2 years
  python tools/backfill.py --days 90     # top-up last 90 days
  python tools/backfill.py --dry-run     # show what would be fetched, no writes
"""

import argparse
import sys
import os
from datetime import datetime

sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))

def main(days: int = 365, dry_run: bool = False):
    if dry_run:
        from ingest.capitol_trades import _fetch_page
        import requests

        HEADERS = {
            "User-Agent": (
                "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
                "AppleWebKit/537.36 (KHTML, like Gecko) "
                "Chrome/120.0.0.0 Safari/537.36"
            ),
            "Accept": "text/html,application/xhtml+xml",
        }
        session = requests.Session()
        records, total_pages = _fetch_page(1, session)
        oldest = records[-1].get("pubDate", "")[:10] if records else "?"
        newest = records[0].get("pubDate", "")[:10] if records else "?"
        print(f"Dry run — {days}-day backfill would fetch up to {total_pages} pages")
        print(f"Page 1 sample: {len(records)} records, {newest} → {oldest}")
        print(f"Estimated pages needed: ~{max(1, days * len(records) // 30 // len(records))}")
        print("Run without --dry-run to execute.")
        return

    from ingest.capitol_trades import ingest

    print(f"{'='*55}")
    print(f"  CongressWatch backfill — {days} days")
    print(f"  Started: {datetime.now().strftime('%Y-%m-%d %H:%M')}")
    print(f"  Estimated time: {max(1, days // 30 * 2 // 60)} – {max(2, days // 30 * 3 // 60)} min")
    print(f"{'='*55}\n")

    summary = ingest(lookback_days=days)

    print(f"\n{'='*55}")
    print(f"  Backfill complete")
    print(f"  New trades ingested: {summary['new_trades']}")
    print(f"  Pages fetched:       {summary['pages_fetched']}")
    print(f"  Cutoff date:         {summary['cutoff']}")
    print(f"{'='*55}")
    print("\nNext: run 'python run_daily.py --no-ingest' to re-score with the full history.")

if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="Backfill historical congressional trades")
    parser.add_argument(
        "--days", type=int, default=365,
        help="Number of days to backfill (default: 365)"
    )
    parser.add_argument(
        "--dry-run", action="store_true",
        help="Preview without writing to DB"
    )
    args = parser.parse_args()
    main(days=args.days, dry_run=args.dry_run)


1	"""Deterministic historical replay and bounded two-stage weight calibration."""
2	from __future__ import annotations
3	import argparse
4	from collections import defaultdict
5	from datetime import date, timedelta
6	import itertools
7	import json
8	import random
9	import sqlite3
10	import statistics
11	import sys
12	import os
13	sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
14	from config import (DB_PATH, DEFAULT_SCORE_WEIGHTS, SCORE_NOTIFY_THRESHOLD, LOOKBACK_DAYS,
15	                    BACKTEST_MIN_ALERTS_PER_MONTH)
16	from analysis.scorer import _cluster_score, _size_score
17	from analysis.weights import weighted_total, promote
18
19	def _on_or_after(prices: dict, ticker: str, day: date, max_days: int = 7):
20	    for offset in range(max_days + 1):
21	        value = prices.get((ticker, (day + timedelta(days=offset)).isoformat()))
22	        if value is not None: return value
23	    return None
24
25	def replay(weights: dict, *, db_path: str = DB_PATH,
26	           threshold: float = SCORE_NOTIFY_THRESHOLD) -> dict:
27	    with sqlite3.connect(db_path) as conn:
28	        conn.row_factory = sqlite3.Row
29	        trades = [dict(r) for r in conn.execute("SELECT * FROM trades WHERE tx_type='buy' AND issuer_ticker != '' ORDER BY pub_date")]
30	        prices = {(r[0], r[1]): r[2] for r in conn.execute("SELECT ticker,date,close FROM price_history")}
31	        research = {r[0]: (r[1] or 0, r[2] or 0) for r in conn.execute(
32	            "SELECT ticker, strategic_score, disruption_score FROM research_cache")}
33	    by_day = defaultdict(list)
34	    for trade in trades: by_day[trade["pub_date"][:10]].append(trade)
35	    excess, alert_dates, unmeasurable = [], [], 0
36	    days = sorted(by_day)
37	    for pub_date in days:
38	        replay_day = date.fromisoformat(pub_date)
39	        window_start = replay_day - timedelta(days=LOOKBACK_DAYS)
40	        rows = [trade for trade in trades if window_start <= date.fromisoformat(trade["pub_date"][:10]) <= replay_day]
41	        by_ticker = defaultdict(list)
42	        for row in rows: by_ticker[row["issuer_ticker"]].append(row)
43	        for ticker, ticker_rows in by_ticker.items():
44	            components = {"committee": 0, "track_record": 1,
45	                          "cluster": _cluster_score(len({r['member_id'] for r in ticker_rows})),
46	                          "options": 2 if any(r["is_option"] for r in ticker_rows) else 0,
47	                          "size": _size_score(max(r["amount_tier"] or 0 for r in ticker_rows)),
48	                          "small_cap": 0, "sell_pressure": 0,
49	                          "strategic": research.get(ticker, (0, 0))[0],
50	                          "disruption": research.get(ticker, (0, 0))[1]}
51	            if weighted_total(components, weights) < threshold: continue
52	            start = date.fromisoformat(pub_date)
53	            end = start + timedelta(days=30)
54	            p0, p1 = _on_or_after(prices, ticker, start), _on_or_after(prices, ticker, end)
55	            s0, s1 = _on_or_after(prices, "SPY", start), _on_or_after(prices, "SPY", end)
56	            if not all((p0, p1, s0, s1)):
57	                unmeasurable += 1; continue
58	            excess.append(((p1 / p0 - 1) - (s1 / s0 - 1)) * 100)
59	            alert_dates.append(start)
60	    months = max(1, len({(date.fromisoformat(day).year, date.fromisoformat(day).month) for day in days}))
61	    rate = len(alert_dates) / months
62	    return {"median_30d_excess_pct": statistics.median(excess) if excess else None,
63	            "precision": sum(v > 0 for v in excess) / len(excess) if excess else None,
64	            "alerts": len(excess), "alerts_per_month": rate, "unmeasurable": unmeasurable,
65	            "eligible": bool(excess) and rate >= BACKTEST_MIN_ALERTS_PER_MONTH,
66	            "limitation": "LLM-derived components replay from cache where present; otherwise zero"}
67
68	def _objective(metrics):
69	    return metrics["median_30d_excess_pct"] if metrics["eligible"] else float("-inf")
70
71	def calibrate(*, db_path: str = DB_PATH, broad_samples: int = 100,
72	              focused_samples: int = 100, seed: int = 42) -> tuple[dict, dict]:
73	    rng = random.Random(seed)
74	    keys = list(DEFAULT_SCORE_WEIGHTS)
75	    candidates = [dict(DEFAULT_SCORE_WEIGHTS)]
76	    for _ in range(broad_samples):
77	        candidates.append({key: rng.uniform(0.5, 1.5) for key in keys})
78	    ranked = sorted((( _objective(m := replay(w, db_path=db_path)), w, m) for w in candidates),
79	                    key=lambda item: item[0], reverse=True)
80	    focused = []
81	    for _, winner, _ in ranked[:3]:
82	        for _ in range(max(1, focused_samples // 3)):
83	            focused.append({key: max(0.1, winner[key] * rng.uniform(0.9, 1.1)) for key in keys})
84	    ranked.extend((_objective(m := replay(w, db_path=db_path)), w, m) for w in focused)
85	    _, weights, metrics = max(ranked, key=lambda item: item[0])
86	    metrics["search"] = {"broad_samples": broad_samples, "focused_samples": len(focused), "seed": seed}
87	    return weights, metrics
88
89	if __name__ == "__main__":
90	    parser = argparse.ArgumentParser()
91	    parser.add_argument("--calibrate", action="store_true")
92	    parser.add_argument("--promote", action="store_true", help="manually promote calibrated winner")
93	    args = parser.parse_args()
94	    if args.calibrate:
95	        weights, metrics = calibrate()
96	        print(json.dumps({"weights": weights, "metrics": metrics}, indent=2))
97	        if args.promote: print(f"Promoted config version {promote(weights, metrics, promoted_by='human')}")
98	    else: print(json.dumps(replay(DEFAULT_SCORE_WEIGHTS), indent=2))
99

"""Deterministic historical replay and bounded two-stage weight calibration."""
from __future__ import annotations
import argparse
from collections import defaultdict
from datetime import date, timedelta
import itertools
import json
import random
import sqlite3
import statistics
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
from config import (DB_PATH, DEFAULT_SCORE_WEIGHTS, SCORE_NOTIFY_THRESHOLD, LOOKBACK_DAYS,
                    BACKTEST_MIN_ALERTS_PER_MONTH)
from analysis.scorer import _cluster_score, _size_score
from analysis.weights import weighted_total, promote

def _on_or_after(prices: dict, ticker: str, day: date, max_days: int = 7):
    for offset in range(max_days + 1):
        value = prices.get((ticker, (day + timedelta(days=offset)).isoformat()))
        if value is not None: return value
    return None

def replay(weights: dict, *, db_path: str = DB_PATH,
           threshold: float = SCORE_NOTIFY_THRESHOLD) -> dict:
    with sqlite3.connect(db_path) as conn:
        conn.row_factory = sqlite3.Row
        trades = [dict(r) for r in conn.execute("SELECT * FROM trades WHERE tx_type='buy' AND issuer_ticker != '' ORDER BY pub_date")]
        prices = {(r[0], r[1]): r[2] for r in conn.execute("SELECT ticker,date,close FROM price_history")}
        research = {r[0]: (r[1] or 0, r[2] or 0) for r in conn.execute(
            "SELECT ticker, strategic_score, disruption_score FROM research_cache")}
    by_day = defaultdict(list)
    for trade in trades: by_day[trade["pub_date"][:10]].append(trade)
    excess, alert_dates, unmeasurable = [], [], 0
    days = sorted(by_day)
    for pub_date in days:
        replay_day = date.fromisoformat(pub_date)
        window_start = replay_day - timedelta(days=LOOKBACK_DAYS)
        rows = [trade for trade in trades if window_start <= date.fromisoformat(trade["pub_date"][:10]) <= replay_day]
        by_ticker = defaultdict(list)
        for row in rows: by_ticker[row["issuer_ticker"]].append(row)
        for ticker, ticker_rows in by_ticker.items():
            components = {"committee": 0, "track_record": 1,
                          "cluster": _cluster_score(len({r['member_id'] for r in ticker_rows})),
                          "options": 2 if any(r["is_option"] for r in ticker_rows) else 0,
                          "size": _size_score(max(r["amount_tier"] or 0 for r in ticker_rows)),
                          "small_cap": 0, "sell_pressure": 0,
                          "strategic": research.get(ticker, (0, 0))[0],
                          "disruption": research.get(ticker, (0, 0))[1]}
            if weighted_total(components, weights) < threshold: continue
            start = date.fromisoformat(pub_date)
            end = start + timedelta(days=30)
            p0, p1 = _on_or_after(prices, ticker, start), _on_or_after(prices, ticker, end)
            s0, s1 = _on_or_after(prices, "SPY", start), _on_or_after(prices, "SPY", end)
            if not all((p0, p1, s0, s1)):
                unmeasurable += 1; continue
            excess.append(((p1 / p0 - 1) - (s1 / s0 - 1)) * 100)
            alert_dates.append(start)
    months = max(1, len({(date.fromisoformat(day).year, date.fromisoformat(day).month) for day in days}))
    rate = len(alert_dates) / months
    return {"median_30d_excess_pct": statistics.median(excess) if excess else None,
            "precision": sum(v > 0 for v in excess) / len(excess) if excess else None,
            "alerts": len(excess), "alerts_per_month": rate, "unmeasurable": unmeasurable,
            "eligible": bool(excess) and rate >= BACKTEST_MIN_ALERTS_PER_MONTH,
            "limitation": "LLM-derived components replay from cache where present; otherwise zero"}

def _objective(metrics):
    return metrics["median_30d_excess_pct"] if metrics["eligible"] else float("-inf")

def calibrate(*, db_path: str = DB_PATH, broad_samples: int = 100,
              focused_samples: int = 100, seed: int = 42) -> tuple[dict, dict]:
    rng = random.Random(seed)
    keys = list(DEFAULT_SCORE_WEIGHTS)
    candidates = [dict(DEFAULT_SCORE_WEIGHTS)]
    for _ in range(broad_samples):
        candidates.append({key: rng.uniform(0.5, 1.5) for key in keys})
    ranked = sorted((( _objective(m := replay(w, db_path=db_path)), w, m) for w in candidates),
                    key=lambda item: item[0], reverse=True)
    focused = []
    for _, winner, _ in ranked[:3]:
        for _ in range(max(1, focused_samples // 3)):
            focused.append({key: max(0.1, winner[key] * rng.uniform(0.9, 1.1)) for key in keys})
    ranked.extend((_objective(m := replay(w, db_path=db_path)), w, m) for w in focused)
    _, weights, metrics = max(ranked, key=lambda item: item[0])
    metrics["search"] = {"broad_samples": broad_samples, "focused_samples": len(focused), "seed": seed}
    return weights, metrics

if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument("--calibrate", action="store_true")
    parser.add_argument("--promote", action="store_true", help="manually promote calibrated winner")
    args = parser.parse_args()
    if args.calibrate:
        weights, metrics = calibrate()
        print(json.dumps({"weights": weights, "metrics": metrics}, indent=2))
        if args.promote: print(f"Promoted config version {promote(weights, metrics, promoted_by='human')}")
    else: print(json.dumps(replay(DEFAULT_SCORE_WEIGHTS), indent=2))


OK, now I have a much better picture. Let me re-examine what the project card Phase 1 actually refers to.

Looking at `win_rate.py`:
- Uses `signals.return_from_tx_pct` which is "% gain/loss since member's trade" using **today's current price**
- This is the "win rate uses current price snapshot, not fixed-horizon" issue mentioned in the card

Looking at `backtest.py`:
- Uses `price_history` table which has historical prices
- Already correctly computes 30-day excess returns from pub_date
-
To fix `win_rate.py`, I need to switch from using today's price to a fixed-horizon return (30 or 60 days from trade date). This requires joining trades with the `price_history` table at both the trade date and the future date, but first I need to verify that `price_history` actually contains data going back far enough to support this approach.

The tricky part is that the signals table has `price_at_tx` from the entry date, while the trades table might have null `price_at_trade` values. So I'll need to either pull prices from `price_history` at both the trade date and 30 days later, or use `signals.price_at_tx` paired with a future price lookup from `price_history`.

Actually, a cleaner approach would be to use `price_history` directly for fixed-horizon returns — look up the entry price at `tx_date` and the exit price at `tx_date + 30`, then compare them to determine win or loss. This is more accurate than relying on the signals table's current return metric. The main constraint is whether `price_history` has complete historical data for all tickers involved.

Let me look at what `price_history.py` actually provides.


1	"""Backfill the deterministic daily close cache used by replay backtests."""
2	from __future__ import annotations
3	from datetime import date, timedelta
4	import sqlite3
5	import sys
6	import os
7	sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
8	from config import DB_PATH
9
10	def backfill(*, db_path: str = DB_PATH) -> dict:
11	    import yfinance as yf
12	    with sqlite3.connect(db_path) as conn:
13	        tickers = [r[0] for r in conn.execute("SELECT DISTINCT issuer_ticker FROM trades WHERE issuer_ticker != ''")]
14	        earliest = conn.execute("SELECT MIN(tx_date) FROM trades WHERE tx_date != ''").fetchone()[0]
15	        tickers = sorted(set(tickers) | {"SPY"})
16	        stored = failures = 0
17	        for ticker in tickers:
18	            try:
19	                history = yf.Ticker(ticker).history(start=earliest,
20	                    end=(date.today() + timedelta(days=1)).isoformat(), auto_adjust=False)
21	                rows = [(ticker, idx.date().isoformat(), float(row["Close"]))
22	                        for idx, row in history.iterrows() if row["Close"] == row["Close"]]
23	                conn.executemany("INSERT INTO price_history(ticker,date,close) VALUES(?,?,?) "
24	                                 "ON CONFLICT(ticker,date) DO UPDATE SET close=excluded.close", rows)
25	                stored += len(rows)
26	            except Exception as exc:
27	                failures += 1
28	                print(f"{ticker}: {exc}")
29	        return {"tickers": len(tickers), "prices": stored, "failures": failures}
30
31	if __name__ == "__main__": print(backfill())
32

"""Backfill the deterministic daily close cache used by replay backtests."""
from __future__ import annotations
from datetime import date, timedelta
import sqlite3
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
from config import DB_PATH

def backfill(*, db_path: str = DB_PATH) -> dict:
    import yfinance as yf
    with sqlite3.connect(db_path) as conn:
        tickers = [r[0] for r in conn.execute("SELECT DISTINCT issuer_ticker FROM trades WHERE issuer_ticker != ''")]
        earliest = conn.execute("SELECT MIN(tx_date) FROM trades WHERE tx_date != ''").fetchone()[0]
        tickers = sorted(set(tickers) | {"SPY"})
        stored = failures = 0
        for ticker in tickers:
            try:
                history = yf.Ticker(ticker).history(start=earliest,
                    end=(date.today() + timedelta(days=1)).isoformat(), auto_adjust=False)
                rows = [(ticker, idx.date().isoformat(), float(row["Close"]))
                        for idx, row in history.iterrows() if row["Close"] == row["Close"]]
                conn.executemany("INSERT INTO price_history(ticker,date,close) VALUES(?,?,?) "
                                 "ON CONFLICT(ticker,date) DO UPDATE SET close=excluded.close", rows)
                stored += len(rows)
            except Exception as exc:
                failures += 1
                print(f"{ticker}: {exc}")
        return {"tickers": len(tickers), "prices": stored, "failures": failures}

if __name__ == "__main__": print(backfill())


1	# CongressWatch — Backlog
2
3	Deferred concerns and ideas we've identified but don't have a clean solution for yet,
4	or that are lower priority than current work. Review periodically.
5
6	---
7
8	## Scoring
9
10	### Committee-sector mapping is too coarse
11	**Filed:** 2026-04-22
12
13	The `_COMMITTEE_SECTOR_MAP` in `scorer.py` is hand-coded and maps committee names to broad
14	sector strings. This creates two problems:
15
16	1. **Over-broad matches**: "Armed Services" maps to both `industrials` and
17	   `information-technology`. This means nearly any tech stock bought by an Armed Services
18	   member scores 3.0 for committee relevance, even if there's no plausible defense nexus.
19	   A member on Armed Services buying NVDA (plausible — AI chips, JWCC contract) and the same
20	   member buying a generic payroll SaaS company both get committee_score = 3.
21
22	2. **No company-level specificity**: The mapping is ticker-blind. A score of 3 means
23	   "the member's committee has broad jurisdiction over this sector" — it can't distinguish
24	   between a member buying the single company their committee directly regulates vs. any
25	   random name in that sector.
26
27	**What a good solution looks like**: Per-trade committee relevance assessment, likely via LLM.
28	At research time, ask the model to score (0–3) how specifically this member's committee
29	assignments relate to *this company's* primary business. Store as `committee_score_llm`
30	alongside the current heuristic score. Use the LLM score when available, fall back to
31	heuristic otherwise.
32
33	**Why deferred**: Adds an LLM call per signal at scoring time (before the research step),
34	increasing latency and cost. Also requires careful prompt design to avoid the model being
35	too generous. Better to accumulate more signals first and validate that the heuristic
36	misfires often enough to justify the extra call.
37
38	---
39
40	## Data Quality
41
42	### Win rate is measured against current market prices, not exit prices
43	**Filed:** 2026-04-22
44
45	`tools/win_rate.py` computes win rate as "fraction of positions that are currently
46	profitable." This is a snapshot, not a realized return:
47
48	- A position could be up today but will eventually be sold at a loss.
49	- A position that was down for months and recently recovered looks like a win.
50	- Bull market conditions inflate all win rates (most of the current data is from 2025–2026).
51
52	**Better approach**: For each member's trade, fetch price at `tx_date + 30d`, `tx_date + 60d`,
53	and `tx_date + 90d` as fixed-horizon returns. Win = positive return at some fixed horizon
54	(e.g., 60 days). This requires yfinance calls per trade (expensive for 14k trades) but could
55	be done as a one-time backfill and then maintained incrementally.
56
57	**Why deferred**: Requires significant yfinance API calls (one per trade × 3 horizons = ~40k
58	requests). Rate limiting and batching need design. Also the current win rate is still useful
59	as a directional signal; the flaw mainly affects members with small trade counts.
60
61	### Signal return uses first buyer's tx_date, not each member's individual date
62	**Filed:** 2026-04-22
63
64	`return_from_tx_pct` in the signals table is computed from `first_tx_date` — the earliest
65	trade date among all buyers within the current scoring window. If member A bought in January
66	and member B bought in March, the signal's return reflects January's price. Member B's actual
67	return would be different.
68
69	**Impact**: Moderate. For cluster signals where multiple members bought at similar times, the
70	error is small. For long-running signals with buyers spread across weeks, it can be
71	misleading (especially for volatile stocks).
72
73	**Fix**: Store per-member returns in the trades table directly. Requires a `price_at_tx` column
74	on the trades table and yfinance lookups per trade. Could be done lazily (only for trades that
75	become part of a high-score signal).
76
77	---
78
79	## Features
80
81	### Sell signals
82	**Filed:** 2026-04-22
83
84	If a member who previously bought a ticker in our window later discloses a sell, that's
85	potentially an exit signal. Currently `sell_count` is tracked in the scorer but not acted on.
86
87	**Implementation sketch**: In the notify step, check if `sell_count >= member_count` for any
88	currently held signal. If so, fire a separate ntfy notification on the `congressWatchSignals`
89	topic with a different title prefix (e.g., "EXIT SIGNAL: $TICKER").
90
91	### Options call vs put distinction
92	**Filed:** 2026-04-22
93
94	The `options_bonus` (+2) applies to any options trade regardless of direction. A congressional
95	put option is a very different signal from a call — it implies a member expects the stock to
96	fall. Capitol Trades sometimes includes the direction in `tx_type_extended`. We should parse
97	this and differentiate: calls get +2, puts might warrant a separate alert rather than boosting
98	the buy signal score.
99
100	### Sector clustering across members
101	**Filed:** 2026-04-22 (from original project_plan.md)
102
103	If 5+ members across different parties buy the same *sector* in the same week — regardless of
104	individual ticker — that may signal a macro view worth flagging. Not captured by current
105	per-ticker signal scoring.
106

# CongressWatch — Backlog

Deferred concerns and ideas we've identified but don't have a clean solution for yet,
or that are lower priority than current work. Review periodically.

---

## Scoring

### Committee-sector mapping is too coarse
**Filed:** 2026-04-22

The `_COMMITTEE_SECTOR_MAP` in `scorer.py` is hand-coded and maps committee names to broad
sector strings. This creates two problems:

1. **Over-broad matches**: "Armed Services" maps to both `industrials` and
   `information-technology`. This means nearly any tech stock bought by an Armed Services
   member scores 3.0 for committee relevance, even if there's no plausible defense nexus.
   A member on Armed Services buying NVDA (plausible — AI chips, JWCC contract) and the same
   member buying a generic payroll SaaS company both get committee_score = 3.

2. **No company-level specificity**: The mapping is ticker-blind. A score of 3 means
   "the member's committee has broad jurisdiction over this sector" — it can't distinguish
   between a member buying the single company their committee directly regulates vs. any
   random name in that sector.

**What a good solution looks like**: Per-trade committee relevance assessment, likely via LLM.
At research time, ask the model to score (0–3) how specifically this member's committee
assignments relate to *this company's* primary business. Store as `committee_score_llm`
alongside the current heuristic score. Use the LLM score when available, fall back to
heuristic otherwise.

**Why deferred**: Adds an LLM call per signal at scoring time (before the research step),
increasing latency and cost. Also requires careful prompt design to avoid the model being
too generous. Better to accumulate more signals first and validate that the heuristic
misfires often enough to justify the extra call.

---

## Data Quality

### Win rate is measured against current market prices, not exit prices
**Filed:** 2026-04-22

`tools/win_rate.py` computes win rate as "fraction of positions that are currently
profitable." This is a snapshot, not a realized return:

- A position could be up today but will eventually be sold at a loss.
- A position that was down for months and recently recovered looks like a win.
- Bull market conditions inflate all win rates (most of the current data is from 2025–2026).

**Better approach**: For each member's trade, fetch price at `tx_date + 30d`, `tx_date + 60d`,
and `tx_date + 90d` as fixed-horizon returns. Win = positive return at some fixed horizon
(e.g., 60 days). This requires yfinance calls per trade (expensive for 14k trades) but could
be done as a one-time backfill and then maintained incrementally.

**Why deferred**: Requires significant yfinance API calls (one per trade × 3 horizons = ~40k
requests). Rate limiting and batching need design. Also the current win rate is still useful
as a directional signal; the flaw mainly affects members with small trade counts.

### Signal return uses first buyer's tx_date, not each member's individual date
**Filed:** 2026-04-22

`return_from_tx_pct` in the signals table is computed from `first_tx_date` — the earliest
trade date among all buyers within the current scoring window. If member A bought in January
and member B bought in March, the signal's return reflects January's price. Member B's actual
return would be different.

**Impact**: Moderate. For cluster signals where multiple members bought at similar times, the
error is small. For long-running signals with buyers spread across weeks, it can be
misleading (especially for volatile stocks).

**Fix**: Store per-member returns in the trades table directly. Requires a `price_at_tx` column
on the trades table and yfinance lookups per trade. Could be done lazily (only for trades that
become part of a high-score signal).

---

## Features

### Sell signals
**Filed:** 2026-04-22

If a member who previously bought a ticker in our window later discloses a sell, that's
potentially an exit signal. Currently `sell_count` is tracked in the scorer but not acted on.

**Implementation sketch**: In the notify step, check if `sell_count >= member_count` for any
currently held signal. If so, fire a separate ntfy notification on the `congressWatchSignals`
topic with a different title prefix (e.g., "EXIT SIGNAL: $TICKER").

### Options call vs put distinction
**Filed:** 2026-04-22

The `options_bonus` (+2) applies to any options trade regardless of direction. A congressional
put option is a very different signal from a call — it implies a member expects the stock to
fall. Capitol Trades sometimes includes the direction in `tx_type_extended`. We should parse
this and differentiate: calls get +2, puts might warrant a separate alert rather than boosting
the buy signal score.

### Sector clustering across members
**Filed:** 2026-04-22 (from original project_plan.md)

If 5+ members across different parties buy the same *sector* in the same week — regardless of
individual ticker — that may signal a macro view worth flagging. Not captured by current
per-ticker signal scoring.


Now I have a complete picture. The backlog entry for "Win rate is measured against current market prices" is the exact Phase 1 issue from the project card. The backlog acknowledges the fix is:
- For each member's trade, fetch price at `tx_date + 30d`, `tx_date + 60d`
- Win = positive return at fixed horizon (e.g., 60 days)
- Requires yfinance calls per trade (~40k requests for 14k trades × 3 horizons)
- "Could be done as a one-time backfill and then maintained incrementally"

And there's already `price_history.py` which backtracks all historical prices for all tickers! The key insight is: if `price_history` has been populated (via `python tools/price_history.py`), then ALL historical prices for all traded tickers are already in the DB.

So instead of making 40k individual yfinance calls, I can just join against the existing `price_history` table. The fix to `win_rate.py` would replace the rolling current price logic with a join that looks up the price at each trade's date and at fixed horizons like 30 or 60 days out, falling back to the current behavior if historical prices aren't available for that ticker and date.

Designing the fix now...

Writing the function and SQL query... I see there's a helper function that finds the nearest trading day for a given date, and the approach is to load all prices into a dictionary upfront rather than doing joins in SQL. Then for each buy trade, I'd look up the entry price at the transaction date and the exit price thirty days later using that helper function to handle missing trading days.

The key consideration is that this only works if the user has actually run the price history backfill script — if the database is new or sparse, there won't be enough historical data to compute these fixed-horizon returns. So my implementation needs to handle that gracefully, either skipping trades where prices aren't available or falling back to the existing signal return method.

I'll add a separate `compute_fixed_horizon` function that uses the price history table, then modify the main `compute()` method to attempt fixed-horizon calculations first and fall back to rolling returns when needed, while also tracking coverage statistics so James can see whether the backfill has been run.


Bash

ls /workspace/extra/projects/congressional-trading/tests/ && cat /workspace/extra/projects/congressional-trading/tests/test_win_rate.py 2>/dev/null | head -60

List tests and check win_rate test file


__init__.py
test_guardrails.py
test_notification_policy.py
test_outcome_tracker.py
test_researcher.py
test_timeliness.py
test_weights.py


No existing win_rate test. I'll need to create one. Let me now write the fix.

Plan:
1. Modify `tools/win_rate.py` to add a `compute_fixed_horizon` function that uses `price_history`
2. Update `compute()` to call `compute_fixed_horizon` when price history data is available, falling back to rolling returns
3. Create `tests/test_win_rate.py` with RED tests first (TDD)
4. Make the tests GREEN

Let me write this. First let me understand the test framework being used.


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()


1	import os
2	from dotenv import load_dotenv
3	load_dotenv()
4
5	def _env_float(name: str, default: float) -> float:
6	    return float(os.getenv(name, default))
7
8	def _env_int(name: str, default: int) -> int:
9	    return int(os.getenv(name, default))
10
11	# --- Database ---
12	DB_PATH = os.path.join(os.path.dirname(__file__), "data", "congress_trades.db")
13
14	# --- Ingest ---
15	LOOKBACK_DAYS = 30          # how many days of trade history to fetch on each run
16	PAGE_SIZE = 100             # max records per page from Capitol Trades
17
18	# --- Scoring ---
19	SCORE_RESEARCH_THRESHOLD = 5.0   # min score to trigger LLM research
20	SCORE_NOTIFY_THRESHOLD = 9.0     # min score to send ntfy notification
21	DEFAULT_SCORE_WEIGHTS = {
22	    "committee": 1.0, "track_record": 1.0, "cluster": 1.0,
23	    "options": 1.0, "size": 1.0, "small_cap": 1.0,
24	    "sell_pressure": 1.0, "strategic": 1.0, "disruption": 1.0,
25	}
26	BACKTEST_MIN_ALERTS_PER_MONTH = _env_float("BACKTEST_MIN_ALERTS_PER_MONTH", 2.0)
27
28	# --- Timeliness multipliers applied to score at notify-decision time only ---
29	# A "late" signal's stored score is unchanged; the multiplier only gates notifications.
30	TIMELINESS_MULTIPLIERS = {"fresh": 1.0, "watch": 0.85, "late": 0.70}
31	FRESHNESS_MAX_HOURS = _env_int("FRESHNESS_MAX_HOURS", 48)
32	WATCH_MAX_DAYS = _env_int("WATCH_MAX_DAYS", 7)
33	PRICED_IN_RUNUP_PCT = _env_float("PRICED_IN_RUNUP_PCT", 15.0)
34	MIN_AVG_DOLLAR_VOLUME = _env_float("MIN_AVG_DOLLAR_VOLUME", 2_000_000)
35	EARNINGS_BLACKOUT_DAYS = _env_int("EARNINGS_BLACKOUT_DAYS", 2)
36	RESEARCH_MIN_CITATIONS = _env_int("RESEARCH_MIN_CITATIONS", 2)
37	REQUIRE_VERIFIER_CONCUR = os.getenv("REQUIRE_VERIFIER_CONCUR", "true").lower() in ("1", "true", "yes")
38	TUNER_MIN_NEW_OUTCOMES = _env_int("TUNER_MIN_NEW_OUTCOMES", 8)
39	TUNER_MAX_STEP_PCT = _env_float("TUNER_MAX_STEP_PCT", 10.0)
40	BREAKER_MIN_WIN_RATE = _env_float("BREAKER_MIN_WIN_RATE", 0.40)

import os
from dotenv import load_dotenv
load_dotenv()

def _env_float(name: str, default: float) -> float:
    return float(os.getenv(name, default))

def _env_int(name: str, default: int) -> int:
    return int(os.getenv(name, default))

# --- Database ---
DB_PATH = os.path.join(os.path.dirname(__file__), "data", "congress_trades.db")

# --- Ingest ---
LOOKBACK_DAYS = 30          # how many days of trade history to fetch on each run
PAGE_SIZE = 100             # max records per page from Capitol Trades

# --- Scoring ---
SCORE_RESEARCH_THRESHOLD = 5.0   # min score to trigger LLM research
SCORE_NOTIFY_THRESHOLD = 9.0     # min score to send ntfy notification
DEFAULT_SCORE_WEIGHTS = {
    "committee": 1.0, "track_record": 1.0, "cluster": 1.0,
    "options": 1.0, "size": 1.0, "small_cap": 1.0,
    "sell_pressure": 1.0, "strategic": 1.0, "disruption": 1.0,
}
BACKTEST_MIN_ALERTS_PER_MONTH = _env_float("BACKTEST_MIN_ALERTS_PER_MONTH", 2.0)

# --- Timeliness multipliers applied to score at notify-decision time only ---
# A "late" signal's stored score is unchanged; the multiplier only gates notifications.
TIMELINESS_MULTIPLIERS = {"fresh": 1.0, "watch": 0.85, "late": 0.70}
FRESHNESS_MAX_HOURS = _env_int("FRESHNESS_MAX_HOURS", 48)
WATCH_MAX_DAYS = _env_int("WATCH_MAX_DAYS", 7)
PRICED_IN_RUNUP_PCT = _env_float("PRICED_IN_RUNUP_PCT", 15.0)
MIN_AVG_DOLLAR_VOLUME = _env_float("MIN_AVG_DOLLAR_VOLUME", 2_000_000)
EARNINGS_BLACKOUT_DAYS = _env_int("EARNINGS_BLACKOUT_DAYS", 2)
RESEARCH_MIN_CITATIONS = _env_int("RESEARCH_MIN_CITATIONS", 2)
REQUIRE_VERIFIER_CONCUR = os.getenv("REQUIRE_VERIFIER_CONCUR", "true").lower() in ("1", "true", "yes")
TUNER_MIN_NEW_OUTCOMES = _env_int("TUNER_MIN_NEW_OUTCOMES", 8)
TUNER_MAX_STEP_PCT = _env_float("TUNER_MAX_STEP_PCT", 10.0)
BREAKER_MIN_WIN_RATE = _env_float("BREAKER_MIN_WIN_RATE", 0.40)


Now I have a very clear picture. Let me implement the fix.

The plan:
1. Add `compute_fixed_horizon(min_trades, verbose, horizon_days=30, max_lag=7)` to `win_rate.py`
2. Modify the default `compute()` to try fixed-horizon first, then fall back to rolling
3. Write tests (RED first, then GREEN)

The key fix:
- Instead of using `signals.return_from_tx_pct` (today's price vs. member's trade date), use price_history to look up the price at tx_date+30d
- Load price_history into a dict like backtest.py does, then for each buy trade, grab the entry price at tx_date and exit price at tx_date+30d using the same `_on_or_after` helper pattern

I'll start by writing the test with a temporary SQLite database containing trades, members, signals, and price_history tables with sample data, then implement the function to make it pass.

Now I'm defining the `compute_fixed_horizon` function that calculates per-member win rates using fixed-horizon returns from price_history, falling back to the existing return_from_tx_pct for tickers not in the historical data.

Writing function implementation... Writing query logic... Calculating win rates and updating the database with the results, then sorting members by their performance and preparing a formatted output table showing their names, win rates, and trade counts. Finishing the compute function logic...

I need to make sure the return dictionary includes the `updated` and `skipped` keys that the caller expects, and my fixed-horizon implementation already provides those along with the additional `fixed_horizon_positions` count. The function will check if price history data exists and use the fixed-horizon method when available, otherwise fall back to the original rolling method.

Now I'm setting up the test suite to verify the behavior — I'll write tests for individual trade calculations (wins and losses), handling insufficient trade counts, missing price history fallback, and the compute function's preference for fixed-horizon when data is available.

I need to create a minimal test database with the required tables: `trades`, `members`, `price_history`, and `signals`. Let me check if there's already a test fixture or helper I can use.


cd /workspace/extra/projects/congressional-trading && python -m pytest tests/ -x -q 2>&1 | tail -20

Run existing tests to check baseline


/bin/bash: line 1: python: command not found


which python3 && python3 --version && python3 -m pytest tests/ -x -q 2>&1 | tail -20

Check python3 and run tests


/usr/bin/python3
Python 3.11.2
/usr/bin/python3: No module named pytest


pip3 install pytest --quiet 2>&1 | tail -5 && python3 -m pytest tests/ -x -q 2>&1 | tail -20

Install pytest and run tests


    See /usr/share/doc/python3.11/README.venv for more information.

note: If you believe this is a mistake, please contact your Python installation or OS distribution provider. You can override this, at the risk of breaking your Python installation or OS, by passing --break-system-packages.
hint: See PEP 668 for the detailed specification.
/usr/bin/python3: No module named pytest


pip3 install pytest --break-system-packages -q 2>&1 | tail -3

Install pytest with system package override


WARNING: Retrying (Retry(total=0, connect=None, read=None, redirect=None, status=None)) after connection broken by 'SSLError(SSLCertVerificationError(1, '[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: self-signed certificate in certificate chain (_ssl.c:992)'))': /simple/pytest/
ERROR: Could not find a version that satisfies the requirement pytest (from versions: none)
ERROR: No matching distribution found for pytest


pip3 install pytest --break-system-packages -q --trusted-host pypi.org --trusted-host files.pythonhosted.org 2>&1 | tail -3

Install pytest with trusted hosts for SSL bypass


  Consider adding this directory to PATH or, if you prefer to suppress this warning, use --no-warn-script-location.
  WARNING: The scripts py.test and pytest are installed in '/home/node/.local/bin' which is not on PATH.
  Consider adding this directory to PATH or, if you prefer to suppress this warning, use --no-warn-script-location.


PATH="$PATH:/home/node/.local/bin" python3 -m pytest tests/ -x -q 2>&1 | tail -20

Run existing test suite with pytest installed


==================================== ERRORS ====================================
__________________ ERROR collecting tests/test_guardrails.py ___________________
ImportError while importing test module '/workspace/extra/projects/congressional-trading/tests/test_guardrails.py'.
Hint: make sure your test modules/packages have valid Python names.
Traceback:
/usr/lib/python3.11/importlib/__init__.py:126: in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
tests/test_guardrails.py:2: in <module>
    from analysis.guardrails import breaker_metrics
analysis/guardrails.py:5: in <module>
    from config import DB_PATH, BREAKER_MIN_WIN_RATE
config.py:2: in <module>
    from dotenv import load_dotenv
E   ModuleNotFoundError: No module named 'dotenv'
=========================== short test summary info ============================
ERROR tests/test_guardrails.py
!!!!!!!!!!!!!!!!!!!!!!!!!! stopping after 1 failures !!!!!!!!!!!!!!!!!!!!!!!!!!!
1 error in 0.02s


pip3 install python-dotenv --break-system-packages -q --trusted-host pypi.org --trusted-host files.pythonhosted.org 2>&1 | tail -2 && PATH="$PATH:/home/node/.local/bin" python3 -m pytest tests/ -x -q 2>&1 | tail -20

Install dotenv and rerun tests


  WARNING: The script dotenv is installed in '/home/node/.local/bin' which is not on PATH.
  Consider adding this directory to PATH or, if you prefer to suppress this warning, use --no-warn-script-location.
...................                                                      [100%]
19 passed in 0.05s


All 19 existing tests pass. Now let me write the RED tests for the win_rate fix, then implement the fix.

Test plan:
1. `test_fixed_horizon_counts_win_when_exit_above_entry` — basic win case
2. `test_fixed_horizon_counts_loss_when_exit_below_entry` — basic loss case
3. `test_fixed_horizon_skips_member_below_min_trades` — min_trades threshold
4. `test_fixed_horizon_handles_lag_when_no_price_on_exact_date` — uses price max_lag days later
5. `test_fixed_horizon_skips_trade_without_price_history` — no price data → skip
6. `test_compute_uses_fixed_horizon_when_price_history_present` — compute() dispatch
7. `test_compute_falls_back_to_rolling_when_no_price_history` — fallback


Now I have a full picture of the codebase. Let me write the RED test first, then implement the fix.


Write

"""
Tests for the fixed-horizon win-rate calculation in tools/win_rate.py.

Phase 1 fix: member win rates should use price at tx_date+30d (fixed horizon)
rather than the current market price snapshot (rolling). This prevents bull-market
inflation and lets the same historical trade be consistently classified.
"""
import os
import sqlite3
import tempfile
import unittest
from datetime import date, timedelta

from tools.win_rate import compute, compute_fixed_horizon

def _make_db(path: str, price_rows: list[tuple] = (), with_signals: bool = True) -> None:
    """Create a minimal schema with one member and two buy trades."""
    conn = sqlite3.connect(path)
    conn.executescript("""
        CREATE TABLE members (
            bioguide_id TEXT PRIMARY KEY,
            first_name TEXT, last_name TEXT,
            win_rate REAL, updated_at TEXT
        );
        CREATE TABLE trades (
            tx_id INTEGER PRIMARY KEY,
            member_id TEXT, issuer_ticker TEXT,
            tx_date TEXT, pub_date TEXT, tx_type TEXT,
            amount_tier INTEGER DEFAULT 1
        );
        CREATE TABLE signals (
            ticker TEXT PRIMARY KEY,
            return_from_tx_pct REAL
        );
        CREATE TABLE price_history (
            ticker TEXT NOT NULL, date TEXT NOT NULL, close REAL NOT NULL,
            PRIMARY KEY (ticker, date)
        );
    """)
    conn.execute("INSERT INTO members VALUES ('M1','Alice','Smith',NULL,NULL)")
    # Two buy trades: AAPL and MSFT
    conn.execute("INSERT INTO trades VALUES (1,'M1','AAPL','2025-01-01','2025-01-05','buy',3)")
    conn.execute("INSERT INTO trades VALUES (2,'M1','MSFT','2025-01-01','2025-01-05','buy',3)")
    if with_signals:
        conn.execute("INSERT INTO signals VALUES ('AAPL', 15.0)")   # rolling: win
        conn.execute("INSERT INTO signals VALUES ('MSFT', -5.0)")   # rolling: loss
    conn.executemany(
        "INSERT INTO price_history(ticker,date,close) VALUES(?,?,?)",
        price_rows
    )
    conn.commit()
    conn.close()

class FixedHorizonTests(unittest.TestCase):

    def test_win_when_exit_price_above_entry(self):
        """Basic win: price up 10% at 30d → member has 1 win."""
        with tempfile.TemporaryDirectory() as tmp:
            path = os.path.join(tmp, "db.sqlite")
            _make_db(path, price_rows=[
                ("AAPL", "2025-01-01", 100.0),
                ("AAPL", "2025-01-31", 110.0),  # tx_date + 30d
                ("MSFT", "2025-01-01", 200.0),
                ("MSFT", "2025-01-31", 190.0),  # loss
            ])
            result = compute_fixed_horizon(min_trades=1, db_path=path)
            self.assertEqual(result["updated"], 1)
            wr = sqlite3.connect(path).execute("SELECT win_rate FROM members WHERE bioguide_id='M1'").fetchone()[0]
            self.assertAlmostEqual(wr, 0.5)  # 1 win out of 2 positions

    def test_loss_when_exit_price_below_entry(self):
        """Both positions down → win_rate = 0."""
        with tempfile.TemporaryDirectory() as tmp:
            path = os.path.join(tmp, "db.sqlite")
            _make_db(path, price_rows=[
                ("AAPL", "2025-01-01", 100.0),
                ("AAPL", "2025-01-31", 90.0),
                ("MSFT", "2025-01-01", 200.0),
                ("MSFT", "2025-01-31", 180.0),
            ])
            result = compute_fixed_horizon(min_trades=1, db_path=path)
            wr = sqlite3.connect(path).execute("SELECT win_rate FROM members WHERE bioguide_id='M1'").fetchone()[0]
            self.assertAlmostEqual(wr, 0.0)

    def test_skips_member_below_min_trades(self):
        """Member with only 2 positions skipped when min_trades=3."""
        with tempfile.TemporaryDirectory() as tmp:
            path = os.path.join(tmp, "db.sqlite")
            _make_db(path, price_rows=[
                ("AAPL", "2025-01-01", 100.0), ("AAPL", "2025-01-31", 110.0),
                ("MSFT", "2025-01-01", 200.0), ("MSFT", "2025-01-31", 220.0),
            ])
            result = compute_fixed_horizon(min_trades=3, db_path=path)
            self.assertEqual(result["updated"], 0)
            self.assertEqual(result["skipped"], 1)

    def test_uses_lag_when_no_price_on_exact_date(self):
        """Price not available on exact exit date; should use price within max_lag days."""
        with tempfile.TemporaryDirectory() as tmp:
            path = os.path.join(tmp, "db.sqlite")
            # Exit date (Jan 31) has no price; Jan 33 = Feb 2 has price (within 7d lag)
            _make_db(path, price_rows=[
                ("AAPL", "2025-01-01", 100.0),
                ("AAPL", "2025-02-02", 115.0),  # +2 days lag from Jan 31
                ("MSFT", "2025-01-01", 200.0),
                ("MSFT", "2025-02-02", 210.0),
            ])
            result = compute_fixed_horizon(min_trades=1, db_path=path)
            self.assertEqual(result["updated"], 1)
            wr = sqlite3.connect(path).execute("SELECT win_rate FROM members WHERE bioguide_id='M1'").fetchone()[0]
            self.assertAlmostEqual(wr, 1.0)  # both up

    def test_skips_trade_with_no_price_history(self):
        """Ticker not in price_history at all → position skipped, not counted."""
        with tempfile.TemporaryDirectory() as tmp:
            path = os.path.join(tmp, "db.sqlite")
            # Only AAPL prices; MSFT has none → only 1 measurable position
            _make_db(path, price_rows=[
                ("AAPL", "2025-01-01", 100.0),
                ("AAPL", "2025-01-31", 110.0),
            ])
            result = compute_fixed_horizon(min_trades=1, db_path=path)
            self.assertEqual(result["updated"], 1)
            self.assertEqual(result["fixed_horizon_positions"], 1)
            wr = sqlite3.connect(path).execute("SELECT win_rate FROM members WHERE bioguide_id='M1'").fetchone()[0]
            self.assertAlmostEqual(wr, 1.0)  # only AAPL counted; it's a win

    def test_skips_member_when_all_tickers_lack_price_history(self):
        """No price history at all → no members updated."""
        with tempfile.TemporaryDirectory() as tmp:
            path = os.path.join(tmp, "db.sqlite")
            _make_db(path, price_rows=[])
            result = compute_fixed_horizon(min_trades=1, db_path=path)
            self.assertEqual(result["updated"], 0)

class ComputeDispatchTests(unittest.TestCase):

    def test_compute_uses_fixed_horizon_when_price_history_present(self):
        """compute() should use fixed-horizon method when price_history has data."""
        with tempfile.TemporaryDirectory() as tmp:
            path = os.path.join(tmp, "db.sqlite")
            # Rolling return says AAPL is a win (+15%), MSFT a loss (-5%).
            # Fixed horizon says both are losses (prices drop over 30d).
            _make_db(path, price_rows=[
                ("AAPL", "2025-01-01", 100.0),
                ("AAPL", "2025-01-31", 90.0),   # loss at horizon
                ("MSFT", "2025-01-01", 200.0),
                ("MSFT", "2025-01-31", 185.0),  # loss at horizon
            ])
            compute(min_trades=1, db_path=path)
            wr = sqlite3.connect(path).execute("SELECT win_rate FROM members WHERE bioguide_id='M1'").fetchone()[0]
            # Fixed horizon: both down → 0.0. If rolling had been used, AAPL win = 0.5.
            self.assertAlmostEqual(wr, 0.0)

    def test_compute_falls_back_to_rolling_when_no_price_history(self):
        """compute() should fall back to rolling method when price_history is empty."""
        with tempfile.TemporaryDirectory() as tmp:
            path = os.path.join(tmp, "db.sqlite")
            # Rolling: AAPL +15% (win), MSFT -5% (loss) → 0.5
            _make_db(path, price_rows=[])  # no price history
            result = compute(min_trades=1, db_path=path)
            self.assertEqual(result["updated"], 1)
            wr = sqlite3.connect(path).execute("SELECT win_rate FROM members WHERE bioguide_id='M1'").fetchone()[0]
            self.assertAlmostEqual(wr, 0.5)  # rolling: AAPL win, MSFT loss

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


File created successfully at: /workspace/extra/projects/congressional-trading/tests/test_win_rate.py (file state is current in your context — no need to Read it back)

"""
Tests for the fixed-horizon win-rate calculation in tools/win_rate.py.

Phase 1 fix: member win rates should use price at tx_date+30d (fixed horizon)
rather than the current market price snapshot (rolling). This prevents bull-market
inflation and lets the same historical trade be consistently classified.
"""
import os
import sqlite3
import tempfile
import unittest
from datetime import date, timedelta

from tools.win_rate import compute, compute_fixed_horizon

def _make_db(path: str, price_rows: list[tuple] = (), with_signals: bool = True) -> None:
    """Create a minimal schema with one member and two buy trades."""
    conn = sqlite3.connect(path)
    conn.executescript("""
        CREATE TABLE members (
            bioguide_id TEXT PRIMARY KEY,
            first_name TEXT, last_name TEXT,
            win_rate REAL, updated_at TEXT
        );
        CREATE TABLE trades (
            tx_id INTEGER PRIMARY KEY,
            member_id TEXT, issuer_ticker TEXT,
            tx_date TEXT, pub_date TEXT, tx_type TEXT,
            amount_tier INTEGER DEFAULT 1
        );
        CREATE TABLE signals (
            ticker TEXT PRIMARY KEY,
            return_from_tx_pct REAL
        );
        CREATE TABLE price_history (
            ticker TEXT NOT NULL, date TEXT NOT NULL, close REAL NOT NULL,
            PRIMARY KEY (ticker, date)
        );
    """)
    conn.execute("INSERT INTO members VALUES ('M1','Alice','Smith',NULL,NULL)")
    # Two buy trades: AAPL and MSFT
    conn.execute("INSERT INTO trades VALUES (1,'M1','AAPL','2025-01-01','2025-01-05','buy',3)")
    conn.execute("INSERT INTO trades VALUES (2,'M1','MSFT','2025-01-01','2025-01-05','buy',3)")
    if with_signals:
        conn.execute("INSERT INTO signals VALUES ('AAPL', 15.0)")   # rolling: win
        conn.execute("INSERT INTO signals VALUES ('MSFT', -5.0)")   # rolling: loss
    conn.executemany(
        "INSERT INTO price_history(ticker,date,close) VALUES(?,?,?)",
        price_rows
    )
    conn.commit()
    conn.close()

class FixedHorizonTests(unittest.TestCase):

    def test_win_when_exit_price_above_entry(self):
        """Basic win: price up 10% at 30d → member has 1 win."""
        with tempfile.TemporaryDirectory() as tmp:
            path = os.path.join(tmp, "db.sqlite")
            _make_db(path, price_rows=[
                ("AAPL", "2025-01-01", 100.0),
                ("AAPL", "2025-01-31", 110.0),  # tx_date + 30d
                ("MSFT", "2025-01-01", 200.0),
                ("MSFT", "2025-01-31", 190.0),  # loss
            ])
            result = compute_fixed_horizon(min_trades=1, db_path=path)
            self.assertEqual(result["updated"], 1)
            wr = sqlite3.connect(path).execute("SELECT win_rate FROM members WHERE bioguide_id='M1'").fetchone()[0]
            self.assertAlmostEqual(wr, 0.5)  # 1 win out of 2 positions

    def test_loss_when_exit_price_below_entry(self):
        """Both positions down → win_rate = 0."""
        with tempfile.TemporaryDirectory() as tmp:
            path = os.path.join(tmp, "db.sqlite")
            _make_db(path, price_rows=[
                ("AAPL", "2025-01-01", 100.0),
                ("AAPL", "2025-01-31", 90.0),
                ("MSFT", "2025-01-01", 200.0),
                ("MSFT", "2025-01-31", 180.0),
            ])
            result = compute_fixed_horizon(min_trades=1, db_path=path)
            wr = sqlite3.connect(path).execute("SELECT win_rate FROM members WHERE bioguide_id='M1'").fetchone()[0]
            self.assertAlmostEqual(wr, 0.0)

    def test_skips_member_below_min_trades(self):
        """Member with only 2 positions skipped when min_trades=3."""
        with tempfile.TemporaryDirectory() as tmp:
            path = os.path.join(tmp, "db.sqlite")
            _make_db(path, price_rows=[
                ("AAPL", "2025-01-01", 100.0), ("AAPL", "2025-01-31", 110.0),
                ("MSFT", "2025-01-01", 200.0), ("MSFT", "2025-01-31", 220.0),
            ])
            result = compute_fixed_horizon(min_trades=3, db_path=path)
            self.assertEqual(result["updated"], 0)
            self.assertEqual(result["skipped"], 1)

    def test_uses_lag_when_no_price_on_exact_date(self):
        """Price not available on exact exit date; should use price within max_lag days."""
        with tempfile.TemporaryDirectory() as tmp:
            path = os.path.join(tmp, "db.sqlite")
            # Exit date (Jan 31) has no price; Jan 33 = Feb 2 has price (within 7d lag)
            _make_db(path, price_rows=[
                ("AAPL", "2025-01-01", 100.0),
                ("AAPL", "2025-02-02", 115.0),  # +2 days lag from Jan 31
                ("MSFT", "2025-01-01", 200.0),
                ("MSFT", "2025-02-02", 210.0),
            ])
            result = compute_fixed_horizon(min_trades=1, db_path=path)
            self.assertEqual(result["updated"], 1)
            wr = sqlite3.connect(path).execute("SELECT win_rate FROM members WHERE bioguide_id='M1'").fetchone()[0]
            self.assertAlmostEqual(wr, 1.0)  # both up

    def test_skips_trade_with_no_price_history(self):
        """Ticker not in price_history at all → position skipped, not counted."""
        with tempfile.TemporaryDirectory() as tmp:
            path = os.path.join(tmp, "db.sqlite")
            # Only AAPL prices; MSFT has none → only 1 measurable position
            _make_db(path, price_rows=[
                ("AAPL", "2025-01-01", 100.0),
                ("AAPL", "2025-01-31", 110.0),
            ])
            result = compute_fixed_horizon(min_trades=1, db_path=path)
            self.assertEqual(result["updated"], 1)
            self.assertEqual(result["fixed_horizon_positions"], 1)
            wr = sqlite3.connect(path).execute("SELECT win_rate FROM members WHERE bioguide_id='M1'").fetchone()[0]
            self.assertAlmostEqual(wr, 1.0)  # only AAPL counted; it's a win

    def test_skips_member_when_all_tickers_lack_price_history(self):
        """No price history at all → no members updated."""
        with tempfile.TemporaryDirectory() as tmp:
            path = os.path.join(tmp, "db.sqlite")
            _make_db(path, price_rows=[])
            result = compute_fixed_horizon(min_trades=1, db_path=path)
            self.assertEqual(result["updated"], 0)

class ComputeDispatchTests(unittest.TestCase):

    def test_compute_uses_fixed_horizon_when_price_history_present(self):
        """compute() should use fixed-horizon method when price_history has data."""
        with tempfile.TemporaryDirectory() as tmp:
            path = os.path.join(tmp, "db.sqlite")
            # Rolling return says AAPL is a win (+15%), MSFT a loss (-5%).
            # Fixed horizon says both are losses (prices drop over 30d).
            _make_db(path, price_rows=[
                ("AAPL", "2025-01-01", 100.0),
                ("AAPL", "2025-01-31", 90.0),   # loss at horizon
                ("MSFT", "2025-01-01", 200.0),
                ("MSFT", "2025-01-31", 185.0),  # loss at horizon
            ])
            compute(min_trades=1, db_path=path)
            wr = sqlite3.connect(path).execute("SELECT win_rate FROM members WHERE bioguide_id='M1'").fetchone()[0]
            # Fixed horizon: both down → 0.0. If rolling had been used, AAPL win = 0.5.
            self.assertAlmostEqual(wr, 0.0)

    def test_compute_falls_back_to_rolling_when_no_price_history(self):
        """compute() should fall back to rolling method when price_history is empty."""
        with tempfile.TemporaryDirectory() as tmp:
            path = os.path.join(tmp, "db.sqlite")
            # Rolling: AAPL +15% (win), MSFT -5% (loss) → 0.5
            _make_db(path, price_rows=[])  # no price history
            result = compute(min_trades=1, db_path=path)
            self.assertEqual(result["updated"], 1)
            wr = sqlite3.connect(path).execute("SELECT win_rate FROM members WHERE bioguide_id='M1'").fetchone()[0]
            self.assertAlmostEqual(wr, 0.5)  # rolling: AAPL win, MSFT loss

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


Bash

PATH="$PATH:/home/node/.local/bin" python3 -m pytest tests/test_win_rate.py -v 2>&1 | tail -30

Run new RED tests


============================= 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
collecting ... collected 0 items / 1 error

==================================== ERRORS ====================================
___________________ ERROR collecting tests/test_win_rate.py ____________________
ImportError while importing test module '/workspace/extra/projects/congressional-trading/tests/test_win_rate.py'.
Hint: make sure your test modules/packages have valid Python names.
Traceback:
/usr/lib/python3.11/importlib/__init__.py:126: in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
tests/test_win_rate.py:14: in <module>
    from tools.win_rate import compute, compute_fixed_horizon
E   ImportError: cannot import name 'compute_fixed_horizon' from 'tools.win_rate' (/workspace/extra/projects/congressional-trading/tools/win_rate.py)
=========================== short test summary info ============================
ERROR tests/test_win_rate.py
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
=============================== 1 error in 0.05s ===============================


Good — RED (as expected, `compute_fixed_horizon` doesn't exist yet). Now I'll implement the fix. I'll modify `win_rate.py` to add `compute_fixed_horizon` and update `compute()` to dispatch to it when price history is available.


RED confirmed. Now implementing the fix: