diff --git a/core/order_executor.py b/core/order_executor.py index fe89bb9..9eb2758 100644 --- a/core/order_executor.py +++ b/core/order_executor.py @@ -17,7 +17,7 @@ from api.kis_api import KISApi, KISOrderResponseUnknown from core.risk_manager import RiskManager from database.repositories import ( - save_trade, save_position, delete_position, reduce_position, + save_trade, save_position, delete_position, reduce_position, delete_trade_by_id, get_position, get_all_positions, save_failed_order, count_monthly_buy_trades, save_order_record, get_open_order_records, reconcile_order_record, ) @@ -1012,7 +1012,7 @@ def _execute_buy_impl( trailing_stop = self.risk_manager.calculate_trailing_stop(fill_price, atr) _order_at = datetime.now() - save_trade( + _trade = save_trade( symbol=symbol, action="BUY", price=fill_price, quantity=quantity, commission=costs["commission"], tax=0, slippage=costs["slippage"], strategy=strategy, signal_score=signal_score, reason=reason, @@ -1026,12 +1026,18 @@ def _execute_buy_impl( _log_op_event("SIGNAL", f"BUY {symbol} {quantity}주 @ {price:,.0f}원", symbol=symbol, strategy=strategy, mode=self.mode) - save_position( - symbol=symbol, avg_price=fill_price, quantity=quantity, - stop_loss_price=stop_loss, take_profit_price=tp_info["target_final"], - trailing_stop_price=trailing_stop, strategy=strategy, - account_key=self.account_key, - ) + try: + save_position( + symbol=symbol, avg_price=fill_price, quantity=quantity, + stop_loss_price=stop_loss, take_profit_price=tp_info["target_final"], + trailing_stop_price=trailing_stop, strategy=strategy, + account_key=self.account_key, + ) + except Exception: + # 원장 보상 롤백: 매매만 남으면 현금만 차감된 반쪽 원장 — 유령 낙폭과 + # 가드 오발동의 뿌리(2026-07-07 실측). 되돌리고 실패를 위로 알린다. + delete_trade_by_id(_trade.id) + raise # 매매 로그 log_trade("BUY", symbol, fill_price, quantity, reason) @@ -1248,7 +1254,7 @@ def _execute_buy_quantity_impl( trailing_stop = self.risk_manager.calculate_trailing_stop(fill_price, atr) _order_at = datetime.now() - save_trade( + _trade = save_trade( symbol=symbol, action="BUY", price=fill_price, @@ -1275,16 +1281,22 @@ def _execute_buy_quantity_impl( strategy=strategy, mode=self.mode, ) - save_position( - symbol=symbol, - avg_price=fill_price, - quantity=quantity, - stop_loss_price=stop_loss, - take_profit_price=tp_info["target_final"], - trailing_stop_price=trailing_stop, - strategy=strategy, - account_key=self.account_key, - ) + try: + save_position( + symbol=symbol, + avg_price=fill_price, + quantity=quantity, + stop_loss_price=stop_loss, + take_profit_price=tp_info["target_final"], + trailing_stop_price=trailing_stop, + strategy=strategy, + account_key=self.account_key, + ) + except Exception: + # 원장 보상 롤백: 매매만 남으면 현금만 차감된 반쪽 원장 — 유령 낙폭과 + # 가드 오발동의 뿌리(2026-07-07 실측). 되돌리고 실패를 위로 알린다. + delete_trade_by_id(_trade.id) + raise log_trade("BUY", symbol, fill_price, quantity, reason) result = { @@ -1490,7 +1502,7 @@ def _execute_sell_impl( pnl = (fill_price - position.avg_price) * sell_qty - costs["commission"] - total_tax pnl_rate = ((fill_price / position.avg_price) - 1) * 100 - save_trade( + _trade = save_trade( symbol=symbol, action="SELL", price=fill_price, quantity=sell_qty, commission=costs["commission"], tax=total_tax, slippage=costs["slippage"], strategy=strategy, signal_score=signal_score, @@ -1502,19 +1514,28 @@ def _execute_sell_impl( order_id=order.order_id, ) - if sell_qty >= position.quantity: - delete_position(symbol, account_key=self.account_key) - else: - remaining_pos = reduce_position(symbol, sell_qty, account_key=self.account_key) - if remaining_pos and reason == "TAKE_PROFIT_PARTIAL": - from database.repositories import update_position_targets - tp_config = self.risk_manager.risk_params.get("take_profit", {}) - final_target = position.avg_price * (1 + tp_config.get("fixed_rate", 0.08)) - # 부분 익절 완료 표시를 영속화해 다음 모니터링 사이클에서 재발동되지 않게 한다. - update_position_targets( - symbol, take_profit_price=round(final_target, 0), - account_key=self.account_key, partial_tp_done=True, - ) + remaining_pos = None + try: + if sell_qty >= position.quantity: + delete_position(symbol, account_key=self.account_key) + else: + remaining_pos = reduce_position(symbol, sell_qty, account_key=self.account_key) + except Exception: + # 원장 보상 롤백(매수와 대칭): 매도 기록만 남고 포지션이 그대로면 + # 현금이 이중 계상된 반쪽 원장이 된다. 되돌리고 실패를 위로 알린다. + delete_trade_by_id(_trade.id) + raise + # 부분 익절 플래그 갱신은 보상 범위 밖 — 이 시점엔 매도·포지션 반영이 모두 + # 끝나 원장이 정합이고, 플래그 실패에 매매를 되돌리면 오히려 원장이 깨진다. + if remaining_pos and reason == "TAKE_PROFIT_PARTIAL": + from database.repositories import update_position_targets + tp_config = self.risk_manager.risk_params.get("take_profit", {}) + final_target = position.avg_price * (1 + tp_config.get("fixed_rate", 0.08)) + # 부분 익절 완료 표시를 영속화해 다음 모니터링 사이클에서 재발동되지 않게 한다. + update_position_targets( + symbol, take_profit_price=round(final_target, 0), + account_key=self.account_key, partial_tp_done=True, + ) # 매매 로그 log_trade("SELL", symbol, fill_price, sell_qty, f"{reason} (수익: {pnl_rate:.2f}%)") diff --git a/database/repositories.py b/database/repositories.py index 576948a..715b3a1 100644 --- a/database/repositories.py +++ b/database/repositories.py @@ -167,6 +167,10 @@ def save_trade( ) session.add(trade) session.commit() + # 커밋으로 만료된 속성 재적재 — 호출부(보상 롤백)가 세션 밖에서 id를 읽는다. + # 이거 없으면 반환 객체 접근이 DetachedInstanceError. + session.refresh(trade) + session.expunge(trade) logger.info("매매 기록 저장: {} {} {}주 @ {:,.0f}원", action, symbol, quantity, price) return trade except Exception as e: @@ -177,6 +181,33 @@ def save_trade( session.close() +def delete_trade_by_id(trade_id: int) -> bool: + """매매 기록 1건 삭제 — 체결 반영(포지션 저장) 실패 시의 보상 롤백 전용. + + 매매만 남고 포지션이 없으면 현금만 차감된 반쪽 원장이 돼 유령 낙폭과 + 리스크 가드 오발동을 만든다(2026-07-07 실측: -41% 스냅샷 4일). 일반 삭제 + 용도로 쓰지 말 것 — 트랙레코드는 불변이 원칙이다. + """ + session = get_session() + try: + row = session.query(TradeHistory).filter(TradeHistory.id == trade_id).first() + if row is None: + return False + session.delete(row) + session.commit() + logger.warning( + "매매 기록 보상 삭제: id={} {} {} {}주 — 포지션 반영 실패에 따른 원장 정합 복구", + trade_id, row.action, row.symbol, row.quantity, + ) + return True + except Exception as e: + session.rollback() + logger.error("매매 기록 보상 삭제 실패: id={} — {}", trade_id, e) + raise + finally: + session.close() + + @with_retry def get_trade_history( symbol: Optional[str] = None, diff --git a/main.py b/main.py index ff66919..502583c 100644 --- a/main.py +++ b/main.py @@ -810,6 +810,27 @@ def run_rebalance(args): ) logger.info(summary) notifier.send_message(summary) + # 예외성 실패(status=error)는 가드 거부(보호 동작, 통상 요약)와 + # 달리 원장 불일치 가능성이 있는 인프라 실패 — 즉시 critical로 + # 승격한다. 7/7 포지션 유실이 통상 요약에 묻혀 3일 지난 교훈. + error_details = [ + d for d in result.get("details", []) + if d.get("status") == "error" + ] + if error_details and not dry_run: + err_msg = ( + f"🚨 바스켓 '{name}' 주문 실행 예외 {len(error_details)}건 " + f"— 원장 정합성 확인 필요: " + + "; ".join( + str(d.get("reason", ""))[:80] + for d in error_details[:3] + ) + ) + record_cycle_event( + "ORDER_ERROR", err_msg, severity="critical", + strategy=live_strategy_name, mode=mode, + ) + notifier.send_message(err_msg, critical=True) # 트랙레코드: 거래 여부와 무관하게 바스켓 계정의 일일 NAV 스냅샷을 남긴다. # 보유 종목 가격이 전부 확보된 경우에만 저장(가짜 NAV 방지), 멱등 upsert. diff --git a/tests/test_order_executor_paper.py b/tests/test_order_executor_paper.py index 0e89ea9..6380551 100644 --- a/tests/test_order_executor_paper.py +++ b/tests/test_order_executor_paper.py @@ -1656,3 +1656,66 @@ def check(total, mdd_pct): # 4) 한도의 절반(7.5%) 아래(-7%)로 회복 → 재개 r = check(9_300_000, 7.0) assert r["allowed"] is True + + +def test_delete_trade_by_id_compensation_helper(fresh_db): + """보상 삭제 헬퍼 — 존재하면 삭제 True, 없으면 False (멱등).""" + from database.repositories import save_trade, delete_trade_by_id + from database.models import get_session, TradeHistory + + t = save_trade(symbol="005930", action="BUY", price=1000, quantity=1, + account_key="del_trade_test") + assert delete_trade_by_id(t.id) is True + assert delete_trade_by_id(t.id) is False # 이미 없음 — 재호출 안전 + session = get_session() + try: + n = session.query(TradeHistory).filter( + TradeHistory.account_key == "del_trade_test" + ).count() + finally: + session.close() + assert n == 0 + + +def test_buy_position_save_failure_compensates_trade(fresh_db, monkeypatch): + """포지션 반영 실패 시 방금 저장한 매매 기록을 되돌린다 — 반쪽 원장 방지. + + 2026-07-07 실측 재연: 매매만 남고 포지션이 유실되면 현금만 차감돼 유령 + -41% 낙폭이 찍히고 리스크 가드가 이후 매수를 전부 차단했다. 이제 포지션 + 저장 실패는 매매 기록을 보상 삭제한 뒤 위로 전파돼야 한다(원장 무변화). + """ + from core.order_executor import OrderExecutor + from database.models import get_session, TradeHistory + + executor = OrderExecutor(account_key="ledger_atomicity_test") + monkeypatch.setattr(executor, "_should_block_new_buy_volatility_window", lambda: False) + monkeypatch.setattr( + "core.paper_preflight.load_preflight_status", + lambda strategy, strict=False: _passing_preflight(), + ) + monkeypatch.setattr( + "core.paper_runtime.get_paper_runtime_state", + lambda *a, **kw: _normal_runtime_state(), + ) + + def _fail_position(*a, **kw): + raise RuntimeError("포지션 저장 실패 주입") + + monkeypatch.setattr("core.order_executor.save_position", _fail_position) + + with pytest.raises(RuntimeError, match="포지션 저장 실패 주입"): + executor.execute_buy_quantity( + symbol="069500", price=123_710, quantity=1, + capital=300_000, available_cash=300_000, + reason="보상 롤백 테스트", strategy="ledger_test", + avg_daily_volume=1_000_000, + ) + + session = get_session() + try: + n = session.query(TradeHistory).filter( + TradeHistory.account_key == "ledger_atomicity_test" + ).count() + finally: + session.close() + assert n == 0, "매매 기록이 남아 있으면 반쪽 원장(현금만 차감)"