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."""