I need to implement a backtest sweep script for a congressional trading signal scoring system. The repo is at /workspace/extra/projects/congressional-trading/.

Please read and summarize for me:
1. The main scoring algorithm — read `scorer.py` or wherever the 0-13 score components are computed
2. The `signal_outcomes` table structure — what columns exist, especially any 30d/60d return columns
3. `tools/backtest.py` — the existing backtest tool (full read, it's the closest to what I need)
4. `config.py` — what constants are defined (thresholds, conviction gates, etc.)
5. The `trades` table schema — from any schema file or CREATE TABLE in the codebase

I specifically need to know:
- What is the `conviction_gate` concept and what values it takes (LOW/MEDIUM/HIGH)?
- What is the `edge_factor` and what range it takes?
- What columns does `signal_outcomes` have (especially return_30d, return_60d)?
- How does the existing backtest replay scoring — does it call the scoring functions directly or work from stored data?

Be thorough — read the actual files so I have the real field names and logic before I write code.


I'll systematically explore the congressional trading repository to understand the scoring algorithm, table structures, and existing backtest tool.


Bash

find /workspace/extra/projects/congressional-trading -type f -name "*.py" | head -20


Now let me read the key files:


1	import os
2	from dotenv import load_dotenv
3	load_dotenv()
4
5	def _env_float(name: str, default: float) -> float:
6	    return float(os.getenv(name, default))
7
8	def _env_int(name: str, default: int) -> int:
9	    return int(os.getenv(name, default))
10
11	# --- Database ---
12	DB_PATH = os.path.join(os.path.dirname(__file__), "data", "congress_trades.db")
13
14	# --- Ingest ---
15	LOOKBACK_DAYS = 30          # how many days of trade history to fetch on each run
16	PAGE_SIZE = 100             # max records per page from Capitol Trades
17
18	# --- Scoring ---
19	SCORE_RESEARCH_THRESHOLD = 5.0   # min score to trigger LLM research
20	SCORE_NOTIFY_THRESHOLD = 9.0     # min score to send ntfy notification
21	DEFAULT_SCORE_WEIGHTS = {
22	    "committee": 1.0, "track_record": 1.0, "cluster": 1.0,
23	    "options": 1.0, "size": 1.0, "small_cap": 1.0,
24	    "sell_pressure": 1.0, "strategic": 1.0, "disruption": 1.0,
25	}
26	BACKTEST_MIN_ALERTS_PER_MONTH = _env_float("BACKTEST_MIN_ALERTS_PER_MONTH", 2.0)
27
28	# --- Timeliness multipliers applied to score at notify-decision time only ---
29	# A "late" signal's stored score is unchanged; the multiplier only gates notifications.
30	TIMELINESS_MULTIPLIERS = {"fresh": 1.0, "watch": 0.85, "late": 0.70}
31	FRESHNESS_MAX_HOURS = _env_int("FRESHNESS_MAX_HOURS", 48)
32	WATCH_MAX_DAYS = _env_int("WATCH_MAX_DAYS", 7)
33	PRICED_IN_RUNUP_PCT = _env_float("PRICED_IN_RUNUP_PCT", 15.0)
34	MIN_AVG_DOLLAR_VOLUME = _env_float("MIN_AVG_DOLLAR_VOLUME", 2_000_000)
35	EARNINGS_BLACKOUT_DAYS = _env_int("EARNINGS_BLACKOUT_DAYS", 2)
36	RESEARCH_MIN_CITATIONS = _env_int("RESEARCH_MIN_CITATIONS", 2)
37	REQUIRE_VERIFIER_CONCUR = os.getenv("REQUIRE_VERIFIER_CONCUR", "true").lower() in ("1", "true", "yes")
38	TUNER_MIN_NEW_OUTCOMES = _env_int("TUNER_MIN_NEW_OUTCOMES", 8)
39	TUNER_MAX_STEP_PCT = _env_float("TUNER_MAX_STEP_PCT", 10.0)
40	BREAKER_MIN_WIN_RATE = _env_float("BREAKER_MIN_WIN_RATE", 0.40)
41	TUNER_MIN_IMPROVEMENT_PCT = _env_float("TUNER_MIN_IMPROVEMENT_PCT", 2.0)
42
43	# --- Win rate ---
44	WIN_RATE_MIN_TRADES = 3          # minimum buy trades with price data before using actual rate
45
46	# --- Deep Research ---
47	# All signals above SCORE_NOTIFY_THRESHOLD are eligible for deep research.
48	# Each run processes up to TOP_N of them, prioritising never-researched tickers
49	# then stalest-first, skipping any researched within DECAY_DAYS.
50	# The decay window (not TOP_N) is the primary cost control.
51	DEEP_RESEARCH_TOP_N = 10            # max deep-research calls per run
52	DEEP_RESEARCH_DECAY_DAYS = 7        # days before re-researching the same ticker
53
54	# --- Notification quality gates ---
55	# Cap how many push alerts fire per pipeline run regardless of score.
56	# Push should be reserved for the single best actionable idea; the report carries the rest.
57	MAX_NOTIFICATIONS_PER_RUN = 1
58
59	# Do not re-alert the same ticker during this rolling window unless --force is used.
60	NOTIFY_TICKER_COOLDOWN_DAYS = 14
61
62	# When True, only signals where deep research recommends "BUY NOW" or "BUY" get notified.
63	# Set False to notify for MONITOR/AVOID signals too (not recommended — defeats the purpose).
64	NOTIFY_REQUIRE_BUY_ACTION = True
65
66	# Minimum conviction level to trigger notification. Options: "LOW", "MEDIUM", "HIGH"
67	NOTIFY_MIN_CONVICTION = "HIGH"
68
69	# Minimum number of independent edge factors before a signal can page you.
70	# Factors include multi-member clusters, large disclosed size, options timing,
71	# committee alignment, small/mid-cap scarcity, and strong LLM catalyst scores.
72	NOTIFY_MIN_EDGE_FACTORS = 3
73
74	# Suppress buy notifications when sellers materially offset buyers.
75	NOTIFY_MAX_SELL_PRESSURE_PENALTY = -1.0
76
77	# --- LLM ---
78	LLM_PROVIDER = "anthropic"          # "anthropic" or "google"
79	ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY", "")
80	GOOGLE_API_KEY = os.getenv("GOOGLE_API_KEY", "")
81
82	# --- Predictive Signals ---
83	# Behavioral analysis: predict future trades before disclosure.
84	# Requires CONGRESS_GOV_API_KEY for live bill data (free at api.congress.gov);
85	# works in degraded mode (LLM-only) without it.
86	CONGRESS_GOV_API_KEY = os.getenv("CONGRESS_GOV_API_KEY", "")
87	PREDICTOR_MAX_MEMBERS = 5          # members to analyze per run (cost control)
88	PREDICTOR_DECAY_DAYS  = 7          # skip re-predicting same member within this window
89	PREDICTOR_MIN_TRADES  = 5          # min historical trades before a member is analyzed
90	PREDICTOR_LEGISLATION_DAYS = 45   # how far back to look for recently active bills
91
92	# --- ntfy ---
93	NTFY_BASE_URL = "https://ntfy.sh"
94	NTFY_TOPIC_SIGNALS = "congressWatchSignals"        # trade signal notifications
95	NTFY_TOPIC_STATUS = "claudeCodeWaitingOnYou"       # system status / needs-input alerts
96


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


Now let me read the scorer.py file and check for schema/trades table definitions:


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


grep -r "CREATE TABLE" /workspace/extra/projects/congressional-trading --include="*.py" | head -20


/workspace/extra/projects/congressional-trading/tests/test_outcome_tracker.py:            sqlite3.connect(path).execute("CREATE TABLE signal_outcomes (notified_at TEXT)").close()
/workspace/extra/projects/congressional-trading/tests/test_outcome_tracker.py:                CREATE TABLE signal_outcomes (
/workspace/extra/projects/congressional-trading/tests/test_notification_policy.py:                CREATE TABLE signal_outcomes (
/workspace/extra/projects/congressional-trading/tests/test_win_rate.py:        CREATE TABLE members (
/workspace/extra/projects/congressional-trading/tests/test_win_rate.py:        CREATE TABLE trades (
/workspace/extra/projects/congressional-trading/tests/test_win_rate.py:        CREATE TABLE signals (
/workspace/extra/projects/congressional-trading/tests/test_win_rate.py:        CREATE TABLE price_history (
/workspace/extra/projects/congressional-trading/.venv/lib/python3.9/site-packages/playhouse/migrate.py:        # Parse out the `CREATE TABLE` and column list portions of the query.
/workspace/extra/projects/congressional-trading/.venv/lib/python3.9/site-packages/playhouse/migrate.py:        # Update the name of the new CREATE TABLE query.
/workspace/extra/projects/congressional-trading/.venv/lib/python3.9/site-packages/playhouse/sqlite_ext.py:    CREATE TABLE tbl(a INTEGER PRIMARY KEY, b);
/workspace/extra/projects/congressional-trading/.venv/lib/python3.9/site-packages/peewee.py:        ctx.literal('CREATE TEMPORARY TABLE ' if is_temp else 'CREATE TABLE ')
/workspace/extra/projects/congressional-trading/.venv/lib/python3.9/site-packages/peewee.py:                        if meta.get('temporary') else 'CREATE TABLE '))
/workspace/extra/projects/congressional-trading/.venv/lib/python3.9/site-packages/pandas/io/sql.py:        structure of a DataFrame.  The first entry will be a CREATE TABLE
/workspace/extra/projects/congressional-trading/.venv/lib/python3.9/site-packages/pandas/io/sql.py:            "CREATE TABLE "
/workspace/extra/projects/congressional-trading/.venv/lib/python3.9/site-packages/pandas/tests/io/test_sql.py:    stmt = """CREATE TABLE iris (
/workspace/extra/projects/congressional-trading/.venv/lib/python3.9/site-packages/pandas/tests/io/test_sql.py:    stmt = """CREATE TABLE iris (
/workspace/extra/projects/congressional-trading/.venv/lib/python3.9/site-packages/pandas/tests/io/test_sql.py:    stmt = """CREATE TABLE types (
/workspace/extra/projects/congressional-trading/.venv/lib/python3.9/site-packages/pandas/tests/io/test_sql.py:        stmt = """CREATE TABLE types (
/workspace/extra/projects/congressional-trading/.venv/lib/python3.9/site-packages/pandas/tests/io/test_sql.py:    CREATE TABLE test_insert_conflict (
/workspace/extra/projects/congressional-trading/.venv/lib/python3.9/site-packages/pandas/tests/io/test_sql.py:    CREATE TABLE test_insert_conflict (


Now let me read the schema file and check for outcome tracker:


1	CREATE TABLE IF NOT EXISTS members (
2	    bioguide_id     TEXT PRIMARY KEY,
3	    first_name      TEXT,
4	    last_name       TEXT,
5	    nickname        TEXT,
6	    party           TEXT,
7	    chamber         TEXT,
8	    state_id        TEXT,
9	    gender          TEXT,
10	    dob             TEXT,
11	    committees      TEXT,   -- JSON array, refreshed weekly
12	    trade_count     INTEGER DEFAULT 0,
13	    win_rate        REAL,   -- nullable until calculable
14	    updated_at      TEXT
15	);
16
17	CREATE TABLE IF NOT EXISTS trades (
18	    tx_id           INTEGER PRIMARY KEY,
19	    member_id       TEXT NOT NULL,
20	    issuer_id       INTEGER,
21	    issuer_name     TEXT,
22	    issuer_ticker   TEXT,   -- clean ticker, e.g. "NVDA"
23	    issuer_sector   TEXT,
24	    issuer_country  TEXT,
25	    chamber         TEXT,
26	    tx_date         TEXT,   -- YYYY-MM-DD, when trade executed
27	    pub_date        TEXT,   -- when disclosure was published
28	    reporting_gap   INTEGER, -- days between tx_date and pub_date
29	    tx_type         TEXT,   -- buy / sell / exchange
30	    tx_type_extended TEXT,
31	    owner           TEXT,   -- self / spouse / not-disclosed / etc
32	    price_at_trade  REAL,
33	    value_estimate  INTEGER, -- midpoint dollar estimate from disclosure
34	    amount_tier     INTEGER, -- 1-6 derived from value_estimate
35	    is_option       INTEGER DEFAULT 0,  -- 0/1
36	    comment         TEXT,
37	    source          TEXT DEFAULT 'capitol_trades',
38	    first_ingested_at TEXT DEFAULT (datetime('now')),
39	    created_at      TEXT DEFAULT (datetime('now'))
40	);
41
42	CREATE TABLE IF NOT EXISTS signals (
43	    id                  INTEGER PRIMARY KEY AUTOINCREMENT,
44	    ticker              TEXT NOT NULL UNIQUE,
45	    first_seen          TEXT,
46	    last_seen           TEXT,
47	    first_tx_date       TEXT,   -- earliest trade exec date in current scoring window
48	    member_count        INTEGER DEFAULT 1,
49	    committee_score     REAL DEFAULT 0,
50	    track_record_score  REAL DEFAULT 1,
51	    cluster_score       REAL DEFAULT 0,
52	    options_bonus       REAL DEFAULT 0,
53	    size_score          REAL DEFAULT 0,
54	    total_score         REAL DEFAULT 0,
55	    research_summary    TEXT,
56	    timeliness          TEXT,   -- fresh / watch / late
57	    price_at_tx         REAL,   -- close on first_tx_date (member's entry)
58	    price_at_pub        REAL,   -- close on first_seen (our actionable entry)
59	    price_current       REAL,   -- yesterday's close
60	    return_from_tx_pct  REAL,   -- % gain/loss since member's trade
61	    return_from_pub_pct REAL,   -- % gain/loss since first disclosure
62	    price_fetched_at    TEXT,
63	    market_cap          REAL,   -- cached from yfinance
64	    small_cap_score     REAL DEFAULT 0,  -- score component: bonus for smaller caps
65	    sell_pressure_score REAL DEFAULT 0,  -- score component: penalty when sellers ≥ buyers
66	    runup_pct           REAL,
67	    notified_at         TEXT,
68	    notified            INTEGER DEFAULT 0,
69	    updated_at          TEXT DEFAULT (datetime('now'))
70	);
71
72	CREATE TABLE IF NOT EXISTS research_cache (
73	    ticker              TEXT PRIMARY KEY,
74	    cached_at           TEXT,
75	    deep_researched_at  TEXT,   -- set when deep prompt (risk_factors + recommended_action) was run
76	    company_summary     TEXT,
77	    recent_news         TEXT,
78	    catalyst_hypothesis TEXT,
79	    timeliness_assessment TEXT,
80	    conviction          TEXT,   -- LOW / MEDIUM / HIGH
81	    conviction_rationale TEXT,
82	    risk_factors        TEXT,
83	    recommended_action  TEXT,
84	    sources             TEXT,
85	    verifier_verdict    TEXT,
86	    verifier_rationale  TEXT,
87	    checklist_json      TEXT,
88	    raw_response        TEXT
89	);
90
91	CREATE TABLE IF NOT EXISTS signal_outcomes (
92	    id              INTEGER PRIMARY KEY AUTOINCREMENT,
93	    ticker          TEXT NOT NULL,
94	    notified_at     TEXT NOT NULL,
95	    score_at_notify REAL,
96	    conviction      TEXT,
97	    recommended_action TEXT,
98	    price_at_notify REAL,
99	    price_30d       REAL,
100	    price_60d       REAL,
101	    return_30d_pct  REAL,
102	    return_60d_pct  REAL,
103	    fired          INTEGER NOT NULL DEFAULT 1,
104	    suppression_reason TEXT,
105	    spy_price_at_notify REAL,
106	    spy_price_30d  REAL,
107	    spy_price_60d  REAL,
108	    return_30d_excess_pct REAL,
109	    return_60d_excess_pct REAL,
110	    verifier_verdict TEXT,
111	    citation_backed INTEGER DEFAULT 0,
112	    config_version_id INTEGER,
113	    outcome         TEXT,   -- "win" / "loss" / "pending" / "unmeasurable"
114	    checked_at      TEXT,
115	    created_at      TEXT DEFAULT (datetime('now'))
116	);
117
118	CREATE TABLE IF NOT EXISTS predicted_signals (
119	    id                      INTEGER PRIMARY KEY AUTOINCREMENT,
120	    member_id               TEXT NOT NULL,
121	    member_name             TEXT,
122	    ticker                  TEXT NOT NULL,
123	    company_name            TEXT,
124	    sector                  TEXT,
125	    predicted_action        TEXT,   -- buy / sell
126	    confidence              TEXT,   -- LOW / MEDIUM / HIGH
127	    legislation_catalyst    TEXT,
128	    historical_precedent    TEXT,
129	    rationale               TEXT,
130	    behavioral_profile      TEXT,   -- member's trading style summary (one per member)
131	    generated_at            TEXT,
132	    confirmed               INTEGER DEFAULT 0,  -- 1 when a matching real disclosure appears
133	    confirmed_at            TEXT,
134	    UNIQUE(member_id, ticker)
135	);
136
137	CREATE INDEX IF NOT EXISTS idx_trades_member ON trades(member_id);
138	CREATE INDEX IF NOT EXISTS idx_trades_ticker ON trades(issuer_ticker);
139	CREATE INDEX IF NOT EXISTS idx_trades_pub_date ON trades(pub_date);
140	CREATE INDEX IF NOT EXISTS idx_trades_tx_date ON trades(tx_date);
141	CREATE INDEX IF NOT EXISTS idx_outcomes_ticker ON signal_outcomes(ticker);
142	CREATE INDEX IF NOT EXISTS idx_outcomes_notified_at ON signal_outcomes(notified_at);
143	CREATE UNIQUE INDEX IF NOT EXISTS idx_outcomes_candidate ON signal_outcomes(ticker, notified_at);
144	CREATE INDEX IF NOT EXISTS idx_predicted_member ON predicted_signals(member_id);
145	CREATE INDEX IF NOT EXISTS idx_predicted_ticker ON predicted_signals(ticker);
146
147	CREATE TABLE IF NOT EXISTS ticker_metadata (
148	    ticker          TEXT PRIMARY KEY,
149	    market_cap      REAL,
150	    last_price      REAL,
151	    is_etf          INTEGER DEFAULT 0,   -- 1 if yfinance quoteType is ETF/MUTUALFUND
152	    is_valid        INTEGER DEFAULT 1,   -- 0 if yfinance returns no data (invalid/delisted)
153	    pe_ratio        REAL,                -- trailing P/E
154	    analyst_rating  TEXT,                -- strong buy / buy / hold / sell / strong sell
155	    price_vs_50d_pct REAL,              -- % current price above/below 50-day MA
156	    fetched_at      TEXT
157	);
158
159	CREATE INDEX IF NOT EXISTS idx_ticker_meta ON ticker_metadata(ticker);
160
161	CREATE TABLE IF NOT EXISTS price_history (
162	    ticker TEXT NOT NULL,
163	    date TEXT NOT NULL,
164	    close REAL NOT NULL,
165	    PRIMARY KEY (ticker, date)
166	);
167
168	CREATE TABLE IF NOT EXISTS config_versions (
169	    id INTEGER PRIMARY KEY AUTOINCREMENT,
170	    weights_json TEXT NOT NULL,
171	    backtest_metrics_json TEXT NOT NULL,
172	    promoted_at TEXT NOT NULL DEFAULT (datetime('now')),
173	    promoted_by TEXT NOT NULL CHECK(promoted_by IN ('human', 'tuner')),
174	    active INTEGER NOT NULL DEFAULT 0,
175	    is_last_known_good INTEGER NOT NULL DEFAULT 0
176	);
177	CREATE INDEX IF NOT EXISTS idx_config_active ON config_versions(active);
178
179	CREATE TABLE IF NOT EXISTS system_state (
180	    key TEXT PRIMARY KEY,
181	    value TEXT NOT NULL,
182	    updated_at TEXT NOT NULL DEFAULT (datetime('now'))
183	);
184	CREATE TABLE IF NOT EXISTS automation_events (
185	    id INTEGER PRIMARY KEY AUTOINCREMENT,
186	    event_type TEXT NOT NULL,
187	    details_json TEXT NOT NULL,
188	    created_at TEXT NOT NULL DEFAULT (datetime('now'))
189	);
190


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


grep -r "conviction_gate\|edge_factor" /workspace/extra/projects/congressional-trading --include="*.py" -n


/workspace/extra/projects/congressional-trading/run_daily.py:37:    from analysis.notification_policy import edge_factors
/workspace/extra/projects/congressional-trading/run_daily.py:45:    factors = ", ".join(edge_factors(sig, research))
/workspace/extra/projects/congressional-trading/analysis/notification_policy.py:28:    edge_factors: tuple[str, ...] = ()
/workspace/extra/projects/congressional-trading/analysis/notification_policy.py:50:def edge_factors(sig: dict, research: dict | None) -> tuple[str, ...]:
/workspace/extra/projects/congressional-trading/analysis/notification_policy.py:98:    min_edge_factors: int = NOTIFY_MIN_EDGE_FACTORS,
/workspace/extra/projects/congressional-trading/analysis/notification_policy.py:135:    factors = edge_factors(sig, research)
/workspace/extra/projects/congressional-trading/analysis/notification_policy.py:136:    if len(factors) < min_edge_factors:
/workspace/extra/projects/congressional-trading/analysis/notification_policy.py:139:            f"only {len(factors)}/{min_edge_factors} edge factors: {', '.join(factors) or 'none'}",
/workspace/extra/projects/congressional-trading/tests/test_notification_policy.py:10:    edge_factors,
/workspace/extra/projects/congressional-trading/tests/test_notification_policy.py:52:        self.assertGreaterEqual(len(decision.edge_factors), 3)
/workspace/extra/projects/congressional-trading/tests/test_notification_policy.py:53:        self.assertIn("multi-member cluster", decision.edge_factors)
/workspace/extra/projects/congressional-trading/tests/test_notification_policy.py:54:        self.assertIn("direct committee alignment", decision.edge_factors)
/workspace/extra/projects/congressional-trading/tests/test_notification_policy.py:69:    def test_rejects_high_score_without_enough_edge_factors(self):
/workspace/extra/projects/congressional-trading/tests/test_notification_policy.py:85:        self.assertEqual(edge_factors(sig, research), ())


Now let me read the notification_policy file to understand conviction_gate and edge_factors:


1	"""Notification gating for high-signal trade ideas."""
2
3	from __future__ import annotations
4
5	from dataclasses import dataclass
6	from datetime import datetime, timedelta
7	import math
8	import re
9	import sqlite3
10
11	from config import (
12	    DB_PATH,
13	    NOTIFY_MAX_SELL_PRESSURE_PENALTY,
14	    NOTIFY_MIN_CONVICTION,
15	    NOTIFY_MIN_EDGE_FACTORS,
16	    NOTIFY_REQUIRE_BUY_ACTION,
17	    NOTIFY_TICKER_COOLDOWN_DAYS,
18	    PRICED_IN_RUNUP_PCT,
19	    REQUIRE_VERIFIER_CONCUR,
20	    SCORE_NOTIFY_THRESHOLD,
21	)
22
23
24	@dataclass(frozen=True)
25	class NotificationDecision:
26	    allowed: bool
27	    reason: str
28	    edge_factors: tuple[str, ...] = ()
29
30
31	_CONVICTION_ORDER = {"LOW": 0, "MEDIUM": 1, "HIGH": 2}
32	_ACTION_BLOCKLIST = (
33	    "AVOID",
34	    "HOLD",
35	    "MONITOR",
36	    "PASS",
37	    "SELL",
38	    "TRIM",
39	    "WAIT",
40	)
41
42
43	def _to_float(value, default: float = 0.0) -> float:
44	    try:
45	        return float(value)
46	    except (TypeError, ValueError):
47	        return default
48
49
50	def edge_factors(sig: dict, research: dict | None) -> tuple[str, ...]:
51	    """Return the independent evidence factors that make a signal notification-worthy."""
52	    factors: list[str] = []
53
54	    if int(sig.get("member_count") or 0) >= 2:
55	        factors.append("multi-member cluster")
56	    if int(sig.get("max_tier") or 0) >= 4:
57	        factors.append("large disclosed size")
58	    if sig.get("has_options"):
59	        factors.append("options timing")
60	    if _to_float(sig.get("committee_score")) >= 2.0:
61	        factors.append("direct committee alignment")
62	    if _to_float(sig.get("small_cap_score")) >= 2.0:
63	        factors.append("undercovered small/mid cap")
64
65	    strategic = _to_float((research or {}).get("strategic_score", sig.get("strategic_score")))
66	    disruption = _to_float((research or {}).get("disruption_score", sig.get("disruption_score")))
67	    if strategic + disruption >= 4.0:
68	        factors.append("strong LLM catalyst")
69
70	    return tuple(factors)
71
72
73	def is_buy_recommendation(action: str | None) -> bool:
74	    """Accept explicit buy/add actions, not incidental mentions of the word buy."""
75	    if not action:
76	        return False
77
78	    text = re.sub(r"\s+", " ", action.strip().upper())
79	    if not text:
80	        return False
81	    if "DO NOT BUY" in text or "NOT BUY" in text:
82	        return False
83	    if text.startswith(_ACTION_BLOCKLIST):
84	        return False
85
86	    first_word = re.split(r"[\s:;-]+", text, maxsplit=1)[0]
87	    return first_word in {"BUY", "ADD", "ACCUMULATE", "INITIATE"}
88
89
90	def evaluate_notification_candidate(
91	    sig: dict,
92	    research: dict | None,
93	    adjusted_score: float,
94	    *,
95	    notify_threshold: float = SCORE_NOTIFY_THRESHOLD,
96	    min_conviction: str = NOTIFY_MIN_CONVICTION,
97	    require_buy_action: bool = NOTIFY_REQUIRE_BUY_ACTION,
98	    min_edge_factors: int = NOTIFY_MIN_EDGE_FACTORS,
99	    max_sell_pressure_penalty: float = NOTIFY_MAX_SELL_PRESSURE_PENALTY,
100	) -> NotificationDecision:
101	    """Evaluate all quality gates except duplicate/cooldown state."""
102	    ticker = sig.get("ticker", "?")
103
104	    if adjusted_score < notify_threshold:
105	        return NotificationDecision(False, f"adjusted score {adjusted_score:.1f} below {notify_threshold:.1f}")
106	    if _to_float(sig.get("runup_pct"), float("-inf")) > PRICED_IN_RUNUP_PCT:
107	        return NotificationDecision(False, f"priced_in: excess run-up {sig['runup_pct']:+.1f}%")
108	    if not research:
109	        return NotificationDecision(False, "no research")
110	    if not research.get("risk_factors"):
111	        return NotificationDecision(False, "no deep research")
112	    checklist = research.get("checklist")
113	    if checklist is None and research.get("checklist_json"):
114	        import json
115	        try: checklist = json.loads(research["checklist_json"])
116	        except (TypeError, ValueError): checklist = None
117	    if not checklist or not checklist.get("passed"):
118	        return NotificationDecision(False, "research_incomplete")
119	    if REQUIRE_VERIFIER_CONCUR and research.get("verifier_verdict") != "CONCUR":
120	        return NotificationDecision(False, "verifier_reject")
121
122	    conviction = (research.get("conviction") or "LOW").upper()
123	    min_order = _CONVICTION_ORDER.get(min_conviction.upper(), 1)
124	    if _CONVICTION_ORDER.get(conviction, 0) < min_order:
125	        return NotificationDecision(False, f"conviction={conviction} below {min_conviction.upper()}")
126
127	    action = research.get("recommended_action") or ""
128	    if require_buy_action and not is_buy_recommendation(action):
129	        return NotificationDecision(False, f"recommended_action is not an explicit buy/add: {action[:80]}")
130
131	    sell_pressure = _to_float(sig.get("sell_pressure_score"))
132	    if sell_pressure < max_sell_pressure_penalty:
133	        return NotificationDecision(False, f"sell pressure {sell_pressure:+.1f} conflicts with buy signal")
134
135	    factors = edge_factors(sig, research)
136	    if len(factors) < min_edge_factors:
137	        return NotificationDecision(
138	            False,
139	            f"only {len(factors)}/{min_edge_factors} edge factors: {', '.join(factors) or 'none'}",
140	            factors,
141	        )
142
143	    return NotificationDecision(True, f"{ticker}: {len(factors)} edge factors", factors)
144
145
146	def _parse_dt(value: str | None) -> datetime | None:
147	    if not value:
148	        return None
149	    try:
150	        return datetime.fromisoformat(value)
151	    except ValueError:
152	        try:
153	            return datetime.fromisoformat(value.replace(" ", "T"))
154	        except ValueError:
155	            return None
156
157
158	def recent_ticker_notification(
159	    ticker: str,
160	    *,
161	    db_path: str = DB_PATH,
162	    cooldown_days: int = NOTIFY_TICKER_COOLDOWN_DAYS,
163	    now: datetime | None = None,
164	) -> tuple[bool, datetime | None, int]:
165	    """
166	    Return whether ticker is inside the rolling cooldown, plus latest notify time
167	    and whole days remaining.
168	    """
169	    now = now or datetime.now()
170	    cutoff = now - timedelta(days=cooldown_days)
171
172	    conn = sqlite3.connect(db_path)
173	    rows = conn.execute(
174	        """
175	        SELECT notified_at
176	        FROM signal_outcomes
177	        WHERE ticker = ?
178	        ORDER BY notified_at DESC
179	        LIMIT 10
180	        """,
181	        (ticker,),
182	    ).fetchall()
183	    conn.close()
184
185	    latest = None
186	    for row in rows:
187	        parsed = _parse_dt(row[0])
188	        if parsed and (latest is None or parsed > latest):
189	            latest = parsed
190
191	    if latest is None or latest < cutoff:
192	        return False, latest, 0
193
194	    remaining_seconds = (latest + timedelta(days=cooldown_days) - now).total_seconds()
195	    days_remaining = max(1, math.ceil(remaining_seconds / 86400))
196	    return True, latest, days_remaining
197


1	"""Versioned scoring-weight access and promotion."""
2	import json
3	import sqlite3
4	from config import DB_PATH, DEFAULT_SCORE_WEIGHTS
5
6	def active_weights(db_path: str = DB_PATH) -> dict[str, float]:
7	    try:
8	        with sqlite3.connect(db_path) as conn:
9	            row = conn.execute("SELECT weights_json FROM config_versions WHERE active=1 ORDER BY id DESC LIMIT 1").fetchone()
10	        return {**DEFAULT_SCORE_WEIGHTS, **(json.loads(row[0]) if row else {})}
11	    except sqlite3.OperationalError:
12	        return dict(DEFAULT_SCORE_WEIGHTS)
13
14	def weighted_total(components: dict[str, float], weights: dict[str, float]) -> float:
15	    return sum(float(components.get(key, 0)) * float(weights.get(key, 1)) for key in DEFAULT_SCORE_WEIGHTS)
16
17	def promote(weights: dict, metrics: dict, *, promoted_by: str = "human", db_path: str = DB_PATH) -> int:
18	    if promoted_by not in ("human", "tuner"): raise ValueError("invalid promoter")
19	    with sqlite3.connect(db_path) as conn:
20	        conn.execute("UPDATE config_versions SET active=0")
21	        cur = conn.execute("INSERT INTO config_versions(weights_json, backtest_metrics_json, promoted_by, active) VALUES (?, ?, ?, 1)",
22	                           (json.dumps(weights, sort_keys=True), json.dumps(metrics, sort_keys=True), promoted_by))
23	        return cur.lastrowid
24


Perfect! Now let me check if there's any "conviction_gate" defined elsewhere and verify the backtest logic more carefully:


grep -r "conviction" /workspace/extra/projects/congressional-trading/analysis --include="*.py" -n | head -30


/workspace/extra/projects/congressional-trading/analysis/notification_policy.py:96:    min_conviction: str = NOTIFY_MIN_CONVICTION,
/workspace/extra/projects/congressional-trading/analysis/notification_policy.py:122:    conviction = (research.get("conviction") or "LOW").upper()
/workspace/extra/projects/congressional-trading/analysis/notification_policy.py:123:    min_order = _CONVICTION_ORDER.get(min_conviction.upper(), 1)
/workspace/extra/projects/congressional-trading/analysis/notification_policy.py:124:    if _CONVICTION_ORDER.get(conviction, 0) < min_order:
/workspace/extra/projects/congressional-trading/analysis/notification_policy.py:125:        return NotificationDecision(False, f"conviction={conviction} below {min_conviction.upper()}")
/workspace/extra/projects/congressional-trading/analysis/researcher.py:66:  "conviction": "LOW|MEDIUM|HIGH",
/workspace/extra/projects/congressional-trading/analysis/researcher.py:67:  "conviction_rationale": "One sentence explaining the conviction level.",
/workspace/extra/projects/congressional-trading/analysis/researcher.py:92:This signal scored above the high-conviction notify threshold. Please provide \
/workspace/extra/projects/congressional-trading/analysis/researcher.py:101:  "conviction": "LOW|MEDIUM|HIGH",
/workspace/extra/projects/congressional-trading/analysis/researcher.py:102:  "conviction_rationale": "One sentence explaining the conviction level.",
/workspace/extra/projects/congressional-trading/analysis/researcher.py:359:            catalyst_hypothesis, timeliness_assessment, conviction, conviction_rationale,
/workspace/extra/projects/congressional-trading/analysis/researcher.py:370:            conviction            = excluded.conviction,
/workspace/extra/projects/congressional-trading/analysis/researcher.py:371:            conviction_rationale  = excluded.conviction_rationale,
/workspace/extra/projects/congressional-trading/analysis/researcher.py:390:            result.get("timeliness", "") + " — " + result.get("conviction_rationale", ""),
/workspace/extra/projects/congressional-trading/analysis/researcher.py:391:            result.get("conviction", ""),
/workspace/extra/projects/congressional-trading/analysis/researcher.py:392:            result.get("conviction_rationale", ""),
/workspace/extra/projects/congressional-trading/analysis/researcher.py:611:            print(f"\n{sig['ticker']} (score={sig['total_score']}) — {res.get('conviction','?')} conviction")
/workspace/extra/projects/congressional-trading/analysis/scorer.py:121:    ratio < 1.0  (e.g. 2 of 3): -2.5 — mixed conviction


Let me check if there's an actual "conviction_gate" term used or if the user is referring to something else:


grep -r "gate" /workspace/extra/projects/congressional-trading --include="*.py" -i | grep -v ".venv" | head -20


/workspace/extra/projects/congressional-trading/tools/generate_report.py:    # Compute which current signals pass all gates (what would notify if run now)
/workspace/extra/projects/congressional-trading/tools/generate_report.py:        p.append('
Would fire on next run (passes all gates)
\n')
/workspace/extra/projects/congressional-trading/tools/generate_report.py:        p.append('
No signals pass all gates right now (need BUY NOW + MEDIUM+ conviction + deep research).
\n')
/workspace/extra/projects/congressional-trading/tools/outcome_tracker.py:    """Record every notify-tier candidate, including candidates suppressed by a gate."""
/workspace/extra/projects/congressional-trading/run_daily.py:    """Apply timeliness multiplier for notify-gate purposes only. Score is not stored."""
/workspace/extra/projects/congressional-trading/run_daily.py:    # 8. Notify — apply timeliness, quality gates, and ticker cooldown before firing
/workspace/extra/projects/congressional-trading/run_daily.py:    notified = skipped_tl = skipped_gate = skipped_cooldown = 0
/workspace/extra/projects/congressional-trading/run_daily.py:                remaining = len(notify_candidates) - notified - skipped_tl - skipped_gate - skipped_cooldown
/workspace/extra/projects/congressional-trading/run_daily.py:            # Timeliness gate
/workspace/extra/projects/congressional-trading/run_daily.py:                skipped_gate += 1
/workspace/extra/projects/congressional-trading/run_daily.py:    # Dense shadow ledger continues in report-only mode so gate recovery stays measurable.
/workspace/extra/projects/congressional-trading/run_daily.py:          + (f", {skipped_gate} filtered by quality gates" if skipped_gate else "")
/workspace/extra/projects/congressional-trading/config.py:# A "late" signal's stored score is unchanged; the multiplier only gates notifications.
/workspace/extra/projects/congressional-trading/config.py:# --- Notification quality gates ---
/workspace/extra/projects/congressional-trading/analysis/predictor.py:    Aggregate predicted_signals by ticker into a ranked consensus view.
/workspace/extra/projects/congressional-trading/analysis/notification_policy.py:    """Evaluate all quality gates except duplicate/cooldown state."""
/workspace/extra/projects/congressional-trading/analysis/scorer.py:for each ticker reflecting the aggregate congressional activity.


Now let me check the run_daily.py to see how scoring and gating work:


1	"""
2	CongressWatch daily runner.
3
4	Pipeline: ingest → committees → score → prices → win_rates → research → report → notify
5
6	Usage:
7	  python run_daily.py              # full run
8	  python run_daily.py --dry-run    # ingest + score + report, no LLM calls, no notifications
9	  python run_daily.py --no-ingest  # skip ingest (re-score + research + report only)
10	  python run_daily.py --force      # re-notify even if already notified today
11	"""
12
13	from __future__ import annotations
14	import argparse
15	import sqlite3
16	import sys
17	import os
18	from datetime import datetime, date
19
20	sys.path.insert(0, os.path.dirname(__file__))
21	from config import (DB_PATH, SCORE_NOTIFY_THRESHOLD, SCORE_RESEARCH_THRESHOLD,
22	                    LOOKBACK_DAYS, TIMELINESS_MULTIPLIERS,
23	                    DEEP_RESEARCH_TOP_N, DEEP_RESEARCH_DECAY_DAYS,
24	                    MAX_NOTIFICATIONS_PER_RUN, NOTIFY_TICKER_COOLDOWN_DAYS,
25	                    PREDICTOR_MAX_MEMBERS, PREDICTOR_DECAY_DAYS)
26
27
28	def _timeliness_adjusted_score(base_score: float, timeliness: str | None) -> float:
29	    """Apply timeliness multiplier for notify-gate purposes only. Score is not stored."""
30	    key = (timeliness or "fresh").split("|")[0].strip().lower()
31	    return base_score * TIMELINESS_MULTIPLIERS.get(key, 1.0)
32
33
34	def _send_signal_notification(sig: dict, research: dict | None, adjusted_score: float):
35	    from notify.ntfy_client import push
36	    from analysis.timeliness import assess_age, age_hours
37	    from analysis.notification_policy import edge_factors
38
39	    ticker = sig["ticker"]
40	    score = sig["total_score"]
41	    buyers = sig["member_count"]
42	    timeliness = assess_age(sig["last_seen"], runup_pct=sig.get("runup_pct"))
43	    conviction = (research.get("conviction") or "?").upper() if research else "?"
44	    hours_since_disclosure = age_hours(sig["last_seen"])
45	    factors = ", ".join(edge_factors(sig, research))
46
47	    tier_labels = {1:"$1K-15K", 2:"$15K-50K", 3:"$50K-100K",
48	                   4:"$100K-250K", 5:"$250K-500K", 6:"$500K+"}
49	    size_label = tier_labels.get(sig.get("max_tier", 0), "unknown")
50	    opt_flag = " | Options: YES" if sig.get("has_options") else ""
51
52	    tl_note = ""
53	    if adjusted_score < score:
54	        tl_note = f" (adj {adjusted_score:.1f} for {timeliness})"
55
56	    action_short = ""
57	    if research and research.get("recommended_action"):
58	        raw_action = research["recommended_action"]
59	        action_short = raw_action.split("—")[0].strip()[:40]
60
61	    summary_line = ""
62	    if research and research.get("company_summary"):
63	        summary_line = "\n\n" + research["company_summary"][:300]
64	    catalyst_line = ""
65	    if research and research.get("catalyst_hypothesis"):
66	        catalyst_line = "\n\nCatalyst: " + research["catalyst_hypothesis"][:200]
67	    source_line = ""
68	    if research and research.get("sources"):
69	        sources = research["sources"]
70	        if isinstance(sources, str):
71	            import json
72	            try: sources = json.loads(sources)
73	            except ValueError: sources = []
74	        source_line = "\nSources: " + ", ".join(s.get("url", "") for s in sources[:2])
75	    risk_line = ""
76	    if research and research.get("risk_factors"):
77	        risk_line = "\nInvalidation/risk: " + research["risk_factors"][:180]
78	    earnings_line = ""
79	    if research and (research.get("checklist") or {}).get("earnings_blackout"):
80	        earnings_line = "\n⚠ Earnings within blackout window"
81	    calibration_line = ""
82	    with sqlite3.connect(DB_PATH) as conn:
83	        calibration = conn.execute(
84	            "SELECT COUNT(*), SUM(CASE WHEN return_30d_excess_pct>0 THEN 1 ELSE 0 END) "
85	            "FROM signal_outcomes WHERE fired=1 AND conviction=? AND return_30d_excess_pct IS NOT NULL",
86	            (conviction,),
87	        ).fetchone()
88	    if calibration and calibration[0]:
89	        calibration_line = f"\n{conviction} alerts: {calibration[1]}/{calibration[0]} positive 30d alpha"
90	    else:
91	        calibration_line = f"\n{conviction} alerts: no matured 30d history yet"
92	    action_line = ""
93	    if action_short:
94	        action_line = f"\n\n→ {action_short}"
95
96	    body = (
97	        f"{sig.get('issuer_name', ticker)} — {buyers} member{'s' if buyers>1 else ''} bought\n"
98	        f"Size: {size_label}{opt_flag}\n"
99	        f"Timeliness: {hours_since_disclosure:.1f}h ({timeliness.upper()}) | Conviction: {conviction} | Action: {action_short}\n"
100	        f"Edge: {factors}"
101	        f"{summary_line}{catalyst_line}{source_line}{risk_line}{earnings_line}{calibration_line}{action_line}"
102	    )
103
104	    push(
105	        title=f"CongressWatch: ${ticker} (score {score:.1f}{tl_note})",
106	        body=body,
107	        priority="high" if adjusted_score >= 9 else "default",
108	        tags=["chart_with_upwards_trend", "money_with_wings"],
109	    )
110	    print(f"  ntfy sent for {ticker} — disclosure-to-alert latency {age_hours(sig['last_seen']):.1f}h")
111
112
113	def _already_notified_today(ticker: str) -> bool:
114	    conn = sqlite3.connect(DB_PATH)
115	    row = conn.execute(
116	        "SELECT notified_at FROM signals WHERE ticker = ? AND notified = 1",
117	        (ticker,),
118	    ).fetchone()
119	    conn.close()
120	    if not row or not row[0]:
121	        return False
122	    try:
123	        return row[0][:10] == str(date.today())
124	    except Exception:
125	        return False
126
127
128	def _mark_notified(ticker: str):
129	    conn = sqlite3.connect(DB_PATH)
130	    conn.execute(
131	        "UPDATE signals SET notified = 1, notified_at = datetime('now') WHERE ticker = ?",
132	        (ticker,),
133	    )
134	    conn.commit()
135	    conn.close()
136
137
138	def run(dry_run: bool = False, no_ingest: bool = False, force_notify: bool = False):
139	    start = datetime.now()
140	    print(f"\n{'='*60}")
141	    print(f"  CongressWatch run — {start.strftime('%Y-%m-%d %H:%M')}")
142	    if dry_run:
143	        print("  MODE: dry-run (no LLM calls, no notifications)")
144	    print(f"{'='*60}\n")
145
146	    # 1. Ingest
147	    if not no_ingest:
148	        print("[ 1/9 ] Ingesting trades...")
149	        from ingest.capitol_trades import ingest
150	        summary = ingest(lookback_days=LOOKBACK_DAYS)


Let me continue reading run_daily.py to see the notification logic:


150	        summary = ingest(lookback_days=LOOKBACK_DAYS)
151	        print(f"        +{summary['new_trades']} new trades\n")
152	    else:
153	        print("[ 1/9 ] Skipping ingest (--no-ingest)\n")
154
155	    # 1b. Executive branch trades (OGE Form 278-T PDFs for POTUS/VP)
156	    if not no_ingest:
157	        print("[ 1b/9 ] OGE Form 278-T executive branch trades...")
158	        from ingest.presidential_oge import ingest_presidential_oge
159	        exec_summary = ingest_presidential_oge()
160	        if exec_summary.get("new_trades", 0) > 0:
161	            print(f"        +{exec_summary['new_trades']} new executive trades "
162	                  f"({exec_summary['pdfs_processed']} PDFs)\n")
163	        else:
164	            print(f"        no new trades (all known PDFs already processed)\n")
165
166	    # 2. Committees (weekly refresh if stale)
167	    print("[ 2/9 ] Committee assignments...")
168	    from ingest.committees import needs_refresh, refresh as refresh_committees
169	    if needs_refresh():
170	        summary = refresh_committees()
171	        print(f"        {summary['updated']} updated, {summary.get('no_data', 0)} with no data\n")
172	    else:
173	        print("        up to date (refreshed within 7 days)\n")
174
175	    # 3. Score (uses win_rates + market_caps from previous run)
176	    print("[ 3/9 ] Scoring signals...")
177	    from analysis.scorer import score_tickers
178	    signals = score_tickers()
179	    print(f"        {len(signals)} tickers scored\n")
180
181	    # Event-driven fast path: LLM work is only justified by newly ingested notify-tier data.
182	    with sqlite3.connect(DB_PATH) as conn:
183	        new_tickers = {row[0] for row in conn.execute(
184	            "SELECT DISTINCT issuer_ticker FROM trades "
185	            "WHERE datetime(first_ingested_at) >= datetime(?)", (start.isoformat(),)
186	        )}
187	    new_notify_tickers = {
188	        s["ticker"] for s in signals
189	        if s["ticker"] in new_tickers and s["total_score"] >= SCORE_NOTIFY_THRESHOLD
190	    }
191
192	    # 4. Prices + market caps
193	    print("[ 4/9 ] Fetching prices...")
194	    from tools.price_fetcher import fetch_and_store as fetch_prices
195	    price_summary = fetch_prices(min_score=SCORE_RESEARCH_THRESHOLD)
196	    print(f"        {price_summary['fetched']} updated, {price_summary['skipped']} already fresh\n")
197
198	    # 5. Win rates (uses today's price data; feeds into tomorrow's scoring)
199	    print("[ 5/9 ] Computing win rates...")
200	    from tools.win_rate import compute as compute_win_rates
201	    wr_summary = compute_win_rates()
202	    print(f"        {wr_summary['updated']} members updated, "
203	          f"{wr_summary['skipped']} skipped (insufficient data)\n")
204
205	    # 6. Research top signals (regular: all signals ≥ SCORE_RESEARCH_THRESHOLD)
206	    print("[ 6/9 ] Researching top signals...")
207	    research_map: dict[str, dict] = {}
208	    if not dry_run:
209	        from analysis.researcher import research_signal
210	        from datetime import timedelta
211
212	        cutoff = (datetime.now() - timedelta(days=LOOKBACK_DAYS)).strftime("%Y-%m-%d")
213	        conn = sqlite3.connect(DB_PATH)
214	        conn.row_factory = sqlite3.Row
215
216	        for sig in signals:
217	            if sig["total_score"] < SCORE_RESEARCH_THRESHOLD:
218	                break  # signals are sorted descending
219	            if sig["ticker"] not in new_notify_tickers:
220	                continue
221	            buyers = conn.execute(
222	                """
223	                SELECT DISTINCT t.member_id,
224	                    m.first_name || ' ' || m.last_name AS member_name,
225	                    m.party, m.chamber, m.committees,
226	                    MAX(t.amount_tier) AS amount_tier
227	                FROM trades t JOIN members m ON m.bioguide_id = t.member_id
228	                WHERE t.issuer_ticker = ? AND t.tx_type = 'buy' AND t.pub_date >= ?
229	                GROUP BY t.member_id
230	                """,
231	                (sig["ticker"], cutoff),
232	            ).fetchall()
233	            result = research_signal(sig, [dict(b) for b in buyers], dry_run=False)
234	            if result:
235	                research_map[sig["ticker"]] = result
236
237	        conn.close()
238	    else:
239	        print("        (skipped — dry-run mode)")
240	    print()
241
242	    # 6b. Deep research — top N scorers with risk_factors + recommended_action
243	    # Skips any ticker already deep-researched within DEEP_RESEARCH_DECAY_DAYS,
244	    # falling through to the next-ranked signal to spend the budget wisely.
245	    print(f"[ 6b/9 ] Deep research (top {DEEP_RESEARCH_TOP_N}, decay {DEEP_RESEARCH_DECAY_DAYS}d)...")
246	    if not dry_run:
247	        from analysis.researcher import research_top_n_deep
248	        notify_tier = [s for s in signals if s["ticker"] in new_notify_tickers]
249	        deep_results = research_top_n_deep(
250	            notify_tier, top_n=DEEP_RESEARCH_TOP_N, decay_days=DEEP_RESEARCH_DECAY_DAYS
251	        )
252	        for sig, result in deep_results:
253	            research_map[sig["ticker"]] = result  # update with richer deep result
254	            print(f"  {sig['ticker']}: deep research done — {result.get('recommended_action', '?')}")
255	        if not deep_results:
256	            print("  All top candidates within decay window — no deep research needed")
257	    else:
258	        print("        (skipped — dry-run mode)")
259	    print()
260
261	    # 6c. Predictive signals — behavioural analysis + forward watchlist per member
262	    print(f"[ 6c/9 ] Predictive signals (top {PREDICTOR_MAX_MEMBERS} members, decay {PREDICTOR_DECAY_DAYS}d)...")
263	    if not dry_run:
264	        from analysis.predictor import run_predictions, confirm_predictions, backfill_committee_alignment
265	        confirm_predictions()
266	        backfill_committee_alignment()
267	        pred_results = run_predictions(
268	            max_members=PREDICTOR_MAX_MEMBERS,
269	            decay_days=PREDICTOR_DECAY_DAYS,
270	        )
271	        for pr in pred_results:
272	            tickers = ", ".join(p["ticker"] for p in pr["predictions"])
273	            print(f"  {pr['member_name']}: {tickers}")
274	        if not pred_results:
275	            print("  All members within decay window — no predictions generated")
276
277	        # Fetch market caps + ETF/validity flags for predicted tickers
278	        print("  Fetching metadata for new predicted tickers...")
279	        from tools.price_fetcher import fetch_for_predicted
280	        meta = fetch_for_predicted()
281	        if meta["fetched"] or meta["invalid"]:
282	            print(f"  {meta['fetched']} metadata fetched, {meta['invalid']} filtered (ETF/invalid)")
283	    else:
284	        print("        (skipped — dry-run mode)")
285	    print()
286
287	    # 7. Generate report
288	    print("[ 7/9 ] Generating report...")
289	    from tools.generate_report import generate
290	    generate()
291	    print()
292
293	    # 7b. Update pending signal outcomes (30d/60d returns)
294	    print("[ 7b/9 ] Checking signal outcomes...")
295	    if not dry_run:
296	        from tools.outcome_tracker import update_pending_outcomes
297	        outcome_result = update_pending_outcomes()
298	        if outcome_result.get("updated"):
299	            print(f"        {outcome_result['updated']} outcomes updated")


Let me continue to see the notification logic:


300	        else:
301	            print("        no pending outcomes ready for scoring")
302	    else:
303	        print("        (skipped — dry-run mode)")
304	    print()
305
306	    # 8. Notify — apply timeliness, quality gates, and ticker cooldown before firing
307	    notified = skipped_tl = skipped_gate = skipped_cooldown = 0
308	    candidate_decisions: dict[str, tuple[bool, str | None]] = {}
309	    notify_candidates = [s for s in signals if s["total_score"] >= SCORE_NOTIFY_THRESHOLD]
310
311	    from analysis.guardrails import evaluate_circuit_breaker, report_only
312	    breaker = {"tripped": False, "reason": "dry_run"} if dry_run else evaluate_circuit_breaker()
313	    if breaker.get("tripped"):
314	        from notify.ntfy_client import push
315	        from config import NTFY_TOPIC_STATUS
316	        push(title="CongressWatch circuit breaker tripped", body=str(breaker),
317	             topic=NTFY_TOPIC_STATUS, priority="high", tags=["warning"])
318	    notifications_paused = report_only()
319	    if notifications_paused:
320	        print("[ 8/9 ] Notifications are report-only pending human review")
321	        for sig in notify_candidates:
322	            candidate_decisions[sig["ticker"]] = (False, "report_only")
323
324	    if not dry_run and notify_candidates and not notifications_paused:
325	        from analysis.notification_policy import (
326	            evaluate_notification_candidate,
327	            recent_ticker_notification,
328	        )
329
330	        print("[ 8/9 ] Sending notifications...")
331	        for sig in notify_candidates:
332	            if notified >= MAX_NOTIFICATIONS_PER_RUN:
333	                remaining = len(notify_candidates) - notified - skipped_tl - skipped_gate - skipped_cooldown
334	                if remaining > 0:
335	                    print(f"  Cap reached ({MAX_NOTIFICATIONS_PER_RUN}/run) — {remaining} remaining suppressed")
336	                for remaining_sig in notify_candidates:
337	                    candidate_decisions.setdefault(remaining_sig["ticker"], (False, "per_run_cap"))
338	                break
339
340	            ticker = sig["ticker"]
341
342	            # Timeliness gate
343	            research = research_map.get(ticker)
344	            from analysis.timeliness import assess_age
345	            timeliness = assess_age(sig["last_seen"], runup_pct=sig.get("runup_pct"))
346	            adj = _timeliness_adjusted_score(sig["total_score"], timeliness)
347	            if adj < SCORE_NOTIFY_THRESHOLD:
348	                tl_key = timeliness.split("|")[0].strip().lower()
349	                print(f"  {ticker}: {sig['total_score']:.1f} × {TIMELINESS_MULTIPLIERS.get(tl_key,1):.2f} = {adj:.1f} — below threshold ({tl_key})")
350	                skipped_tl += 1
351	                candidate_decisions[ticker] = (False, "timeliness")
352	                continue
353
354	            decision = evaluate_notification_candidate(sig, research, adj)
355	            if not decision.allowed:
356	                print(f"  {ticker}: {decision.reason} — skipping")
357	                skipped_gate += 1
358	                candidate_decisions[ticker] = (False, decision.reason)
359	                continue
360
361	            if not force_notify:
362	                in_cooldown, last_notified, days_left = recent_ticker_notification(ticker)
363	                if in_cooldown:
364	                    last_label = last_notified.isoformat(timespec="minutes") if last_notified else "recently"
365	                    print(
366	                        f"  {ticker}: notified {last_label}; "
367	                        f"{days_left}d left in {NOTIFY_TICKER_COOLDOWN_DAYS}d cooldown"
368	                    )
369	                    skipped_cooldown += 1
370	                    candidate_decisions[ticker] = (False, "ticker_cooldown")
371	                    continue
372
373	            if not force_notify and _already_notified_today(ticker):
374	                print(f"  {ticker}: already notified today")
375	                candidate_decisions[ticker] = (False, "already_notified_today")
376	                continue
377
378	            _send_signal_notification(sig, research, adj)
379	            _mark_notified(ticker)
380
381	            candidate_decisions[ticker] = (True, None)
382	            notified += 1
383	        print()
384
385	    # Dense shadow ledger continues in report-only mode so gate recovery stays measurable.
386	    if not dry_run and notify_candidates:
387	        from tools.outcome_tracker import record_candidate
388	        from analysis.timeliness import assess_age
389	        observed_at = start.isoformat(timespec="seconds")
390	        for sig in notify_candidates:
391	            research = research_map.get(sig["ticker"]) or {}
392	            fired, reason = candidate_decisions.get(sig["ticker"], (False, "not_evaluated"))
393	            record_candidate(
394	                ticker=sig["ticker"], score=_timeliness_adjusted_score(
395	                    sig["total_score"], assess_age(sig["last_seen"], runup_pct=sig.get("runup_pct"))),
396	                conviction=research.get("conviction", ""),
397	                recommended_action=research.get("recommended_action", ""),
398	                price=sig.get("price_current"), observed_at=observed_at,
399	                fired=fired, suppression_reason=reason,
400	                verifier_verdict=research.get("verifier_verdict"),
401	                citation_backed=bool(research.get("sources")),
402	            )
403
404	    elapsed = (datetime.now() - start).total_seconds()
405	    print(f"Run complete in {elapsed:.1f}s — {notified} notifications sent"
406	          + (f", {skipped_tl} suppressed by timeliness" if skipped_tl else "")
407	          + (f", {skipped_gate} filtered by quality gates" if skipped_gate else "")
408	          + (f", {skipped_cooldown} suppressed by cooldown" if skipped_cooldown else "") + ".")
409	    print(f"Report: reports/signals_latest.md\n")
410
411
412	if __name__ == "__main__":
413	    parser = argparse.ArgumentParser(description="CongressWatch daily runner")
414	    parser.add_argument("--dry-run",   action="store_true", help="no LLM calls, no notifications")
415	    parser.add_argument("--no-ingest", action="store_true", help="skip ingest step")
416	    parser.add_argument("--force",     action="store_true", help="re-notify even if notified today")
417	    args = parser.parse_args()
418	    run(dry_run=args.dry_run, no_ingest=args.no_ingest, force_notify=args.force)
419


Perfect! Now I have all the information I need. Let me create a comprehensive summary:

## Summary: Congressional Trading Signal Scoring System

Based on my thorough review of the codebase, here's what you need to know to implement your backtest sweep script:

### 1. **Scoring Algorithm (0-13 components)**

The scoring happens in `/workspace/extra/projects/congressional-trading/analysis/scorer.py`. The 9 score components are:

| Component | Range | Description |
|-----------|-------|-------------|
| `committee_score` | 0-3 | Member's committee overlap with company sector (3=direct jurisdiction) |
| `track_record_score` | 0-2 | Member's historical win rate (defaults to 1.0 until calculable) |
| `cluster_score` | 0-3 | Unique member buyers: 1=0pts, 2=1pt, 3=2pts, 4+=3pts (discounted by breadth_mult) |
| `options_bonus` | 0-2 | +2 if ANY option trade on this ticker, else 0 |
| `size_score` | 0-5 | Highest dollar tier among buy trades (amount_tier 1-6, capped at 5) |
| `small_cap_score` | -2.5 to 6.0 | Market cap bonus/penalty (mega=-2.5, small=+4.5, micro=+6.0) |
| `sell_pressure_score` | -4.0 to 0 | Penalty when sellers exist: 0 (clean) to -4 (sellers≥buyers) |
| `strategic_score` | 0-3 | LLM research component (from research_cache) |
| `disruption_score` | 0-3 | LLM research component (from research_cache) |

**Total max theoretical score: ~13** (but weighted by `DEFAULT_SCORE_WEIGHTS` in config.py)

### 2. **`signal_outcomes` Table Structure**

Location: `/workspace/extra/projects/congressional-trading/db/schema.sql` (lines 91-116)

**Key columns for backtest:**
```
ticker TEXT NOT NULL
notified_at TEXT NOT NULL          -- when signal was created/evaluated
score_at_notify REAL               -- the score at notify time
conviction TEXT                    -- LOW / MEDIUM / HIGH
recommended_action TEXT            -- LLM research recommendation
price_at_notify REAL               -- close price when signal fired
price_30d REAL                     -- close price 30 days later
price_60d REAL                     -- close price 60 days later
return_30d_pct REAL                -- % gain/loss in 30 days
return_60d_pct REAL                -- % gain/loss in 60 days
return_30d_excess_pct REAL         -- alpha vs SPY (30d)
return_60d_excess_pct REAL         -- alpha vs SPY (60d)
fired INTEGER                      -- 1 if notification was sent, 0 if suppressed
suppression_reason TEXT            -- reason it was filtered (e.g., "conviction", "edge_factors", "timeliness")
outcome TEXT                       -- "win" / "loss" / "pending" / "unmeasurable"
config_version_id INTEGER          -- which weight version was active
citation_backed INTEGER            -- 1 if LLM sources were cited
verifier_verdict TEXT              -- verifier's assessment
```

### 3. **Existing Backtest Tool**

Location: `/workspace/extra/projects/congressional-trading/tools/backtest.py`

**What it does:**
- `replay(weights, threshold)` — Replays all historical buy trades, scores them by looking back 30 days within LOOKBACK_DAYS window
- Does NOT call scoring functions directly; instead uses **cached data** from two tables:
  - `_cluster_score()` from pre-computed trade membership
  - `research_cache` table (strategic_score, disruption_score from prior LLM runs)
- Returns metrics: `median_30d_excess_pct`, `precision`, `alerts_per_month`, `eligible` (boolean if meets MIN_ALERTS_PER_MONTH)

**Key limitation:** "LLM-derived components replay from cache where present; otherwise zero"

- `calibrate()` — Runs 100 broad samples + 100 focused samples of weight permutations, returns best by 30d excess return
- Computation method: Looks up `price_history` table to fetch prices on/after signal date, computes 30-60d returns vs SPY

### 4. **Config Constants**

Location: `/workspace/extra/projects/congressional-trading/config.py`

**Critical thresholds:**
```python
SCORE_RESEARCH_THRESHOLD = 5.0       # LLM research triggered at this score
SCORE_NOTIFY_THRESHOLD = 9.0         # Notify candidate threshold
BACKTEST_MIN_ALERTS_PER_MONTH = 2.0  # Backtest eligibility gate
NOTIFY_MIN_CONVICTION = "HIGH"       # Conviction levels: LOW, MEDIUM, HIGH
NOTIFY_MIN_EDGE_FACTORS = 3          # 3+ independent edge factors to notify
NOTIFY_MAX_SELL_PRESSURE_PENALTY = -1.0  # Suppress if sell_pressure < this
LOOKBACK_DAYS = 30                   # Rolling window for trades
FRESHNESS_MAX_HOURS = 48             # "fresh" timeliness gate
WATCH_MAX_DAYS = 7                   # "watch" timeliness gate
```

**Timeliness multipliers (applied at notify time only, NOT stored):**
```python
TIMELINESS_MULTIPLIERS = {"fresh": 1.0, "watch": 0.85, "late": 0.70}
```

### 5. **Trades Table Schema**

Location: `/workspace/extra/projects/congressional-trading/db/schema.sql` (lines 17-40)

**Key columns:**
```
tx_id INTEGER PRIMARY KEY
member_id TEXT                 -- congressional member bioguide_id
issuer_ticker TEXT             -- clean ticker (e.g., "NVDA")
issuer_sector TEXT             -- sector classification
tx_date TEXT                   -- when trade executed (YYYY-MM-DD)
pub_date TEXT                  -- when disclosure published
tx_type TEXT                   -- "buy" / "sell" / "exchange"
amount_tier INTEGER            -- 1-6 (derived from value_estimate)
is_option INTEGER              -- 0/1
value_estimate INTEGER         -- dollar estimate from disclosure
```

---

## Key Insights for Your Backtest Sweep

1. **No "conviction_gate" exists as a single concept** — instead:
   - `NOTIFY_MIN_CONVICTION` is a config string ("LOW"/"MEDIUM"/"HIGH")
   - Conviction comes from LLM research (stored in `research_cache.conviction`)
   - It's checked during notification gating, not during scoring

2. **No "edge_factor" numerical value** — instead:
   - `edge_factors()` function returns a **tuple of strings** (lines 50-70 of notification_policy.py):
     - "multi-member cluster" (≥2 unique buyers)
     - "large disclosed size" (tier ≥4)
     - "options timing" (has_options=true)
     - "direct committee alignment" (committee_score ≥2.0)
     - "undercovered small/mid cap" (small_cap_score ≥2.0)
     - "strong LLM catalyst" (strategic + disruption ≥4.0)
   - Gate: need **≥3 factors** to notify (NOTIFY_MIN_EDGE_FACTORS)

3. **How backtest replays scoring:**
   - Iterates through all buy trades ordered by pub_date
   - For each day, groups trades by ticker within LOOKBACK_DAYS window
   - Computes components (cluster, size, etc.) from live trade data
   - **Reads strategic/disruption from research_cache** (LLM components from prior runs)
   - Applies weights and checks threshold
   - Fetches 30/60d prices from price_history table
   - Returns excess return over SPY

4. **Price data required:**
   - `price_history` table: (ticker, date, close)
   - Must have prices on signal date AND 30d/60d later for outcomes

The backtest is **deterministic** and **replay-based** — it doesn't re-run the LLM or re-fetch prices, it uses cached data.