Compare commits

...

2 Commits

Author SHA1 Message Date
agentson
63fa6841a2 feat: dashboard background thread with CLI flag (issue #97)
Some checks failed
CI / test (pull_request) Has been cancelled
Add --dashboard CLI flag and DASHBOARD_ENABLED env var to start
FastAPI dashboard in a daemon thread alongside the trading loop.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 00:01:29 +09:00
ece3c5597b Merge pull request 'feat: FastAPI 읽기 전용 대시보드 (issue #96)' (#127) from feature/issue-96-evolution-main-integration into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #127
2026-02-14 23:57:17 +09:00
4 changed files with 100 additions and 0 deletions

View File

@@ -10,6 +10,7 @@ dependencies = [
"google-genai>=1.0,<2", "google-genai>=1.0,<2",
"scipy>=1.11,<2", "scipy>=1.11,<2",
"fastapi>=0.110,<1", "fastapi>=0.110,<1",
"uvicorn>=0.29,<1",
] ]
[project.optional-dependencies] [project.optional-dependencies]

View File

@@ -83,6 +83,11 @@ class Settings(BaseSettings):
TELEGRAM_COMMANDS_ENABLED: bool = True TELEGRAM_COMMANDS_ENABLED: bool = True
TELEGRAM_POLLING_INTERVAL: float = 1.0 # seconds 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"} model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
@property @property

View File

@@ -10,6 +10,7 @@ import argparse
import asyncio import asyncio
import logging import logging
import signal import signal
import threading
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Any from typing import Any
@@ -844,6 +845,48 @@ async def _run_evolution_loop(
logger.warning("Evolution notification failed on %s: %s", market_date, exc) 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: async def run(settings: Settings) -> None:
"""Main async loop — iterate over open markets on a timer.""" """Main async loop — iterate over open markets on a timer."""
broker = KISBroker(settings) broker = KISBroker(settings)
@@ -1042,6 +1085,7 @@ async def run(settings: Settings) -> None:
low_volatility_threshold=30.0, low_volatility_threshold=30.0,
) )
priority_queue = PriorityTaskQueue(max_size=1000) priority_queue = PriorityTaskQueue(max_size=1000)
_start_dashboard_server(settings)
# Track last scan time for each market # Track last scan time for each market
last_scan_time: dict[str, float] = {} last_scan_time: dict[str, float] = {}
@@ -1395,10 +1439,16 @@ def main() -> None:
default="paper", default="paper",
help="Trading mode (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() args = parser.parse_args()
setup_logging() setup_logging()
settings = Settings(MODE=args.mode) # type: ignore[call-arg] settings = Settings(MODE=args.mode) # type: ignore[call-arg]
settings = _apply_dashboard_flag(settings, args.dashboard)
asyncio.run(run(settings)) asyncio.run(run(settings))

View File

@@ -5,6 +5,7 @@ from unittest.mock import ANY, AsyncMock, MagicMock, patch
import pytest import pytest
from src.config import Settings
from src.context.layer import ContextLayer from src.context.layer import ContextLayer
from src.context.scheduler import ScheduleResult from src.context.scheduler import ScheduleResult
from src.core.risk_manager import CircuitBreakerTripped, FatFingerRejected 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.evolution.scorecard import DailyScorecard
from src.logging.decision_logger import DecisionLogger from src.logging.decision_logger import DecisionLogger
from src.main import ( from src.main import (
_apply_dashboard_flag,
_handle_market_close, _handle_market_close,
_run_context_scheduler, _run_context_scheduler,
_run_evolution_loop, _run_evolution_loop,
_start_dashboard_server,
safe_float, safe_float,
trading_cycle, trading_cycle,
) )
@@ -1454,3 +1457,44 @@ async def test_run_evolution_loop_notification_error_is_ignored() -> None:
optimizer.evolve.assert_called_once() optimizer.evolve.assert_called_once()
telegram.send_message.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()