Compare commits
4 Commits
fix/test-f
...
feature/is
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4e31be27a | ||
| 9d9ade14eb | |||
|
|
9a8936ab34 | ||
| c5831966ed |
@@ -64,3 +64,25 @@
|
|||||||
**참고:** Realtime 모드 전용. Daily 모드는 배치 효율성을 위해 정적 watchlist 사용.
|
**참고:** Realtime 모드 전용. Daily 모드는 배치 효율성을 위해 정적 watchlist 사용.
|
||||||
|
|
||||||
**이슈/PR:** #76, #77
|
**이슈/PR:** #76, #77
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-02-10
|
||||||
|
|
||||||
|
### 코드 리뷰 시 플랜-구현 일치 검증 규칙
|
||||||
|
|
||||||
|
**배경:**
|
||||||
|
- 코드 리뷰 시 플랜(EnterPlanMode에서 승인된 계획)과 실제 구현이 일치하는지 확인하는 절차가 없었음
|
||||||
|
- 플랜과 다른 구현이 리뷰 없이 통과될 위험
|
||||||
|
|
||||||
|
**요구사항:**
|
||||||
|
1. 모든 PR 리뷰에서 플랜-구현 일치 여부를 필수 체크
|
||||||
|
2. 플랜에 없는 변경은 정당한 사유 필요
|
||||||
|
3. 플랜 항목이 누락되면 PR 설명에 사유 기록
|
||||||
|
4. 스코프가 플랜과 일치하는지 확인
|
||||||
|
|
||||||
|
**구현 결과:**
|
||||||
|
- `docs/workflow.md`에 Code Review Checklist 섹션 추가
|
||||||
|
- Plan Consistency (필수), Safety & Constraints, Quality, Workflow 4개 카테고리
|
||||||
|
|
||||||
|
**이슈/PR:** #114
|
||||||
|
|||||||
@@ -74,3 +74,37 @@ task_tool(
|
|||||||
```
|
```
|
||||||
|
|
||||||
Use `run_in_background=True` for independent tasks that don't block subsequent work.
|
Use `run_in_background=True` for independent tasks that don't block subsequent work.
|
||||||
|
|
||||||
|
## Code Review Checklist
|
||||||
|
|
||||||
|
**CRITICAL: Every PR review MUST verify plan-implementation consistency.**
|
||||||
|
|
||||||
|
Before approving any PR, the reviewer (human or agent) must check ALL of the following:
|
||||||
|
|
||||||
|
### 1. Plan Consistency (MANDATORY)
|
||||||
|
|
||||||
|
- [ ] **Implementation matches the approved plan** — Compare the actual code changes against the plan created during `EnterPlanMode`. Every item in the plan must be addressed.
|
||||||
|
- [ ] **No unplanned changes** — If the implementation includes changes not in the plan, they must be explicitly justified.
|
||||||
|
- [ ] **No plan items omitted** — If any planned item was skipped, the reason must be documented in the PR description.
|
||||||
|
- [ ] **Scope matches** — The PR does not exceed or fall short of the planned scope.
|
||||||
|
|
||||||
|
### 2. Safety & Constraints
|
||||||
|
|
||||||
|
- [ ] `src/core/risk_manager.py` is unchanged (READ-ONLY)
|
||||||
|
- [ ] Circuit breaker threshold not weakened (only stricter allowed)
|
||||||
|
- [ ] Fat-finger protection (30% max order) still enforced
|
||||||
|
- [ ] Confidence < 80 still forces HOLD
|
||||||
|
- [ ] No hardcoded API keys or secrets
|
||||||
|
|
||||||
|
### 3. Quality
|
||||||
|
|
||||||
|
- [ ] All new/modified code has corresponding tests
|
||||||
|
- [ ] Test coverage >= 80%
|
||||||
|
- [ ] `ruff check src/ tests/` passes (no lint errors)
|
||||||
|
- [ ] No `assert` statements removed from tests
|
||||||
|
|
||||||
|
### 4. Workflow
|
||||||
|
|
||||||
|
- [ ] PR references the Gitea issue number
|
||||||
|
- [ ] Feature branch follows naming convention (`feature/issue-N-description`)
|
||||||
|
- [ ] Commit messages are clear and descriptive
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ class MarketScanner:
|
|||||||
self.context_store.set_context(
|
self.context_store.set_context(
|
||||||
ContextLayer.L7_REALTIME,
|
ContextLayer.L7_REALTIME,
|
||||||
timeframe,
|
timeframe,
|
||||||
f"{market.code}_{stock_code}_volatility",
|
f"volatility_{market.code}_{stock_code}",
|
||||||
{
|
{
|
||||||
"price": metrics.current_price,
|
"price": metrics.current_price,
|
||||||
"atr": metrics.atr,
|
"atr": metrics.atr,
|
||||||
@@ -179,7 +179,7 @@ class MarketScanner:
|
|||||||
self.context_store.set_context(
|
self.context_store.set_context(
|
||||||
ContextLayer.L7_REALTIME,
|
ContextLayer.L7_REALTIME,
|
||||||
timeframe,
|
timeframe,
|
||||||
f"{market.code}_scan_result",
|
f"scan_result_{market.code}",
|
||||||
{
|
{
|
||||||
"total_scanned": len(valid_metrics),
|
"total_scanned": len(valid_metrics),
|
||||||
"top_movers": [m.stock_code for m in top_movers],
|
"top_movers": [m.stock_code for m in top_movers],
|
||||||
|
|||||||
44
src/main.py
44
src/main.py
@@ -13,7 +13,6 @@ import signal
|
|||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from src.analysis.scanner import MarketScanner
|
|
||||||
from src.analysis.smart_scanner import ScanCandidate, SmartVolatilityScanner
|
from src.analysis.smart_scanner import ScanCandidate, SmartVolatilityScanner
|
||||||
from src.analysis.volatility import VolatilityAnalyzer
|
from src.analysis.volatility import VolatilityAnalyzer
|
||||||
from src.brain.context_selector import ContextSelector
|
from src.brain.context_selector import ContextSelector
|
||||||
@@ -154,6 +153,38 @@ async def trading_cycle(
|
|||||||
market_data["rsi"] = candidate.rsi
|
market_data["rsi"] = candidate.rsi
|
||||||
market_data["volume_ratio"] = candidate.volume_ratio
|
market_data["volume_ratio"] = candidate.volume_ratio
|
||||||
|
|
||||||
|
# 1.3. Record L7 real-time context (market-scoped keys)
|
||||||
|
timeframe = datetime.now(UTC).isoformat()
|
||||||
|
context_store.set_context(
|
||||||
|
ContextLayer.L7_REALTIME,
|
||||||
|
timeframe,
|
||||||
|
f"volatility_{market.code}_{stock_code}",
|
||||||
|
{
|
||||||
|
"momentum_score": 50.0,
|
||||||
|
"volume_surge": 1.0,
|
||||||
|
"price_change_1m": 0.0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
context_store.set_context(
|
||||||
|
ContextLayer.L7_REALTIME,
|
||||||
|
timeframe,
|
||||||
|
f"price_{market.code}_{stock_code}",
|
||||||
|
{"current_price": current_price},
|
||||||
|
)
|
||||||
|
if candidate:
|
||||||
|
context_store.set_context(
|
||||||
|
ContextLayer.L7_REALTIME,
|
||||||
|
timeframe,
|
||||||
|
f"rsi_{market.code}_{stock_code}",
|
||||||
|
{"rsi": candidate.rsi},
|
||||||
|
)
|
||||||
|
context_store.set_context(
|
||||||
|
ContextLayer.L7_REALTIME,
|
||||||
|
timeframe,
|
||||||
|
f"volume_ratio_{market.code}_{stock_code}",
|
||||||
|
{"volume_ratio": candidate.volume_ratio},
|
||||||
|
)
|
||||||
|
|
||||||
# Build portfolio data for global rule evaluation
|
# Build portfolio data for global rule evaluation
|
||||||
portfolio_data = {
|
portfolio_data = {
|
||||||
"portfolio_pnl_pct": pnl_pct,
|
"portfolio_pnl_pct": pnl_pct,
|
||||||
@@ -171,7 +202,7 @@ async def trading_cycle(
|
|||||||
volatility_data = context_store.get_context(
|
volatility_data = context_store.get_context(
|
||||||
ContextLayer.L7_REALTIME,
|
ContextLayer.L7_REALTIME,
|
||||||
latest_timeframe,
|
latest_timeframe,
|
||||||
f"volatility_{stock_code}",
|
f"volatility_{market.code}_{stock_code}",
|
||||||
)
|
)
|
||||||
if volatility_data:
|
if volatility_data:
|
||||||
volatility_score = volatility_data.get("momentum_score", 50.0)
|
volatility_score = volatility_data.get("momentum_score", 50.0)
|
||||||
@@ -835,15 +866,6 @@ async def run(settings: Settings) -> None:
|
|||||||
|
|
||||||
# Initialize volatility hunter
|
# Initialize volatility hunter
|
||||||
volatility_analyzer = VolatilityAnalyzer(min_volume_surge=2.0, min_price_change=1.0)
|
volatility_analyzer = VolatilityAnalyzer(min_volume_surge=2.0, min_price_change=1.0)
|
||||||
market_scanner = MarketScanner(
|
|
||||||
broker=broker,
|
|
||||||
overseas_broker=overseas_broker,
|
|
||||||
volatility_analyzer=volatility_analyzer,
|
|
||||||
context_store=context_store,
|
|
||||||
top_n=5,
|
|
||||||
max_concurrent_scans=1, # Fully serialized to avoid EGW00201
|
|
||||||
)
|
|
||||||
|
|
||||||
# Initialize smart scanner (Python-first, AI-last pipeline)
|
# Initialize smart scanner (Python-first, AI-last pipeline)
|
||||||
smart_scanner = SmartVolatilityScanner(
|
smart_scanner = SmartVolatilityScanner(
|
||||||
broker=broker,
|
broker=broker,
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
"""Tests for main trading loop integration."""
|
"""Tests for main trading loop integration."""
|
||||||
|
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import ANY, AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.core.risk_manager import CircuitBreakerTripped, FatFingerRejected
|
from src.core.risk_manager import CircuitBreakerTripped, FatFingerRejected
|
||||||
|
from src.context.layer import ContextLayer
|
||||||
from src.main import safe_float, trading_cycle
|
from src.main import safe_float, trading_cycle
|
||||||
from src.strategy.models import (
|
from src.strategy.models import (
|
||||||
DayPlaybook,
|
DayPlaybook,
|
||||||
@@ -810,6 +811,69 @@ class TestScenarioEngineIntegration:
|
|||||||
assert "portfolio_pnl_pct" in portfolio_data
|
assert "portfolio_pnl_pct" in portfolio_data
|
||||||
assert "total_cash" in portfolio_data
|
assert "total_cash" in portfolio_data
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_trading_cycle_sets_l7_context_keys(
|
||||||
|
self, mock_broker: MagicMock, mock_market: MagicMock, mock_telegram: MagicMock,
|
||||||
|
) -> None:
|
||||||
|
"""Test L7 context is written with market-scoped keys."""
|
||||||
|
from src.analysis.smart_scanner import ScanCandidate
|
||||||
|
|
||||||
|
engine = MagicMock(spec=ScenarioEngine)
|
||||||
|
engine.evaluate = MagicMock(return_value=_make_hold_match())
|
||||||
|
playbook = _make_playbook()
|
||||||
|
context_store = MagicMock(get_latest_timeframe=MagicMock(return_value=None))
|
||||||
|
|
||||||
|
candidate = ScanCandidate(
|
||||||
|
stock_code="005930", name="Samsung", price=50000,
|
||||||
|
volume=1000000, volume_ratio=3.5, rsi=25.0,
|
||||||
|
signal="oversold", score=85.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("src.main.log_trade"):
|
||||||
|
await trading_cycle(
|
||||||
|
broker=mock_broker,
|
||||||
|
overseas_broker=MagicMock(),
|
||||||
|
scenario_engine=engine,
|
||||||
|
playbook=playbook,
|
||||||
|
risk=MagicMock(),
|
||||||
|
db_conn=MagicMock(),
|
||||||
|
decision_logger=MagicMock(),
|
||||||
|
context_store=context_store,
|
||||||
|
criticality_assessor=MagicMock(
|
||||||
|
assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")),
|
||||||
|
get_timeout=MagicMock(return_value=5.0),
|
||||||
|
),
|
||||||
|
telegram=mock_telegram,
|
||||||
|
market=mock_market,
|
||||||
|
stock_code="005930",
|
||||||
|
scan_candidates={"KR": {"005930": candidate}},
|
||||||
|
)
|
||||||
|
|
||||||
|
context_store.set_context.assert_any_call(
|
||||||
|
ContextLayer.L7_REALTIME,
|
||||||
|
ANY,
|
||||||
|
"volatility_KR_005930",
|
||||||
|
{"momentum_score": 50.0, "volume_surge": 1.0, "price_change_1m": 0.0},
|
||||||
|
)
|
||||||
|
context_store.set_context.assert_any_call(
|
||||||
|
ContextLayer.L7_REALTIME,
|
||||||
|
ANY,
|
||||||
|
"price_KR_005930",
|
||||||
|
{"current_price": 50000.0},
|
||||||
|
)
|
||||||
|
context_store.set_context.assert_any_call(
|
||||||
|
ContextLayer.L7_REALTIME,
|
||||||
|
ANY,
|
||||||
|
"rsi_KR_005930",
|
||||||
|
{"rsi": 25.0},
|
||||||
|
)
|
||||||
|
context_store.set_context.assert_any_call(
|
||||||
|
ContextLayer.L7_REALTIME,
|
||||||
|
ANY,
|
||||||
|
"volume_ratio_KR_005930",
|
||||||
|
{"volume_ratio": 3.5},
|
||||||
|
)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_scan_candidates_market_scoped(
|
async def test_scan_candidates_market_scoped(
|
||||||
self, mock_broker: MagicMock, mock_market: MagicMock, mock_telegram: MagicMock,
|
self, mock_broker: MagicMock, mock_market: MagicMock, mock_telegram: MagicMock,
|
||||||
|
|||||||
@@ -412,7 +412,7 @@ class TestMarketScanner:
|
|||||||
scan_result = context_store.get_context(
|
scan_result = context_store.get_context(
|
||||||
ContextLayer.L7_REALTIME,
|
ContextLayer.L7_REALTIME,
|
||||||
latest_timeframe,
|
latest_timeframe,
|
||||||
"KR_scan_result",
|
"scan_result_KR",
|
||||||
)
|
)
|
||||||
assert scan_result is not None
|
assert scan_result is not None
|
||||||
assert scan_result["total_scanned"] == 3
|
assert scan_result["total_scanned"] == 3
|
||||||
|
|||||||
Reference in New Issue
Block a user