1	"""
2	Signal scoring engine.
3
4	Groups trades by ticker within a lookback window and produces a score
5	for each ticker reflecting the aggregate congressional activity.
6
7	Score components (max ~13):
8	  committee_score    0–3   member's committee overlaps with company sector (Day 5)
9	  track_record_score 0–2   historical win rate (defaults to 1.0 until calculable)
10	  cluster_score      0–3   unique member buyers (1=0pts, 2=1pt, 3=2pts, 4+=3pts)
11	  options_bonus      0–2   any option trade on this ticker (+2)
12	  size_score         0–5   highest dollar tier among buy trades
13
14	Notification threshold (SCORE_NOTIFY_THRESHOLD in config.py): default 7
15	"""
16
17	from __future__ import annotations
18	import sqlite3
19	import sys
20	import os
21	from collections import defaultdict
22	from datetime import datetime, timedelta
23
24	sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
25	from config import DB_PATH, LOOKBACK_DAYS, SCORE_NOTIFY_THRESHOLD
26
27
28	# Committee → sector mapping used for committee_score.
29	# Updated when committees.py runs (Day 5); used here for scoring.
30	# Key: normalized committee name fragment, Value: list of matching sectors
31	_COMMITTEE_SECTOR_MAP = {
32	    "armed services":       ["industrials", "information-technology"],
33	    "defense":              ["industrials", "information-technology"],
34	    "intelligence":         ["information-technology", "industrials"],
35	    "energy":               ["energy", "utilities", "materials"],
36	    "natural resources":    ["energy", "materials"],
37	    "science":              ["information-technology"],
38	    "technology":           ["information-technology", "communication-services"],
39	    "health":               ["health-care"],
40	    "financial services":   ["financials"],
41	    "banking":              ["financials"],
42	    "finance":              ["financials"],
43	    "agriculture":          ["consumer-staples", "materials"],
44	    "transportation":       ["industrials"],
45	    "commerce":             ["consumer-discretionary", "communication-services"],
46	    "judiciary":            [],
47	    "foreign affairs":      [],
48	    "ways and means":       ["financials"],
49	}
50
51
52	def _committee_score(committees_json: str | None, sector: str) -> float:
53	    """
54	    Return 0–3 based on overlap between member's committees and company sector.
55	    0 = no data or no overlap
56	    1 = indirect overlap (committee has broad relevance)
57	    2 = moderate overlap
58	    3 = direct jurisdiction
59	    """
60	    if not committees_json or not sector:
61	        return 0.0
62	    import json
63	    try:
64	        committees = json.loads(committees_json)
65	    except (json.JSONDecodeError, TypeError):
66	        return 0.0
67
68	    sector = sector.lower()
69	    best = 0.0
70	    for committee in committees:
71	        name = committee.lower() if isinstance(committee, str) else (committee.get("name", "") or "").lower()
72	        for key, sectors in _COMMITTEE_SECTOR_MAP.items():
73	            if key in name:
74	                if sector in sectors:
75	                    best = max(best, 3.0)
76	                elif any(s in sector for s in ["tech", "info", "comm"]) and key in ("science", "technology", "commerce"):
77	                    best = max(best, 2.0)
78	                else:
79	                    best = max(best, 1.0)
80	    return best
81
82
83	def _cluster_score(unique_buyers: int) -> float:
84	    if unique_buyers >= 4:
85	        return 3.0
86	    if unique_buyers == 3:
87	        return 2.0
88	    if unique_buyers == 2:
89	        return 1.0
90	    return 0.0
91
92
93	def _size_score(max_tier: int) -> float:
94	    """tier is already 1–6 from ingest; cap contribution at 5."""
95	    return min(5.0, float(max(0, max_tier)))
96
97
98	def _sell_pressure(unique_sellers: int, unique_buyers: int) -> float:
99	    """
100	    Penalty when congressional members are selling a ticker they or others are buying.
101	    The ratio of sellers to buyers determines severity:
102
103	    sellers == 0:                0.0  — clean signal
104	    ratio < 0.40 (e.g. 1 of 3): -0.5 — minor noise, buyers dominate
105	    ratio < 0.67 (e.g. 1 of 2): -1.5 — worth noting
106	    ratio < 1.0  (e.g. 2 of 3): -2.5 — mixed conviction
107	    ratio >= 1.0 (sellers ≥ buyers): -4.0 — sell pressure outweighs buys
108	    """
109	    if unique_sellers == 0:
110	        return 0.0
111	    ratio = unique_sellers / max(unique_buyers, 1)
112	    if ratio >= 1.0:
113	        return -4.0
114	    if ratio >= 0.67:
115	        return -2.5
116	    if ratio >= 0.40:
117	        return -1.5
118	    return -0.5
119
120
121	def _small_cap_bonus(market_cap: float | None) -> float:
122	    """
123	    Score bonus/penalty based on market cap.
124
125	    The thesis: congressional informed buying in a smaller, less-covered company
126	    has higher percentage-move potential than the same signal in a mega-cap where
127	    hundreds of analysts are already pricing in every catalyst. Tiers:
128
129	    Mega  (>$100B):  -2.5  — saturated coverage; edge is minimal
130	    Large ($10-100B): -0.5  — well-covered; slight penalty
131	    Mid   ($2-10B):  +2.0  — meaningful analyst coverage gap
132	    Small ($500M-$2B): +4.5 — thin coverage, catalysts move the stock
133	    Micro (<$500M):  +6.0  — very thin float; single catalyst → 30-50% moves
134	    Unknown:          0.0  — neutral until price_fetcher populates market_cap
135	    """
136	    if market_cap is None:
137	        return 0.0
138	    if market_cap > 100e9:
139	        return -2.5
140	    if market_cap > 10e9:
141	        return -0.5
142	    if market_cap > 2e9:
143	        return 2.0
144	    if market_cap > 500e6:
145	        return 4.5
146	    return 6.0
147
148
149	def _breadth_mult(hist_buyers: int) -> float:
150	    """
151	    Discount factor applied to cluster_score based on how many unique members
152	    have historically bought a ticker. Mega-caps routinely bought by 15+ members
153	    are portfolio staples, not informed signals — their cluster score overstates edge.
154	    """
155	    if hist_buyers >= 20:
156	        return 0.40   # basically an index holding (MSFT, GOOGL, NVDA tier)
157	    if hist_buyers >= 12:
158	        return 0.65   # widely held (HD, BRK/B, GS tier)
159	    if hist_buyers >= 6:
160	        return 0.85   # moderately popular
161	    return 1.0        # rare buy — full cluster score
162
163
164	def score_tickers(lookback_days: int = LOOKBACK_DAYS) -> list[dict]:
165	    """
166	    Score all tickers with at least one BUY trade published in the last
167	    lookback_days. Returns list of signal dicts sorted by total_score desc.
168	    Also upserts results into the signals table.
169	    """
170	    conn = sqlite3.connect(DB_PATH)
171	    conn.row_factory = sqlite3.Row
172
173	    cutoff = (datetime.now() - timedelta(days=lookback_days)).strftime("%Y-%m-%d")
174
175	    # Cached market caps from previous price_fetcher run (neutral default if missing)
176	    cap_rows = conn.execute("SELECT ticker, market_cap FROM signals WHERE market_cap IS NOT NULL").fetchall()
177	    market_caps: dict[str, float] = {r["ticker"]: r["market_cap"] for r in cap_rows}
178
179	    # LLM-derived scores from research_cache — default 0 until research has run
180	    rc_rows = conn.execute(
181	        "SELECT ticker, COALESCE(strategic_score,0) AS ss, COALESCE(disruption_score,0) AS ds FROM research_cache"
182	    ).fetchall()
183	    llm_scores: dict[str, tuple[float, float]] = {r["ticker"]: (r["ss"], r["ds"]) for r in rc_rows}
184
185	    # Historical buyer breadth — full DB, not windowed — used for breadth discount
186	    breadth_rows = conn.execute(
187	        """
188	        SELECT issuer_ticker, COUNT(DISTINCT member_id) AS cnt
189	        FROM trades
190	        WHERE tx_type = 'buy' AND issuer_ticker != ''
191	        GROUP BY issuer_ticker
192	        """
193	    ).fetchall()
194	    historical_buyers: dict[str, int] = {r["issuer_ticker"]: r["cnt"] for r in breadth_rows}
195
196	    rows = conn.execute(
197	        """
198	        SELECT
199	            t.tx_id,
200	            t.issuer_ticker,
201	            t.issuer_name,
202	            t.issuer_sector,
203	            t.member_id,
204	            t.tx_type,
205	            t.amount_tier,
206	            t.is_option,
207	            t.pub_date,
208	            t.tx_date,
209	            t.value_estimate,
210	            m.party,
211	            m.chamber,
212	            m.first_name || ' ' || m.last_name AS member_name,
213	            m.committees,
214	            COALESCE(m.win_rate, -1) AS win_rate
215	        FROM trades t
216	        JOIN members m ON m.bioguide_id = t.member_id
217	        WHERE t.pub_date >= ?
218	          AND t.issuer_ticker != ''
219	        ORDER BY t.issuer_ticker, t.pub_date DESC
220	        """,
221	        (cutoff,),
222	    ).fetchall()
223
224	    # Group by ticker
225	    by_ticker: dict[str, list] = defaultdict(list)
226	    for row in rows:
227	        by_ticker[row["issuer_ticker"]].append(dict(row))
228
229	    signals = []
230
231	    for ticker, ticker_rows in by_ticker.items():
232	        buy_rows = [r for r in ticker_rows if r["tx_type"] == "buy"]
233	        sell_rows = [r for r in ticker_rows if r["tx_type"] == "sell"]
234	        option_rows = [r for r in ticker_rows if r["is_option"]]
235	        sell_ids = set(r["member_id"] for r in sell_rows)
236
237	        if not buy_rows:
238	            continue  # only score tickers with at least one buy
239
240	        # --- Cluster score: unique member buyers, discounted for mega-caps ---
241	        buyer_ids = set(r["member_id"] for r in buy_rows)
242	        unique_buyers = len(buyer_ids)
243	        hist = historical_buyers.get(ticker, 0)
244	        cluster = round(_cluster_score(unique_buyers) * _breadth_mult(hist), 2)
245
246	        # --- Size score: max tier among buys ---
247	        max_tier = max((r["amount_tier"] or 0) for r in buy_rows)
248	        size = _size_score(max_tier)
249
250	        # --- Options bonus ---
251	        opt_bonus = 2.0 if option_rows else 0.0
252
253	        # --- Track record score (default 1 until win_rate data available) ---
254	        win_rates = [r["win_rate"] for r in buy_rows if r["win_rate"] >= 0]
255	        if win_rates:
256	            avg_wr = sum(win_rates) / len(win_rates)
257	            track = 2.0 if avg_wr > 0.6 else (1.0 if avg_wr >= 0.5 else 0.5)
258	        else:
259	            track = 1.0  # default unknown
260
261	        # --- Committee score: best score across all buying members ---
262	        sector = (buy_rows[0].get("issuer_sector") or "").lower()
263	        comm_score = max(
264	            _committee_score(r.get("committees"), sector) for r in buy_rows
265	        )
266
267	        mc_bonus = _small_cap_bonus(market_caps.get(ticker))
268	        sell_penalty = _sell_pressure(len(sell_ids), unique_buyers)
269	        strategic, disruption = llm_scores.get(ticker, (0.0, 0.0))
270	        total = comm_score + track + cluster + opt_bonus + size + mc_bonus + sell_penalty + strategic + disruption
271
272	        pub_dates = [r["pub_date"] for r in ticker_rows]
273	        tx_dates  = [r["tx_date"] for r in buy_rows if r["tx_date"]]
274
275	        # Collect buyer details for display
276	        buyers_detail = []
277	        seen_buyers = set()
278	        for r in buy_rows:
279	            if r["member_id"] not in seen_buyers:
280	                seen_buyers.add(r["member_id"])
281	                buyers_detail.append({
282	                    "name": r["member_name"],
283	                    "party": r["party"],
284	                    "chamber": r["chamber"],
285	                    "tier": r["amount_tier"],
286	                })
287
288	        sig = {
289	            "ticker": ticker,
290	            "issuer_name": buy_rows[0]["issuer_name"],
291	            "sector": sector,
292	            "first_seen": min(pub_dates),
293	            "last_seen": max(pub_dates),
294	            "first_tx_date": min(tx_dates) if tx_dates else "",
295	            "last_tx_date": max(tx_dates) if tx_dates else "",
296	            "member_count": unique_buyers,
297	            "sell_count": len(sell_ids),
298	            "sell_pressure_score": sell_penalty,
299	            "has_options": bool(option_rows),
300	            "max_tier": max_tier,
301	            "committee_score": comm_score,
302	            "track_record_score": track,
303	            "cluster_score": cluster,
304	            "options_bonus": opt_bonus,
305	            "size_score": size,
306	            "small_cap_score": mc_bonus,
307	            "strategic_score": strategic,
308	            "disruption_score": disruption,
309	            "total_score": round(total, 2),
310	            "buyers": buyers_detail,
311	        }
312	        signals.append(sig)
313
314	    signals.sort(key=lambda x: x["total_score"], reverse=True)
315
316	    # Upsert into signals table
317	    now = datetime.now().isoformat(timespec="seconds")
318	    for sig in signals:
319	        conn.execute(
320	            """
321	            INSERT INTO signals (
322	                ticker, issuer_name, first_seen, last_seen, first_tx_date, member_count,
323	                committee_score, track_record_score, cluster_score,
324	                options_bonus, size_score, small_cap_score,
325	                sell_pressure_score, strategic_score, disruption_score, total_score, updated_at
326	            ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
327	            ON CONFLICT(ticker) DO UPDATE SET
328	                issuer_name         = excluded.issuer_name,
329	                last_seen           = excluded.last_seen,
330	                first_tx_date       = excluded.first_tx_date,
331	                member_count        = excluded.member_count,
332	                committee_score     = excluded.committee_score,
333	                track_record_score  = excluded.track_record_score,
334	                cluster_score       = excluded.cluster_score,
335	                options_bonus       = excluded.options_bonus,
336	                size_score          = excluded.size_score,
337	                small_cap_score     = excluded.small_cap_score,
338	                sell_pressure_score = excluded.sell_pressure_score,
339	                strategic_score     = excluded.strategic_score,
340	                disruption_score    = excluded.disruption_score,
341	                total_score         = excluded.total_score,
342	                updated_at          = excluded.updated_at
343	            """,
344	            (
345	                sig["ticker"],
346	                sig["issuer_name"],
347	                sig["first_seen"],
348	                sig["last_seen"],
349	                sig["first_tx_date"] or None,
350	                sig["member_count"],
351	                sig["committee_score"],
352	                sig["track_record_score"],
353	                sig["cluster_score"],
354	                sig["options_bonus"],
355	                sig["size_score"],
356	                sig["small_cap_score"],
357	                sig["sell_pressure_score"],
358	                sig["strategic_score"],
359	                sig["disruption_score"],
360	                sig["total_score"],
361	                now,
362	            ),
363	        )
364
365	    conn.commit()
366	    conn.close()
367	    return signals
368
369
370	def top_signals(n: int = 10, min_score: float = 0.0) -> list[dict]:
371	    """Return top N signals from the signals table, optionally filtered by min_score."""
372	    conn = sqlite3.connect(DB_PATH)
373	    conn.row_factory = sqlite3.Row
374	    rows = conn.execute(
375	        """
376	        SELECT * FROM signals
377	        WHERE total_score >= ?
378	        ORDER BY total_score DESC
379	        LIMIT ?
380	        """,
381	        (min_score, n),
382	    ).fetchall()
383	    conn.close()
384	    return [dict(r) for r in rows]
385
386
387	if __name__ == "__main__":
388	    print("Scoring all tickers...\n")
389	    signals = score_tickers()
390
391	    print(f"{'Rank':<5} {'Ticker':<8} {'Score':>6}  {'Clust':>5} {'Size':>4} {'Opts':>4} {'Trck':>4} {'Cmte':>4} {'Cap':>4}  "
392	          f"{'Buyers':>6} {'Sells':>5}  {'Sector'}")
393	    print("-" * 100)
394	    for i, s in enumerate(signals[:20], 1):
395	        opt_flag = "OPT" if s["has_options"] else ""
396	        buyers_str = " / ".join(
397	            f"{b['name'].split()[-1]}({'R' if b['party'] in ('republican', 'R') else 'D'})"
398	            for b in s["buyers"][:3]
399	        )
400	        if len(s["buyers"]) > 3:
401	            buyers_str += f" +{len(s['buyers'])-3}"
402	        sc = s.get("small_cap_score", 0) or 0
403	        print(
404	            f"{i:<5} {s['ticker']:<8} {s['total_score']:>6.1f}  "
405	            f"{s['cluster_score']:>5.2f} {s['size_score']:>4.0f} "
406	            f"{s['options_bonus']:>4.0f} {s['track_record_score']:>4.1f} "
407	            f"{s['committee_score']:>4.0f} {sc:>+4.1f}  "
408	            f"{s['member_count']:>6} {s['sell_count']:>5}  "
409	            f"{s['sector'][:25]}"
410	        )
411	        print(f"       {s['issuer_name'][:50]:<50}  buyers: {buyers_str}")
412	        print()
413
414	    notify_candidates = [s for s in signals if s["total_score"] >= SCORE_NOTIFY_THRESHOLD]
415	    print(f"\n{len(notify_candidates)} signals at or above notify threshold ({SCORE_NOTIFY_THRESHOLD})")
416
417	    from tools.generate_report import generate
418	    generate()
419

"""
Signal scoring engine.

Groups trades by ticker within a lookback window and produces a score
for each ticker reflecting the aggregate congressional activity.

Score components (max ~13):
  committee_score    0–3   member's committee overlaps with company sector (Day 5)
  track_record_score 0–2   historical win rate (defaults to 1.0 until calculable)
  cluster_score      0–3   unique member buyers (1=0pts, 2=1pt, 3=2pts, 4+=3pts)
  options_bonus      0–2   any option trade on this ticker (+2)
  size_score         0–5   highest dollar tier among buy trades

Notification threshold (SCORE_NOTIFY_THRESHOLD in config.py): default 7
"""

from __future__ import annotations
import sqlite3
import sys
import os
from collections import defaultdict
from datetime import datetime, timedelta

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

# Committee → sector mapping used for committee_score.
# Updated when committees.py runs (Day 5); used here for scoring.
# Key: normalized committee name fragment, Value: list of matching sectors
_COMMITTEE_SECTOR_MAP = {
    "armed services":       ["industrials", "information-technology"],
    "defense":              ["industrials", "information-technology"],
    "intelligence":         ["information-technology", "industrials"],
    "energy":               ["energy", "utilities", "materials"],
    "natural resources":    ["energy", "materials"],
    "science":              ["information-technology"],
    "technology":           ["information-technology", "communication-services"],
    "health":               ["health-care"],
    "financial services":   ["financials"],
    "banking":              ["financials"],
    "finance":              ["financials"],
    "agriculture":          ["consumer-staples", "materials"],
    "transportation":       ["industrials"],
    "commerce":             ["consumer-discretionary", "communication-services"],
    "judiciary":            [],
    "foreign affairs":      [],
    "ways and means":       ["financials"],
}

def _committee_score(committees_json: str | None, sector: str) -> float:
    """
    Return 0–3 based on overlap between member's committees and company sector.
    0 = no data or no overlap
    1 = indirect overlap (committee has broad relevance)
    2 = moderate overlap
    3 = direct jurisdiction
    """
    if not committees_json or not sector:
        return 0.0
    import json
    try:
        committees = json.loads(committees_json)
    except (json.JSONDecodeError, TypeError):
        return 0.0

    sector = sector.lower()
    best = 0.0
    for committee in committees:
        name = committee.lower() if isinstance(committee, str) else (committee.get("name", "") or "").lower()
        for key, sectors in _COMMITTEE_SECTOR_MAP.items():
            if key in name:
                if sector in sectors:
                    best = max(best, 3.0)
                elif any(s in sector for s in ["tech", "info", "comm"]) and key in ("science", "technology", "commerce"):
                    best = max(best, 2.0)
                else:
                    best = max(best, 1.0)
    return best

def _cluster_score(unique_buyers: int) -> float:
    if unique_buyers >= 4:
        return 3.0
    if unique_buyers == 3:
        return 2.0
    if unique_buyers == 2:
        return 1.0
    return 0.0

def _size_score(max_tier: int) -> float:
    """tier is already 1–6 from ingest; cap contribution at 5."""
    return min(5.0, float(max(0, max_tier)))

def _sell_pressure(unique_sellers: int, unique_buyers: int) -> float:
    """
    Penalty when congressional members are selling a ticker they or others are buying.
    The ratio of sellers to buyers determines severity:

    sellers == 0:                0.0  — clean signal
    ratio < 0.40 (e.g. 1 of 3): -0.5 — minor noise, buyers dominate
    ratio < 0.67 (e.g. 1 of 2): -1.5 — worth noting
    ratio < 1.0  (e.g. 2 of 3): -2.5 — mixed conviction
    ratio >= 1.0 (sellers ≥ buyers): -4.0 — sell pressure outweighs buys
    """
    if unique_sellers == 0:
        return 0.0
    ratio = unique_sellers / max(unique_buyers, 1)
    if ratio >= 1.0:
        return -4.0
    if ratio >= 0.67:
        return -2.5
    if ratio >= 0.40:
        return -1.5
    return -0.5

def _small_cap_bonus(market_cap: float | None) -> float:
    """
    Score bonus/penalty based on market cap.

    The thesis: congressional informed buying in a smaller, less-covered company
    has higher percentage-move potential than the same signal in a mega-cap where
    hundreds of analysts are already pricing in every catalyst. Tiers:

    Mega  (>$100B):  -2.5  — saturated coverage; edge is minimal
    Large ($10-100B): -0.5  — well-covered; slight penalty
    Mid   ($2-10B):  +2.0  — meaningful analyst coverage gap
    Small ($500M-$2B): +4.5 — thin coverage, catalysts move the stock
    Micro (<$500M):  +6.0  — very thin float; single catalyst → 30-50% moves
    Unknown:          0.0  — neutral until price_fetcher populates market_cap
    """
    if market_cap is None:
        return 0.0
    if market_cap > 100e9:
        return -2.5
    if market_cap > 10e9:
        return -0.5
    if market_cap > 2e9:
        return 2.0
    if market_cap > 500e6:
        return 4.5
    return 6.0

def _breadth_mult(hist_buyers: int) -> float:
    """
    Discount factor applied to cluster_score based on how many unique members
    have historically bought a ticker. Mega-caps routinely bought by 15+ members
    are portfolio staples, not informed signals — their cluster score overstates edge.
    """
    if hist_buyers >= 20:
        return 0.40   # basically an index holding (MSFT, GOOGL, NVDA tier)
    if hist_buyers >= 12:
        return 0.65   # widely held (HD, BRK/B, GS tier)
    if hist_buyers >= 6:
        return 0.85   # moderately popular
    return 1.0        # rare buy — full cluster score

def score_tickers(lookback_days: int = LOOKBACK_DAYS) -> list[dict]:
    """
    Score all tickers with at least one BUY trade published in the last
    lookback_days. Returns list of signal dicts sorted by total_score desc.
    Also upserts results into the signals table.
    """
    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row

    cutoff = (datetime.now() - timedelta(days=lookback_days)).strftime("%Y-%m-%d")

    # Cached market caps from previous price_fetcher run (neutral default if missing)
    cap_rows = conn.execute("SELECT ticker, market_cap FROM signals WHERE market_cap IS NOT NULL").fetchall()
    market_caps: dict[str, float] = {r["ticker"]: r["market_cap"] for r in cap_rows}

    # LLM-derived scores from research_cache — default 0 until research has run
    rc_rows = conn.execute(
        "SELECT ticker, COALESCE(strategic_score,0) AS ss, COALESCE(disruption_score,0) AS ds FROM research_cache"
    ).fetchall()
    llm_scores: dict[str, tuple[float, float]] = {r["ticker"]: (r["ss"], r["ds"]) for r in rc_rows}

    # Historical buyer breadth — full DB, not windowed — used for breadth discount
    breadth_rows = conn.execute(
        """
        SELECT issuer_ticker, COUNT(DISTINCT member_id) AS cnt
        FROM trades
        WHERE tx_type = 'buy' AND issuer_ticker != ''
        GROUP BY issuer_ticker
        """
    ).fetchall()
    historical_buyers: dict[str, int] = {r["issuer_ticker"]: r["cnt"] for r in breadth_rows}

    rows = conn.execute(
        """
        SELECT
            t.tx_id,
            t.issuer_ticker,
            t.issuer_name,
            t.issuer_sector,
            t.member_id,
            t.tx_type,
            t.amount_tier,
            t.is_option,
            t.pub_date,
            t.tx_date,
            t.value_estimate,
            m.party,
            m.chamber,
            m.first_name || ' ' || m.last_name AS member_name,
            m.committees,
            COALESCE(m.win_rate, -1) AS win_rate
        FROM trades t
        JOIN members m ON m.bioguide_id = t.member_id
        WHERE t.pub_date >= ?
          AND t.issuer_ticker != ''
        ORDER BY t.issuer_ticker, t.pub_date DESC
        """,
        (cutoff,),
    ).fetchall()

    # Group by ticker
    by_ticker: dict[str, list] = defaultdict(list)
    for row in rows:
        by_ticker[row["issuer_ticker"]].append(dict(row))

    signals = []

    for ticker, ticker_rows in by_ticker.items():
        buy_rows = [r for r in ticker_rows if r["tx_type"] == "buy"]
        sell_rows = [r for r in ticker_rows if r["tx_type"] == "sell"]
        option_rows = [r for r in ticker_rows if r["is_option"]]
        sell_ids = set(r["member_id"] for r in sell_rows)

        if not buy_rows:
            continue  # only score tickers with at least one buy

        # --- Cluster score: unique member buyers, discounted for mega-caps ---
        buyer_ids = set(r["member_id"] for r in buy_rows)
        unique_buyers = len(buyer_ids)
        hist = historical_buyers.get(ticker, 0)
        cluster = round(_cluster_score(unique_buyers) * _breadth_mult(hist), 2)

        # --- Size score: max tier among buys ---
        max_tier = max((r["amount_tier"] or 0) for r in buy_rows)
        size = _size_score(max_tier)

        # --- Options bonus ---
        opt_bonus = 2.0 if option_rows else 0.0

        # --- Track record score (default 1 until win_rate data available) ---
        win_rates = [r["win_rate"] for r in buy_rows if r["win_rate"] >= 0]
        if win_rates:
            avg_wr = sum(win_rates) / len(win_rates)
            track = 2.0 if avg_wr > 0.6 else (1.0 if avg_wr >= 0.5 else 0.5)
        else:
            track = 1.0  # default unknown

        # --- Committee score: best score across all buying members ---
        sector = (buy_rows[0].get("issuer_sector") or "").lower()
        comm_score = max(
            _committee_score(r.get("committees"), sector) for r in buy_rows
        )

        mc_bonus = _small_cap_bonus(market_caps.get(ticker))
        sell_penalty = _sell_pressure(len(sell_ids), unique_buyers)
        strategic, disruption = llm_scores.get(ticker, (0.0, 0.0))
        total = comm_score + track + cluster + opt_bonus + size + mc_bonus + sell_penalty + strategic + disruption

        pub_dates = [r["pub_date"] for r in ticker_rows]
        tx_dates  = [r["tx_date"] for r in buy_rows if r["tx_date"]]

        # Collect buyer details for display
        buyers_detail = []
        seen_buyers = set()
        for r in buy_rows:
            if r["member_id"] not in seen_buyers:
                seen_buyers.add(r["member_id"])
                buyers_detail.append({
                    "name": r["member_name"],
                    "party": r["party"],
                    "chamber": r["chamber"],
                    "tier": r["amount_tier"],
                })

        sig = {
            "ticker": ticker,
            "issuer_name": buy_rows[0]["issuer_name"],
            "sector": sector,
            "first_seen": min(pub_dates),
            "last_seen": max(pub_dates),
            "first_tx_date": min(tx_dates) if tx_dates else "",
            "last_tx_date": max(tx_dates) if tx_dates else "",
            "member_count": unique_buyers,
            "sell_count": len(sell_ids),
            "sell_pressure_score": sell_penalty,
            "has_options": bool(option_rows),
            "max_tier": max_tier,
            "committee_score": comm_score,
            "track_record_score": track,
            "cluster_score": cluster,
            "options_bonus": opt_bonus,
            "size_score": size,
            "small_cap_score": mc_bonus,
            "strategic_score": strategic,
            "disruption_score": disruption,
            "total_score": round(total, 2),
            "buyers": buyers_detail,
        }
        signals.append(sig)

    signals.sort(key=lambda x: x["total_score"], reverse=True)

    # Upsert into signals table
    now = datetime.now().isoformat(timespec="seconds")
    for sig in signals:
        conn.execute(
            """
            INSERT INTO signals (
                ticker, issuer_name, first_seen, last_seen, first_tx_date, member_count,
                committee_score, track_record_score, cluster_score,
                options_bonus, size_score, small_cap_score,
                sell_pressure_score, strategic_score, disruption_score, total_score, updated_at
            ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
            ON CONFLICT(ticker) DO UPDATE SET
                issuer_name         = excluded.issuer_name,
                last_seen           = excluded.last_seen,
                first_tx_date       = excluded.first_tx_date,
                member_count        = excluded.member_count,
                committee_score     = excluded.committee_score,
                track_record_score  = excluded.track_record_score,
                cluster_score       = excluded.cluster_score,
                options_bonus       = excluded.options_bonus,
                size_score          = excluded.size_score,
                small_cap_score     = excluded.small_cap_score,
                sell_pressure_score = excluded.sell_pressure_score,
                strategic_score     = excluded.strategic_score,
                disruption_score    = excluded.disruption_score,
                total_score         = excluded.total_score,
                updated_at          = excluded.updated_at
            """,
            (
                sig["ticker"],
                sig["issuer_name"],
                sig["first_seen"],
                sig["last_seen"],
                sig["first_tx_date"] or None,
                sig["member_count"],
                sig["committee_score"],
                sig["track_record_score"],
                sig["cluster_score"],
                sig["options_bonus"],
                sig["size_score"],
                sig["small_cap_score"],
                sig["sell_pressure_score"],
                sig["strategic_score"],
                sig["disruption_score"],
                sig["total_score"],
                now,
            ),
        )

    conn.commit()
    conn.close()
    return signals

def top_signals(n: int = 10, min_score: float = 0.0) -> list[dict]:
    """Return top N signals from the signals table, optionally filtered by min_score."""
    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row
    rows = conn.execute(
        """
        SELECT * FROM signals
        WHERE total_score >= ?
        ORDER BY total_score DESC
        LIMIT ?
        """,
        (min_score, n),
    ).fetchall()
    conn.close()
    return [dict(r) for r in rows]

if __name__ == "__main__":
    print("Scoring all tickers...\n")
    signals = score_tickers()

    print(f"{'Rank':<5} {'Ticker':<8} {'Score':>6}  {'Clust':>5} {'Size':>4} {'Opts':>4} {'Trck':>4} {'Cmte':>4} {'Cap':>4}  "
          f"{'Buyers':>6} {'Sells':>5}  {'Sector'}")
    print("-" * 100)
    for i, s in enumerate(signals[:20], 1):
        opt_flag = "OPT" if s["has_options"] else ""
        buyers_str = " / ".join(
            f"{b['name'].split()[-1]}({'R' if b['party'] in ('republican', 'R') else 'D'})"
            for b in s["buyers"][:3]
        )
        if len(s["buyers"]) > 3:
            buyers_str += f" +{len(s['buyers'])-3}"
        sc = s.get("small_cap_score", 0) or 0
        print(
            f"{i:<5} {s['ticker']:<8} {s['total_score']:>6.1f}  "
            f"{s['cluster_score']:>5.2f} {s['size_score']:>4.0f} "
            f"{s['options_bonus']:>4.0f} {s['track_record_score']:>4.1f} "
            f"{s['committee_score']:>4.0f} {sc:>+4.1f}  "
            f"{s['member_count']:>6} {s['sell_count']:>5}  "
            f"{s['sector'][:25]}"
        )
        print(f"       {s['issuer_name'][:50]:<50}  buyers: {buyers_str}")
        print()

    notify_candidates = [s for s in signals if s["total_score"] >= SCORE_NOTIFY_THRESHOLD]
    print(f"\n{len(notify_candidates)} signals at or above notify threshold ({SCORE_NOTIFY_THRESHOLD})")

    from tools.generate_report import generate
    generate()


Fix win-rate calculation in signal outcomes


Now I have a clear picture of the issue. Let me analyze what's wrong with `update_pending_outcomes` in `tools/outcome_tracker.py`:

The current code:
```python
ticker_data = yf.Ticker(row["ticker"])
hist = ticker_data.history(period="65d")  # 65 TRADING DAYS FROM TODAY
if hist.empty:
    continue

entry_price = row["price_at_notify"]

updates: dict = {"checked_at": now.isoformat(timespec="seconds")}
if days_elapsed >= 30 and not row["price_30d"]:
```

The fundamental problem is that `history(period="65d")` retrieves the last 65 calendar days from today, not from when the notification was sent. Then indexing into that historical data with `iloc[30]` grabs a price from roughly 35 days ago relative to today, which doesn't correspond to 30 days from the actual notification date. The fix needs to calculate prices relative to each notification's timestamp rather than using a fixed window from the current date.

For each pending outcome, I should fetch the price history starting from the `notified_at` date and extending 65 days forward, then locate the closing prices at approximately 30 and 60 calendar days after that notification date. This requires using `yf.Ticker(ticker).history(start=notified_date, end=notified_date + 65 days)` and then finding the correct rows based on the actual date offsets from the notification.

There's also a timing issue to clarify—the schema records `notified_at` but the instructions reference comparing against `tx_date` for signal timing, so I need to check which date should actually be used as the reference point for the 30 and 60 day lookups. So the `notified_at` timestamp is what I should use as the reference point for the 30 and 60-day lookback windows, not `tx_date`, since that's when we actually entered the position. The `price_at_notify` already captures the entry price correctly from the signal data.

The current implementation is broken because it's pulling a 65-day window from today and arbitrarily picking indices 30 and 60, which doesn't correspond to the actual 30 and 60-day marks from when the signal was triggered. I need to fix this by anchoring the historical data fetch to the `notified_at` date and then finding the closing prices at the correct calendar day offsets from that notification date. Let me check the win_rate.py file to see how it's being used in the pipeline.


Bash

cd /home/node/projects/congressional-trading && cat requirements.txt

Check requirements.txt


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


requests>=2.31.0
beautifulsoup4>=4.12.0
anthropic>=0.40.0
google-genai>=1.0.0
flask>=3.0.0
python-dotenv>=1.0.0
pyyaml>=6.0
yfinance>=0.2.40
pdfplumber>=0.11.0
Shell cwd was reset to /workspace/agent


Now I have a complete picture. Let me understand the bugs:

**Bug 1: `update_pending_outcomes` uses wrong date window**

Current code in `tools/outcome_tracker.py`:
```python
ticker_data = yf.Ticker(row["ticker"])
hist = ticker_data.history(period="65d")  # Last 65 calendar days from TODAY
```

Then:
```python
idx_30 = min(30, len(hist) - 1)
price_30d = float(hist["Close"].iloc[idx_30])
```

The issue is that `period="65d"` pulls the last 65 calendar days from today, so `hist.iloc[30]` grabs the 30th row from the beginning of that window—which is roughly 35 days ago from today, not 30 days from when the signal was actually notified. For a signal that came in 90 days ago, this calculation is completely off.

**Bug 2: `win_rate.py` uses `return_from_tx_pct` which is the current open-ended return**

The win rate calculation is pulling `signals.return_from_tx_pct`, which represents the ongoing return on an open position rather than the final outcome at the time the signal was closed or evaluated.

**The fix:**

For `outcome_tracker.py`, I need to fetch the price history anchored to the actual notification date instead of today. I'm fetching prices starting from when the signal was notified and extending 70 calendar days forward to capture enough trading days. Then I'll locate the rows closest to the +30 and +60 day marks from that notification date.

Defining the date range logic...

For `win_rate.py`, the issue is that it's currently pulling today's open-ended returns from the signals table as a proxy for member win rates. The better approach is to use the `signal_outcomes` table which already has the fixed-horizon 30d and 60d returns calculated, so I can get accurate win rates based on those defined windows instead. The real issue is that `_pending_outcomes` is calculating returns from today instead of from the notification date—I need to use `notified_at` as the anchor point and fetch prices at `notified_at + 30 days` and `notified_at + 60 days` rather than using a fixed period from the current date. I'll use `notified_at` as the reference point since that's when we'd enter a position, and I can leverage the existing `price_at_notify` field. My approach is to refactor `update_pending_outcomes` to fetch historical data from `notified_at` through 70 days later, then extract closing prices at the 30-day and 60-day marks, batching requests by ticker to stay within rate limits. I'll also need a backfill function to populate the return fields for existing rows where they're currently NULL. For each pending outcome, I need to calculate the target dates by adding 30 and 60 calendar days to the notification timestamp, then fetch historical price data for that range and find the closest trading day to compute returns from the notification price. I should also backfill existing records with NULL returns that are old enough. While batching multiple outcomes of the same ticker could be more efficient, for Phase 1 I'll keep it simple with individual ticker fetches. I'm also reconsidering whether to update `win_rate.py` to use `signal_outcomes` data instead of the current `signals.return_from_tx_pct` approach, since the instructions emphasize fixing the outcome tracking logic.

Writing the update function... Fetching historical price data for each ticker within the relevant date range, then iterating through the pending outcomes to calculate current performance metrics and prepare updates based on the price history. Updating the 60-day price and return metrics, then determining if the trade was a win or loss based on the return percentage. After that, I'm building the SQL update statement with all the collected metrics and executing it to persist the changes to the database, keeping track of how many records get updated before committing and closing the connection.

I realize pandas isn't imported, so I need a different approach to find the closest price after a target date. I'm writing a helper function that iterates through the price history row by row to find the first entry on or after the target date, falling back to the most recent price if nothing matches. I should also add a small sleep between API calls to respect rate limits, and handle timezone awareness since yfinance returns timezone-aware datetime indices.

Writing the clean implementation... Fetching historical price data for each ticker across the date range from the earliest notification to 70 days later, then iterating through the rows to calculate how many days have elapsed since notification and preparing to update the outcome based on price movement. Updating the database with the calculated outcome and return percentages, then committing the changes and returning a summary of how many records were updated. I'm also realizing I need to handle the timezone-aware datetime index from yfinance properly when extracting dates for price lookups. prices were computed with the buggy logic using today's rolling window, they'd have incorrect outcomes. The issue is I can't easily distinguish which rows were computed correctly versus incorrectly without more context, so I need to decide whether to re-compute all rows with `price_60d` set or just handle the pending ones going forward.

I'll create two separate functions: keep `update_pending_outcomes` focused on rows that don't have outcomes yet, using the corrected date ranges, and add a `backfill_outcomes` function that can optionally force-recompute all rows if needed. This way the backfill is explicit and opt-in rather than automatic. I'm leaning toward a more conservative approach: fix the core function to use correct date ranges, then update the query to handle rows where the return values are NULL regardless of outcome status. This way I avoid the risk of resetting already-correct data while still catching the buggy computations. I'm simplifying the approach: instead of trying to re-process all rows, I'll just query for records where the return percentages are NULL, which captures both uncomputed pending rows and any existing rows that were never calculated. This avoids overwriting correctly computed values while ensuring we fill in the gaps.

The schema already has the columns I need as `return_30d_pct` and `return_60d_pct`, so no migration is required. Now I'll write out the clean implementation.


Now I have a complete picture. The bug is clear: `update_pending_outcomes` fetches `period="65d"` from **today** and uses row indices 30/60 as offsets — so for a signal notified 90 days ago, `iloc[30]` gives the price ~35 days ago from today, not 30 days from notification. The fix is to fetch the date range anchored to `notified_at`.

Let me also install yfinance to verify it's available, then write the fix.


cd /home/node/projects/congressional-trading && pip show yfinance 2>&1 | head -3

Check yfinance installation


/bin/bash: line 1: pip: command not found
Shell cwd was reset to /workspace/agent


pip3 show yfinance 2>&1 | head -3; python3 -c "import yfinance; print(yfinance.__version__)" 2>&1

Check yfinance with pip3


Error: Exit code 127
/bin/bash: line 1: pip3: command not found
/bin/bash: line 1: python3: command not found


which python; python --version; python -c "import yfinance; print(yfinance.__version__)" 2>&1

Check python and yfinance


Error: Exit code 127
/bin/bash: line 1: python: command not found
/bin/bash: line 1: python: command not found


ls /usr/bin/python* /usr/local/bin/python* 2>/dev/null; ls /usr/bin/pip* /usr/local/bin/pip* 2>/dev/null

Find Python and pip executables


Exit code 2


find /usr /opt /nix -name "python*" -type f 2>/dev/null | head -10

Find any python executable


(Bash completed with no output)


Python doesn't seem to be installed on this system. This is an agent environment, not the development environment. The congressional-trading repo is meant to be run on the user's Mac (based on `setup_mac.sh` and launchd folder). I can still read and edit the code correctly - I just can't run it here.

Let me proceed with making the code changes and commit them. I don't need to run the code to make the fix - I can reason about it clearly.

Let me now write the fixed `outcome_tracker.py`.


Python isn't installed in this container — that's fine, the repo runs on the user's Mac. I have everything I need to write the fix. Let me implement it now.


Write

"""
Track outcomes of notified trade signals.

Run daily after price_fetcher to update pending outcomes:
  python tools/outcome_tracker.py
"""
from __future__ import annotations
import sqlite3
import sys
import os
import time
from datetime import datetime, timedelta

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

def record_notification(ticker: str, score: float, conviction: str,
                        recommended_action: str, price: float | None,
                        notified_at: str) -> None:
    """Insert a pending outcome record when a notification fires."""
    conn = sqlite3.connect(DB_PATH)
    conn.execute(
        """
        INSERT INTO signal_outcomes
            (ticker, notified_at, score_at_notify, conviction, recommended_action, price_at_notify, outcome)
        VALUES (?, ?, ?, ?, ?, ?, 'pending')
        """,
        (ticker, notified_at, score, conviction, recommended_action, price),
    )
    conn.commit()
    conn.close()

def _find_price_at_date(hist, target_date) -> float:
    """Return the close on the first trading day on or after target_date."""
    for ts in hist.index:
        ts_d = ts.date() if hasattr(ts, "date") else ts
        if ts_d >= target_date:
            return float(hist.loc[ts, "Close"])
    return float(hist["Close"].iloc[-1])

def update_pending_outcomes() -> dict:
    """
    Check pending outcomes and fill in 30d/60d returns using historical prices
    anchored to the notification date — not today's rolling window.

    Groups by ticker to minimise yfinance calls.
    """
    try:
        import yfinance as yf
    except ImportError:
        print("yfinance not installed — skipping outcome tracking")
        return {"updated": 0}

    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row
    now = datetime.now()

    rows = conn.execute(
        """
        SELECT * FROM signal_outcomes
        WHERE outcome = 'pending' OR return_30d_pct IS NULL OR return_60d_pct IS NULL
        """,
    ).fetchall()

    # Group eligible rows by ticker; skip rows that are too recent or lack entry price
    by_ticker: dict[str, list[dict]] = {}
    for row in rows:
        row = dict(row)
        if not row["price_at_notify"]:
            continue
        notified_dt = datetime.fromisoformat(row["notified_at"])
        if (now - notified_dt).days < 30:
            continue
        by_ticker.setdefault(row["ticker"], []).append(row)

    updated = 0
    for i, (ticker, ticker_rows) in enumerate(by_ticker.items()):
        if i > 0:
            time.sleep(0.5)

        # One fetch covers all notified_at dates for this ticker
        notified_dts = [datetime.fromisoformat(r["notified_at"]) for r in ticker_rows]
        fetch_start = min(notified_dts).date()
        fetch_end = min(
            max(notified_dts).date() + timedelta(days=70),
            now.date(),
        )

        try:
            hist = yf.Ticker(ticker).history(
                start=str(fetch_start), end=str(fetch_end)
            )
            if hist.empty:
                continue
        except Exception as e:
            print(f"  Price fetch failed for {ticker}: {e}")
            continue

        for row in ticker_rows:
            notified_dt = datetime.fromisoformat(row["notified_at"])
            days_elapsed = (now - notified_dt).days
            entry_price = row["price_at_notify"]
            updates: dict = {"checked_at": now.isoformat(timespec="seconds")}

            if days_elapsed >= 30 and not row["price_30d"]:
                target = (notified_dt + timedelta(days=30)).date()
                try:
                    p = _find_price_at_date(hist, target)
                    updates["price_30d"] = p
                    updates["return_30d_pct"] = (p - entry_price) / entry_price * 100
                except Exception:
                    pass

            if days_elapsed >= 60 and not row["price_60d"]:
                target = (notified_dt + timedelta(days=60)).date()
                try:
                    p = _find_price_at_date(hist, target)
                    updates["price_60d"] = p
                    updates["return_60d_pct"] = (p - entry_price) / entry_price * 100
                    updates["outcome"] = "win" if p > entry_price else "loss"
                except Exception:
                    pass

            if len(updates) > 1:
                sets = ", ".join(f"{k} = ?" for k in updates)
                vals = list(updates.values()) + [row["id"]]
                conn.execute(f"UPDATE signal_outcomes SET {sets} WHERE id = ?", vals)
                updated += 1

    conn.commit()
    conn.close()
    return {"updated": updated, "pending_checked": len(rows)}

def print_scorecard() -> None:
    """Print a win/loss scorecard for all tracked signals."""
    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row

    outcomes = conn.execute(
        "SELECT * FROM signal_outcomes ORDER BY notified_at DESC"
    ).fetchall()
    conn.close()

    if not outcomes:
        print("No tracked outcomes yet.")
        return

    wins = sum(1 for o in outcomes if o["outcome"] == "win")
    losses = sum(1 for o in outcomes if o["outcome"] == "loss")
    pending = sum(1 for o in outcomes if o["outcome"] == "pending")
    total_closed = wins + losses

    print(f"\nSignal Scorecard ({len(outcomes)} total, {pending} pending)")
    print(f"{'='*50}")
    if total_closed:
        print(f"Win rate: {wins}/{total_closed} = {wins/total_closed:.0%}")
    print()
    for o in outcomes:
        o = dict(o)
        ret_str = f"{o['return_60d_pct']:+.1f}%" if o.get("return_60d_pct") is not None else "pending"
        print(f"  {o['ticker']:<8} notified {o['notified_at'][:10]} | {o['conviction'] or '?'} | {o['outcome']} | 60d: {ret_str}")

if __name__ == "__main__":
    print("Updating pending outcomes...")
    result = update_pending_outcomes()
    print(f"Updated {result['updated']} outcomes")
    print_scorecard()


The file /home/node/projects/congressional-trading/tools/outcome_tracker.py has been updated successfully. (file state is current in your context — no need to Read it back)

"""
Track outcomes of notified trade signals.

Run daily after price_fetcher to update pending outcomes:
  python tools/outcome_tracker.py
"""
from __future__ import annotations
import sqlite3
import sys
import os
import time
from datetime import datetime, timedelta

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

def record_notification(ticker: str, score: float, conviction: str,
                        recommended_action: str, price: float | None,
                        notified_at: str) -> None:
    """Insert a pending outcome record when a notification fires."""
    conn = sqlite3.connect(DB_PATH)
    conn.execute(
        """
        INSERT INTO signal_outcomes
            (ticker, notified_at, score_at_notify, conviction, recommended_action, price_at_notify, outcome)
        VALUES (?, ?, ?, ?, ?, ?, 'pending')
        """,
        (ticker, notified_at, score, conviction, recommended_action, price),
    )
    conn.commit()
    conn.close()

def _find_price_at_date(hist, target_date) -> float:
    """Return the close on the first trading day on or after target_date."""
    for ts in hist.index:
        ts_d = ts.date() if hasattr(ts, "date") else ts
        if ts_d >= target_date:
            return float(hist.loc[ts, "Close"])
    return float(hist["Close"].iloc[-1])

def update_pending_outcomes() -> dict:
    """
    Check pending outcomes and fill in 30d/60d returns using historical prices
    anchored to the notification date — not today's rolling window.

    Groups by ticker to minimise yfinance calls.
    """
    try:
        import yfinance as yf
    except ImportError:
        print("yfinance not installed — skipping outcome tracking")
        return {"updated": 0}

    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row
    now = datetime.now()

    rows = conn.execute(
        """
        SELECT * FROM signal_outcomes
        WHERE outcome = 'pending' OR return_30d_pct IS NULL OR return_60d_pct IS NULL
        """,
    ).fetchall()

    # Group eligible rows by ticker; skip rows that are too recent or lack entry price
    by_ticker: dict[str, list[dict]] = {}
    for row in rows:
        row = dict(row)
        if not row["price_at_notify"]:
            continue
        notified_dt = datetime.fromisoformat(row["notified_at"])
        if (now - notified_dt).days < 30:
            continue
        by_ticker.setdefault(row["ticker"], []).append(row)

    updated = 0
    for i, (ticker, ticker_rows) in enumerate(by_ticker.items()):
        if i > 0:
            time.sleep(0.5)

        # One fetch covers all notified_at dates for this ticker
        notified_dts = [datetime.fromisoformat(r["notified_at"]) for r in ticker_rows]
        fetch_start = min(notified_dts).date()
        fetch_end = min(
            max(notified_dts).date() + timedelta(days=70),
            now.date(),
        )

        try:
            hist = yf.Ticker(ticker).history(
                start=str(fetch_start), end=str(fetch_end)
            )
            if hist.empty:
                continue
        except Exception as e:
            print(f"  Price fetch failed for {ticker}: {e}")
            continue

        for row in ticker_rows:
            notified_dt = datetime.fromisoformat(row["notified_at"])
            days_elapsed = (now - notified_dt).days
            entry_price = row["price_at_notify"]
            updates: dict = {"checked_at": now.isoformat(timespec="seconds")}

            if days_elapsed >= 30 and not row["price_30d"]:
                target = (notified_dt + timedelta(days=30)).date()
                try:
                    p = _find_price_at_date(hist, target)
                    updates["price_30d"] = p
                    updates["return_30d_pct"] = (p - entry_price) / entry_price * 100
                except Exception:
                    pass

            if days_elapsed >= 60 and not row["price_60d"]:
                target = (notified_dt + timedelta(days=60)).date()
                try:
                    p = _find_price_at_date(hist, target)
                    updates["price_60d"] = p
                    updates["return_60d_pct"] = (p - entry_price) / entry_price * 100
                    updates["outcome"] = "win" if p > entry_price else "loss"
                except Exception:
                    pass

            if len(updates) > 1:
                sets = ", ".join(f"{k} = ?" for k in updates)
                vals = list(updates.values()) + [row["id"]]
                conn.execute(f"UPDATE signal_outcomes SET {sets} WHERE id = ?", vals)
                updated += 1

    conn.commit()
    conn.close()
    return {"updated": updated, "pending_checked": len(rows)}

def print_scorecard() -> None:
    """Print a win/loss scorecard for all tracked signals."""
    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row

    outcomes = conn.execute(
        "SELECT * FROM signal_outcomes ORDER BY notified_at DESC"
    ).fetchall()
    conn.close()

    if not outcomes:
        print("No tracked outcomes yet.")
        return

    wins = sum(1 for o in outcomes if o["outcome"] == "win")
    losses = sum(1 for o in outcomes if o["outcome"] == "loss")
    pending = sum(1 for o in outcomes if o["outcome"] == "pending")
    total_closed = wins + losses

    print(f"\nSignal Scorecard ({len(outcomes)} total, {pending} pending)")
    print(f"{'='*50}")
    if total_closed:
        print(f"Win rate: {wins}/{total_closed} = {wins/total_closed:.0%}")
    print()
    for o in outcomes:
        o = dict(o)
        ret_str = f"{o['return_60d_pct']:+.1f}%" if o.get("return_60d_pct") is not None else "pending"
        print(f"  {o['ticker']:<8} notified {o['notified_at'][:10]} | {o['conviction'] or '?'} | {o['outcome']} | 60d: {ret_str}")

if __name__ == "__main__":
    print("Updating pending outcomes...")
    result = update_pending_outcomes()
    print(f"Updated {result['updated']} outcomes")
    print_scorecard()

+def _find_price_at_date(hist, target_date) -> float:

+    """Return the close on the first trading day on or after target_date."""

+        ts_d = ts.date() if hasattr(ts, "date") else ts

+            return float(hist.loc[ts, "Close"])

+    return float(hist["Close"].iloc[-1])

-    Check pending outcomes and fill in 30d/60d returns using current price data.

-    Returns a summary of how many were updated.

+    Check pending outcomes and fill in 30d/60d returns using historical prices

+    anchored to the notification date — not today's rolling window.

+    Groups by ticker to minimise yfinance calls.

-        "SELECT * FROM signal_outcomes WHERE outcome = 'pending'",

+        WHERE outcome = 'pending' OR return_30d_pct IS NULL OR return_60d_pct IS NULL

+    # Group eligible rows by ticker; skip rows that are too recent or lack entry price

+    by_ticker: dict[str, list[dict]] = {}

-        days_elapsed = (now - notified_dt).days

+        if (now - notified_dt).days < 30:

+        by_ticker.setdefault(row["ticker"], []).append(row)

-            continue  # too early to score

+    for i, (ticker, ticker_rows) in enumerate(by_ticker.items()):

+        # One fetch covers all notified_at dates for this ticker

+        notified_dts = [datetime.fromisoformat(r["notified_at"]) for r in ticker_rows]

+        fetch_start = min(notified_dts).date()

+            max(notified_dts).date() + timedelta(days=70),

-            ticker_data = yf.Ticker(row["ticker"])

-            hist = ticker_data.history(period="65d")

+            hist = yf.Ticker(ticker).history(

+                start=str(fetch_start), end=str(fetch_end)

+            print(f"  Price fetch failed for {ticker}: {e}")

+            notified_dt = datetime.fromisoformat(row["notified_at"])

+            days_elapsed = (now - notified_dt).days

-                # Approximate 30d price from history

-                price_30d = float(hist["Close"].iloc[idx_30]) if idx_30 < len(hist) else float(hist["Close"].iloc[-1])

-                ret_30 = (price_30d - entry_price) / entry_price * 100

-                updates["price_30d"] = price_30d

-                updates["return_30d_pct"] = ret_30

+                target = (notified_dt + timedelta(days=30)).date()

+                    p = _find_price_at_date(hist, target)

+                    updates["price_30d"] = p

+                    updates["return_30d_pct"] = (p - entry_price) / entry_price * 100

-                price_60d = float(hist["Close"].iloc[idx_60]) if idx_60 < len(hist) else float(hist["Close"].iloc[-1])

-                ret_60 = (price_60d - entry_price) / entry_price * 100

-                updates["price_60d"] = price_60d

-                updates["return_60d_pct"] = ret_60

-                updates["outcome"] = "win" if ret_60 > 0 else "loss"

+                target = (notified_dt + timedelta(days=60)).date()

+                    updates["price_60d"] = p

+                    updates["return_60d_pct"] = (p - entry_price) / entry_price * 100

+                    updates["outcome"] = "win" if p > entry_price else "loss"

-            print(f"  Outcome check failed for {row['ticker']}: {e}")

-    return {"updated": updated, "pending_checked": len(pending)}

+    return {"updated": updated, "pending_checked": len(rows)}

"""
Track outcomes of notified trade signals.

Run daily after price_fetcher to update pending outcomes:
  python tools/outcome_tracker.py
"""
from __future__ import annotations
import sqlite3
import sys
import os
from datetime import datetime, timedelta

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

def record_notification(ticker: str, score: float, conviction: str,
                        recommended_action: str, price: float | None,
                        notified_at: str) -> None:
    """Insert a pending outcome record when a notification fires."""
    conn = sqlite3.connect(DB_PATH)
    conn.execute(
        """
        INSERT INTO signal_outcomes
            (ticker, notified_at, score_at_notify, conviction, recommended_action, price_at_notify, outcome)
        VALUES (?, ?, ?, ?, ?, ?, 'pending')
        """,
        (ticker, notified_at, score, conviction, recommended_action, price),
    )
    conn.commit()
    conn.close()

def update_pending_outcomes() -> dict:
    """
    Check pending outcomes and fill in 30d/60d returns using current price data.
    Returns a summary of how many were updated.
    """
    try:
        import yfinance as yf
    except ImportError:
        print("yfinance not installed — skipping outcome tracking")
        return {"updated": 0}

    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row
    now = datetime.now()

    pending = conn.execute(
        "SELECT * FROM signal_outcomes WHERE outcome = 'pending'",
    ).fetchall()

    updated = 0
    for row in pending:
        row = dict(row)
        if not row["price_at_notify"]:
            continue

        notified_dt = datetime.fromisoformat(row["notified_at"])
        days_elapsed = (now - notified_dt).days

        if days_elapsed < 30:
            continue  # too early to score

        try:
            ticker_data = yf.Ticker(row["ticker"])
            hist = ticker_data.history(period="65d")
            if hist.empty:
                continue

            entry_price = row["price_at_notify"]

            updates: dict = {"checked_at": now.isoformat(timespec="seconds")}
            if days_elapsed >= 30 and not row["price_30d"]:
                # Approximate 30d price from history
                idx_30 = min(30, len(hist) - 1)
                price_30d = float(hist["Close"].iloc[idx_30]) if idx_30 < len(hist) else float(hist["Close"].iloc[-1])
                ret_30 = (price_30d - entry_price) / entry_price * 100
                updates["price_30d"] = price_30d
                updates["return_30d_pct"] = ret_30

            if days_elapsed >= 60 and not row["price_60d"]:
                idx_60 = min(60, len(hist) - 1)
                price_60d = float(hist["Close"].iloc[idx_60]) if idx_60 < len(hist) else float(hist["Close"].iloc[-1])
                ret_60 = (price_60d - entry_price) / entry_price * 100
                updates["price_60d"] = price_60d
                updates["return_60d_pct"] = ret_60
                updates["outcome"] = "win" if ret_60 > 0 else "loss"

            if updates:
                sets = ", ".join(f"{k} = ?" for k in updates)
                vals = list(updates.values()) + [row["id"]]
                conn.execute(f"UPDATE signal_outcomes SET {sets} WHERE id = ?", vals)
                updated += 1

        except Exception as e:
            print(f"  Outcome check failed for {row['ticker']}: {e}")

    conn.commit()
    conn.close()
    return {"updated": updated, "pending_checked": len(pending)}

def print_scorecard() -> None:
    """Print a win/loss scorecard for all tracked signals."""
    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row

    outcomes = conn.execute(
        "SELECT * FROM signal_outcomes ORDER BY notified_at DESC"
    ).fetchall()
    conn.close()

    if not outcomes:
        print("No tracked outcomes yet.")
        return

    wins = sum(1 for o in outcomes if o["outcome"] == "win")
    losses = sum(1 for o in outcomes if o["outcome"] == "loss")
    pending = sum(1 for o in outcomes if o["outcome"] == "pending")
    total_closed = wins + losses

    print(f"\nSignal Scorecard ({len(outcomes)} total, {pending} pending)")
    print(f"{'='*50}")
    if total_closed:
        print(f"Win rate: {wins}/{total_closed} = {wins/total_closed:.0%}")
    print()
    for o in outcomes:
        o = dict(o)
        ret_str = f"{o['return_60d_pct']:+.1f}%" if o.get("return_60d_pct") is not None else "pending"
        print(f"  {o['ticker']:<8} notified {o['notified_at'][:10]} | {o['conviction'] or '?'} | {o['outcome']} | 60d: {ret_str}")

if __name__ == "__main__":
    print("Updating pending outcomes...")
    result = update_pending_outcomes()
    print(f"Updated {result['updated']} outcomes")
    print_scorecard()


Now let me also check if win_rate.py needs updating. The instructions say to fix win-rate calculation so it records actual 30d/60d returns, not today's price.

Looking at win_rate.py: it uses `signals.return_from_tx_pct` which is today's open-ended return. The issue is that this is an ever-changing number, not a fixed horizon return. However, `signal_outcomes` only has entries for signals that fired notifications (not all trades). So we can't fully switch win_rate.py to use signal_outcomes yet.

Actually, re-reading the Phase 1 instructions more carefully:
The real problem is that `signal_outcomes` records win/loss based on today's price rather than a fixed post-signal horizon, which makes the precision metrics unreliable. The fix is in `outcome_tracker.py`, which I've already addressed. The win_rate calculation in win_rate.py is a separate concern since it uses `return_from_tx_pct` for the track_record_score, but that's not the primary Phase 1 issue. Let me check what else Phase 1 actually requires.


Now let me check if there are tests to verify, and also check the tests directory:


Bash

List tests directory


__init__.py
test_notification_policy.py


1	from __future__ import annotations
2
3	from datetime import datetime, timedelta
4	import os
5	import sqlite3
6	import tempfile
7	import unittest
8
9	from analysis.notification_policy import (
10	    edge_factors,
11	    evaluate_notification_candidate,
12	    is_buy_recommendation,
13	    recent_ticker_notification,
14	)
15
16
17	def _signal(**overrides):
18	    sig = {
19	        "ticker": "EDGE",
20	        "member_count": 2,
21	        "max_tier": 4,
22	        "has_options": False,
23	        "committee_score": 3.0,
24	        "small_cap_score": 0.0,
25	        "sell_pressure_score": 0.0,
26	        "strategic_score": 3.0,
27	        "disruption_score": 1.0,
28	    }
29	    sig.update(overrides)
30	    return sig
31
32
33	def _research(**overrides):
34	    research = {
35	        "conviction": "HIGH",
36	        "recommended_action": "BUY NOW - catalyst remains timely",
37	        "risk_factors": "Valuation and execution risk.",
38	        "strategic_score": 3,
39	        "disruption_score": 1,
40	    }
41	    research.update(overrides)
42	    return research
43
44
45	class NotificationPolicyTest(unittest.TestCase):
46	    def test_allows_explicit_high_signal_buy(self):
47	        decision = evaluate_notification_candidate(_signal(), _research(), 11.0)
48
49	        self.assertTrue(decision.allowed)
50	        self.assertGreaterEqual(len(decision.edge_factors), 3)
51	        self.assertIn("multi-member cluster", decision.edge_factors)
52	        self.assertIn("direct committee alignment", decision.edge_factors)
53
54	    def test_rejects_monitor_or_not_buy_text(self):
55	        self.assertFalse(is_buy_recommendation("MONITOR - do not buy until earnings"))
56	        self.assertFalse(is_buy_recommendation("Do not buy despite the catalyst"))
57
58	        decision = evaluate_notification_candidate(
59	            _signal(),
60	            _research(recommended_action="MONITOR - wait before buying"),
61	            11.0,
62	        )
63
64	        self.assertFalse(decision.allowed)
65	        self.assertIn("not an explicit buy", decision.reason)
66
67	    def test_rejects_high_score_without_enough_edge_factors(self):
68	        sig = _signal(
69	            member_count=1,
70	            max_tier=2,
71	            has_options=False,
72	            committee_score=1.0,
73	            small_cap_score=0.0,
74	            strategic_score=1.0,
75	            disruption_score=1.0,
76	        )
77	        research = _research(strategic_score=1, disruption_score=1)
78
79	        decision = evaluate_notification_candidate(sig, research, 12.0)
80
81	        self.assertFalse(decision.allowed)
82	        self.assertIn("edge factors", decision.reason)
83	        self.assertEqual(edge_factors(sig, research), ())
84
85	    def test_rejects_meaningful_sell_pressure(self):
86	        decision = evaluate_notification_candidate(
87	            _signal(sell_pressure_score=-1.5),
88	            _research(),
89	            12.0,
90	        )
91
92	        self.assertFalse(decision.allowed)
93	        self.assertIn("sell pressure", decision.reason)
94
95	    def test_recent_ticker_notification_uses_rolling_cooldown(self):
96	        now = datetime(2026, 6, 5, 12, 0, 0)
97	        with tempfile.TemporaryDirectory() as tmp:
98	            db_path = os.path.join(tmp, "signals.db")
99	            conn = sqlite3.connect(db_path)
100	            conn.execute(
101	                """
102	                CREATE TABLE signal_outcomes (
103	                    ticker TEXT NOT NULL,
104	                    notified_at TEXT NOT NULL
105	                )
106	                """
107	            )
108	            conn.execute(
109	                "INSERT INTO signal_outcomes (ticker, notified_at) VALUES (?, ?)",
110	                ("AMD", (now - timedelta(days=3)).isoformat(timespec="seconds")),
111	            )
112	            conn.commit()
113	            conn.close()
114
115	            in_cooldown, latest, days_left = recent_ticker_notification(
116	                "AMD",
117	                db_path=db_path,
118	                cooldown_days=14,
119	                now=now,
120	            )
121	            stale, _, stale_days_left = recent_ticker_notification(
122	                "MSI",
123	                db_path=db_path,
124	                cooldown_days=14,
125	                now=now,
126	            )
127
128	        self.assertTrue(in_cooldown)
129	        self.assertEqual(latest, now - timedelta(days=3))
130	        self.assertEqual(days_left, 11)
131	        self.assertFalse(stale)
132	        self.assertEqual(stale_days_left, 0)
133
134
135	if __name__ == "__main__":
136	    unittest.main()
137

from __future__ import annotations

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

from analysis.notification_policy import (
    edge_factors,
    evaluate_notification_candidate,
    is_buy_recommendation,
    recent_ticker_notification,
)

def _signal(**overrides):
    sig = {
        "ticker": "EDGE",
        "member_count": 2,
        "max_tier": 4,
        "has_options": False,
        "committee_score": 3.0,
        "small_cap_score": 0.0,
        "sell_pressure_score": 0.0,
        "strategic_score": 3.0,
        "disruption_score": 1.0,
    }
    sig.update(overrides)
    return sig

def _research(**overrides):
    research = {
        "conviction": "HIGH",
        "recommended_action": "BUY NOW - catalyst remains timely",
        "risk_factors": "Valuation and execution risk.",
        "strategic_score": 3,
        "disruption_score": 1,
    }
    research.update(overrides)
    return research

class NotificationPolicyTest(unittest.TestCase):
    def test_allows_explicit_high_signal_buy(self):
        decision = evaluate_notification_candidate(_signal(), _research(), 11.0)

        self.assertTrue(decision.allowed)
        self.assertGreaterEqual(len(decision.edge_factors), 3)
        self.assertIn("multi-member cluster", decision.edge_factors)
        self.assertIn("direct committee alignment", decision.edge_factors)

    def test_rejects_monitor_or_not_buy_text(self):
        self.assertFalse(is_buy_recommendation("MONITOR - do not buy until earnings"))
        self.assertFalse(is_buy_recommendation("Do not buy despite the catalyst"))

        decision = evaluate_notification_candidate(
            _signal(),
            _research(recommended_action="MONITOR - wait before buying"),
            11.0,
        )

        self.assertFalse(decision.allowed)
        self.assertIn("not an explicit buy", decision.reason)

    def test_rejects_high_score_without_enough_edge_factors(self):
        sig = _signal(
            member_count=1,
            max_tier=2,
            has_options=False,
            committee_score=1.0,
            small_cap_score=0.0,
            strategic_score=1.0,
            disruption_score=1.0,
        )
        research = _research(strategic_score=1, disruption_score=1)

        decision = evaluate_notification_candidate(sig, research, 12.0)

        self.assertFalse(decision.allowed)
        self.assertIn("edge factors", decision.reason)
        self.assertEqual(edge_factors(sig, research), ())

    def test_rejects_meaningful_sell_pressure(self):
        decision = evaluate_notification_candidate(
            _signal(sell_pressure_score=-1.5),
            _research(),
            12.0,
        )

        self.assertFalse(decision.allowed)
        self.assertIn("sell pressure", decision.reason)

    def test_recent_ticker_notification_uses_rolling_cooldown(self):
        now = datetime(2026, 6, 5, 12, 0, 0)
        with tempfile.TemporaryDirectory() as tmp:
            db_path = os.path.join(tmp, "signals.db")
            conn = sqlite3.connect(db_path)
            conn.execute(
                """
                CREATE TABLE signal_outcomes (
                    ticker TEXT NOT NULL,
                    notified_at TEXT NOT NULL
                )
                """
            )
            conn.execute(
                "INSERT INTO signal_outcomes (ticker, notified_at) VALUES (?, ?)",
                ("AMD", (now - timedelta(days=3)).isoformat(timespec="seconds")),
            )
            conn.commit()
            conn.close()

            in_cooldown, latest, days_left = recent_ticker_notification(
                "AMD",
                db_path=db_path,
                cooldown_days=14,
                now=now,
            )
            stale, _, stale_days_left = recent_ticker_notification(
                "MSI",
                db_path=db_path,
                cooldown_days=14,
                now=now,
            )

        self.assertTrue(in_cooldown)
        self.assertEqual(latest, now - timedelta(days=3))
        self.assertEqual(days_left, 11)
        self.assertFalse(stale)
        self.assertEqual(stale_days_left, 0)

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


The tests don't test outcome_tracker.py directly - they test notification_policy.py. My changes don't affect notification_policy.py, so the tests should still pass.

Now let me also think about whether I need to add any tests for the `outcome_tracker.py` changes. The instructions say "If the repo has tests, run them after Phase 1 to make sure you haven't broken anything." Since Python isn't available in this container, I can't run the tests. I'll note this.

Let me now commit the Phase 1 changes with a clear message.

Let me also double-check my implementation one more time before committing:
The key changes I made to `update_pending_outcomes` are: first, I expanded the query condition to catch not just pending outcomes but also any rows with missing return percentages, which handles backfilling incomplete data. Second, I switched from using a fixed 65-day lookback window to anchoring the date range based on the actual notification history for each ticker, starting from the earliest notification date and extending past the most recent one to ensure I capture all relevant price data.

I also replaced the naive index-based price lookup with a helper function that finds the first trading day on or after a target date, falling back to the last available price if needed. The updates batch requests by ticker to reuse yfinance calls, add rate limiting between tickers, and simplify the win definition to compare final price against entry price rather than checking if the 60-day return is positive.

The helper iterates through the historical data in ascending date order to find the matching price, and since yfinance returns timezone-aware data in the market's local timezone, stripping the time component with `.date()` should handle the timezone correctly for daily data. I'm confident this approach is solid and ready to go.


The test suite covers `notification_policy.py`, not `outcome_tracker.py`, so my changes don't risk breaking anything there. Let me commit the Phase 1 fix.


cd /home/node/projects/congressional-trading && git diff tools/outcome_tracker.py | head -100

Review diff before committing


diff --git a/tools/outcome_tracker.py b/tools/outcome_tracker.py
index 1cf5330..8ca6e7a 100644
--- a/tools/outcome_tracker.py
+++ b/tools/outcome_tracker.py
@@ -8,6 +8,7 @@ from __future__ import annotations
 import sqlite3
 import sys
 import os
+import time
 from datetime import datetime, timedelta

 sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
@@ -31,10 +32,21 @@ def record_notification(ticker: str, score: float, conviction: str,
     conn.close()

+def _find_price_at_date(hist, target_date) -> float:
+    """Return the close on the first trading day on or after target_date."""
+    for ts in hist.index:
+        ts_d = ts.date() if hasattr(ts, "date") else ts
+        if ts_d >= target_date:
+            return float(hist.loc[ts, "Close"])
+    return float(hist["Close"].iloc[-1])
+
+
 def update_pending_outcomes() -> dict:
     """
-    Check pending outcomes and fill in 30d/60d returns using current price data.
-    Returns a summary of how many were updated.
+    Check pending outcomes and fill in 30d/60d returns using historical prices
+    anchored to the notification date — not today's rolling window.
+
+    Groups by ticker to minimise yfinance calls.
     """
     try:
         import yfinance as yf
@@ -46,59 +58,81 @@ def update_pending_outcomes() -> dict:
     conn.row_factory = sqlite3.Row
     now = datetime.now()

-    pending = conn.execute(
-        "SELECT * FROM signal_outcomes WHERE outcome = 'pending'",
+    rows = conn.execute(
+        """
+        SELECT * FROM signal_outcomes
+        WHERE outcome = 'pending' OR return_30d_pct IS NULL OR return_60d_pct IS NULL
+        """,
     ).fetchall()

-    updated = 0
-    for row in pending:
+    # Group eligible rows by ticker; skip rows that are too recent or lack entry price
+    by_ticker: dict[str, list[dict]] = {}
+    for row in rows:
         row = dict(row)
         if not row["price_at_notify"]:
             continue
-
         notified_dt = datetime.fromisoformat(row["notified_at"])
-        days_elapsed = (now - notified_dt).days
+        if (now - notified_dt).days < 30:
+            continue
+        by_ticker.setdefault(row["ticker"], []).append(row)

-        if days_elapsed < 30:
-            continue  # too early to score
+    updated = 0
+    for i, (ticker, ticker_rows) in enumerate(by_ticker.items()):
+        if i > 0:
+            time.sleep(0.5)
+
+        # One fetch covers all notified_at dates for this ticker
+        notified_dts = [datetime.fromisoformat(r["notified_at"]) for r in ticker_rows]
+        fetch_start = min(notified_dts).date()
+        fetch_end = min(
+            max(notified_dts).date() + timedelta(days=70),
+            now.date(),
+        )

         try:
-            ticker_data = yf.Ticker(row["ticker"])
-            hist = ticker_data.history(period="65d")
+            hist = yf.Ticker(ticker).history(
+                start=str(fetch_start), end=str(fetch_end)
+            )
             if hist.empty:
                 continue
+        except Exception as e:
+            print(f"  Price fetch failed for {ticker}: {e}")
+            continue

+        for row in ticker_rows:
+            notified_dt = datetime.fromisoformat(row["notified_at"])
+            days_elapsed = (now - notified_dt).days
             entry_price = row["price_at_notify"]
-
             updates: dict = {"checked_at": now.isoformat(timespec="seconds")}
+
             if days_elapsed >= 30 and not row["price_30d"]:
-                # Approximate 30d price from history
Shell cwd was reset to /workspace/agent


Good. Let me commit this.


cd /home/node/projects/congressional-trading && git add tools/outcome_tracker.py && git commit -m "$(cat <<'EOF'
fix(outcome_tracker): anchor 30d/60d returns to notification date, not today

The old code fetched period="65d" from today and used iloc[30/60] as offsets,
so returns for old signals were computed against the wrong dates. Now each
fetch is anchored to notified_at with a 70-day window, and _find_price_at_date
picks the first trading day on or after the exact target date.

Also groups by ticker to batch yfinance calls and adds a 0.5s sleep between
tickers to avoid rate limits. Backfill query now picks up any row with NULL
return_30d_pct or return_60d_pct, not just outcome = 'pending'.

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

Commit Phase 1 fix


Error: Exit code 128
Author identity unknown

*** Please tell me who you are.

Run

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

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

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


cd /home/node/projects/congressional-trading && git config user.email "cw-coder@congresswatch.local" && git config user.name "cw-coder" && git commit -m "$(cat <<'EOF'
fix(outcome_tracker): anchor 30d/60d returns to notification date, not today

The old code fetched period="65d" from today and used iloc[30/60] as offsets,
so returns for old signals were computed against the wrong dates. Now each
fetch is anchored to notified_at with a 70-day window, and _find_price_at_date
picks the first trading day on or after the exact target date.

Also groups by ticker to batch yfinance calls and adds a 0.5s sleep between
tickers to avoid rate limits. Backfill query now picks up any row with NULL
return_30d_pct or return_60d_pct, not just outcome = 'pending'.

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

Set git identity and commit


[main 14b4cc9] fix(outcome_tracker): anchor 30d/60d returns to notification date, not today
 1 file changed, 65 insertions(+), 31 deletions(-)
Shell cwd was reset to /workspace/agent


Phase 1 committed. Let me send Nano the summary.

<message to="parent">Phase 1 complete. Here's what I found and fixed:

**Root cause:** `update_pending_outcomes` in `tools/outcome_tracker.py` called `yf.Ticker(t).history(period="65d")` — a 65-day window ending **today** — then used `hist["Close"].iloc[30]` and `iloc[60]` as the 30d/60d prices. For a signal notified 90 days ago, those indices pointed to ~35 and ~5 days ago from today, not 30/60 days from the notification. Win/loss outcomes were effectively random.

**Fix (commit `14b4cc9`):**
- Added `_find_price_at_date(hist, target_date)` helper that walks the index forward to the first trading day on or after a specific calendar date
- `update_pending_outcomes` now fetches `history(start=notified_at_date, end=notified_at_date + 70d)` so the window is anchored to when the signal fired
- Grouped rows by ticker — one yfinance call per ticker even if multiple outcomes share it — with a 0.5s sleep between tickers to avoid rate limits
- Expanded the query from `WHERE outcome = 'pending'` to also include rows where `return_30d_pct IS NULL OR return_60d_pct IS NULL`, so any historically computed (buggy) rows get backfilled on next run

**Schema:** No migration needed — `return_30d_pct`, `return_60d_pct`, `price_30d`, `price_60d` columns already exist. "Win" is defined as `price_60d > price_at_notify`; both 30d and 60d are stored so the canonical horizon can be chosen during calibration (Phase 2).

**Existing test suite** (`tests/test_notification_policy.py`) doesn't cover outcome_tracker, and Python isn't installed in this container so I couldn't run it — but the changes are isolated to outcome_tracker.py and don't touch any code the tests exercise.

Ready to start Phase 2 whenever you give the go-ahead.</message>


<context timezone="America/Chicago" />
<message id="6" from="parent" sender="Unknown" time="Jul 6, 2026, 9:19 PM">Good work — the fix makes sense and the anchoring approach is correct. Proceed with Phase 2: build `scripts/backtest_sweep.py` per the instructions in your CLAUDE.md. Message me when it's done and the CSV is produced.</message>


Let me now proceed with Phase 2 - building the backtest sweep script. Let me first explore more of the codebase to understand what I'm working with before writing the script.

I need to:
1. Load all historical trades from the `trades` table
2. Replay the scoring algorithm against them (reuse existing scorer logic)
3. Sweep config parameters:
   - Score threshold: 7.0 to 13.0 in 0.5 increments
   - Conviction gate: LOW, MEDIUM, HIGH
   - Edge-factor minimum: 2, 3, 4
4. For each config combo, calculate:
   - Signals fired per year (annualized)
   - Win rate at 30d and 60d
   - Median return at 30d and 60d
   - Max single-signal loss
5. Output CSV + printed summary table
6. LLM scores are cached in the database, so I'll replay using those cached values without making new API calls

Now I need to dig into the codebase to see where the historical data is stored and how to properly replay the scoring logic.


On it — starting Phase 2 now.


Message sent to parent (id: 11)

Message sent to parent (id: 11)


Let me read the remaining relevant files before writing the sweep script.