이번에 들어간 내용
한투 호가 = 2번째 앱키 전용 키 없거나 start 실패 시 메인에 H0STASP0 안 붙임. 운영설정 WS_ORDERBOOK_SAVE_KIS 빨간 danger. LS RAM 합집합 후보∪보유∪영구∪grace. sync_targets와 split reconcile 둘 다. 틱 DB 영구 게이트는 그대로. 분봉 쓰레기 → 다음 소스 봉 통째 그 분 틱 0건이거나 전부 봉끝 대비 LIVE_FEED_FALLBACK_MAX_AGE_SEC 초과면 구멍. 메인 WS → 2차 → LS → REST → rollup. CANDLE_GARBAGE_FALLBACK 기본 true. 파일: feed_fallback.py(신규), ws_manager.py, kis_ws.py, candle_series.py, bt_candle_source.py, live_config_schema.py, database.py, 스모크, MD 2개. 같은 ws_manager/database/kis_ws/live_config에는 직전 커밋 이후 쌓여 있던 시세 폴백·ENV 키 정리도 같이 들어갔습니다. 파일 단위로 나눌 수 없어서입니다.
This commit is contained in:
@@ -8,7 +8,8 @@ SafeRequest 기반으로 통합. 두 봇이 독립 토큰을 발급받아 충돌
|
||||
제공 기능 (매매 봇이 쓰는 최소 세트):
|
||||
- inquire_price : 현재가
|
||||
- get_account_balance : 계좌 잔고 (output1=종목별, output2=예수금)
|
||||
- get_broker_holdings_map : 잔고 → {code: {qty, avg_price, name}} 맵
|
||||
- get_broker_holdings_map : 잔고 → {code: {qty=보유, hldg_qty, ord_psbl_qty=매도가능, ...}}
|
||||
- get_cancelable_sell_map : 정정취소가능주문조회 → {code: {psbl_qty, odno, ...}}
|
||||
- get_minute_chart : 분봉 (갭보정용)
|
||||
- get_daily_chart : 일봉 (대/중/소형주 판정용)
|
||||
- get_orderbook : 호가 잔량 (익절 지정가용)
|
||||
@@ -40,6 +41,101 @@ logger = get_logger("kis_trader.kis_client")
|
||||
_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
|
||||
|
||||
def parse_kis_int_qty(v) -> int:
|
||||
"""한투 잔고/주문 수량 문자열 → int. 콤마·공백·빈값 안전."""
|
||||
try:
|
||||
s = str(v if v is not None else "0").replace(",", "").strip()
|
||||
if not s:
|
||||
return 0
|
||||
return int(float(s))
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def holdings_row_from_balance_item(it: dict) -> Optional[Dict]:
|
||||
"""
|
||||
inquire-balance output1 한 행 → 보유/매도가능 분리.
|
||||
|
||||
- hldg_qty : 보유수량 (아직 내 주식)
|
||||
- ord_psbl_qty : 주문가능수량 = 매도가능 (미체결 매도가 잠그면 0)
|
||||
- qty : 하위호환 = 보유수량 (매도가능과 섞지 않음)
|
||||
|
||||
둘 다 0이면 None (맵에서 제외). 매도가능만 0이고 보유>0 이면 행을 남긴다.
|
||||
"""
|
||||
if not isinstance(it, dict):
|
||||
return None
|
||||
code = str(it.get("pdno") or it.get("PDNO") or "").strip()
|
||||
if not code:
|
||||
return None
|
||||
hldg = parse_kis_int_qty(it.get("hldg_qty") or it.get("HLDG_QTY"))
|
||||
psbl = parse_kis_int_qty(it.get("ord_psbl_qty") or it.get("ORD_PSBL_QTY"))
|
||||
if hldg <= 0 and psbl <= 0:
|
||||
return None
|
||||
avg_raw = it.get("pchs_avg_pric") or it.get("PCHS_AVG_PRIC") or 0
|
||||
try:
|
||||
avg_price = abs(float(str(avg_raw).replace(",", "")))
|
||||
except Exception:
|
||||
avg_price = 0.0
|
||||
name = (it.get("prdt_name") or it.get("PRDT_NAME") or code).strip()
|
||||
evlu_raw = it.get("evlu_amt") or it.get("EVLU_AMT") or 0
|
||||
try:
|
||||
evlu_amt = float(str(evlu_raw).replace(",", ""))
|
||||
except Exception:
|
||||
evlu_amt = 0.0
|
||||
prpr_raw = it.get("prpr") or it.get("PRPR") or 0
|
||||
try:
|
||||
current_price = abs(float(str(prpr_raw).replace(",", "")))
|
||||
except Exception:
|
||||
current_price = 0.0
|
||||
thdt_sll = parse_kis_int_qty(it.get("thdt_sll_qty") or it.get("THDT_SLL_QTY"))
|
||||
thdt_buy = parse_kis_int_qty(it.get("thdt_buyqty") or it.get("THDT_BUYQTY"))
|
||||
return {
|
||||
"qty": hldg,
|
||||
"hldg_qty": hldg,
|
||||
"ord_psbl_qty": psbl,
|
||||
"sellable_qty": psbl,
|
||||
"thdt_sll_qty": thdt_sll,
|
||||
"thdt_buyqty": thdt_buy,
|
||||
"avg_price": avg_price,
|
||||
"name": name,
|
||||
"evlu_amt": evlu_amt,
|
||||
"current_price": current_price,
|
||||
}
|
||||
|
||||
|
||||
def broker_row_hldg_qty(row: Optional[dict]) -> int:
|
||||
"""잔고 맵 행의 보유수량. hldg_qty 우선, 없으면 qty."""
|
||||
if not row:
|
||||
return 0
|
||||
if "hldg_qty" in row:
|
||||
return parse_kis_int_qty(row.get("hldg_qty"))
|
||||
return parse_kis_int_qty(row.get("qty"))
|
||||
|
||||
|
||||
def broker_row_sellable_qty(row: Optional[dict]) -> int:
|
||||
"""잔고 맵 행의 매도가능수량. 키 없으면 보유수량(구맵 호환)."""
|
||||
if not row:
|
||||
return 0
|
||||
if "ord_psbl_qty" in row:
|
||||
return parse_kis_int_qty(row.get("ord_psbl_qty"))
|
||||
if "sellable_qty" in row:
|
||||
return parse_kis_int_qty(row.get("sellable_qty"))
|
||||
return broker_row_hldg_qty(row)
|
||||
|
||||
|
||||
def broker_row_still_held(row: Optional[dict]) -> bool:
|
||||
"""보유>0 또는 매도가능>0 이면 계좌에 아직 주식이 있다."""
|
||||
return broker_row_hldg_qty(row) > 0 or broker_row_sellable_qty(row) > 0
|
||||
|
||||
|
||||
def cancelable_remainder_qty(cmap: Optional[Dict[str, Dict]], code: str) -> int:
|
||||
"""정정취소가능 맵에서 해당 종목 잔량. cmap is None 이면 0 (호출부가 API실패를 먼저 가릴 것)."""
|
||||
if not cmap or not code:
|
||||
return 0
|
||||
row = cmap.get(str(code).strip()) or {}
|
||||
return parse_kis_int_qty(row.get("psbl_qty"))
|
||||
|
||||
|
||||
class KISClient(SafeRequest):
|
||||
"""한국투자증권 REST API 통합 클라이언트. SafeRequest 상속."""
|
||||
|
||||
@@ -102,6 +198,8 @@ class KISClient(SafeRequest):
|
||||
self._last_sell_msg_cd: Optional[str] = None
|
||||
self._last_sell_msg1: Optional[str] = None
|
||||
self._inquire_price_cache: dict = {}
|
||||
# 같은 요청에서 holdings_map + 계좌요약이 inquire-balance 를 두 번 치지 않게
|
||||
self._last_inquire_balance: Optional[dict] = None
|
||||
|
||||
self._init_token()
|
||||
|
||||
@@ -717,6 +815,7 @@ class KISClient(SafeRequest):
|
||||
if merged is None:
|
||||
return None
|
||||
merged["output1"] = all_output1 # 누적 병합본으로 교체 (단일 페이지면 동일)
|
||||
self._last_inquire_balance = merged
|
||||
return merged
|
||||
except Exception as e:
|
||||
logger.error("계좌 잔고 조회 실패: %s", e)
|
||||
@@ -724,8 +823,9 @@ class KISClient(SafeRequest):
|
||||
|
||||
def get_broker_holdings_map(self) -> Optional[Dict[str, Dict]]:
|
||||
"""
|
||||
잔고 API output1 → {code: {qty, avg_price, name, evlu_amt}} 맵 변환.
|
||||
잔고 API output1 → {code: {qty=보유, hldg_qty, ord_psbl_qty=매도가능, ...}}.
|
||||
** 모든 매도 주문 직전 이 맵으로 실제 보유 수량을 검증해야 한다. **
|
||||
매도주문 중이면 매도가능=0 이 정상 — 보유와 섞어 0으로 만들지 않는다.
|
||||
API 실패 시 None (빈 dict 와 구분 — 유령잔고 오판 방지).
|
||||
"""
|
||||
balance = self.get_account_balance()
|
||||
@@ -736,42 +836,193 @@ class KISClient(SafeRequest):
|
||||
output1 = [output1]
|
||||
result: Dict[str, Dict] = {}
|
||||
for it in output1:
|
||||
code = (it.get("pdno") or it.get("PDNO") or "").strip()
|
||||
row = holdings_row_from_balance_item(it)
|
||||
if not row:
|
||||
continue
|
||||
code = str(it.get("pdno") or it.get("PDNO") or "").strip()
|
||||
result[code] = row
|
||||
return result
|
||||
|
||||
def get_cancelable_sell_map(self) -> Optional[Dict[str, Dict]]:
|
||||
"""
|
||||
주식정정취소가능주문조회 [국내주식-004] inquire-psbl-rvsecncl.
|
||||
|
||||
※ 공식 스펙: **실전 전용** (모의는 '없는 서비스 코드').
|
||||
모의·미지원이면 당일 주문체결(inquire-daily-ccld) 미체결 매도로 대체.
|
||||
반환: {code: {psbl_qty, odno, ord_qty, tot_ccld_qty, name}}
|
||||
API 실패 시 None. 미체결 없으면 빈 dict.
|
||||
"""
|
||||
if self.mock:
|
||||
logger.info(
|
||||
"정정취소가능조회는 모의 미지원 → 당일주문체결 미체결 매도로 대체"
|
||||
)
|
||||
return self._open_sell_map_from_daily_ccld()
|
||||
default_tr = str(
|
||||
get_env_from_db("PSBL_RVSECNCL_TR_ID", "TTTC0084R") or "TTTC0084R"
|
||||
).strip() or "TTTC0084R"
|
||||
inqr1 = str(
|
||||
get_env_from_db("PSBL_RVSECNCL_INQR_DVSN_1", "0") or "0"
|
||||
).strip() or "0"
|
||||
inqr2 = str(
|
||||
get_env_from_db("PSBL_RVSECNCL_INQR_DVSN_2", "1") or "1"
|
||||
).strip() or "1"
|
||||
max_pages = max(1, int(get_env_int("PSBL_RVSECNCL_MAX_PAGES", 5)))
|
||||
path = "/uapi/domestic-stock/v1/trading/inquire-psbl-rvsecncl"
|
||||
merged: List[dict] = []
|
||||
fk100, nk100, tr_cont = "", "", ""
|
||||
last_ok = False
|
||||
try:
|
||||
import time as _t
|
||||
for page in range(1, max_pages + 1):
|
||||
r = self._get(
|
||||
path,
|
||||
default_tr,
|
||||
{
|
||||
"CANO": self.account_no,
|
||||
"ACNT_PRDT_CD": self.account_code,
|
||||
"INQR_DVSN_1": inqr1,
|
||||
"INQR_DVSN_2": inqr2,
|
||||
"CTX_AREA_FK100": fk100,
|
||||
"CTX_AREA_NK100": nk100,
|
||||
},
|
||||
tr_cont=tr_cont,
|
||||
)
|
||||
if r is None or r.status_code != 200:
|
||||
if page == 1:
|
||||
logger.warning(
|
||||
"정정취소가능조회 HTTP 실패 → 당일체결 미체결로 대체"
|
||||
)
|
||||
return self._open_sell_map_from_daily_ccld()
|
||||
logger.warning(
|
||||
"정정취소가능 연속조회 %d페이지 HTTP %s → 부분 사용",
|
||||
page, getattr(r, "status_code", "?"),
|
||||
)
|
||||
break
|
||||
j = r.json() if r is not None else {}
|
||||
if j.get("rt_cd") != "0":
|
||||
msg1 = str(j.get("msg1") or "")
|
||||
if page == 1:
|
||||
logger.warning(
|
||||
"정정취소가능조회 실패 rt_cd=%s msg=%s → 당일체결 미체결로 대체",
|
||||
j.get("rt_cd"), msg1,
|
||||
)
|
||||
return self._open_sell_map_from_daily_ccld()
|
||||
logger.warning(
|
||||
"정정취소가능 연속조회 %d페이지 rt_cd=%s → 부분 사용",
|
||||
page, j.get("rt_cd"),
|
||||
)
|
||||
break
|
||||
last_ok = True
|
||||
chunk = j.get("output") or j.get("output1") or []
|
||||
if isinstance(chunk, dict):
|
||||
chunk = [chunk]
|
||||
if isinstance(chunk, list):
|
||||
merged.extend(chunk)
|
||||
hdr_cont = ""
|
||||
try:
|
||||
hdr_cont = str(r.headers.get("tr_cont") or "").strip().upper()
|
||||
except Exception:
|
||||
hdr_cont = ""
|
||||
body_fk = str(j.get("ctx_area_fk100") or "").strip()
|
||||
body_nk = str(j.get("ctx_area_nk100") or "").strip()
|
||||
if hdr_cont not in ("M", "F") or not body_nk:
|
||||
break
|
||||
fk100 = body_fk
|
||||
nk100 = body_nk
|
||||
tr_cont = "N"
|
||||
_t.sleep(max(0.05, float(get_env_float("PSBL_RVSECNCL_PAGE_GAP_SEC", 0.08))))
|
||||
if not last_ok:
|
||||
return self._open_sell_map_from_daily_ccld()
|
||||
except Exception as e:
|
||||
logger.warning("정정취소가능조회 예외: %s → 당일체결 미체결로 대체", e)
|
||||
return self._open_sell_map_from_daily_ccld()
|
||||
|
||||
result: Dict[str, Dict] = {}
|
||||
for it in merged:
|
||||
if not isinstance(it, dict):
|
||||
continue
|
||||
code = str(it.get("pdno") or it.get("PDNO") or "").strip()
|
||||
if not code:
|
||||
continue
|
||||
qty_raw = (
|
||||
it.get("hldg_qty") or it.get("HLDG_QTY")
|
||||
or it.get("ord_psbl_qty") or it.get("ORD_PSBL_QTY") or 0
|
||||
)
|
||||
try:
|
||||
qty = int(float(str(qty_raw).replace(",", "")))
|
||||
except Exception:
|
||||
qty = 0
|
||||
if qty <= 0:
|
||||
psbl = parse_kis_int_qty(it.get("psbl_qty") or it.get("PSBL_QTY"))
|
||||
if psbl <= 0:
|
||||
continue
|
||||
avg_raw = it.get("pchs_avg_pric") or it.get("PCHS_AVG_PRIC") or 0
|
||||
try:
|
||||
avg_price = abs(float(str(avg_raw).replace(",", "")))
|
||||
except Exception:
|
||||
avg_price = 0.0
|
||||
name = (it.get("prdt_name") or it.get("PRDT_NAME") or code).strip()
|
||||
evlu_raw = it.get("evlu_amt") or it.get("EVLU_AMT") or 0
|
||||
try:
|
||||
evlu_amt = float(str(evlu_raw).replace(",", ""))
|
||||
except Exception:
|
||||
evlu_amt = 0.0
|
||||
prpr_raw = it.get("prpr") or it.get("PRPR") or 0
|
||||
try:
|
||||
current_price = abs(float(str(prpr_raw).replace(",", "")))
|
||||
except Exception:
|
||||
current_price = 0.0
|
||||
result[code] = {
|
||||
"qty": qty,
|
||||
"avg_price": avg_price,
|
||||
"name": name,
|
||||
"evlu_amt": evlu_amt,
|
||||
"current_price": current_price,
|
||||
}
|
||||
odno = str(it.get("odno") or it.get("ODNO") or "").strip()
|
||||
name = str(it.get("prdt_name") or it.get("PRDT_NAME") or code).strip()
|
||||
ord_qty = parse_kis_int_qty(it.get("ord_qty") or it.get("ORD_QTY"))
|
||||
ccld = parse_kis_int_qty(
|
||||
it.get("tot_ccld_qty") or it.get("TOT_CCLD_QTY")
|
||||
)
|
||||
prev = result.get(code)
|
||||
if prev:
|
||||
prev["psbl_qty"] = int(prev.get("psbl_qty") or 0) + psbl
|
||||
prev["ord_qty"] = int(prev.get("ord_qty") or 0) + ord_qty
|
||||
prev["tot_ccld_qty"] = int(prev.get("tot_ccld_qty") or 0) + ccld
|
||||
if not prev.get("odno") and odno:
|
||||
prev["odno"] = odno
|
||||
else:
|
||||
result[code] = {
|
||||
"psbl_qty": psbl,
|
||||
"odno": odno,
|
||||
"ord_qty": ord_qty,
|
||||
"tot_ccld_qty": ccld,
|
||||
"name": name,
|
||||
}
|
||||
return result
|
||||
|
||||
def _open_sell_map_from_daily_ccld(self) -> Optional[Dict[str, Dict]]:
|
||||
"""모의·정정취소가능 미지원 시: 당일 주문체결에서 미체결 매도 잔량."""
|
||||
j = self.get_order_history_today(odno="")
|
||||
if j is None:
|
||||
return None
|
||||
out1 = j.get("output1") or []
|
||||
if isinstance(out1, dict):
|
||||
out1 = [out1]
|
||||
result: Dict[str, Dict] = {}
|
||||
for row in out1:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
side = str(
|
||||
row.get("sll_buy_dvsn_cd") or row.get("SLL_BUY_DVSN_CD") or ""
|
||||
).strip()
|
||||
# 01=매도, 02=매수 (한자리 1 도 허용)
|
||||
if side not in ("01", "1"):
|
||||
continue
|
||||
cncl = str(
|
||||
row.get("cncl_yn") or row.get("CNCL_YN") or ""
|
||||
).strip().upper()
|
||||
if cncl in ("Y", "1"):
|
||||
continue
|
||||
code = str(row.get("pdno") or row.get("PDNO") or "").strip()
|
||||
if not code:
|
||||
continue
|
||||
ord_qty = parse_kis_int_qty(row.get("ord_qty") or row.get("ORD_QTY"))
|
||||
ccld = parse_kis_int_qty(
|
||||
row.get("tot_ccld_qty") or row.get("TOT_CCLD_QTY")
|
||||
)
|
||||
remain = parse_kis_int_qty(row.get("rmn_qty") or row.get("RMN_QTY"))
|
||||
if remain <= 0:
|
||||
remain = max(0, ord_qty - ccld)
|
||||
if remain <= 0:
|
||||
continue
|
||||
odno = str(row.get("odno") or row.get("ODNO") or "").strip()
|
||||
name = str(row.get("prdt_name") or row.get("PRDT_NAME") or code).strip()
|
||||
prev = result.get(code)
|
||||
if prev:
|
||||
prev["psbl_qty"] = int(prev.get("psbl_qty") or 0) + remain
|
||||
prev["ord_qty"] = int(prev.get("ord_qty") or 0) + ord_qty
|
||||
prev["tot_ccld_qty"] = int(prev.get("tot_ccld_qty") or 0) + ccld
|
||||
if not prev.get("odno") and odno:
|
||||
prev["odno"] = odno
|
||||
else:
|
||||
result[code] = {
|
||||
"psbl_qty": remain,
|
||||
"odno": odno,
|
||||
"ord_qty": ord_qty,
|
||||
"tot_ccld_qty": ccld,
|
||||
"name": name,
|
||||
"source": "daily_ccld",
|
||||
}
|
||||
return result
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -1547,6 +1798,9 @@ class KISClient(SafeRequest):
|
||||
"""
|
||||
ODNO 로 당일 체결 조회.
|
||||
반환: {"filled_qty": int, "avg_price": float} 또는 None
|
||||
|
||||
주문유형(01/13)과 무관하게 먼저 ``wait_sec`` 만큼 sleep 한 뒤 REST.
|
||||
틱매도 콜백에서 호출되면 그 WS 수신 스레드가 그대로 멈춘다.
|
||||
"""
|
||||
import time as _t
|
||||
if not ord_no:
|
||||
|
||||
Reference in New Issue
Block a user