이번에 들어간 내용

한투 호가 = 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:
Your Name
2026-08-19 22:11:31 +09:00
parent d1dc274f0e
commit 0ecac7cb95
229 changed files with 8487 additions and 1647 deletions

View File

@@ -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: