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:
Your Name
2026-08-01 16:19:24 +09:00
parent 7050f788c5
commit cb7e5037a0
30 changed files with 2206 additions and 253 deletions

View File

@@ -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: