Files
2026-08-31 14:33:53 +09:00

356 lines
15 KiB
Python

"""Tests for us2kr.store.Store (temp sqlite; no network)."""
from __future__ import annotations
import json
import sqlite3
from datetime import date, datetime, timedelta
from pathlib import Path
from zoneinfo import ZoneInfo
import pytest
from us2kr.models import BriefingResult, MarketPayload, Quote, Report
from us2kr.store import Store, _contiguous_tail, _level_cross_notes, _Row
from us2kr.symbols import display_name, group_of
ET = ZoneInfo("America/New_York")
KST = ZoneInfo("Asia/Seoul")
def _q(symbol: str, close: float, prev: float, **kw) -> Quote:
return Quote(
symbol=symbol,
name=display_name(symbol),
group=group_of(symbol) or "other",
close=close,
prev_close=prev,
change_pct=(close / prev - 1) * 100,
change_abs=close - prev,
**kw,
)
def _payload(krx: date = date(2026, 8, 26), session: date = date(2026, 8, 25)) -> MarketPayload:
return MarketPayload(
session_date=session,
us_close_time=datetime(2026, 8, 25, 16, 0, tzinfo=ET),
generated_at=datetime(2026, 8, 26, 6, 15, tzinfo=KST),
krx_date=krx,
krx_open_today=True,
quotes=[_q("^GSPC", 6400.0, 6350.0)],
)
def _report() -> Report:
return Report(
headline="반도체 강세",
market_overview="개요",
key_drivers=["a"],
rates_fx_commodities="금리",
korea_implications=["b"],
watchlist_today=["c"],
risk_flags=["d"],
)
@pytest.fixture
def store(tmp_path: Path):
s = Store(tmp_path / "nested" / "dir" / "test.sqlite3")
yield s
s.close()
def _seed(store: Store, symbol: str, closes: list[float], end: date) -> None:
"""Seed consecutive prior sessions ending the day before ``end`` with the given closes."""
days = []
d = end
while len(days) < len(closes):
d -= timedelta(days=1)
if d.weekday() < 5:
days.append(d)
days.reverse()
prev = closes[0]
for d, c in zip(days, closes, strict=True):
store.save_quotes(d, [_q(symbol, c, prev)])
prev = c
# ------------------------------------------------------------------ schema / quotes
def test_creates_parent_dirs_and_wal(tmp_path: Path):
path = tmp_path / "a" / "b" / "x.sqlite3"
s = Store(path)
try:
assert path.exists()
mode = s._conn.execute("PRAGMA journal_mode").fetchone()[0]
assert mode.lower() == "wal"
tables = {r[0] for r in s._conn.execute("SELECT name FROM sqlite_master WHERE type='table'")}
assert {"quotes", "reports", "deliveries", "raw"} <= tables
finally:
s.close()
def test_save_quotes_upserts(store: Store):
d = date(2026, 8, 25)
store.save_quotes(d, [_q("^GSPC", 6400.0, 6350.0), _q("NVDA", 180.0, 175.0)])
store.save_quotes(d, [_q("^GSPC", 6410.0, 6350.0)]) # same key -> update, not duplicate
rows = store.load_quotes(d)
assert len(rows) == 2
by_sym = {r["symbol"]: r for r in rows}
assert by_sym["^GSPC"]["close"] == pytest.approx(6410.0)
assert by_sym["NVDA"]["close"] == pytest.approx(180.0)
def test_save_quotes_handles_none_values(store: Store):
q = Quote(symbol="^VIX", name="VIX", group="indices")
store.save_quotes(date(2026, 8, 25), [q])
row = store.load_quotes(date(2026, 8, 25))[0]
assert row["close"] is None and row["change_pct"] is None
# ------------------------------------------------------------------ reports / deliveries / raw
def test_has_report_and_save_report_upsert(store: Store):
payload = _payload()
assert store.has_report(payload.krx_date) is False
r1 = BriefingResult(payload=payload, report=_report(), markdown="# md1", model_used="MiniMax-M3")
id1 = store.save_report(r1)
assert store.has_report(payload.krx_date) is True
assert store.has_report(payload.krx_date, kind="premarket") is False
r2 = BriefingResult(payload=payload, report=None, markdown="# md2", degraded=True)
id2 = store.save_report(r2)
assert id2 == id1 # upsert on (krx_date, kind)
row = store._conn.execute("SELECT * FROM reports WHERE id = ?", (id1,)).fetchone()
assert row["markdown"] == "# md2"
assert row["degraded"] == 1
assert row["model_used"] is None
assert row["session_date"] == "2026-08-25"
assert json.loads(row["result_json"])["degraded"] is True
assert store._conn.execute("SELECT COUNT(*) FROM reports").fetchone()[0] == 1
id3 = store.save_report(r1, kind="premarket")
assert id3 != id1
assert store.has_report(payload.krx_date, kind="premarket") is True
def test_mark_sent(store: Store):
rid = store.save_report(BriefingResult(payload=_payload(), report=None, markdown="x"))
store.mark_sent(rid, "telegram", "msg:123")
store.mark_sent(rid, "file", "reports/2026-08-26.md")
rows = store.deliveries_for(rid)
assert [(r["channel"], r["receipt"]) for r in rows] == [
("telegram", "msg:123"),
("file", "reports/2026-08-26.md"),
]
assert all(r["sent_at"] for r in rows)
def test_save_raw_upsert(store: Store):
d = date(2026, 8, 25)
store.save_raw(d, "quotes", '{"a": 1}')
store.save_raw(d, "quotes", '{"a": 2}')
store.save_raw(d, "news", "[]")
assert store.load_raw(d, "quotes") == '{"a": 2}'
assert store.load_raw(d, "news") == "[]"
assert store.load_raw(d, "missing") is None
assert store._conn.execute("SELECT COUNT(*) FROM raw").fetchone()[0] == 2
def test_close_then_use_raises(tmp_path: Path):
s = Store(tmp_path / "x.sqlite3")
s.close()
with pytest.raises(sqlite3.ProgrammingError):
s.has_report(date(2026, 1, 1))
# ------------------------------------------------------------------ history notes
def test_history_notes_empty_without_history(store: Store):
quotes = [_q("^GSPC", 6400.0, 6350.0), _q("^VIX", 21.0, 19.0)]
assert store.history_notes(date(2026, 8, 25), quotes) == []
def test_history_notes_down_streak(store: Store):
today = date(2026, 8, 25)
# three prior sessions all down, today down too -> 4-day streak
_seed(store, "^GSPC", [6500.0, 6480.0, 6460.0, 6440.0], today)
notes = store.history_notes(today, [_q("^GSPC", 6420.0, 6440.0)])
assert any("S&P 500 4일 연속 하락" in n for n in notes), notes
# a reversal breaks the streak
notes_up = store.history_notes(today, [_q("^GSPC", 6450.0, 6440.0)])
assert not any("연속" in n for n in notes_up), notes_up
def test_history_notes_streak_requires_three(store: Store):
today = date(2026, 8, 25)
_seed(store, "NVDA", [100.0, 101.0], today) # only one up day stored
notes = store.history_notes(today, [_q("NVDA", 102.0, 101.0)])
assert not any("연속" in n for n in notes), notes
def test_history_notes_ignores_today_in_db(store: Store):
today = date(2026, 8, 25)
_seed(store, "^IXIC", [100.0, 101.0, 102.0, 103.0], today)
q = _q("^IXIC", 104.0, 103.0)
store.save_quotes(today, [q]) # pipeline may save before asking for notes
notes = store.history_notes(today, [q])
assert any("나스닥 종합 4일 연속 상승" in n for n in notes), notes
def test_history_notes_big_move(store: Store):
today = date(2026, 8, 25)
# alternating +-0.3% moves for 8 sessions, then a -2.5% day
closes = [1000.0]
for i in range(8):
closes.append(closes[-1] * (1.003 if i % 2 == 0 else 0.997))
_seed(store, "^SOX", closes, today)
prev = closes[-1]
notes = store.history_notes(today, [_q("^SOX", prev * 0.975, prev)])
big = [n for n in notes if "평소보다 큰 변동" in n]
assert len(big) == 1 and big[0].startswith("필라델피아 반도체(SOX) 오늘 -2.50%"), notes
# a normal-sized move produces no flag
notes2 = store.history_notes(today, [_q("^SOX", prev * 1.002, prev)])
assert not any("평소보다" in n for n in notes2), notes2
def test_history_notes_big_move_needs_enough_sessions(store: Store):
today = date(2026, 8, 25)
_seed(store, "^SOX", [1000.0, 1003.0, 1000.0], today) # only 3 prior sessions
notes = store.history_notes(today, [_q("^SOX", 950.0, 1000.0)])
assert not any("평소보다" in n for n in notes), notes
def test_history_notes_rates_in_bp(store: Store):
today = date(2026, 8, 25)
closes = [4.20, 4.21, 4.20, 4.22, 4.21, 4.22]
_seed(store, "^TNX", closes, today)
notes = store.history_notes(today, [_q("^TNX", 4.32, 4.22)]) # +10bp vs ~1bp avg
big = [n for n in notes if "평소보다 큰 변동" in n]
assert len(big) == 1 and "+10.0bp" in big[0], notes
def test_history_notes_vix_crossing(store: Store):
today = date(2026, 8, 25)
_seed(store, "^VIX", [17.0, 18.5], today)
up = store.history_notes(today, [_q("^VIX", 21.2, 18.5)])
assert any("VIX 20 상향 돌파" in n and "18.5 → 21.2" in n for n in up), up
down = store.history_notes(today, [_q("^VIX", 19.0, 18.5)])
assert not any("돌파" in n or "이탈" in n for n in down), down
_seed(store, "^VIX", [28.0, 31.0], today) # overwrite prior sessions
dn = store.history_notes(today, [_q("^VIX", 29.5, 31.0)])
assert any("VIX 30 하향 이탈" in n for n in dn), dn
def test_history_notes_krw_crossing(store: Store):
today = date(2026, 8, 25)
_seed(store, "KRW=X", [1390.0, 1395.2], today)
up = store.history_notes(today, [_q("KRW=X", 1402.1, 1395.2)])
assert any("달러/원 1,400원 상향 돌파" in n for n in up), up
flat = store.history_notes(today, [_q("KRW=X", 1398.0, 1395.2)])
assert not any("돌파" in n or "이탈" in n for n in flat), flat
_seed(store, "KRW=X", [1410.0, 1402.0], today)
dn = store.history_notes(today, [_q("KRW=X", 1396.0, 1402.0)])
assert any("달러/원 1,400원 하향 이탈" in n for n in dn), dn
def test_history_notes_only_key_symbols(store: Store):
today = date(2026, 8, 25)
_seed(store, "AAPL", [100.0, 101.0, 102.0, 103.0], today)
notes = store.history_notes(today, [_q("AAPL", 104.0, 103.0)])
assert notes == []
# ------------------------------------------------------------------ streak contiguity
def test_history_notes_streak_breaks_on_missing_sessions(store: Store):
# 08-17, 08-18, 08-21 stored (all up); 08-19/08-20 missing (daemon down). Today 08-24 up.
store.save_quotes(date(2026, 8, 17), [_q("^GSPC", 6400.0, 6380.0)])
store.save_quotes(date(2026, 8, 18), [_q("^GSPC", 6420.0, 6400.0)])
store.save_quotes(date(2026, 8, 21), [_q("^GSPC", 6370.0, 6350.0)]) # prev_close != 6420: hole
notes = store.history_notes(date(2026, 8, 24), [_q("^GSPC", 6390.0, 6370.0)])
assert not any("연속" in n for n in notes), notes
def test_history_notes_streak_breaks_on_calendar_gap_even_if_prices_chain(store: Store):
# Same closes chain, but a 6-day hole (08-18 -> 08-24) cannot be two consecutive sessions.
store.save_quotes(date(2026, 8, 14), [_q("^GSPC", 6400.0, 6380.0)])
store.save_quotes(date(2026, 8, 17), [_q("^GSPC", 6420.0, 6400.0)])
store.save_quotes(date(2026, 8, 18), [_q("^GSPC", 6440.0, 6420.0)])
notes = store.history_notes(date(2026, 8, 24), [_q("^GSPC", 6460.0, 6440.0)])
assert not any("연속" in n for n in notes), notes
def test_history_notes_streak_spans_weekend(store: Store):
store.save_quotes(date(2026, 8, 19), [_q("^GSPC", 6330.0, 6300.0)])
store.save_quotes(date(2026, 8, 20), [_q("^GSPC", 6350.0, 6330.0)])
store.save_quotes(date(2026, 8, 21), [_q("^GSPC", 6370.0, 6350.0)]) # Fri
notes = store.history_notes(date(2026, 8, 24), [_q("^GSPC", 6390.0, 6370.0)]) # Mon
assert any("S&P 500 4일 연속 상승" in n for n in notes), notes
def test_history_notes_streak_spans_holiday_weekend(store: Store):
# Wed 9/2, Thu 9/3, Fri 9/4 up; Mon 9/7 Labor Day; today Tue 9/8 up -> genuine 4-day streak
store.save_quotes(date(2026, 9, 2), [_q("^GSPC", 100.5, 100.0)])
store.save_quotes(date(2026, 9, 3), [_q("^GSPC", 101.0, 100.5)])
store.save_quotes(date(2026, 9, 4), [_q("^GSPC", 101.5, 101.0)])
notes = store.history_notes(date(2026, 9, 8), [_q("^GSPC", 102.0, 101.5)])
assert any("S&P 500 4일 연속 상승" in n for n in notes), notes
def test_contiguous_tail_tolerates_small_revisions_and_missing_prices():
rows = [
_Row("2026-08-18", 100.0, 99.0, 1.0, 1.0),
_Row("2026-08-19", 101.0, 100.15, 0.85, 0.85), # prev_close revised by 0.15% -> still chained
_Row("2026-08-20", None, None, None, None), # no prices: calendar gap alone decides
_Row("2026-08-21", 103.0, 102.0, 1.0, 1.0),
]
assert _contiguous_tail(rows, date(2026, 8, 24), 103.0) == rows
assert _contiguous_tail(rows, date(2026, 8, 24), 110.0) == [] # today does not follow 08-21
assert _contiguous_tail(rows, date(2026, 8, 28), 103.0) == [] # 7-day gap
assert _contiguous_tail([], date(2026, 8, 24), 103.0) == []
# ------------------------------------------------------------------ level crossings
def test_history_notes_level_cross_uses_quote_prev_close(store: Store):
today = date(2026, 8, 25)
# stored snapshot 18.0 but the collector's preceding bar is 21.0 -> a real cross below 20
_seed(store, "^VIX", [17.0, 18.0], today)
notes = store.history_notes(today, [_q("^VIX", 19.5, 21.0)])
assert any("VIX 20 하향 이탈" in n and "21.0 → 19.5" in n for n in notes), notes
# stored snapshot 1398 but the preceding bar is already 1402 -> no 1,400 crossing must be reported
_seed(store, "KRW=X", [1390.0, 1398.0], today)
notes = store.history_notes(today, [_q("KRW=X", 1405.0, 1402.0)])
assert not any("돌파" in n or "이탈" in n for n in notes), notes
# without a collector prev_close the stored close is the fallback
q = Quote(symbol="^VIX", name="VIX", group="volatility", close=19.5)
_seed(store, "^VIX", [22.0, 21.0], today)
notes = store.history_notes(today, [q])
assert any("VIX 20 하향 이탈" in n and "21.0 → 19.5" in n for n in notes), notes
def test_level_cross_note_never_shows_identical_prev_and_close():
notes = _level_cross_notes("달러/원", 1399.6, 1400.4, [1350.0, 1400.0, 1450.0], 0, "원")
assert notes == ["달러/원 1,400원 상향 돌파 (1,399.6 → 1,400.4)"]
notes = _level_cross_notes("VIX", 19.96, 20.04, [20.0, 30.0], 1)
assert notes == ["VIX 20 상향 돌파 (19.96 → 20.04)"]
# the normal case keeps the configured precision
assert _level_cross_notes("달러/원", 1395.2, 1402.1, [1400.0], 0, "원") == [
"달러/원 1,400원 상향 돌파 (1,395 → 1,402)"
]
def test_level_cross_requires_strict_cross():
assert _level_cross_notes("달러/원", 1399.99, 1400.0, [1400.0], 0, "원") == [] # lands on the level
assert _level_cross_notes("달러/원", 1400.0, 1399.99, [1400.0], 0, "원") == [] # leaves from the level
assert _level_cross_notes("VIX", 19.96, 20.0, [20.0, 30.0], 1) == []
assert _level_cross_notes("VIX", 20.0, 20.5, [20.0, 30.0], 1) == []