trade: apply runtime strategy/fx pnl split on sell paths (#370) #383
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 = ?
|
||||
|
||||
131
src/main.py
131
src/main.py
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
Reference in New Issue
Block a user