diff --git a/stock-lab/app/screener/_test_fixtures.py b/stock-lab/app/screener/_test_fixtures.py new file mode 100644 index 0000000..ca90608 --- /dev/null +++ b/stock-lab/app/screener/_test_fixtures.py @@ -0,0 +1,76 @@ +"""Synthetic fixtures for screener tests — no DB / no FDR / no naver.""" + +import datetime as dt +import pandas as pd + + +def make_master(tickers: list[str], market_caps: dict | None = None, + preferred: set | None = None, managed: set | None = None) -> pd.DataFrame: + market_caps = market_caps or {t: 100_000_000_000 for t in tickers} + preferred = preferred or set() + managed = managed or set() + return pd.DataFrame([ + { + "ticker": t, + "name": f"테스트{t}", + "market": "KOSPI", + "market_cap": market_caps.get(t), + "is_managed": int(t in managed), + "is_preferred": int(t in preferred), + "is_spac": 0, + "listed_date": None, + } + for t in tickers + ]).set_index("ticker") + + +def make_prices(tickers: list[str], days: int = 260, start_close: int = 50000, + trend_pct: float = 0.0, + asof: dt.date = dt.date(2026, 5, 12)) -> pd.DataFrame: + """trend_pct: 일별 종가 등락률(%). 양수면 상승 추세.""" + rows = [] + for t in tickers: + close = start_close + for i in range(days): + day_idx = days - 1 - i # asof가 마지막 + date = asof - dt.timedelta(days=day_idx) + high = int(close * 1.012) + low = int(close * 0.988) + rows.append({ + "ticker": t, "date": date.isoformat(), + "open": close, "high": high, "low": low, "close": close, + "volume": 1_000_000, "value": close * 1_000_000, + }) + close = int(close * (1 + trend_pct / 100)) + return pd.DataFrame(rows) + + +def make_flow(tickers: list[str], days: int = 260, + foreign_per_day: dict | None = None, + asof: dt.date = dt.date(2026, 5, 12)) -> pd.DataFrame: + foreign_per_day = foreign_per_day or {t: 0 for t in tickers} + rows = [] + for t in tickers: + for i in range(days): + day_idx = days - 1 - i + date = asof - dt.timedelta(days=day_idx) + rows.append({ + "ticker": t, "date": date.isoformat(), + "foreign_net": foreign_per_day.get(t, 0), + "institution_net": 0, + }) + return pd.DataFrame(rows) + + +def make_kospi(days: int = 260, start: int = 2500, trend_pct: float = 0.0, + asof: dt.date = dt.date(2026, 5, 12)) -> pd.Series: + values = [] + dates = [] + v = start + for i in range(days): + day_idx = days - 1 - i + d = asof - dt.timedelta(days=day_idx) + dates.append(d.isoformat()) + values.append(v) + v = v * (1 + trend_pct / 100) + return pd.Series(values, index=dates, name="kospi") diff --git a/stock-lab/app/screener/engine.py b/stock-lab/app/screener/engine.py new file mode 100644 index 0000000..672b207 --- /dev/null +++ b/stock-lab/app/screener/engine.py @@ -0,0 +1,69 @@ +"""Screener engine — ScreenContext (Phase 0) + Screener/combine (Phase 2).""" + +from __future__ import annotations + +import datetime as dt +import sqlite3 +from dataclasses import dataclass, replace + +import pandas as pd + + +@dataclass(frozen=True) +class ScreenContext: + """1회 실행 동안 공유되는 읽기 전용 데이터 컨테이너.""" + master: pd.DataFrame # index=ticker + prices: pd.DataFrame # cols: ticker,date,open,high,low,close,volume,value + flow: pd.DataFrame # cols: ticker,date,foreign_net,institution_net + kospi: pd.Series # index=date(str), name="kospi" + asof: dt.date + + @classmethod + def load(cls, conn: sqlite3.Connection, asof: dt.date, + lookback_days: int = 252 * 2) -> "ScreenContext": + cutoff = (asof - dt.timedelta(days=int(lookback_days * 1.5))).isoformat() + asof_iso = asof.isoformat() + + master = pd.read_sql_query( + "SELECT * FROM krx_master", + conn, index_col="ticker", + ) + prices = pd.read_sql_query( + "SELECT ticker,date,open,high,low,close,volume,value " + "FROM krx_daily_prices WHERE date BETWEEN ? AND ? ORDER BY date", + conn, params=(cutoff, asof_iso), + ) + flow = pd.read_sql_query( + "SELECT ticker,date,foreign_net,institution_net " + "FROM krx_flow WHERE date BETWEEN ? AND ? ORDER BY date", + conn, params=(cutoff, asof_iso), + ) + + # KOSPI 지수: MVP에서는 005930(삼성전자) 종가를 시장 대용으로 사용. + # 후속 슬라이스에서 ^KS11 별도 캐시. + kospi = pd.Series(dtype=float, name="kospi") + if "005930" in master.index and not prices.empty: + sub = prices[prices["ticker"] == "005930"].set_index("date")["close"] + kospi = sub.copy() + kospi.name = "kospi" + + return cls(master=master, prices=prices, flow=flow, kospi=kospi, asof=asof) + + def restrict(self, tickers) -> "ScreenContext": + tickers = pd.Index(tickers) + return replace( + self, + master=self.master.loc[self.master.index.intersection(tickers)], + prices=self.prices[self.prices["ticker"].isin(tickers)], + flow=self.flow[self.flow["ticker"].isin(tickers)], + ) + + def latest_close(self) -> pd.Series: + if self.prices.empty: + return pd.Series(dtype=float) + return self.prices.sort_values("date").groupby("ticker")["close"].last() + + def latest_high(self) -> pd.Series: + if self.prices.empty: + return pd.Series(dtype=float) + return self.prices.sort_values("date").groupby("ticker")["high"].last() diff --git a/stock-lab/app/test_screener_context.py b/stock-lab/app/test_screener_context.py new file mode 100644 index 0000000..13cbc4f --- /dev/null +++ b/stock-lab/app/test_screener_context.py @@ -0,0 +1,61 @@ +import datetime as dt +import sqlite3 + +import pandas as pd +import pytest + +from app.screener.engine import ScreenContext +from app.screener.schema import ensure_screener_schema +from app.screener._test_fixtures import make_master, make_prices, make_flow + + +@pytest.fixture +def conn(tmp_path): + db_path = tmp_path / "ctx.db" + c = sqlite3.connect(db_path) + ensure_screener_schema(c) + yield c + c.close() + + +def _seed(conn, master_df, prices_df, flow_df): + now = dt.datetime.utcnow().isoformat() + for t, row in master_df.iterrows(): + conn.execute("""INSERT INTO krx_master (ticker,name,market,market_cap, + is_managed,is_preferred,is_spac,listed_date,updated_at) + VALUES (?,?,?,?,?,?,?,?,?)""", + (t, row["name"], row["market"], row["market_cap"], + row["is_managed"], row["is_preferred"], row["is_spac"], None, now)) + prices_df.to_sql("krx_daily_prices", conn, if_exists="append", index=False) + flow_df.to_sql("krx_flow", conn, if_exists="append", index=False) + conn.commit() + + +def test_load_returns_dataframes(conn): + asof = dt.date(2026, 5, 12) + _seed(conn, + make_master(["005930", "035420"]), + make_prices(["005930", "035420"], days=30, asof=asof), + make_flow(["005930", "035420"], days=30, asof=asof)) + + ctx = ScreenContext.load(conn, asof, lookback_days=30) + + assert ctx.asof == asof + assert set(ctx.master.index) == {"005930", "035420"} + assert ctx.prices.shape[0] == 60 # 2 종목 × 30일 + assert ctx.flow.shape[0] == 60 + + +def test_restrict_filters_tickers(conn): + asof = dt.date(2026, 5, 12) + _seed(conn, + make_master(["005930", "035420", "091990"]), + make_prices(["005930", "035420", "091990"], days=30, asof=asof), + make_flow(["005930", "035420", "091990"], days=30, asof=asof)) + + ctx = ScreenContext.load(conn, asof, lookback_days=30) + scoped = ctx.restrict(pd.Index(["005930"])) + + assert list(scoped.master.index) == ["005930"] + assert (scoped.prices["ticker"] == "005930").all() + assert (scoped.flow["ticker"] == "005930").all()