trade: apply runtime strategy/fx pnl split on sell paths (#370)
All checks were successful
Gitea CI / test (push) Successful in 32s
Gitea CI / test (pull_request) Successful in 32s

This commit is contained in:
agentson
2026-03-02 02:35:54 +09:00
parent 3914f24872
commit d4f37ee392
5 changed files with 161 additions and 8 deletions

View File

@@ -1,6 +1,6 @@
<!--
Doc-ID: DOC-REQ-001
Version: 1.0.3
Version: 1.0.4
Status: active
Owner: strategy
Updated: 2026-03-02

View File

@@ -48,7 +48,7 @@ Updated: 2026-03-02
| REQ-V3-004 | 블랙아웃 큐 + 복구 시 재검증 | ⚠️ 부분 | 큐 포화 시 intent 유실 경로 존재 (`#371`), 재검증 강화를 `#328`에서 추적 |
| REQ-V3-005 | 저유동 세션 시장가 금지 | ✅ 완료 | `src/core/order_policy.py` |
| REQ-V3-006 | 보수적 백테스트 체결 (불리 방향) | ✅ 완료 | `src/analysis/backtest_execution_model.py` |
| REQ-V3-007 | FX 손익 분리 (전략 PnL vs 환율 PnL) | ⚠️ 부분 | 스키마 존재, 런타임 분리 계산/전달 적용 (`#370`) |
| REQ-V3-007 | FX 손익 분리 (전략 PnL vs 환율 PnL) | ⚠️ 부분 | 런타임 분리 계산/전달 적용 (`#370`), buy-side `fx_rate` 미관측 시 `fx_pnl=0` fallback |
| REQ-V3-008 | 오버나잇 예외 vs Kill Switch 우선순위 | ✅ 완료 | `src/main.py``_should_force_exit_for_overnight()`, `_apply_staged_exit_override_for_hold()` |
### 1.4 운영 거버넌스: 부분 완료 (2026-03-02 재평가)
@@ -107,10 +107,11 @@ Updated: 2026-03-02
- `max_holding_bars` deprecated 경고 유지 (하위 호환)
- **요구사항**: REQ-V2-005 / v3 확장
### GAP-6 (신규): FX PnL 분리 미완료 (MEDIUM — 부분 구현)
### GAP-6 (신규): FX PnL 분리 부분 해소 (MEDIUM)
- **위치**: `src/db.py` (`fx_pnl`, `strategy_pnl` 컬럼 존재)
- **문제**: 스키마와 함수는 존재하지만 런타임 경로에서 `strategy_pnl`/`fx_pnl` 분리 계산 전달이 누락됨 (`#370`)
- **현 상태**: 런타임 SELL 경로에서 `strategy_pnl`/`fx_pnl` 분리 계산 및 전달을 적용함 (`#370`).
- **잔여**: 과거 BUY 레코드에 `fx_rate`가 없으면 해외 구간도 `fx_pnl=0` fallback으로 기록됨.
- **영향**: USD 거래에서 환율 손익과 전략 손익이 분리되지 않아 성과 분석 부정확
- **요구사항**: REQ-V3-007

View File

@@ -318,7 +318,7 @@ def get_latest_buy_trade(
if exchange_code:
cursor = conn.execute(
"""
SELECT decision_id, price, quantity
SELECT decision_id, price, quantity, selection_context
FROM trades
WHERE stock_code = ?
AND market = ?
@@ -339,7 +339,7 @@ def get_latest_buy_trade(
else:
cursor = conn.execute(
"""
SELECT decision_id, price, quantity
SELECT decision_id, price, quantity, selection_context
FROM trades
WHERE stock_code = ?
AND market = ?

View File

@@ -128,6 +128,81 @@ def _resolve_sell_qty_for_pnl(*, sell_qty: int | None, buy_qty: int | None) -> i
return max(0, int(buy_qty or 0))
def _extract_fx_rate_from_sources(*sources: dict[str, Any] | None) -> float | None:
"""Best-effort FX rate extraction from broker payloads."""
rate_keys = (
"frst_bltn_exrt",
"bass_exrt",
"ovrs_exrt",
"aply_xchg_rt",
"xchg_rt",
"exchange_rate",
"fx_rate",
)
for source in sources:
if not isinstance(source, dict):
continue
for key in rate_keys:
rate = safe_float(source.get(key), 0.0)
if rate > 0:
return rate
return None
def _split_trade_pnl_components(
*,
market: MarketInfo,
trade_pnl: float,
buy_price: float,
sell_price: float,
quantity: int,
buy_fx_rate: float | None = None,
sell_fx_rate: float | None = None,
) -> tuple[float, float]:
"""Split total trade pnl into strategy/fx components.
For overseas symbols, use buy/sell FX rates when both are available.
Otherwise preserve backward-compatible behaviour (all strategy pnl).
"""
if trade_pnl == 0.0:
return 0.0, 0.0
if market.is_domestic:
return trade_pnl, 0.0
if (
buy_fx_rate is not None
and sell_fx_rate is not None
and buy_fx_rate > 0
and sell_fx_rate > 0
and quantity > 0
and buy_price > 0
and sell_price > 0
):
buy_notional = buy_price * quantity
fx_return = (sell_fx_rate - buy_fx_rate) / buy_fx_rate
fx_pnl = buy_notional * fx_return
strategy_pnl = trade_pnl - fx_pnl
return strategy_pnl, fx_pnl
return trade_pnl, 0.0
def _extract_buy_fx_rate(buy_trade: dict[str, Any] | None) -> float | None:
if not buy_trade:
return None
raw_ctx = buy_trade.get("selection_context")
if not isinstance(raw_ctx, str) or not raw_ctx.strip():
return None
try:
decoded = json.loads(raw_ctx)
except (TypeError, ValueError):
return None
if not isinstance(decoded, dict):
return None
rate = safe_float(decoded.get("fx_rate"), 0.0)
return rate if rate > 0 else None
def _compute_kr_dynamic_stop_loss_pct(
*,
market: MarketInfo | None = None,
@@ -1372,6 +1447,7 @@ async def trading_cycle(
_session_risk_overrides(market=market, settings=settings)
# 1. Fetch market data
balance_info: dict[str, Any] = {}
price_output: dict[str, Any] = {} # Populated for overseas markets; used for fallback metrics
if market.is_domestic:
current_price, price_change_pct, foreigner_net = await broker.get_current_price(stock_code)
@@ -1394,8 +1470,6 @@ async def trading_cycle(
balance_info = output2[0]
elif isinstance(output2, dict):
balance_info = output2
else:
balance_info = {}
total_eval = safe_float(balance_info.get("frcr_evlu_tota", "0") or "0")
purchase_total = safe_float(balance_info.get("frcr_buy_amt_smtl", "0") or "0")
@@ -1815,6 +1889,9 @@ async def trading_cycle(
quantity = 0
trade_price = current_price
trade_pnl = 0.0
buy_trade: dict[str, Any] | None = None
buy_price = 0.0
sell_qty = 0
if decision.action in ("BUY", "SELL"):
if KILL_SWITCH.new_orders_blocked and decision.action == "BUY":
logger.critical(
@@ -2129,6 +2206,26 @@ async def trading_cycle(
"signal": candidate.signal,
"score": candidate.score,
}
sell_fx_rate = _extract_fx_rate_from_sources(price_output, balance_info)
if sell_fx_rate is not None and not market.is_domestic:
if selection_context is None:
selection_context = {"fx_rate": sell_fx_rate}
else:
selection_context["fx_rate"] = sell_fx_rate
strategy_pnl: float | None = None
fx_pnl: float | None = None
if decision.action == "SELL" and order_succeeded:
buy_fx_rate = _extract_buy_fx_rate(buy_trade)
strategy_pnl, fx_pnl = _split_trade_pnl_components(
market=market,
trade_pnl=trade_pnl,
buy_price=buy_price,
sell_price=trade_price,
quantity=sell_qty or quantity,
buy_fx_rate=buy_fx_rate,
sell_fx_rate=sell_fx_rate,
)
log_trade(
conn=db_conn,
@@ -2139,6 +2236,8 @@ async def trading_cycle(
quantity=quantity,
price=trade_price,
pnl=trade_pnl,
strategy_pnl=strategy_pnl,
fx_pnl=fx_pnl,
market=market.code,
exchange_code=market.exchange_code,
session_id=runtime_session_id,
@@ -2737,6 +2836,7 @@ async def run_daily_session(
)
continue
balance_info: dict[str, Any] = {}
if market.is_domestic:
output2 = balance_data.get("output2", [{}])
total_eval = safe_float(output2[0].get("tot_evlu_amt", "0")) if output2 else 0
@@ -2991,6 +3091,9 @@ async def run_daily_session(
quantity = 0
trade_price = stock_data["current_price"]
trade_pnl = 0.0
buy_trade: dict[str, Any] | None = None
buy_price = 0.0
sell_qty = 0
order_succeeded = True
if decision.action in ("BUY", "SELL"):
if KILL_SWITCH.new_orders_blocked and decision.action == "BUY":
@@ -3273,6 +3376,27 @@ async def run_daily_session(
# Log trade (skip if order was rejected by API)
if decision.action in ("BUY", "SELL") and not order_succeeded:
continue
strategy_pnl: float | None = None
fx_pnl: float | None = None
selection_context: dict[str, Any] | None = None
if decision.action == "SELL" and order_succeeded:
buy_fx_rate = _extract_buy_fx_rate(buy_trade)
sell_fx_rate = _extract_fx_rate_from_sources(balance_info, stock_data)
strategy_pnl, fx_pnl = _split_trade_pnl_components(
market=market,
trade_pnl=trade_pnl,
buy_price=buy_price,
sell_price=trade_price,
quantity=sell_qty or quantity,
buy_fx_rate=buy_fx_rate,
sell_fx_rate=sell_fx_rate,
)
if sell_fx_rate is not None and not market.is_domestic:
selection_context = {"fx_rate": sell_fx_rate}
elif not market.is_domestic:
snapshot_fx_rate = _extract_fx_rate_from_sources(balance_info, stock_data)
if snapshot_fx_rate is not None:
selection_context = {"fx_rate": snapshot_fx_rate}
log_trade(
conn=db_conn,
stock_code=stock_code,
@@ -3282,9 +3406,12 @@ async def run_daily_session(
quantity=quantity,
price=trade_price,
pnl=trade_pnl,
strategy_pnl=strategy_pnl,
fx_pnl=fx_pnl,
market=market.code,
exchange_code=market.exchange_code,
session_id=runtime_session_id,
selection_context=selection_context,
decision_id=decision_id,
mode=settings.MODE,
)

View File

@@ -40,6 +40,7 @@ from src.main import (
_run_evolution_loop,
_should_block_overseas_buy_for_fx_buffer,
_should_force_exit_for_overnight,
_split_trade_pnl_components,
_start_dashboard_server,
_stoploss_cooldown_minutes,
_trigger_emergency_kill_switch,
@@ -3181,6 +3182,13 @@ async def test_sell_order_uses_broker_balance_qty_not_db() -> None:
updated_buy = decision_logger.get_decision_by_id(buy_decision_id)
assert updated_buy is not None
assert updated_buy.outcome_pnl == -25.0
sell_row = db_conn.execute(
"SELECT pnl, strategy_pnl, fx_pnl FROM trades WHERE action='SELL' ORDER BY id DESC LIMIT 1"
).fetchone()
assert sell_row is not None
assert sell_row[0] == -25.0
assert sell_row[1] == -25.0
assert sell_row[2] == 0.0
@pytest.mark.asyncio
@@ -4598,6 +4606,23 @@ def test_fx_buffer_guard_applies_only_to_us_and_respects_boundary() -> None:
assert required_jp == 0.0
def test_split_trade_pnl_components_overseas_fx_split_preserves_total() -> None:
market = MagicMock()
market.is_domestic = False
strategy_pnl, fx_pnl = _split_trade_pnl_components(
market=market,
trade_pnl=20.0,
buy_price=100.0,
sell_price=110.0,
quantity=2,
buy_fx_rate=1200.0,
sell_fx_rate=1260.0,
)
assert strategy_pnl == 10.0
assert fx_pnl == 10.0
assert strategy_pnl + fx_pnl == pytest.approx(20.0)
# run_daily_session — daily CB baseline (daily_start_eval) tests (issue #207)
# ---------------------------------------------------------------------------