Fix live ingest, email config, and dependencies for homelab deploy.
CI / security-scan (pull_request) Failing after 28s
CI / lint-and-test (pull_request) Failing after 31s
CI / dependency-scan (pull_request) Successful in 22s
CI / docker-build-test (pull_request) Failing after 34s
CI / workflow-summary (pull_request) Successful in 4s

Switch HouseWatcherClient to public STOCK Act JSON (legacy housestockwatcher.com is down), add httpx/scikit-learn to pyproject, wire SMTP settings through Settings, fix get_session() as a context manager, and use savepoints in TradeLoader so one bad row does not roll back the batch.
This commit is contained in:
2026-05-26 19:52:29 -04:00
parent ead0820cf9
commit 31c656d66f
7 changed files with 124 additions and 55 deletions
+10
View File
@@ -30,6 +30,16 @@ class Settings(BaseSettings):
# Logging
log_level: str = Field(default="INFO", description="Log level (DEBUG, INFO, WARNING, ERROR)")
# Email (Mailcow @ mail.levkine.ca — use LAN IP from app LXCs if DNS points public)
smtp_host: str = Field(default="mail.levkine.ca", description="SMTP server hostname")
smtp_port: int = Field(default=587, description="SMTP port (587 STARTTLS)")
smtp_user: str = Field(default="", description="SMTP auth username")
smtp_password: str = Field(default="", description="SMTP auth password")
from_email: str = Field(default="", description="From address for reports")
report_recipients: str = Field(
default="", description="Default report recipients (comma-separated)"
)
# Application
app_name: str = "POTE"
app_version: str = "0.1.0"
+3 -1
View File
@@ -3,6 +3,7 @@ Database layer: engine, session factory, and base model.
"""
from collections.abc import Generator
from contextlib import contextmanager
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
@@ -26,8 +27,9 @@ class Base(DeclarativeBase):
pass
@contextmanager
def get_session() -> Generator[Session, None, None]:
"""Get a database session (use as a context manager or dependency)."""
"""Get a database session (context manager or FastAPI-style dependency)."""
session = SessionLocal()
try:
yield session
+85 -28
View File
@@ -1,9 +1,12 @@
"""
House Stock Watcher client for fetching congressional trade data.
Free, no API key required - scrapes from housestockwatcher.com
Uses public STOCK Act disclosure datasets (no API key). The legacy
housestockwatcher.com host is often unavailable; we try several mirrors.
"""
import logging
import os
from datetime import date, datetime
from typing import Any
@@ -11,16 +14,69 @@ import httpx
logger = logging.getLogger(__name__)
def _default_data_urls() -> tuple[str, ...]:
override = os.environ.get("POTE_HOUSE_DATA_URL", "").strip()
if override:
return (override,)
return (
"https://raw.githubusercontent.com/kadoa-org/congress-trading-monitor/main/public/data/trades.json",
"https://housestockwatcher.com/api/all_transactions",
"https://house-stock-watcher-data.s3-us-west-2.amazonaws.com/data/all_transactions.json",
)
DEFAULT_DATA_URLS: tuple[str, ...] = _default_data_urls()
def _ascii_safe(text: str | None) -> str:
"""Normalize text for DB fields that may use ASCII-only client encoding."""
if not text:
return ""
return text.replace("\u00b7", "-").replace("·", "-").strip()
def _party_label(code: str | None) -> str:
if not code:
return ""
mapping = {"D": "Democrat", "R": "Republican", "I": "Independent"}
return mapping.get(code.strip().upper(), code.strip())
def normalize_transaction_record(raw: dict[str, Any]) -> dict[str, Any]:
"""
Map a raw disclosure record to the House Stock Watcher field names
expected by TradeLoader.
"""
if "representative" in raw and "disclosure_date" in raw:
return raw
# Kadoa congress-trading-monitor export
if "filer_name" in raw:
chamber = (raw.get("chamber") or "").strip().lower()
house = "Senate" if chamber == "senate" else "House"
return {
"representative": raw.get("filer_name", "").strip(),
"ticker": (raw.get("ticker") or "").strip(),
"transaction_date": raw.get("transaction_date", ""),
"disclosure_date": raw.get("filing_date", ""),
"transaction": raw.get("transaction_type", ""),
"amount": raw.get("amount_range_label", ""),
"house": house,
"district": _ascii_safe(raw.get("office")),
"party": _party_label(raw.get("party")),
}
return raw
class HouseWatcherClient:
"""
Client for House Stock Watcher API (free, community-maintained).
Client for congressional trade JSON feeds (free, community-maintained).
Data source: https://housestockwatcher.com/
No authentication required.
Primary source: congress-trading-monitor public dataset on GitHub.
"""
BASE_URL = "https://housestockwatcher.com/api"
data_urls: tuple[str, ...] = DEFAULT_DATA_URLS
def __init__(self, timeout: float = 30.0):
"""
@@ -65,30 +121,31 @@ class HouseWatcherClient:
Raises:
httpx.HTTPError: If request fails
"""
url = f"{self.BASE_URL}/all_transactions"
logger.info(f"Fetching transactions from {url}")
last_error: Exception | None = None
for url in self.data_urls:
if not url:
continue
logger.info(f"Fetching transactions from {url}")
try:
response = self._client.get(url)
response.raise_for_status()
data = response.json()
if not isinstance(data, list):
raise ValueError(f"Expected list response, got {type(data)}")
data = [normalize_transaction_record(item) for item in data]
logger.info(f"Fetched {len(data)} transactions from {url}")
if limit:
data = data[:limit]
return data
except Exception as e:
last_error = e
logger.warning(f"Failed to fetch from {url}: {e}")
continue
try:
response = self._client.get(url)
response.raise_for_status()
data = response.json()
if not isinstance(data, list):
raise ValueError(f"Expected list response, got {type(data)}")
logger.info(f"Fetched {len(data)} transactions from House Stock Watcher")
if limit:
data = data[:limit]
return data
except httpx.HTTPError as e:
logger.error(f"Failed to fetch from House Stock Watcher: {e}")
raise
except Exception as e:
logger.error(f"Unexpected error fetching transactions: {e}")
raise
logger.error("Failed to fetch congressional trades from all configured URLs")
if last_error:
raise last_error
raise RuntimeError("No data URLs configured")
def fetch_recent_transactions(self, days: int = 30) -> list[dict[str, Any]]:
"""
+16 -18
View File
@@ -45,27 +45,25 @@ class TradeLoader:
for txn in transactions:
try:
# Get or create official
official, is_new_official = self._get_or_create_official(txn)
if is_new_official:
officials_created += 1
with self.session.begin_nested():
official, is_new_official = self._get_or_create_official(txn)
if is_new_official:
officials_created += 1
# Get or create security
ticker = txn.get("ticker", "").strip().upper()
if not ticker or ticker in ("N/A", "--", ""):
logger.debug(
f"Skipping transaction with no ticker: {txn.get('representative')}"
)
continue
ticker = txn.get("ticker", "").strip().upper()
if not ticker or ticker in ("N/A", "--", ""):
logger.debug(
f"Skipping transaction with no ticker: {txn.get('representative')}"
)
continue
security, is_new_security = self._get_or_create_security(ticker)
if is_new_security:
securities_created += 1
security, is_new_security = self._get_or_create_security(ticker)
if is_new_security:
securities_created += 1
# Create trade (upsert)
trade_created = self._upsert_trade(txn, official.id, security.id, source)
if trade_created:
trades_created += 1
trade_created = self._upsert_trade(txn, official.id, security.id, source)
if trade_created:
trades_created += 1
except Exception as e:
logger.error(f"Failed to ingest transaction {txn}: {e}")