File size: 12,689 Bytes
35676b4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7880373
35676b4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7880373
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35676b4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
"""tests/test_analytics_deltas.py β€” unit tests for analytics/deltas.py.

Uses a temporary SQLite DB with synthetic data.
No network calls; alphavantage is mocked where needed.
"""

import pytest
from pathlib import Path
from unittest.mock import patch

from analytics.deltas import (
    compute_metric_deltas,
    compute_eps_surprise,
    compute_guidance_change,
    compute_risk_diff,
    build_quarter_snapshot,
    MetricDelta,
    EpsSurprise,
    GuidanceChange,
    QuarterSnapshot,
    _derive_virtual_q4_row,
)
from storage import metrics_db
from storage.metrics_db import init_db, upsert_metrics


# ---------------------------------------------------------------------------
# Synthetic test data
# ---------------------------------------------------------------------------

QUARTERLY_ROWS = [
    # (period, filing_date, revenue, eps, gross_margin, operating_margin,
    #  free_cash_flow, capex, buybacks, dividends_paid, total_debt,
    #  shares_diluted, guidance_disclosed)
    ("Q12025", "2025-02-01", 100e9,  2.50, 0.30, 0.20, 20e9,  5e9,  8e9,  2e9,  50e9,  15_000e6, 1),  # latest
    ("Q12024", "2024-02-01",  90e9,  2.20, 0.28, 0.18, 18e9,  4.5e9, 6e9, 1.8e9, 55e9, 15_200e6, 1),  # YoY peer
    ("Q42024", "2024-11-01",  95e9,  2.40, 0.29, 0.19, 19e9,  4.8e9, 7e9,  1.9e9, 52e9, 15_100e6, 1),  # QoQ peer
    ("Q32024", "2024-08-01",  85e9,  2.10, 0.27, 0.17, 17e9,  4.2e9, 5e9,  1.7e9, 57e9, 15_300e6, 0),
    ("Q22024", "2024-05-01",  80e9,  2.00, 0.26, 0.16, 15e9,  4.0e9, 4e9,  1.5e9, 60e9, 15_400e6, 0),
]


def _make_row(period, filing_date, revenue, eps, gross_margin, operating_margin,
              free_cash_flow, capex, buybacks, dividends_paid, total_debt,
              shares_diluted, guidance_disclosed):
    return {
        "ticker": "TEST",
        "company_name": "Test Corp",
        "period": period,
        "filing_date": filing_date,
        "form_type": "10-Q",
        "revenue": revenue,
        "revenue_yoy_pct": None,
        "eps": eps,
        "gross_margin": gross_margin,
        "operating_margin": operating_margin,
        "free_cash_flow": free_cash_flow,
        "capex": capex,
        "buybacks": buybacks,
        "dividends_paid": dividends_paid,
        "total_debt": total_debt,
        "shares_diluted": shares_diluted,
        "guidance_disclosed": guidance_disclosed,
        "guidance_text": None,
        "ingested_at": "2026-05-05T00:00:00",
        "effective_tax_rate": None,
        "interest_expense": None,
        "stockholders_equity": None,
    }


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------

@pytest.fixture
def tmp_metrics_db(tmp_path, monkeypatch):
    """Create a temp SQLite DB, patch DB_PATH, and insert synthetic rows."""
    db_path = tmp_path / "metrics.db"
    monkeypatch.setattr(metrics_db, "DB_PATH", db_path)
    init_db()
    for row_args in QUARTERLY_ROWS:
        upsert_metrics(_make_row(*row_args))
    yield db_path


@pytest.fixture
def single_row_db(tmp_path, monkeypatch):
    """Temp DB with exactly one row β€” no comparison possible."""
    db_path = tmp_path / "metrics_single.db"
    monkeypatch.setattr(metrics_db, "DB_PATH", db_path)
    init_db()
    upsert_metrics(_make_row(
        "Q12025", "2025-02-01",
        100e9, 2.50, 0.30, 0.20, 20e9, 5e9, 8e9, 2e9, 50e9, 15_000e6, 1,
    ))
    # Override ticker so it doesn't collide with "TEST"
    row = _make_row("Q12025", "2025-02-01", 100e9, 2.50, 0.30, 0.20,
                    20e9, 5e9, 8e9, 2e9, 50e9, 15_000e6, 1)
    row["ticker"] = "SINGLE"
    upsert_metrics(row)
    yield db_path


@pytest.fixture
def withdrawn_guidance_db(tmp_path, monkeypatch):
    """Temp DB where the latest row has guidance_disclosed=0 but prior has 1."""
    db_path = tmp_path / "metrics_withdrawn.db"
    monkeypatch.setattr(metrics_db, "DB_PATH", db_path)
    init_db()
    # latest: guidance withdrawn
    row_latest = _make_row("Q12025", "2025-02-01", 100e9, 2.50, 0.30, 0.20,
                            20e9, 5e9, 8e9, 2e9, 50e9, 15_000e6, 0)
    row_latest["ticker"] = "WTEST"
    # prior: guidance was present
    row_prior = _make_row("Q42024", "2024-11-01", 95e9, 2.40, 0.29, 0.19,
                           19e9, 4.8e9, 7e9, 1.9e9, 52e9, 15_100e6, 1)
    row_prior["ticker"] = "WTEST"
    upsert_metrics(row_latest)
    upsert_metrics(row_prior)
    yield db_path


# ---------------------------------------------------------------------------
# Helper
# ---------------------------------------------------------------------------

def _find_delta(deltas: list, label: str) -> MetricDelta:
    """Return the first MetricDelta whose label matches, or raise AssertionError."""
    for d in deltas:
        if d.label == label:
            return d
    raise AssertionError(f"No MetricDelta with label={label!r}; available: {[d.label for d in deltas]}")


# ---------------------------------------------------------------------------
# compute_metric_deltas
# ---------------------------------------------------------------------------

def test_compute_metric_deltas_yoy(tmp_metrics_db):
    deltas = compute_metric_deltas("TEST")

    assert deltas, "Expected non-empty list of MetricDelta"
    assert any(d.period_basis == "YoY" for d in deltas), "Expected at least one YoY delta"

    rev = _find_delta(deltas, "Revenue")
    assert rev.period_basis == "YoY"
    assert abs(rev.current - 100.0) < 0.01,  f"current={rev.current}"
    assert abs(rev.prior  -  90.0) < 0.01,   f"prior={rev.prior}"
    assert abs(rev.delta_pct - 11.11) < 0.1, f"delta_pct={rev.delta_pct}"
    assert rev.direction == "up"
    assert rev.favorable is True


def test_compute_metric_deltas_margin_pp(tmp_metrics_db):
    deltas = compute_metric_deltas("TEST")
    op = _find_delta(deltas, "Op. Margin")

    # 0.20 - 0.18 = 0.02 * 100 = 2.0 pp
    assert abs(op.delta_pct - 2.0) < 0.01, f"delta_pct={op.delta_pct}"
    assert op.unit == "pp"


def test_compute_metric_deltas_debt_direction(tmp_metrics_db):
    deltas = compute_metric_deltas("TEST")
    debt = _find_delta(deltas, "Total Debt")

    # current=50B, prior=55B β†’ down β†’ favorable (lower debt is good)
    assert debt.direction == "down"
    assert debt.favorable is True
    assert abs(debt.delta_pct - (-9.09)) < 0.1, f"delta_pct={debt.delta_pct}"


def test_compute_metric_deltas_single_quarter(single_row_db):
    """Only one row β†’ no comparison possible β†’ empty list."""
    result = compute_metric_deltas("SINGLE")
    assert result == []


def test_compute_metric_deltas_empty():
    """Ticker not in DB β†’ returns empty list."""
    result = compute_metric_deltas("NONEXISTENT")
    assert result == []


def test_qoq_requires_exact_adjacent_period(tmp_path, monkeypatch):
    db_path = tmp_path / "metrics_gap.db"
    monkeypatch.setattr(metrics_db, "DB_PATH", db_path)
    init_db()
    for period, filing_date, revenue in (
        ("Q32025", "2025-10-20", 120e9),
        ("Q12025", "2025-04-20", 100e9),
    ):
        row = _make_row(
            period, filing_date, revenue, None, None, None,
            None, None, None, None, None, None, 0,
        )
        row["ticker"] = "GAP"
        upsert_metrics(row)

    assert compute_metric_deltas("GAP") == []


def test_qoq_uses_exact_q4_for_q1(tmp_path, monkeypatch):
    db_path = tmp_path / "metrics_adjacent.db"
    monkeypatch.setattr(metrics_db, "DB_PATH", db_path)
    init_db()
    for period, filing_date, revenue in (
        ("Q12025", "2025-04-20", 100e9),
        ("Q42024", "2025-02-01", 80e9),
    ):
        row = _make_row(
            period, filing_date, revenue, None, None, None,
            None, None, None, None, None, None, 0,
        )
        row["ticker"] = "ADJ"
        upsert_metrics(row)

    revenue = _find_delta(compute_metric_deltas("ADJ"), "Revenue")
    assert revenue.period_basis == "QoQ"
    assert revenue.prior == 80.0


def test_virtual_q4_derives_additive_metrics_and_margins():
    quarters = [
        {"period": "Q12025", "revenue": 20.0, "gross_margin": 0.50,
         "operating_margin": 0.25, "free_cash_flow": 3.0, "capex": 1.0,
         "buybacks": 1.0, "dividends_paid": 0.2},
        {"period": "Q22025", "revenue": 25.0, "gross_margin": 0.52,
         "operating_margin": 0.28, "free_cash_flow": 4.0, "capex": 1.2,
         "buybacks": 1.5, "dividends_paid": 0.2},
        {"period": "Q32025", "revenue": 30.0, "gross_margin": 0.54,
         "operating_margin": 0.30, "free_cash_flow": 5.0, "capex": 1.3,
         "buybacks": 2.0, "dividends_paid": 0.2},
    ]
    annual = {
        "period": "FY2025", "form_type": "10-K", "filing_date": "2026-02-01",
        "revenue": 110.0, "gross_margin": 0.55, "operating_margin": 0.31,
        "free_cash_flow": 20.0, "capex": 5.0, "buybacks": 7.0,
        "dividends_paid": 0.8, "eps": 10.0, "shares_diluted": 100.0,
    }
    q4 = _derive_virtual_q4_row(annual, quarters)
    assert q4["period"] == "Q42025"
    assert q4["revenue"] == 35.0
    assert q4["free_cash_flow"] == 8.0
    assert q4["capex"] == 1.5
    assert q4["eps"] is None
    expected_gp = (110.0 * 0.55 - (20.0 * 0.50 + 25.0 * 0.52 + 30.0 * 0.54)) / 35.0
    assert q4["gross_margin"] == pytest.approx(expected_gp)


# ---------------------------------------------------------------------------
# compute_eps_surprise
# ---------------------------------------------------------------------------

_SURPRISE_DATA = {
    "quarterlyEarnings": [
        {"surprisePercentage": "5.2"},
        {"surprisePercentage": "3.1"},
        {"surprisePercentage": "2.8"},
        {"surprisePercentage": "-1.5"},
        {"surprisePercentage": "4.0"},
    ]
}


@patch("ingestion.alphavantage.fetch_earnings", return_value=(_SURPRISE_DATA, None))
def test_compute_eps_surprise_beat_streak(mock_fetch):
    result = compute_eps_surprise("TEST")

    assert result is not None
    assert abs(result.latest_beat_pct - 5.2) < 0.01
    assert result.beat_streak == 3, f"beat_streak={result.beat_streak}"
    # avg of first 4: (5.2 + 3.1 + 2.8 + -1.5) / 4 = 9.6 / 4 = 2.4
    assert abs(result.avg_4q_surprise - 2.4) < 0.01, f"avg_4q={result.avg_4q_surprise}"


@patch("ingestion.alphavantage.fetch_earnings",
       return_value=({"quarterlyEarnings": []}, None))
def test_compute_eps_surprise_returns_none_on_empty(mock_fetch):
    result = compute_eps_surprise("TEST")
    assert result is None


# ---------------------------------------------------------------------------
# compute_guidance_change
# ---------------------------------------------------------------------------

def test_compute_guidance_change_maintained(tmp_metrics_db):
    # Q12025 guidance_disclosed=1, Q42024 guidance_disclosed=1 β†’ maintained
    result = compute_guidance_change("TEST", {})
    assert result.disclosed_change == "maintained"


def test_compute_guidance_change_withdrawn(withdrawn_guidance_db):
    # Q12025 guidance_disclosed=0, Q42024 guidance_disclosed=1 β†’ withdrawn
    result = compute_guidance_change("WTEST", {})
    assert result.disclosed_change == "withdrawn"


# ---------------------------------------------------------------------------
# compute_risk_diff
# ---------------------------------------------------------------------------

def test_compute_risk_diff_counts_new():
    brief = {
        "risks_categorized": [
            {"is_new_this_filing": True},
            {"is_new_this_filing": False},
            {"is_new_this_filing": True},
        ]
    }
    assert compute_risk_diff(brief) == 2


def test_compute_risk_diff_empty_brief():
    assert compute_risk_diff({}) == 0


def test_compute_risk_diff_none():
    assert compute_risk_diff(None) == 0


# ---------------------------------------------------------------------------
# build_quarter_snapshot
# ---------------------------------------------------------------------------

@patch("analytics.deltas.compute_metric_deltas", return_value=[])
def test_build_quarter_snapshot_none_on_no_data(mock_deltas, tmp_path, monkeypatch):
    """No DB rows β†’ returns None."""
    db_path = tmp_path / "empty.db"
    monkeypatch.setattr(metrics_db, "DB_PATH", db_path)
    init_db()
    result = build_quarter_snapshot("NONEXISTENT", {})
    assert result is None


@patch("analytics.deltas.compute_eps_surprise", return_value=None)
def test_build_quarter_snapshot_success(mock_eps, tmp_metrics_db):
    snapshot = build_quarter_snapshot("TEST", {})

    assert snapshot is not None
    assert isinstance(snapshot, QuarterSnapshot)
    assert snapshot.ticker == "TEST"
    assert snapshot.period == "Q12025"
    assert snapshot.filing_date == "2025-02-01"