ls증권 히스토리 구독 넣음

This commit is contained in:
Your Name
2026-07-30 18:05:07 +09:00
parent 61bec4bd1d
commit 67eab24603
1593 changed files with 135733 additions and 1232 deletions

View File

@@ -34,6 +34,10 @@ kis_trader/execution/order_manager.py — Master Executor
**시장가 보강 전에 지정가 취소 → ODNO 재조회**. 취소 실패·체결 미확인이면 시장가 금지
(PENDING). 지정가+시장가 이중체결로 타전략 몫까지 파는 사고 방지 (전 전략 공통).
* ``SELL_LIMIT_RECHECK_WAIT_SEC`` (기본 1): 취소 후 지정가 ODNO 재조회 대기.
* ``SELL_LIMIT_CANCEL_FAIL_BALANCE_CONFIRM`` (기본 True): 취소 실패 후 ODNO 미확인이어도
브로커 잔고가 0(또는 요청수량만큼 감소)이면 **지정가 매도 체결로 finalize** (유령 금지).
* ``GHOST_PURGE_BLOCK_WHILE_PENDING_SELL`` (기본 True): PENDING 매도 ODNO 있으면
ghost_purge 대신 체결 재조회·잔고확정 청산.
* ``GHOST_PURGE_RECORD_HISTORY`` (기본 True): 유령잔고 삭제 시 trade_history 에
``ghost_purge`` 기록 (미기록 유령 방지).
* ``AccountCashLedger`` — kv_store+메모리 예수금. **기존 qty 우선**, 부족할 때만
@@ -94,6 +98,10 @@ class OrderRequest:
# 매도 시 계산 결과 전달 (로그용)
buy_price: float = 0.0
profit_pct: float = 0.0
# 해외(US) — market="US" 또는 exchange 있으면 해외 주문 경로
market: str = "" # "" | "US" | "KR"
exchange: str = "" # NASD / NYSE / AMEX …
currency: str = "" # "" | "USD"
@dataclass
@@ -959,7 +967,72 @@ class OrderManager:
브로커 0주인데 로컬만 남은 포지션 정리.
DB 삭제(+ 선택적 trade_history) + 전략 holdings.pop 트리거(extra.purge_holdings).
동일 (전략, 종목) 은 GHOST_POSITION_COOLDOWN_SEC 동안 재로그·재API 방지.
PENDING 매도 ODNO 가 있으면 유령이 아니라 **미확정 매도** 로 본다.
→ 체결 재조회 / 잔고확정 finalize (PnL 기록). ghost_purge(0원) 금지.
"""
# ── B: PENDING 매도 있으면 유령정리 금지 → 매도 확정 시도 ──
if get_env_bool("GHOST_PURGE_BLOCK_WHILE_PENDING_SELL", True):
try:
pending = self.db.get_pending_sell_order(req.strategy_id, req.code)
except Exception:
pending = None
if pending:
odno = str(pending.get("ord_no") or "").strip()
order_qty = int(pending.get("qty") or req.qty or 0)
wait_sec = float(get_env_int("SELL_LIMIT_RECHECK_WAIT_SEC", 1))
fill = None
if odno:
try:
fill = self.client.get_execution_by_odno(
odno, code=req.code, wait_sec=wait_sec,
)
except Exception as e:
logger.debug(
"유령경로 체결재조회 실패 %s ODNO=%s: %s",
req.code, odno, e,
)
if fill and int(fill.get("filled_qty", 0) or 0) > 0:
fq = int(fill["filled_qty"])
fp = float(fill["avg_price"])
logger.info(
"%s✅ [PENDING매도→체결확정] [%s] %s %s ODNO=%s × %d @ %.0f "
"(유령정리 대신 정상 매도마감)%s",
LOG_GREEN, req.strategy_id, req.name, req.code,
odno, fq, fp, LOG_RESET,
)
return self._finalize_sell_fill(
req, odno, fq, fp, max(order_qty, fq),
)
# 체결 API 빈손 + 잔고 0 = 이미 팔림 (지정가 체결·취소잔량없음 패턴)
px = float(
(fill or {}).get("avg_price")
or req.price_ref
or req.buy_price
or 0
)
qty = order_qty if order_qty > 0 else int(req.qty or 0)
if px > 0 and qty > 0 and odno:
logger.warning(
"%s✅ [PENDING매도→잔고확정청산] [%s] %s %s ODNO=%s × %d @ %.0f "
"(브로커0·체결API미확인 → 유령금지·정상마감)%s",
LOG_GREEN, req.strategy_id, req.name, req.code,
odno, qty, px, LOG_RESET,
)
return self._finalize_sell_fill(req, odno, qty, px, qty)
logger.warning(
"%s⏸ [유령보류] [%s] %s %s — PENDING 매도 ODNO=%s 존재, "
"체결가 미확보 → ghost_purge 스킵%s",
LOG_YELLOW, req.strategy_id, req.name, req.code,
odno or "-", LOG_RESET,
)
return OrderResult(
False,
reason="pending_sell_block_ghost",
request=req,
ord_no=odno or None,
)
key = (req.strategy_id, req.code)
cooldown_sec = get_env_int("GHOST_POSITION_COOLDOWN_SEC", 300)
now = time.time()
@@ -1010,6 +1083,7 @@ class OrderManager:
sell_qty: int,
filled_so_far: int,
wait_sec: float,
limit_price: float = 0.0,
) -> tuple:
"""
익절 지정가 미확인·부분체결 후 시장가 보강.
@@ -1083,6 +1157,32 @@ class OrderManager:
if not cancel_ok:
# 취소 실패 = 이미 체결·처리 중 가능 → 시장가 금지 (이중매도 방지)
# A: ODNO 미확인이어도 잔고가 이미 0이면 지정가 체결로 finalize
if get_env_bool("SELL_LIMIT_CANCEL_FAIL_BALANCE_CONFIRM", True):
try:
self.invalidate_holdings_cache()
real_map = self.get_broker_holdings(force=True)
real_qty = int(
(real_map.get(req.code) or {}).get("qty", 0) or 0
)
except Exception as e:
real_qty = -1
logger.debug("취소실패 잔고확인 예외 %s: %s", req.code, e)
if real_qty == 0:
px = refill_px if refill_px > 0 else float(
limit_price or req.price_ref or req.buy_price or 0
)
if px > 0:
logger.info(
"%s✅ [익절지정가 잔고확정체결] %s ODNO=%s × %d주 @ %.0f "
"(취소실패·브로커0 → 시장가생략·정상마감)%s",
LOG_GREEN, req.code, ord_no, sell_qty, px, LOG_RESET,
)
return (
{"filled_qty": int(sell_qty), "avg_price": px},
None,
False,
)
logger.warning(
"%s⏸ [익절시장가보류] %s ODNO=%s — 취소실패·체결미확정 → PENDING "
"(시장가 재시도 금지)%s",
@@ -1140,12 +1240,430 @@ class OrderManager:
def place(self, req: OrderRequest) -> OrderResult:
"""전략이 호출하는 유일한 진입점. BUY / SELL 모두 처리."""
side = (req.side or "").upper()
if self._is_overseas_request(req):
if side == "BUY":
return self._place_overseas_buy(req)
if side == "SELL":
return self._place_overseas_sell(req)
return OrderResult(success=False, reason=f"invalid side={req.side}", request=req)
if side == "BUY":
return self._place_buy(req)
if side == "SELL":
return self._place_sell(req)
return OrderResult(success=False, reason=f"invalid side={req.side}", request=req)
@staticmethod
def _is_overseas_request(req: OrderRequest) -> bool:
mkt = str(req.market or "").strip().upper()
if mkt in ("US", "OVERSEAS", "OVRS"):
return True
if str(req.currency or "").strip().upper() == "USD":
return True
if str(req.exchange or "").strip():
return True
sid = str(req.strategy_id or "").upper()
return sid.startswith("US_")
def _overseas_exchange(self, req: OrderRequest) -> str:
ex = str(req.exchange or "").strip().upper()
if ex:
return ex
return str(
get_env_from_db("KIS_OVRS_DEFAULT_EXCG", "NASD") or "NASD"
).strip().upper() or "NASD"
def _place_overseas_buy(self, req: OrderRequest) -> OrderResult:
"""해외 지정가 매수 → orders/active_trades + MM (접수=FILLED 추적)."""
if req.qty <= 0:
return OrderResult(False, reason="qty<=0", request=req)
px = float(req.price_ref or 0)
if px <= 0:
return OrderResult(False, reason="price<=0", request=req)
code = str(req.code or "").strip().upper()
if not code:
return OrderResult(False, reason="empty_code", request=req)
exchange = self._overseas_exchange(req)
slip = abs(float(get_env_float("KIS_OVRS_BUY_LIMIT_SLIPPAGE_PCT", 0.3) or 0))
limit_px = px * (1.0 + slip / 100.0) if slip > 0 else px
with self._lock_for(code):
from kis_trader.utils.api_reject_log import (
mark_order_cooldown,
order_cooldown_remaining,
record_api_reject,
)
rem = order_cooldown_remaining(
side="BUY", code=code, strategy_id=str(req.strategy_id or ""),
)
if rem > 0:
# journal/JSONL 도배 방지 — remain 값은 fingerprint에 넣지 않음
logger.debug(
"⏳ [해외매수] 쿨다운 스킵 [%s] %s remain=%.0fs",
req.strategy_id, code, rem,
)
record_api_reject(
kind="overseas_buy_cooldown_skip",
side="BUY",
code=code,
strategy_id=str(req.strategy_id or ""),
msg_cd="COOLDOWN",
msg1="order_api_skipped",
path="order_manager",
extra={"remain_sec": int(rem)},
)
return OrderResult(
False,
reason="overseas_buy_fail:cooldown",
request=req,
extra={"cooldown_remain_sec": int(rem)},
)
if not hasattr(self.client, "buy_overseas_limit"):
return OrderResult(False, reason="no_overseas_buy", request=req)
ord_no = self.client.buy_overseas_limit(
code, int(req.qty), float(limit_px), exchange=exchange,
)
if not ord_no:
msg_cd = str(getattr(self.client, "_last_order_msg_cd", "") or "")
msg1 = str(getattr(self.client, "_last_order_msg1", "") or "")
logger.warning(
"⚠️ [해외매수실패] [%s] %s qty=%s msg_cd=%s | %s",
req.strategy_id, code, req.qty, msg_cd or "-", (msg1 or "")[:100],
)
until = mark_order_cooldown(
side="BUY",
code=code,
strategy_id=str(req.strategy_id or ""),
msg_cd=msg_cd,
msg1=msg1,
http=500 if str(msg_cd).startswith("HTTP_5") else None,
)
if until:
logger.warning(
"🧊 [해외매수] 영구형 거절 → %.0f초 쿨다운 [%s] %s msg_cd=%s",
max(0.0, until - time.time()),
req.strategy_id, code, msg_cd or "-",
)
return OrderResult(
False,
reason=f"overseas_buy_fail:{msg_cd or 'reject'}",
request=req,
extra={"msg_cd": msg_cd, "msg1": msg1},
)
feats = dict(req.entry_features or {})
feats["exchange"] = exchange
feats["overseas"] = True
req.entry_features = feats
req.market = "US"
req.currency = "USD"
req.exchange = exchange
self.db.insert_order(
ord_no=str(ord_no),
strategy_id=req.strategy_id,
code=code,
name=req.name or code,
side="BUY",
qty=int(req.qty),
price=float(px),
status="SUBMITTED",
msg1="overseas_limit_accept",
)
return self._finalize_overseas_buy_fill(
req, str(ord_no), int(req.qty), float(px), int(req.qty),
)
def _place_overseas_sell(self, req: OrderRequest) -> OrderResult:
"""해외 지정가 매도 → orders + close_trade + MM."""
if req.qty <= 0:
return OrderResult(False, reason="qty<=0", request=req)
px = float(req.price_ref or 0)
if px <= 0:
return OrderResult(False, reason="price<=0", request=req)
code = str(req.code or "").strip().upper()
if not code:
return OrderResult(False, reason="empty_code", request=req)
exchange = self._overseas_exchange(req)
slip = abs(float(get_env_float("KIS_OVRS_SELL_LIMIT_SLIPPAGE_PCT", 0.3) or 0))
limit_px = px * (1.0 - slip / 100.0) if slip > 0 else px
if limit_px <= 0:
limit_px = px
with self._lock_for(code):
from kis_trader.utils.api_reject_log import (
mark_order_cooldown,
order_cooldown_remaining,
record_api_reject,
)
rem = order_cooldown_remaining(
side="SELL", code=code, strategy_id=str(req.strategy_id or ""),
)
if rem > 0:
# journal/JSONL 도배 방지 — remain 값은 fingerprint에 넣지 않음
logger.debug(
"⏳ [해외매도] 쿨다운 스킵 [%s] %s remain=%.0fs",
req.strategy_id, code, rem,
)
record_api_reject(
kind="overseas_sell_cooldown_skip",
side="SELL",
code=code,
strategy_id=str(req.strategy_id or ""),
msg_cd="COOLDOWN",
msg1="order_api_skipped",
path="order_manager",
extra={"remain_sec": int(rem)},
)
return OrderResult(
False,
reason="overseas_sell_fail:cooldown",
request=req,
extra={"cooldown_remain_sec": int(rem)},
)
if not hasattr(self.client, "sell_overseas_limit"):
return OrderResult(False, reason="no_overseas_sell", request=req)
ord_no = self.client.sell_overseas_limit(
code, int(req.qty), float(limit_px), exchange=exchange,
)
if not ord_no:
# 매도는 client 가 _last_sell_msg_* 에 기록 (매수 _last_order_msg_* 와 분리)
msg_cd = str(getattr(self.client, "_last_sell_msg_cd", "") or "")
msg1 = str(getattr(self.client, "_last_sell_msg1", "") or "")
if not msg_cd and not msg1:
msg_cd = str(getattr(self.client, "_last_order_msg_cd", "") or "")
msg1 = str(getattr(self.client, "_last_order_msg1", "") or "")
logger.warning(
"⚠️ [해외매도실패] [%s] %s qty=%s msg_cd=%s | %s",
req.strategy_id, code, req.qty, msg_cd or "-", (msg1 or "")[:100],
)
until = mark_order_cooldown(
side="SELL",
code=code,
strategy_id=str(req.strategy_id or ""),
msg_cd=msg_cd,
msg1=msg1,
http=500 if str(msg_cd).startswith("HTTP_5") else None,
)
if until:
logger.warning(
"🧊 [해외매도] 영구형 거절 → %.0f초 쿨다운 [%s] %s msg_cd=%s",
max(0.0, until - time.time()),
req.strategy_id, code, msg_cd or "-",
)
return OrderResult(
False,
reason=f"overseas_sell_fail:{msg_cd or 'reject'}",
request=req,
extra={"msg_cd": msg_cd, "msg1": msg1},
)
req.market = "US"
req.currency = "USD"
req.exchange = exchange
self.db.insert_order(
ord_no=str(ord_no),
strategy_id=req.strategy_id,
code=code,
name=req.name or code,
side="SELL",
qty=int(req.qty),
price=float(px),
status="SUBMITTED",
msg1="overseas_limit_accept",
)
return self._finalize_overseas_sell_fill(
req, str(ord_no), int(req.qty), float(px), int(req.qty),
)
def _finalize_overseas_buy_fill(
self,
req: OrderRequest,
ord_no: str,
filled_qty: int,
filled_price: float,
order_qty: int,
*,
log_tag: str = "해외매수",
) -> OrderResult:
"""해외 매수 접수=체결 추적 → active_trades + MM($)."""
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,
)
now_str = dt.now().strftime("%Y-%m-%d %H:%M:%S")
from ..utils.strategy_ids import canonical_strategy_id
feats = dict(req.entry_features or {})
feats.setdefault("overseas", True)
if req.exchange:
feats["exchange"] = req.exchange
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": feats,
})
self.invalidate_holdings_cache()
logger.info(
"%s✅ [%s] [%s] %s %s @ $%.4f × %d (ODNO=%s excg=%s)%s",
LOG_GREEN, log_tag, req.strategy_id, req.name, req.code,
filled_price, filled_qty, ord_no, req.exchange or "-", LOG_RESET,
)
try:
disp = _strategy_display(req.strategy_id)
header = (
f"🔷 **[{log_tag}:{disp}]** {req.name}({req.code})\n"
f"${filled_price:.4f} × {filled_qty}주 = ${filled_price * filled_qty:.2f}\n"
f"손절 ${req.stop_price:.4f} / 목표 ${req.target_price:.4f}\n"
f"거래소 {req.exchange or '-'} · ODNO={ord_no}"
)
tail = ""
if self.asset_line_provider is not None:
try:
tail = self.asset_line_provider("BUY", {"currency": "USD"}) or ""
except Exception as _e:
logger.debug("asset_line_provider(BUY overseas) 실패: %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_overseas_sell_fill(
self,
req: OrderRequest,
ord_no: str,
filled_qty: int,
sell_price: float,
order_qty: int,
) -> OrderResult:
"""해외 매도 접수=체결 → close_trade + MM($)."""
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,
)
# US_MOMENTUM_* 우선 (수수료·SEC·환전). 없으면 한투 미국 온라인 기본.
try:
from kis_trader.engine.us_momentum_env_keys import us_momentum_trading_cost_rates
_c = us_momentum_trading_cost_rates()
fee_rate = float(_c["fee_rate"])
tax_rate = float(_c["sell_tax"])
fx_rate = float(_c["fx_fee_rate"])
except Exception:
fee_rate = float(get_env_float("US_MOMENTUM_FEE_RATE", 0.0025) or 0.0025)
tax_rate = float(get_env_float("US_MOMENTUM_SELL_TAX", 0.0000206) or 0.0000206)
fx_rate = float(get_env_float("US_MOMENTUM_FX_FEE_RATE", 0.0005) or 0.0005)
if fee_rate > 1.0:
fee_rate = fee_rate / 100.0
if tax_rate > 1.0:
tax_rate = tax_rate / 100.0
if fx_rate > 1.0:
fx_rate = fx_rate / 100.0
buy_price = float(req.buy_price or 0)
realized_pnl = None
if buy_price > 0:
buy_amt = buy_price * filled_qty
sell_amt = sell_price * filled_qty
fees = (
buy_amt * fee_rate
+ sell_amt * (fee_rate + tax_rate)
+ (buy_amt + sell_amt) * fx_rate
)
realized_pnl = (sell_amt - buy_amt) - fees
from ..utils.strategy_ids import canonical_strategy_id
sid = canonical_strategy_id(req.strategy_id)
ok_close = False
try:
ok_close = bool(self.db.close_trade(
code=req.code,
sell_price=float(sell_price),
sell_reason=str(req.reason or "overseas"),
strategy=sid,
realized_pnl_override=realized_pnl,
))
except Exception as e:
logger.error("overseas close_trade 실패 %s: %s", req.code, e)
self.invalidate_holdings_cache()
color = LOG_GREEN if (realized_pnl is None or realized_pnl >= 0) else LOG_RED
logger.info(
"%s💸 [해외매도] [%s] %s %s × %d @ $%.4f | 사유=%s (ODNO=%s close=%s)%s",
color, req.strategy_id, req.name, req.code,
filled_qty, sell_price, req.reason, ord_no, ok_close, LOG_RESET,
)
try:
emoji = "🟢" if (realized_pnl is None or realized_pnl >= 0) else "🔴"
pnl_str = f"{realized_pnl:+.2f}$" 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:.4f} × {filled_qty}\n"
f"{req.reason} · 수익률 {req.profit_pct * 100:+.2f}%\n"
f"실현 {pnl_str} · 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(SELL overseas) 실패: %s", _e)
tail = ""
if self.asset_line_provider is not None:
try:
tail = self.asset_line_provider(
"SELL",
{"realized_pnl": realized_pnl, "currency": "USD"},
) or ""
except Exception as _e:
logger.debug("asset_line_provider(SELL overseas) 실패: %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,
extra={"close_ok": ok_close, "realized_pnl": realized_pnl},
)
# ------------------------------------------------------------------
# 매수
# ------------------------------------------------------------------
@@ -1563,6 +2081,7 @@ class OrderManager:
sell_qty=sell_qty,
filled_so_far=filled_so_far,
wait_sec=wait_sec,
limit_price=float(limit_price or 0),
)
if merged and int(merged.get("filled_qty", 0) or 0) > 0:
filled_qty = int(merged["filled_qty"])
@@ -1636,6 +2155,11 @@ class OrderManager:
def _strategy_mm_channel(strategy_id: str) -> str:
"""전략별 MM 채널 alias (config_*). 없으면 MATTERMOST_CHANNEL(통합) 폴백."""
sid = (strategy_id or "").upper()
# US_MOMENTUM 은 MOMENTUM prefix 보다 먼저 (startswith("MOMENTUM")에 안 걸리지만 명시)
if sid.startswith("US_MOMENTUM") or sid == "US_MOMENTUM":
return str(
get_env_from_db("KIS_US_MOMENTUM_MM_CHANNEL", "stock") or "stock"
)
if sid.startswith("SCALP"):
return str(get_env_from_db("KIS_SCALP_MM_CHANNEL", "scalping") or "scalping")
if sid.startswith("SHORT"):