feat: 새로운 안전 규칙 및 최적화 적용을 통한 트레이딩 시스템 개선

변경 사항 (Changes):

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

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

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

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

기대 효과 (Impact):

이러한 개선 사항들은 트레이딩 시스템의 견고성(Robustness)과 신뢰성을 향상시키며, 에러 발생을 최소화하고 다양한 시스템 컴포넌트 간의 원활한 상호작용을 보장함.
This commit is contained in:
Your Name
2026-07-17 01:09:09 +09:00
parent a4626e0351
commit fc27e726f9
151 changed files with 20718 additions and 6450 deletions

View File

@@ -0,0 +1,417 @@
#!/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