From 62169ad33fe90ae9ab72d586b088a0b03ae818e6 Mon Sep 17 00:00:00 2001 From: gahusb Date: Sun, 31 May 2026 21:45:19 +0900 Subject: [PATCH] =?UTF-8?q?refactor(stock):=20Phase=201=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81=20(public=20get=5Fkrx=5Ftickers?= =?UTF-8?q?=C2=B7=ED=83=80=EC=9E=85=C2=B7limit=EB=AA=85=EB=AA=85=C2=B7?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- stock/app/db.py | 25 ++++++++++++++++---- stock/app/holdings_intel.py | 9 ++------ stock/app/test_holdings_db.py | 17 ++++++++++++-- stock/app/test_holdings_intel.py | 39 ++++++++++++++++++++++++++++++++ 4 files changed, 76 insertions(+), 14 deletions(-) diff --git a/stock/app/db.py b/stock/app/db.py index 89cd147..5d4c9cc 100644 --- a/stock/app/db.py +++ b/stock/app/db.py @@ -117,7 +117,7 @@ def init_db(): close INTEGER, pnl_rate REAL, reasons TEXT, - created_at TEXT NOT NULL DEFAULT (datetime('now')), + created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')), PRIMARY KEY (date, ticker) ); """ @@ -321,10 +321,24 @@ def get_asset_snapshots(days: int = 30) -> List[Dict[str, Any]]: return [dict(r) for r in rows] +# --- KRX Master --- + +def get_krx_tickers() -> set: + with _conn() as conn: + try: + rows = conn.execute("SELECT ticker FROM krx_master").fetchall() + except Exception: + return set() + return {r["ticker"] for r in rows} + + # --- Holdings Signals CRUD --- -def upsert_holdings_signal(date, ticker, name, action, tech_score, exit_flags, - issues, close, pnl_rate, reasons) -> None: +def upsert_holdings_signal( + date: str, ticker: str, name: Optional[str], action: str, + tech_score: Optional[float], exit_flags: dict, issues: list, + close: Optional[int], pnl_rate: Optional[float], reasons: Optional[str], +) -> None: with _conn() as conn: conn.execute( """ @@ -358,9 +372,10 @@ def get_latest_holdings_date() -> str | None: r = conn.execute("SELECT MAX(date) AS d FROM holdings_signals").fetchone() return r["d"] if r and r["d"] else None -def get_holdings_signal_history(ticker: str, days: int = 30) -> list: +def get_holdings_signal_history(ticker: str, limit: int = 30) -> list: + """최근 N개 시그널 행 (시그널은 거래일당 1행이라 ≈ N 거래일).""" with _conn() as conn: rows = conn.execute( "SELECT * FROM holdings_signals WHERE ticker=? ORDER BY date DESC LIMIT ?", - (ticker, days)).fetchall() + (ticker, limit)).fetchall() return [_row_to_signal(r) for r in rows] diff --git a/stock/app/holdings_intel.py b/stock/app/holdings_intel.py index a8bc58d..0e83634 100644 --- a/stock/app/holdings_intel.py +++ b/stock/app/holdings_intel.py @@ -8,13 +8,8 @@ from . import price_fetcher def _krx_tickers() -> set: - """krx_master에 존재하는 ticker 집합 (KRX 판별용).""" - with db._conn() as conn: - try: - rows = conn.execute("SELECT ticker FROM krx_master").fetchall() - except Exception: - return set() - return {r["ticker"] for r in rows} + """krx_master ticker 집합 (KRX 판별용).""" + return db.get_krx_tickers() def get_holdings() -> list[dict]: diff --git a/stock/app/test_holdings_db.py b/stock/app/test_holdings_db.py index 297caf0..a5bd030 100644 --- a/stock/app/test_holdings_db.py +++ b/stock/app/test_holdings_db.py @@ -1,4 +1,4 @@ -import os, tempfile, importlib +import os, tempfile def _fresh_db(monkeypatch): tmp = tempfile.mkdtemp() @@ -20,5 +20,18 @@ def test_holdings_signals_table_and_upsert(monkeypatch): assert len(rows) == 1 # upsert 멱등 assert rows[0]["action"] == "trim" assert rows[0]["exit_flags"]["ma50_break"] is True # JSON 역직렬화 - hist = db.get_holdings_signal_history("005930", days=30) + hist = db.get_holdings_signal_history("005930", limit=30) assert len(hist) == 1 + + +def test_get_latest_holdings_date(monkeypatch): + db = _fresh_db(monkeypatch) + # empty table → None + assert db.get_latest_holdings_date() is None + # after an upsert → returns that date + db.upsert_holdings_signal( + date="2026-05-30", ticker="005930", name="삼성전자", + action="hold", tech_score=70.0, exit_flags={}, issues=[], + close=80000, pnl_rate=4.0, reasons="테스트", + ) + assert db.get_latest_holdings_date() == "2026-05-30" diff --git a/stock/app/test_holdings_intel.py b/stock/app/test_holdings_intel.py index c104abe..01d4dc7 100644 --- a/stock/app/test_holdings_intel.py +++ b/stock/app/test_holdings_intel.py @@ -15,3 +15,42 @@ def test_get_holdings_merges_price_and_pnl(monkeypatch): assert s["005930"]["is_krx"] is True assert round(s["005930"]["pnl_rate"], 1) == 10.0 # (77000-70000)/70000 assert s["AAPL"]["is_krx"] is False # KRX 외 + + +def test_get_holdings_zero_avg_price(monkeypatch): + """avg_price=0인 종목은 pnl_rate가 None이어야 한다 (ZeroDivisionError 없음).""" + monkeypatch.setattr(hi.db, "get_all_portfolio", lambda: [ + {"id": 1, "broker": "kis", "ticker": "005930", "name": "삼성전자", + "quantity": 10, "avg_price": 0, "purchase_price": 0}, + ]) + monkeypatch.setattr(hi.price_fetcher, "get_current_prices", + lambda tickers: {"005930": 80000}) + monkeypatch.setattr(hi, "_krx_tickers", lambda: {"005930"}) + hs = hi.get_holdings() + assert hs[0]["pnl_rate"] is None + + +def test_get_holdings_empty_portfolio(monkeypatch): + """포트폴리오가 비어있으면 빈 리스트를 반환하고 가격 조회를 호출하지 않는다.""" + monkeypatch.setattr(hi.db, "get_all_portfolio", lambda: []) + called = [] + monkeypatch.setattr(hi.price_fetcher, "get_current_prices", + lambda tickers: called.append(tickers) or {}) + monkeypatch.setattr(hi, "_krx_tickers", lambda: set()) + result = hi.get_holdings() + assert result == [] + assert called == [] # get_current_prices must NOT have been called + + +def test_get_holdings_price_missing(monkeypatch): + """prices dict에 ticker가 없으면 current_price와 pnl_rate는 None이다.""" + monkeypatch.setattr(hi.db, "get_all_portfolio", lambda: [ + {"id": 1, "broker": "kis", "ticker": "000660", "name": "SK하이닉스", + "quantity": 5, "avg_price": 150000, "purchase_price": 150000}, + ]) + monkeypatch.setattr(hi.price_fetcher, "get_current_prices", + lambda tickers: {}) # 가격 없음 + monkeypatch.setattr(hi, "_krx_tickers", lambda: {"000660"}) + hs = hi.get_holdings() + assert hs[0]["current_price"] is None + assert hs[0]["pnl_rate"] is None