feat: 새로운 안전 규칙 및 최적화 적용을 통한 트레이딩 시스템 개선
변경 사항 (Changes): 구문 오류(Syntax error) 및 토큰 낭비를 방지하기 위해 에이전트 쉘(Agent shell)과 파이썬 코드 스니펫에 다수의 신규 안전 규칙(Safety rules)을 추가함. 스키마 검증 및 적절한 SQL 포맷팅을 보장하기 위해 임시(Ad-hoc) 데이터베이스 쿼리 작성 가이드라인을 도입함. 코드 수정 후 UI 기능이 정상 작동하는지 확인하기 위해, 백테스트 웹 서비스 재시작 및 브라우저 검증에 대한 새로운 규칙을 구현함. 시스템 전반의 무결성(Integrity)을 유지하기 위해 실전 매매(Live trading), 웹 백테스팅, 파라미터 탐색(Parameter searches) 간의 일관성 검사(Consistency checks) 체계를 확립함. 기대 효과 (Impact): 이러한 개선 사항들은 트레이딩 시스템의 견고성(Robustness)과 신뢰성을 향상시키며, 에러 발생을 최소화하고 다양한 시스템 컴포넌트 간의 원활한 상호작용을 보장함.
This commit is contained in:
@@ -26,6 +26,9 @@ kis_trader/execution/order_manager.py — Master Executor
|
||||
``PENDING_SELL_MAX_AGE_SEC`` / ``PENDING_SELL_STOP_MAX_AGE_SEC`` — 미체결 재조회·만료.
|
||||
* ``PENDING_POLL_BATCH_FETCH`` (기본 True): heartbeat ``poll_pending_fills`` 에서
|
||||
당일 체결을 1 REST 로 일괄 조회 (실매 전용 · 백테 무관).
|
||||
* ``DUPLICATE_ORDER_FILL_RECOVERY_ENABLED`` (기본 True): insert 실패(주문DB중복) 시
|
||||
``inquire-daily-ccld`` 1회로 체결 복구 → active_trades 반영.
|
||||
* ``DUPLICATE_ORDER_RECOVERY_WAIT_SEC`` (기본 ORDER_FILL_WAIT_SEC): 중복복구 체결 대기.
|
||||
* ``SELL_PENDING_REORDER_ON_EXPIRE`` — 손절 등 긴급 매도 만료 시 즉시 시장가 재주문.
|
||||
* ``AccountCashLedger`` — kv_store+메모리 예수금. **기존 qty 우선**, 부족할 때만
|
||||
``ORDER_CASH_PCT`` 로 수량 축소 (매수체크 루프에서는 REST 미호출).
|
||||
@@ -43,6 +46,7 @@ from typing import Callable, Dict, Optional
|
||||
|
||||
from ..database.db_manager import TradeDBExt
|
||||
from ..utils.env import get_env_bool, get_env_float, get_env_from_db, get_env_int
|
||||
from ..utils.stock_name import resolve_stock_display_name
|
||||
from ..utils.logger import (
|
||||
LOG_CYAN,
|
||||
LOG_GREEN,
|
||||
@@ -194,6 +198,25 @@ class OrderManager:
|
||||
self._holdings_cache = None
|
||||
self._holdings_cache_ts = 0.0
|
||||
|
||||
def _resolve_order_display_name(self, req: OrderRequest) -> str:
|
||||
"""MM·DB·로그용 종목명 — code=이름이면 DB/잔고에서 보완."""
|
||||
fb = str(req.name or req.code or "").strip()
|
||||
if fb and fb != req.code:
|
||||
return fb
|
||||
try:
|
||||
holdings = self.get_broker_holdings(force=False)
|
||||
except Exception:
|
||||
holdings = None
|
||||
resolved = resolve_stock_display_name(
|
||||
self.db,
|
||||
req.code,
|
||||
fb,
|
||||
holdings_map=holdings,
|
||||
)
|
||||
if resolved and resolved != req.code:
|
||||
req.name = resolved
|
||||
return req.name or req.code
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 체결 검증 (실전 항상 엄격 / 모의는 STRICT_FILL_VERIFY)
|
||||
# ------------------------------------------------------------------
|
||||
@@ -337,7 +360,10 @@ class OrderManager:
|
||||
sell_qty,
|
||||
)
|
||||
if self._strict_fill_required():
|
||||
self.db.update_order_status(ord_no=ord_no, status="PENDING_FILL")
|
||||
self.db.update_order_status(
|
||||
ord_no=ord_no, strategy_id=req.strategy_id, code=req.code,
|
||||
status="PENDING_FILL",
|
||||
)
|
||||
return OrderResult(
|
||||
False, ord_no=ord_no, reason="sell_fill_pending", request=req,
|
||||
)
|
||||
@@ -399,6 +425,100 @@ class OrderManager:
|
||||
except Exception as e:
|
||||
logger.warning("매수 잔량 취소 실패 %s ord_no=%s: %s", code, ord_no, e)
|
||||
|
||||
def _try_recover_duplicate_buy_fill(
|
||||
self,
|
||||
req: OrderRequest,
|
||||
ord_no: str,
|
||||
buy_qty: int,
|
||||
) -> Optional[OrderResult]:
|
||||
"""
|
||||
insert_order 실패(주문DB중복) 시 브로커 체결 1회 조회 → active_trades 복구.
|
||||
DUPLICATE_ORDER_FILL_RECOVERY_ENABLED=false 이면 None (호출부에서 실패 반환).
|
||||
"""
|
||||
if not get_env_bool("DUPLICATE_ORDER_FILL_RECOVERY_ENABLED", True):
|
||||
return None
|
||||
|
||||
from ..utils.strategy_ids import canonical_strategy_id
|
||||
|
||||
sid = canonical_strategy_id(req.strategy_id)
|
||||
existing = self.db.get_order_by_odno(
|
||||
ord_no, strategy_id=req.strategy_id, code=req.code,
|
||||
)
|
||||
if existing:
|
||||
ex_filled = int(existing.get("filled_qty") or 0)
|
||||
ex_status = str(existing.get("status") or "").upper()
|
||||
if ex_filled > 0 and ex_status in ("FILLED", "PARTIAL", "SUBMITTED"):
|
||||
at_qty = 0
|
||||
try:
|
||||
row = self.db.conn.execute(
|
||||
"SELECT current_qty FROM active_trades "
|
||||
"WHERE code=%s AND strategy=%s",
|
||||
(req.code, sid),
|
||||
).fetchone()
|
||||
if row:
|
||||
at_qty = int(float(
|
||||
row.get("current_qty")
|
||||
if isinstance(row, dict)
|
||||
else row[0]
|
||||
) or 0)
|
||||
except Exception as e:
|
||||
logger.debug("체결복구 active_trades 조회 실패 %s: %s", req.code, e)
|
||||
if at_qty > 0:
|
||||
logger.info(
|
||||
"🔄 [체결복구스킵] %s %s — orders/active_trades 이미 반영 (qty=%d)",
|
||||
req.code, ord_no, at_qty,
|
||||
)
|
||||
return OrderResult(
|
||||
True,
|
||||
ord_no=ord_no,
|
||||
filled_qty=ex_filled,
|
||||
filled_avg_price=float(
|
||||
existing.get("filled_avg_price") or req.price_ref
|
||||
),
|
||||
reason="duplicate_already_finalized",
|
||||
request=req,
|
||||
)
|
||||
|
||||
if req.use_limit_buy:
|
||||
wait_sec = float(get_env_int("LIMIT_ORDER_FILL_WAIT_SEC", 1))
|
||||
else:
|
||||
wait_sec = float(get_env_int(
|
||||
"DUPLICATE_ORDER_RECOVERY_WAIT_SEC",
|
||||
get_env_int("ORDER_FILL_WAIT_SEC", 2),
|
||||
))
|
||||
fill = self.client.get_execution_by_odno(
|
||||
ord_no, code=req.code, wait_sec=wait_sec,
|
||||
)
|
||||
if not fill or int(fill.get("filled_qty", 0) or 0) <= 0:
|
||||
logger.warning(
|
||||
"%s⚠️ [체결복구실패] insert 실패 + 브로커 미체결 %s %s ODNO=%s%s",
|
||||
LOG_YELLOW, req.name, req.code, ord_no, LOG_RESET,
|
||||
)
|
||||
return None
|
||||
|
||||
filled_qty = int(fill["filled_qty"])
|
||||
filled_price = float(fill["avg_price"])
|
||||
if 0 < filled_qty < buy_qty:
|
||||
miss = buy_qty - filled_qty
|
||||
logger.warning(
|
||||
"%s⚠️ [체결복구·부분체결] [%s] %s %s: %d/%d주%s",
|
||||
LOG_YELLOW, req.strategy_id, req.name, req.code,
|
||||
filled_qty, buy_qty, LOG_RESET,
|
||||
)
|
||||
self._try_cancel_buy_remainder(
|
||||
ord_no, req.code, miss, use_limit=req.use_limit_buy,
|
||||
)
|
||||
|
||||
logger.warning(
|
||||
"%s🔄 [체결복구] insert 실패했으나 브로커 체결 확인 — "
|
||||
"[%s] %s %s × %d주%s",
|
||||
LOG_CYAN, req.strategy_id, req.name, req.code, filled_qty, LOG_RESET,
|
||||
)
|
||||
return self._finalize_buy_fill(
|
||||
req, ord_no, filled_qty, filled_price, buy_qty,
|
||||
log_tag="체결복구",
|
||||
)
|
||||
|
||||
def _finalize_buy_fill(
|
||||
self,
|
||||
req: OrderRequest,
|
||||
@@ -413,9 +533,13 @@ class OrderManager:
|
||||
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,
|
||||
@@ -496,9 +620,13 @@ class OrderManager:
|
||||
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,
|
||||
@@ -516,6 +644,34 @@ class OrderManager:
|
||||
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_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
|
||||
|
||||
self.db.close_trade(
|
||||
code=req.code,
|
||||
sell_price=sell_price,
|
||||
@@ -523,6 +679,20 @@ class OrderManager:
|
||||
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
|
||||
@@ -692,7 +862,10 @@ class OrderManager:
|
||||
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, status="CANCELLED")
|
||||
self.db.update_order_status(
|
||||
ord_no=ord_no, strategy_id=req.strategy_id, code=req.code,
|
||||
status="CANCELLED",
|
||||
)
|
||||
logger.warning(
|
||||
"%s⏱ [체결만료] %s %s ODNO=%s — 미체결 취소 (%.0fs)%s",
|
||||
LOG_YELLOW, req.name, req.code, ord_no, age, LOG_RESET,
|
||||
@@ -703,7 +876,10 @@ class OrderManager:
|
||||
req, order_qty, tag="만료재손절",
|
||||
)
|
||||
elif side == "BUY" and prev_filled > 0:
|
||||
self.db.update_order_status(ord_no=ord_no, status="PARTIAL")
|
||||
self.db.update_order_status(
|
||||
ord_no=ord_no, strategy_id=req.strategy_id, code=req.code,
|
||||
status="PARTIAL",
|
||||
)
|
||||
logger.warning(
|
||||
"%s⏱ [부분체결만료] %s %s ODNO=%s — 잔량 %d주 취소%s",
|
||||
LOG_YELLOW, req.name, req.code, ord_no, remain, LOG_RESET,
|
||||
@@ -902,6 +1078,11 @@ class OrderManager:
|
||||
"브로커 체결 여부 확인 필요 (active_trades 미반영 가능)%s",
|
||||
LOG_RED, req.strategy_id, req.code, ord_no, LOG_RESET,
|
||||
)
|
||||
recovered = self._try_recover_duplicate_buy_fill(
|
||||
req, ord_no, buy_qty,
|
||||
)
|
||||
if recovered is not None:
|
||||
return recovered
|
||||
return OrderResult(
|
||||
False, ord_no=ord_no, reason="duplicate_order_record", request=req,
|
||||
)
|
||||
@@ -939,7 +1120,10 @@ class OrderManager:
|
||||
request=req,
|
||||
)
|
||||
elif self._strict_fill_required():
|
||||
self.db.update_order_status(ord_no=ord_no, status="PENDING_FILL")
|
||||
self.db.update_order_status(
|
||||
ord_no=ord_no, strategy_id=req.strategy_id, code=req.code,
|
||||
status="PENDING_FILL",
|
||||
)
|
||||
logger.warning(
|
||||
"%s⏳ [매수체결대기] [%s] %s %s ODNO=%s — fill 미확인, heartbeat 재조회%s",
|
||||
LOG_YELLOW, req.strategy_id, req.name, req.code, ord_no, LOG_RESET,
|
||||
@@ -956,6 +1140,8 @@ class OrderManager:
|
||||
filled_price = req.price_ref
|
||||
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="SUBMITTED",
|
||||
@@ -1138,7 +1324,10 @@ class OrderManager:
|
||||
filled_qty = int(fill["filled_qty"])
|
||||
sell_price = float(fill["avg_price"])
|
||||
elif self._strict_fill_required():
|
||||
self.db.update_order_status(ord_no=ord_no, status="PENDING_FILL")
|
||||
self.db.update_order_status(
|
||||
ord_no=ord_no, strategy_id=req.strategy_id, code=req.code,
|
||||
status="PENDING_FILL",
|
||||
)
|
||||
logger.warning(
|
||||
"%s⏳ [매도체결대기] [%s] %s %s ODNO=%s — fill 미확인%s",
|
||||
LOG_YELLOW, req.strategy_id, req.name, req.code, ord_no, LOG_RESET,
|
||||
@@ -1151,7 +1340,8 @@ class OrderManager:
|
||||
sell_price = req.price_ref or req.buy_price
|
||||
filled_qty = sell_qty
|
||||
self.db.update_order_fill(
|
||||
ord_no=ord_no, filled_qty=filled_qty,
|
||||
ord_no=ord_no, strategy_id=req.strategy_id, code=req.code,
|
||||
filled_qty=filled_qty,
|
||||
filled_avg_price=sell_price, status="SUBMITTED",
|
||||
)
|
||||
|
||||
@@ -1181,6 +1371,8 @@ class OrderManager:
|
||||
fill = {"filled_qty": filled_qty, "avg_price": sell_price}
|
||||
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="FILLED" if filled_qty >= sell_qty else "PARTIAL",
|
||||
@@ -1201,7 +1393,10 @@ class OrderManager:
|
||||
|
||||
if filled_qty <= 0 or sell_price <= 0:
|
||||
if self._strict_fill_required():
|
||||
self.db.update_order_status(ord_no=ord_no, status="PENDING_FILL")
|
||||
self.db.update_order_status(
|
||||
ord_no=ord_no, strategy_id=req.strategy_id, code=req.code,
|
||||
status="PENDING_FILL",
|
||||
)
|
||||
return OrderResult(
|
||||
False, ord_no=ord_no, reason="sell_fill_pending", request=req,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user