Compare commits
15 Commits
feature/is
...
feature/is
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2275a23b1 | ||
| 7522bb7e66 | |||
|
|
63fa6841a2 | ||
| ece3c5597b | |||
|
|
63f4e49d88 | ||
|
|
e0a6b307a2 | ||
| 75320eb587 | |||
|
|
afb31b7f4b | ||
| a429a9f4da | |||
|
|
d9763def85 | ||
| ab7f0444b2 | |||
|
|
6b3960a3a4 | ||
| 6cad8e74e1 | |||
|
|
86c94cff62 | ||
| 692cb61991 |
@@ -9,6 +9,8 @@ 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",
|
||||||
|
"uvicorn>=0.29,<1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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>
|
||||||
130
src/main.py
130
src/main.py
@@ -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
|
||||||
|
|
||||||
@@ -22,12 +23,14 @@ from src.broker.overseas import OverseasBroker
|
|||||||
from src.config import Settings
|
from src.config import Settings
|
||||||
from src.context.aggregator import ContextAggregator
|
from src.context.aggregator import ContextAggregator
|
||||||
from src.context.layer import ContextLayer
|
from src.context.layer import ContextLayer
|
||||||
|
from src.context.scheduler import ContextScheduler
|
||||||
from src.context.store import ContextStore
|
from src.context.store import ContextStore
|
||||||
from src.core.criticality import CriticalityAssessor
|
from src.core.criticality import CriticalityAssessor
|
||||||
from src.core.priority_queue import PriorityTaskQueue
|
from src.core.priority_queue import PriorityTaskQueue
|
||||||
from src.core.risk_manager import CircuitBreakerTripped, FatFingerRejected, RiskManager
|
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, init_db, log_trade
|
||||||
from src.evolution.daily_review import DailyReviewer
|
from src.evolution.daily_review import DailyReviewer
|
||||||
|
from src.evolution.optimizer import EvolutionOptimizer
|
||||||
from src.logging.decision_logger import DecisionLogger
|
from src.logging.decision_logger import DecisionLogger
|
||||||
from src.logging_config import setup_logging
|
from src.logging_config import setup_logging
|
||||||
from src.markets.schedule import MarketInfo, get_next_market_open, get_open_markets
|
from src.markets.schedule import MarketInfo, get_next_market_open, get_open_markets
|
||||||
@@ -744,6 +747,7 @@ async def _handle_market_close(
|
|||||||
telegram: TelegramClient,
|
telegram: TelegramClient,
|
||||||
context_aggregator: ContextAggregator,
|
context_aggregator: ContextAggregator,
|
||||||
daily_reviewer: DailyReviewer,
|
daily_reviewer: DailyReviewer,
|
||||||
|
evolution_optimizer: EvolutionOptimizer | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Handle market-close tasks: notify, aggregate, review, and store context."""
|
"""Handle market-close tasks: notify, aggregate, review, and store context."""
|
||||||
await telegram.notify_market_close(market_name, 0.0)
|
await telegram.notify_market_close(market_name, 0.0)
|
||||||
@@ -771,6 +775,117 @@ async def _handle_market_close(
|
|||||||
f"Lessons: {', '.join(scorecard.lessons) if scorecard.lessons else 'N/A'}"
|
f"Lessons: {', '.join(scorecard.lessons) if scorecard.lessons else 'N/A'}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if evolution_optimizer is not None:
|
||||||
|
await _run_evolution_loop(
|
||||||
|
evolution_optimizer=evolution_optimizer,
|
||||||
|
telegram=telegram,
|
||||||
|
market_code=market_code,
|
||||||
|
market_date=market_date,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_context_scheduler(
|
||||||
|
scheduler: ContextScheduler, now: datetime | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Run periodic context scheduler tasks and log when anything executes."""
|
||||||
|
result = scheduler.run_if_due(now=now)
|
||||||
|
if any(
|
||||||
|
[
|
||||||
|
result.weekly,
|
||||||
|
result.monthly,
|
||||||
|
result.quarterly,
|
||||||
|
result.annual,
|
||||||
|
result.legacy,
|
||||||
|
result.cleanup,
|
||||||
|
]
|
||||||
|
):
|
||||||
|
logger.info(
|
||||||
|
(
|
||||||
|
"Context scheduler ran (weekly=%s, monthly=%s, quarterly=%s, "
|
||||||
|
"annual=%s, legacy=%s, cleanup=%s)"
|
||||||
|
),
|
||||||
|
result.weekly,
|
||||||
|
result.monthly,
|
||||||
|
result.quarterly,
|
||||||
|
result.annual,
|
||||||
|
result.legacy,
|
||||||
|
result.cleanup,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_evolution_loop(
|
||||||
|
evolution_optimizer: EvolutionOptimizer,
|
||||||
|
telegram: TelegramClient,
|
||||||
|
market_code: str,
|
||||||
|
market_date: str,
|
||||||
|
) -> None:
|
||||||
|
"""Run evolution loop once at US close (end of trading day)."""
|
||||||
|
if market_code != "US":
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
pr_info = await evolution_optimizer.evolve()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Evolution loop failed on %s: %s", market_date, exc)
|
||||||
|
return
|
||||||
|
|
||||||
|
if pr_info is None:
|
||||||
|
logger.info("Evolution loop skipped on %s (no actionable failures)", market_date)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
await telegram.send_message(
|
||||||
|
"<b>Evolution Update</b>\n"
|
||||||
|
f"Date: {market_date}\n"
|
||||||
|
f"PR: {pr_info.get('title', 'N/A')}\n"
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
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."""
|
||||||
@@ -782,6 +897,11 @@ async def run(settings: Settings) -> None:
|
|||||||
decision_logger = DecisionLogger(db_conn)
|
decision_logger = DecisionLogger(db_conn)
|
||||||
context_store = ContextStore(db_conn)
|
context_store = ContextStore(db_conn)
|
||||||
context_aggregator = ContextAggregator(db_conn)
|
context_aggregator = ContextAggregator(db_conn)
|
||||||
|
context_scheduler = ContextScheduler(
|
||||||
|
aggregator=context_aggregator,
|
||||||
|
store=context_store,
|
||||||
|
)
|
||||||
|
evolution_optimizer = EvolutionOptimizer(settings)
|
||||||
|
|
||||||
# V2 proactive strategy components
|
# V2 proactive strategy components
|
||||||
context_selector = ContextSelector(context_store)
|
context_selector = ContextSelector(context_store)
|
||||||
@@ -965,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] = {}
|
||||||
@@ -1015,6 +1136,7 @@ async def run(settings: Settings) -> None:
|
|||||||
while not shutdown.is_set():
|
while not shutdown.is_set():
|
||||||
# Wait for trading to be unpaused
|
# Wait for trading to be unpaused
|
||||||
await pause_trading.wait()
|
await pause_trading.wait()
|
||||||
|
_run_context_scheduler(context_scheduler, now=datetime.now(UTC))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await run_daily_session(
|
await run_daily_session(
|
||||||
@@ -1053,6 +1175,7 @@ async def run(settings: Settings) -> None:
|
|||||||
while not shutdown.is_set():
|
while not shutdown.is_set():
|
||||||
# Wait for trading to be unpaused
|
# Wait for trading to be unpaused
|
||||||
await pause_trading.wait()
|
await pause_trading.wait()
|
||||||
|
_run_context_scheduler(context_scheduler, now=datetime.now(UTC))
|
||||||
|
|
||||||
# Get currently open markets
|
# Get currently open markets
|
||||||
open_markets = get_open_markets(settings.enabled_market_list)
|
open_markets = get_open_markets(settings.enabled_market_list)
|
||||||
@@ -1073,6 +1196,7 @@ async def run(settings: Settings) -> None:
|
|||||||
telegram=telegram,
|
telegram=telegram,
|
||||||
context_aggregator=context_aggregator,
|
context_aggregator=context_aggregator,
|
||||||
daily_reviewer=daily_reviewer,
|
daily_reviewer=daily_reviewer,
|
||||||
|
evolution_optimizer=evolution_optimizer,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("Market close notification failed: %s", exc)
|
logger.warning("Market close notification failed: %s", exc)
|
||||||
@@ -1315,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))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from datetime import date
|
from datetime import date, timedelta
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from src.analysis.smart_scanner import ScanCandidate
|
from src.analysis.smart_scanner import ScanCandidate
|
||||||
@@ -95,10 +95,17 @@ class PreMarketPlanner:
|
|||||||
try:
|
try:
|
||||||
# 1. Gather context
|
# 1. Gather context
|
||||||
context_data = self._gather_context()
|
context_data = self._gather_context()
|
||||||
|
self_market_scorecard = self.build_self_market_scorecard(market, today)
|
||||||
cross_market = self.build_cross_market_context(market, today)
|
cross_market = self.build_cross_market_context(market, today)
|
||||||
|
|
||||||
# 2. Build prompt
|
# 2. Build prompt
|
||||||
prompt = self._build_prompt(market, candidates, context_data, cross_market)
|
prompt = self._build_prompt(
|
||||||
|
market,
|
||||||
|
candidates,
|
||||||
|
context_data,
|
||||||
|
self_market_scorecard,
|
||||||
|
cross_market,
|
||||||
|
)
|
||||||
|
|
||||||
# 3. Call Gemini
|
# 3. Call Gemini
|
||||||
market_data = {
|
market_data = {
|
||||||
@@ -145,7 +152,8 @@ class PreMarketPlanner:
|
|||||||
other_market = "US" if target_market == "KR" else "KR"
|
other_market = "US" if target_market == "KR" else "KR"
|
||||||
if today is None:
|
if today is None:
|
||||||
today = date.today()
|
today = date.today()
|
||||||
timeframe = today.isoformat()
|
timeframe_date = today - timedelta(days=1) if target_market == "KR" else today
|
||||||
|
timeframe = timeframe_date.isoformat()
|
||||||
|
|
||||||
scorecard_key = f"scorecard_{other_market}"
|
scorecard_key = f"scorecard_{other_market}"
|
||||||
scorecard_data = self._context_store.get_context(
|
scorecard_data = self._context_store.get_context(
|
||||||
@@ -175,6 +183,37 @@ class PreMarketPlanner:
|
|||||||
lessons=scorecard_data.get("lessons", []),
|
lessons=scorecard_data.get("lessons", []),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def build_self_market_scorecard(
|
||||||
|
self, market: str, today: date | None = None,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Build previous-day scorecard for the same market."""
|
||||||
|
if today is None:
|
||||||
|
today = date.today()
|
||||||
|
timeframe = (today - timedelta(days=1)).isoformat()
|
||||||
|
scorecard_key = f"scorecard_{market}"
|
||||||
|
scorecard_data = self._context_store.get_context(
|
||||||
|
ContextLayer.L6_DAILY, timeframe, scorecard_key
|
||||||
|
)
|
||||||
|
|
||||||
|
if scorecard_data is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if isinstance(scorecard_data, str):
|
||||||
|
try:
|
||||||
|
scorecard_data = json.loads(scorecard_data)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not isinstance(scorecard_data, dict):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"date": timeframe,
|
||||||
|
"total_pnl": float(scorecard_data.get("total_pnl", 0.0)),
|
||||||
|
"win_rate": float(scorecard_data.get("win_rate", 0.0)),
|
||||||
|
"lessons": scorecard_data.get("lessons", []),
|
||||||
|
}
|
||||||
|
|
||||||
def _gather_context(self) -> dict[str, Any]:
|
def _gather_context(self) -> dict[str, Any]:
|
||||||
"""Gather strategic context using ContextSelector."""
|
"""Gather strategic context using ContextSelector."""
|
||||||
layers = self._context_selector.select_layers(
|
layers = self._context_selector.select_layers(
|
||||||
@@ -188,6 +227,7 @@ class PreMarketPlanner:
|
|||||||
market: str,
|
market: str,
|
||||||
candidates: list[ScanCandidate],
|
candidates: list[ScanCandidate],
|
||||||
context_data: dict[str, Any],
|
context_data: dict[str, Any],
|
||||||
|
self_market_scorecard: dict[str, Any] | None,
|
||||||
cross_market: CrossMarketContext | None,
|
cross_market: CrossMarketContext | None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Build a structured prompt for Gemini to generate scenario JSON."""
|
"""Build a structured prompt for Gemini to generate scenario JSON."""
|
||||||
@@ -211,6 +251,18 @@ class PreMarketPlanner:
|
|||||||
if cross_market.lessons:
|
if cross_market.lessons:
|
||||||
cross_market_text += f"- Lessons: {'; '.join(cross_market.lessons[:3])}\n"
|
cross_market_text += f"- Lessons: {'; '.join(cross_market.lessons[:3])}\n"
|
||||||
|
|
||||||
|
self_market_text = ""
|
||||||
|
if self_market_scorecard:
|
||||||
|
self_market_text = (
|
||||||
|
f"\n## My Market Previous Day ({market})\n"
|
||||||
|
f"- Date: {self_market_scorecard['date']}\n"
|
||||||
|
f"- P&L: {self_market_scorecard['total_pnl']:+.2f}%\n"
|
||||||
|
f"- Win Rate: {self_market_scorecard['win_rate']:.0f}%\n"
|
||||||
|
)
|
||||||
|
lessons = self_market_scorecard.get("lessons", [])
|
||||||
|
if lessons:
|
||||||
|
self_market_text += f"- Lessons: {'; '.join(lessons[:3])}\n"
|
||||||
|
|
||||||
context_text = ""
|
context_text = ""
|
||||||
if context_data:
|
if context_data:
|
||||||
context_text = "\n## Strategic Context\n"
|
context_text = "\n## Strategic Context\n"
|
||||||
@@ -224,6 +276,7 @@ class PreMarketPlanner:
|
|||||||
f"You are a pre-market trading strategist for the {market} market.\n"
|
f"You are a pre-market trading strategist for the {market} market.\n"
|
||||||
f"Generate structured trading scenarios for today.\n\n"
|
f"Generate structured trading scenarios for today.\n\n"
|
||||||
f"## Candidates (from volatility scanner)\n{candidates_text}\n"
|
f"## Candidates (from volatility scanner)\n{candidates_text}\n"
|
||||||
|
f"{self_market_text}"
|
||||||
f"{cross_market_text}"
|
f"{cross_market_text}"
|
||||||
f"{context_text}\n"
|
f"{context_text}\n"
|
||||||
f"## Instructions\n"
|
f"## Instructions\n"
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ from src.evolution.daily_review import DailyReviewer
|
|||||||
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 datetime import UTC, datetime
|
||||||
|
|
||||||
|
TODAY = datetime.now(UTC).strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def db_conn() -> sqlite3.Connection:
|
def db_conn() -> sqlite3.Connection:
|
||||||
@@ -116,7 +120,7 @@ def test_generate_scorecard_market_scoped(
|
|||||||
exchange_code="NASDAQ",
|
exchange_code="NASDAQ",
|
||||||
)
|
)
|
||||||
|
|
||||||
scorecard = reviewer.generate_scorecard("2026-02-14", "KR")
|
scorecard = reviewer.generate_scorecard(TODAY, "KR")
|
||||||
|
|
||||||
assert scorecard.market == "KR"
|
assert scorecard.market == "KR"
|
||||||
assert scorecard.total_decisions == 2
|
assert scorecard.total_decisions == 2
|
||||||
@@ -158,7 +162,7 @@ def test_generate_scorecard_top_winners_and_losers(
|
|||||||
decision_id=decision_id,
|
decision_id=decision_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
scorecard = reviewer.generate_scorecard("2026-02-14", "KR")
|
scorecard = reviewer.generate_scorecard(TODAY, "KR")
|
||||||
assert scorecard.top_winners == ["005930", "000660"]
|
assert scorecard.top_winners == ["005930", "000660"]
|
||||||
assert scorecard.top_losers == ["035420", "051910"]
|
assert scorecard.top_losers == ["035420", "051910"]
|
||||||
|
|
||||||
@@ -167,7 +171,7 @@ def test_generate_scorecard_empty_day(
|
|||||||
db_conn: sqlite3.Connection, context_store: ContextStore,
|
db_conn: sqlite3.Connection, context_store: ContextStore,
|
||||||
) -> None:
|
) -> None:
|
||||||
reviewer = DailyReviewer(db_conn, context_store)
|
reviewer = DailyReviewer(db_conn, context_store)
|
||||||
scorecard = reviewer.generate_scorecard("2026-02-14", "KR")
|
scorecard = reviewer.generate_scorecard(TODAY, "KR")
|
||||||
|
|
||||||
assert scorecard.total_decisions == 0
|
assert scorecard.total_decisions == 0
|
||||||
assert scorecard.total_pnl == 0.0
|
assert scorecard.total_pnl == 0.0
|
||||||
|
|||||||
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
|
||||||
@@ -1,16 +1,26 @@
|
|||||||
"""Tests for main trading loop integration."""
|
"""Tests for main trading loop integration."""
|
||||||
|
|
||||||
from datetime import UTC, date
|
from datetime import UTC, date, datetime
|
||||||
from unittest.mock import ANY, AsyncMock, MagicMock, patch
|
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.core.risk_manager import CircuitBreakerTripped, FatFingerRejected
|
from src.core.risk_manager import CircuitBreakerTripped, FatFingerRejected
|
||||||
from src.db import init_db, log_trade
|
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 _handle_market_close, safe_float, trading_cycle
|
from src.main import (
|
||||||
|
_apply_dashboard_flag,
|
||||||
|
_handle_market_close,
|
||||||
|
_run_context_scheduler,
|
||||||
|
_run_evolution_loop,
|
||||||
|
_start_dashboard_server,
|
||||||
|
safe_float,
|
||||||
|
trading_cycle,
|
||||||
|
)
|
||||||
from src.strategy.models import (
|
from src.strategy.models import (
|
||||||
DayPlaybook,
|
DayPlaybook,
|
||||||
ScenarioAction,
|
ScenarioAction,
|
||||||
@@ -1295,3 +1305,196 @@ 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:
|
||||||
|
"""Scheduler helper should call run_if_due with provided datetime."""
|
||||||
|
scheduler = MagicMock()
|
||||||
|
scheduler.run_if_due = MagicMock(return_value=ScheduleResult(cleanup=True))
|
||||||
|
|
||||||
|
_run_context_scheduler(scheduler, now=datetime(2026, 2, 14, tzinfo=UTC))
|
||||||
|
|
||||||
|
scheduler.run_if_due.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_evolution_loop_skips_non_us_market() -> None:
|
||||||
|
optimizer = MagicMock()
|
||||||
|
optimizer.evolve = AsyncMock()
|
||||||
|
telegram = MagicMock()
|
||||||
|
telegram.send_message = AsyncMock()
|
||||||
|
|
||||||
|
await _run_evolution_loop(
|
||||||
|
evolution_optimizer=optimizer,
|
||||||
|
telegram=telegram,
|
||||||
|
market_code="KR",
|
||||||
|
market_date="2026-02-14",
|
||||||
|
)
|
||||||
|
|
||||||
|
optimizer.evolve.assert_not_called()
|
||||||
|
telegram.send_message.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_evolution_loop_notifies_when_pr_generated() -> 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()
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
|
||||||
|
@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()
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from unittest.mock import AsyncMock, MagicMock
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.analysis.smart_scanner import ScanCandidate
|
from src.analysis.smart_scanner import ScanCandidate
|
||||||
|
from src.brain.context_selector import DecisionType
|
||||||
from src.brain.gemini_client import TradeDecision
|
from src.brain.gemini_client import TradeDecision
|
||||||
from src.config import Settings
|
from src.config import Settings
|
||||||
from src.context.store import ContextLayer
|
from src.context.store import ContextLayer
|
||||||
@@ -16,12 +17,10 @@ from src.strategy.models import (
|
|||||||
CrossMarketContext,
|
CrossMarketContext,
|
||||||
DayPlaybook,
|
DayPlaybook,
|
||||||
MarketOutlook,
|
MarketOutlook,
|
||||||
PlaybookStatus,
|
|
||||||
ScenarioAction,
|
ScenarioAction,
|
||||||
)
|
)
|
||||||
from src.strategy.pre_market_planner import PreMarketPlanner
|
from src.strategy.pre_market_planner import PreMarketPlanner
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Fixtures
|
# Fixtures
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -89,6 +88,7 @@ def _make_planner(
|
|||||||
token_count: int = 200,
|
token_count: int = 200,
|
||||||
context_data: dict | None = None,
|
context_data: dict | None = None,
|
||||||
scorecard_data: dict | None = None,
|
scorecard_data: dict | None = None,
|
||||||
|
scorecard_map: dict[tuple[str, str, str], dict | None] | None = None,
|
||||||
) -> PreMarketPlanner:
|
) -> PreMarketPlanner:
|
||||||
"""Create a PreMarketPlanner with mocked dependencies."""
|
"""Create a PreMarketPlanner with mocked dependencies."""
|
||||||
if not gemini_response:
|
if not gemini_response:
|
||||||
@@ -107,11 +107,20 @@ def _make_planner(
|
|||||||
|
|
||||||
# Mock ContextStore
|
# Mock ContextStore
|
||||||
store = MagicMock()
|
store = MagicMock()
|
||||||
|
if scorecard_map is not None:
|
||||||
|
store.get_context = MagicMock(
|
||||||
|
side_effect=lambda layer, timeframe, key: scorecard_map.get(
|
||||||
|
(layer.value if hasattr(layer, "value") else layer, timeframe, key)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
store.get_context = MagicMock(return_value=scorecard_data)
|
store.get_context = MagicMock(return_value=scorecard_data)
|
||||||
|
|
||||||
# Mock ContextSelector
|
# Mock ContextSelector
|
||||||
selector = MagicMock()
|
selector = MagicMock()
|
||||||
selector.select_layers = MagicMock(return_value=[ContextLayer.L7_REALTIME, ContextLayer.L6_DAILY])
|
selector.select_layers = MagicMock(
|
||||||
|
return_value=[ContextLayer.L7_REALTIME, ContextLayer.L6_DAILY]
|
||||||
|
)
|
||||||
selector.get_context_data = MagicMock(return_value=context_data or {})
|
selector.get_context_data = MagicMock(return_value=context_data or {})
|
||||||
|
|
||||||
settings = Settings(
|
settings = Settings(
|
||||||
@@ -220,11 +229,25 @@ class TestGeneratePlaybook:
|
|||||||
stocks = [
|
stocks = [
|
||||||
{
|
{
|
||||||
"stock_code": "005930",
|
"stock_code": "005930",
|
||||||
"scenarios": [{"condition": {"rsi_below": 30}, "action": "BUY", "confidence": 85, "rationale": "ok"}],
|
"scenarios": [
|
||||||
|
{
|
||||||
|
"condition": {"rsi_below": 30},
|
||||||
|
"action": "BUY",
|
||||||
|
"confidence": 85,
|
||||||
|
"rationale": "ok",
|
||||||
|
}
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"stock_code": "UNKNOWN",
|
"stock_code": "UNKNOWN",
|
||||||
"scenarios": [{"condition": {"rsi_below": 20}, "action": "BUY", "confidence": 90, "rationale": "bad"}],
|
"scenarios": [
|
||||||
|
{
|
||||||
|
"condition": {"rsi_below": 20},
|
||||||
|
"action": "BUY",
|
||||||
|
"confidence": 90,
|
||||||
|
"rationale": "bad",
|
||||||
|
}
|
||||||
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
planner = _make_planner(gemini_response=_gemini_response_json(stocks=stocks))
|
planner = _make_planner(gemini_response=_gemini_response_json(stocks=stocks))
|
||||||
@@ -254,6 +277,43 @@ class TestGeneratePlaybook:
|
|||||||
|
|
||||||
assert pb.token_count == 450
|
assert pb.token_count == 450
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_generate_playbook_uses_strategic_context_selector(self) -> None:
|
||||||
|
planner = _make_planner()
|
||||||
|
candidates = [_candidate()]
|
||||||
|
|
||||||
|
await planner.generate_playbook("KR", candidates, today=date(2026, 2, 8))
|
||||||
|
|
||||||
|
planner._context_selector.select_layers.assert_called_once_with(
|
||||||
|
decision_type=DecisionType.STRATEGIC,
|
||||||
|
include_realtime=True,
|
||||||
|
)
|
||||||
|
planner._context_selector.get_context_data.assert_called_once()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_generate_playbook_injects_self_and_cross_scorecards(self) -> None:
|
||||||
|
scorecard_map = {
|
||||||
|
(ContextLayer.L6_DAILY.value, "2026-02-07", "scorecard_KR"): {
|
||||||
|
"total_pnl": -1.0,
|
||||||
|
"win_rate": 40,
|
||||||
|
"lessons": ["Tighten entries"],
|
||||||
|
},
|
||||||
|
(ContextLayer.L6_DAILY.value, "2026-02-07", "scorecard_US"): {
|
||||||
|
"total_pnl": 1.5,
|
||||||
|
"win_rate": 62,
|
||||||
|
"index_change_pct": 0.9,
|
||||||
|
"lessons": ["Follow momentum"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
planner = _make_planner(scorecard_map=scorecard_map)
|
||||||
|
|
||||||
|
await planner.generate_playbook("KR", [_candidate()], today=date(2026, 2, 8))
|
||||||
|
|
||||||
|
call_market_data = planner._gemini.decide.call_args.args[0]
|
||||||
|
prompt = call_market_data["prompt_override"]
|
||||||
|
assert "My Market Previous Day (KR)" in prompt
|
||||||
|
assert "Other Market (US)" in prompt
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# _parse_response
|
# _parse_response
|
||||||
@@ -402,7 +462,12 @@ class TestParseResponse:
|
|||||||
|
|
||||||
class TestBuildCrossMarketContext:
|
class TestBuildCrossMarketContext:
|
||||||
def test_kr_reads_us_scorecard(self) -> None:
|
def test_kr_reads_us_scorecard(self) -> None:
|
||||||
scorecard = {"total_pnl": 2.5, "win_rate": 65, "index_change_pct": 0.8, "lessons": ["Stay patient"]}
|
scorecard = {
|
||||||
|
"total_pnl": 2.5,
|
||||||
|
"win_rate": 65,
|
||||||
|
"index_change_pct": 0.8,
|
||||||
|
"lessons": ["Stay patient"],
|
||||||
|
}
|
||||||
planner = _make_planner(scorecard_data=scorecard)
|
planner = _make_planner(scorecard_data=scorecard)
|
||||||
|
|
||||||
ctx = planner.build_cross_market_context("KR", today=date(2026, 2, 8))
|
ctx = planner.build_cross_market_context("KR", today=date(2026, 2, 8))
|
||||||
@@ -415,8 +480,9 @@ class TestBuildCrossMarketContext:
|
|||||||
|
|
||||||
# Verify it queried scorecard_US
|
# Verify it queried scorecard_US
|
||||||
planner._context_store.get_context.assert_called_once_with(
|
planner._context_store.get_context.assert_called_once_with(
|
||||||
ContextLayer.L6_DAILY, "2026-02-08", "scorecard_US"
|
ContextLayer.L6_DAILY, "2026-02-07", "scorecard_US"
|
||||||
)
|
)
|
||||||
|
assert ctx.date == "2026-02-07"
|
||||||
|
|
||||||
def test_us_reads_kr_scorecard(self) -> None:
|
def test_us_reads_kr_scorecard(self) -> None:
|
||||||
scorecard = {"total_pnl": -1.0, "win_rate": 40, "index_change_pct": -0.5}
|
scorecard = {"total_pnl": -1.0, "win_rate": 40, "index_change_pct": -0.5}
|
||||||
@@ -447,6 +513,32 @@ class TestBuildCrossMarketContext:
|
|||||||
assert ctx is None
|
assert ctx is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# build_self_market_scorecard
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildSelfMarketScorecard:
|
||||||
|
def test_reads_previous_day_scorecard(self) -> None:
|
||||||
|
scorecard = {"total_pnl": -1.2, "win_rate": 45, "lessons": ["Reduce overtrading"]}
|
||||||
|
planner = _make_planner(scorecard_data=scorecard)
|
||||||
|
|
||||||
|
data = planner.build_self_market_scorecard("KR", today=date(2026, 2, 8))
|
||||||
|
|
||||||
|
assert data is not None
|
||||||
|
assert data["date"] == "2026-02-07"
|
||||||
|
assert data["total_pnl"] == -1.2
|
||||||
|
assert data["win_rate"] == 45
|
||||||
|
assert "Reduce overtrading" in data["lessons"]
|
||||||
|
planner._context_store.get_context.assert_called_once_with(
|
||||||
|
ContextLayer.L6_DAILY, "2026-02-07", "scorecard_KR"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_missing_scorecard_returns_none(self) -> None:
|
||||||
|
planner = _make_planner(scorecard_data=None)
|
||||||
|
assert planner.build_self_market_scorecard("US", today=date(2026, 2, 8)) is None
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# _build_prompt
|
# _build_prompt
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -457,7 +549,7 @@ class TestBuildPrompt:
|
|||||||
planner = _make_planner()
|
planner = _make_planner()
|
||||||
candidates = [_candidate(code="005930", name="Samsung")]
|
candidates = [_candidate(code="005930", name="Samsung")]
|
||||||
|
|
||||||
prompt = planner._build_prompt("KR", candidates, {}, None)
|
prompt = planner._build_prompt("KR", candidates, {}, None, None)
|
||||||
|
|
||||||
assert "005930" in prompt
|
assert "005930" in prompt
|
||||||
assert "Samsung" in prompt
|
assert "Samsung" in prompt
|
||||||
@@ -471,7 +563,7 @@ class TestBuildPrompt:
|
|||||||
win_rate=60, index_change_pct=0.8, lessons=["Cut losses early"],
|
win_rate=60, index_change_pct=0.8, lessons=["Cut losses early"],
|
||||||
)
|
)
|
||||||
|
|
||||||
prompt = planner._build_prompt("KR", [_candidate()], {}, cross)
|
prompt = planner._build_prompt("KR", [_candidate()], {}, None, cross)
|
||||||
|
|
||||||
assert "Other Market (US)" in prompt
|
assert "Other Market (US)" in prompt
|
||||||
assert "+1.50%" in prompt
|
assert "+1.50%" in prompt
|
||||||
@@ -481,7 +573,7 @@ class TestBuildPrompt:
|
|||||||
planner = _make_planner()
|
planner = _make_planner()
|
||||||
context = {"L6_DAILY": {"win_rate": 0.65, "total_pnl": 2.5}}
|
context = {"L6_DAILY": {"win_rate": 0.65, "total_pnl": 2.5}}
|
||||||
|
|
||||||
prompt = planner._build_prompt("KR", [_candidate()], context, None)
|
prompt = planner._build_prompt("KR", [_candidate()], context, None, None)
|
||||||
|
|
||||||
assert "Strategic Context" in prompt
|
assert "Strategic Context" in prompt
|
||||||
assert "L6_DAILY" in prompt
|
assert "L6_DAILY" in prompt
|
||||||
@@ -489,15 +581,30 @@ class TestBuildPrompt:
|
|||||||
|
|
||||||
def test_prompt_contains_max_scenarios(self) -> None:
|
def test_prompt_contains_max_scenarios(self) -> None:
|
||||||
planner = _make_planner()
|
planner = _make_planner()
|
||||||
prompt = planner._build_prompt("KR", [_candidate()], {}, None)
|
prompt = planner._build_prompt("KR", [_candidate()], {}, None, None)
|
||||||
|
|
||||||
assert f"Max {planner._settings.MAX_SCENARIOS_PER_STOCK} scenarios" in prompt
|
assert f"Max {planner._settings.MAX_SCENARIOS_PER_STOCK} scenarios" in prompt
|
||||||
|
|
||||||
def test_prompt_market_name(self) -> None:
|
def test_prompt_market_name(self) -> None:
|
||||||
planner = _make_planner()
|
planner = _make_planner()
|
||||||
prompt = planner._build_prompt("US", [_candidate()], {}, None)
|
prompt = planner._build_prompt("US", [_candidate()], {}, None, None)
|
||||||
assert "US market" in prompt
|
assert "US market" in prompt
|
||||||
|
|
||||||
|
def test_prompt_contains_self_market_scorecard(self) -> None:
|
||||||
|
planner = _make_planner()
|
||||||
|
self_scorecard = {
|
||||||
|
"date": "2026-02-07",
|
||||||
|
"total_pnl": -0.8,
|
||||||
|
"win_rate": 45.0,
|
||||||
|
"lessons": ["Avoid midday entries"],
|
||||||
|
}
|
||||||
|
prompt = planner._build_prompt("KR", [_candidate()], {}, self_scorecard, None)
|
||||||
|
|
||||||
|
assert "My Market Previous Day (KR)" in prompt
|
||||||
|
assert "2026-02-07" in prompt
|
||||||
|
assert "-0.80%" in prompt
|
||||||
|
assert "Avoid midday entries" in prompt
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# _extract_json
|
# _extract_json
|
||||||
|
|||||||
Reference in New Issue
Block a user