Compare commits

..

2 Commits

Author SHA1 Message Date
agentson
a14f944fcc feat: link decision outcomes to trades via decision_id (issue #92)
Some checks failed
CI / test (pull_request) Has been cancelled
Add decision_id column to trades table, capture log_decision() return
value, and update original BUY decision outcome on SELL execution.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 21:36:57 +09:00
56f7405baa Merge pull request 'feat: 컨텍스트 집계 스케줄러 (issue #87)' (#119) from feature/issue-87-context-scheduler into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #119
2026-02-10 04:28:42 +09:00
3 changed files with 180 additions and 7 deletions

View File

@@ -6,6 +6,7 @@ import json
import sqlite3 import sqlite3
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from typing import Any
def init_db(db_path: str) -> sqlite3.Connection: def init_db(db_path: str) -> sqlite3.Connection:
@@ -26,7 +27,8 @@ def init_db(db_path: str) -> sqlite3.Connection:
price REAL, price REAL,
pnl REAL DEFAULT 0.0, pnl REAL DEFAULT 0.0,
market TEXT DEFAULT 'KR', market TEXT DEFAULT 'KR',
exchange_code TEXT DEFAULT 'KRX' exchange_code TEXT DEFAULT 'KRX',
decision_id TEXT
) )
""" """
) )
@@ -41,6 +43,8 @@ def init_db(db_path: str) -> sqlite3.Connection:
conn.execute("ALTER TABLE trades ADD COLUMN exchange_code TEXT DEFAULT 'KRX'") conn.execute("ALTER TABLE trades ADD COLUMN exchange_code TEXT DEFAULT 'KRX'")
if "selection_context" not in columns: if "selection_context" not in columns:
conn.execute("ALTER TABLE trades ADD COLUMN selection_context TEXT") conn.execute("ALTER TABLE trades ADD COLUMN selection_context TEXT")
if "decision_id" not in columns:
conn.execute("ALTER TABLE trades ADD COLUMN decision_id TEXT")
# Context tree tables for multi-layered memory management # Context tree tables for multi-layered memory management
conn.execute( conn.execute(
@@ -143,6 +147,7 @@ def log_trade(
market: str = "KR", market: str = "KR",
exchange_code: str = "KRX", exchange_code: str = "KRX",
selection_context: dict[str, any] | None = None, selection_context: dict[str, any] | None = None,
decision_id: str | None = None,
) -> None: ) -> None:
"""Insert a trade record into the database. """Insert a trade record into the database.
@@ -166,9 +171,9 @@ def log_trade(
""" """
INSERT INTO trades ( INSERT INTO trades (
timestamp, stock_code, action, confidence, rationale, timestamp, stock_code, action, confidence, rationale,
quantity, price, pnl, market, exchange_code, selection_context quantity, price, pnl, market, exchange_code, selection_context, decision_id
) )
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
datetime.now(UTC).isoformat(), datetime.now(UTC).isoformat(),
@@ -182,6 +187,30 @@ def log_trade(
market, market,
exchange_code, exchange_code,
context_json, context_json,
decision_id,
), ),
) )
conn.commit() conn.commit()
def get_latest_buy_trade(
conn: sqlite3.Connection, stock_code: str, market: str
) -> dict[str, Any] | None:
"""Fetch the most recent BUY trade for a stock and market."""
cursor = conn.execute(
"""
SELECT decision_id, price, quantity
FROM trades
WHERE stock_code = ?
AND market = ?
AND action = 'BUY'
AND decision_id IS NOT NULL
ORDER BY timestamp DESC
LIMIT 1
""",
(stock_code, market),
)
row = cursor.fetchone()
if not row:
return None
return {"decision_id": row[0], "price": row[1], "quantity": row[2]}

View File

@@ -26,7 +26,7 @@ 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 init_db, log_trade from src.db import get_latest_buy_trade, init_db, log_trade
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
@@ -279,7 +279,7 @@ async def trading_cycle(
"pnl_pct": pnl_pct, "pnl_pct": pnl_pct,
} }
decision_logger.log_decision( decision_id = decision_logger.log_decision(
stock_code=stock_code, stock_code=stock_code,
market=market.code, market=market.code,
exchange_code=market.exchange_code, exchange_code=market.exchange_code,
@@ -291,6 +291,9 @@ async def trading_cycle(
) )
# 3. Execute if actionable # 3. Execute if actionable
quantity = 0
trade_price = current_price
trade_pnl = 0.0
if decision.action in ("BUY", "SELL"): if decision.action in ("BUY", "SELL"):
# Determine order size (simplified: 1 lot) # Determine order size (simplified: 1 lot)
quantity = 1 quantity = 1
@@ -346,6 +349,18 @@ async def trading_cycle(
except Exception as exc: except Exception as exc:
logger.warning("Telegram notification failed: %s", exc) logger.warning("Telegram notification failed: %s", exc)
if decision.action == "SELL":
buy_trade = get_latest_buy_trade(db_conn, stock_code, market.code)
if buy_trade and buy_trade.get("price") is not None:
buy_price = float(buy_trade["price"])
buy_qty = int(buy_trade.get("quantity") or 1)
trade_pnl = (trade_price - buy_price) * buy_qty
decision_logger.update_outcome(
decision_id=buy_trade["decision_id"],
pnl=trade_pnl,
accuracy=1 if trade_pnl > 0 else 0,
)
# 6. Log trade with selection context # 6. Log trade with selection context
selection_context = None selection_context = None
if stock_code in market_candidates: if stock_code in market_candidates:
@@ -363,9 +378,13 @@ async def trading_cycle(
action=decision.action, action=decision.action,
confidence=decision.confidence, confidence=decision.confidence,
rationale=decision.rationale, rationale=decision.rationale,
quantity=quantity,
price=trade_price,
pnl=trade_pnl,
market=market.code, market=market.code,
exchange_code=market.exchange_code, exchange_code=market.exchange_code,
selection_context=selection_context, selection_context=selection_context,
decision_id=decision_id,
) )
# 7. Latency monitoring # 7. Latency monitoring
@@ -600,7 +619,7 @@ async def run_daily_session(
"pnl_pct": pnl_pct, "pnl_pct": pnl_pct,
} }
decision_logger.log_decision( decision_id = decision_logger.log_decision(
stock_code=stock_code, stock_code=stock_code,
market=market.code, market=market.code,
exchange_code=market.exchange_code, exchange_code=market.exchange_code,
@@ -612,6 +631,9 @@ async def run_daily_session(
) )
# Execute if actionable # Execute if actionable
quantity = 0
trade_price = stock_data["current_price"]
trade_pnl = 0.0
if decision.action in ("BUY", "SELL"): if decision.action in ("BUY", "SELL"):
quantity = 1 quantity = 1
order_amount = stock_data["current_price"] * quantity order_amount = stock_data["current_price"] * quantity
@@ -684,6 +706,18 @@ async def run_daily_session(
) )
continue continue
if decision.action == "SELL":
buy_trade = get_latest_buy_trade(db_conn, stock_code, market.code)
if buy_trade and buy_trade.get("price") is not None:
buy_price = float(buy_trade["price"])
buy_qty = int(buy_trade.get("quantity") or 1)
trade_pnl = (trade_price - buy_price) * buy_qty
decision_logger.update_outcome(
decision_id=buy_trade["decision_id"],
pnl=trade_pnl,
accuracy=1 if trade_pnl > 0 else 0,
)
# Log trade # Log trade
log_trade( log_trade(
conn=db_conn, conn=db_conn,
@@ -691,8 +725,12 @@ async def run_daily_session(
action=decision.action, action=decision.action,
confidence=decision.confidence, confidence=decision.confidence,
rationale=decision.rationale, rationale=decision.rationale,
quantity=quantity,
price=trade_price,
pnl=trade_pnl,
market=market.code, market=market.code,
exchange_code=market.exchange_code, exchange_code=market.exchange_code,
decision_id=decision_id,
) )
logger.info("Daily trading session completed") logger.info("Daily trading session completed")

View File

@@ -5,8 +5,10 @@ from unittest.mock import ANY, AsyncMock, MagicMock, patch
import pytest import pytest
from src.core.risk_manager import CircuitBreakerTripped, FatFingerRejected
from src.context.layer import ContextLayer from src.context.layer import ContextLayer
from src.core.risk_manager import CircuitBreakerTripped, FatFingerRejected
from src.db import init_db, log_trade
from src.logging.decision_logger import DecisionLogger
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,
@@ -44,6 +46,17 @@ def _make_hold_match(stock_code: str = "005930") -> ScenarioMatch:
) )
def _make_sell_match(stock_code: str = "005930") -> ScenarioMatch:
"""Create a ScenarioMatch that returns SELL."""
return ScenarioMatch(
stock_code=stock_code,
matched_scenario=None,
action=ScenarioAction.SELL,
confidence=90,
rationale="Test sell",
)
class TestSafeFloat: class TestSafeFloat:
"""Test safe_float() helper function.""" """Test safe_float() helper function."""
@@ -1113,3 +1126,96 @@ class TestScenarioEngineIntegration:
# REDUCE_ALL is not BUY or SELL — no order sent # REDUCE_ALL is not BUY or SELL — no order sent
mock_broker.send_order.assert_not_called() mock_broker.send_order.assert_not_called()
mock_telegram.notify_trade_execution.assert_not_called() mock_telegram.notify_trade_execution.assert_not_called()
@pytest.mark.asyncio
async def test_sell_updates_original_buy_decision_outcome() -> None:
"""SELL should update the original BUY decision outcome in decision_logs."""
db_conn = init_db(":memory:")
decision_logger = DecisionLogger(db_conn)
buy_decision_id = decision_logger.log_decision(
stock_code="005930",
market="KR",
exchange_code="KRX",
action="BUY",
confidence=85,
rationale="Initial buy",
context_snapshot={},
input_data={},
)
log_trade(
conn=db_conn,
stock_code="005930",
action="BUY",
confidence=85,
rationale="Initial buy",
quantity=1,
price=100.0,
pnl=0.0,
market="KR",
exchange_code="KRX",
decision_id=buy_decision_id,
)
broker = MagicMock()
broker.get_orderbook = AsyncMock(
return_value={"output1": {"stck_prpr": "120", "frgn_ntby_qty": "0"}}
)
broker.get_balance = AsyncMock(
return_value={
"output2": [
{
"tot_evlu_amt": "100000",
"dnca_tot_amt": "10000",
"pchs_amt_smtl_amt": "90000",
}
]
}
)
broker.send_order = AsyncMock(return_value={"msg1": "OK"})
overseas_broker = MagicMock()
engine = MagicMock(spec=ScenarioEngine)
engine.evaluate = MagicMock(return_value=_make_sell_match())
risk = MagicMock()
context_store = MagicMock(
get_latest_timeframe=MagicMock(return_value=None),
set_context=MagicMock(),
)
criticality_assessor = MagicMock(
assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")),
get_timeout=MagicMock(return_value=5.0),
)
telegram = MagicMock()
telegram.notify_trade_execution = AsyncMock()
telegram.notify_fat_finger = AsyncMock()
telegram.notify_circuit_breaker = AsyncMock()
telegram.notify_scenario_matched = AsyncMock()
market = MagicMock()
market.name = "Korea"
market.code = "KR"
market.exchange_code = "KRX"
market.is_domestic = True
await trading_cycle(
broker=broker,
overseas_broker=overseas_broker,
scenario_engine=engine,
playbook=_make_playbook(),
risk=risk,
db_conn=db_conn,
decision_logger=decision_logger,
context_store=context_store,
criticality_assessor=criticality_assessor,
telegram=telegram,
market=market,
stock_code="005930",
scan_candidates={},
)
updated_buy = decision_logger.get_decision_by_id(buy_decision_id)
assert updated_buy is not None
assert updated_buy.outcome_pnl == 20.0
assert updated_buy.outcome_accuracy == 1