feat: FastAPI 읽기 전용 대시보드 (issue #96) #127
@@ -9,6 +9,7 @@ dependencies = [
|
|||||||
"pydantic-settings>=2.1,<3",
|
"pydantic-settings>=2.1,<3",
|
||||||
"google-genai>=1.0,<2",
|
"google-genai>=1.0,<2",
|
||||||
"scipy>=1.11,<2",
|
"scipy>=1.11,<2",
|
||||||
|
"fastapi>=0.110,<1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
5
src/dashboard/__init__.py
Normal file
5
src/dashboard/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""FastAPI dashboard package for observability APIs."""
|
||||||
|
|
||||||
|
from src.dashboard.app import create_dashboard_app
|
||||||
|
|
||||||
|
__all__ = ["create_dashboard_app"]
|
||||||
349
src/dashboard/app.py
Normal file
349
src/dashboard/app.py
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
"""FastAPI application for observability dashboard endpoints."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import FastAPI, HTTPException, Query
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
|
||||||
|
|
||||||
|
def create_dashboard_app(db_path: str) -> FastAPI:
|
||||||
|
"""Create dashboard FastAPI app bound to a SQLite database path."""
|
||||||
|
app = FastAPI(title="The Ouroboros Dashboard", version="1.0.0")
|
||||||
|
app.state.db_path = db_path
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
def index() -> FileResponse:
|
||||||
|
index_path = Path(__file__).parent / "static" / "index.html"
|
||||||
|
return FileResponse(index_path)
|
||||||
|
|
||||||
|
@app.get("/api/status")
|
||||||
|
def get_status() -> dict[str, Any]:
|
||||||
|
today = datetime.now(UTC).date().isoformat()
|
||||||
|
with _connect(db_path) as conn:
|
||||||
|
markets = ["KR", "US"]
|
||||||
|
market_status: dict[str, Any] = {}
|
||||||
|
total_trades = 0
|
||||||
|
total_pnl = 0.0
|
||||||
|
total_decisions = 0
|
||||||
|
for market in markets:
|
||||||
|
trade_row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) AS c, COALESCE(SUM(pnl), 0.0) AS p
|
||||||
|
FROM trades
|
||||||
|
WHERE DATE(timestamp) = ? AND market = ?
|
||||||
|
""",
|
||||||
|
(today, market),
|
||||||
|
).fetchone()
|
||||||
|
decision_row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) AS c
|
||||||
|
FROM decision_logs
|
||||||
|
WHERE DATE(timestamp) = ? AND market = ?
|
||||||
|
""",
|
||||||
|
(today, market),
|
||||||
|
).fetchone()
|
||||||
|
playbook_row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT status
|
||||||
|
FROM playbooks
|
||||||
|
WHERE date = ? AND market = ?
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(today, market),
|
||||||
|
).fetchone()
|
||||||
|
market_status[market] = {
|
||||||
|
"trade_count": int(trade_row["c"] if trade_row else 0),
|
||||||
|
"total_pnl": float(trade_row["p"] if trade_row else 0.0),
|
||||||
|
"decision_count": int(decision_row["c"] if decision_row else 0),
|
||||||
|
"playbook_status": playbook_row["status"] if playbook_row else None,
|
||||||
|
}
|
||||||
|
total_trades += market_status[market]["trade_count"]
|
||||||
|
total_pnl += market_status[market]["total_pnl"]
|
||||||
|
total_decisions += market_status[market]["decision_count"]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"date": today,
|
||||||
|
"markets": market_status,
|
||||||
|
"totals": {
|
||||||
|
"trade_count": total_trades,
|
||||||
|
"total_pnl": round(total_pnl, 2),
|
||||||
|
"decision_count": total_decisions,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
@app.get("/api/playbook/{date_str}")
|
||||||
|
def get_playbook(date_str: str, market: str = Query("KR")) -> dict[str, Any]:
|
||||||
|
with _connect(db_path) as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT date, market, status, playbook_json, generated_at,
|
||||||
|
token_count, scenario_count, match_count
|
||||||
|
FROM playbooks
|
||||||
|
WHERE date = ? AND market = ?
|
||||||
|
""",
|
||||||
|
(date_str, market),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(status_code=404, detail="playbook not found")
|
||||||
|
return {
|
||||||
|
"date": row["date"],
|
||||||
|
"market": row["market"],
|
||||||
|
"status": row["status"],
|
||||||
|
"playbook": json.loads(row["playbook_json"]),
|
||||||
|
"generated_at": row["generated_at"],
|
||||||
|
"token_count": row["token_count"],
|
||||||
|
"scenario_count": row["scenario_count"],
|
||||||
|
"match_count": row["match_count"],
|
||||||
|
}
|
||||||
|
|
||||||
|
@app.get("/api/scorecard/{date_str}")
|
||||||
|
def get_scorecard(date_str: str, market: str = Query("KR")) -> dict[str, Any]:
|
||||||
|
key = f"scorecard_{market}"
|
||||||
|
with _connect(db_path) as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT value
|
||||||
|
FROM contexts
|
||||||
|
WHERE layer = 'L6_DAILY' AND timeframe = ? AND key = ?
|
||||||
|
""",
|
||||||
|
(date_str, key),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(status_code=404, detail="scorecard not found")
|
||||||
|
return {"date": date_str, "market": market, "scorecard": json.loads(row["value"])}
|
||||||
|
|
||||||
|
@app.get("/api/performance")
|
||||||
|
def get_performance(market: str = Query("all")) -> dict[str, Any]:
|
||||||
|
with _connect(db_path) as conn:
|
||||||
|
if market == "all":
|
||||||
|
by_market_rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT market,
|
||||||
|
COUNT(*) AS total_trades,
|
||||||
|
SUM(CASE WHEN pnl > 0 THEN 1 ELSE 0 END) AS wins,
|
||||||
|
SUM(CASE WHEN pnl < 0 THEN 1 ELSE 0 END) AS losses,
|
||||||
|
COALESCE(SUM(pnl), 0.0) AS total_pnl,
|
||||||
|
COALESCE(AVG(confidence), 0.0) AS avg_confidence
|
||||||
|
FROM trades
|
||||||
|
GROUP BY market
|
||||||
|
ORDER BY market
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
combined = _performance_from_rows(by_market_rows)
|
||||||
|
return {
|
||||||
|
"market": "all",
|
||||||
|
"combined": combined,
|
||||||
|
"by_market": [
|
||||||
|
_row_to_performance(row)
|
||||||
|
for row in by_market_rows
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT market,
|
||||||
|
COUNT(*) AS total_trades,
|
||||||
|
SUM(CASE WHEN pnl > 0 THEN 1 ELSE 0 END) AS wins,
|
||||||
|
SUM(CASE WHEN pnl < 0 THEN 1 ELSE 0 END) AS losses,
|
||||||
|
COALESCE(SUM(pnl), 0.0) AS total_pnl,
|
||||||
|
COALESCE(AVG(confidence), 0.0) AS avg_confidence
|
||||||
|
FROM trades
|
||||||
|
WHERE market = ?
|
||||||
|
GROUP BY market
|
||||||
|
""",
|
||||||
|
(market,),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return {"market": market, "metrics": _empty_performance(market)}
|
||||||
|
return {"market": market, "metrics": _row_to_performance(row)}
|
||||||
|
|
||||||
|
@app.get("/api/context/{layer}")
|
||||||
|
def get_context_layer(
|
||||||
|
layer: str,
|
||||||
|
timeframe: str | None = Query(default=None),
|
||||||
|
limit: int = Query(default=100, ge=1, le=1000),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
with _connect(db_path) as conn:
|
||||||
|
if timeframe is None:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT timeframe, key, value, updated_at
|
||||||
|
FROM contexts
|
||||||
|
WHERE layer = ?
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(layer, limit),
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT timeframe, key, value, updated_at
|
||||||
|
FROM contexts
|
||||||
|
WHERE layer = ? AND timeframe = ?
|
||||||
|
ORDER BY key
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(layer, timeframe, limit),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
entries = [
|
||||||
|
{
|
||||||
|
"timeframe": row["timeframe"],
|
||||||
|
"key": row["key"],
|
||||||
|
"value": json.loads(row["value"]),
|
||||||
|
"updated_at": row["updated_at"],
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"layer": layer,
|
||||||
|
"timeframe": timeframe,
|
||||||
|
"count": len(entries),
|
||||||
|
"entries": entries,
|
||||||
|
}
|
||||||
|
|
||||||
|
@app.get("/api/decisions")
|
||||||
|
def get_decisions(
|
||||||
|
market: str = Query("KR"),
|
||||||
|
limit: int = Query(default=50, ge=1, le=500),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
with _connect(db_path) as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT decision_id, timestamp, stock_code, market, exchange_code,
|
||||||
|
action, confidence, rationale, context_snapshot, input_data,
|
||||||
|
outcome_pnl, outcome_accuracy
|
||||||
|
FROM decision_logs
|
||||||
|
WHERE market = ?
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(market, limit),
|
||||||
|
).fetchall()
|
||||||
|
decisions = []
|
||||||
|
for row in rows:
|
||||||
|
decisions.append(
|
||||||
|
{
|
||||||
|
"decision_id": row["decision_id"],
|
||||||
|
"timestamp": row["timestamp"],
|
||||||
|
"stock_code": row["stock_code"],
|
||||||
|
"market": row["market"],
|
||||||
|
"exchange_code": row["exchange_code"],
|
||||||
|
"action": row["action"],
|
||||||
|
"confidence": row["confidence"],
|
||||||
|
"rationale": row["rationale"],
|
||||||
|
"context_snapshot": json.loads(row["context_snapshot"]),
|
||||||
|
"input_data": json.loads(row["input_data"]),
|
||||||
|
"outcome_pnl": row["outcome_pnl"],
|
||||||
|
"outcome_accuracy": row["outcome_accuracy"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {"market": market, "count": len(decisions), "decisions": decisions}
|
||||||
|
|
||||||
|
@app.get("/api/scenarios/active")
|
||||||
|
def get_active_scenarios(
|
||||||
|
market: str = Query("US"),
|
||||||
|
date_str: str | None = Query(default=None),
|
||||||
|
limit: int = Query(default=50, ge=1, le=500),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if date_str is None:
|
||||||
|
date_str = datetime.now(UTC).date().isoformat()
|
||||||
|
|
||||||
|
with _connect(db_path) as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT timestamp, stock_code, action, confidence, rationale, context_snapshot
|
||||||
|
FROM decision_logs
|
||||||
|
WHERE market = ? AND DATE(timestamp) = ?
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(market, date_str, limit),
|
||||||
|
).fetchall()
|
||||||
|
matches: list[dict[str, Any]] = []
|
||||||
|
for row in rows:
|
||||||
|
snapshot = json.loads(row["context_snapshot"])
|
||||||
|
scenario_match = snapshot.get("scenario_match", {})
|
||||||
|
if not isinstance(scenario_match, dict) or not scenario_match:
|
||||||
|
continue
|
||||||
|
matches.append(
|
||||||
|
{
|
||||||
|
"timestamp": row["timestamp"],
|
||||||
|
"stock_code": row["stock_code"],
|
||||||
|
"action": row["action"],
|
||||||
|
"confidence": row["confidence"],
|
||||||
|
"rationale": row["rationale"],
|
||||||
|
"scenario_match": scenario_match,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {"market": market, "date": date_str, "count": len(matches), "matches": matches}
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def _connect(db_path: str) -> sqlite3.Connection:
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_performance(row: sqlite3.Row) -> dict[str, Any]:
|
||||||
|
wins = int(row["wins"] or 0)
|
||||||
|
losses = int(row["losses"] or 0)
|
||||||
|
total = int(row["total_trades"] or 0)
|
||||||
|
win_rate = round((wins / (wins + losses) * 100), 2) if (wins + losses) > 0 else 0.0
|
||||||
|
return {
|
||||||
|
"market": row["market"],
|
||||||
|
"total_trades": total,
|
||||||
|
"wins": wins,
|
||||||
|
"losses": losses,
|
||||||
|
"win_rate": win_rate,
|
||||||
|
"total_pnl": round(float(row["total_pnl"] or 0.0), 2),
|
||||||
|
"avg_confidence": round(float(row["avg_confidence"] or 0.0), 2),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _performance_from_rows(rows: list[sqlite3.Row]) -> dict[str, Any]:
|
||||||
|
total_trades = 0
|
||||||
|
wins = 0
|
||||||
|
losses = 0
|
||||||
|
total_pnl = 0.0
|
||||||
|
confidence_weighted = 0.0
|
||||||
|
for row in rows:
|
||||||
|
market_total = int(row["total_trades"] or 0)
|
||||||
|
market_conf = float(row["avg_confidence"] or 0.0)
|
||||||
|
total_trades += market_total
|
||||||
|
wins += int(row["wins"] or 0)
|
||||||
|
losses += int(row["losses"] or 0)
|
||||||
|
total_pnl += float(row["total_pnl"] or 0.0)
|
||||||
|
confidence_weighted += market_total * market_conf
|
||||||
|
win_rate = round((wins / (wins + losses) * 100), 2) if (wins + losses) > 0 else 0.0
|
||||||
|
avg_confidence = round(confidence_weighted / total_trades, 2) if total_trades > 0 else 0.0
|
||||||
|
return {
|
||||||
|
"market": "all",
|
||||||
|
"total_trades": total_trades,
|
||||||
|
"wins": wins,
|
||||||
|
"losses": losses,
|
||||||
|
"win_rate": win_rate,
|
||||||
|
"total_pnl": round(total_pnl, 2),
|
||||||
|
"avg_confidence": avg_confidence,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _empty_performance(market: str) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"market": market,
|
||||||
|
"total_trades": 0,
|
||||||
|
"wins": 0,
|
||||||
|
"losses": 0,
|
||||||
|
"win_rate": 0.0,
|
||||||
|
"total_pnl": 0.0,
|
||||||
|
"avg_confidence": 0.0,
|
||||||
|
}
|
||||||
61
src/dashboard/static/index.html
Normal file
61
src/dashboard/static/index.html
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>The Ouroboros Dashboard</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0b1724;
|
||||||
|
--panel: #12263a;
|
||||||
|
--fg: #e6eef7;
|
||||||
|
--muted: #9fb3c8;
|
||||||
|
--accent: #3cb371;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
background: radial-gradient(circle at top left, #173b58, var(--bg));
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
.wrap {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 48px auto;
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
background: color-mix(in oklab, var(--panel), black 12%);
|
||||||
|
border: 1px solid #28455f;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
code {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
li {
|
||||||
|
margin: 6px 0;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="card">
|
||||||
|
<h1>The Ouroboros Dashboard API</h1>
|
||||||
|
<p>Use the following endpoints:</p>
|
||||||
|
<ul>
|
||||||
|
<li><code>/api/status</code></li>
|
||||||
|
<li><code>/api/playbook/{date}?market=KR</code></li>
|
||||||
|
<li><code>/api/scorecard/{date}?market=KR</code></li>
|
||||||
|
<li><code>/api/performance?market=all</code></li>
|
||||||
|
<li><code>/api/context/{layer}</code></li>
|
||||||
|
<li><code>/api/decisions?market=KR</code></li>
|
||||||
|
<li><code>/api/scenarios/active?market=US</code></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
17
src/main.py
17
src/main.py
@@ -832,13 +832,16 @@ async def _run_evolution_loop(
|
|||||||
logger.info("Evolution loop skipped on %s (no actionable failures)", market_date)
|
logger.info("Evolution loop skipped on %s (no actionable failures)", market_date)
|
||||||
return
|
return
|
||||||
|
|
||||||
await telegram.send_message(
|
try:
|
||||||
"<b>Evolution Update</b>\n"
|
await telegram.send_message(
|
||||||
f"Date: {market_date}\n"
|
"<b>Evolution Update</b>\n"
|
||||||
f"PR: {pr_info.get('title', 'N/A')}\n"
|
f"Date: {market_date}\n"
|
||||||
f"Branch: {pr_info.get('branch', 'N/A')}\n"
|
f"PR: {pr_info.get('title', 'N/A')}\n"
|
||||||
f"Status: {pr_info.get('status', 'N/A')}"
|
f"Branch: {pr_info.get('branch', 'N/A')}\n"
|
||||||
)
|
f"Status: {pr_info.get('status', 'N/A')}"
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Evolution notification failed on %s: %s", market_date, exc)
|
||||||
|
|
||||||
|
|
||||||
async def run(settings: Settings) -> None:
|
async def run(settings: Settings) -> None:
|
||||||
|
|||||||
270
tests/test_dashboard.py
Normal file
270
tests/test_dashboard.py
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
"""Tests for FastAPI dashboard endpoints."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
pytest.importorskip("fastapi")
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from src.dashboard.app import create_dashboard_app
|
||||||
|
from src.db import init_db
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_db(conn: sqlite3.Connection) -> None:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO playbooks (
|
||||||
|
date, market, status, playbook_json, generated_at,
|
||||||
|
token_count, scenario_count, match_count
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
"2026-02-14",
|
||||||
|
"KR",
|
||||||
|
"ready",
|
||||||
|
json.dumps({"market": "KR", "stock_playbooks": []}),
|
||||||
|
"2026-02-14T08:30:00+00:00",
|
||||||
|
123,
|
||||||
|
2,
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO contexts (layer, timeframe, key, value, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
"L6_DAILY",
|
||||||
|
"2026-02-14",
|
||||||
|
"scorecard_KR",
|
||||||
|
json.dumps({"market": "KR", "total_pnl": 1.5, "win_rate": 60.0}),
|
||||||
|
"2026-02-14T15:30:00+00:00",
|
||||||
|
"2026-02-14T15:30:00+00:00",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO contexts (layer, timeframe, key, value, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
"L7_REALTIME",
|
||||||
|
"2026-02-14T10:00:00+00:00",
|
||||||
|
"volatility_KR_005930",
|
||||||
|
json.dumps({"momentum_score": 70.0}),
|
||||||
|
"2026-02-14T10:00:00+00:00",
|
||||||
|
"2026-02-14T10:00:00+00:00",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO decision_logs (
|
||||||
|
decision_id, timestamp, stock_code, market, exchange_code,
|
||||||
|
action, confidence, rationale, context_snapshot, input_data
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
"d-kr-1",
|
||||||
|
"2026-02-14T09:10:00+00:00",
|
||||||
|
"005930",
|
||||||
|
"KR",
|
||||||
|
"KRX",
|
||||||
|
"BUY",
|
||||||
|
85,
|
||||||
|
"signal matched",
|
||||||
|
json.dumps({"scenario_match": {"rsi": 28.0}}),
|
||||||
|
json.dumps({"current_price": 70000}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO decision_logs (
|
||||||
|
decision_id, timestamp, stock_code, market, exchange_code,
|
||||||
|
action, confidence, rationale, context_snapshot, input_data
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
"d-us-1",
|
||||||
|
"2026-02-14T21:10:00+00:00",
|
||||||
|
"AAPL",
|
||||||
|
"US",
|
||||||
|
"NASDAQ",
|
||||||
|
"SELL",
|
||||||
|
80,
|
||||||
|
"no match",
|
||||||
|
json.dumps({"scenario_match": {}}),
|
||||||
|
json.dumps({"current_price": 200}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO trades (
|
||||||
|
timestamp, stock_code, action, confidence, rationale,
|
||||||
|
quantity, price, pnl, market, exchange_code, selection_context, decision_id
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
"2026-02-14T09:11:00+00:00",
|
||||||
|
"005930",
|
||||||
|
"BUY",
|
||||||
|
85,
|
||||||
|
"buy",
|
||||||
|
1,
|
||||||
|
70000,
|
||||||
|
2.0,
|
||||||
|
"KR",
|
||||||
|
"KRX",
|
||||||
|
None,
|
||||||
|
"d-kr-1",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO trades (
|
||||||
|
timestamp, stock_code, action, confidence, rationale,
|
||||||
|
quantity, price, pnl, market, exchange_code, selection_context, decision_id
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
"2026-02-14T21:11:00+00:00",
|
||||||
|
"AAPL",
|
||||||
|
"SELL",
|
||||||
|
80,
|
||||||
|
"sell",
|
||||||
|
1,
|
||||||
|
200,
|
||||||
|
-1.0,
|
||||||
|
"US",
|
||||||
|
"NASDAQ",
|
||||||
|
None,
|
||||||
|
"d-us-1",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _client(tmp_path: Path) -> TestClient:
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
assert "KR" in body["markets"]
|
||||||
|
assert "US" 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"
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
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()
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
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()
|
||||||
|
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()
|
||||||
|
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()
|
||||||
|
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
|
||||||
@@ -1304,6 +1304,82 @@ async def test_handle_market_close_without_lessons_stores_once() -> None:
|
|||||||
assert reviewer.store_scorecard_in_context.call_count == 1
|
assert reviewer.store_scorecard_in_context.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_handle_market_close_triggers_evolution_for_us() -> None:
|
||||||
|
telegram = MagicMock()
|
||||||
|
telegram.notify_market_close = AsyncMock()
|
||||||
|
telegram.send_message = AsyncMock()
|
||||||
|
|
||||||
|
context_aggregator = MagicMock()
|
||||||
|
reviewer = MagicMock()
|
||||||
|
reviewer.generate_scorecard.return_value = DailyScorecard(
|
||||||
|
date="2026-02-14",
|
||||||
|
market="US",
|
||||||
|
total_decisions=2,
|
||||||
|
buys=1,
|
||||||
|
sells=1,
|
||||||
|
holds=0,
|
||||||
|
total_pnl=3.0,
|
||||||
|
win_rate=50.0,
|
||||||
|
avg_confidence=80.0,
|
||||||
|
scenario_match_rate=100.0,
|
||||||
|
)
|
||||||
|
reviewer.generate_lessons = AsyncMock(return_value=[])
|
||||||
|
|
||||||
|
evolution_optimizer = MagicMock()
|
||||||
|
evolution_optimizer.evolve = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
await _handle_market_close(
|
||||||
|
market_code="US",
|
||||||
|
market_name="United States",
|
||||||
|
market_timezone=UTC,
|
||||||
|
telegram=telegram,
|
||||||
|
context_aggregator=context_aggregator,
|
||||||
|
daily_reviewer=reviewer,
|
||||||
|
evolution_optimizer=evolution_optimizer,
|
||||||
|
)
|
||||||
|
|
||||||
|
evolution_optimizer.evolve.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_handle_market_close_skips_evolution_for_kr() -> None:
|
||||||
|
telegram = MagicMock()
|
||||||
|
telegram.notify_market_close = AsyncMock()
|
||||||
|
telegram.send_message = AsyncMock()
|
||||||
|
|
||||||
|
context_aggregator = MagicMock()
|
||||||
|
reviewer = MagicMock()
|
||||||
|
reviewer.generate_scorecard.return_value = DailyScorecard(
|
||||||
|
date="2026-02-14",
|
||||||
|
market="KR",
|
||||||
|
total_decisions=1,
|
||||||
|
buys=1,
|
||||||
|
sells=0,
|
||||||
|
holds=0,
|
||||||
|
total_pnl=1.0,
|
||||||
|
win_rate=100.0,
|
||||||
|
avg_confidence=90.0,
|
||||||
|
scenario_match_rate=100.0,
|
||||||
|
)
|
||||||
|
reviewer.generate_lessons = AsyncMock(return_value=[])
|
||||||
|
|
||||||
|
evolution_optimizer = MagicMock()
|
||||||
|
evolution_optimizer.evolve = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
await _handle_market_close(
|
||||||
|
market_code="KR",
|
||||||
|
market_name="Korea",
|
||||||
|
market_timezone=UTC,
|
||||||
|
telegram=telegram,
|
||||||
|
context_aggregator=context_aggregator,
|
||||||
|
daily_reviewer=reviewer,
|
||||||
|
evolution_optimizer=evolution_optimizer,
|
||||||
|
)
|
||||||
|
|
||||||
|
evolution_optimizer.evolve.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
def test_run_context_scheduler_invokes_scheduler() -> None:
|
def test_run_context_scheduler_invokes_scheduler() -> None:
|
||||||
"""Scheduler helper should call run_if_due with provided datetime."""
|
"""Scheduler helper should call run_if_due with provided datetime."""
|
||||||
scheduler = MagicMock()
|
scheduler = MagicMock()
|
||||||
@@ -1354,3 +1430,27 @@ async def test_run_evolution_loop_notifies_when_pr_generated() -> None:
|
|||||||
|
|
||||||
optimizer.evolve.assert_called_once()
|
optimizer.evolve.assert_called_once()
|
||||||
telegram.send_message.assert_called_once()
|
telegram.send_message.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_evolution_loop_notification_error_is_ignored() -> None:
|
||||||
|
optimizer = MagicMock()
|
||||||
|
optimizer.evolve = AsyncMock(
|
||||||
|
return_value={
|
||||||
|
"title": "[Evolution] New strategy: v20260214_050000",
|
||||||
|
"branch": "evolution/v20260214_050000",
|
||||||
|
"status": "ready_for_review",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
telegram = MagicMock()
|
||||||
|
telegram.send_message = AsyncMock(side_effect=RuntimeError("telegram down"))
|
||||||
|
|
||||||
|
await _run_evolution_loop(
|
||||||
|
evolution_optimizer=optimizer,
|
||||||
|
telegram=telegram,
|
||||||
|
market_code="US",
|
||||||
|
market_date="2026-02-14",
|
||||||
|
)
|
||||||
|
|
||||||
|
optimizer.evolve.assert_called_once()
|
||||||
|
telegram.send_message.assert_called_once()
|
||||||
|
|||||||
Reference in New Issue
Block a user