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:
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user