feat: Add DART strategy and related configurations

ㅇ
Changes:
- Introduced the DART strategy to the trading system, including its configuration and integration into the existing framework.
- Updated the database schema to include DART-specific tables for disclosures and watchlists.
- Enhanced the backtesting and parameter search functionalities to support the DART strategy.
- Implemented new rules for browser verification and API interactions to ensure compliance with the updated DART strategy.

Impact:
- These additions expand the trading capabilities of the system, allowing for more comprehensive analysis and execution of DART-related strategies, while maintaining system integrity and performance.
This commit is contained in:
Your Name
2026-07-21 07:50:24 +09:00
parent 74db49149f
commit 61bec4bd1d
86 changed files with 4811 additions and 297 deletions

View File

@@ -30,6 +30,12 @@ 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`` — 손절 등 긴급 매도 만료 시 즉시 시장가 재주문.
* ``SELL_LIMIT_CANCEL_BEFORE_MARKET_RETRY`` (기본 True): 익절 지정가 미확인/부분체결 시
**시장가 보강 전에 지정가 취소 → ODNO 재조회**. 취소 실패·체결 미확인이면 시장가 금지
(PENDING). 지정가+시장가 이중체결로 타전략 몫까지 파는 사고 방지 (전 전략 공통).
* ``SELL_LIMIT_RECHECK_WAIT_SEC`` (기본 1): 취소 후 지정가 ODNO 재조회 대기.
* ``GHOST_PURGE_RECORD_HISTORY`` (기본 True): 유령잔고 삭제 시 trade_history 에
``ghost_purge`` 기록 (미기록 유령 방지).
* ``AccountCashLedger`` — kv_store+메모리 예수금. **기존 qty 우선**, 부족할 때만
``ORDER_CASH_PCT`` 로 수량 축소 (매수체크 루프에서는 REST 미호출).
"""
@@ -888,10 +894,70 @@ class OrderManager:
return handled
def _record_ghost_purge_history(
self,
code: str,
strategy_id: str,
*,
sell_reason: str = "ghost_purge",
) -> bool:
"""
유령(브로커 0주) 정리 시 trade_history 기록.
실제 매도 체결이 아니므로 realized_pnl=0 (사유=ghost_purge/broker_zero).
"""
if not get_env_bool("GHOST_PURGE_RECORD_HISTORY", True):
self.db.delete_active_trade(code=code, strategy=strategy_id)
return False
try:
from ..utils.strategy_ids import canonical_strategy_id
sid = canonical_strategy_id(strategy_id)
row = None
matched_sid = strategy_id
for try_sid in (sid, strategy_id):
row = self.db.conn.execute(
"SELECT avg_buy_price, current_price FROM active_trades "
"WHERE code=%s AND strategy=%s LIMIT 1",
(code, try_sid),
).fetchone()
if row:
matched_sid = try_sid
break
buy_px = 0.0
mark = 0.0
if row:
d = dict(row)
buy_px = float(d.get("avg_buy_price") or 0)
cur = float(d.get("current_price") or 0)
# 장부용 매도가: 마지막 시세 있으면 사용, 없으면 매수가 (PnL은 0 고정)
mark = cur if cur > 0 else buy_px
if mark <= 0:
mark = buy_px if buy_px > 0 else 1.0
ok = self.db.close_trade(
code=code,
sell_price=mark,
sell_reason=sell_reason,
strategy=matched_sid,
realized_pnl_override=0.0,
)
if not ok:
self.db.delete_active_trade(code=code, strategy=matched_sid)
return bool(ok)
except Exception as e:
logger.warning(
"유령 trade_history 기록 실패 %s [%s]: %s → delete만",
code, strategy_id, e,
)
try:
self.db.delete_active_trade(code=code, strategy=strategy_id)
except Exception:
pass
return False
def _purge_ghost_position(self, req: OrderRequest, log_tag: str) -> OrderResult:
"""
브로커 0주인데 로컬만 남은 포지션 정리.
DB 삭제 + 전략 holdings.pop 트리거(extra.purge_holdings).
DB 삭제(+ 선택적 trade_history) + 전략 holdings.pop 트리거(extra.purge_holdings).
동일 (전략, 종목) 은 GHOST_POSITION_COOLDOWN_SEC 동안 재로그·재API 방지.
"""
key = (req.strategy_id, req.code)
@@ -910,17 +976,24 @@ class OrderManager:
request=req,
)
self._ghost_purged_at[key] = now
reason_tag = (
"ghost_purge:broker_response"
if log_tag == "broker_response"
else "ghost_purge:broker_zero"
)
if log_tag == "broker_response":
logger.warning(
"%s⚠️ [유령잔고응답] [%s] %s %s: 로컬 active_trades 삭제%s",
LOG_YELLOW, req.strategy_id, req.name, req.code, LOG_RESET,
"%s⚠️ [유령잔고응답] [%s] %s %s: 로컬 정리 (%s)%s",
LOG_YELLOW, req.strategy_id, req.name, req.code, reason_tag, LOG_RESET,
)
else:
logger.warning(
"%s⚠️ [유령잔고정리] [%s] %s %s: 브로커 0주 → 로컬 active_trades 삭제%s",
LOG_YELLOW, req.strategy_id, req.name, req.code, LOG_RESET,
"%s⚠️ [유령잔고정리] [%s] %s %s: 브로커 0주 → 로컬 정리 (%s)%s",
LOG_YELLOW, req.strategy_id, req.name, req.code, reason_tag, LOG_RESET,
)
self.db.delete_active_trade(code=req.code, strategy=req.strategy_id)
self._record_ghost_purge_history(
req.code, req.strategy_id, sell_reason=reason_tag,
)
self.invalidate_holdings_cache()
return OrderResult(
False,
@@ -929,6 +1002,138 @@ class OrderManager:
extra={"purge_holdings": True},
)
def _after_limit_sell_need_market(
self,
*,
req: OrderRequest,
ord_no: str,
sell_qty: int,
filled_so_far: int,
wait_sec: float,
) -> tuple:
"""
익절 지정가 미확인·부분체결 후 시장가 보강.
Returns:
(fill_dict|None, market_ord_no|None, pending:bool)
fill_dict: {"filled_qty", "avg_price"} — 지정가(+시장가) 합산 체결
pending=True 이면 호출부가 PENDING_FILL 로 두고 시장가 금지
"""
remain = max(0, int(sell_qty) - int(filled_so_far))
if remain <= 0:
return None, None, False
use_cancel = get_env_bool("SELL_LIMIT_CANCEL_BEFORE_MARKET_RETRY", True)
recheck_sec = float(get_env_int("SELL_LIMIT_RECHECK_WAIT_SEC", 1))
if not use_cancel:
# 레거시: 즉시 시장가 (이중체결 위험 — 기본 OFF 경로)
logger.warning(
"%s⚠️ [익절지정가→시장가] %s 잔여 %d주 (취소가드 OFF)%s",
LOG_YELLOW, req.code, remain, LOG_RESET,
)
mkt_no = self.client.sell_market_order(req.code, remain)
if not mkt_no:
return None, None, False
mfill = self.client.get_execution_by_odno(
mkt_no, code=req.code, wait_sec=wait_sec,
)
return mfill, mkt_no, False
# ── 근본: 잔여 지정가 취소 → ODNO 재조회 → 필요할 때만 시장가 ──
try:
cancel_ok = bool(
self.client.cancel_order(str(ord_no), qty=remain)
)
except Exception as e:
cancel_ok = False
logger.warning(
"익절지정가 취소 예외 %s ODNO=%s: %s", req.code, ord_no, e,
)
logger.info(
"%s🛑 [익절지정가취소] %s ODNO=%s 잔여=%d 결과=%s%s",
LOG_CYAN, req.code, ord_no, remain,
"OK" if cancel_ok else "FAIL", LOG_RESET,
)
# 취소 직전·직후 체결됐을 수 있음 → 같은 ODNO 재조회
refill = self.client.get_execution_by_odno(
ord_no, code=req.code, wait_sec=recheck_sec,
)
refill_qty = int((refill or {}).get("filled_qty", 0) or 0)
refill_px = float((refill or {}).get("avg_price", 0) or 0)
if refill_qty >= sell_qty and refill_px > 0:
logger.info(
"%s✅ [익절지정가 재확인체결] %s ODNO=%s × %d주 @ %.0f — 시장가 생략%s",
LOG_GREEN, req.code, ord_no, refill_qty, refill_px, LOG_RESET,
)
return (
{"filled_qty": refill_qty, "avg_price": refill_px},
None,
False,
)
still_need = max(0, sell_qty - max(filled_so_far, refill_qty))
if still_need <= 0 and refill_qty > 0 and refill_px > 0:
return (
{"filled_qty": refill_qty, "avg_price": refill_px},
None,
False,
)
if not cancel_ok:
# 취소 실패 = 이미 체결·처리 중 가능 → 시장가 금지 (이중매도 방지)
logger.warning(
"%s⏸ [익절시장가보류] %s ODNO=%s — 취소실패·체결미확정 → PENDING "
"(시장가 재시도 금지)%s",
LOG_YELLOW, req.code, ord_no, LOG_RESET,
)
if refill_qty > 0 and refill_px > 0:
return (
{"filled_qty": refill_qty, "avg_price": refill_px},
None,
refill_qty < sell_qty, # 부분만 보이면 pending
)
return None, None, True
# 취소 성공 + 아직 잔여 → 시장가
logger.warning(
"%s⚠️ [익절지정가 미체결→시장가] %s 잔여 %d%s",
LOG_YELLOW, req.code, still_need, LOG_RESET,
)
mkt_no = self.client.sell_market_order(req.code, still_need)
if not mkt_no:
if refill_qty > 0 and refill_px > 0:
return (
{"filled_qty": refill_qty, "avg_price": refill_px},
None,
True,
)
return None, None, True
mfill = self.client.get_execution_by_odno(
mkt_no, code=req.code, wait_sec=wait_sec,
)
if not mfill or int(mfill.get("filled_qty", 0) or 0) <= 0:
if refill_qty > 0 and refill_px > 0:
return (
{"filled_qty": refill_qty, "avg_price": refill_px},
mkt_no,
True,
)
return None, mkt_no, True
add_q = int(mfill["filled_qty"])
add_p = float(mfill["avg_price"])
prev_q = max(int(filled_so_far), int(refill_qty))
prev_p = refill_px if refill_qty > 0 and refill_px > 0 else 0.0
if prev_q > 0 and prev_p > 0:
tot_q = prev_q + add_q
tot_p = (prev_p * prev_q + add_p * add_q) / tot_q if tot_q > 0 else add_p
else:
tot_q = add_q
tot_p = add_p
return {"filled_qty": tot_q, "avg_price": tot_p}, mkt_no, False
# ------------------------------------------------------------------
# 공개 API
# ------------------------------------------------------------------
@@ -1345,51 +1550,46 @@ class OrderManager:
filled_avg_price=sell_price, status="SUBMITTED",
)
# 익절 지정가 미체결 잔량 → 시장가로 잔여 청산 (손절은 처음부터 시장가)
if use_limit and fill and int(fill.get("filled_qty", 0) or 0) < sell_qty:
remain = sell_qty - int(fill["filled_qty"])
logger.warning(
"%s⚠️ [익절지정가 부분체결] %s 잔여 %d주 시장가 보완%s",
LOG_YELLOW, req.code, remain, LOG_RESET,
# 익절 지정가 미확인·부분체결 → 취소 후 재조회, 필요할 때만 시장가
# (지정가 체결 중 시장가 재시도 → 타전략 몫까지 이중매도 방지)
if use_limit and (
not fill
or int(fill.get("filled_qty", 0) or 0) < sell_qty
):
filled_so_far = int((fill or {}).get("filled_qty", 0) or 0)
merged, _mkt_no, pending = self._after_limit_sell_need_market(
req=req,
ord_no=ord_no,
sell_qty=sell_qty,
filled_so_far=filled_so_far,
wait_sec=wait_sec,
)
mkt_no = self.client.sell_market_order(req.code, remain)
if mkt_no:
mfill = self.client.get_execution_by_odno(
mkt_no, code=req.code, wait_sec=wait_sec,
if merged and int(merged.get("filled_qty", 0) or 0) > 0:
filled_qty = int(merged["filled_qty"])
sell_price = float(merged["avg_price"])
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",
)
if mfill:
add_q = int(mfill.get("filled_qty", 0) or 0)
add_p = float(mfill.get("avg_price", 0) or 0)
if add_q > 0 and add_p > 0:
prev_q = int(fill["filled_qty"])
prev_p = float(fill["avg_price"])
filled_qty = prev_q + add_q
sell_price = (
(prev_p * prev_q + add_p * add_q) / filled_qty
if filled_qty > 0 else add_p
)
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",
)
elif use_limit and not fill:
logger.warning(
"%s⚠️ [익절지정가 미확인] %s 시장가 재시도%s",
LOG_YELLOW, req.code, LOG_RESET,
)
ord_no2 = self.client.sell_market_order(req.code, sell_qty)
if ord_no2:
fill = self.client.get_execution_by_odno(
ord_no2, code=req.code, wait_sec=wait_sec,
if pending or filled_qty <= 0 or sell_price <= 0:
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"
"지정가 미확정·시장가보류, heartbeat 재조회%s",
LOG_YELLOW, req.strategy_id, req.name, req.code,
ord_no, LOG_RESET,
)
return OrderResult(
False, ord_no=ord_no, reason="sell_fill_pending", request=req,
)
if fill:
sell_price = float(fill["avg_price"])
filled_qty = int(fill["filled_qty"])
if filled_qty <= 0 or sell_price <= 0:
if self._strict_fill_required():