feat: Enhance trading system with new e_min_chg_pct parameter and related logic
Changes: - Introduced the `e_min_chg_pct` parameter to define the minimum price change percentage compared to the previous day's close, enhancing the momentum trading strategy. - Updated various functions and classes to incorporate this new parameter, ensuring it is utilized in both backtesting and live trading scenarios. - Improved documentation and comments to clarify the purpose and usage of the new parameter across the codebase. Impact: - This addition allows for more precise control over trading conditions, potentially increasing the effectiveness of the momentum strategy while maintaining system integrity and performance.
This commit is contained in:
@@ -30,6 +30,9 @@ kis_trader/execution/order_manager.py — Master Executor
|
||||
``inquire-daily-ccld`` 1회로 체결 복구 → active_trades 반영.
|
||||
* ``DUPLICATE_ORDER_RECOVERY_WAIT_SEC`` (기본 ORDER_FILL_WAIT_SEC): 중복복구 체결 대기.
|
||||
* ``SELL_PENDING_REORDER_ON_EXPIRE`` — 손절 등 긴급 매도 만료 시 즉시 시장가 재주문.
|
||||
* ``USE_MARKET_IOC`` — 실전 매수 시장가 IOC(13). 모의는 01 고정.
|
||||
* ``USE_MARKET_IOC_SELL`` — 실전 매도 시장가 IOC(13). 모의는 01 고정.
|
||||
부분체결 시 잔량은 브로커 자동취소·포지션 축소 후 긴급이면 즉시 재매도.
|
||||
* ``SELL_LIMIT_CANCEL_BEFORE_MARKET_RETRY`` (기본 True): 익절 지정가 미확인/부분체결 시
|
||||
**시장가 보강 전에 지정가 취소 → ODNO 재조회**. 취소 실패·체결 미확인이면 시장가 금지
|
||||
(PENDING). 지정가+시장가 이중체결로 타전략 몫까지 파는 사고 방지 (전 전략 공통).
|
||||
@@ -133,7 +136,8 @@ class OrderManager:
|
||||
self.db = db
|
||||
self.cash_ledger: Optional[AccountCashLedger] = cash_ledger
|
||||
# 종목별 Lock: 한 종목에 대한 주문 요청은 순차 처리
|
||||
self._code_locks: Dict[str, threading.Lock] = defaultdict(threading.Lock)
|
||||
# RLock: place_buy 가 락을 잡은 채 _finalize_* 를 호출해도 재진입 가능
|
||||
self._code_locks: Dict[str, threading.RLock] = defaultdict(threading.RLock)
|
||||
# 전역 Lock: _code_locks 인스턴스 생성 시 경합 방지
|
||||
self._global_lock = threading.Lock()
|
||||
# 실잔고 맵 캐시
|
||||
@@ -158,7 +162,7 @@ class OrderManager:
|
||||
# ------------------------------------------------------------------
|
||||
# Lock 헬퍼
|
||||
# ------------------------------------------------------------------
|
||||
def _lock_for(self, code: str) -> threading.Lock:
|
||||
def _lock_for(self, code: str) -> threading.RLock:
|
||||
with self._global_lock:
|
||||
return self._code_locks[code]
|
||||
|
||||
@@ -543,84 +547,125 @@ class OrderManager:
|
||||
*,
|
||||
log_tag: str = "매수체결",
|
||||
) -> OrderResult:
|
||||
"""체결 확정 후 active_trades·알림 반영."""
|
||||
"""체결 확정 후 active_trades·알림 반영.
|
||||
|
||||
본매수 경로와 ``poll_pending_fills`` 가 동시에 올 수 있어,
|
||||
DB 체결수량이 이미 같거나 더 크면 알림·예수금 델타를 건너뛴다.
|
||||
(동일 ODNO 중복 MM 방지. 부분→증가 47→61 은 증가분만 반영·재알림.)
|
||||
"""
|
||||
if filled_qty <= 0 or filled_price <= 0:
|
||||
return OrderResult(False, ord_no=ord_no, reason="zero_fill", request=req)
|
||||
|
||||
self._resolve_order_display_name(req)
|
||||
|
||||
status = "FILLED" if filled_qty >= order_qty else "PARTIAL"
|
||||
self.db.update_order_fill(
|
||||
ord_no=ord_no,
|
||||
strategy_id=req.strategy_id,
|
||||
code=req.code,
|
||||
filled_qty=filled_qty,
|
||||
filled_avg_price=filled_price,
|
||||
status=status,
|
||||
)
|
||||
with self._lock_for(req.code):
|
||||
existing = self.db.get_order_by_odno(
|
||||
ord_no, strategy_id=req.strategy_id, code=req.code,
|
||||
)
|
||||
prev_filled = int((existing or {}).get("filled_qty") or 0)
|
||||
if filled_qty <= prev_filled:
|
||||
logger.info(
|
||||
"%s⏭ [%s] 이미 반영된 매수체결 스킵 ODNO=%s "
|
||||
"filled=%d <= db=%d (중복알림 방지)%s",
|
||||
LOG_CYAN, log_tag, ord_no, filled_qty, prev_filled, LOG_RESET,
|
||||
)
|
||||
prev_px = float(
|
||||
(existing or {}).get("filled_avg_price") or filled_price or 0
|
||||
)
|
||||
return OrderResult(
|
||||
True,
|
||||
ord_no=ord_no,
|
||||
filled_qty=prev_filled,
|
||||
filled_avg_price=prev_px,
|
||||
reason="buy_fill_already_applied",
|
||||
request=req,
|
||||
)
|
||||
|
||||
now_str = dt.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
from ..utils.strategy_ids import canonical_strategy_id
|
||||
|
||||
self.db.upsert_trade({
|
||||
"code": req.code,
|
||||
"name": req.name,
|
||||
"strategy": canonical_strategy_id(req.strategy_id),
|
||||
"avg_buy_price": filled_price,
|
||||
"current_price": filled_price,
|
||||
"stop_price": req.stop_price,
|
||||
"target_price": req.target_price,
|
||||
"max_price": filled_price,
|
||||
"atr_entry": req.atr_entry,
|
||||
"target_qty": filled_qty,
|
||||
"current_qty": filled_qty,
|
||||
"total_invested": filled_price * filled_qty,
|
||||
"status": "HOLDING",
|
||||
"buy_date": now_str,
|
||||
"size_class": req.size_class or "",
|
||||
"entry_features": req.entry_features or {},
|
||||
})
|
||||
self.invalidate_holdings_cache()
|
||||
if self.cash_ledger is not None:
|
||||
fee_buf = max(1.0, get_env_float("ORDER_CASH_FEE_BUFFER", 1.01))
|
||||
self.cash_ledger.apply_trade_delta(
|
||||
-filled_qty * filled_price * fee_buf,
|
||||
source="trade_delta",
|
||||
delta_qty = filled_qty - prev_filled
|
||||
status = "FILLED" if filled_qty >= order_qty else "PARTIAL"
|
||||
self.db.update_order_fill(
|
||||
ord_no=ord_no,
|
||||
strategy_id=req.strategy_id,
|
||||
code=req.code,
|
||||
filled_qty=filled_qty,
|
||||
filled_avg_price=filled_price,
|
||||
status=status,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"%s✅ [%s] [%s] %s %s @ %d원 × %d주 (ODNO=%s)%s",
|
||||
LOG_GREEN, log_tag, req.strategy_id, req.name, req.code,
|
||||
int(filled_price), filled_qty, ord_no, LOG_RESET,
|
||||
)
|
||||
try:
|
||||
disp = _strategy_display(req.strategy_id)
|
||||
header = (
|
||||
f"🔷 **[{log_tag}:{disp}]** {req.name}({req.code})\n"
|
||||
f"{filled_price:,.0f}원 × {filled_qty}주 = {filled_price*filled_qty:,.0f}원\n"
|
||||
f"손절 {req.stop_price:,.0f} / 목표 {req.target_price:,.0f}\n"
|
||||
f"ODNO={ord_no}"
|
||||
)
|
||||
tail = ""
|
||||
if self.asset_line_provider is not None:
|
||||
try:
|
||||
tail = self.asset_line_provider("BUY", None) or ""
|
||||
except Exception as _e:
|
||||
logger.debug("asset_line_provider(BUY) 실패: %s", _e)
|
||||
msg = header + ("\n\n" + tail if tail else "")
|
||||
msg_mm_strategy(
|
||||
msg,
|
||||
_strategy_mm_channel(req.strategy_id),
|
||||
jitter=False,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
now_str = dt.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
from ..utils.strategy_ids import canonical_strategy_id
|
||||
|
||||
return OrderResult(
|
||||
True, ord_no=ord_no,
|
||||
filled_qty=filled_qty, filled_avg_price=filled_price,
|
||||
request=req,
|
||||
)
|
||||
self.db.upsert_trade({
|
||||
"code": req.code,
|
||||
"name": req.name,
|
||||
"strategy": canonical_strategy_id(req.strategy_id),
|
||||
"avg_buy_price": filled_price,
|
||||
"current_price": filled_price,
|
||||
"stop_price": req.stop_price,
|
||||
"target_price": req.target_price,
|
||||
"max_price": filled_price,
|
||||
"atr_entry": req.atr_entry,
|
||||
"target_qty": filled_qty,
|
||||
"current_qty": filled_qty,
|
||||
"total_invested": filled_price * filled_qty,
|
||||
"status": "HOLDING",
|
||||
"buy_date": now_str,
|
||||
"size_class": req.size_class or "",
|
||||
"entry_features": req.entry_features or {},
|
||||
})
|
||||
self.invalidate_holdings_cache()
|
||||
if self.cash_ledger is not None and delta_qty > 0:
|
||||
fee_buf = max(1.0, get_env_float("ORDER_CASH_FEE_BUFFER", 1.01))
|
||||
self.cash_ledger.apply_trade_delta(
|
||||
-delta_qty * filled_price * fee_buf,
|
||||
source="trade_delta",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"%s✅ [%s] [%s] %s %s @ %d원 × %d주 (ODNO=%s)%s",
|
||||
LOG_GREEN, log_tag, req.strategy_id, req.name, req.code,
|
||||
int(filled_price), filled_qty, ord_no, LOG_RESET,
|
||||
)
|
||||
try:
|
||||
disp = _strategy_display(req.strategy_id)
|
||||
header = (
|
||||
f"🔷 **[{log_tag}:{disp}]** {req.name}({req.code})\n"
|
||||
f"{filled_price:,.0f}원 × {filled_qty}주 = {filled_price*filled_qty:,.0f}원\n"
|
||||
f"손절 {req.stop_price:,.0f} / 목표 {req.target_price:,.0f}\n"
|
||||
f"ODNO={ord_no}"
|
||||
)
|
||||
if prev_filled > 0:
|
||||
header += f"\n(추가체결 +{delta_qty}주 · 누적 {filled_qty}주)"
|
||||
if self.strategy_pnl_provider is not None:
|
||||
try:
|
||||
_sp = self.strategy_pnl_provider(req.strategy_id)
|
||||
if _sp is not None:
|
||||
_spnl, _scnt = _sp
|
||||
header += (
|
||||
f"\n📊 {disp} 당일 {_spnl:+,.0f}원 · 청산 {_scnt}건"
|
||||
)
|
||||
except Exception as _e:
|
||||
logger.debug("strategy_pnl_provider(BUY) 실패: %s", _e)
|
||||
tail = ""
|
||||
if self.asset_line_provider is not None:
|
||||
try:
|
||||
tail = self.asset_line_provider("BUY", None) or ""
|
||||
except Exception as _e:
|
||||
logger.debug("asset_line_provider(BUY) 실패: %s", _e)
|
||||
msg = header + ("\n\n" + tail if tail else "")
|
||||
msg_mm_strategy(
|
||||
msg,
|
||||
_strategy_mm_channel(req.strategy_id),
|
||||
jitter=False,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return OrderResult(
|
||||
True, ord_no=ord_no,
|
||||
filled_qty=filled_qty, filled_avg_price=filled_price,
|
||||
request=req,
|
||||
)
|
||||
|
||||
def _finalize_sell_fill(
|
||||
self,
|
||||
@@ -630,138 +675,242 @@ class OrderManager:
|
||||
sell_price: float,
|
||||
order_qty: int,
|
||||
) -> OrderResult:
|
||||
"""매도 체결 확정 후 close_trade·알림."""
|
||||
"""매도 체결 확정 후 close_trade·알림. 부분체결이면 잔량 유지 + 긴급 시 재매도."""
|
||||
if filled_qty <= 0 or sell_price <= 0:
|
||||
return OrderResult(False, ord_no=ord_no, reason="zero_sell_fill", request=req)
|
||||
|
||||
self._resolve_order_display_name(req)
|
||||
|
||||
status = "FILLED" if filled_qty >= order_qty else "PARTIAL"
|
||||
self.db.update_order_fill(
|
||||
ord_no=ord_no,
|
||||
strategy_id=req.strategy_id,
|
||||
code=req.code,
|
||||
filled_qty=filled_qty,
|
||||
filled_avg_price=sell_price,
|
||||
status=status,
|
||||
)
|
||||
|
||||
fee_rate = float(get_env_from_db("FEE_RATE_PCT", "0.015")) / 100.0
|
||||
tax_rate = float(get_env_from_db("SELL_TAX_RATE_PCT", "0.18")) / 100.0
|
||||
buy_price = req.buy_price or 0
|
||||
if buy_price > 0:
|
||||
fees = (
|
||||
buy_price * filled_qty * fee_rate
|
||||
+ sell_price * filled_qty * (fee_rate + tax_rate)
|
||||
with self._lock_for(req.code):
|
||||
existing = self.db.get_order_by_odno(
|
||||
ord_no, strategy_id=req.strategy_id, code=req.code,
|
||||
)
|
||||
realized_pnl = (sell_price - buy_price) * filled_qty - fees
|
||||
else:
|
||||
realized_pnl = None
|
||||
prev_filled = int((existing or {}).get("filled_qty") or 0)
|
||||
if filled_qty <= prev_filled:
|
||||
logger.info(
|
||||
"%s⏭ [매도체결] 이미 반영 스킵 ODNO=%s filled=%d <= db=%d%s",
|
||||
LOG_CYAN, ord_no, filled_qty, prev_filled, LOG_RESET,
|
||||
)
|
||||
prev_px = float(
|
||||
(existing or {}).get("filled_avg_price") or sell_price or 0
|
||||
)
|
||||
return OrderResult(
|
||||
True,
|
||||
ord_no=ord_no,
|
||||
filled_qty=prev_filled,
|
||||
filled_avg_price=prev_px,
|
||||
reason="sell_fill_already_applied",
|
||||
request=req,
|
||||
)
|
||||
delta_qty = filled_qty - prev_filled
|
||||
|
||||
if req.name and req.name != req.code:
|
||||
status = "FILLED" if filled_qty >= order_qty else "PARTIAL"
|
||||
self.db.update_order_fill(
|
||||
ord_no=ord_no,
|
||||
strategy_id=req.strategy_id,
|
||||
code=req.code,
|
||||
filled_qty=filled_qty,
|
||||
filled_avg_price=sell_price,
|
||||
status=status,
|
||||
)
|
||||
|
||||
# 시장가 부분체결: IOC면 잔량 이미 취소, 아니면 잔량 취소 후 pending 해제
|
||||
if 0 < filled_qty < order_qty:
|
||||
miss = order_qty - filled_qty
|
||||
if not getattr(self.client, "uses_market_sell_ioc", lambda: False)():
|
||||
try:
|
||||
self.client.cancel_order(ord_no, qty=miss)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"매도 잔량 취소 스킵/실패 %s ODNO=%s: %s",
|
||||
req.code, ord_no, e,
|
||||
)
|
||||
self._seal_partial_sell_order(ord_no, req, filled_qty)
|
||||
|
||||
fee_rate = float(get_env_from_db("FEE_RATE_PCT", "0.015")) / 100.0
|
||||
tax_rate = float(get_env_from_db("SELL_TAX_RATE_PCT", "0.18")) / 100.0
|
||||
buy_price = req.buy_price or 0
|
||||
if buy_price > 0:
|
||||
fees = (
|
||||
buy_price * delta_qty * fee_rate
|
||||
+ sell_price * delta_qty * (fee_rate + tax_rate)
|
||||
)
|
||||
realized_pnl = (sell_price - buy_price) * delta_qty - fees
|
||||
else:
|
||||
realized_pnl = None
|
||||
|
||||
if req.name and req.name != req.code:
|
||||
try:
|
||||
from ..utils.strategy_ids import canonical_strategy_id
|
||||
|
||||
sid = canonical_strategy_id(req.strategy_id)
|
||||
with self.db.conn:
|
||||
self.db.conn.execute(
|
||||
"UPDATE active_trades SET name=%s WHERE code=%s AND strategy=%s",
|
||||
(req.name, req.code, sid),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("active_trades name 보정 실패(%s): %s", req.code, exc)
|
||||
|
||||
# close_trade 전에 매수시각 확보 (보유구간 봉 백필용)
|
||||
buy_date_for_bf = None
|
||||
try:
|
||||
from ..utils.strategy_ids import canonical_strategy_id
|
||||
|
||||
sid = canonical_strategy_id(req.strategy_id)
|
||||
with self.db.conn:
|
||||
self.db.conn.execute(
|
||||
"UPDATE active_trades SET name=%s WHERE code=%s AND strategy=%s",
|
||||
(req.name, req.code, sid),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("active_trades name 보정 실패(%s): %s", req.code, exc)
|
||||
_sid_bf = canonical_strategy_id(req.strategy_id)
|
||||
_ar = self.db.conn.execute(
|
||||
"SELECT buy_date FROM active_trades WHERE code=%s AND strategy=%s LIMIT 1",
|
||||
(req.code, _sid_bf),
|
||||
).fetchone()
|
||||
if _ar:
|
||||
buy_date_for_bf = dict(_ar).get("buy_date")
|
||||
except Exception:
|
||||
buy_date_for_bf = None
|
||||
|
||||
# close_trade 전에 매수시각 확보 (보유구간 봉 백필용)
|
||||
buy_date_for_bf = None
|
||||
# 증가분만 청산 (재조회 중복·부분증가 이중청산 방지)
|
||||
self.db.close_trade(
|
||||
code=req.code,
|
||||
sell_price=sell_price,
|
||||
sell_reason=req.reason or "",
|
||||
strategy=req.strategy_id,
|
||||
realized_pnl_override=realized_pnl,
|
||||
sell_qty=int(delta_qty),
|
||||
)
|
||||
# 매수~매도 구간 1분봉 REST 백필 (백테 봉구멍·슬롯 좀비 방지) — 비동기 1회
|
||||
# 부분매도면 포지션 잔존 → 전량 청산 시에만 백필
|
||||
remain_after = self._active_qty(req.strategy_id, req.code)
|
||||
if buy_date_for_bf and remain_after <= 0:
|
||||
try:
|
||||
from kis_trader.engine.post_sell_candle_backfill import (
|
||||
schedule_post_sell_backfill,
|
||||
)
|
||||
schedule_post_sell_backfill(
|
||||
code=req.code,
|
||||
buy_date=buy_date_for_bf,
|
||||
sell_date=None,
|
||||
strategy=str(req.strategy_id or ""),
|
||||
)
|
||||
except Exception as _bf_e:
|
||||
logger.debug("post-sell candle backfill schedule 스킵: %s", _bf_e)
|
||||
self.invalidate_holdings_cache()
|
||||
if self.cash_ledger is not None and delta_qty > 0:
|
||||
gross = delta_qty * sell_price
|
||||
net = gross * (1.0 - fee_rate - tax_rate)
|
||||
self.cash_ledger.apply_trade_delta(net, source="trade_delta")
|
||||
|
||||
color = LOG_GREEN if (realized_pnl is None or realized_pnl >= 0) else LOG_RED
|
||||
logger.info(
|
||||
"%s💸 [매도체결] [%s] %s %s × %d주 @ %d원 | 사유=%s (ODNO=%s)%s",
|
||||
color, req.strategy_id, req.name, req.code,
|
||||
delta_qty, int(sell_price), req.reason, ord_no, LOG_RESET,
|
||||
)
|
||||
if remain_after > 0:
|
||||
logger.warning(
|
||||
"%s⚠️ [매도부분체결] [%s] %s %s: 누적체결 %d/%d주 · 잔량 %d주%s",
|
||||
LOG_YELLOW, req.strategy_id, req.name, req.code,
|
||||
filled_qty, order_qty, remain_after, LOG_RESET,
|
||||
)
|
||||
try:
|
||||
emoji = "🟢" if (realized_pnl is None or realized_pnl >= 0) else "🔴"
|
||||
pnl_str = f"{realized_pnl:+,.0f}원" if realized_pnl is not None else "-"
|
||||
disp = _strategy_display(req.strategy_id)
|
||||
header = (
|
||||
f"{emoji} **[매도체결:{disp}]** {req.name}({req.code})\n"
|
||||
f"{sell_price:,.0f}원 × {delta_qty}주\n"
|
||||
f"{req.reason} · 수익률 {req.profit_pct*100:+.2f}%\n"
|
||||
f"실현 {pnl_str} · ODNO={ord_no}"
|
||||
)
|
||||
if remain_after > 0:
|
||||
header += f"\n⚠️ 부분체결 잔량 {remain_after}주"
|
||||
if self.strategy_pnl_provider is not None:
|
||||
try:
|
||||
_sp = self.strategy_pnl_provider(req.strategy_id)
|
||||
if _sp is not None:
|
||||
_spnl, _scnt = _sp
|
||||
header += (
|
||||
f"\n📊 {disp} 당일 {_spnl:+,.0f}원 · 청산 {_scnt}건"
|
||||
)
|
||||
except Exception as _e:
|
||||
logger.debug("strategy_pnl_provider(SELL) 실패: %s", _e)
|
||||
tail = ""
|
||||
if self.asset_line_provider is not None:
|
||||
try:
|
||||
tail = self.asset_line_provider(
|
||||
"SELL",
|
||||
{"realized_pnl": realized_pnl},
|
||||
) or ""
|
||||
except Exception as _e:
|
||||
logger.debug("asset_line_provider(SELL) 실패: %s", _e)
|
||||
msg = header + ("\n\n" + tail if tail else "")
|
||||
msg_mm_strategy(
|
||||
msg,
|
||||
_strategy_mm_channel(req.strategy_id),
|
||||
jitter=False,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 손절 등 긴급: 잔량 있으면 즉시 시장가 재매도 (IOC면 pending 이미 seal)
|
||||
if remain_after > 0 and is_urgent_market_sell_reason(req.reason or ""):
|
||||
self._escalate_urgent_sell_market(
|
||||
req, remain_after, tag="부분체결재매도",
|
||||
)
|
||||
|
||||
return OrderResult(
|
||||
True, ord_no=ord_no,
|
||||
filled_qty=filled_qty, filled_avg_price=sell_price,
|
||||
request=req,
|
||||
)
|
||||
|
||||
def _active_qty(self, strategy_id: str, code: str) -> int:
|
||||
"""전략·종목 active_trades 잔량 (없으면 0)."""
|
||||
try:
|
||||
from ..utils.strategy_ids import canonical_strategy_id
|
||||
|
||||
_sid_bf = canonical_strategy_id(req.strategy_id)
|
||||
_ar = self.db.conn.execute(
|
||||
"SELECT buy_date FROM active_trades WHERE code=%s AND strategy=%s LIMIT 1",
|
||||
(req.code, _sid_bf),
|
||||
).fetchone()
|
||||
if _ar:
|
||||
buy_date_for_bf = dict(_ar).get("buy_date")
|
||||
except Exception:
|
||||
buy_date_for_bf = None
|
||||
sid = canonical_strategy_id(strategy_id)
|
||||
for try_sid in (sid, strategy_id):
|
||||
row = self.db.conn.execute(
|
||||
"SELECT current_qty FROM active_trades "
|
||||
"WHERE code=%s AND strategy=%s LIMIT 1",
|
||||
(code, try_sid),
|
||||
).fetchone()
|
||||
if row:
|
||||
return int(dict(row).get("current_qty") or 0)
|
||||
except Exception as e:
|
||||
logger.debug("active_qty 조회 실패 %s/%s: %s", strategy_id, code, e)
|
||||
return 0
|
||||
|
||||
self.db.close_trade(
|
||||
code=req.code,
|
||||
sell_price=sell_price,
|
||||
sell_reason=req.reason or "",
|
||||
strategy=req.strategy_id,
|
||||
realized_pnl_override=realized_pnl,
|
||||
)
|
||||
# 매수~매도 구간 1분봉 REST 백필 (백테 봉구멍·슬롯 좀비 방지) — 비동기 1회
|
||||
if buy_date_for_bf:
|
||||
try:
|
||||
from kis_trader.engine.post_sell_candle_backfill import (
|
||||
schedule_post_sell_backfill,
|
||||
)
|
||||
schedule_post_sell_backfill(
|
||||
code=req.code,
|
||||
buy_date=buy_date_for_bf,
|
||||
sell_date=None,
|
||||
strategy=str(req.strategy_id or ""),
|
||||
)
|
||||
except Exception as _bf_e:
|
||||
logger.debug("post-sell candle backfill schedule 스킵: %s", _bf_e)
|
||||
self.invalidate_holdings_cache()
|
||||
if self.cash_ledger is not None:
|
||||
gross = filled_qty * sell_price
|
||||
net = gross * (1.0 - fee_rate - tax_rate)
|
||||
self.cash_ledger.apply_trade_delta(net, source="trade_delta")
|
||||
|
||||
color = LOG_GREEN if (realized_pnl is None or realized_pnl >= 0) else LOG_RED
|
||||
logger.info(
|
||||
"%s💸 [매도체결] [%s] %s %s × %d주 @ %d원 | 사유=%s (ODNO=%s)%s",
|
||||
color, req.strategy_id, req.name, req.code,
|
||||
filled_qty, int(sell_price), req.reason, ord_no, LOG_RESET,
|
||||
)
|
||||
def _seal_partial_sell_order(
|
||||
self, ord_no: str, req: OrderRequest, filled_qty: int,
|
||||
) -> None:
|
||||
"""
|
||||
부분매도 후 주문행을 pending 에서 제외 (qty=체결분 고정).
|
||||
get_pending_sell_order 가 잔량 재주문을 막지 않게 한다.
|
||||
"""
|
||||
if filled_qty <= 0 or not ord_no:
|
||||
return
|
||||
od = datetime.datetime.now().strftime("%Y-%m-%d")
|
||||
try:
|
||||
emoji = "🟢" if (realized_pnl is None or realized_pnl >= 0) else "🔴"
|
||||
pnl_str = f"{realized_pnl:+,.0f}원" if realized_pnl is not None else "-"
|
||||
disp = _strategy_display(req.strategy_id)
|
||||
header = (
|
||||
f"{emoji} **[매도체결:{disp}]** {req.name}({req.code})\n"
|
||||
f"{sell_price:,.0f}원 × {filled_qty}주\n"
|
||||
f"{req.reason} · 수익률 {req.profit_pct*100:+.2f}%\n"
|
||||
f"실현 {pnl_str} · ODNO={ord_no}"
|
||||
with self.db.conn:
|
||||
self.db.conn.execute(
|
||||
"""
|
||||
UPDATE orders
|
||||
SET qty=%s, filled_qty=%s, status='PARTIAL'
|
||||
WHERE ord_no=%s AND strategy_id=%s AND code=%s AND ord_date=%s
|
||||
""",
|
||||
(
|
||||
int(filled_qty),
|
||||
int(filled_qty),
|
||||
ord_no,
|
||||
req.strategy_id,
|
||||
req.code,
|
||||
od,
|
||||
),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"seal_partial_sell_order 실패 ODNO=%s: %s", ord_no, e,
|
||||
)
|
||||
if self.strategy_pnl_provider is not None:
|
||||
try:
|
||||
_sp = self.strategy_pnl_provider(req.strategy_id)
|
||||
if _sp is not None:
|
||||
_spnl, _scnt = _sp
|
||||
header += (
|
||||
f"\n📊 {disp} 당일 {_spnl:+,.0f}원 · 청산 {_scnt}건"
|
||||
)
|
||||
except Exception as _e:
|
||||
logger.debug("strategy_pnl_provider(SELL) 실패: %s", _e)
|
||||
tail = ""
|
||||
if self.asset_line_provider is not None:
|
||||
try:
|
||||
tail = self.asset_line_provider(
|
||||
"SELL",
|
||||
{"realized_pnl": realized_pnl},
|
||||
) or ""
|
||||
except Exception as _e:
|
||||
logger.debug("asset_line_provider(SELL) 실패: %s", _e)
|
||||
msg = header + ("\n\n" + tail if tail else "")
|
||||
msg_mm_strategy(
|
||||
msg,
|
||||
_strategy_mm_channel(req.strategy_id),
|
||||
jitter=False,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return OrderResult(
|
||||
True, ord_no=ord_no,
|
||||
filled_qty=filled_qty, filled_avg_price=sell_price,
|
||||
request=req,
|
||||
)
|
||||
|
||||
def _lookup_fill(
|
||||
self,
|
||||
@@ -841,6 +990,19 @@ class OrderManager:
|
||||
if fill and int(fill.get("filled_qty", 0) or 0) > 0:
|
||||
filled_qty = int(fill["filled_qty"])
|
||||
avg_price = float(fill["avg_price"])
|
||||
# 본매수/본매도 경로와 heartbeat 레이스·동일수량 재폴링 시
|
||||
# finalize(알림·예수금 델타) 중복 방지 — 수량이 늘었을 때만 확정.
|
||||
# (부분체결 20→26 처럼 증가분은 그대로 반영)
|
||||
fresh = self.db.get_order_by_odno(
|
||||
ord_no, strategy_id=req.strategy_id, code=req.code,
|
||||
)
|
||||
if fresh is not None:
|
||||
prev_filled = int(fresh.get("filled_qty") or 0)
|
||||
fresh_st = str(fresh.get("status") or "").upper()
|
||||
if fresh_st == "FILLED" and prev_filled >= order_qty:
|
||||
continue
|
||||
if filled_qty <= prev_filled:
|
||||
continue
|
||||
if side == "BUY":
|
||||
if 0 < filled_qty < order_qty:
|
||||
miss = order_qty - filled_qty
|
||||
@@ -871,10 +1033,16 @@ class OrderManager:
|
||||
# 만료 — 미체결 또는 부분체결 잔량 정리
|
||||
remain = max(0, order_qty - prev_filled)
|
||||
if remain > 0:
|
||||
try:
|
||||
self.client.cancel_order(ord_no, qty=remain)
|
||||
except Exception as e:
|
||||
logger.debug("만료 주문 취소 실패 ord_no=%s: %s", ord_no, e)
|
||||
# 매도 IOC면 잔량 주문은 이미 브로커 취소 — cancel REST 생략
|
||||
skip_cancel = (
|
||||
side == "SELL"
|
||||
and getattr(self.client, "uses_market_sell_ioc", lambda: False)()
|
||||
)
|
||||
if not skip_cancel:
|
||||
try:
|
||||
self.client.cancel_order(ord_no, qty=remain)
|
||||
except Exception as e:
|
||||
logger.debug("만료 주문 취소 실패 ord_no=%s: %s", ord_no, e)
|
||||
if prev_filled <= 0:
|
||||
self.db.update_order_status(
|
||||
ord_no=ord_no, strategy_id=req.strategy_id, code=req.code,
|
||||
@@ -899,6 +1067,21 @@ class OrderManager:
|
||||
LOG_YELLOW, req.name, req.code, ord_no, remain, LOG_RESET,
|
||||
)
|
||||
handled += 1
|
||||
elif side == "SELL" and prev_filled > 0:
|
||||
# 부분매도 만료: pending 해제 후 긴급이면 잔량 재매도
|
||||
self._seal_partial_sell_order(ord_no, req, prev_filled)
|
||||
logger.warning(
|
||||
"%s⏱ [매도부분만료] %s %s ODNO=%s — 체결 %d · 미체결잔량 %d%s",
|
||||
LOG_YELLOW, req.name, req.code, ord_no,
|
||||
prev_filled, remain, LOG_RESET,
|
||||
)
|
||||
handled += 1
|
||||
pos_left = self._active_qty(req.strategy_id, req.code)
|
||||
re_qty = pos_left if pos_left > 0 else remain
|
||||
if re_qty > 0:
|
||||
self._escalate_urgent_sell_market(
|
||||
req, re_qty, tag="만료재손절(부분)",
|
||||
)
|
||||
|
||||
return handled
|
||||
|
||||
@@ -1533,6 +1716,14 @@ class OrderManager:
|
||||
f"손절 ${req.stop_price:.4f} / 목표 ${req.target_price:.4f}\n"
|
||||
f"거래소 {req.exchange or '-'} · ODNO={ord_no}"
|
||||
)
|
||||
if self.strategy_pnl_provider is not None:
|
||||
try:
|
||||
_sp = self.strategy_pnl_provider(req.strategy_id)
|
||||
if _sp is not None:
|
||||
_spnl, _scnt = _sp
|
||||
header += f"\n📊 {disp} 당일 {_spnl:+.2f} · 청산 {_scnt}건"
|
||||
except Exception as _e:
|
||||
logger.debug("strategy_pnl_provider(BUY overseas) 실패: %s", _e)
|
||||
tail = ""
|
||||
if self.asset_line_provider is not None:
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user