feat: integrate decision logger with main trading loop
Some checks failed
CI / test (pull_request) Has been cancelled
Some checks failed
CI / test (pull_request) Has been cancelled
- Add DecisionLogger to main.py trading cycle - Log all decisions with context snapshot (L1-L2 layers) - Capture market data and balance info in context - Add comprehensive tests (9 tests, 100% coverage) - All tests passing (63 total) Implements issue #17 acceptance criteria: - ✅ decision_logs table with proper schema - ✅ DecisionLogger class with all required methods - ✅ Automatic logging in trading loop - ✅ Tests achieve 100% coverage of decision_logger.py - ⚠️ Context snapshot uses L1-L2 data (L3-L7 pending issue #15) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
37
src/main.py
37
src/main.py
@@ -19,6 +19,7 @@ from src.broker.overseas import OverseasBroker
|
||||
from src.config import Settings
|
||||
from src.core.risk_manager import CircuitBreakerTripped, RiskManager
|
||||
from src.db import init_db, log_trade
|
||||
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
|
||||
|
||||
@@ -42,6 +43,7 @@ async def trading_cycle(
|
||||
brain: GeminiClient,
|
||||
risk: RiskManager,
|
||||
db_conn: Any,
|
||||
decision_logger: DecisionLogger,
|
||||
market: MarketInfo,
|
||||
stock_code: str,
|
||||
) -> None:
|
||||
@@ -101,6 +103,39 @@ async def trading_cycle(
|
||||
decision.confidence,
|
||||
)
|
||||
|
||||
# 2.5. Log decision with context snapshot
|
||||
context_snapshot = {
|
||||
"L1": {
|
||||
"current_price": current_price,
|
||||
"foreigner_net": foreigner_net,
|
||||
},
|
||||
"L2": {
|
||||
"total_eval": total_eval,
|
||||
"total_cash": total_cash,
|
||||
"purchase_total": purchase_total,
|
||||
"pnl_pct": pnl_pct,
|
||||
},
|
||||
# L3-L7 will be populated when context tree is implemented
|
||||
}
|
||||
input_data = {
|
||||
"current_price": current_price,
|
||||
"foreigner_net": foreigner_net,
|
||||
"total_eval": total_eval,
|
||||
"total_cash": total_cash,
|
||||
"pnl_pct": pnl_pct,
|
||||
}
|
||||
|
||||
decision_logger.log_decision(
|
||||
stock_code=stock_code,
|
||||
market=market.code,
|
||||
exchange_code=market.exchange_code,
|
||||
action=decision.action,
|
||||
confidence=decision.confidence,
|
||||
rationale=decision.rationale,
|
||||
context_snapshot=context_snapshot,
|
||||
input_data=input_data,
|
||||
)
|
||||
|
||||
# 3. Execute if actionable
|
||||
if decision.action in ("BUY", "SELL"):
|
||||
# Determine order size (simplified: 1 lot)
|
||||
@@ -151,6 +186,7 @@ async def run(settings: Settings) -> None:
|
||||
brain = GeminiClient(settings)
|
||||
risk = RiskManager(settings)
|
||||
db_conn = init_db(settings.DB_PATH)
|
||||
decision_logger = DecisionLogger(db_conn)
|
||||
|
||||
shutdown = asyncio.Event()
|
||||
|
||||
@@ -218,6 +254,7 @@ async def run(settings: Settings) -> None:
|
||||
brain,
|
||||
risk,
|
||||
db_conn,
|
||||
decision_logger,
|
||||
market,
|
||||
stock_code,
|
||||
)
|
||||
|
||||
292
tests/test_decision_logger.py
Normal file
292
tests/test_decision_logger.py
Normal file
@@ -0,0 +1,292 @@
|
||||
"""Tests for decision logging and audit trail."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from src.db import init_db
|
||||
from src.logging.decision_logger import DecisionLog, DecisionLogger
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_conn() -> sqlite3.Connection:
|
||||
"""Provide an in-memory database with initialized schema."""
|
||||
conn = init_db(":memory:")
|
||||
return conn
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def logger(db_conn: sqlite3.Connection) -> DecisionLogger:
|
||||
"""Provide a DecisionLogger instance."""
|
||||
return DecisionLogger(db_conn)
|
||||
|
||||
|
||||
def test_log_decision_creates_record(logger: DecisionLogger, db_conn: sqlite3.Connection) -> None:
|
||||
"""Test that log_decision creates a database record."""
|
||||
context_snapshot = {
|
||||
"L1": {"quote": {"price": 100.0, "volume": 1000}},
|
||||
"L2": {"orderbook": {"bid": [99.0], "ask": [101.0]}},
|
||||
}
|
||||
input_data = {"price": 100.0, "volume": 1000, "foreigner_net": 500}
|
||||
|
||||
decision_id = logger.log_decision(
|
||||
stock_code="005930",
|
||||
market="KR",
|
||||
exchange_code="KRX",
|
||||
action="BUY",
|
||||
confidence=85,
|
||||
rationale="Strong upward momentum",
|
||||
context_snapshot=context_snapshot,
|
||||
input_data=input_data,
|
||||
)
|
||||
|
||||
# Verify decision_id is a valid UUID
|
||||
assert decision_id is not None
|
||||
assert len(decision_id) == 36 # UUID v4 format
|
||||
|
||||
# Verify record exists in database
|
||||
cursor = db_conn.execute(
|
||||
"SELECT decision_id, action, confidence FROM decision_logs WHERE decision_id = ?",
|
||||
(decision_id,),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
assert row is not None
|
||||
assert row[0] == decision_id
|
||||
assert row[1] == "BUY"
|
||||
assert row[2] == 85
|
||||
|
||||
|
||||
def test_log_decision_stores_context_snapshot(logger: DecisionLogger) -> None:
|
||||
"""Test that context snapshot is stored as JSON."""
|
||||
context_snapshot = {
|
||||
"L1": {"real_time": "data"},
|
||||
"L3": {"daily": "aggregate"},
|
||||
"L7": {"legacy": "wisdom"},
|
||||
}
|
||||
input_data = {"price": 50000.0, "volume": 2000}
|
||||
|
||||
decision_id = logger.log_decision(
|
||||
stock_code="035420",
|
||||
market="KR",
|
||||
exchange_code="KRX",
|
||||
action="HOLD",
|
||||
confidence=75,
|
||||
rationale="Waiting for clearer signal",
|
||||
context_snapshot=context_snapshot,
|
||||
input_data=input_data,
|
||||
)
|
||||
|
||||
# Retrieve and verify context snapshot
|
||||
decision = logger.get_decision_by_id(decision_id)
|
||||
assert decision is not None
|
||||
assert decision.context_snapshot == context_snapshot
|
||||
assert decision.input_data == input_data
|
||||
|
||||
|
||||
def test_get_unreviewed_decisions(logger: DecisionLogger) -> None:
|
||||
"""Test retrieving unreviewed decisions with confidence filter."""
|
||||
# Log multiple decisions with varying confidence
|
||||
logger.log_decision(
|
||||
stock_code="005930",
|
||||
market="KR",
|
||||
exchange_code="KRX",
|
||||
action="BUY",
|
||||
confidence=90,
|
||||
rationale="High confidence buy",
|
||||
context_snapshot={},
|
||||
input_data={},
|
||||
)
|
||||
logger.log_decision(
|
||||
stock_code="000660",
|
||||
market="KR",
|
||||
exchange_code="KRX",
|
||||
action="SELL",
|
||||
confidence=75,
|
||||
rationale="Low confidence sell",
|
||||
context_snapshot={},
|
||||
input_data={},
|
||||
)
|
||||
logger.log_decision(
|
||||
stock_code="035420",
|
||||
market="KR",
|
||||
exchange_code="KRX",
|
||||
action="HOLD",
|
||||
confidence=85,
|
||||
rationale="Medium confidence hold",
|
||||
context_snapshot={},
|
||||
input_data={},
|
||||
)
|
||||
|
||||
# Get unreviewed decisions with default threshold (80)
|
||||
unreviewed = logger.get_unreviewed_decisions()
|
||||
assert len(unreviewed) == 2 # Only confidence >= 80
|
||||
assert all(d.confidence >= 80 for d in unreviewed)
|
||||
assert all(not d.reviewed for d in unreviewed)
|
||||
|
||||
# Get with lower threshold
|
||||
unreviewed_all = logger.get_unreviewed_decisions(min_confidence=70)
|
||||
assert len(unreviewed_all) == 3
|
||||
|
||||
|
||||
def test_mark_reviewed(logger: DecisionLogger) -> None:
|
||||
"""Test marking a decision as reviewed."""
|
||||
decision_id = logger.log_decision(
|
||||
stock_code="005930",
|
||||
market="KR",
|
||||
exchange_code="KRX",
|
||||
action="BUY",
|
||||
confidence=85,
|
||||
rationale="Test decision",
|
||||
context_snapshot={},
|
||||
input_data={},
|
||||
)
|
||||
|
||||
# Initially unreviewed
|
||||
decision = logger.get_decision_by_id(decision_id)
|
||||
assert decision is not None
|
||||
assert not decision.reviewed
|
||||
assert decision.review_notes is None
|
||||
|
||||
# Mark as reviewed
|
||||
review_notes = "Good decision, captured bullish momentum correctly"
|
||||
logger.mark_reviewed(decision_id, review_notes)
|
||||
|
||||
# Verify updated
|
||||
decision = logger.get_decision_by_id(decision_id)
|
||||
assert decision is not None
|
||||
assert decision.reviewed
|
||||
assert decision.review_notes == review_notes
|
||||
|
||||
# Should not appear in unreviewed list
|
||||
unreviewed = logger.get_unreviewed_decisions()
|
||||
assert all(d.decision_id != decision_id for d in unreviewed)
|
||||
|
||||
|
||||
def test_update_outcome(logger: DecisionLogger) -> None:
|
||||
"""Test updating decision outcome with P&L and accuracy."""
|
||||
decision_id = logger.log_decision(
|
||||
stock_code="005930",
|
||||
market="KR",
|
||||
exchange_code="KRX",
|
||||
action="BUY",
|
||||
confidence=90,
|
||||
rationale="Expecting price increase",
|
||||
context_snapshot={},
|
||||
input_data={},
|
||||
)
|
||||
|
||||
# Initially no outcome
|
||||
decision = logger.get_decision_by_id(decision_id)
|
||||
assert decision is not None
|
||||
assert decision.outcome_pnl is None
|
||||
assert decision.outcome_accuracy is None
|
||||
|
||||
# Update outcome (profitable trade)
|
||||
logger.update_outcome(decision_id, pnl=5000.0, accuracy=1)
|
||||
|
||||
# Verify updated
|
||||
decision = logger.get_decision_by_id(decision_id)
|
||||
assert decision is not None
|
||||
assert decision.outcome_pnl == 5000.0
|
||||
assert decision.outcome_accuracy == 1
|
||||
|
||||
|
||||
def test_get_losing_decisions(logger: DecisionLogger) -> None:
|
||||
"""Test retrieving high-confidence losing decisions."""
|
||||
# Profitable decision
|
||||
id1 = logger.log_decision(
|
||||
stock_code="005930",
|
||||
market="KR",
|
||||
exchange_code="KRX",
|
||||
action="BUY",
|
||||
confidence=85,
|
||||
rationale="Correct prediction",
|
||||
context_snapshot={},
|
||||
input_data={},
|
||||
)
|
||||
logger.update_outcome(id1, pnl=3000.0, accuracy=1)
|
||||
|
||||
# High-confidence loss
|
||||
id2 = logger.log_decision(
|
||||
stock_code="000660",
|
||||
market="KR",
|
||||
exchange_code="KRX",
|
||||
action="SELL",
|
||||
confidence=90,
|
||||
rationale="Wrong prediction",
|
||||
context_snapshot={},
|
||||
input_data={},
|
||||
)
|
||||
logger.update_outcome(id2, pnl=-2000.0, accuracy=0)
|
||||
|
||||
# Low-confidence loss (should be ignored)
|
||||
id3 = logger.log_decision(
|
||||
stock_code="035420",
|
||||
market="KR",
|
||||
exchange_code="KRX",
|
||||
action="BUY",
|
||||
confidence=70,
|
||||
rationale="Low confidence, wrong",
|
||||
context_snapshot={},
|
||||
input_data={},
|
||||
)
|
||||
logger.update_outcome(id3, pnl=-1500.0, accuracy=0)
|
||||
|
||||
# Get high-confidence losing decisions
|
||||
losers = logger.get_losing_decisions(min_confidence=80, min_loss=-1000.0)
|
||||
assert len(losers) == 1
|
||||
assert losers[0].decision_id == id2
|
||||
assert losers[0].outcome_pnl == -2000.0
|
||||
assert losers[0].confidence == 90
|
||||
|
||||
|
||||
def test_get_decision_by_id_not_found(logger: DecisionLogger) -> None:
|
||||
"""Test that get_decision_by_id returns None for non-existent ID."""
|
||||
decision = logger.get_decision_by_id("non-existent-uuid")
|
||||
assert decision is None
|
||||
|
||||
|
||||
def test_unreviewed_limit(logger: DecisionLogger) -> None:
|
||||
"""Test that get_unreviewed_decisions respects limit parameter."""
|
||||
# Create 5 unreviewed decisions
|
||||
for i in range(5):
|
||||
logger.log_decision(
|
||||
stock_code=f"00{i}",
|
||||
market="KR",
|
||||
exchange_code="KRX",
|
||||
action="HOLD",
|
||||
confidence=85,
|
||||
rationale=f"Decision {i}",
|
||||
context_snapshot={},
|
||||
input_data={},
|
||||
)
|
||||
|
||||
# Get only 3
|
||||
unreviewed = logger.get_unreviewed_decisions(limit=3)
|
||||
assert len(unreviewed) == 3
|
||||
|
||||
|
||||
def test_decision_log_dataclass() -> None:
|
||||
"""Test DecisionLog dataclass creation."""
|
||||
now = datetime.now(UTC).isoformat()
|
||||
log = DecisionLog(
|
||||
decision_id="test-uuid",
|
||||
timestamp=now,
|
||||
stock_code="005930",
|
||||
market="KR",
|
||||
exchange_code="KRX",
|
||||
action="BUY",
|
||||
confidence=85,
|
||||
rationale="Test",
|
||||
context_snapshot={"L1": "data"},
|
||||
input_data={"price": 100.0},
|
||||
)
|
||||
|
||||
assert log.decision_id == "test-uuid"
|
||||
assert log.action == "BUY"
|
||||
assert log.confidence == 85
|
||||
assert log.reviewed is False
|
||||
assert log.outcome_pnl is None
|
||||
Reference in New Issue
Block a user