from __future__ import annotations
import gzip
import json
import re
from html import escape
from pathlib import Path
import gradio as gr
import pandas as pd
import plotly.graph_objects as go
import plotly.express as px
ROOT = Path(__file__).resolve().parent
DATA = ROOT / "data"
AUDIT = DATA / "audit"
DATASET = DATA / "dataset_bundle"
MANIFEST = json.loads((DATA / "public_release_manifest.json").read_text(encoding="utf-8"))
CLAIM_BOUNDARY = json.loads((AUDIT / "claim_boundary.json").read_text(encoding="utf-8"))
SOURCE_GAPS = json.loads((AUDIT / "source_gap_report.json").read_text(encoding="utf-8"))
FONT_FAMILY = "Arial, Helvetica, sans-serif"
def style_figure(fig):
fig.update_layout(
template="plotly_white",
font=dict(family=FONT_FAMILY, size=14, color="#1f2933"),
title_font=dict(family=FONT_FAMILY, size=18, color="#0f172a"),
legend=dict(font=dict(family=FONT_FAMILY, size=13, color="#334155")),
hoverlabel=dict(font_size=13, font_family=FONT_FAMILY),
paper_bgcolor="#ffffff",
plot_bgcolor="#ffffff",
)
fig.update_xaxes(
title_font=dict(family=FONT_FAMILY, size=14, color="#334155"),
tickfont=dict(family=FONT_FAMILY, size=13, color="#334155"),
gridcolor="#e7edf5",
linecolor="#cbd5e1",
)
fig.update_yaxes(
title_font=dict(family=FONT_FAMILY, size=14, color="#334155"),
tickfont=dict(family=FONT_FAMILY, size=13, color="#334155"),
gridcolor="#e7edf5",
linecolor="#cbd5e1",
)
return fig
def read_jsonl_gz(name: str, limit: int | None = None) -> pd.DataFrame:
rows = []
with gzip.open(DATASET / name, "rt", encoding="utf-8") as fh:
for idx, line in enumerate(fh):
if limit is not None and idx >= limit:
break
rows.append(json.loads(line))
return pd.DataFrame(rows)
def pct(value) -> float | None:
try:
return float(value) * 100.0
except (TypeError, ValueError):
return None
def fmt_int(value) -> str:
try:
return f"{int(value):,}"
except (TypeError, ValueError):
return str(value or "")
def html_escape(value) -> str:
if value is None:
return ""
try:
if pd.isna(value):
return ""
except (TypeError, ValueError):
pass
return escape(str(value))
def fmt_money(value) -> str:
if value is None:
return ""
try:
if pd.isna(value):
return ""
except (TypeError, ValueError):
pass
text = str(value).strip()
if not text:
return ""
try:
number = float(value)
except (TypeError, ValueError):
return html_escape(value)
if pd.isna(number):
return ""
prefix = "-$" if number < 0 else "$"
number = abs(number)
if number >= 1_000_000_000:
return f"{prefix}{number / 1_000_000_000:.2f}B"
if number >= 1_000_000:
return f"{prefix}{number / 1_000_000:.2f}M"
if number >= 1_000:
return f"{prefix}{number / 1_000:.0f}K"
return f"{prefix}{number:,.0f}"
def present_text(value, fallback: str = "not available") -> str:
text = html_escape(value).strip()
return text if text else fallback
def normalize_text(value) -> str:
text = str(value or "").lower()
text = re.sub(r"[^a-z0-9]+", " ", text)
return re.sub(r"\s+", " ", text).strip()
COMMON_NAME_ALIASES = {
"bill": ["william"],
"bob": ["robert"],
"chuck": ["charles"],
"dick": ["richard"],
"jim": ["james"],
"jack": ["john"],
"jeff": ["jeffrey"],
"jon": ["john", "jonathan", "thomas"],
"maggie": ["margaret"],
"mitch": ["mitchell"],
"rick": ["richard"],
"ted": ["theodore", "edward", "rafael"],
"tom": ["thomas"],
"tommy": ["thomas"],
"bernie": ["bernard"],
}
def query_variants(query: str) -> list[str]:
base = normalize_text(query)
if not base:
return []
variants = {base}
tokens = base.split()
for idx, token in enumerate(tokens):
for alias in COMMON_NAME_ALIASES.get(token, []):
changed = tokens.copy()
changed[idx] = alias
variants.add(" ".join(changed))
return sorted(variants, key=lambda value: (value != base, value))
def last_name_from_display(value: str) -> str:
tokens = [
token
for token in normalize_text(value).split()
if token not in {"jr", "sr", "ii", "iii", "iv", "v"}
]
return tokens[-1] if tokens else ""
def has_all_tokens(text: str, tokens: list[str]) -> bool:
padded = f" {text} "
return all(f" {token} " in padded for token in tokens if token)
def data_status(row) -> str:
filings = int(row.get("filings", 0) or 0)
transactions = int(row.get("transactions", 0) or 0)
wealth = int(row.get("wealth_ranges", 0) or 0)
if filings or transactions or wealth:
return "disclosure rows found"
if bool(row.get("in_roster", False)):
return "roster row; no disclosure rows in this public bundle"
return "disclosure filer row; no transaction or wealth rows"
def public_empty_html(title: str, body: str) -> str:
return (
"
"
f"
{html_escape(title)}
"
f"
{html_escape(body)}
"
"
"
)
def public_table_html(
frame: pd.DataFrame | None,
title: str,
empty_title: str,
empty_body: str,
max_rows: int = 12,
) -> str:
if frame is None or frame.empty:
return public_empty_html(empty_title, empty_body)
display = frame.head(int(max_rows)).fillna("").copy()
headers = "".join(f"{html_escape(col)} | " for col in display.columns)
body_rows = []
for _, row in display.iterrows():
cells = "".join(f"{html_escape(row[col])} | " for col in display.columns)
body_rows.append(f"{cells}
")
more = ""
if len(frame) > max_rows:
more = f"Showing {fmt_int(max_rows)} of {fmt_int(len(frame))} matching rows.
"
return (
""
f"
{html_escape(title)}
"
"
"
"
"
f"{headers}
"
f"{''.join(body_rows)}"
"
"
"
"
f"{more}"
"
"
)
def public_filer_cards_html(
frame: pd.DataFrame | None,
empty_title: str,
empty_body: str,
max_rows: int = 8,
) -> str:
if frame is None or frame.empty:
return public_empty_html(empty_title, empty_body)
cards = []
display = frame.head(int(max_rows)).fillna("").copy()
for _, row in display.iterrows():
status = str(row.get("Record status", ""))
if "roster row; no disclosure rows" in status:
status_text = "Roster match only; no disclosure rows under this exact ID"
elif "disclosure rows found" in status:
status_text = "Disclosure rows found"
else:
status_text = status or "Record status unavailable"
lag = str(row.get("Typical filing lag (days)", "")).strip()
lag_text = f"{lag} day typical filing lag" if lag else "filing lag not available"
counts = [
f"{fmt_int(row.get('Public forms', 0))} public forms",
f"{fmt_int(row.get('Trade rows', 0))} trade rows",
f"{fmt_int(row.get('Wealth summaries', 0))} wealth summaries",
lag_text,
]
count_html = "".join(f"{html_escape(item)}" for item in counts)
meta_parts = [part for part in [row.get("Party", ""), row.get("State", "")] if str(part).strip()]
meta = f"{html_escape(' - '.join(str(part) for part in meta_parts))}" if meta_parts else ""
why = str(row.get("Why shown", "")).strip()
why_html = f"{html_escape(why)}
" if why else ""
cards.append(
""
f"{html_escape(row.get('Matching record', ''))}
{meta}"
f"{html_escape(status_text)}"
f"{count_html}
"
f"{why_html}"
""
)
more = ""
if len(frame) > max_rows:
more = f"Showing {fmt_int(max_rows)} of {fmt_int(len(frame))} matching public records.
"
return (
""
"
Matching records
"
"
If more than one record appears, use the one that says disclosure rows were found.
"
f"
{''.join(cards)}
"
f"{more}"
"
"
)
def basis_label(value: str) -> str:
labels = {
"filing_date": "after public filing date",
"transaction_date": "after reported transaction date",
}
return labels.get(str(value), str(value).replace("_", " "))
def window_label(value: str) -> str:
return str(value).replace("_trading_days", " trading days").replace("_", " ")
def claim_label(value: str) -> str:
labels = {
"blocked_market_data_policy": "Not enough public market data for a market-beating claim",
"blocked": "Not enough public data for a market-beating claim",
}
return labels.get(str(value), str(value).replace("_", " "))
RETURN_TESTS = pd.read_csv(DATASET / "return_tests.csv")
RETURN_TESTS["equal_weight_alpha_pct"] = RETURN_TESTS["alpha_equal_weight"].map(pct)
RETURN_TESTS["midpoint_weight_alpha_pct"] = RETURN_TESTS["alpha_midpoint"].map(pct)
RETURN_TESTS["ci_low_pct"] = RETURN_TESTS["bootstrap_ci_low"].map(pct)
RETURN_TESTS["ci_high_pct"] = RETURN_TESTS["bootstrap_ci_high"].map(pct)
RETURN_TESTS["date_basis_label"] = RETURN_TESTS["event_date_source"].map(basis_label)
RETURN_TESTS["window_label"] = RETURN_TESTS["window"].map(window_label)
RETURN_TESTS["claim_status_label"] = RETURN_TESTS["claim_status"].map(claim_label)
RETURN_HANDOFF = pd.read_csv(DATA / "reporter_handoff" / "return_test_summary_handoff.csv")
TRADES = read_jsonl_gz("trades.jsonl.gz")
WEALTH = read_jsonl_gz("net_worth_intervals.jsonl.gz")
FILINGS = read_jsonl_gz("filings.jsonl.gz")
SENATORS = pd.read_csv(DATASET / "senators.csv")
TRADES["transaction_year"] = pd.to_datetime(TRADES["transaction_date"], errors="coerce").dt.year
TRADES["filing_lag_days_num"] = pd.to_numeric(TRADES.get("filing_lag_days"), errors="coerce")
TRADES["amount_midpoint_num"] = pd.to_numeric(TRADES.get("amount_midpoint"), errors="coerce")
TRADES["ticker_display"] = TRADES["ticker_candidate"].fillna("").replace("", "(no ticker)")
WEALTH["report_year_num"] = pd.to_numeric(WEALTH.get("report_year"), errors="coerce")
for col in ["net_worth_min", "net_worth_midpoint", "net_worth_max", "uncertainty_width"]:
WEALTH[col] = pd.to_numeric(WEALTH.get(col), errors="coerce")
def title_name(value: str) -> str:
text = str(value or "").replace("-", " ").strip()
return " ".join(part.capitalize() for part in text.split())
def build_filer_index() -> pd.DataFrame:
filing_counts = FILINGS.groupby("senator_id").size().rename("filings")
trade_counts = TRADES.groupby("senator_id").size().rename("transactions")
wealth_counts = WEALTH.groupby("senator_id").size().rename("wealth_ranges")
median_lag = TRADES.groupby("senator_id")["filing_lag_days_num"].median().round().rename("median_filing_lag_days")
ids = sorted(set(FILINGS["senator_id"]) | set(TRADES["senator_id"]) | set(WEALTH["senator_id"]) | set(SENATORS["senator_id"]))
frame = pd.DataFrame({"senator_id": ids})
frame = frame.merge(filing_counts, on="senator_id", how="left")
frame = frame.merge(trade_counts, on="senator_id", how="left")
frame = frame.merge(wealth_counts, on="senator_id", how="left")
frame = frame.merge(median_lag, on="senator_id", how="left")
roster = SENATORS[["senator_id", "display_name", "state", "party"]].copy()
frame = frame.merge(roster, on="senator_id", how="left")
filing_names = (
FILINGS.assign(
filing_display=(
FILINGS.get("filer_first_name", "").fillna("").map(title_name)
+ " "
+ FILINGS.get("filer_last_name", "").fillna("").map(title_name)
).str.strip()
)
.groupby("senator_id")["filing_display"]
.agg(lambda values: values.dropna().iloc[0] if len(values.dropna()) else "")
)
frame = frame.merge(filing_names.rename("filing_display"), on="senator_id", how="left")
frame["display_name"] = frame["display_name"].fillna(frame["filing_display"]).fillna("")
frame.loc[frame["display_name"].eq(""), "display_name"] = frame.loc[frame["display_name"].eq(""), "senator_id"]
frame["in_roster"] = frame["senator_id"].isin(set(SENATORS["senator_id"]))
for col in ["filings", "transactions", "wealth_ranges"]:
frame[col] = frame[col].fillna(0).astype(int)
frame["median_filing_lag_days"] = frame["median_filing_lag_days"].fillna("")
frame["state"] = frame["state"].fillna("")
frame["party"] = frame["party"].fillna("")
frame["display_norm"] = frame["display_name"].map(normalize_text)
frame["last_name"] = frame["display_name"].map(last_name_from_display)
frame["search_text"] = (
frame["display_name"].astype(str)
+ " "
+ frame["senator_id"].astype(str)
+ " "
+ frame["state"].astype(str)
+ " "
+ frame["party"].astype(str)
).str.lower()
frame["search_norm"] = frame["search_text"].map(normalize_text)
frame["total_rows"] = frame[["filings", "transactions", "wealth_ranges"]].sum(axis=1)
frame["has_parsed_rows"] = frame["total_rows"] > 0
return frame.sort_values(["transactions", "filings", "display_name"], ascending=[False, False, True])
FILER_INDEX = build_filer_index()
def name_for_id(senator_id: str) -> str:
match = FILER_INDEX[FILER_INDEX["senator_id"] == senator_id]
if match.empty:
return str(senator_id)
row = match.iloc[0]
suffix = ""
if row.get("state") or row.get("party"):
suffix = f" ({row.get('party', '')}-{row.get('state', '')})".replace(" (-", " (").replace("-)", ")")
return f"{row['display_name']}{suffix}"
PUBLIC_FILER_COLUMNS = [
"Matching record",
"State",
"Party",
"Public forms",
"Trade rows",
"Wealth summaries",
"Typical filing lag (days)",
"Record status",
"Why shown",
]
PUBLIC_WEALTH_COLUMNS = [
"Year",
"What the form can say",
"Low number",
"Middle of range",
"High number",
"Missing number",
"Filing date",
"Plain-English note",
]
PUBLIC_TRADE_COLUMNS = [
"Filer",
"Reported transaction date",
"Public filing date",
"Filing lag days",
"Disclosure transaction type",
"Reported owner",
"Asset name from filing",
"Ticker text from filing",
"Amount low",
"Amount high",
"What this row does not prove",
]
def resolve_filer(query: str) -> str:
matches = filer_candidates(query, max_rows=25)
if not matches.empty:
with_wealth = matches[matches["wealth_ranges"].fillna(0).astype(int) > 0]
if not with_wealth.empty:
return str(with_wealth.iloc[0]["senator_id"])
with_data = matches[matches["has_parsed_rows"]]
if not with_data.empty:
return str(with_data.iloc[0]["senator_id"])
return str(matches.iloc[0]["senator_id"])
if str(query or "").strip():
return ""
wealth_ids = set(WEALTH["senator_id"])
matches = FILER_INDEX[FILER_INDEX["senator_id"].isin(wealth_ids)].sort_values(["wealth_ranges", "transactions"], ascending=[False, False])
return str(matches.iloc[0]["senator_id"]) if not matches.empty else str(FILER_INDEX.iloc[0]["senator_id"])
def filer_candidates(query: str, max_rows: int = 12) -> pd.DataFrame:
variants = query_variants(query)
if not variants:
return FILER_INDEX.head(0).copy()
frames = []
for variant in variants:
tokens = variant.split()
direct = FILER_INDEX[
FILER_INDEX["search_norm"].str.contains(variant, regex=False)
| FILER_INDEX["display_norm"].str.contains(variant, regex=False)
| FILER_INDEX["search_norm"].map(lambda value: has_all_tokens(value, tokens))
].copy()
if not direct.empty:
direct["match_score"] = 100 if variant == variants[0] else 82
direct["why_shown"] = "direct name, state, party, or filer-ID match" if variant == variants[0] else "nickname or legal-name variant match"
frames.append(direct)
if frames:
direct_all = pd.concat(frames, ignore_index=True)
roster_last_names = direct_all[direct_all["in_roster"]]["last_name"].dropna().unique().tolist()
if roster_last_names:
related = FILER_INDEX[
FILER_INDEX["last_name"].isin(roster_last_names)
& ~FILER_INDEX["senator_id"].isin(set(direct_all["senator_id"]))
].copy()
if not related.empty:
related["match_score"] = 74
related["why_shown"] = "related disclosure filer name with the same last name"
frames.append(related)
if not frames:
return FILER_INDEX.head(0).copy()
result = pd.concat(frames, ignore_index=True)
result = result.sort_values(
["has_parsed_rows", "match_score", "wealth_ranges", "transactions", "filings", "display_name"],
ascending=[False, False, False, False, False, True],
)
result = result.drop_duplicates("senator_id", keep="first")
return result.head(int(max_rows)).copy()
def empty_state_figure(message: str):
fig = go.Figure()
fig.add_annotation(
text=message,
x=0.5,
y=0.5,
xref="paper",
yref="paper",
showarrow=False,
font=dict(size=16, color="#334155", family=FONT_FAMILY),
)
fig.update_layout(height=360, margin=dict(l=24, r=24, t=34, b=24))
return style_figure(fig)
def hero_html() -> str:
return """
Official Senate disclosure forms
See what senators reported
Search a name. The app turns public Senate financial disclosure forms into plain answers: reported wealth range, disclosed transaction rows, filing dates, and the source filing.
People might care because lawmakers file public financial disclosures. This makes those official records easier to read without turning them into accusations.
Forms, not accusations
Ranges, not exact net worth
Receipts, not guesses
No claim of wrongdoing or market-beating performance.
"""
def plain_english_cards_html() -> str:
cards = [
(
"Start with a person",
"Search a senator and read the latest public form summary before opening charts or tables.",
"#e8f7f3",
"#0f766e",
),
(
"Read money as a range",
"Forms report dollar ranges. A result like 'at least $363K' is a disclosure floor, not a precise fortune.",
"#eef4ff",
"#3155b7",
),
(
"Open the receipt",
"Every useful row points back to the public filing so readers can check the source record.",
"#fff7e8",
"#8a4b00",
),
]
cells = "".join(
f"""
"""
for title, body, bg, accent in cards
)
return f"{cells}
"
def metric_html() -> str:
counts = MANIFEST.get("counts", {})
dataset = counts.get("dataset", {})
items = [
(fmt_int(dataset.get("filings", "")), "public filings"),
(fmt_int(dataset.get("trades", "")), "reported trades"),
(fmt_int(dataset.get("net_worth_intervals", "")), "wealth summaries"),
]
cells = "".join(
f"{value} {label}"
for value, label in items
)
return f"Behind the app:{cells}More technical tables live in Advanced.
"
def boundary_html() -> str:
return """
Plain-English boundary: Public disclosure records can show what was reported and when.
They cannot, by themselves, prove why someone traded or whether a private return was earned.
"""
def overview_intro_html() -> str:
return """
Understand it in five seconds
Use this like a receipt-backed reader for public Senate financial forms.
1. Who?Search the senator or filer name.
2. What was reported?Read the latest wealth range and disclosed transaction rows.
3. Where is the proof?Open the Senate filing linked beside the result.
"""
def public_source_summary_html() -> str:
return """
Best first step: search a senator by name, then open the original filing when you need the source.
Public disclosure forms show what was reported and when. They usually do not show intent, exact dollar amounts, or the reason for a trade.
"""
def coverage_summary_html() -> str:
roster_count = len(SENATORS)
parsed_roster_like_count = int((FILER_INDEX["has_parsed_rows"]).sum())
zero_roster_count = int((FILER_INDEX["in_roster"] & ~FILER_INDEX["has_parsed_rows"]).sum())
trade_filer_count = int((FILER_INDEX["transactions"].fillna(0).astype(int) > 0).sum())
wealth_filer_count = int((FILER_INDEX["wealth_ranges"].fillna(0).astype(int) > 0).sum())
return f"""
What the search covers
The roster table includes {fmt_int(roster_count)} Senate roster records.
This public bundle has disclosure rows for {fmt_int(parsed_roster_like_count)} filer-name records,
transaction rows for {fmt_int(trade_filer_count)}, and wealth summaries for {fmt_int(wealth_filer_count)}.
{fmt_int(zero_roster_count)} roster records currently show no filings, trades, or wealth rows under that exact roster ID.
That can mean no data under this ID, a legal-name/filing-name split, or no matching rows in this public bundle.
It is not a claim that the senator never filed or never traded.
"""
def market_note_html() -> str:
return """
How to read this benchmark-context chart
Bars above zero mean the disclosed transaction set moved more than a simple benchmark in that window under one assumption.
Bars below zero mean it moved less. Because this public bundle uses no-cost adjusted-close data, every row remains exploratory.
"""
def return_chart():
fig = go.Figure()
for date_basis, frame in RETURN_TESTS.groupby("date_basis_label"):
fig.add_trace(
go.Bar(
name=str(date_basis).capitalize(),
x=frame["window_label"],
y=frame["equal_weight_alpha_pct"],
customdata=frame[["claim_status_label", "n_events"]],
hovertemplate=(
"%{x}
"
"Benchmark difference: %{y:.3f}%
"
"Rows: %{customdata[1]}
"
"%{customdata[0]}"
),
)
)
fig.add_hline(y=0, line_width=1, line_color="#555")
fig.update_layout(
title="Advanced benchmark context",
yaxis_title="Market move vs benchmark (%)",
xaxis_title="Market window after date",
barmode="group",
margin=dict(l=64, r=20, t=70, b=62),
legend_title_text="Measured from",
)
return style_figure(fig)
def filing_delay_chart():
frame = TRADES.dropna(subset=["filing_lag_days_num"]).copy()
frame = frame[(frame["filing_lag_days_num"] >= 0) & (frame["filing_lag_days_num"] <= 800)]
bins = [-1, 30, 45, 90, 180, 365, 800]
labels = ["0-30 days", "31-45 days", "46-90 days", "91-180 days", "181-365 days", "366+ days"]
frame["lag_bucket"] = pd.cut(frame["filing_lag_days_num"], bins=bins, labels=labels)
counts = frame["lag_bucket"].value_counts().reindex(labels).reset_index()
counts.columns = ["lag_bucket", "rows"]
fig = px.bar(counts, x="lag_bucket", y="rows", color="lag_bucket", color_discrete_sequence=px.colors.qualitative.Safe)
fig.update_layout(
title="How long after a transaction was it filed?",
xaxis_title="Days between transaction and filing",
yaxis_title="Disclosed trade rows",
showlegend=False,
margin=dict(l=40, r=20, t=60, b=50),
)
return style_figure(fig)
def trade_type_chart():
frame = TRADES.copy()
frame["kind"] = frame["transaction_type"].fillna("").str.lower().str.extract(r"(purchase|sale|exchange)", expand=False).fillna("other")
yearly = frame.dropna(subset=["transaction_year"]).groupby(["transaction_year", "kind"]).size().reset_index(name="rows")
yearly = yearly[(yearly["transaction_year"] >= 2012) & (yearly["transaction_year"] <= 2026)]
fig = px.area(yearly, x="transaction_year", y="rows", color="kind", color_discrete_sequence=px.colors.qualitative.Safe)
fig.update_layout(
title="Disclosed transaction rows by year and type",
xaxis_title="Transaction year",
yaxis_title="Rows",
margin=dict(l=40, r=20, t=60, b=50),
)
return style_figure(fig)
def top_ticker_chart():
frame = TRADES[TRADES["ticker_display"] != "(no ticker)"]
counts = frame["ticker_display"].value_counts().head(20).sort_values().reset_index()
counts.columns = ["ticker", "rows"]
fig = px.bar(counts, x="rows", y="ticker", orientation="h", color="rows", color_continuous_scale="Viridis")
fig.update_layout(
title="Stock-symbol text found most often in disclosures",
xaxis_title="Disclosure rows",
yaxis_title="Symbol text from filing",
margin=dict(l=80, r=20, t=60, b=50),
coloraxis_showscale=False,
)
return style_figure(fig)
def wealth_senators() -> list[str]:
counts = WEALTH["senator_id"].value_counts()
return counts.index.tolist()
def wealth_chart(senator_id: str):
frame = WEALTH[WEALTH["senator_id"] == senator_id].sort_values("report_year_num")
if frame.empty:
fig = go.Figure()
fig.add_annotation(
text="No wealth form summary found for this filer.",
x=0.5,
y=0.5,
xref="paper",
yref="paper",
showarrow=False,
font=dict(size=16, color="#334155", family=FONT_FAMILY),
)
fig.update_layout(height=360, margin=dict(l=20, r=20, t=40, b=20))
return style_figure(fig)
fig = go.Figure()
fig.add_trace(
go.Scatter(
x=frame["report_year"],
y=frame["net_worth_max"],
mode="lines",
line=dict(width=0),
showlegend=False,
hoverinfo="skip",
)
)
fig.add_trace(
go.Scatter(
x=frame["report_year"],
y=frame["net_worth_min"],
mode="lines",
fill="tonexty",
fillcolor="rgba(56, 142, 129, 0.22)",
line=dict(width=0),
name="disclosed interval",
hovertemplate="Year %{x}
Low bound: $%{y:,.0f}",
)
)
fig.add_trace(
go.Scatter(
x=frame["report_year"],
y=frame["net_worth_midpoint"],
mode="lines+markers",
line=dict(color="#1b6b63", width=3),
name="midpoint",
hovertemplate="Year %{x}
Midpoint: $%{y:,.0f}",
)
)
fig.update_layout(
title=f"Reported wealth range over time: {name_for_id(senator_id)}",
xaxis_title="Report year",
yaxis_title="Estimated range from disclosure brackets ($)",
margin=dict(l=70, r=20, t=60, b=50),
)
return style_figure(fig)
def wealth_table(senator_id: str) -> pd.DataFrame:
cols = [
"report_year",
"net_worth_min",
"net_worth_midpoint",
"net_worth_max",
"top_coded",
"filing_date",
"calculation_note",
]
frame = WEALTH[WEALTH["senator_id"] == senator_id][cols].sort_values("report_year")
display = frame.copy()
display["reported_range"] = display.apply(
lambda row: f"{fmt_money(row.get('net_worth_min')) or 'not available'} to {fmt_money(row.get('net_worth_max')) or 'not available'}",
axis=1,
)
display["top_coded"] = display.apply(wealth_missing_number_text, axis=1)
display = display[
[
"report_year",
"reported_range",
"net_worth_min",
"net_worth_midpoint",
"net_worth_max",
"top_coded",
"filing_date",
"calculation_note",
]
]
return display.rename(
columns={
"report_year": "Year",
"reported_range": "What the form can say",
"net_worth_min": "Low number",
"net_worth_midpoint": "Middle of range",
"net_worth_max": "High number",
"top_coded": "Missing number",
"filing_date": "Filing date",
"calculation_note": "Plain-English note",
}
)
def filer_search(query: str, max_rows: int = 12) -> pd.DataFrame:
if not str(query or "").strip():
return pd.DataFrame(columns=PUBLIC_FILER_COLUMNS)
frame = filer_candidates(query, max_rows=max_rows)
if frame.empty:
return pd.DataFrame(columns=PUBLIC_FILER_COLUMNS)
display = frame[
[
"display_name",
"state",
"party",
"filings",
"transactions",
"wealth_ranges",
"median_filing_lag_days",
"why_shown",
]
].copy()
display["median_filing_lag_days"] = display["median_filing_lag_days"].map(
lambda value: "" if not str(value).strip() else f"{int(float(value))}"
)
display["record_status"] = frame.apply(data_status, axis=1).values
return display.rename(
columns={
"display_name": "Matching record",
"state": "State",
"party": "Party",
"filings": "Public forms",
"transactions": "Trade rows",
"wealth_ranges": "Wealth summaries",
"median_filing_lag_days": "Typical filing lag (days)",
"record_status": "Record status",
"why_shown": "Why shown",
}
)
def filer_search_html(query: str) -> str:
if not str(query or "").strip():
return public_empty_html(
"Type a senator name and press Search",
"Try a name, state, party, or filer ID. Results stay plain because this is the public reading path.",
)
results = filer_search(query)
note = ""
if not results.empty:
has_roster_only = results["Record status"].astype(str).str.contains("roster row; no disclosure rows", regex=False).any()
has_data = results["Record status"].astype(str).str.contains("disclosure rows found", regex=False).any()
if has_roster_only or has_data:
note = (
""
"Read this first: Senate roster names and disclosure filing names do not always use the same ID or name text. "
"If a roster row says no disclosure rows, that means this public bundle has no rows under that exact roster ID. "
"It does not prove the senator had no filings or no trades; check any related disclosure-name rows shown with it."
"
"
)
return note + public_filer_cards_html(
results,
"No matching public records found",
"Try a last name, state abbreviation, party, filer ID, or common/legal-name variant.",
max_rows=12,
)
def filer_profile_html(query: str) -> str:
if not str(query or "").strip():
return """
Search a senator to start
Type a name, state, party, or filer ID. The result shows what appears in the public disclosure dataset, not a judgment about the person.
"""
senator_id = resolve_filer(query)
if not senator_id:
return "No matching senator or filer record found
Try a last name, state abbreviation, party, filer ID, or legal-name variant.
"
match = FILER_INDEX[FILER_INDEX["senator_id"] == senator_id]
if match.empty:
return "No match found. Try a name, state, or filer ID.
"
row = match.iloc[0]
lag = row.get("median_filing_lag_days", "")
lag_text = f"{int(lag)} days" if str(lag).strip() else "not enough transaction rows"
status = data_status(row)
status_note = ""
if "no disclosure rows" in status:
status_note = (
"Coverage note: This exact public record has no filings, trades, or wealth rows in this bundle. "
"That is a data-coverage statement, not a claim that nothing exists or nothing happened.
"
)
elif "disclosure rows found" in status:
status_note = (
"Coverage note: Disclosure rows were found for this public filing-name record. "
"Names may differ from the official roster name because Senate forms can use legal or filer names.
"
)
return f"""
Selected public filer
{name_for_id(senator_id)}
{status_note}
{fmt_int(row.get('filings', 0))} public forms
{fmt_int(row.get('transactions', 0))} trade rows
{fmt_int(row.get('wealth_ranges', 0))} wealth summaries
{lag_text} typical filing lag
"""
def filer_wealth_chart(query: str):
if not str(query or "").strip():
return empty_state_figure("Search a senator to see the rough wealth range from public forms.")
senator_id = resolve_filer(query)
if not senator_id:
return empty_state_figure("No matching senator or filer record found.")
return wealth_chart(senator_id)
def filer_wealth_table(query: str) -> pd.DataFrame:
if not str(query or "").strip():
return pd.DataFrame(columns=PUBLIC_WEALTH_COLUMNS)
senator_id = resolve_filer(query)
if not senator_id:
return pd.DataFrame(columns=PUBLIC_WEALTH_COLUMNS)
return wealth_table(senator_id)
def numeric_or_none(value):
try:
number = float(value)
except (TypeError, ValueError):
return None
try:
if pd.isna(number):
return None
except (TypeError, ValueError):
pass
return number
def floor_money_text(value: float) -> str:
if value < 0:
return f"No lower than {fmt_money(value)}"
return f"At least {fmt_money(value)}"
def disclosure_math_plain_text() -> str:
return "subtracting debts from assets reported on the public form"
def wealth_plain_result(row) -> dict[str, str]:
low_num = numeric_or_none(row.get("net_worth_min"))
high_num = numeric_or_none(row.get("net_worth_max"))
year = present_text(row.get("report_year", "latest"), "latest")
if low_num is not None and high_num is not None:
short = f"{fmt_money(low_num)} to {fmt_money(high_num)}"
sentence = f"For {year}, the public form puts {disclosure_math_plain_text()} between {short}."
missing = "Exact net worth is not reported."
note = "That is a reported range, not an exact private valuation."
elif low_num is not None:
short = floor_money_text(low_num)
sentence_value = short[:1].lower() + short[1:]
sentence = f"For {year}, the public form shows {sentence_value} after {disclosure_math_plain_text()}."
missing = "No calculable maximum from this filing."
note = "The exact amount is not public here, and the real number may be higher."
elif high_num is not None:
short = f"No more than {fmt_money(high_num)}"
sentence = f"For {year}, the public form shows no more than {fmt_money(high_num)} after {disclosure_math_plain_text()}."
missing = "No calculable minimum from this filing."
note = "The exact amount is not public here, and the real number may be lower."
else:
short = "Not enough dollar detail"
sentence = f"The {year} public form does not give enough dollar detail for a wealth summary."
missing = "Low and high numbers not available."
note = "Open the source filing to inspect the underlying disclosure rows."
return {
"short": short,
"sentence": sentence,
"missing": missing,
"note": note,
}
def wealth_range_text(row) -> str:
return wealth_plain_result(row)["short"]
def wealth_range_sentence(row) -> str:
return wealth_plain_result(row)["sentence"]
def wealth_year_explanation(row) -> str:
year = present_text(row.get("report_year", "this year"), "this year")
low_num = numeric_or_none(row.get("net_worth_min"))
high_num = numeric_or_none(row.get("net_worth_max"))
if low_num is not None and high_num is not None:
return (
f"For {year}, the reported assets-minus-debts number lands somewhere from {fmt_money(low_num)} to {fmt_money(high_num)}. "
"It is a public disclosure range, not an appraisal or exact net worth."
)
if low_num is not None:
sentence = (
f"For {year}, the public form supports at least {fmt_money(low_num)}. "
"This bundle cannot calculate a top end from that filing, so the real number could be higher."
)
if low_num < 0:
sentence += " A negative floor can happen when reported debt ranges can exceed reported asset ranges."
return sentence
if high_num is not None:
return (
f"For {year}, the public form supports no more than {fmt_money(high_num)}. "
"This bundle cannot calculate a bottom end from that filing, so the real number could be lower."
)
return f"For {year}, the filing rows do not have enough dollar detail here to summarize assets minus debts."
def wealth_movement_sentence(ordered: pd.DataFrame) -> str:
if len(ordered) < 2:
return "Only one wealth report is available for this filer in the public bundle."
latest = ordered.iloc[0]
previous = ordered.iloc[1]
latest_low = numeric_or_none(latest.get("net_worth_min"))
latest_high = numeric_or_none(latest.get("net_worth_max"))
previous_low = numeric_or_none(previous.get("net_worth_min"))
previous_high = numeric_or_none(previous.get("net_worth_max"))
if None in {latest_low, latest_high, previous_low, previous_high}:
return "The forms are too broad for a clean year-to-year comparison."
latest_year = present_text(latest.get("report_year", "latest"))
previous_year = present_text(previous.get("report_year", "previous"))
if latest_low > previous_high:
direction = "is above"
elif latest_high < previous_low:
direction = "is below"
else:
return f"Compared with {previous_year}, the {latest_year} reported range overlaps the earlier one, so the forms do not show a clear move."
return f"Compared with {previous_year}, the {latest_year} reported range {direction} the earlier reported range."
def wealth_missing_number_text(row) -> str:
low_num = numeric_or_none(row.get("net_worth_min"))
high_num = numeric_or_none(row.get("net_worth_max"))
if low_num is not None and high_num is None:
return "No calculable maximum from this filing."
if low_num is None and high_num is not None:
return "No calculable minimum from this filing."
if low_num is None and high_num is None:
return "Low and high numbers not available."
return "Low and high numbers available."
def filer_wealth_summary_html(query: str) -> str:
if not str(query or "").strip():
return public_empty_html(
"Search a senator to see what the forms can say about wealth",
"Senate records use dollar brackets, so this page shows a reported range rather than exact net worth.",
)
senator_id = resolve_filer(query)
if not senator_id:
return public_empty_html(
"No matching senator or filer record found",
"Try a last name, state abbreviation, party, filer ID, or legal-name variant.",
)
table = wealth_table(senator_id)
if table.empty:
return public_empty_html(
"No wealth summary in this public bundle",
"This does not mean no filing, no assets, or no disclosure. It only means this bundle does not have a wealth summary for the selected public filer record.",
)
raw = WEALTH[WEALTH["senator_id"] == senator_id].copy()
raw["_year_sort"] = pd.to_numeric(raw["report_year"], errors="coerce")
ordered = raw.sort_values("_year_sort", ascending=False).drop(columns=["_year_sort"])
latest = ordered.iloc[0]
latest_year = present_text(latest.get("report_year", ""))
latest_filing_date = present_text(latest.get("filing_date", ""), "filing date not available")
latest_url = str(latest.get("source_url", "") or "").strip()
source_link = (
f"Open Senate filing"
if latest_url
else "Source link not available in this row"
)
latest_plain = wealth_plain_result(latest)
index_match = FILER_INDEX[FILER_INDEX["senator_id"] == senator_id]
index_row = index_match.iloc[0] if not index_match.empty else {}
filing_count = int(index_row.get("filings", 0) or 0) if hasattr(index_row, "get") else 0
transaction_count = int(index_row.get("transactions", 0) or 0) if hasattr(index_row, "get") else 0
summary_cards = [
("Year covered", latest_year),
("Filed with Senate", latest_filing_date),
("Records found", f"{fmt_int(filing_count)} public forms; {fmt_int(transaction_count)} trade rows"),
]
card_html = "".join(
f"{html_escape(label)}{html_escape(value)}
"
for label, value in summary_cards
)
year_items = []
for _, row in ordered.head(6).iterrows():
row_url = str(row.get("source_url", "") or "").strip()
row_source = (
f"View filing"
if row_url
else "source link not available"
)
year_items.append(
""
f"{html_escape(present_text(row.get('report_year', '')))}"
""
f"
{html_escape(wealth_range_text(row))}"
f"
{html_escape(wealth_year_explanation(row))}
"
"
"
f"Filed {html_escape(present_text(row.get('filing_date', ''), 'date not available'))}."
f"{row_source}"
""
)
more_note = ""
if len(ordered) > 6:
more_note = f"Showing 6 of {fmt_int(len(ordered))} past filing summaries. Download files and technical rows live in Advanced.
"
return (
""
f"
Latest filing in plain English
"
f"
{html_escape(name_for_id(senator_id))}
"
"
This is a summary of public Senate disclosure forms, not a judgment about the person.
"
f"
What the latest financial form says{html_escape(latest_plain['short'])}{html_escape(latest_plain['sentence'])} {html_escape(latest_plain['note'])}
Why someone might care: it shows the financial picture this official publicly reported for that year, and lets readers compare filings over time with source receipts.
"
f"
{card_html}
"
"
"
"How to read this number
"
"Senate forms report assets and debts in dollar ranges. This app reads those ranges and summarizes the public form as a low-to-high range, or as a floor when the top end cannot be calculated.
"
f"{html_escape(latest_plain['missing'])} The number cannot show exact net worth, taxable income, trading profit, intent, wrongdoing, or whether any trade made money.
"
" "
f"
{html_escape(wealth_movement_sentence(ordered))}
"
f"
Latest filing date: {html_escape(latest_filing_date)}{source_link}
"
"
"
""
"Show year-by-year filings
"
"What each year meansThe year is the report year named by the public filing. The dollar phrase is what that filing supports for reported assets minus reported debts. A floor is not a ranking, and changes over time can reflect broad form ranges, purchases, sales, debts, filing differences, or parser limits.
"
f""
f"{more_note}"
" "
)
def trade_search(query: str, min_lag: int, max_rows: int) -> pd.DataFrame:
frame = TRADES.copy()
if query:
q = query.lower().strip()
matching_ids = filer_candidates(query, max_rows=50)["senator_id"].tolist()
frame = frame[
frame["asset_name"].fillna("").str.lower().str.contains(q, regex=False)
| frame["ticker_candidate"].fillna("").str.lower().str.contains(q, regex=False)
| frame["senator_id"].fillna("").str.lower().str.contains(q, regex=False)
| frame["senator_id"].isin(matching_ids)
]
frame = frame[frame["filing_lag_days_num"].fillna(-1) >= min_lag]
frame = frame.head(int(max_rows)).copy()
frame["filer_name"] = frame["senator_id"].fillna("").map(name_for_id)
cols = [
"filer_name",
"transaction_date",
"filing_date",
"filing_lag_days",
"transaction_type",
"owner",
"asset_name",
"ticker_candidate",
"amount_min",
"amount_max",
"what_this_does_not_prove",
]
return frame[cols].rename(
columns={
"filer_name": "Filer",
"transaction_date": "Reported transaction date",
"filing_date": "Public filing date",
"filing_lag_days": "Filing lag days",
"transaction_type": "Disclosure transaction type",
"owner": "Reported owner",
"asset_name": "Asset name from filing",
"ticker_candidate": "Ticker text from filing",
"amount_min": "Amount low",
"amount_max": "Amount high",
"what_this_does_not_prove": "What this row does not prove",
}
)
def public_trade_search(query: str) -> pd.DataFrame:
if not str(query or "").strip():
return pd.DataFrame(columns=PUBLIC_TRADE_COLUMNS)
return trade_search(query, 0, 50)
def public_trade_search_html(query: str) -> str:
if not str(query or "").strip():
return public_empty_html(
"Type a senator, company, ticker, asset, or filer ID and press Search",
"Try Ted Cruz, AAPL, Microsoft, SPY, or a filer ID. Rows show public disclosure text, not exact private returns.",
)
table = public_table_html(
public_trade_search(query),
"Matching public transaction disclosure rows",
"No matching transaction rows found",
"Try a shorter ticker, company name, asset name, senator name, or filer ID.",
max_rows=20,
)
if not filer_candidates(query, max_rows=1).empty:
note = (
"Name matching note: "
"Senate roster names and disclosure filing names do not always use the same text. "
"A senator-name search may include related disclosure-filer names when the records line up by common name or last name.
"
)
return note + table
return table
def update_filer_public(query: str):
return filer_wealth_summary_html(query), filer_search_html(query), filer_profile_html(query)
def update_trade_public(query: str):
return public_trade_search_html(query)
def return_handoff_display() -> pd.DataFrame:
frame = RETURN_HANDOFF.copy()
if "date_basis" in frame:
frame["date_basis"] = frame["date_basis"].map(basis_label)
if "window" in frame:
frame["window"] = frame["window"].map(window_label)
if "claim_status" in frame:
frame["claim_status"] = frame["claim_status"].map(claim_label)
cols = [
"date_basis",
"window",
"events",
"senators",
"tickers",
"equal_weight_alpha_pct",
"midpoint_weight_alpha_pct",
"bootstrap_ci_low_pct",
"bootstrap_ci_high_pct",
"claim_status",
"plain_english_status",
]
frame = frame[[col for col in cols if col in frame.columns]]
return frame.rename(
columns={
"date_basis": "Measured from",
"window": "Window",
"events": "Trade windows checked",
"senators": "Filers",
"tickers": "Stock symbols found",
"equal_weight_alpha_pct": "Equal-weight benchmark difference (%)",
"midpoint_weight_alpha_pct": "Midpoint-weight benchmark difference (%)",
"bootstrap_ci_low_pct": "Bootstrap low (%)",
"bootstrap_ci_high_pct": "Bootstrap high (%)",
"claim_status": "Status",
"plain_english_status": "Plain English status",
}
)
def source_gap_table() -> pd.DataFrame:
return pd.DataFrame(SOURCE_GAPS.get("source_gaps", []))
def file_inventory() -> pd.DataFrame:
rows = []
for row in MANIFEST.get("files", []):
rows.append(
{
"path": row.get("path", "").replace(str(DATA.parent), ""),
"bytes": row.get("byte_count", ""),
"sha256": row.get("sha256", ""),
"compressed": row.get("compressed", False),
}
)
return pd.DataFrame(rows)
def audit_summary_html() -> str:
counts = MANIFEST.get("counts", {})
dataset = counts.get("dataset", {})
source_gaps = SOURCE_GAPS.get("source_gaps", [])
return f"""
Source summary
{fmt_int(dataset.get('source_artifacts', ''))}source artifacts tracked
{fmt_int(len(source_gaps))}known source-gap notes
Noraw market-price file redistributed
Use the downloads below if you want to audit the package. Most visitors do not need this section.
"""
CSS = """
:root {
--accent: #1b6b63;
--ink: #0b1220;
--muted: #1f2937;
--warn: #8a4b00;
--line: #cbd5e1;
--soft: #f8fafc;
--font: Arial, Helvetica, sans-serif;
color-scheme: light;
}
html,
body,
#root,
.gradio-container,
.gradio-container .main,
.gradio-container .wrap,
.gradio-container .contain {
background: #f6f8fb !important;
color: var(--ink) !important;
color-scheme: light !important;
}
.dark .gradio-container,
.dark .gradio-container .main,
.dark .gradio-container .wrap,
.dark .gradio-container .contain {
background: #f6f8fb !important;
color: var(--ink) !important;
color-scheme: light !important;
}
.gradio-container,
.gradio-container * {
font-family: var(--font) !important;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
letter-spacing: 0;
}
.gradio-container {
max-width: 1080px !important;
margin: auto;
color: var(--ink) !important;
font-size: 16px;
line-height: 1.48;
padding: 0 18px 28px;
}
.gradio-container .hero-panel,
.gradio-container .hero-panel *,
.gradio-container .plain-card,
.gradio-container .plain-card *,
.gradio-container .count-strip,
.gradio-container .count-strip *,
.gradio-container .boundary,
.gradio-container .boundary *,
.gradio-container .simple-start,
.gradio-container .simple-start *,
.gradio-container .path-panel,
.gradio-container .path-panel *,
.gradio-container .section-intro,
.gradio-container .section-intro *,
.gradio-container .profile-card,
.gradio-container .profile-card *,
.gradio-container .coverage-panel,
.gradio-container .coverage-panel *,
.gradio-container .coverage-note,
.gradio-container .coverage-note *,
.gradio-container .result-card,
.gradio-container .result-card *,
.gradio-container .public-table,
.gradio-container .public-table *,
.gradio-container .audit-summary,
.gradio-container .audit-summary * {
font-family: Arial, Helvetica, sans-serif !important;
opacity: 1 !important;
filter: none !important;
mix-blend-mode: normal !important;
text-shadow: none !important;
}
.hero-panel {
background: #ffffff;
border: 1px solid #cbd5e1;
border-radius: 14px;
padding: 26px 28px;
margin: 14px 0 14px;
box-shadow: 0 14px 34px rgba(15,23,42,.08);
}
.hero-panel .eyebrow {
color: #006b5d !important;
font-size: .86rem;
font-weight: 800;
letter-spacing: .04em;
text-transform: uppercase;
margin-bottom: 8px;
}
.hero-panel h1 {
color: #0f172a !important;
font-size: 2.55rem !important;
line-height: 1.04 !important;
font-weight: 800 !important;
margin: 0 0 10px !important;
}
.hero-answer {
color: #111827 !important;
font-size: 1.12rem !important;
line-height: 1.55 !important;
max-width: 830px;
margin: 0 !important;
}
.hero-why {
color: #111827 !important;
font-size: 1rem !important;
line-height: 1.5 !important;
max-width: 860px;
margin: 10px 0 0 !important;
font-weight: 700 !important;
}
.reader-model {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 14px;
}
.reader-model span {
background: #eef8f6 !important;
border: 1px solid #99d4cb !important;
border-radius: 999px;
color: #073d38 !important;
font-weight: 800 !important;
padding: 8px 11px;
}
.not-verdict {
display: inline-block;
margin-top: 14px;
color: #4d2a00 !important;
background: #fff7e8;
border: 1px solid #e6bd7a;
border-radius: 999px;
padding: 8px 12px;
font-weight: 800;
}
.not-verdict,
.not-verdict * {
color: #4d2a00 !important;
opacity: 1 !important;
}
.plain-cards {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
margin: 14px 0;
}
.plain-card {
border: 1px solid;
border-radius: 12px;
padding: 16px;
min-height: 120px;
}
.plain-card-title {
color: #0b1220 !important;
font-size: 1.08rem;
font-weight: 800;
margin-bottom: 8px;
}
.plain-card-body {
color: #111827 !important;
font-size: 1rem;
line-height: 1.45;
font-weight: 600;
opacity: 1 !important;
}
.count-strip {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
background: #ffffff;
border: 1px solid #d7dee8;
border-radius: 12px;
padding: 12px 14px;
margin: 12px 0;
}
.count-label {
color: #111827 !important;
font-weight: 800;
}
.count-pill {
background: #eef7f5;
color: #111827 !important;
border: 1px solid #b8dcd6;
border-radius: 999px;
padding: 6px 10px;
font-weight: 700;
}
.count-pill strong {
color: #0b1220 !important;
font-weight: 800 !important;
}
.count-note {
color: #111827 !important;
font-weight: 700;
}
.simple-start,
.path-panel,
.section-intro,
.profile-card,
.coverage-panel,
.coverage-note {
background: #ffffff;
border: 1px solid #cbd5e1;
border-radius: 12px;
padding: 16px 18px;
margin: 10px 0 14px;
color: #111827 !important;
}
.simple-start *,
.path-panel *,
.section-intro *,
.profile-card *,
.coverage-panel *,
.coverage-note *,
.audit-summary * {
color: #111827 !important;
opacity: 1 !important;
}
.simple-start h2,
.profile-card h2,
.coverage-panel h2 {
color: #0f172a !important;
font-size: 1.45rem !important;
font-weight: 800 !important;
line-height: 1.2 !important;
margin: 0 0 8px !important;
}
.question-grid,
.profile-grid,
.audit-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
margin-top: 12px;
}
.question-grid div,
.profile-grid span,
.audit-grid div {
background: #f8fafc;
border: 1px solid #d7dee8;
border-radius: 10px;
padding: 12px;
color: #1f2937;
opacity: 1 !important;
}
.question-grid strong,
.profile-grid strong,
.audit-grid strong {
display: block;
color: #0f172a !important;
font-weight: 800 !important;
margin-bottom: 4px;
}
.question-grid span,
.profile-grid span,
.audit-grid span {
color: #334155;
opacity: 1 !important;
font-weight: 700;
}
.audit-summary h2 {
color: #0f172a !important;
font-size: 1.35rem !important;
font-weight: 800 !important;
margin: 0 0 8px !important;
}
.coverage-note {
background: #fff7e8 !important;
border-left: 5px solid #8a4b00 !important;
}
.coverage-note strong,
.status-note strong {
color: #0f172a !important;
}
.status-note {
color: #111827 !important;
background: #f8fafc !important;
border: 1px solid #d7dee8 !important;
border-radius: 10px !important;
padding: 10px 12px !important;
margin: 8px 0 12px !important;
}
.result-card {
background: #ffffff !important;
border: 1px solid #cbd5e1 !important;
border-radius: 12px !important;
padding: 18px 20px !important;
margin: 12px 0 16px !important;
color: #111827 !important;
box-shadow: none !important;
}
.result-card h2 {
color: #0f172a !important;
font-size: 1.42rem !important;
line-height: 1.2 !important;
font-weight: 800 !important;
margin: 0 0 8px !important;
}
.result-card p,
.result-card .table-note {
color: #111827 !important;
font-size: 1rem !important;
line-height: 1.55 !important;
margin: 0 0 10px !important;
}
.filer-card-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
margin-top: 12px;
}
.filer-match-card {
background: #f8fafc;
border: 1px solid #d7dee8;
border-radius: 10px;
padding: 14px;
}
.filer-match-card h3 {
color: #0f172a !important;
font-size: 1.08rem !important;
line-height: 1.25 !important;
font-weight: 800 !important;
margin: 0 0 4px !important;
}
.filer-meta {
color: #334155 !important;
font-size: .92rem !important;
font-weight: 800 !important;
}
.filer-status {
display: inline-block;
color: #0f766e !important;
background: #e8f7f3;
border: 1px solid #b8dcd6;
border-radius: 999px;
padding: 5px 9px;
font-size: .9rem !important;
margin: 10px 0;
}
.filer-counts {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 7px;
}
.filer-counts span {
background: #ffffff;
border: 1px solid #e5edf5;
border-radius: 8px;
color: #111827 !important;
font-weight: 800 !important;
padding: 8px;
}
.filer-match-card p {
color: #334155 !important;
font-size: .95rem !important;
font-weight: 700 !important;
margin-top: 10px !important;
}
.empty-state {
background: #ffffff !important;
}
.public-table-wrap {
overflow-x: auto;
border: 1px solid #d7dee8;
border-radius: 10px;
background: #ffffff;
}
.public-table {
width: 100%;
border-collapse: collapse;
min-width: 720px;
background: #ffffff !important;
}
.public-table th,
.public-table td {
color: #111827 !important;
background: #ffffff !important;
border-bottom: 1px solid #e5edf5 !important;
padding: 10px 11px !important;
text-align: left !important;
vertical-align: top !important;
font-size: .95rem !important;
line-height: 1.45 !important;
}
.public-table th {
color: #0f172a !important;
background: #eef4f8 !important;
font-weight: 800 !important;
white-space: nowrap;
}
.wealth-answer-card {
background: #effaf8 !important;
border: 1px solid #99d4cb !important;
border-radius: 10px;
color: #06211f !important;
padding: 16px 18px;
margin: 16px 0 12px;
}
.wealth-answer-card span {
display: block;
color: #0f766e !important;
font-size: .86rem !important;
font-weight: 800 !important;
letter-spacing: .03em;
text-transform: uppercase;
margin-bottom: 6px;
}
.wealth-answer-card strong {
display: block;
color: #061a18 !important;
font-size: 2rem !important;
line-height: 1.08 !important;
font-weight: 800 !important;
margin-bottom: 8px;
}
.wealth-answer-card p {
color: #111827 !important;
font-size: 1.03rem !important;
line-height: 1.55 !important;
margin: 0 !important;
}
.wealth-answer-card .why-care-line {
margin-top: 10px !important;
font-weight: 800 !important;
}
.wealth-plain-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
margin-top: 12px;
}
.wealth-plain-grid div {
background: #f8fafc;
border: 1px solid #d7dee8;
border-radius: 10px;
padding: 12px;
}
.wealth-plain-grid strong {
display: block;
color: #334155 !important;
font-size: .84rem !important;
font-weight: 800 !important;
margin-bottom: 5px;
}
.wealth-plain-grid span {
color: #0f172a !important;
font-size: 1.1rem !important;
font-weight: 800 !important;
}
.why-matter-card,
.wealth-year-guide {
background: #fff7e8 !important;
border: 1px solid #e4b56d !important;
border-left: 5px solid #8a4b00 !important;
border-radius: 10px;
color: #2b1700 !important;
padding: 12px 14px;
margin-top: 12px;
}
.why-matter-card strong,
.wealth-year-guide strong {
display: block;
color: #1f1200 !important;
font-weight: 800 !important;
margin-bottom: 4px;
}
.why-matter-card p {
color: #2b1700 !important;
margin: 0 !important;
}
.wealth-year-guide span {
color: #2b1700 !important;
display: block;
font-weight: 700 !important;
line-height: 1.45 !important;
}
.wealth-reader h2 {
max-width: 860px;
}
.profile-lens {
color: #334155 !important;
font-weight: 800 !important;
margin: 0 0 10px !important;
}
.wealth-lede {
font-size: 1.12rem !important;
line-height: 1.5 !important;
margin-bottom: 16px !important;
}
.wealth-range-bar {
position: relative;
height: 18px;
border-radius: 999px;
background: #e5edf3;
border: 1px solid #cbd5e1;
margin: 18px 0 8px;
overflow: visible;
}
.wealth-range-fill {
position: absolute;
top: 3px;
bottom: 3px;
border-radius: 999px;
background: #0f766e;
}
.wealth-range-dot {
position: absolute;
top: 50%;
width: 16px;
height: 16px;
transform: translate(-50%, -50%);
border-radius: 50%;
background: #ffffff;
border: 4px solid #0f172a;
box-shadow: 0 0 0 2px #ffffff;
}
.wealth-range-labels {
display: flex;
justify-content: space-between;
gap: 10px;
color: #334155 !important;
font-size: .9rem !important;
font-weight: 800 !important;
margin-bottom: 12px;
}
.wealth-range-labels span {
color: #334155 !important;
}
.wealth-open-range {
display: grid;
grid-template-columns: minmax(180px, .8fr) minmax(180px, 1fr) minmax(220px, 1fr);
gap: 12px;
align-items: center;
background: #f8fafc;
border: 1px solid #d7dee8;
border-radius: 10px;
padding: 12px;
margin: 16px 0 10px;
}
.wealth-open-range span,
.wealth-open-range strong {
color: #111827 !important;
font-weight: 800 !important;
}
.wealth-open-line {
position: relative;
height: 12px;
border-radius: 999px;
background: linear-gradient(90deg, #0f766e 0%, #0f766e 72%, rgba(15, 118, 110, .08) 100%);
}
.wealth-open-line i {
position: absolute;
right: -2px;
top: 50%;
width: 0;
height: 0;
transform: translateY(-50%);
border-left: 12px solid #0f766e;
border-top: 8px solid transparent;
border-bottom: 8px solid transparent;
}
.wealth-explain,
.receipt-strip {
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
align-items: center;
background: #f8fafc;
border: 1px solid #d7dee8;
border-radius: 10px;
color: #111827 !important;
padding: 12px;
margin-top: 12px;
}
.wealth-explain span {
display: block;
flex-basis: 100%;
color: #334155 !important;
font-weight: 700 !important;
}
.reader-details,
.history-drawer {
color: #111827 !important;
}
.reader-details {
background: #f8fafc !important;
border: 1px solid #d7dee8 !important;
border-radius: 10px;
padding: 12px;
margin-top: 12px;
}
.reader-details summary,
.history-drawer summary {
color: #0f172a !important;
cursor: pointer;
font-size: 1.05rem !important;
font-weight: 800 !important;
list-style-position: inside;
}
.reader-details p {
color: #111827 !important;
margin: 8px 0 0 !important;
}
.wealth-movement {
margin-top: 12px !important;
font-weight: 700 !important;
}
.source-link,
.wealth-year-list a {
color: #0f766e !important;
font-weight: 800 !important;
text-decoration: underline;
text-underline-offset: 2px;
}
.wealth-year-list {
list-style: none !important;
padding: 0 !important;
margin: 12px 0 0 !important;
display: grid;
gap: 10px;
}
.wealth-year-list li {
display: grid;
grid-template-columns: minmax(70px, .45fr) minmax(340px, 2.4fr) minmax(120px, .7fr) minmax(100px, .55fr);
gap: 10px;
align-items: start;
background: #f8fafc;
border: 1px solid #d7dee8;
border-radius: 10px;
padding: 12px;
margin: 0 !important;
}
.wealth-year-list strong {
color: #0f172a !important;
font-size: 1.02rem !important;
}
.wealth-year-list .year-meaning span {
display: block;
color: #111827 !important;
font-weight: 800 !important;
margin-bottom: 4px;
}
.wealth-year-list .year-meaning p {
color: #334155 !important;
font-size: .96rem !important;
font-weight: 700 !important;
line-height: 1.45 !important;
margin: 0 !important;
}
.wealth-year-list em {
color: #334155 !important;
font-style: normal !important;
font-weight: 700 !important;
}
.profile-kicker {
color: #0f766e;
font-size: .84rem;
font-weight: 800;
letter-spacing: .04em;
text-transform: uppercase;
margin-bottom: 6px;
}
.gradio-container a,
.gradio-container a *,
.gradio-container strong,
.gradio-container b {
color: #0f172a !important;
}
.gradio-container h1,
.gradio-container .prose h1,
.gradio-container .markdown h1 {
color: #0f172a !important;
font-size: 2rem !important;
line-height: 1.15 !important;
font-weight: 800 !important;
margin: 10px 0 8px !important;
}
.gradio-container p,
.gradio-container li,
.gradio-container .prose {
color: #111827 !important;
font-size: 1rem !important;
line-height: 1.58 !important;
}
.gradio-container .hero-answer,
.gradio-container .simple-start p,
.gradio-container .path-panel p,
.gradio-container .section-intro,
.gradio-container .profile-card p,
.gradio-container .audit-summary p {
color: #111827 !important;
opacity: 1 !important;
}
.gradio-container label,
.gradio-container .label-wrap,
.gradio-container .label-wrap span,
.gradio-container button[role="tab"] {
color: #0f172a !important;
font-size: 1rem !important;
font-weight: 700 !important;
}
.gradio-container button[role="tab"][aria-selected="true"] {
color: var(--accent) !important;
font-weight: 800 !important;
}
.gradio-container button[role="tab"] {
min-height: 42px !important;
}
.gradio-container [role="tablist"] {
flex-wrap: wrap !important;
gap: 4px 10px !important;
}
.gradio-container input,
.gradio-container textarea,
.gradio-container select {
color: #111827 !important;
font-size: 1rem !important;
background: #ffffff !important;
}
.gradio-container button {
color: #0f172a !important;
font-weight: 800 !important;
font-family: Arial, Helvetica, sans-serif !important;
}
.gradio-container button.primary,
.gradio-container button[variant="primary"] {
background: #0f766e !important;
border-color: #0f766e !important;
color: #ffffff !important;
}
.gradio-container button.primary *,
.gradio-container button[variant="primary"] * {
color: #ffffff !important;
}
#filer-search-button,
#trade-search-button {
max-width: 220px !important;
min-height: 44px !important;
margin: 6px 0 14px !important;
}
.gradio-container code {
color: #0f172a !important;
background: #eef4f8 !important;
border: 1px solid #d7dee8 !important;
border-radius: 6px !important;
padding: 1px 5px !important;
}
[role="listbox"],
[role="option"],
.gradio-container .options,
.gradio-container .option,
.gradio-container .svelte-select-list,
.gradio-container .dropdown-options {
background: #ffffff !important;
color: #111827 !important;
}
.gradio-container .block,
.gradio-container .form,
.gradio-container .panel,
.gradio-container .plot-container,
.gradio-container .table-wrap,
.gradio-container .dataframe,
.gradio-container .input-container,
.gradio-container .container {
background: #ffffff !important;
color: #111827 !important;
}
.metrics { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 12px; margin: 14px 0 18px; }
.metric { border: 1px solid var(--line); border-radius: 8px; padding: 13px 14px; background: #ffffff; }
.metric-value { font-size: 1.45rem; font-weight: 800; color: var(--accent); line-height: 1.18; overflow-wrap: anywhere; }
.metric-label { font-size: .92rem; color: var(--muted); font-weight: 700; margin-top: 5px; }
.boundary {
border-left: 5px solid var(--warn);
background: #fff7e8;
color: #2b1700 !important;
padding: 13px 15px;
border-radius: 6px;
margin: 12px 0 20px;
font-size: 1rem;
line-height: 1.55;
}
.boundary,
.boundary * {
color: #2b1700 !important;
opacity: 1 !important;
}
.boundary strong { color: #1f1200 !important; font-weight: 800; }
.small-note { color: #111827 !important; font-size: .98rem; opacity: 1 !important; }
.gradio-container table,
.gradio-container th,
.gradio-container td {
color: #111827 !important;
font-size: .94rem !important;
line-height: 1.45 !important;
}
.gradio-container th {
background: #eef4f8 !important;
color: #0f172a !important;
font-weight: 700 !important;
}
.gradio-container td {
background: #ffffff !important;
}
.gradio-container .dataframe,
.gradio-container .table-wrap {
border-color: var(--line) !important;
}
.gradio-container .wealth-reader,
.gradio-container .wealth-reader *,
.gradio-container .wealth-years-card,
.gradio-container .wealth-years-card * {
color: #111827 !important;
font-family: Arial, Helvetica, sans-serif !important;
opacity: 1 !important;
}
.gradio-container .wealth-answer-card,
.gradio-container .wealth-answer-card * {
color: #061a18 !important;
opacity: 1 !important;
}
.gradio-container .wealth-answer-card span,
.gradio-container .profile-kicker {
color: #0f766e !important;
}
.gradio-container .wealth-explain,
.gradio-container .wealth-explain *,
.gradio-container .receipt-strip,
.gradio-container .receipt-strip * {
color: #111827 !important;
opacity: 1 !important;
}
.gradio-container .source-link,
.gradio-container .wealth-year-list a {
color: #0f766e !important;
}
@media (max-width: 900px) {
.plain-cards, .question-grid, .profile-grid, .audit-grid, .wealth-plain-grid, .filer-card-list { grid-template-columns: 1fr !important; }
.wealth-year-list li { grid-template-columns: 1fr !important; }
.wealth-open-range { grid-template-columns: 1fr !important; }
.hero-panel h1 { font-size: 2rem !important; }
}
@media (max-width: 620px) {
.plain-cards { grid-template-columns: 1fr !important; }
.hero-panel {
padding: 20px 18px !important;
}
.hero-panel h1 {
font-size: 2rem !important;
line-height: 1.08 !important;
}
.not-verdict {
display: block !important;
border-radius: 12px !important;
padding: 10px 12px !important;
}
.count-strip {
align-items: flex-start !important;
}
.gradio-container [role="tablist"] {
display: flex !important;
flex-wrap: wrap !important;
}
.gradio-container button[role="tab"] {
flex: 1 1 44% !important;
min-width: 0 !important;
min-height: 38px !important;
padding: 6px 8px !important;
font-size: .92rem !important;
}
#filer-search-button,
#trade-search-button {
width: 100% !important;
max-width: none !important;
}
}
"""
with gr.Blocks(title="Senate Disclosure Reader") as demo:
gr.HTML(hero_html())
with gr.Tabs():
with gr.Tab("Search Senator"):
gr.HTML("Search a senator. The first result gives the latest filing in plain English. If this bundle has no usable rows for that exact record, it says that directly.
")
filer_query = gr.Textbox(
label="Name, state, or filer ID",
value="",
placeholder="Type a senator name, state, party, or filer ID",
show_label=True,
elem_id="filer-search-input",
)
filer_button = gr.Button("Search", variant="primary", elem_id="filer-search-button")
wealth_summary = gr.HTML(filer_wealth_summary_html(""))
filer_results = gr.HTML(filer_search_html(""))
filer_profile = gr.HTML(filer_profile_html(""))
filer_query.submit(
update_filer_public,
inputs=filer_query,
outputs=[wealth_summary, filer_results, filer_profile],
show_progress="minimal",
)
filer_button.click(
update_filer_public,
inputs=filer_query,
outputs=[wealth_summary, filer_results, filer_profile],
show_progress="minimal",
)
with gr.Tab("Search Trades"):
gr.HTML("Search transaction disclosures. These rows show what was reported on forms. They do not show exact execution price or private profit.
")
search_box = gr.Textbox(label="Senator, company, ticker, asset, or filer ID", value="", placeholder="Try: Ted Cruz, AAPL, Microsoft, SPY", elem_id="trade-search-input")
trade_button = gr.Button("Search", variant="primary", elem_id="trade-search-button")
trade_rows = gr.HTML(public_trade_search_html(""))
search_box.submit(update_trade_public, inputs=search_box, outputs=trade_rows, show_progress="minimal")
trade_button.click(update_trade_public, inputs=search_box, outputs=trade_rows, show_progress="minimal")
with gr.Tab("Advanced"):
gr.HTML("Advanced area. This is where the heavier charts, market assumptions, and receipts live.
")
with gr.Accordion("What this app can and cannot say", open=False):
gr.HTML(overview_intro_html())
gr.HTML(metric_html())
gr.HTML(boundary_html())
gr.HTML(coverage_summary_html())
with gr.Accordion("Filing-delay chart", open=False):
gr.Plot(value=filing_delay_chart(), show_label=False)
with gr.Accordion("Stock-symbol text chart", open=False):
gr.HTML("Ticker text is read from disclosure forms. It is not always a verified security identifier.
")
gr.Plot(value=top_ticker_chart(), show_label=False)
with gr.Accordion("Benchmark context", open=False):
gr.HTML(market_note_html())
gr.Plot(value=return_chart(), show_label=False)
gr.Dataframe(value=return_handoff_display(), label="Market-check rows", interactive=False, wrap=True)
gr.HTML("Bottom line: these rows are not public alpha proof. A positive cell is not proof of intent, illegality, causality, insider trading, or realized private return.
")
with gr.Accordion("Audit trail and downloads", open=False):
gr.HTML(audit_summary_html())
gr.Dataframe(value=source_gap_table(), label="Source-gap report", interactive=False, wrap=True)
gr.Dataframe(value=file_inventory(), label="Bundle inventory", interactive=False, wrap=True)
gr.File(
value=str(DATA / "public_release_manifest.json"),
label="Public release manifest",
interactive=False,
)
gr.File(
value=str(DATA / "dataset_bundle" / "return_tests.csv"),
label="Return-test summary CSV",
interactive=False,
)
if __name__ == "__main__":
demo.launch(theme=gr.themes.Soft(), css=CSS)