diff --git a/pyproject.toml b/pyproject.toml index 4cef7f1..2befd6c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,7 @@ dependencies = [ "google-genai>=1.0,<2", "scipy>=1.11,<2", "fastapi>=0.110,<1", + "uvicorn>=0.29,<1", ] [project.optional-dependencies] diff --git a/src/config.py b/src/config.py index 16960bc..70ffd5d 100644 --- a/src/config.py +++ b/src/config.py @@ -83,6 +83,11 @@ class Settings(BaseSettings): TELEGRAM_COMMANDS_ENABLED: bool = True TELEGRAM_POLLING_INTERVAL: float = 1.0 # seconds + # Dashboard (optional) + DASHBOARD_ENABLED: bool = False + DASHBOARD_HOST: str = "127.0.0.1" + DASHBOARD_PORT: int = Field(default=8080, ge=1, le=65535) + model_config = {"env_file": ".env", "env_file_encoding": "utf-8"} @property diff --git a/src/main.py b/src/main.py index f0e8f17..12a4d2e 100644 --- a/src/main.py +++ b/src/main.py @@ -10,6 +10,7 @@ import argparse import asyncio import logging import signal +import threading from datetime import UTC, datetime from typing import Any @@ -844,6 +845,48 @@ async def _run_evolution_loop( logger.warning("Evolution notification failed on %s: %s", market_date, exc) +def _start_dashboard_server(settings: Settings) -> threading.Thread | None: + """Start FastAPI dashboard in a daemon thread when enabled.""" + if not settings.DASHBOARD_ENABLED: + return None + + def _serve() -> None: + try: + import uvicorn + + from src.dashboard import create_dashboard_app + + app = create_dashboard_app(settings.DB_PATH) + uvicorn.run( + app, + host=settings.DASHBOARD_HOST, + port=settings.DASHBOARD_PORT, + log_level="info", + ) + except Exception as exc: + logger.warning("Dashboard server failed to start: %s", exc) + + thread = threading.Thread( + target=_serve, + name="dashboard-server", + daemon=True, + ) + thread.start() + logger.info( + "Dashboard server started at http://%s:%d", + settings.DASHBOARD_HOST, + settings.DASHBOARD_PORT, + ) + return thread + + +def _apply_dashboard_flag(settings: Settings, dashboard_flag: bool) -> Settings: + """Apply CLI dashboard flag over environment settings.""" + if dashboard_flag and not settings.DASHBOARD_ENABLED: + return settings.model_copy(update={"DASHBOARD_ENABLED": True}) + return settings + + async def run(settings: Settings) -> None: """Main async loop — iterate over open markets on a timer.""" broker = KISBroker(settings) @@ -1042,6 +1085,7 @@ async def run(settings: Settings) -> None: low_volatility_threshold=30.0, ) priority_queue = PriorityTaskQueue(max_size=1000) + _start_dashboard_server(settings) # Track last scan time for each market last_scan_time: dict[str, float] = {} @@ -1395,10 +1439,16 @@ def main() -> None: default="paper", help="Trading mode (default: paper)", ) + parser.add_argument( + "--dashboard", + action="store_true", + help="Enable FastAPI dashboard server in background thread", + ) args = parser.parse_args() setup_logging() settings = Settings(MODE=args.mode) # type: ignore[call-arg] + settings = _apply_dashboard_flag(settings, args.dashboard) asyncio.run(run(settings)) diff --git a/tests/test_main.py b/tests/test_main.py index 9afc36f..8bda052 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -5,6 +5,7 @@ from unittest.mock import ANY, AsyncMock, MagicMock, patch import pytest +from src.config import Settings from src.context.layer import ContextLayer from src.context.scheduler import ScheduleResult from src.core.risk_manager import CircuitBreakerTripped, FatFingerRejected @@ -12,9 +13,11 @@ from src.db import init_db, log_trade from src.evolution.scorecard import DailyScorecard from src.logging.decision_logger import DecisionLogger from src.main import ( + _apply_dashboard_flag, _handle_market_close, _run_context_scheduler, _run_evolution_loop, + _start_dashboard_server, safe_float, trading_cycle, ) @@ -1454,3 +1457,44 @@ async def test_run_evolution_loop_notification_error_is_ignored() -> None: optimizer.evolve.assert_called_once() telegram.send_message.assert_called_once() + + +def test_apply_dashboard_flag_enables_dashboard() -> None: + settings = Settings( + KIS_APP_KEY="test_key", + KIS_APP_SECRET="test_secret", + KIS_ACCOUNT_NO="12345678-01", + GEMINI_API_KEY="test_gemini_key", + DASHBOARD_ENABLED=False, + ) + updated = _apply_dashboard_flag(settings, dashboard_flag=True) + assert updated.DASHBOARD_ENABLED is True + + +def test_start_dashboard_server_disabled_returns_none() -> None: + settings = Settings( + KIS_APP_KEY="test_key", + KIS_APP_SECRET="test_secret", + KIS_ACCOUNT_NO="12345678-01", + GEMINI_API_KEY="test_gemini_key", + DASHBOARD_ENABLED=False, + ) + thread = _start_dashboard_server(settings) + assert thread is None + + +def test_start_dashboard_server_enabled_starts_thread() -> None: + settings = Settings( + KIS_APP_KEY="test_key", + KIS_APP_SECRET="test_secret", + KIS_ACCOUNT_NO="12345678-01", + GEMINI_API_KEY="test_gemini_key", + DASHBOARD_ENABLED=True, + ) + mock_thread = MagicMock() + with patch("src.main.threading.Thread", return_value=mock_thread) as mock_thread_cls: + thread = _start_dashboard_server(settings) + + assert thread == mock_thread + mock_thread_cls.assert_called_once() + mock_thread.start.assert_called_once()