| """analytics/deltas.py — deterministic delta calculations. |
| |
| Pure Python, no LLM calls, no Streamlit imports. |
| Computes MetricDelta, EpsSurprise, GuidanceChange, and QuarterSnapshot |
| from metrics_db rows and alphavantage earnings data. |
| """ |
|
|
| from dataclasses import dataclass |
| from typing import Optional |
|
|
| |
| |
| |
|
|
| @dataclass |
| class MetricDelta: |
| label: str |
| current: Optional[float] |
| prior: Optional[float] |
| delta_pct: Optional[float] |
| direction: str |
| favorable: bool |
| significant: bool |
| period_basis: str |
| unit: str |
|
|
|
|
| @dataclass |
| class EpsSurprise: |
| latest_beat_pct: float |
| beat_streak: int |
| avg_4q_surprise: float |
|
|
|
|
| @dataclass |
| class GuidanceChange: |
| disclosed_change: str |
| latest_verdict: Optional[str] |
| prior_verdict: Optional[str] = None |
| prior_actual_result: Optional[str] = None |
|
|
|
|
| @dataclass |
| class QuarterSnapshot: |
| ticker: str |
| period: str |
| filing_date: str |
| metric_deltas: list[MetricDelta] |
| eps_surprise: Optional[EpsSurprise] |
| guidance_change: Optional[GuidanceChange] |
| new_risks_count: int |
|
|
|
|
| |
| |
| |
|
|
| def _parse_period(period: str) -> Optional[tuple[int, int]]: |
| """Parse "Q12025" -> (1, 2025). Returns None for "FY2024" or unparseable.""" |
| if not period or not period.startswith("Q"): |
| return None |
| try: |
| |
| body = period[1:] |
| year = int(body[-4:]) |
| quarter = int(body[:-4]) |
| if quarter < 1 or quarter > 4: |
| return None |
| return (quarter, year) |
| except (ValueError, IndexError): |
| return None |
|
|
|
|
| def _prior_yoy_period(quarter: int, year: int) -> str: |
| """Return the period string for the same quarter one year prior.""" |
| return f"Q{quarter}{year - 1}" |
|
|
|
|
| def _previous_quarter_period(period: str) -> Optional[str]: |
| parsed = _parse_period(period) |
| if parsed is None: |
| return None |
| quarter, year = parsed |
| return f"Q{quarter - 1}{year}" if quarter > 1 else f"Q4{year - 1}" |
|
|
|
|
| def _period_sort_key(period: str) -> tuple[int, int]: |
| parsed = _parse_period(period) |
| if parsed is None: |
| return (-1, -1) |
| quarter, year = parsed |
| return (year, quarter) |
|
|
|
|
| _ADDITIVE_Q4_METRICS = ( |
| "revenue", "free_cash_flow", "capex", "buybacks", "dividends_paid", |
| ) |
|
|
|
|
| def _derive_virtual_q4_row(annual: dict, quarters: list[dict]) -> Optional[dict]: |
| period = annual.get("period", "") |
| if not period.startswith("FY") or not period[2:].isdigit(): |
| return None |
| year = int(period[2:]) |
| by_period = {q.get("period"): q for q in quarters} |
| required = [by_period.get(f"Q{q}{year}") for q in (1, 2, 3)] |
| if any(row is None for row in required): |
| return None |
|
|
| q4 = dict(annual) |
| q4.update({ |
| "period": f"Q4{year}", |
| "form_type": "DERIVED-Q4", |
| "period_basis": "quarter", |
| "data_quality_status": "DERIVED", |
| "_derived": True, |
| "eps": None, |
| "shares_diluted": None, |
| "effective_tax_rate": None, |
| "interest_expense": None, |
| }) |
| for key in _ADDITIVE_Q4_METRICS: |
| annual_value = annual.get(key) |
| quarter_values = [row.get(key) for row in required] |
| q4[key] = ( |
| annual_value - sum(quarter_values) |
| if annual_value is not None and all(v is not None for v in quarter_values) |
| else None |
| ) |
|
|
| q4_revenue = q4.get("revenue") |
| for margin_key in ("gross_margin", "operating_margin"): |
| annual_margin = annual.get(margin_key) |
| annual_revenue = annual.get("revenue") |
| quarter_profits = [] |
| for row in required: |
| rev, margin = row.get("revenue"), row.get(margin_key) |
| if rev is None or margin is None: |
| quarter_profits = [] |
| break |
| quarter_profits.append(rev * margin) |
| if ( |
| annual_margin is not None and annual_revenue is not None |
| and q4_revenue not in (None, 0) and len(quarter_profits) == 3 |
| ): |
| q4[margin_key] = (annual_revenue * annual_margin - sum(quarter_profits)) / q4_revenue |
| else: |
| q4[margin_key] = None |
| q4["revenue_yoy_pct"] = None |
| q4["quality_warnings"] = ["virtual_q4:derived_from_fy_minus_q1_q2_q3"] |
| return q4 |
|
|
|
|
| def _quarter_rows_with_virtual_q4(rows: list[dict]) -> list[dict]: |
| quarterly = [ |
| dict(r) for r in rows |
| if r.get("form_type") in {"10-Q", "DERIVED-Q4"} |
| ] |
| existing = {r.get("period") for r in quarterly} |
| for annual in (r for r in rows if r.get("form_type") == "10-K"): |
| fy = annual.get("period", "") |
| q4_period = f"Q4{fy[2:]}" if fy.startswith("FY") else "" |
| if not q4_period or q4_period in existing: |
| continue |
| derived = _derive_virtual_q4_row(annual, quarterly) |
| if derived: |
| quarterly.append(derived) |
| existing.add(q4_period) |
| quarterly.sort(key=lambda r: _period_sort_key(r.get("period", "")), reverse=True) |
| return quarterly |
|
|
|
|
| def _compute_delta( |
| label: str, |
| current: Optional[float], |
| prior: Optional[float], |
| is_margin: bool, |
| favorable_direction: str, |
| threshold: float, |
| period_basis: str, |
| unit: str, |
| ) -> Optional["MetricDelta"]: |
| """ |
| Compute a MetricDelta from raw current/prior values. |
| |
| For margins: delta = (current - prior) * 100 (percentage points). |
| For other metrics: delta_pct = (current - prior) / abs(prior) * 100. |
| |
| Returns None if either value is None, or prior is 0 for non-margin metrics. |
| """ |
| if current is None or prior is None: |
| return None |
| if not is_margin and prior == 0: |
| return None |
|
|
| delta = (current - prior) * 100 if is_margin else (current - prior) / abs(prior) * 100 |
|
|
| if delta > 0: |
| direction = "up" |
| elif delta < 0: |
| direction = "down" |
| else: |
| direction = "flat" |
|
|
| if favorable_direction == "neutral": |
| favorable = True |
| elif direction == "flat": |
| favorable = False |
| else: |
| favorable = (direction == favorable_direction) |
|
|
| return MetricDelta( |
| label=label, |
| current=current, |
| prior=prior, |
| delta_pct=delta, |
| direction=direction, |
| favorable=favorable, |
| significant=abs(delta) >= threshold, |
| period_basis=period_basis, |
| unit=unit, |
| ) |
|
|
|
|
| |
| |
| _METRIC_CONFIG = [ |
| |
| ("revenue", "Revenue", "$B", False, "up", 5.0, 1e9), |
| ("eps", "EPS", "$", False, "up", 5.0, 1.0), |
| ("gross_margin", "Gross Margin", "pp", True, "up", 1.0, 1.0), |
| ("operating_margin","Op. Margin", "pp", True, "up", 1.0, 1.0), |
| ("free_cash_flow", "Free Cash Flow", "$B", False, "up", 10.0, 1e9), |
| ("capex", "CapEx", "$B", False, "neutral", 10.0, 1e9), |
| ("buybacks", "Buybacks", "$B", False, "up", 20.0, 1e9), |
| ("dividends_paid", "Dividends", "$B", False, "up", 10.0, 1e9), |
| ("total_debt", "Total Debt", "$B", False, "down", 5.0, 1e9), |
| ("shares_diluted", "Shares Out.", "M", False, "down", 1.0, 1e6), |
| ] |
|
|
| |
| _PRIORITY = [ |
| "revenue", "eps", "operating_margin", "gross_margin", "free_cash_flow", |
| "buybacks", "total_debt", "shares_diluted", "capex", "dividends_paid", |
| ] |
|
|
|
|
| |
| |
| |
|
|
| def compute_metric_deltas(ticker: str) -> list: |
| """ |
| Compute MetricDelta for each tracked metric using metrics_db data. |
| |
| Uses YoY comparison as primary, QoQ as fallback. |
| Returns up to 8 MetricDelta objects, prioritised by significance then metric priority. |
| """ |
| from storage.metrics_db import get_all_metrics |
|
|
| try: |
| all_rows = get_all_metrics(ticker) |
| except Exception: |
| return [] |
|
|
| if not all_rows: |
| return [] |
|
|
| |
| |
| quarterly = _quarter_rows_with_virtual_q4(all_rows) |
|
|
| if not quarterly: |
| return [] |
|
|
| latest = quarterly[0] |
| latest_period = latest.get("period", "") |
| parsed = _parse_period(latest_period) |
|
|
| |
| |
| |
| period_lookup: dict[str, dict] = {} |
| for r in quarterly: |
| p = r.get("period", "") |
| if p and p not in period_lookup: |
| period_lookup[p] = r |
|
|
| results_yoy: dict[str, MetricDelta] = {} |
| results_qoq: dict[str, MetricDelta] = {} |
|
|
| for db_key, label, unit, is_margin, fav_dir, threshold, divisor in _METRIC_CONFIG: |
| current_raw = latest.get(db_key) |
| if current_raw is None: |
| continue |
|
|
| current_val = current_raw / divisor if divisor != 1.0 else current_raw |
|
|
| |
| if parsed is not None: |
| quarter_num, year = parsed |
| yoy_period = _prior_yoy_period(quarter_num, year) |
| yoy_row = period_lookup.get(yoy_period) |
| if yoy_row is not None: |
| prior_raw = yoy_row.get(db_key) |
| if prior_raw is not None: |
| prior_val = prior_raw / divisor if divisor != 1.0 else prior_raw |
| delta = _compute_delta( |
| label, current_val, prior_val, |
| is_margin, fav_dir, threshold, "YoY", unit, |
| ) |
| if delta is not None: |
| results_yoy[db_key] = delta |
|
|
| |
| if db_key not in results_yoy: |
| prior_period = _previous_quarter_period(latest_period) |
| prior_row = period_lookup.get(prior_period) if prior_period else None |
| else: |
| prior_row = None |
| if prior_row is not None: |
| prior_raw = prior_row.get(db_key) |
| if prior_raw is not None: |
| prior_val = prior_raw / divisor if divisor != 1.0 else prior_raw |
| delta = _compute_delta( |
| label, current_val, prior_val, |
| is_margin, fav_dir, threshold, "QoQ", unit, |
| ) |
| if delta is not None: |
| results_qoq[db_key] = delta |
|
|
| |
| all_deltas: dict[str, MetricDelta] = {**results_qoq, **results_yoy} |
|
|
| |
| priority_map = {key: idx for idx, key in enumerate(_PRIORITY)} |
|
|
| def sort_key(item: tuple[str, MetricDelta]) -> tuple[int, int]: |
| db_key, delta = item |
| sig_rank = 0 if delta.significant else 1 |
| prio_rank = priority_map.get(db_key, len(_PRIORITY)) |
| return (sig_rank, prio_rank) |
|
|
| sorted_deltas = sorted(all_deltas.items(), key=sort_key) |
| return [delta for _, delta in sorted_deltas[:8]] |
|
|
|
|
| def compute_eps_surprise(ticker: str) -> Optional[EpsSurprise]: |
| """ |
| Compute EpsSurprise from Alpha Vantage quarterly earnings data. |
| |
| Returns None if data is unavailable or parsing fails. |
| """ |
| try: |
| from ingestion.alphavantage import fetch_earnings |
| data, _err = fetch_earnings(ticker) |
| if data is None: |
| return None |
|
|
| quarterly = data.get("quarterlyEarnings") |
| if not quarterly: |
| return None |
|
|
| |
| parsed_surprises: list[float] = [] |
| for item in quarterly: |
| raw = item.get("surprisePercentage") |
| if raw is None: |
| continue |
| try: |
| parsed_surprises.append(float(raw)) |
| except (ValueError, TypeError): |
| continue |
|
|
| if not parsed_surprises: |
| return None |
|
|
| latest_beat_pct = parsed_surprises[0] |
|
|
| |
| beat_streak = 0 |
| for val in parsed_surprises: |
| if val > 0: |
| beat_streak += 1 |
| else: |
| break |
|
|
| |
| avg_4q_surprise = sum(parsed_surprises[:4]) / min(len(parsed_surprises), 4) |
|
|
| return EpsSurprise( |
| latest_beat_pct=latest_beat_pct, |
| beat_streak=beat_streak, |
| avg_4q_surprise=avg_4q_surprise, |
| ) |
| except Exception: |
| return None |
|
|
|
|
| def compute_guidance_change(ticker: str, brief: dict) -> GuidanceChange: |
| """ |
| Determine whether guidance was newly disclosed, withdrawn, maintained, or absent |
| by comparing the two most recent 10-Q rows. |
| """ |
| if not isinstance(brief, dict): |
| brief = {} |
|
|
| from storage.metrics_db import get_all_metrics |
|
|
| try: |
| all_rows = get_all_metrics(ticker) |
| except Exception: |
| all_rows = [] |
|
|
| quarterly = _quarter_rows_with_virtual_q4(all_rows) |
| latest_row = quarterly[0] if quarterly else None |
| previous_period = _previous_quarter_period(latest_row.get("period", "")) if latest_row else None |
| prior_row = next( |
| (r for r in quarterly if r.get("period") == previous_period), None |
| ) if previous_period else None |
|
|
| if latest_row is None: |
| disclosed_change = "absent" |
| elif prior_row is None: |
| if latest_row.get("guidance_disclosed"): |
| disclosed_change = "newly_disclosed" |
| else: |
| disclosed_change = "absent" |
| elif latest_row.get("guidance_disclosed") and not prior_row.get("guidance_disclosed"): |
| disclosed_change = "newly_disclosed" |
| elif not latest_row.get("guidance_disclosed") and prior_row.get("guidance_disclosed"): |
| disclosed_change = "withdrawn" |
| elif latest_row.get("guidance_disclosed") and prior_row.get("guidance_disclosed"): |
| disclosed_change = "maintained" |
| else: |
| disclosed_change = "absent" |
|
|
| latest_verdict: Optional[str] = None |
| prior_verdict: Optional[str] = None |
| prior_actual_result: Optional[str] = None |
| guidance_history = brief.get("guidance_history") |
| if guidance_history and isinstance(guidance_history, list): |
| if guidance_history: |
| latest_verdict = guidance_history[0].get("verdict") |
| if len(guidance_history) >= 2: |
| prior_verdict = guidance_history[1].get("verdict") |
| prior_actual_result = guidance_history[1].get("actual_result") |
|
|
| return GuidanceChange( |
| disclosed_change=disclosed_change, |
| latest_verdict=latest_verdict, |
| prior_verdict=prior_verdict, |
| prior_actual_result=prior_actual_result, |
| ) |
|
|
|
|
| def compute_risk_diff(brief: dict) -> int: |
| """Return the count of risks flagged as new in the current filing.""" |
| if not isinstance(brief, dict): |
| return 0 |
| return sum(1 for r in brief.get("risks_categorized", []) if r.get("is_new_this_filing")) |
|
|
|
|
| def build_quarter_snapshot(ticker: str, brief: dict) -> Optional[QuarterSnapshot]: |
| """ |
| Facade: assemble a QuarterSnapshot from all delta sub-computations. |
| |
| Returns None on any exception so callers never crash. |
| """ |
| try: |
| from storage.metrics_db import get_all_metrics |
|
|
| rows = get_all_metrics(ticker) |
| if not rows: |
| return None |
|
|
| quarterly = _quarter_rows_with_virtual_q4(rows) |
| if not quarterly: |
| return None |
| latest = quarterly[0] |
| metric_deltas = compute_metric_deltas(ticker) |
| eps_surprise = compute_eps_surprise(ticker) |
| guidance_change = compute_guidance_change(ticker, brief) |
| new_risks_count = compute_risk_diff(brief) |
|
|
| return QuarterSnapshot( |
| ticker=ticker, |
| period=latest.get("period", ""), |
| filing_date=latest.get("filing_date", ""), |
| metric_deltas=metric_deltas, |
| eps_surprise=eps_surprise, |
| guidance_change=guidance_change, |
| new_risks_count=new_risks_count, |
| ) |
| except Exception: |
| return None |
|
|