Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 54 additions & 33 deletions core/order_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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 = {
Expand Down Expand Up @@ -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,
Expand All @@ -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}%)")
Expand Down
31 changes: 31 additions & 0 deletions database/repositories.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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,
Expand Down
21 changes: 21 additions & 0 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
63 changes: 63 additions & 0 deletions tests/test_order_executor_paper.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, "매매 기록이 남아 있으면 반쪽 원장(현금만 차감)"
Loading