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

@@ -101,4 +101,7 @@ class Settings(BaseSettings):
@property @property
def enabled_market_list(self) -> list[str]: def enabled_market_list(self) -> list[str]:
"""Parse ENABLED_MARKETS into list of market codes.""" """Parse ENABLED_MARKETS into list of market codes."""
return [m.strip() for m in self.ENABLED_MARKETS.split(",") if m.strip()] from src.markets.schedule import expand_market_codes
raw = [m.strip() for m in self.ENABLED_MARKETS.split(",") if m.strip()]
return expand_market_codes(raw)

View File

@@ -26,7 +26,19 @@ def create_dashboard_app(db_path: str) -> FastAPI:
def get_status() -> dict[str, Any]: def get_status() -> dict[str, Any]:
today = datetime.now(UTC).date().isoformat() today = datetime.now(UTC).date().isoformat()
with _connect(db_path) as conn: with _connect(db_path) as conn:
markets = ["KR", "US"] market_rows = conn.execute(
"""
SELECT DISTINCT market FROM (
SELECT market FROM trades WHERE DATE(timestamp) = ?
UNION
SELECT market FROM decision_logs WHERE DATE(timestamp) = ?
UNION
SELECT market FROM playbooks WHERE date = ?
) ORDER BY market
""",
(today, today, today),
).fetchall()
markets = [row[0] for row in market_rows] if market_rows else []
market_status: dict[str, Any] = {} market_status: dict[str, Any] = {}
total_trades = 0 total_trades = 0
total_pnl = 0.0 total_pnl = 0.0

View File

@@ -214,3 +214,24 @@ def get_latest_buy_trade(
if not row: if not row:
return None return None
return {"decision_id": row[0], "price": row[1], "quantity": row[2]} return {"decision_id": row[0], "price": row[1], "quantity": row[2]}
def get_open_position(
conn: sqlite3.Connection, stock_code: str, market: str
) -> dict[str, Any] | None:
"""Return open position if latest trade is BUY, else None."""
cursor = conn.execute(
"""
SELECT action, decision_id, price, quantity
FROM trades
WHERE stock_code = ?
AND market = ?
ORDER BY timestamp DESC
LIMIT 1
""",
(stock_code, market),
)
row = cursor.fetchone()
if not row or row[0] != "BUY":
return None
return {"decision_id": row[1], "price": row[2], "quantity": row[3]}

View File

@@ -8,6 +8,7 @@ from __future__ import annotations
import argparse import argparse
import asyncio import asyncio
import json
import logging import logging
import signal import signal
import threading import threading
@@ -28,7 +29,7 @@ from src.context.store import ContextStore
from src.core.criticality import CriticalityAssessor from src.core.criticality import CriticalityAssessor
from src.core.priority_queue import PriorityTaskQueue from src.core.priority_queue import PriorityTaskQueue
from src.core.risk_manager import CircuitBreakerTripped, FatFingerRejected, RiskManager from src.core.risk_manager import CircuitBreakerTripped, FatFingerRejected, RiskManager
from src.db import get_latest_buy_trade, init_db, log_trade from src.db import get_latest_buy_trade, get_open_position, init_db, log_trade
from src.evolution.daily_review import DailyReviewer from src.evolution.daily_review import DailyReviewer
from src.evolution.optimizer import EvolutionOptimizer from src.evolution.optimizer import EvolutionOptimizer
from src.logging.decision_logger import DecisionLogger from src.logging.decision_logger import DecisionLogger
@@ -114,6 +115,7 @@ async def trading_cycle(
current_price = safe_float(orderbook.get("output1", {}).get("stck_prpr", "0")) current_price = safe_float(orderbook.get("output1", {}).get("stck_prpr", "0"))
foreigner_net = safe_float(orderbook.get("output1", {}).get("frgn_ntby_qty", "0")) foreigner_net = safe_float(orderbook.get("output1", {}).get("frgn_ntby_qty", "0"))
price_change_pct = safe_float(orderbook.get("output1", {}).get("prdy_ctrt", "0"))
else: else:
# Overseas market # Overseas market
price_data = await overseas_broker.get_overseas_price( price_data = await overseas_broker.get_overseas_price(
@@ -136,6 +138,7 @@ async def trading_cycle(
current_price = safe_float(price_data.get("output", {}).get("last", "0")) current_price = safe_float(price_data.get("output", {}).get("last", "0"))
foreigner_net = 0.0 # Not available for overseas foreigner_net = 0.0 # Not available for overseas
price_change_pct = safe_float(price_data.get("output", {}).get("rate", "0"))
# Calculate daily P&L % # Calculate daily P&L %
pnl_pct = ( pnl_pct = (
@@ -149,6 +152,7 @@ async def trading_cycle(
"market_name": market.name, "market_name": market.name,
"current_price": current_price, "current_price": current_price,
"foreigner_net": foreigner_net, "foreigner_net": foreigner_net,
"price_change_pct": price_change_pct,
} }
# Enrich market_data with scanner metrics for scenario engine # Enrich market_data with scanner metrics for scenario engine
@@ -240,6 +244,34 @@ async def trading_cycle(
confidence=match.confidence, confidence=match.confidence,
rationale=match.rationale, rationale=match.rationale,
) )
stock_playbook = playbook.get_stock_playbook(stock_code)
if decision.action == "HOLD":
open_position = get_open_position(db_conn, stock_code, market.code)
if open_position:
entry_price = safe_float(open_position.get("price"), 0.0)
if entry_price > 0:
loss_pct = (current_price - entry_price) / entry_price * 100
stop_loss_threshold = -2.0
if stock_playbook and stock_playbook.scenarios:
stop_loss_threshold = stock_playbook.scenarios[0].stop_loss_pct
if loss_pct <= stop_loss_threshold:
decision = TradeDecision(
action="SELL",
confidence=95,
rationale=(
f"Stop-loss triggered ({loss_pct:.2f}% <= "
f"{stop_loss_threshold:.2f}%)"
),
)
logger.info(
"Stop-loss override for %s (%s): %.2f%% <= %.2f%%",
stock_code,
market.name,
loss_pct,
stop_loss_threshold,
)
logger.info( logger.info(
"Decision for %s (%s): %s (confidence=%d)", "Decision for %s (%s): %s (confidence=%d)",
stock_code, stock_code,
@@ -278,6 +310,7 @@ async def trading_cycle(
input_data = { input_data = {
"current_price": current_price, "current_price": current_price,
"foreigner_net": foreigner_net, "foreigner_net": foreigner_net,
"price_change_pct": price_change_pct,
"total_eval": total_eval, "total_eval": total_eval,
"total_cash": total_cash, "total_cash": total_cash,
"pnl_pct": pnl_pct, "pnl_pct": pnl_pct,
@@ -507,6 +540,9 @@ async def run_daily_session(
foreigner_net = safe_float( foreigner_net = safe_float(
orderbook.get("output1", {}).get("frgn_ntby_qty", "0") orderbook.get("output1", {}).get("frgn_ntby_qty", "0")
) )
price_change_pct = safe_float(
orderbook.get("output1", {}).get("prdy_ctrt", "0")
)
else: else:
price_data = await overseas_broker.get_overseas_price( price_data = await overseas_broker.get_overseas_price(
market.exchange_code, stock_code market.exchange_code, stock_code
@@ -515,12 +551,16 @@ async def run_daily_session(
price_data.get("output", {}).get("last", "0") price_data.get("output", {}).get("last", "0")
) )
foreigner_net = 0.0 foreigner_net = 0.0
price_change_pct = safe_float(
price_data.get("output", {}).get("rate", "0")
)
stock_data: dict[str, Any] = { stock_data: dict[str, Any] = {
"stock_code": stock_code, "stock_code": stock_code,
"market_name": market.name, "market_name": market.name,
"current_price": current_price, "current_price": current_price,
"foreigner_net": foreigner_net, "foreigner_net": foreigner_net,
"price_change_pct": price_change_pct,
} }
# Enrich with scanner metrics # Enrich with scanner metrics
cand = candidate_map.get(stock_code) cand = candidate_map.get(stock_code)
@@ -820,7 +860,7 @@ async def _run_evolution_loop(
market_date: str, market_date: str,
) -> None: ) -> None:
"""Run evolution loop once at US close (end of trading day).""" """Run evolution loop once at US close (end of trading day)."""
if market_code != "US": if not market_code.startswith("US"):
return return
try: try:
@@ -936,6 +976,10 @@ async def run(settings: Settings) -> None:
"/help - Show available commands\n" "/help - Show available commands\n"
"/status - Trading status (mode, markets, P&L)\n" "/status - Trading status (mode, markets, P&L)\n"
"/positions - Current holdings\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" "/stop - Pause trading\n"
"/resume - Resume trading" "/resume - Resume trading"
) )
@@ -1055,11 +1099,164 @@ async def run(settings: Settings) -> None:
"<b>⚠️ Error</b>\n\nFailed to retrieve positions." "<b>⚠️ Error</b>\n\nFailed to retrieve positions."
) )
async def handle_report() -> None:
"""Handle /report command - show daily summary metrics."""
try:
today = datetime.now(UTC).date().isoformat()
trade_row = db_conn.execute(
"""
SELECT COUNT(*) AS trade_count,
COALESCE(SUM(pnl), 0.0) AS total_pnl,
SUM(CASE WHEN pnl > 0 THEN 1 ELSE 0 END) AS wins
FROM trades
WHERE DATE(timestamp) = ?
""",
(today,),
).fetchone()
decision_row = db_conn.execute(
"""
SELECT COUNT(*) AS decision_count,
COALESCE(AVG(confidence), 0.0) AS avg_confidence
FROM decision_logs
WHERE DATE(timestamp) = ?
""",
(today,),
).fetchone()
trade_count = int(trade_row[0] if trade_row else 0)
total_pnl = float(trade_row[1] if trade_row else 0.0)
wins = int(trade_row[2] if trade_row and trade_row[2] is not None else 0)
decision_count = int(decision_row[0] if decision_row else 0)
avg_confidence = float(decision_row[1] if decision_row else 0.0)
win_rate = (wins / trade_count * 100.0) if trade_count > 0 else 0.0
await telegram.send_message(
"<b>📈 Daily Report</b>\n\n"
f"<b>Date:</b> {today}\n"
f"<b>Trades:</b> {trade_count}\n"
f"<b>Total P&L:</b> {total_pnl:+.2f}\n"
f"<b>Win Rate:</b> {win_rate:.2f}%\n"
f"<b>Decisions:</b> {decision_count}\n"
f"<b>Avg Confidence:</b> {avg_confidence:.2f}"
)
except Exception as exc:
logger.error("Error in /report handler: %s", exc)
await telegram.send_message(
"<b>⚠️ Error</b>\n\nFailed to generate daily report."
)
async def handle_scenarios() -> None:
"""Handle /scenarios command - show today's playbook scenarios."""
try:
today = datetime.now(UTC).date().isoformat()
rows = db_conn.execute(
"""
SELECT market, playbook_json
FROM playbooks
WHERE date = ?
ORDER BY market
""",
(today,),
).fetchall()
if not rows:
await telegram.send_message(
"<b>🧠 Today's Scenarios</b>\n\nNo playbooks found for today."
)
return
lines = ["<b>🧠 Today's Scenarios</b>", ""]
for market, playbook_json in rows:
lines.append(f"<b>{market}</b>")
playbook_data = {}
try:
playbook_data = json.loads(playbook_json)
except Exception:
playbook_data = {}
stock_playbooks = playbook_data.get("stock_playbooks", [])
if not stock_playbooks:
lines.append("- No scenarios")
lines.append("")
continue
for stock_pb in stock_playbooks:
stock_code = stock_pb.get("stock_code", "N/A")
scenarios = stock_pb.get("scenarios", [])
for sc in scenarios:
action = sc.get("action", "HOLD")
confidence = sc.get("confidence", 0)
lines.append(f"- {stock_code}: {action} ({confidence})")
lines.append("")
await telegram.send_message("\n".join(lines).strip())
except Exception as exc:
logger.error("Error in /scenarios handler: %s", exc)
await telegram.send_message(
"<b>⚠️ Error</b>\n\nFailed to retrieve scenarios."
)
async def handle_review() -> None:
"""Handle /review command - show recent scorecards."""
try:
rows = db_conn.execute(
"""
SELECT timeframe, key, value
FROM contexts
WHERE layer = 'L6_DAILY' AND key LIKE 'scorecard_%'
ORDER BY updated_at DESC
LIMIT 5
"""
).fetchall()
if not rows:
await telegram.send_message(
"<b>📝 Recent Reviews</b>\n\nNo scorecards available."
)
return
lines = ["<b>📝 Recent Reviews</b>", ""]
for timeframe, key, value in rows:
scorecard = json.loads(value)
market = key.replace("scorecard_", "")
total_pnl = float(scorecard.get("total_pnl", 0.0))
win_rate = float(scorecard.get("win_rate", 0.0))
decisions = int(scorecard.get("total_decisions", 0))
lines.append(
f"- {timeframe} {market}: P&L {total_pnl:+.2f}, "
f"Win {win_rate:.2f}%, Decisions {decisions}"
)
await telegram.send_message("\n".join(lines))
except Exception as exc:
logger.error("Error in /review handler: %s", exc)
await telegram.send_message(
"<b>⚠️ Error</b>\n\nFailed to retrieve reviews."
)
async def handle_dashboard() -> None:
"""Handle /dashboard command - show dashboard URL if enabled."""
if not settings.DASHBOARD_ENABLED:
await telegram.send_message(
"<b>🖥️ Dashboard</b>\n\nDashboard is not enabled."
)
return
url = f"http://{settings.DASHBOARD_HOST}:{settings.DASHBOARD_PORT}"
await telegram.send_message(
"<b>🖥️ Dashboard</b>\n\n"
f"<b>URL:</b> {url}"
)
command_handler.register_command("help", handle_help) command_handler.register_command("help", handle_help)
command_handler.register_command("stop", handle_stop) command_handler.register_command("stop", handle_stop)
command_handler.register_command("resume", handle_resume) command_handler.register_command("resume", handle_resume)
command_handler.register_command("status", handle_status) command_handler.register_command("status", handle_status)
command_handler.register_command("positions", handle_positions) command_handler.register_command("positions", handle_positions)
command_handler.register_command("report", handle_report)
command_handler.register_command("scenarios", handle_scenarios)
command_handler.register_command("review", handle_review)
command_handler.register_command("dashboard", handle_dashboard)
# Initialize volatility hunter # Initialize volatility hunter
volatility_analyzer = VolatilityAnalyzer(min_volume_surge=2.0, min_price_change=1.0) volatility_analyzer = VolatilityAnalyzer(min_volume_surge=2.0, min_price_change=1.0)

View File

@@ -123,6 +123,23 @@ MARKETS: dict[str, MarketInfo] = {
), ),
} }
MARKET_SHORTHAND: dict[str, list[str]] = {
"US": ["US_NASDAQ", "US_NYSE", "US_AMEX"],
"CN": ["CN_SHA", "CN_SZA"],
"VN": ["VN_HAN", "VN_HCM"],
}
def expand_market_codes(codes: list[str]) -> list[str]:
"""Expand shorthand market codes into concrete exchange market codes."""
expanded: list[str] = []
for code in codes:
if code in MARKET_SHORTHAND:
expanded.extend(MARKET_SHORTHAND[code])
else:
expanded.append(code)
return expanded
def is_market_open(market: MarketInfo, now: datetime | None = None) -> bool: def is_market_open(market: MarketInfo, now: datetime | None = None) -> bool:
""" """

View File

@@ -1,21 +1,25 @@
"""Tests for FastAPI dashboard endpoints.""" """Tests for dashboard endpoint handlers."""
from __future__ import annotations from __future__ import annotations
import json import json
import sqlite3 import sqlite3
from collections.abc import Callable
from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from typing import Any
import pytest import pytest
from fastapi import HTTPException
pytest.importorskip("fastapi") from fastapi.responses import FileResponse
from fastapi.testclient import TestClient
from src.dashboard.app import create_dashboard_app from src.dashboard.app import create_dashboard_app
from src.db import init_db from src.db import init_db
def _seed_db(conn: sqlite3.Connection) -> None: def _seed_db(conn: sqlite3.Connection) -> None:
today = datetime.now(UTC).date().isoformat()
conn.execute( conn.execute(
""" """
INSERT INTO playbooks ( INSERT INTO playbooks (
@@ -34,6 +38,24 @@ def _seed_db(conn: sqlite3.Connection) -> None:
1, 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( conn.execute(
""" """
INSERT INTO contexts (layer, timeframe, key, value, created_at, updated_at) 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", "d-kr-1",
"2026-02-14T09:10:00+00:00", f"{today}T09:10:00+00:00",
"005930", "005930",
"KR", "KR",
"KRX", "KRX",
@@ -91,9 +113,9 @@ def _seed_db(conn: sqlite3.Connection) -> None:
""", """,
( (
"d-us-1", "d-us-1",
"2026-02-14T21:10:00+00:00", f"{today}T21:10:00+00:00",
"AAPL", "AAPL",
"US", "US_NASDAQ",
"NASDAQ", "NASDAQ",
"SELL", "SELL",
80, 80,
@@ -110,7 +132,7 @@ def _seed_db(conn: sqlite3.Connection) -> None:
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
"2026-02-14T09:11:00+00:00", f"{today}T09:11:00+00:00",
"005930", "005930",
"BUY", "BUY",
85, 85,
@@ -132,7 +154,7 @@ def _seed_db(conn: sqlite3.Connection) -> None:
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
"2026-02-14T21:11:00+00:00", f"{today}T21:11:00+00:00",
"AAPL", "AAPL",
"SELL", "SELL",
80, 80,
@@ -140,7 +162,7 @@ def _seed_db(conn: sqlite3.Connection) -> None:
1, 1,
200, 200,
-1.0, -1.0,
"US", "US_NASDAQ",
"NASDAQ", "NASDAQ",
None, None,
"d-us-1", "d-us-1",
@@ -149,122 +171,128 @@ def _seed_db(conn: sqlite3.Connection) -> None:
conn.commit() conn.commit()
def _client(tmp_path: Path) -> TestClient: def _app(tmp_path: Path) -> Any:
db_path = tmp_path / "dashboard_test.db" db_path = tmp_path / "dashboard_test.db"
conn = init_db(str(db_path)) conn = init_db(str(db_path))
_seed_db(conn) _seed_db(conn)
conn.close() conn.close()
app = create_dashboard_app(str(db_path)) return create_dashboard_app(str(db_path))
return TestClient(app)
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: def test_index_serves_html(tmp_path: Path) -> None:
client = _client(tmp_path) app = _app(tmp_path)
resp = client.get("/") index = _endpoint(app, "/")
assert resp.status_code == 200 resp = index()
assert "The Ouroboros Dashboard API" in resp.text assert isinstance(resp, FileResponse)
assert "index.html" in str(resp.path)
def test_status_endpoint(tmp_path: Path) -> None: def test_status_endpoint(tmp_path: Path) -> None:
client = _client(tmp_path) app = _app(tmp_path)
resp = client.get("/api/status") get_status = _endpoint(app, "/api/status")
assert resp.status_code == 200 body = get_status()
body = resp.json()
assert "KR" in body["markets"] assert "KR" in body["markets"]
assert "US" in body["markets"] assert "US_NASDAQ" in body["markets"]
assert "totals" in body assert "totals" in body
def test_playbook_found(tmp_path: Path) -> None: def test_playbook_found(tmp_path: Path) -> None:
client = _client(tmp_path) app = _app(tmp_path)
resp = client.get("/api/playbook/2026-02-14?market=KR") get_playbook = _endpoint(app, "/api/playbook/{date_str}")
assert resp.status_code == 200 body = get_playbook("2026-02-14", market="KR")
assert resp.json()["market"] == "KR" assert body["market"] == "KR"
def test_playbook_not_found(tmp_path: Path) -> None: def test_playbook_not_found(tmp_path: Path) -> None:
client = _client(tmp_path) app = _app(tmp_path)
resp = client.get("/api/playbook/2026-02-15?market=KR") get_playbook = _endpoint(app, "/api/playbook/{date_str}")
assert resp.status_code == 404 with pytest.raises(HTTPException, match="playbook not found"):
get_playbook("2026-02-15", market="KR")
def test_scorecard_found(tmp_path: Path) -> None: def test_scorecard_found(tmp_path: Path) -> None:
client = _client(tmp_path) app = _app(tmp_path)
resp = client.get("/api/scorecard/2026-02-14?market=KR") get_scorecard = _endpoint(app, "/api/scorecard/{date_str}")
assert resp.status_code == 200 body = get_scorecard("2026-02-14", market="KR")
assert resp.json()["scorecard"]["total_pnl"] == 1.5 assert body["scorecard"]["total_pnl"] == 1.5
def test_scorecard_not_found(tmp_path: Path) -> None: def test_scorecard_not_found(tmp_path: Path) -> None:
client = _client(tmp_path) app = _app(tmp_path)
resp = client.get("/api/scorecard/2026-02-15?market=KR") get_scorecard = _endpoint(app, "/api/scorecard/{date_str}")
assert resp.status_code == 404 with pytest.raises(HTTPException, match="scorecard not found"):
get_scorecard("2026-02-15", market="KR")
def test_performance_all(tmp_path: Path) -> None: def test_performance_all(tmp_path: Path) -> None:
client = _client(tmp_path) app = _app(tmp_path)
resp = client.get("/api/performance?market=all") get_performance = _endpoint(app, "/api/performance")
assert resp.status_code == 200 body = get_performance(market="all")
body = resp.json()
assert body["market"] == "all" assert body["market"] == "all"
assert body["combined"]["total_trades"] == 2 assert body["combined"]["total_trades"] == 2
assert len(body["by_market"]) == 2 assert len(body["by_market"]) == 2
def test_performance_market_filter(tmp_path: Path) -> None: def test_performance_market_filter(tmp_path: Path) -> None:
client = _client(tmp_path) app = _app(tmp_path)
resp = client.get("/api/performance?market=KR") get_performance = _endpoint(app, "/api/performance")
assert resp.status_code == 200 body = get_performance(market="KR")
body = resp.json()
assert body["market"] == "KR" assert body["market"] == "KR"
assert body["metrics"]["total_trades"] == 1 assert body["metrics"]["total_trades"] == 1
def test_performance_empty_market(tmp_path: Path) -> None: def test_performance_empty_market(tmp_path: Path) -> None:
client = _client(tmp_path) app = _app(tmp_path)
resp = client.get("/api/performance?market=JP") get_performance = _endpoint(app, "/api/performance")
assert resp.status_code == 200 body = get_performance(market="JP")
assert resp.json()["metrics"]["total_trades"] == 0 assert body["metrics"]["total_trades"] == 0
def test_context_layer_all(tmp_path: Path) -> None: def test_context_layer_all(tmp_path: Path) -> None:
client = _client(tmp_path) app = _app(tmp_path)
resp = client.get("/api/context/L7_REALTIME") get_context_layer = _endpoint(app, "/api/context/{layer}")
assert resp.status_code == 200 body = get_context_layer("L7_REALTIME", timeframe=None, limit=100)
body = resp.json()
assert body["layer"] == "L7_REALTIME" assert body["layer"] == "L7_REALTIME"
assert body["count"] == 1 assert body["count"] == 1
def test_context_layer_timeframe_filter(tmp_path: Path) -> None: def test_context_layer_timeframe_filter(tmp_path: Path) -> None:
client = _client(tmp_path) app = _app(tmp_path)
resp = client.get("/api/context/L6_DAILY?timeframe=2026-02-14") get_context_layer = _endpoint(app, "/api/context/{layer}")
assert resp.status_code == 200 body = get_context_layer("L6_DAILY", timeframe="2026-02-14", limit=100)
body = resp.json()
assert body["count"] == 1 assert body["count"] == 1
assert body["entries"][0]["key"] == "scorecard_KR" assert body["entries"][0]["key"] == "scorecard_KR"
def test_decisions_endpoint(tmp_path: Path) -> None: def test_decisions_endpoint(tmp_path: Path) -> None:
client = _client(tmp_path) app = _app(tmp_path)
resp = client.get("/api/decisions?market=KR") get_decisions = _endpoint(app, "/api/decisions")
assert resp.status_code == 200 body = get_decisions(market="KR", limit=50)
body = resp.json()
assert body["count"] == 1 assert body["count"] == 1
assert body["decisions"][0]["decision_id"] == "d-kr-1" assert body["decisions"][0]["decision_id"] == "d-kr-1"
def test_scenarios_active_filters_non_matched(tmp_path: Path) -> None: def test_scenarios_active_filters_non_matched(tmp_path: Path) -> None:
client = _client(tmp_path) app = _app(tmp_path)
resp = client.get("/api/scenarios/active?market=KR&date_str=2026-02-14") get_active_scenarios = _endpoint(app, "/api/scenarios/active")
assert resp.status_code == 200 body = get_active_scenarios(
body = resp.json() market="KR",
date_str=datetime.now(UTC).date().isoformat(),
limit=50,
)
assert body["count"] == 1 assert body["count"] == 1
assert body["matches"][0]["stock_code"] == "005930" assert body["matches"][0]["stock_code"] == "005930"
def test_scenarios_active_empty_when_no_matches(tmp_path: Path) -> None: def test_scenarios_active_empty_when_no_matches(tmp_path: Path) -> None:
client = _client(tmp_path) app = _app(tmp_path)
resp = client.get("/api/scenarios/active?market=US&date_str=2026-02-14") get_active_scenarios = _endpoint(app, "/api/scenarios/active")
assert resp.status_code == 200 body = get_active_scenarios(market="US", date_str="2026-02-14", limit=50)
assert resp.json()["count"] == 0 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": { "output1": {
"stck_prpr": "50000", "stck_prpr": "50000",
"frgn_ntby_qty": "100", "frgn_ntby_qty": "100",
"prdy_ctrt": "1.23",
} }
} }
) )
@@ -747,7 +748,7 @@ class TestScenarioEngineIntegration:
broker = MagicMock() broker = MagicMock()
broker.get_orderbook = AsyncMock( broker.get_orderbook = AsyncMock(
return_value={ 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( broker.get_balance = AsyncMock(
@@ -830,6 +831,7 @@ class TestScenarioEngineIntegration:
assert market_data["rsi"] == 25.0 assert market_data["rsi"] == 25.0
assert market_data["volume_ratio"] == 3.5 assert market_data["volume_ratio"] == 3.5
assert market_data["current_price"] == 50000.0 assert market_data["current_price"] == 50000.0
assert market_data["price_change_pct"] == 2.5
# Portfolio data should include pnl # Portfolio data should include pnl
assert "portfolio_pnl_pct" in portfolio_data 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 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 @pytest.mark.asyncio
async def test_handle_market_close_runs_daily_review_flow() -> None: async def test_handle_market_close_runs_daily_review_flow() -> None:
"""Market close should aggregate, create scorecard, lessons, and notify.""" """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( await _run_evolution_loop(
evolution_optimizer=optimizer, evolution_optimizer=optimizer,
telegram=telegram, telegram=telegram,
market_code="US", market_code="US_NASDAQ",
market_date="2026-02-14", market_date="2026-02-14",
) )
@@ -1451,7 +1554,7 @@ async def test_run_evolution_loop_notification_error_is_ignored() -> None:
await _run_evolution_loop( await _run_evolution_loop(
evolution_optimizer=optimizer, evolution_optimizer=optimizer,
telegram=telegram, telegram=telegram,
market_code="US", market_code="US_NYSE",
market_date="2026-02-14", market_date="2026-02-14",
) )

View File

@@ -7,6 +7,7 @@ import pytest
from src.markets.schedule import ( from src.markets.schedule import (
MARKETS, MARKETS,
expand_market_codes,
get_next_market_open, get_next_market_open,
get_open_markets, get_open_markets,
is_market_open, is_market_open,
@@ -199,3 +200,28 @@ class TestGetNextMarketOpen:
enabled_markets=["INVALID", "KR"], now=test_time enabled_markets=["INVALID", "KR"], now=test_time
) )
assert market.code == "KR" 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" "/help - Show available commands\n"
"/status - Trading status (mode, markets, P&L)\n" "/status - Trading status (mode, markets, P&L)\n"
"/positions - Current holdings\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" "/stop - Pause trading\n"
"/resume - Resume trading" "/resume - Resume trading"
) )
@@ -707,10 +711,106 @@ class TestBasicCommands:
assert "/help" in payload["text"] assert "/help" in payload["text"]
assert "/status" in payload["text"] assert "/status" in payload["text"]
assert "/positions" 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 "/stop" in payload["text"]
assert "/resume" 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: class TestGetUpdates:
"""Test getUpdates API interaction.""" """Test getUpdates API interaction."""