You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

324 lines
13 KiB
Python

"""LLM vs Rule-Based Parser Comparison Test.
qwen3.5:9b (Ollama local) vs RuleBasedParser on real SEC exhibit texts.
Tests: direction accuracy, guidance detection, EPS extraction, low-confidence rescue.
Usage:
uv run python3 scripts/llm_parser_test.py
"""
from __future__ import annotations
import json
import os
import sys
import time
from pathlib import Path
import pandas as pd
import requests
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from libs.common.file_store import read_exhibit
from libs.parser.rule_parser import RuleBasedParser
from libs.parser.text_normalizer import looks_like_html, normalize_text
OLLAMA_URL = "http://localhost:11434/api/generate"
MODEL = "gpt-oss:20b"
EXHIBIT_CACHE = "data/cache/exhibits"
_rule_parser = RuleBasedParser()
# ── LLM Prompt ────────────────────────────────────────────────────────────────
SYSTEM_PROMPT = """You are a financial document analyst specializing in SEC 8-K press releases.
Analyze the given earnings/corporate announcement text and extract structured information.
Respond ONLY with a valid JSON object, no markdown, no explanation."""
USER_PROMPT_TEMPLATE = """Analyze this SEC press release and return ONLY a JSON object with these fields:
{{
"event_type": "earnings_release|guidance_update|material_contract|management_change|other_material_event|unknown",
"event_direction": "bullish|bearish|neutral|unknown",
"guidance_status": "raised|lowered|maintained|withdrawn|not_provided",
"eps_actual": <float or null>,
"eps_estimate": <float or null>,
"eps_beat_pct": <float or null, positive=beat, negative=miss>,
"revenue_actual_bn": <float or null, in billions>,
"revenue_beat_pct": <float or null>,
"key_signal": "<one sentence: the single most important signal>",
"confidence": <0.0-1.0>
}}
Rules:
- event_direction "bullish" = results/news clearly positive for stock price
- event_direction "bearish" = results/news clearly negative
- guidance_status "raised" = company raised future guidance/outlook
- Extract EPS numbers only if explicitly stated (e.g. "$2.34 vs $2.10 estimate")
- confidence reflects how clearly the document supports your answer
DOCUMENT:
{text}"""
# ── Helpers ───────────────────────────────────────────────────────────────────
def extract_accession_no(event_id: str) -> str | None:
parts = event_id.split("::")
if len(parts) >= 7:
return parts[6]
return None
def get_exhibit_text(accession_no: str) -> str | None:
try:
text = read_exhibit(accession_no, "EX-99.1")
normalized = normalize_text(text, is_html=looks_like_html(text))
# Truncate to ~3000 chars for LLM (key info is in first part)
return normalized[:3000]
except FileNotFoundError:
return None
def run_rule_parser(accession_no: str, form_type: str = "8-K") -> dict:
try:
text = read_exhibit(accession_no, "EX-99.1")
normalized = normalize_text(text, is_html=looks_like_html(text))
output = _rule_parser.parse(
document_id=accession_no,
form_type=form_type,
text=normalized,
metadata={"item_numbers": []},
)
return {
"event_type": output.event_type,
"event_direction": output.event_direction,
"guidance_status": output.guidance.status if output.guidance else "not_provided",
"confidence": output.confidence.overall,
"signal_strength": output.signals.demand_strength if output.signals else None,
}
except Exception as e:
return {"error": str(e)}
def run_llm_parser(text: str) -> dict:
prompt = USER_PROMPT_TEMPLATE.format(text=text)
payload = {
"model": MODEL,
"prompt": prompt,
"system": SYSTEM_PROMPT,
"stream": False,
"options": {"temperature": 0.1, "num_predict": 400},
}
try:
t0 = time.time()
resp = requests.post(OLLAMA_URL, json=payload, timeout=60)
elapsed = time.time() - t0
if resp.status_code != 200:
return {"error": f"HTTP {resp.status_code}"}
raw = resp.json().get("response", "")
# Extract JSON from response (strip thinking tags if present)
if "<think>" in raw:
raw = raw[raw.rfind("</think>") + 8:].strip()
# Find JSON block
start = raw.find("{")
end = raw.rfind("}") + 1
if start == -1 or end == 0:
return {"error": "no JSON in response", "raw": raw[:200]}
result = json.loads(raw[start:end])
result["_elapsed_s"] = round(elapsed, 2)
return result
except json.JSONDecodeError as e:
return {"error": f"JSON parse error: {e}", "raw": raw[:200]}
except Exception as e:
return {"error": str(e)}
# ── Main Test ─────────────────────────────────────────────────────────────────
def main():
print(f"Loading dataset...")
df = pd.read_parquet(
"data/parquet/midlarge-liquid-long-v1_bucketfix_full_audit_tier3/train.parquet",
columns=["event_id", "ticker", "event_date", "event_type", "event_direction",
"parse_confidence_overall", "guidance_status", "fwd_return_5d",
"document_quality_score", "signal_strength_score"]
)
print(f"Total events: {len(df)}")
# Build sample: mix of confidence levels and outcomes
# Group 1: High confidence, bullish (LLM should agree with rule)
g1 = df[
(df["parse_confidence_overall"] >= 0.75) &
(df["event_direction"] == "bullish") &
(df["event_type"] == "earnings_release") &
(df["fwd_return_5d"] > 0.03)
].sample(n=15, random_state=42)
# Group 2: Low confidence, currently filtered (conf 0.40-0.60), positive outcome
g2 = df[
(df["parse_confidence_overall"] >= 0.40) &
(df["parse_confidence_overall"] < 0.60) &
(df["fwd_return_5d"] > 0.03)
].sample(n=15, random_state=42)
# Group 3: Low confidence, negative outcome (these should stay filtered)
g3 = df[
(df["parse_confidence_overall"] >= 0.40) &
(df["parse_confidence_overall"] < 0.60) &
(df["fwd_return_5d"] < -0.02)
].sample(n=10, random_state=42)
# Group 4: Guidance raised cases (test guidance detection)
g4 = df[
(df["guidance_status"] == "raised") &
(df["parse_confidence_overall"] >= 0.65)
].sample(n=10, random_state=42)
sample = pd.concat([g1, g2, g3, g4]).drop_duplicates(subset="event_id")
sample["group"] = (
["high_conf_bullish"] * len(g1) +
["low_conf_positive"] * len(g2) +
["low_conf_negative"] * len(g3) +
["guidance_raised"] * len(g4)
)[:len(sample)]
print(f"Sample size: {len(sample)} events\n")
results = []
for i, row in enumerate(sample.itertuples()):
accession_no = extract_accession_no(row.event_id)
if not accession_no:
continue
text = get_exhibit_text(accession_no)
if not text:
print(f" [{i+1}/{len(sample)}] {row.ticker} {row.event_date} — no exhibit, skip")
continue
# Rule parser
rule_result = run_rule_parser(accession_no)
# LLM parser
llm_result = run_llm_parser(text)
# Agreement checks
direction_agree = (
rule_result.get("event_direction") == llm_result.get("event_direction")
if "error" not in llm_result else None
)
guidance_agree = (
rule_result.get("guidance_status") == llm_result.get("guidance_status")
if "error" not in llm_result else None
)
llm_has_eps = (
llm_result.get("eps_beat_pct") is not None
if "error" not in llm_result else False
)
record = {
"ticker": row.ticker,
"date": str(row.event_date),
"accession": accession_no,
"true_event_type": row.event_type,
"true_direction": row.event_direction,
"true_guidance": row.guidance_status,
"rule_conf": round(row.parse_confidence_overall, 3),
"fwd5d": round(row.fwd_return_5d * 100, 2),
# Rule parser
"rule_direction": rule_result.get("event_direction"),
"rule_guidance": rule_result.get("guidance_status"),
# LLM parser
"llm_direction": llm_result.get("event_direction"),
"llm_guidance": llm_result.get("guidance_status"),
"llm_conf": llm_result.get("confidence"),
"llm_eps_beat": llm_result.get("eps_beat_pct"),
"llm_rev_beat": llm_result.get("revenue_beat_pct"),
"llm_signal": llm_result.get("key_signal", ""),
"llm_elapsed": llm_result.get("_elapsed_s"),
"llm_error": llm_result.get("error"),
# Agreement
"direction_agree": direction_agree,
"guidance_agree": guidance_agree,
"llm_has_eps": llm_has_eps,
"group": getattr(row, "group", "?"),
}
results.append(record)
agree_str = "" if direction_agree else ("" if direction_agree is False else "?")
eps_str = f" EPS:{llm_result.get('eps_beat_pct'):+.1f}%" if llm_has_eps else ""
err_str = f" ERR:{llm_result.get('error','')[:40]}" if llm_result.get("error") else ""
print(f" [{i+1:2d}/{len(sample)}] {row.ticker:6s} {row.event_date} "
f"rule_conf={row.parse_confidence_overall:.2f} fwd5d={row.fwd_return_5d*100:+.1f}%"
f" | dir: {rule_result.get('event_direction','?'):8s}{llm_result.get('event_direction','?'):8s} {agree_str}"
f" | guid: {rule_result.get('guidance_status','?'):10s}{llm_result.get('guidance_status','?'):10s}"
f"{eps_str}{err_str}")
if not results:
print("No results!")
return
# ── Summary ───────────────────────────────────────────────────────────────
rdf = pd.DataFrame(results)
print("\n" + "="*80)
print("SUMMARY")
print("="*80)
valid = rdf[rdf["llm_error"].isna()]
print(f"\nProcessed: {len(results)}, LLM errors: {len(rdf[rdf['llm_error'].notna()])}")
print(f"Avg LLM latency: {valid['llm_elapsed'].mean():.1f}s")
print(f"\n── Direction Agreement ──")
print(f" Overall agreement: {valid['direction_agree'].mean()*100:.1f}%")
for grp in valid['group'].unique():
sub = valid[valid['group'] == grp]
agree_pct = sub['direction_agree'].mean() * 100
print(f" {grp:25s}: {agree_pct:.1f}% ({len(sub)} events)")
print(f"\n── Guidance Agreement ──")
print(f" Overall: {valid['guidance_agree'].mean()*100:.1f}%")
print(f"\n── EPS Extraction (LLM only) ──")
eps_rows = valid[valid["llm_has_eps"]]
print(f" EPS extracted in {len(eps_rows)}/{len(valid)} events ({len(eps_rows)/len(valid)*100:.1f}%)")
if len(eps_rows) > 0:
beats = eps_rows[eps_rows["llm_eps_beat"] > 0]
misses = eps_rows[eps_rows["llm_eps_beat"] < 0]
print(f" Beats: {len(beats)}, Misses: {len(misses)}")
print(f" Avg beat %: {eps_rows['llm_eps_beat'].mean():+.2f}%")
print(f"\n── Low-Confidence Case Analysis (rule_conf < 0.60) ──")
low_conf = valid[valid["rule_conf"] < 0.60]
if len(low_conf) > 0:
print(f" Events currently filtered (conf<0.60): {len(low_conf)}")
print(f" LLM avg confidence on these: {low_conf['llm_conf'].mean():.2f}")
would_rescue = low_conf[low_conf["llm_conf"] >= 0.70]
print(f" LLM would rescue (LLM conf ≥ 0.70): {len(would_rescue)}")
if len(would_rescue) > 0:
correct = would_rescue[
(would_rescue["llm_direction"] == "bullish") &
(would_rescue["fwd5d"] > 0)
]
print(f" Of rescued: correct direction + positive outcome: {len(correct)}/{len(would_rescue)}")
print(f"\n── Direction Disagreements ──")
disagree = valid[valid["direction_agree"] == False]
if len(disagree) > 0:
for _, row in disagree.iterrows():
rule_dir = row['rule_direction'] or "?"
llm_dir = row['llm_direction'] or "?"
print(f" {row['ticker']:6s} {row['date']} rule={rule_dir:8s} "
f"llm={llm_dir:8s} fwd5d={row['fwd5d']:+.1f}% "
f"[{row['group']}]")
if row["llm_signal"]:
print(f" LLM says: {row['llm_signal'][:100]}")
# Save full results
out_path = "data/analysis/llm_parser_comparison.csv"
os.makedirs("data/analysis", exist_ok=True)
rdf.to_csv(out_path, index=False)
print(f"\nFull results saved to: {out_path}")
if __name__ == "__main__":
main()