Compare commits
12 Commits
feature/is
...
feature/is
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afb31b7f4b | ||
| a429a9f4da | |||
|
|
d9763def85 | ||
| ab7f0444b2 | |||
|
|
6b3960a3a4 | ||
| 6cad8e74e1 | |||
|
|
86c94cff62 | ||
| 692cb61991 | |||
|
|
392422992b | ||
| cc637a9738 | |||
|
|
8c27473fed | ||
| bde54c7487 |
@@ -1,6 +1,7 @@
|
|||||||
"""Evolution engine for self-improving trading strategies."""
|
"""Evolution engine for self-improving trading strategies."""
|
||||||
|
|
||||||
from src.evolution.ab_test import ABTester, ABTestResult, StrategyPerformance
|
from src.evolution.ab_test import ABTester, ABTestResult, StrategyPerformance
|
||||||
|
from src.evolution.daily_review import DailyReviewer
|
||||||
from src.evolution.optimizer import EvolutionOptimizer
|
from src.evolution.optimizer import EvolutionOptimizer
|
||||||
from src.evolution.performance_tracker import (
|
from src.evolution.performance_tracker import (
|
||||||
PerformanceDashboard,
|
PerformanceDashboard,
|
||||||
@@ -18,4 +19,5 @@ __all__ = [
|
|||||||
"PerformanceDashboard",
|
"PerformanceDashboard",
|
||||||
"StrategyMetrics",
|
"StrategyMetrics",
|
||||||
"DailyScorecard",
|
"DailyScorecard",
|
||||||
|
"DailyReviewer",
|
||||||
]
|
]
|
||||||
|
|||||||
196
src/evolution/daily_review.py
Normal file
196
src/evolution/daily_review.py
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
"""Daily review generator for market-scoped end-of-day scorecards."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import sqlite3
|
||||||
|
from dataclasses import asdict
|
||||||
|
|
||||||
|
from src.brain.gemini_client import GeminiClient
|
||||||
|
from src.context.layer import ContextLayer
|
||||||
|
from src.context.store import ContextStore
|
||||||
|
from src.evolution.scorecard import DailyScorecard
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class DailyReviewer:
|
||||||
|
"""Builds daily scorecards and optional AI-generated lessons."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
context_store: ContextStore,
|
||||||
|
gemini_client: GeminiClient | None = None,
|
||||||
|
) -> None:
|
||||||
|
self._conn = conn
|
||||||
|
self._context_store = context_store
|
||||||
|
self._gemini = gemini_client
|
||||||
|
|
||||||
|
def generate_scorecard(self, date: str, market: str) -> DailyScorecard:
|
||||||
|
"""Generate a market-scoped scorecard from decision logs and trades."""
|
||||||
|
decision_rows = self._conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT action, confidence, context_snapshot
|
||||||
|
FROM decision_logs
|
||||||
|
WHERE DATE(timestamp) = ? AND market = ?
|
||||||
|
""",
|
||||||
|
(date, market),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
total_decisions = len(decision_rows)
|
||||||
|
buys = sum(1 for row in decision_rows if row[0] == "BUY")
|
||||||
|
sells = sum(1 for row in decision_rows if row[0] == "SELL")
|
||||||
|
holds = sum(1 for row in decision_rows if row[0] == "HOLD")
|
||||||
|
avg_confidence = (
|
||||||
|
round(sum(int(row[1]) for row in decision_rows) / total_decisions, 2)
|
||||||
|
if total_decisions > 0
|
||||||
|
else 0.0
|
||||||
|
)
|
||||||
|
|
||||||
|
matched = 0
|
||||||
|
for row in decision_rows:
|
||||||
|
try:
|
||||||
|
snapshot = json.loads(row[2]) if row[2] else {}
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
snapshot = {}
|
||||||
|
scenario_match = snapshot.get("scenario_match", {})
|
||||||
|
if isinstance(scenario_match, dict) and scenario_match:
|
||||||
|
matched += 1
|
||||||
|
scenario_match_rate = (
|
||||||
|
round((matched / total_decisions) * 100, 2)
|
||||||
|
if total_decisions
|
||||||
|
else 0.0
|
||||||
|
)
|
||||||
|
|
||||||
|
trade_stats = self._conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
COALESCE(SUM(pnl), 0.0),
|
||||||
|
SUM(CASE WHEN pnl > 0 THEN 1 ELSE 0 END),
|
||||||
|
SUM(CASE WHEN pnl < 0 THEN 1 ELSE 0 END)
|
||||||
|
FROM trades
|
||||||
|
WHERE DATE(timestamp) = ? AND market = ?
|
||||||
|
""",
|
||||||
|
(date, market),
|
||||||
|
).fetchone()
|
||||||
|
total_pnl = round(float(trade_stats[0] or 0.0), 2) if trade_stats else 0.0
|
||||||
|
wins = int(trade_stats[1] or 0) if trade_stats else 0
|
||||||
|
losses = int(trade_stats[2] or 0) if trade_stats else 0
|
||||||
|
win_rate = round((wins / (wins + losses)) * 100, 2) if (wins + losses) > 0 else 0.0
|
||||||
|
|
||||||
|
top_winners = [
|
||||||
|
row[0]
|
||||||
|
for row in self._conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT stock_code, SUM(pnl) AS stock_pnl
|
||||||
|
FROM trades
|
||||||
|
WHERE DATE(timestamp) = ? AND market = ?
|
||||||
|
GROUP BY stock_code
|
||||||
|
HAVING stock_pnl > 0
|
||||||
|
ORDER BY stock_pnl DESC
|
||||||
|
LIMIT 3
|
||||||
|
""",
|
||||||
|
(date, market),
|
||||||
|
).fetchall()
|
||||||
|
]
|
||||||
|
|
||||||
|
top_losers = [
|
||||||
|
row[0]
|
||||||
|
for row in self._conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT stock_code, SUM(pnl) AS stock_pnl
|
||||||
|
FROM trades
|
||||||
|
WHERE DATE(timestamp) = ? AND market = ?
|
||||||
|
GROUP BY stock_code
|
||||||
|
HAVING stock_pnl < 0
|
||||||
|
ORDER BY stock_pnl ASC
|
||||||
|
LIMIT 3
|
||||||
|
""",
|
||||||
|
(date, market),
|
||||||
|
).fetchall()
|
||||||
|
]
|
||||||
|
|
||||||
|
return DailyScorecard(
|
||||||
|
date=date,
|
||||||
|
market=market,
|
||||||
|
total_decisions=total_decisions,
|
||||||
|
buys=buys,
|
||||||
|
sells=sells,
|
||||||
|
holds=holds,
|
||||||
|
total_pnl=total_pnl,
|
||||||
|
win_rate=win_rate,
|
||||||
|
avg_confidence=avg_confidence,
|
||||||
|
scenario_match_rate=scenario_match_rate,
|
||||||
|
top_winners=top_winners,
|
||||||
|
top_losers=top_losers,
|
||||||
|
lessons=[],
|
||||||
|
cross_market_note="",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def generate_lessons(self, scorecard: DailyScorecard) -> list[str]:
|
||||||
|
"""Generate concise lessons from scorecard metrics using Gemini."""
|
||||||
|
if self._gemini is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
prompt = (
|
||||||
|
"You are a trading performance reviewer.\n"
|
||||||
|
"Return ONLY a JSON array of 1-3 short lessons in English.\n"
|
||||||
|
f"Market: {scorecard.market}\n"
|
||||||
|
f"Date: {scorecard.date}\n"
|
||||||
|
f"Total decisions: {scorecard.total_decisions}\n"
|
||||||
|
f"Buys/Sells/Holds: {scorecard.buys}/{scorecard.sells}/{scorecard.holds}\n"
|
||||||
|
f"Total PnL: {scorecard.total_pnl}\n"
|
||||||
|
f"Win rate: {scorecard.win_rate}%\n"
|
||||||
|
f"Average confidence: {scorecard.avg_confidence}\n"
|
||||||
|
f"Scenario match rate: {scorecard.scenario_match_rate}%\n"
|
||||||
|
f"Top winners: {', '.join(scorecard.top_winners) or 'N/A'}\n"
|
||||||
|
f"Top losers: {', '.join(scorecard.top_losers) or 'N/A'}\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
decision = await self._gemini.decide(
|
||||||
|
{
|
||||||
|
"stock_code": "REVIEW",
|
||||||
|
"market_name": scorecard.market,
|
||||||
|
"current_price": 0,
|
||||||
|
"prompt_override": prompt,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return self._parse_lessons(decision.rationale)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to generate daily lessons: %s", exc)
|
||||||
|
return []
|
||||||
|
|
||||||
|
def store_scorecard_in_context(self, scorecard: DailyScorecard) -> None:
|
||||||
|
"""Store scorecard in L6 using market-scoped key."""
|
||||||
|
self._context_store.set_context(
|
||||||
|
ContextLayer.L6_DAILY,
|
||||||
|
scorecard.date,
|
||||||
|
f"scorecard_{scorecard.market}",
|
||||||
|
asdict(scorecard),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _parse_lessons(self, raw_text: str) -> list[str]:
|
||||||
|
"""Parse lessons from JSON array response or fallback text."""
|
||||||
|
raw_text = raw_text.strip()
|
||||||
|
try:
|
||||||
|
parsed = json.loads(raw_text)
|
||||||
|
if isinstance(parsed, list):
|
||||||
|
return [str(item).strip() for item in parsed if str(item).strip()][:3]
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
match = re.search(r"\[.*\]", raw_text, re.DOTALL)
|
||||||
|
if match:
|
||||||
|
try:
|
||||||
|
parsed = json.loads(match.group(0))
|
||||||
|
if isinstance(parsed, list):
|
||||||
|
return [str(item).strip() for item in parsed if str(item).strip()][:3]
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
lines = [line.strip("-* \t") for line in raw_text.splitlines() if line.strip()]
|
||||||
|
return lines[:3]
|
||||||
128
src/main.py
128
src/main.py
@@ -22,11 +22,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.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
|
||||||
@@ -736,6 +739,108 @@ async def run_daily_session(
|
|||||||
logger.info("Daily trading session completed")
|
logger.info("Daily trading session completed")
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_market_close(
|
||||||
|
market_code: str,
|
||||||
|
market_name: str,
|
||||||
|
market_timezone: Any,
|
||||||
|
telegram: TelegramClient,
|
||||||
|
context_aggregator: ContextAggregator,
|
||||||
|
daily_reviewer: DailyReviewer,
|
||||||
|
evolution_optimizer: EvolutionOptimizer | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Handle market-close tasks: notify, aggregate, review, and store context."""
|
||||||
|
await telegram.notify_market_close(market_name, 0.0)
|
||||||
|
|
||||||
|
market_date = datetime.now(market_timezone).date().isoformat()
|
||||||
|
context_aggregator.aggregate_daily_from_trades(
|
||||||
|
date=market_date,
|
||||||
|
market=market_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
scorecard = daily_reviewer.generate_scorecard(market_date, market_code)
|
||||||
|
daily_reviewer.store_scorecard_in_context(scorecard)
|
||||||
|
|
||||||
|
lessons = await daily_reviewer.generate_lessons(scorecard)
|
||||||
|
if lessons:
|
||||||
|
scorecard.lessons = lessons
|
||||||
|
daily_reviewer.store_scorecard_in_context(scorecard)
|
||||||
|
|
||||||
|
await telegram.send_message(
|
||||||
|
f"<b>Daily Review ({market_code})</b>\n"
|
||||||
|
f"Date: {scorecard.date}\n"
|
||||||
|
f"Decisions: {scorecard.total_decisions}\n"
|
||||||
|
f"P&L: {scorecard.total_pnl:+.2f}\n"
|
||||||
|
f"Win Rate: {scorecard.win_rate:.2f}%\n"
|
||||||
|
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
|
||||||
|
|
||||||
|
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')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def run(settings: Settings) -> None:
|
async def run(settings: Settings) -> None:
|
||||||
"""Main async loop — iterate over open markets on a timer."""
|
"""Main async loop — iterate over open markets on a timer."""
|
||||||
broker = KISBroker(settings)
|
broker = KISBroker(settings)
|
||||||
@@ -746,11 +851,17 @@ 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)
|
||||||
scenario_engine = ScenarioEngine()
|
scenario_engine = ScenarioEngine()
|
||||||
playbook_store = PlaybookStore(db_conn)
|
playbook_store = PlaybookStore(db_conn)
|
||||||
|
daily_reviewer = DailyReviewer(db_conn, context_store, gemini_client=brain)
|
||||||
pre_market_planner = PreMarketPlanner(
|
pre_market_planner = PreMarketPlanner(
|
||||||
gemini_client=brain,
|
gemini_client=brain,
|
||||||
context_store=context_store,
|
context_store=context_store,
|
||||||
@@ -978,6 +1089,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(
|
||||||
@@ -1016,6 +1128,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)
|
||||||
@@ -1029,13 +1142,14 @@ async def run(settings: Settings) -> None:
|
|||||||
|
|
||||||
market_info = MARKETS.get(market_code)
|
market_info = MARKETS.get(market_code)
|
||||||
if market_info:
|
if market_info:
|
||||||
await telegram.notify_market_close(market_info.name, 0.0)
|
await _handle_market_close(
|
||||||
market_date = datetime.now(
|
market_code=market_code,
|
||||||
market_info.timezone
|
market_name=market_info.name,
|
||||||
).date().isoformat()
|
market_timezone=market_info.timezone,
|
||||||
context_aggregator.aggregate_daily_from_trades(
|
telegram=telegram,
|
||||||
date=market_date,
|
context_aggregator=context_aggregator,
|
||||||
market=market_code,
|
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)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
383
tests/test_daily_review.py
Normal file
383
tests/test_daily_review.py
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
"""Tests for DailyReviewer."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.context.layer import ContextLayer
|
||||||
|
from src.context.store import ContextStore
|
||||||
|
from src.db import init_db, log_trade
|
||||||
|
from src.evolution.daily_review import DailyReviewer
|
||||||
|
from src.evolution.scorecard import DailyScorecard
|
||||||
|
from src.logging.decision_logger import DecisionLogger
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def db_conn() -> sqlite3.Connection:
|
||||||
|
return init_db(":memory:")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def context_store(db_conn: sqlite3.Connection) -> ContextStore:
|
||||||
|
return ContextStore(db_conn)
|
||||||
|
|
||||||
|
|
||||||
|
def _log_decision(
|
||||||
|
logger: DecisionLogger,
|
||||||
|
*,
|
||||||
|
stock_code: str,
|
||||||
|
market: str,
|
||||||
|
action: str,
|
||||||
|
confidence: int,
|
||||||
|
scenario_match: dict[str, float] | None = None,
|
||||||
|
) -> str:
|
||||||
|
return logger.log_decision(
|
||||||
|
stock_code=stock_code,
|
||||||
|
market=market,
|
||||||
|
exchange_code="KRX" if market == "KR" else "NASDAQ",
|
||||||
|
action=action,
|
||||||
|
confidence=confidence,
|
||||||
|
rationale="test",
|
||||||
|
context_snapshot={"scenario_match": scenario_match or {}},
|
||||||
|
input_data={"stock_code": stock_code},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_scorecard_market_scoped(
|
||||||
|
db_conn: sqlite3.Connection, context_store: ContextStore,
|
||||||
|
) -> None:
|
||||||
|
reviewer = DailyReviewer(db_conn, context_store)
|
||||||
|
logger = DecisionLogger(db_conn)
|
||||||
|
|
||||||
|
buy_id = _log_decision(
|
||||||
|
logger,
|
||||||
|
stock_code="005930",
|
||||||
|
market="KR",
|
||||||
|
action="BUY",
|
||||||
|
confidence=90,
|
||||||
|
scenario_match={"rsi": 29.0},
|
||||||
|
)
|
||||||
|
_log_decision(
|
||||||
|
logger,
|
||||||
|
stock_code="000660",
|
||||||
|
market="KR",
|
||||||
|
action="HOLD",
|
||||||
|
confidence=60,
|
||||||
|
)
|
||||||
|
_log_decision(
|
||||||
|
logger,
|
||||||
|
stock_code="AAPL",
|
||||||
|
market="US",
|
||||||
|
action="SELL",
|
||||||
|
confidence=80,
|
||||||
|
scenario_match={"volume_ratio": 2.1},
|
||||||
|
)
|
||||||
|
|
||||||
|
log_trade(
|
||||||
|
db_conn,
|
||||||
|
"005930",
|
||||||
|
"BUY",
|
||||||
|
90,
|
||||||
|
"buy",
|
||||||
|
quantity=1,
|
||||||
|
price=100.0,
|
||||||
|
pnl=10.0,
|
||||||
|
market="KR",
|
||||||
|
exchange_code="KRX",
|
||||||
|
decision_id=buy_id,
|
||||||
|
)
|
||||||
|
log_trade(
|
||||||
|
db_conn,
|
||||||
|
"000660",
|
||||||
|
"HOLD",
|
||||||
|
60,
|
||||||
|
"hold",
|
||||||
|
quantity=0,
|
||||||
|
price=0.0,
|
||||||
|
pnl=0.0,
|
||||||
|
market="KR",
|
||||||
|
exchange_code="KRX",
|
||||||
|
)
|
||||||
|
log_trade(
|
||||||
|
db_conn,
|
||||||
|
"AAPL",
|
||||||
|
"SELL",
|
||||||
|
80,
|
||||||
|
"sell",
|
||||||
|
quantity=1,
|
||||||
|
price=200.0,
|
||||||
|
pnl=-5.0,
|
||||||
|
market="US",
|
||||||
|
exchange_code="NASDAQ",
|
||||||
|
)
|
||||||
|
|
||||||
|
scorecard = reviewer.generate_scorecard("2026-02-14", "KR")
|
||||||
|
|
||||||
|
assert scorecard.market == "KR"
|
||||||
|
assert scorecard.total_decisions == 2
|
||||||
|
assert scorecard.buys == 1
|
||||||
|
assert scorecard.sells == 0
|
||||||
|
assert scorecard.holds == 1
|
||||||
|
assert scorecard.total_pnl == 10.0
|
||||||
|
assert scorecard.win_rate == 100.0
|
||||||
|
assert scorecard.avg_confidence == 75.0
|
||||||
|
assert scorecard.scenario_match_rate == 50.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_scorecard_top_winners_and_losers(
|
||||||
|
db_conn: sqlite3.Connection, context_store: ContextStore,
|
||||||
|
) -> None:
|
||||||
|
reviewer = DailyReviewer(db_conn, context_store)
|
||||||
|
logger = DecisionLogger(db_conn)
|
||||||
|
|
||||||
|
for code, pnl in [("005930", 30.0), ("000660", 10.0), ("035420", -15.0), ("051910", -5.0)]:
|
||||||
|
decision_id = _log_decision(
|
||||||
|
logger,
|
||||||
|
stock_code=code,
|
||||||
|
market="KR",
|
||||||
|
action="BUY" if pnl >= 0 else "SELL",
|
||||||
|
confidence=80,
|
||||||
|
scenario_match={"rsi": 30.0},
|
||||||
|
)
|
||||||
|
log_trade(
|
||||||
|
db_conn,
|
||||||
|
code,
|
||||||
|
"BUY" if pnl >= 0 else "SELL",
|
||||||
|
80,
|
||||||
|
"test",
|
||||||
|
quantity=1,
|
||||||
|
price=100.0,
|
||||||
|
pnl=pnl,
|
||||||
|
market="KR",
|
||||||
|
exchange_code="KRX",
|
||||||
|
decision_id=decision_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
scorecard = reviewer.generate_scorecard("2026-02-14", "KR")
|
||||||
|
assert scorecard.top_winners == ["005930", "000660"]
|
||||||
|
assert scorecard.top_losers == ["035420", "051910"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_scorecard_empty_day(
|
||||||
|
db_conn: sqlite3.Connection, context_store: ContextStore,
|
||||||
|
) -> None:
|
||||||
|
reviewer = DailyReviewer(db_conn, context_store)
|
||||||
|
scorecard = reviewer.generate_scorecard("2026-02-14", "KR")
|
||||||
|
|
||||||
|
assert scorecard.total_decisions == 0
|
||||||
|
assert scorecard.total_pnl == 0.0
|
||||||
|
assert scorecard.win_rate == 0.0
|
||||||
|
assert scorecard.avg_confidence == 0.0
|
||||||
|
assert scorecard.scenario_match_rate == 0.0
|
||||||
|
assert scorecard.top_winners == []
|
||||||
|
assert scorecard.top_losers == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_generate_lessons_without_gemini_returns_empty(
|
||||||
|
db_conn: sqlite3.Connection, context_store: ContextStore,
|
||||||
|
) -> None:
|
||||||
|
reviewer = DailyReviewer(db_conn, context_store, gemini_client=None)
|
||||||
|
lessons = await reviewer.generate_lessons(
|
||||||
|
DailyScorecard(
|
||||||
|
date="2026-02-14",
|
||||||
|
market="KR",
|
||||||
|
total_decisions=1,
|
||||||
|
buys=1,
|
||||||
|
sells=0,
|
||||||
|
holds=0,
|
||||||
|
total_pnl=5.0,
|
||||||
|
win_rate=100.0,
|
||||||
|
avg_confidence=90.0,
|
||||||
|
scenario_match_rate=100.0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert lessons == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_generate_lessons_parses_json_array(
|
||||||
|
db_conn: sqlite3.Connection, context_store: ContextStore,
|
||||||
|
) -> None:
|
||||||
|
mock_gemini = MagicMock()
|
||||||
|
mock_gemini.decide = AsyncMock(
|
||||||
|
return_value=SimpleNamespace(rationale='["Cut losers earlier", "Reduce midday churn"]')
|
||||||
|
)
|
||||||
|
reviewer = DailyReviewer(db_conn, context_store, gemini_client=mock_gemini)
|
||||||
|
|
||||||
|
lessons = await reviewer.generate_lessons(
|
||||||
|
DailyScorecard(
|
||||||
|
date="2026-02-14",
|
||||||
|
market="KR",
|
||||||
|
total_decisions=3,
|
||||||
|
buys=1,
|
||||||
|
sells=1,
|
||||||
|
holds=1,
|
||||||
|
total_pnl=-2.5,
|
||||||
|
win_rate=50.0,
|
||||||
|
avg_confidence=70.0,
|
||||||
|
scenario_match_rate=66.7,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert lessons == ["Cut losers earlier", "Reduce midday churn"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_generate_lessons_fallback_to_lines(
|
||||||
|
db_conn: sqlite3.Connection, context_store: ContextStore,
|
||||||
|
) -> None:
|
||||||
|
mock_gemini = MagicMock()
|
||||||
|
mock_gemini.decide = AsyncMock(
|
||||||
|
return_value=SimpleNamespace(rationale="- Keep risk tighter\n- Increase selectivity")
|
||||||
|
)
|
||||||
|
reviewer = DailyReviewer(db_conn, context_store, gemini_client=mock_gemini)
|
||||||
|
|
||||||
|
lessons = await reviewer.generate_lessons(
|
||||||
|
DailyScorecard(
|
||||||
|
date="2026-02-14",
|
||||||
|
market="US",
|
||||||
|
total_decisions=2,
|
||||||
|
buys=1,
|
||||||
|
sells=1,
|
||||||
|
holds=0,
|
||||||
|
total_pnl=1.0,
|
||||||
|
win_rate=50.0,
|
||||||
|
avg_confidence=75.0,
|
||||||
|
scenario_match_rate=100.0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert lessons == ["Keep risk tighter", "Increase selectivity"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_generate_lessons_handles_gemini_error(
|
||||||
|
db_conn: sqlite3.Connection, context_store: ContextStore,
|
||||||
|
) -> None:
|
||||||
|
mock_gemini = MagicMock()
|
||||||
|
mock_gemini.decide = AsyncMock(side_effect=RuntimeError("boom"))
|
||||||
|
reviewer = DailyReviewer(db_conn, context_store, gemini_client=mock_gemini)
|
||||||
|
|
||||||
|
lessons = await reviewer.generate_lessons(
|
||||||
|
DailyScorecard(
|
||||||
|
date="2026-02-14",
|
||||||
|
market="US",
|
||||||
|
total_decisions=0,
|
||||||
|
buys=0,
|
||||||
|
sells=0,
|
||||||
|
holds=0,
|
||||||
|
total_pnl=0.0,
|
||||||
|
win_rate=0.0,
|
||||||
|
avg_confidence=0.0,
|
||||||
|
scenario_match_rate=0.0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert lessons == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_store_scorecard_in_context(
|
||||||
|
db_conn: sqlite3.Connection, context_store: ContextStore,
|
||||||
|
) -> None:
|
||||||
|
reviewer = DailyReviewer(db_conn, context_store)
|
||||||
|
scorecard = DailyScorecard(
|
||||||
|
date="2026-02-14",
|
||||||
|
market="KR",
|
||||||
|
total_decisions=5,
|
||||||
|
buys=2,
|
||||||
|
sells=1,
|
||||||
|
holds=2,
|
||||||
|
total_pnl=15.0,
|
||||||
|
win_rate=66.67,
|
||||||
|
avg_confidence=82.0,
|
||||||
|
scenario_match_rate=80.0,
|
||||||
|
lessons=["Keep position sizing stable"],
|
||||||
|
cross_market_note="US risk-off",
|
||||||
|
)
|
||||||
|
|
||||||
|
reviewer.store_scorecard_in_context(scorecard)
|
||||||
|
|
||||||
|
stored = context_store.get_context(
|
||||||
|
ContextLayer.L6_DAILY,
|
||||||
|
"2026-02-14",
|
||||||
|
"scorecard_KR",
|
||||||
|
)
|
||||||
|
assert stored is not None
|
||||||
|
assert stored["market"] == "KR"
|
||||||
|
assert stored["total_pnl"] == 15.0
|
||||||
|
assert stored["lessons"] == ["Keep position sizing stable"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_store_scorecard_key_is_market_scoped(
|
||||||
|
db_conn: sqlite3.Connection, context_store: ContextStore,
|
||||||
|
) -> None:
|
||||||
|
reviewer = DailyReviewer(db_conn, context_store)
|
||||||
|
kr = 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,
|
||||||
|
)
|
||||||
|
us = DailyScorecard(
|
||||||
|
date="2026-02-14",
|
||||||
|
market="US",
|
||||||
|
total_decisions=1,
|
||||||
|
buys=0,
|
||||||
|
sells=1,
|
||||||
|
holds=0,
|
||||||
|
total_pnl=-1.0,
|
||||||
|
win_rate=0.0,
|
||||||
|
avg_confidence=70.0,
|
||||||
|
scenario_match_rate=100.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
reviewer.store_scorecard_in_context(kr)
|
||||||
|
reviewer.store_scorecard_in_context(us)
|
||||||
|
|
||||||
|
kr_ctx = context_store.get_context(ContextLayer.L6_DAILY, "2026-02-14", "scorecard_KR")
|
||||||
|
us_ctx = context_store.get_context(ContextLayer.L6_DAILY, "2026-02-14", "scorecard_US")
|
||||||
|
|
||||||
|
assert kr_ctx["market"] == "KR"
|
||||||
|
assert us_ctx["market"] == "US"
|
||||||
|
assert kr_ctx["total_pnl"] == 1.0
|
||||||
|
assert us_ctx["total_pnl"] == -1.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_scorecard_handles_invalid_context_snapshot(
|
||||||
|
db_conn: sqlite3.Connection, context_store: ContextStore,
|
||||||
|
) -> None:
|
||||||
|
reviewer = DailyReviewer(db_conn, context_store)
|
||||||
|
db_conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO decision_logs (
|
||||||
|
decision_id, timestamp, stock_code, market, exchange_code,
|
||||||
|
action, confidence, rationale, context_snapshot, input_data
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
"d1",
|
||||||
|
"2026-02-14T09:00:00+00:00",
|
||||||
|
"005930",
|
||||||
|
"KR",
|
||||||
|
"KRX",
|
||||||
|
"HOLD",
|
||||||
|
50,
|
||||||
|
"test",
|
||||||
|
"{invalid_json",
|
||||||
|
json.dumps({}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
db_conn.commit()
|
||||||
|
|
||||||
|
scorecard = reviewer.generate_scorecard("2026-02-14", "KR")
|
||||||
|
assert scorecard.total_decisions == 1
|
||||||
|
assert scorecard.scenario_match_rate == 0.0
|
||||||
@@ -1,15 +1,23 @@
|
|||||||
"""Tests for main trading loop integration."""
|
"""Tests for main trading loop integration."""
|
||||||
|
|
||||||
from datetime import 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.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.logging.decision_logger import DecisionLogger
|
from src.logging.decision_logger import DecisionLogger
|
||||||
from src.main import safe_float, trading_cycle
|
from src.main import (
|
||||||
|
_handle_market_close,
|
||||||
|
_run_context_scheduler,
|
||||||
|
_run_evolution_loop,
|
||||||
|
safe_float,
|
||||||
|
trading_cycle,
|
||||||
|
)
|
||||||
from src.strategy.models import (
|
from src.strategy.models import (
|
||||||
DayPlaybook,
|
DayPlaybook,
|
||||||
ScenarioAction,
|
ScenarioAction,
|
||||||
@@ -1219,3 +1227,130 @@ async def test_sell_updates_original_buy_decision_outcome() -> None:
|
|||||||
assert updated_buy is not None
|
assert updated_buy is not None
|
||||||
assert updated_buy.outcome_pnl == 20.0
|
assert updated_buy.outcome_pnl == 20.0
|
||||||
assert updated_buy.outcome_accuracy == 1
|
assert updated_buy.outcome_accuracy == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_handle_market_close_runs_daily_review_flow() -> None:
|
||||||
|
"""Market close should aggregate, create scorecard, lessons, and notify."""
|
||||||
|
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=3,
|
||||||
|
buys=1,
|
||||||
|
sells=1,
|
||||||
|
holds=1,
|
||||||
|
total_pnl=12.5,
|
||||||
|
win_rate=50.0,
|
||||||
|
avg_confidence=75.0,
|
||||||
|
scenario_match_rate=66.7,
|
||||||
|
)
|
||||||
|
reviewer.generate_lessons = AsyncMock(return_value=["Cut losers faster"])
|
||||||
|
|
||||||
|
await _handle_market_close(
|
||||||
|
market_code="KR",
|
||||||
|
market_name="Korea",
|
||||||
|
market_timezone=UTC,
|
||||||
|
telegram=telegram,
|
||||||
|
context_aggregator=context_aggregator,
|
||||||
|
daily_reviewer=reviewer,
|
||||||
|
)
|
||||||
|
|
||||||
|
telegram.notify_market_close.assert_called_once_with("Korea", 0.0)
|
||||||
|
context_aggregator.aggregate_daily_from_trades.assert_called_once()
|
||||||
|
reviewer.generate_scorecard.assert_called_once()
|
||||||
|
assert reviewer.store_scorecard_in_context.call_count == 2
|
||||||
|
reviewer.generate_lessons.assert_called_once()
|
||||||
|
telegram.send_message.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_handle_market_close_without_lessons_stores_once() -> None:
|
||||||
|
"""If no lessons are generated, scorecard should be stored once."""
|
||||||
|
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=1,
|
||||||
|
buys=0,
|
||||||
|
sells=1,
|
||||||
|
holds=0,
|
||||||
|
total_pnl=-3.0,
|
||||||
|
win_rate=0.0,
|
||||||
|
avg_confidence=65.0,
|
||||||
|
scenario_match_rate=100.0,
|
||||||
|
)
|
||||||
|
reviewer.generate_lessons = AsyncMock(return_value=[])
|
||||||
|
|
||||||
|
await _handle_market_close(
|
||||||
|
market_code="US",
|
||||||
|
market_name="United States",
|
||||||
|
market_timezone=UTC,
|
||||||
|
telegram=telegram,
|
||||||
|
context_aggregator=context_aggregator,
|
||||||
|
daily_reviewer=reviewer,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert reviewer.store_scorecard_in_context.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
|||||||
@@ -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