JOINs news_sentiment with krx_master for name fallback. Sorted by
score DESC. Date param defaults to latest. Empty table returns
{date: null, count: 0, items: []}. 4 integration tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
174 lines
5.7 KiB
Python
174 lines
5.7 KiB
Python
import os
|
|
import sqlite3
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from app.screener.schema import ensure_screener_schema
|
|
from app.db import init_db
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def isolated_db_and_auth(tmp_path, monkeypatch):
|
|
db_path = tmp_path / "stock.db"
|
|
# 기본 stock DB 스키마
|
|
monkeypatch.setenv("STOCK_DB_PATH", str(db_path))
|
|
init_db()
|
|
# screener 스키마 (news_sentiment, krx_master 등)
|
|
c = sqlite3.connect(db_path)
|
|
ensure_screener_schema(c)
|
|
c.close()
|
|
# WEBAI_API_KEY 활성화
|
|
monkeypatch.setenv("WEBAI_API_KEY", "test-secret")
|
|
|
|
|
|
@pytest.fixture
|
|
def client():
|
|
from app.main import app
|
|
return TestClient(app)
|
|
|
|
|
|
HEADERS_OK = {"X-WebAI-Key": "test-secret"}
|
|
|
|
|
|
def _seed_portfolio(broker="키움", ticker="005930", name="삼성전자",
|
|
quantity=100, avg_price=75000.0, purchase_price=75500.0):
|
|
from app.db import add_portfolio_item
|
|
return add_portfolio_item(broker, ticker, name, quantity, avg_price,
|
|
purchase_price=purchase_price)
|
|
|
|
|
|
def test_webai_portfolio_normal_response_includes_pnl_pct(client, monkeypatch):
|
|
_seed_portfolio()
|
|
|
|
# current_price 모킹 — profit_rate 4.67% 만들기
|
|
from app import main
|
|
monkeypatch.setattr(
|
|
main, "get_current_prices_detail",
|
|
lambda tickers: {"005930": {"price": 78500.0, "session": "REGULAR", "as_of": "2026-05-15T15:30:00"}}
|
|
)
|
|
|
|
r = client.get("/api/webai/portfolio", headers=HEADERS_OK)
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert len(body["holdings"]) == 1
|
|
h = body["holdings"][0]
|
|
assert h["pnl_pct"] is not None
|
|
assert abs(h["pnl_pct"] - 0.0467) < 0.0005 # 0.0467 ± rounding
|
|
|
|
|
|
def test_webai_portfolio_summary_has_total_pnl_pct(client, monkeypatch):
|
|
_seed_portfolio()
|
|
from app import main
|
|
monkeypatch.setattr(
|
|
main, "get_current_prices_detail",
|
|
lambda tickers: {"005930": {"price": 78500.0, "session": "REGULAR", "as_of": "x"}}
|
|
)
|
|
|
|
r = client.get("/api/webai/portfolio", headers=HEADERS_OK)
|
|
body = r.json()
|
|
assert "total_pnl_pct" in body["summary"]
|
|
assert abs(body["summary"]["total_pnl_pct"] - 0.0467) < 0.0005
|
|
|
|
|
|
def test_webai_portfolio_pnl_pct_matches_profit_rate_divided_100(client, monkeypatch):
|
|
_seed_portfolio()
|
|
from app import main
|
|
monkeypatch.setattr(
|
|
main, "get_current_prices_detail",
|
|
lambda tickers: {"005930": {"price": 78500.0, "session": "REGULAR", "as_of": "x"}}
|
|
)
|
|
|
|
r = client.get("/api/webai/portfolio", headers=HEADERS_OK)
|
|
h = r.json()["holdings"][0]
|
|
assert h["pnl_pct"] == round(h["profit_rate"] / 100, 6)
|
|
|
|
|
|
def test_webai_portfolio_missing_key_returns_401(client):
|
|
r = client.get("/api/webai/portfolio")
|
|
assert r.status_code == 401
|
|
assert "X-WebAI-Key" in r.json()["detail"]
|
|
|
|
|
|
def _seed_news_sentiment(date_str: str, rows: list[tuple]):
|
|
"""rows: list of (ticker, score_raw, reason, news_count)."""
|
|
db_path = os.environ["STOCK_DB_PATH"]
|
|
c = sqlite3.connect(db_path)
|
|
for ticker, score, reason, news_count in rows:
|
|
c.execute(
|
|
"INSERT OR REPLACE INTO news_sentiment "
|
|
"(ticker, date, score_raw, reason, news_count, source) "
|
|
"VALUES (?, ?, ?, ?, ?, 'articles')",
|
|
(ticker, date_str, score, reason, news_count)
|
|
)
|
|
c.commit()
|
|
c.close()
|
|
|
|
|
|
def _seed_krx_master(rows: list[tuple]):
|
|
"""rows: list of (ticker, name)."""
|
|
db_path = os.environ["STOCK_DB_PATH"]
|
|
c = sqlite3.connect(db_path)
|
|
import datetime as dt
|
|
now = dt.datetime.utcnow().isoformat()
|
|
for ticker, name in rows:
|
|
c.execute(
|
|
"INSERT OR REPLACE INTO krx_master "
|
|
"(ticker, name, market, market_cap, updated_at) VALUES (?, ?, 'KOSPI', 0, ?)",
|
|
(ticker, name, now)
|
|
)
|
|
c.commit()
|
|
c.close()
|
|
|
|
|
|
def test_webai_news_sentiment_returns_latest_date_when_no_param(client):
|
|
_seed_krx_master([("005930", "삼성전자"), ("000660", "SK하이닉스")])
|
|
_seed_news_sentiment("2026-05-14", [("005930", 5.0, "old", 5)])
|
|
_seed_news_sentiment("2026-05-15", [
|
|
("005930", 6.2, "HBM 양산 가시화", 12),
|
|
("000660", 5.5, "PPI 우려에도 강세", 8),
|
|
])
|
|
|
|
r = client.get("/api/webai/news-sentiment", headers=HEADERS_OK)
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert body["date"] == "2026-05-15"
|
|
assert body["count"] == 2
|
|
# sorted by score DESC
|
|
assert body["items"][0]["ticker"] == "005930"
|
|
assert body["items"][0]["score"] == 6.2
|
|
assert body["items"][0]["name"] == "삼성전자"
|
|
assert body["items"][0]["reason"] == "HBM 양산 가시화"
|
|
|
|
|
|
def test_webai_news_sentiment_filters_by_date_param(client):
|
|
_seed_krx_master([("005930", "삼성전자")])
|
|
_seed_news_sentiment("2026-05-14", [("005930", 5.0, "yesterday", 5)])
|
|
_seed_news_sentiment("2026-05-15", [("005930", 6.2, "today", 12)])
|
|
|
|
r = client.get("/api/webai/news-sentiment?date=2026-05-14", headers=HEADERS_OK)
|
|
body = r.json()
|
|
assert body["date"] == "2026-05-14"
|
|
assert body["count"] == 1
|
|
assert body["items"][0]["reason"] == "yesterday"
|
|
|
|
|
|
def test_webai_news_sentiment_empty_table_returns_count_zero(client):
|
|
r = client.get("/api/webai/news-sentiment", headers=HEADERS_OK)
|
|
body = r.json()
|
|
assert body["date"] is None
|
|
assert body["count"] == 0
|
|
assert body["items"] == []
|
|
|
|
|
|
def test_webai_news_sentiment_items_sorted_by_score_desc(client):
|
|
_seed_krx_master([("A", "A주"), ("B", "B주"), ("C", "C주")])
|
|
_seed_news_sentiment("2026-05-15", [
|
|
("A", 1.0, "low", 1),
|
|
("B", 9.0, "high", 1),
|
|
("C", 5.0, "mid", 1),
|
|
])
|
|
|
|
r = client.get("/api/webai/news-sentiment", headers=HEADERS_OK)
|
|
items = r.json()["items"]
|
|
assert [i["score"] for i in items] == [9.0, 5.0, 1.0]
|