ls증권 히스토리 구독 넣음
This commit is contained in:
@@ -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"):
|
||||
|
||||
Reference in New Issue
Block a user