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:
Your Name
2026-07-17 01:09:09 +09:00
parent a4626e0351
commit fc27e726f9
151 changed files with 20718 additions and 6450 deletions

View File

@@ -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,
)