feat: US market code 정합성, Telegram 명령 4종, 손절 모니터링 (#132)
Some checks failed
CI / test (pull_request) Has been cancelled

- MARKET_SHORTHAND + expand_market_codes()로 config "US" → schedule "US_NASDAQ/NYSE/AMEX" 자동 확장
- /report, /scenarios, /review, /dashboard 텔레그램 명령 추가
- price_change_pct를 trading_cycle과 run_daily_session에 주입
- HOLD시 get_open_position 기반 손절 모니터링 및 자동 SELL 오버라이드
- 대시보드 /api/status 동적 market 조회로 변경

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
agentson
2026-02-16 20:24:01 +09:00
parent 31b4d0bf1e
commit 3fdb7a29d4
10 changed files with 642 additions and 75 deletions

View File

@@ -1,21 +1,25 @@
"""Tests for FastAPI dashboard endpoints."""
"""Tests for dashboard endpoint handlers."""
from __future__ import annotations
import json
import sqlite3
from collections.abc import Callable
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
import pytest
pytest.importorskip("fastapi")
from fastapi.testclient import TestClient
from fastapi import HTTPException
from fastapi.responses import FileResponse
from src.dashboard.app import create_dashboard_app
from src.db import init_db
def _seed_db(conn: sqlite3.Connection) -> None:
today = datetime.now(UTC).date().isoformat()
conn.execute(
"""
INSERT INTO playbooks (
@@ -34,6 +38,24 @@ def _seed_db(conn: sqlite3.Connection) -> None:
1,
),
)
conn.execute(
"""
INSERT INTO playbooks (
date, market, status, playbook_json, generated_at,
token_count, scenario_count, match_count
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
today,
"US_NASDAQ",
"ready",
json.dumps({"market": "US_NASDAQ", "stock_playbooks": []}),
f"{today}T08:30:00+00:00",
100,
1,
0,
),
)
conn.execute(
"""
INSERT INTO contexts (layer, timeframe, key, value, created_at, updated_at)
@@ -71,7 +93,7 @@ def _seed_db(conn: sqlite3.Connection) -> None:
""",
(
"d-kr-1",
"2026-02-14T09:10:00+00:00",
f"{today}T09:10:00+00:00",
"005930",
"KR",
"KRX",
@@ -91,9 +113,9 @@ def _seed_db(conn: sqlite3.Connection) -> None:
""",
(
"d-us-1",
"2026-02-14T21:10:00+00:00",
f"{today}T21:10:00+00:00",
"AAPL",
"US",
"US_NASDAQ",
"NASDAQ",
"SELL",
80,
@@ -110,7 +132,7 @@ def _seed_db(conn: sqlite3.Connection) -> None:
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
"2026-02-14T09:11:00+00:00",
f"{today}T09:11:00+00:00",
"005930",
"BUY",
85,
@@ -132,7 +154,7 @@ def _seed_db(conn: sqlite3.Connection) -> None:
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
"2026-02-14T21:11:00+00:00",
f"{today}T21:11:00+00:00",
"AAPL",
"SELL",
80,
@@ -140,7 +162,7 @@ def _seed_db(conn: sqlite3.Connection) -> None:
1,
200,
-1.0,
"US",
"US_NASDAQ",
"NASDAQ",
None,
"d-us-1",
@@ -149,122 +171,128 @@ def _seed_db(conn: sqlite3.Connection) -> None:
conn.commit()
def _client(tmp_path: Path) -> TestClient:
def _app(tmp_path: Path) -> Any:
db_path = tmp_path / "dashboard_test.db"
conn = init_db(str(db_path))
_seed_db(conn)
conn.close()
app = create_dashboard_app(str(db_path))
return TestClient(app)
return create_dashboard_app(str(db_path))
def _endpoint(app: Any, path: str) -> Callable[..., Any]:
for route in app.routes:
if getattr(route, "path", None) == path:
return route.endpoint
raise AssertionError(f"route not found: {path}")
def test_index_serves_html(tmp_path: Path) -> None:
client = _client(tmp_path)
resp = client.get("/")
assert resp.status_code == 200
assert "The Ouroboros Dashboard API" in resp.text
app = _app(tmp_path)
index = _endpoint(app, "/")
resp = index()
assert isinstance(resp, FileResponse)
assert "index.html" in str(resp.path)
def test_status_endpoint(tmp_path: Path) -> None:
client = _client(tmp_path)
resp = client.get("/api/status")
assert resp.status_code == 200
body = resp.json()
app = _app(tmp_path)
get_status = _endpoint(app, "/api/status")
body = get_status()
assert "KR" in body["markets"]
assert "US" in body["markets"]
assert "US_NASDAQ" in body["markets"]
assert "totals" in body
def test_playbook_found(tmp_path: Path) -> None:
client = _client(tmp_path)
resp = client.get("/api/playbook/2026-02-14?market=KR")
assert resp.status_code == 200
assert resp.json()["market"] == "KR"
app = _app(tmp_path)
get_playbook = _endpoint(app, "/api/playbook/{date_str}")
body = get_playbook("2026-02-14", market="KR")
assert body["market"] == "KR"
def test_playbook_not_found(tmp_path: Path) -> None:
client = _client(tmp_path)
resp = client.get("/api/playbook/2026-02-15?market=KR")
assert resp.status_code == 404
app = _app(tmp_path)
get_playbook = _endpoint(app, "/api/playbook/{date_str}")
with pytest.raises(HTTPException, match="playbook not found"):
get_playbook("2026-02-15", market="KR")
def test_scorecard_found(tmp_path: Path) -> None:
client = _client(tmp_path)
resp = client.get("/api/scorecard/2026-02-14?market=KR")
assert resp.status_code == 200
assert resp.json()["scorecard"]["total_pnl"] == 1.5
app = _app(tmp_path)
get_scorecard = _endpoint(app, "/api/scorecard/{date_str}")
body = get_scorecard("2026-02-14", market="KR")
assert body["scorecard"]["total_pnl"] == 1.5
def test_scorecard_not_found(tmp_path: Path) -> None:
client = _client(tmp_path)
resp = client.get("/api/scorecard/2026-02-15?market=KR")
assert resp.status_code == 404
app = _app(tmp_path)
get_scorecard = _endpoint(app, "/api/scorecard/{date_str}")
with pytest.raises(HTTPException, match="scorecard not found"):
get_scorecard("2026-02-15", market="KR")
def test_performance_all(tmp_path: Path) -> None:
client = _client(tmp_path)
resp = client.get("/api/performance?market=all")
assert resp.status_code == 200
body = resp.json()
app = _app(tmp_path)
get_performance = _endpoint(app, "/api/performance")
body = get_performance(market="all")
assert body["market"] == "all"
assert body["combined"]["total_trades"] == 2
assert len(body["by_market"]) == 2
def test_performance_market_filter(tmp_path: Path) -> None:
client = _client(tmp_path)
resp = client.get("/api/performance?market=KR")
assert resp.status_code == 200
body = resp.json()
app = _app(tmp_path)
get_performance = _endpoint(app, "/api/performance")
body = get_performance(market="KR")
assert body["market"] == "KR"
assert body["metrics"]["total_trades"] == 1
def test_performance_empty_market(tmp_path: Path) -> None:
client = _client(tmp_path)
resp = client.get("/api/performance?market=JP")
assert resp.status_code == 200
assert resp.json()["metrics"]["total_trades"] == 0
app = _app(tmp_path)
get_performance = _endpoint(app, "/api/performance")
body = get_performance(market="JP")
assert body["metrics"]["total_trades"] == 0
def test_context_layer_all(tmp_path: Path) -> None:
client = _client(tmp_path)
resp = client.get("/api/context/L7_REALTIME")
assert resp.status_code == 200
body = resp.json()
app = _app(tmp_path)
get_context_layer = _endpoint(app, "/api/context/{layer}")
body = get_context_layer("L7_REALTIME", timeframe=None, limit=100)
assert body["layer"] == "L7_REALTIME"
assert body["count"] == 1
def test_context_layer_timeframe_filter(tmp_path: Path) -> None:
client = _client(tmp_path)
resp = client.get("/api/context/L6_DAILY?timeframe=2026-02-14")
assert resp.status_code == 200
body = resp.json()
app = _app(tmp_path)
get_context_layer = _endpoint(app, "/api/context/{layer}")
body = get_context_layer("L6_DAILY", timeframe="2026-02-14", limit=100)
assert body["count"] == 1
assert body["entries"][0]["key"] == "scorecard_KR"
def test_decisions_endpoint(tmp_path: Path) -> None:
client = _client(tmp_path)
resp = client.get("/api/decisions?market=KR")
assert resp.status_code == 200
body = resp.json()
app = _app(tmp_path)
get_decisions = _endpoint(app, "/api/decisions")
body = get_decisions(market="KR", limit=50)
assert body["count"] == 1
assert body["decisions"][0]["decision_id"] == "d-kr-1"
def test_scenarios_active_filters_non_matched(tmp_path: Path) -> None:
client = _client(tmp_path)
resp = client.get("/api/scenarios/active?market=KR&date_str=2026-02-14")
assert resp.status_code == 200
body = resp.json()
app = _app(tmp_path)
get_active_scenarios = _endpoint(app, "/api/scenarios/active")
body = get_active_scenarios(
market="KR",
date_str=datetime.now(UTC).date().isoformat(),
limit=50,
)
assert body["count"] == 1
assert body["matches"][0]["stock_code"] == "005930"
def test_scenarios_active_empty_when_no_matches(tmp_path: Path) -> None:
client = _client(tmp_path)
resp = client.get("/api/scenarios/active?market=US&date_str=2026-02-14")
assert resp.status_code == 200
assert resp.json()["count"] == 0
app = _app(tmp_path)
get_active_scenarios = _endpoint(app, "/api/scenarios/active")
body = get_active_scenarios(market="US", date_str="2026-02-14", limit=50)
assert body["count"] == 0

60
tests/test_db.py Normal file
View File

@@ -0,0 +1,60 @@
"""Tests for database helper functions."""
from src.db import get_open_position, init_db, log_trade
def test_get_open_position_returns_latest_buy() -> None:
conn = init_db(":memory:")
log_trade(
conn=conn,
stock_code="005930",
action="BUY",
confidence=90,
rationale="entry",
quantity=2,
price=70000.0,
market="KR",
exchange_code="KRX",
decision_id="d-buy-1",
)
position = get_open_position(conn, "005930", "KR")
assert position is not None
assert position["decision_id"] == "d-buy-1"
assert position["price"] == 70000.0
assert position["quantity"] == 2
def test_get_open_position_returns_none_when_latest_is_sell() -> None:
conn = init_db(":memory:")
log_trade(
conn=conn,
stock_code="005930",
action="BUY",
confidence=90,
rationale="entry",
quantity=1,
price=70000.0,
market="KR",
exchange_code="KRX",
decision_id="d-buy-1",
)
log_trade(
conn=conn,
stock_code="005930",
action="SELL",
confidence=95,
rationale="exit",
quantity=1,
price=71000.0,
market="KR",
exchange_code="KRX",
decision_id="d-sell-1",
)
assert get_open_position(conn, "005930", "KR") is None
def test_get_open_position_returns_none_when_no_trades() -> None:
conn = init_db(":memory:")
assert get_open_position(conn, "AAPL", "US_NASDAQ") is None

View File

@@ -116,6 +116,7 @@ class TestTradingCycleTelegramIntegration:
"output1": {
"stck_prpr": "50000",
"frgn_ntby_qty": "100",
"prdy_ctrt": "1.23",
}
}
)
@@ -747,7 +748,7 @@ class TestScenarioEngineIntegration:
broker = MagicMock()
broker.get_orderbook = AsyncMock(
return_value={
"output1": {"stck_prpr": "50000", "frgn_ntby_qty": "100"}
"output1": {"stck_prpr": "50000", "frgn_ntby_qty": "100", "prdy_ctrt": "2.50"}
}
)
broker.get_balance = AsyncMock(
@@ -830,6 +831,7 @@ class TestScenarioEngineIntegration:
assert market_data["rsi"] == 25.0
assert market_data["volume_ratio"] == 3.5
assert market_data["current_price"] == 50000.0
assert market_data["price_change_pct"] == 2.5
# Portfolio data should include pnl
assert "portfolio_pnl_pct" in portfolio_data
@@ -1232,6 +1234,107 @@ async def test_sell_updates_original_buy_decision_outcome() -> None:
assert updated_buy.outcome_accuracy == 1
@pytest.mark.asyncio
async def test_hold_overridden_to_sell_when_stop_loss_triggered() -> None:
"""HOLD decision should be overridden to SELL when stop-loss threshold is breached."""
db_conn = init_db(":memory:")
decision_logger = DecisionLogger(db_conn)
buy_decision_id = decision_logger.log_decision(
stock_code="005930",
market="KR",
exchange_code="KRX",
action="BUY",
confidence=90,
rationale="entry",
context_snapshot={},
input_data={},
)
log_trade(
conn=db_conn,
stock_code="005930",
action="BUY",
confidence=90,
rationale="entry",
quantity=1,
price=100.0,
market="KR",
exchange_code="KRX",
decision_id=buy_decision_id,
)
broker = MagicMock()
broker.get_orderbook = AsyncMock(
return_value={"output1": {"stck_prpr": "95", "frgn_ntby_qty": "0", "prdy_ctrt": "-5.0"}}
)
broker.get_balance = AsyncMock(
return_value={
"output2": [
{
"tot_evlu_amt": "100000",
"dnca_tot_amt": "10000",
"pchs_amt_smtl_amt": "90000",
}
]
}
)
broker.send_order = AsyncMock(return_value={"msg1": "OK"})
scenario = StockScenario(
condition=StockCondition(rsi_below=30),
action=ScenarioAction.BUY,
confidence=88,
stop_loss_pct=-2.0,
rationale="stop loss policy",
)
playbook = DayPlaybook(
date=date(2026, 2, 8),
market="KR",
stock_playbooks=[
{"stock_code": "005930", "stock_name": "Samsung", "scenarios": [scenario]}
],
)
engine = MagicMock(spec=ScenarioEngine)
engine.evaluate = MagicMock(return_value=_make_hold_match())
market = MagicMock()
market.name = "Korea"
market.code = "KR"
market.exchange_code = "KRX"
market.is_domestic = True
telegram = MagicMock()
telegram.notify_trade_execution = AsyncMock()
telegram.notify_fat_finger = AsyncMock()
telegram.notify_circuit_breaker = AsyncMock()
telegram.notify_scenario_matched = AsyncMock()
await trading_cycle(
broker=broker,
overseas_broker=MagicMock(),
scenario_engine=engine,
playbook=playbook,
risk=MagicMock(),
db_conn=db_conn,
decision_logger=decision_logger,
context_store=MagicMock(
get_latest_timeframe=MagicMock(return_value=None),
set_context=MagicMock(),
),
criticality_assessor=MagicMock(
assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")),
get_timeout=MagicMock(return_value=5.0),
),
telegram=telegram,
market=market,
stock_code="005930",
scan_candidates={},
)
broker.send_order.assert_called_once()
assert broker.send_order.call_args.kwargs["order_type"] == "SELL"
@pytest.mark.asyncio
async def test_handle_market_close_runs_daily_review_flow() -> None:
"""Market close should aggregate, create scorecard, lessons, and notify."""
@@ -1427,7 +1530,7 @@ async def test_run_evolution_loop_notifies_when_pr_generated() -> None:
await _run_evolution_loop(
evolution_optimizer=optimizer,
telegram=telegram,
market_code="US",
market_code="US_NASDAQ",
market_date="2026-02-14",
)
@@ -1451,7 +1554,7 @@ async def test_run_evolution_loop_notification_error_is_ignored() -> None:
await _run_evolution_loop(
evolution_optimizer=optimizer,
telegram=telegram,
market_code="US",
market_code="US_NYSE",
market_date="2026-02-14",
)

View File

@@ -7,6 +7,7 @@ import pytest
from src.markets.schedule import (
MARKETS,
expand_market_codes,
get_next_market_open,
get_open_markets,
is_market_open,
@@ -199,3 +200,28 @@ class TestGetNextMarketOpen:
enabled_markets=["INVALID", "KR"], now=test_time
)
assert market.code == "KR"
class TestExpandMarketCodes:
"""Test shorthand market expansion."""
def test_expand_us_shorthand(self) -> None:
assert expand_market_codes(["US"]) == ["US_NASDAQ", "US_NYSE", "US_AMEX"]
def test_expand_cn_shorthand(self) -> None:
assert expand_market_codes(["CN"]) == ["CN_SHA", "CN_SZA"]
def test_expand_vn_shorthand(self) -> None:
assert expand_market_codes(["VN"]) == ["VN_HAN", "VN_HCM"]
def test_expand_mixed_codes(self) -> None:
assert expand_market_codes(["KR", "US", "JP"]) == [
"KR",
"US_NASDAQ",
"US_NYSE",
"US_AMEX",
"JP",
]
def test_expand_preserves_unknown_code(self) -> None:
assert expand_market_codes(["KR", "UNKNOWN"]) == ["KR", "UNKNOWN"]

View File

@@ -682,6 +682,10 @@ class TestBasicCommands:
"/help - Show available commands\n"
"/status - Trading status (mode, markets, P&L)\n"
"/positions - Current holdings\n"
"/report - Daily summary report\n"
"/scenarios - Today's playbook scenarios\n"
"/review - Recent scorecards\n"
"/dashboard - Dashboard URL/status\n"
"/stop - Pause trading\n"
"/resume - Resume trading"
)
@@ -707,10 +711,106 @@ class TestBasicCommands:
assert "/help" in payload["text"]
assert "/status" in payload["text"]
assert "/positions" in payload["text"]
assert "/report" in payload["text"]
assert "/scenarios" in payload["text"]
assert "/review" in payload["text"]
assert "/dashboard" in payload["text"]
assert "/stop" in payload["text"]
assert "/resume" in payload["text"]
class TestExtendedCommands:
"""Test additional bot commands."""
@pytest.mark.asyncio
async def test_report_command(self) -> None:
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
handler = TelegramCommandHandler(client)
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock(return_value=False)
async def mock_report() -> None:
await client.send_message("<b>📈 Daily Report</b>\n\nTrades: 1")
handler.register_command("report", mock_report)
with patch("aiohttp.ClientSession.post", return_value=mock_resp) as mock_post:
await handler._handle_update(
{"update_id": 1, "message": {"chat": {"id": 456}, "text": "/report"}}
)
payload = mock_post.call_args.kwargs["json"]
assert "Daily Report" in payload["text"]
@pytest.mark.asyncio
async def test_scenarios_command(self) -> None:
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
handler = TelegramCommandHandler(client)
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock(return_value=False)
async def mock_scenarios() -> None:
await client.send_message("<b>🧠 Today's Scenarios</b>\n\n- AAPL: BUY (85)")
handler.register_command("scenarios", mock_scenarios)
with patch("aiohttp.ClientSession.post", return_value=mock_resp) as mock_post:
await handler._handle_update(
{"update_id": 1, "message": {"chat": {"id": 456}, "text": "/scenarios"}}
)
payload = mock_post.call_args.kwargs["json"]
assert "Today's Scenarios" in payload["text"]
@pytest.mark.asyncio
async def test_review_command(self) -> None:
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
handler = TelegramCommandHandler(client)
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock(return_value=False)
async def mock_review() -> None:
await client.send_message("<b>📝 Recent Reviews</b>\n\n- 2026-02-14 KR")
handler.register_command("review", mock_review)
with patch("aiohttp.ClientSession.post", return_value=mock_resp) as mock_post:
await handler._handle_update(
{"update_id": 1, "message": {"chat": {"id": 456}, "text": "/review"}}
)
payload = mock_post.call_args.kwargs["json"]
assert "Recent Reviews" in payload["text"]
@pytest.mark.asyncio
async def test_dashboard_command(self) -> None:
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
handler = TelegramCommandHandler(client)
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock(return_value=False)
async def mock_dashboard() -> None:
await client.send_message("<b>🖥️ Dashboard</b>\n\nURL: http://127.0.0.1:8080")
handler.register_command("dashboard", mock_dashboard)
with patch("aiohttp.ClientSession.post", return_value=mock_resp) as mock_post:
await handler._handle_update(
{"update_id": 1, "message": {"chat": {"id": 456}, "text": "/dashboard"}}
)
payload = mock_post.call_args.kwargs["json"]
assert "Dashboard" in payload["text"]
class TestGetUpdates:
"""Test getUpdates API interaction."""