diff --git a/src/config.py b/src/config.py index 70ffd5d..d5c1aa4 100644 --- a/src/config.py +++ b/src/config.py @@ -101,4 +101,7 @@ class Settings(BaseSettings): @property def enabled_market_list(self) -> list[str]: """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) diff --git a/src/dashboard/app.py b/src/dashboard/app.py index 205f035..e25b584 100644 --- a/src/dashboard/app.py +++ b/src/dashboard/app.py @@ -26,7 +26,19 @@ def create_dashboard_app(db_path: str) -> FastAPI: def get_status() -> dict[str, Any]: today = datetime.now(UTC).date().isoformat() 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] = {} total_trades = 0 total_pnl = 0.0 diff --git a/src/db.py b/src/db.py index ea57f79..619bb3c 100644 --- a/src/db.py +++ b/src/db.py @@ -214,3 +214,24 @@ def get_latest_buy_trade( if not row: return None 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]} diff --git a/src/main.py b/src/main.py index 12a4d2e..eea2dc1 100644 --- a/src/main.py +++ b/src/main.py @@ -8,6 +8,7 @@ from __future__ import annotations import argparse import asyncio +import json import logging import signal import threading @@ -28,7 +29,7 @@ from src.context.store import ContextStore from src.core.criticality import CriticalityAssessor from src.core.priority_queue import PriorityTaskQueue 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.optimizer import EvolutionOptimizer 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")) 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: # Overseas market 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")) foreigner_net = 0.0 # Not available for overseas + price_change_pct = safe_float(price_data.get("output", {}).get("rate", "0")) # Calculate daily P&L % pnl_pct = ( @@ -149,6 +152,7 @@ async def trading_cycle( "market_name": market.name, "current_price": current_price, "foreigner_net": foreigner_net, + "price_change_pct": price_change_pct, } # Enrich market_data with scanner metrics for scenario engine @@ -240,6 +244,34 @@ async def trading_cycle( confidence=match.confidence, 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( "Decision for %s (%s): %s (confidence=%d)", stock_code, @@ -278,6 +310,7 @@ async def trading_cycle( input_data = { "current_price": current_price, "foreigner_net": foreigner_net, + "price_change_pct": price_change_pct, "total_eval": total_eval, "total_cash": total_cash, "pnl_pct": pnl_pct, @@ -507,6 +540,9 @@ async def run_daily_session( 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: price_data = await overseas_broker.get_overseas_price( market.exchange_code, stock_code @@ -515,12 +551,16 @@ async def run_daily_session( price_data.get("output", {}).get("last", "0") ) foreigner_net = 0.0 + price_change_pct = safe_float( + price_data.get("output", {}).get("rate", "0") + ) stock_data: dict[str, Any] = { "stock_code": stock_code, "market_name": market.name, "current_price": current_price, "foreigner_net": foreigner_net, + "price_change_pct": price_change_pct, } # Enrich with scanner metrics cand = candidate_map.get(stock_code) @@ -820,7 +860,7 @@ async def _run_evolution_loop( market_date: str, ) -> None: """Run evolution loop once at US close (end of trading day).""" - if market_code != "US": + if not market_code.startswith("US"): return try: @@ -936,6 +976,10 @@ async def run(settings: Settings) -> None: "/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" ) @@ -1055,11 +1099,164 @@ async def run(settings: Settings) -> None: "⚠️ Error\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( + "📈 Daily Report\n\n" + f"Date: {today}\n" + f"Trades: {trade_count}\n" + f"Total P&L: {total_pnl:+.2f}\n" + f"Win Rate: {win_rate:.2f}%\n" + f"Decisions: {decision_count}\n" + f"Avg Confidence: {avg_confidence:.2f}" + ) + except Exception as exc: + logger.error("Error in /report handler: %s", exc) + await telegram.send_message( + "⚠️ Error\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( + "🧠 Today's Scenarios\n\nNo playbooks found for today." + ) + return + + lines = ["🧠 Today's Scenarios", ""] + for market, playbook_json in rows: + lines.append(f"{market}") + 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( + "⚠️ Error\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( + "📝 Recent Reviews\n\nNo scorecards available." + ) + return + + lines = ["📝 Recent Reviews", ""] + 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( + "⚠️ Error\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( + "🖥️ Dashboard\n\nDashboard is not enabled." + ) + return + + url = f"http://{settings.DASHBOARD_HOST}:{settings.DASHBOARD_PORT}" + await telegram.send_message( + "🖥️ Dashboard\n\n" + f"URL: {url}" + ) + command_handler.register_command("help", handle_help) command_handler.register_command("stop", handle_stop) command_handler.register_command("resume", handle_resume) command_handler.register_command("status", handle_status) 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 volatility_analyzer = VolatilityAnalyzer(min_volume_surge=2.0, min_price_change=1.0) diff --git a/src/markets/schedule.py b/src/markets/schedule.py index 0adfe56..b7daf22 100644 --- a/src/markets/schedule.py +++ b/src/markets/schedule.py @@ -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: """ diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index caaf3fb..8c18d4a 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -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 diff --git a/tests/test_db.py b/tests/test_db.py new file mode 100644 index 0000000..fe956eb --- /dev/null +++ b/tests/test_db.py @@ -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 diff --git a/tests/test_main.py b/tests/test_main.py index 8bda052..8cc234a 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -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", ) diff --git a/tests/test_market_schedule.py b/tests/test_market_schedule.py index ea33ab4..f3a9de7 100644 --- a/tests/test_market_schedule.py +++ b/tests/test_market_schedule.py @@ -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"] diff --git a/tests/test_telegram_commands.py b/tests/test_telegram_commands.py index c1ef067..c0f0b98 100644 --- a/tests/test_telegram_commands.py +++ b/tests/test_telegram_commands.py @@ -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("📈 Daily Report\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("🧠 Today's Scenarios\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("📝 Recent Reviews\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("🖥️ Dashboard\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."""