Files
kis_bot/kis_trader/engine/post_sell_candle_backfill.py
Your Name fc27e726f9 feat: 새로운 안전 규칙 및 최적화 적용을 통한 트레이딩 시스템 개선
변경 사항 (Changes):

구문 오류(Syntax error) 및 토큰 낭비를 방지하기 위해 에이전트 쉘(Agent shell)과 파이썬 코드 스니펫에 다수의 신규 안전 규칙(Safety rules)을 추가함.

스키마 검증 및 적절한 SQL 포맷팅을 보장하기 위해 임시(Ad-hoc) 데이터베이스 쿼리 작성 가이드라인을 도입함.

코드 수정 후 UI 기능이 정상 작동하는지 확인하기 위해, 백테스트 웹 서비스 재시작 및 브라우저 검증에 대한 새로운 규칙을 구현함.

시스템 전반의 무결성(Integrity)을 유지하기 위해 실전 매매(Live trading), 웹 백테스팅, 파라미터 탐색(Parameter searches) 간의 일관성 검사(Consistency checks) 체계를 확립함.

기대 효과 (Impact):

이러한 개선 사항들은 트레이딩 시스템의 견고성(Robustness)과 신뢰성을 향상시키며, 에러 발생을 최소화하고 다양한 시스템 컴포넌트 간의 원활한 상호작용을 보장함.
2026-07-17 01:09:09 +09:00

418 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
매수~매도(또는 ~now) 구간 1분봉 REST 백필 — 판 뒤 1회 / 보유 중 즉시 백필.
실매는 벽시계로 청산하지만, ws_candles 가 중간에 끊기면 백테가
그 종목 포지션을 오후까지 붙잡아 슬롯이 막힌다.
→ 보유 구간의 분봉을 키움 ka10080 으로 채운다 (DB UPSERT).
- 기본 OFF 아님: ``POST_SELL_CANDLE_BACKFILL`` 기본 true
- 매도 체결 후 백그라운드 1회 (주문 경로 비차단)
- CLI/스크립트로 과거·현재 보유분 즉시 채우기
"""
from __future__ import annotations
import random
import threading
import time
from datetime import datetime
from typing import Any, Dict, List, Optional, Sequence, Tuple
from kis_trader.utils.env import get_env_bool, get_env_float, get_env_int
from kis_trader.utils.logger import get_logger
logger = get_logger("kis_trader.post_sell_candle_backfill")
_INSERT_SQL = """
INSERT INTO ws_candles
(code, timeframe, candle_time, `open`, high, low, close,
volume, rsi_2, rsi_3, rsi_5, is_confirmed, source, updated_at)
VALUES
(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
ON DUPLICATE KEY UPDATE
`open`=VALUES(`open`), high=VALUES(high), low=VALUES(low),
close=VALUES(close),
volume=IF(VALUES(volume) > volume, VALUES(volume), volume),
is_confirmed=1, updated_at=VALUES(updated_at),
source=IF(VALUES(volume) > volume, VALUES(source), source)
"""
def post_sell_candle_backfill_enabled() -> bool:
return get_env_bool("POST_SELL_CANDLE_BACKFILL", True)
def post_sell_candle_rollup_3m_enabled() -> bool:
"""1분 채운 뒤 3분 롤업 UPSERT (꼬리 TF 정합)."""
return get_env_bool("POST_SELL_CANDLE_ROLLUP_3M", True)
def _dt_to_candle_key(raw: Any) -> str:
"""datetime / 'YYYY-MM-DD HH:MM:SS' / 'YYYYMMDDHHMM' → YYYYMMDDHHMM."""
if raw is None:
return ""
if isinstance(raw, datetime):
return raw.strftime("%Y%m%d%H%M")
s = str(raw).strip()
if len(s) >= 12 and s[:12].isdigit():
return s[:12]
try:
return datetime.strptime(s[:19], "%Y-%m-%d %H:%M:%S").strftime("%Y%m%d%H%M")
except ValueError:
try:
return datetime.strptime(s[:16], "%Y-%m-%d %H:%M").strftime("%Y%m%d%H%M")
except ValueError:
return ""
def _bars_needed(start_key: str, end_key: str) -> int:
"""
ka10080 은 **최신→과거** N봉을 주므로, 보유 구간 길이만 요청하면
장초·어제 보유분은 아예 안 내려온다.
→ ``지금(또는 end)에서 start 까지`` 캘린더 일수 × 장중분 여유로 요청.
"""
cap = max(120, int(get_env_int("POST_SELL_CANDLE_MAX_BARS", 1200)))
try:
start = datetime.strptime(start_key[:12], "%Y%m%d%H%M")
end = datetime.strptime(end_key[:12], "%Y%m%d%H%M")
except ValueError:
return min(cap, 500)
if end < start:
start, end = end, start
# REST 기준점 = max(end, now) — 이미 지난 청산도 ‘지금’에서 거슬러 올라감
now = datetime.now()
anchor = end if end > now else now
cal_days = max(1, (anchor.date() - start.date()).days + 1)
# 1영업일 ≈ 390분 + 여유. 주말 포함 캘린더일 보정
n = int(cal_days * 390 * 1.15) + 60
return min(cap, max(150, n))
def load_kiwoom_credentials(db: Any = None) -> Tuple[str, str, bool]:
"""시세(ka10080)용 키움 키 — 기본 **실키** (KIS_MOCK 매매와 분리).
``POST_SELL_CANDLE_FORCE_MOCK=true`` 일 때만 모의 키.
"""
from kis_trader.utils.env import get_env_bool as _geb
from kis_trader.utils.env import get_env_from_db
# 분봉 차트는 시세 → 실키 기본 (주문 모의와 무관)
force_mock = _geb("POST_SELL_CANDLE_FORCE_MOCK", False)
is_mock = bool(force_mock)
if is_mock:
key = str(get_env_from_db("KIWOOM_APP_KEY_MOCK", "") or "").strip()
sec = str(get_env_from_db("KIWOOM_APP_SECRET_MOCK", "") or "").strip()
else:
key = str(get_env_from_db("KIWOOM_APP_KEY_REAL", "") or "").strip()
sec = str(get_env_from_db("KIWOOM_APP_SECRET_REAL", "") or "").strip()
if not key or not sec:
key = str(get_env_from_db("KIWOOM_APP_KEY", "") or "").strip()
sec = str(get_env_from_db("KIWOOM_APP_SECRET", "") or "").strip()
if (not key or not sec) and db is not None:
try:
latest = db.get_latest_env()
snap = (latest or {}).get("snapshot") or {}
if is_mock:
key = str(snap.get("KIWOOM_APP_KEY_MOCK") or key).strip()
sec = str(snap.get("KIWOOM_APP_SECRET_MOCK") or sec).strip()
else:
key = str(snap.get("KIWOOM_APP_KEY_REAL") or key).strip()
sec = str(snap.get("KIWOOM_APP_SECRET_REAL") or sec).strip()
if not key or not sec:
key = str(snap.get("KIWOOM_APP_KEY") or key).strip()
sec = str(snap.get("KIWOOM_APP_SECRET") or sec).strip()
except Exception:
pass
return key, sec, bool(is_mock)
def _upsert_df_rows(db: Any, code: str, tf_min: int, rows: List[Dict[str, Any]]) -> int:
if not rows:
return 0
now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
payload = []
for rec in rows:
try:
ct = str(rec.get("candle_time") or rec.get("time") or "")[:12]
if len(ct) < 12:
continue
payload.append((
code,
int(tf_min),
ct,
float(rec["open"]),
float(rec["high"]),
float(rec["low"]),
float(rec["close"]),
int(float(rec.get("volume") or 0)),
None, None, None,
1,
str(rec.get("source") or "kw_rest")[:10],
now_str,
))
except Exception:
continue
if not payload:
return 0
with db.conn._lock:
db.conn._ensure_connected()
cur = db.conn._conn.cursor()
cur.executemany(_INSERT_SQL, payload)
db.conn._conn.commit()
return len(payload)
def count_confirmed_1m(db: Any, code: str, start_key: str, end_key: str) -> int:
row = db.conn.execute(
"SELECT COUNT(*) AS n FROM ws_candles "
"WHERE timeframe=1 AND code=%s AND candle_time >= %s AND candle_time <= %s "
"AND is_confirmed=1",
(code, start_key[:12], end_key[:12]),
).fetchone()
return int((row or {}).get("n") or 0)
def backfill_hold_window(
db: Any,
code: str,
start_raw: Any,
end_raw: Any,
*,
kiwoom_key: str = "",
kiwoom_secret: str = "",
is_mock: Optional[bool] = None,
rollup_3m: Optional[bool] = None,
) -> Dict[str, Any]:
"""
``[start, end]`` 1분봉을 ka10080 으로 조회해 ws_candles UPSERT.
반환: before/after/upserted/rollup3m/ok/error
"""
code = str(code or "").strip()
start_key = _dt_to_candle_key(start_raw)
end_key = _dt_to_candle_key(end_raw)
out: Dict[str, Any] = {
"code": code,
"start": start_key,
"end": end_key,
"before": 0,
"after": 0,
"upserted": 0,
"rollup3m": 0,
"ok": False,
"error": "",
}
if not code or len(start_key) < 12 or len(end_key) < 12:
out["error"] = "bad_range"
return out
if end_key < start_key:
start_key, end_key = end_key, start_key
out["start"], out["end"] = start_key, end_key
before = count_confirmed_1m(db, code, start_key, end_key)
out["before"] = before
key, sec, mock = kiwoom_key, kiwoom_secret, is_mock
if not key or not sec or mock is None:
k2, s2, m2 = load_kiwoom_credentials(db)
key = key or k2
sec = sec or s2
mock = m2 if mock is None else mock
if not key or not sec:
out["error"] = "no_kiwoom_keys"
return out
n_req = _bars_needed(start_key, end_key)
try:
from kis_trader.ws.kis_ws import get_kiwoom_candles_df
df = get_kiwoom_candles_df(
code, 1, key, sec, is_mock=bool(mock), n=n_req,
)
except Exception as e:
out["error"] = f"ka10080:{e}"
logger.warning("📦 보유구간 백필 REST 실패 %s: %s", code, e)
return out
if df is None or getattr(df, "empty", True):
out["error"] = "empty_df"
out["after"] = before
return out
rows_1m: List[Dict[str, Any]] = []
for _, rec in df.iterrows():
ct = str(rec.get("time") or "")[:12]
if len(ct) < 12 or ct < start_key or ct > end_key:
continue
cl = float(rec.get("close") or 0)
if cl <= 0:
continue
rows_1m.append({
"candle_time": ct,
"open": float(rec.get("open") or cl),
"high": float(rec.get("high") or cl),
"low": float(rec.get("low") or cl),
"close": cl,
"volume": int(float(rec.get("volume") or 0)),
"source": "kw_rest",
})
try:
upserted = _upsert_df_rows(db, code, 1, rows_1m)
except Exception as e:
out["error"] = f"upsert:{e}"
logger.warning("📦 보유구간 백필 UPSERT 실패 %s: %s", code, e)
return out
out["upserted"] = upserted
do_rollup = post_sell_candle_rollup_3m_enabled() if rollup_3m is None else bool(rollup_3m)
if do_rollup and rows_1m:
try:
from kis_trader.engine.candle_rollup import rollup_1m_bars_to_tf
bars3 = rollup_1m_bars_to_tf(rows_1m, 3)
for b in bars3:
b["source"] = "rollup_1m"
out["rollup3m"] = _upsert_df_rows(db, code, 3, bars3)
except Exception as e:
logger.debug("3M 롤업 스킵 %s: %s", code, e)
after = count_confirmed_1m(db, code, start_key, end_key)
out["after"] = after
out["ok"] = True
logger.info(
"📦 보유구간 백필 %s %s~%s | 1M %d%d (upsert %d) 3M+%d",
code, start_key, end_key, before, after, upserted, int(out["rollup3m"]),
)
return out
def schedule_post_sell_backfill(
*,
code: str,
buy_date: Any,
sell_date: Any = None,
strategy: str = "",
) -> None:
"""매도 체결 후 비동기 1회 백필 (주문 스레드 비차단)."""
if not post_sell_candle_backfill_enabled():
return
code = str(code or "").strip()
if not code:
return
end = sell_date or datetime.now()
def _worker() -> None:
try:
# 서버 부하 완충 — 실매 체결 직후 REST 폭주 방지
lo = float(get_env_float("POST_SELL_CANDLE_SLEEP_MIN_SEC", 1.0))
hi = float(get_env_float("POST_SELL_CANDLE_SLEEP_MAX_SEC", 3.0))
if hi < lo:
hi = lo
time.sleep(random.uniform(lo, hi))
from database import TradeDB
db = TradeDB()
try:
backfill_hold_window(db, code, buy_date, end)
finally:
try:
db.close()
except Exception:
pass
except Exception as e:
logger.warning("📦 post-sell 백필 워커 예외 [%s/%s]: %s", strategy, code, e)
threading.Thread(
target=_worker,
name=f"post_sell_candle_{code}",
daemon=True,
).start()
def backfill_trades_from_db(
db: Any,
*,
buy_date_like: str = "2026-07-16%",
strategies: Optional[Sequence[str]] = None,
include_active: bool = True,
active_max_age_days: int = 5,
) -> List[Dict[str, Any]]:
"""
trade_history(청산) + 최근 active_trades(보유) 보유구간 즉시 백필.
``buy_date_like`` — pymysql 바인딩용 (예: '2026-07-16%').
"""
key, sec, mock = load_kiwoom_credentials(db)
results: List[Dict[str, Any]] = []
sleep_lo = float(get_env_float("POST_SELL_CANDLE_SLEEP_MIN_SEC", 1.0))
sleep_hi = float(get_env_float("POST_SELL_CANDLE_SLEEP_MAX_SEC", 3.0))
if sleep_hi < sleep_lo:
sleep_hi = sleep_lo
sql = (
"SELECT code, name, strategy, buy_date, sell_date FROM trade_history "
"WHERE buy_date LIKE %s"
)
params: List[Any] = [buy_date_like]
if strategies:
ph = ",".join(["%s"] * len(strategies))
sql += f" AND strategy IN ({ph})"
params.extend(list(strategies))
sql += " ORDER BY buy_date"
closed = db.conn.execute(sql, tuple(params)).fetchall()
jobs: List[Tuple[str, Any, Any, str]] = []
for r in closed:
d = dict(r)
jobs.append((
str(d.get("code") or ""),
d.get("buy_date"),
d.get("sell_date"),
str(d.get("strategy") or ""),
))
if include_active:
act_sql = (
"SELECT code, name, strategy, buy_date FROM active_trades "
"WHERE buy_date >= DATE_SUB(NOW(), INTERVAL %s DAY)"
)
act_params: List[Any] = [int(active_max_age_days)]
if strategies:
ph = ",".join(["%s"] * len(strategies))
act_sql += f" AND strategy IN ({ph})"
act_params.extend(list(strategies))
for r in db.conn.execute(act_sql, tuple(act_params)).fetchall():
d = dict(r)
jobs.append((
str(d.get("code") or ""),
d.get("buy_date"),
datetime.now(),
str(d.get("strategy") or ""),
))
# 동일 code+구간 중복 제거 (같은 날 재진입은 구간 합치지 않고 각각)
seen = set()
uniq_jobs = []
for code, b, e, sid in jobs:
if not code:
continue
sk = (_dt_to_candle_key(b), _dt_to_candle_key(e), code, sid)
if sk in seen:
continue
seen.add(sk)
uniq_jobs.append((code, b, e, sid))
logger.info(
"📦 즉시 백필 시작: %d건 (closed=%d active포함=%s)",
len(uniq_jobs), len(closed), include_active,
)
for i, (code, b, e, sid) in enumerate(uniq_jobs, 1):
if i > 1:
time.sleep(random.uniform(sleep_lo, sleep_hi))
st = backfill_hold_window(
db, code, b, e,
kiwoom_key=key, kiwoom_secret=sec, is_mock=mock,
)
st["strategy"] = sid
results.append(st)
return results