Compare commits

...

4 Commits

Author SHA1 Message Date
agentson
86c94cff62 feat: cross-market date fix and strategic context selector (issue #88)
Some checks failed
CI / test (pull_request) Has been cancelled
KR planner now reads US scorecard from previous day (timezone-aware),
and generate_playbook uses STRATEGIC context selection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 23:20:24 +09:00
692cb61991 Merge pull request 'feat: main.py에 일일 리뷰 연결 (issue #93)' (#122) from feature/issue-93-daily-review-integration into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #122
2026-02-14 23:15:26 +09:00
agentson
392422992b feat: integrate DailyReviewer into market close flow (issue #93)
Some checks failed
CI / test (pull_request) Has been cancelled
Extract _handle_market_close() helper that runs EOD aggregation,
generates scorecard with optional AI lessons, and sends Telegram summary.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 23:13:57 +09:00
cc637a9738 Merge pull request 'feat: Daily Reviewer - 시장별 성적표 생성 (issue #91)' (#121) from feature/issue-91-daily-reviewer into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #121
2026-02-14 23:08:05 +09:00
4 changed files with 166 additions and 18 deletions

View File

@@ -27,6 +27,7 @@ from src.core.criticality import CriticalityAssessor
from src.core.priority_queue import PriorityTaskQueue
from src.core.risk_manager import CircuitBreakerTripped, FatFingerRejected, RiskManager
from src.db import get_latest_buy_trade, init_db, log_trade
from src.evolution.daily_review import DailyReviewer
from src.logging.decision_logger import DecisionLogger
from src.logging_config import setup_logging
from src.markets.schedule import MarketInfo, get_next_market_open, get_open_markets
@@ -736,6 +737,41 @@ async def run_daily_session(
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,
) -> 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'}"
)
async def run(settings: Settings) -> None:
"""Main async loop — iterate over open markets on a timer."""
broker = KISBroker(settings)
@@ -751,6 +787,7 @@ async def run(settings: Settings) -> None:
context_selector = ContextSelector(context_store)
scenario_engine = ScenarioEngine()
playbook_store = PlaybookStore(db_conn)
daily_reviewer = DailyReviewer(db_conn, context_store, gemini_client=brain)
pre_market_planner = PreMarketPlanner(
gemini_client=brain,
context_store=context_store,
@@ -1029,13 +1066,13 @@ async def run(settings: Settings) -> None:
market_info = MARKETS.get(market_code)
if market_info:
await telegram.notify_market_close(market_info.name, 0.0)
market_date = datetime.now(
market_info.timezone
).date().isoformat()
context_aggregator.aggregate_daily_from_trades(
date=market_date,
market=market_code,
await _handle_market_close(
market_code=market_code,
market_name=market_info.name,
market_timezone=market_info.timezone,
telegram=telegram,
context_aggregator=context_aggregator,
daily_reviewer=daily_reviewer,
)
except Exception as exc:
logger.warning("Market close notification failed: %s", exc)

View File

@@ -8,7 +8,7 @@ from __future__ import annotations
import json
import logging
from datetime import date
from datetime import date, timedelta
from typing import Any
from src.analysis.smart_scanner import ScanCandidate
@@ -145,7 +145,8 @@ class PreMarketPlanner:
other_market = "US" if target_market == "KR" else "KR"
if today is None:
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_data = self._context_store.get_context(

View File

@@ -1,6 +1,6 @@
"""Tests for main trading loop integration."""
from datetime import date
from datetime import UTC, date
from unittest.mock import ANY, AsyncMock, MagicMock, patch
import pytest
@@ -8,8 +8,9 @@ import pytest
from src.context.layer import ContextLayer
from src.core.risk_manager import CircuitBreakerTripped, FatFingerRejected
from src.db import init_db, log_trade
from src.evolution.scorecard import DailyScorecard
from src.logging.decision_logger import DecisionLogger
from src.main import safe_float, trading_cycle
from src.main import _handle_market_close, safe_float, trading_cycle
from src.strategy.models import (
DayPlaybook,
ScenarioAction,
@@ -1219,3 +1220,78 @@ async def test_sell_updates_original_buy_decision_outcome() -> None:
assert updated_buy is not None
assert updated_buy.outcome_pnl == 20.0
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

View File

@@ -9,6 +9,7 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
from src.analysis.smart_scanner import ScanCandidate
from src.brain.context_selector import DecisionType
from src.brain.gemini_client import TradeDecision
from src.config import Settings
from src.context.store import ContextLayer
@@ -16,12 +17,10 @@ from src.strategy.models import (
CrossMarketContext,
DayPlaybook,
MarketOutlook,
PlaybookStatus,
ScenarioAction,
)
from src.strategy.pre_market_planner import PreMarketPlanner
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@@ -111,7 +110,9 @@ def _make_planner(
# Mock ContextSelector
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 {})
settings = Settings(
@@ -220,11 +221,25 @@ class TestGeneratePlaybook:
stocks = [
{
"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",
"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))
@@ -254,6 +269,19 @@ class TestGeneratePlaybook:
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()
# ---------------------------------------------------------------------------
# _parse_response
@@ -402,7 +430,12 @@ class TestParseResponse:
class TestBuildCrossMarketContext:
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)
ctx = planner.build_cross_market_context("KR", today=date(2026, 2, 8))
@@ -415,8 +448,9 @@ class TestBuildCrossMarketContext:
# Verify it queried scorecard_US
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:
scorecard = {"total_pnl": -1.0, "win_rate": 40, "index_change_pct": -0.5}