356 lines
15 KiB
Python
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) == []
|