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

@@ -8,13 +8,134 @@ from __future__ import annotations
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
def flatten_remaining_portfolio_trades(
portfolio: Dict[str, Dict[str, Any]],
ctx_by_code: Dict[str, Dict[str, Any]],
all_trades: List[Dict[str, Any]],
*,
params: Optional[Dict[str, Any]] = None,
strategy: str = "",
default_reason: str = "bt_flatten",
) -> int:
"""루프 종료 후 미청산 포지션을 마지막 확정봉 종가로 장부에 남긴다.
분봉이 중간에 끊겨 EOD/max_hold 판정이 안 돈 종목(실매는 벽시계로 청산) 정합용.
``eod_enabled`` 이면 sell_reason=``eod``, 아니면 ``bt_flatten``.
"""
reason = str(default_reason or "bt_flatten")
if params is not None and strategy:
try:
from kis_trader.engine.strategy_eod import resolve_strategy_eod_params
eod_on, _eod_hm = resolve_strategy_eod_params(params, strategy)
if eod_on:
reason = "eod"
except Exception:
pass
n = 0
for code in list(portfolio.keys()):
pos = portfolio[code]
ctx = ctx_by_code.get(code)
entry_t = str(pos.get("entry_time") or "")
last = None
if ctx and ctx.get("candles"):
candles = ctx["candles"]
for c in reversed(candles):
ct = str(c.get("candle_time") or "")
if not ct:
continue
if entry_t and ct < entry_t[:12]:
continue
last = c
break
if last is None and candles:
last = candles[-1]
if last is None:
exit_price = float(pos.get("entry_price") or 0)
sell_time = entry_t
else:
exit_price = float(last.get("close") or 0)
sell_time = str(last.get("candle_time") or entry_t)
if exit_price <= 0 or not sell_time:
del portfolio[code]
continue
trade: Dict[str, Any] = {
"code": code,
"buy_time": pos["entry_time"],
"sell_time": sell_time,
"buy_price": pos["entry_price"],
"sell_price": round(exit_price, 2),
"qty": pos.get("qty", 1),
"pnl": 0,
"sell_reason": reason,
"hold_min": 0,
"exit_source": "bt_flatten",
}
if strategy:
trade["strategy"] = strategy
if pos.get("rsi") is not None:
try:
trade["rsi_entry"] = round(float(pos["rsi"]), 1)
except (TypeError, ValueError):
pass
all_trades.append(trade)
if ctx is not None:
day = sell_time[:8]
try:
from kis_trader.engine.scalping_engine import _t2dt
ctx.setdefault("last_exit_dt", {})[day] = _t2dt(sell_time)
except Exception:
pass
del portfolio[code]
n += 1
return n
def load_portfolio_env_row(db: Any = None) -> Dict[str, Any]:
"""웹·실매와 동일 — ``get_latest_env()`` 병합 스냅샷.
``SELECT * FROM env_config`` 만 쓰면 ``config_momentum`` 등의
``*_TOTAL_BUDGET_KRW`` 가 빠져 ``total_budget≤0 → max_stocks×slot``
(예: 동시20×30만=**600만**) 폴백이 난다. Optuna/Grid 공통 금지.
"""
own_db = db is None
if own_db:
from database import TradeDB
db = TradeDB()
try:
latest = db.get_latest_env()
if latest and isinstance(latest.get("snapshot"), dict):
return dict(latest["snapshot"])
row = db.conn.execute(
"SELECT * FROM env_config ORDER BY id DESC LIMIT 1"
).fetchone()
return dict(row) if row else {}
finally:
if own_db:
try:
db.close()
except Exception:
pass
# 전략별 env 키 (첫 매칭 우선)
STRATEGY_PORTFOLIO_KEYS: Dict[str, Dict[str, Tuple[str, ...]]] = {
"TAIL": {
"max_stocks": ("TAIL_MAX_STOCKS",),
"total_budget": ("TAIL_TOTAL_BUDGET_KRW",),
"slot": ("TAIL_SLOT_MONEY",),
"per_stock_cap": ("TAIL_MAX_BUY_AMOUNT",),
"max_stocks": ("TAIL_MAX_STOCKS", "SHORT_MAX_STOCKS"),
"total_budget": ("TAIL_TOTAL_BUDGET_KRW", "SHORT_TOTAL_BUDGET_KRW"),
"slot": ("TAIL_SLOT_MONEY", "SLOT_MONEY_DEFAULT"),
"per_stock_cap": ("TAIL_MAX_BUY_AMOUNT", "SHORT_MAX_BUY_AMOUNT"),
"min_invest_env": ("TAIL_MIN_INVEST_RATIO_OF_SLOT", "MIN_INVEST_RATIO_OF_SLOT"),
},
# 실매 strategy_id=SHORT — TAIL 과 동일 env. 없으면 SCALP 폴백되어 한도 600만이 찍히는 버그 방지.
"SHORT": {
"max_stocks": ("SHORT_MAX_STOCKS", "TAIL_MAX_STOCKS"),
"total_budget": ("SHORT_TOTAL_BUDGET_KRW", "TAIL_TOTAL_BUDGET_KRW"),
"slot": ("TAIL_SLOT_MONEY", "SLOT_MONEY_DEFAULT"),
"per_stock_cap": ("SHORT_MAX_BUY_AMOUNT", "TAIL_MAX_BUY_AMOUNT"),
"min_invest_env": ("TAIL_MIN_INVEST_RATIO_OF_SLOT", "MIN_INVEST_RATIO_OF_SLOT"),
},
"SCALP": {
@@ -232,17 +353,59 @@ _KNOWN_STRATEGY_PREFIXES: Tuple[str, ...] = (
def _is_foreign_strategy_key(key: str, own_prefix: str) -> bool:
"""key 가 '자기 전략 prefix 가 아닌' 다른 전략 전용 키인지. (무prefix 공용키는 False)."""
# SHORT ↔ TAIL 동일 전략군 — 서로의 prefix 는 자기 키로 취급
own_set = {own_prefix}
if own_prefix in ("SHORT_", "TAIL_"):
own_set.update(("SHORT_", "TAIL_"))
for pfx in _KNOWN_STRATEGY_PREFIXES:
if key.startswith(pfx):
return pfx != own_prefix
return pfx not in own_set
return False
def portfolio_env_keys(strategy: str) -> frozenset:
"""파람서치 apply 시 제외할 슬롯·동시보유·총한도·1회상한 env 키 집합.
웹「봇에 설정저장」은 운영 한도를 의도적으로 바꿀 수 있으므로 이 키를 써도 된다.
Optuna/Grid 1등 적용은 slot×max_stocks 로 총한도를 덮어쓰는 사고(예: 120만→600만)를
막기 위해 ``strip_portfolio_keys_from_apply_patch`` 로 제거한다.
"""
strat = (strategy or "").upper()
keys = STRATEGY_PORTFOLIO_KEYS.get(strat)
if not keys:
return frozenset()
own_prefix = strat + "_"
out: set = set()
for group in ("slot", "per_stock_cap", "max_stocks", "total_budget"):
for k in keys.get(group, ()):
if _is_foreign_strategy_key(k, own_prefix):
continue
out.add(k)
return frozenset(out)
def strip_portfolio_keys_from_apply_patch(
patch: Dict[str, str],
strategy: str,
*extra_strategies: str,
) -> Dict[str, str]:
"""파람서치/Optuna apply patch 에서 포트폴리오(슬롯·한도) 키 제거."""
if not patch:
return patch
skip: set = set(portfolio_env_keys(strategy))
for s in extra_strategies:
skip |= set(portfolio_env_keys(s))
if not skip:
return patch
return {k: v for k, v in patch.items() if k not in skip}
def portfolio_env_patch(strategy: str, p: Dict[str, Any]) -> Dict[str, str]:
"""1회투자·동시보유·총한도 → env_config 문자열 patch (웹 save·파라서치 apply 공통).
"""1회투자·동시보유·총한도 → env_config 문자열 patch (웹 save 전용 권장).
※ 쓰기 시 '자기 전략 키 + 공용 무prefix 키' 만 기록한다. 읽기용 fallback 튜플에
섞인 다른 전략 전용 키(SCALP_*, SHORT_* 등)는 제외 → 남의 전략 설정 오염 금지.
※ 파람서치/Optuna apply 경로에서는 호출하지 말 것 — ``strip_portfolio_keys_from_apply_patch`` 참고.
"""
patch: Dict[str, str] = {}
strat = (strategy or "").upper()
@@ -322,10 +485,8 @@ def build_budget_warning(
def _t2dt(candle_time: str) -> datetime:
s = str(candle_time).strip().replace("-", "").replace(":", "").replace(" ", "")
if len(s) >= 14:
return datetime.strptime(s[:14], "%Y%m%d%H%M%S")
return datetime.strptime(s[:12], "%Y%m%d%H%M")
from kis_trader.utils.trade_time import parse_trade_datetime
return parse_trade_datetime(candle_time)
def backtest_slip_pct(params: Optional[Dict[str, Any]]) -> float:
@@ -368,9 +529,21 @@ def vol_capped_qty(qty: int, candle: Optional[Dict[str, Any]], cap_pct: float) -
def _daily_halt_cfg(p: Dict[str, Any]) -> Dict[str, Any]:
"""백테 daily_profit 시뮬용 cfg — params 우선 → env(DB) 폴백. (실매 load_* 와 동일)."""
"""백테 daily_profit 시뮬용 cfg — params 우선 → 전략접두 env → 마스터 env (실매 load_* 와 동일)."""
from kis_trader.utils.env import get_env_from_db, get_env_float
sid = str(p.get("_daily_profit_strategy_id") or p.get("strategy_id") or "").strip().upper()
if sid in ("TAIL", ""):
sid = "SHORT"
pfx = sid # SHORT / MOMENTUM / BREAKOUT / SCALP …
def _env_str(key: str, default: str = "") -> str:
# 전략 서브키 우선 → 마스터 폴백 (실매 load_strategy_profit_target 과 동일)
raw = str(get_env_from_db(f"{pfx}_{key}", "") or "").strip()
if raw != "":
return raw
return str(get_env_from_db(key, default) or default).strip()
def _pf(param_keys: Tuple[str, ...], key_env: str, default: float = 0.0) -> float:
# daily 전용 키(daily_trail_*) 우선 → 파람서치 호환 키 → env 폴백.
# 주의: 꼬리 개별포지션 'trail_arm_pct' 와 충돌 방지 위해 arm_pct 는
@@ -379,15 +552,18 @@ def _daily_halt_cfg(p: Dict[str, Any]) -> Dict[str, Any]:
v = p.get(k)
if v is not None:
return float(v or 0.0)
return float(get_env_float(key_env, default) or 0.0)
try:
return float(_env_str(key_env, str(default)) or default)
except (TypeError, ValueError):
return float(get_env_float(key_env, default) or 0.0)
mode = str(
p.get("daily_profit_mode") or get_env_from_db("DAILY_PROFIT_MODE", "fixed") or "fixed"
p.get("daily_profit_mode") or _env_str("DAILY_PROFIT_MODE", "fixed") or "fixed"
).lower()
# 다단계 tier(우선) — 전용키 daily_trail_tiers → env. 비면 단일 drop 폴백.
tiers = p.get("daily_trail_tiers")
if tiers is None:
tiers = get_env_from_db("DAILY_PROFIT_TRAIL_TIERS", "")
tiers = _env_str("DAILY_PROFIT_TRAIL_TIERS", "")
return {
"enabled": True,
"mode": mode,
@@ -414,14 +590,22 @@ def apply_daily_profit_halt_sim(
고점대비 trail_drop_pct% 되돌림이 처음 발생한 시각 이후의 신규 진입을 차단한다.
(보유분 청산은 그대로 — 실매 buy_allowed 와 동일: 신규매수만 중단.)
명시 게이트 BACKTEST_DAILY_PROFIT_TRAIL(또는 params['_backtest_daily_profit_trail'])
OFF 면 그대로 반환 → 기존 백테 동작 100% 불변.
게이트:
- params['_backtest_daily_profit_trail'] 명시 시 그 값 (웹 손익감시 스위치).
- 미지정이면 실매 ``{pfx}_DAILY_PROFIT_TARGET_ENABLED`` 추종.
"""
p = params or {}
gate = p.get("_backtest_daily_profit_trail")
if gate is None:
from kis_trader.utils.env import get_env_bool
gate = get_env_bool("BACKTEST_DAILY_PROFIT_TRAIL", False)
sid = str(p.get("_daily_profit_strategy_id") or p.get("strategy_id") or "SHORT").strip().upper()
if sid == "TAIL":
sid = "SHORT"
try:
from kis_trader.engine.daily_profit_halt import load_strategy_profit_target
gate = bool(load_strategy_profit_target(sid).get("enabled"))
except Exception:
from kis_trader.utils.env import get_env_bool
gate = get_env_bool(f"{sid}_DAILY_PROFIT_TARGET_ENABLED", False)
if not bool(gate):
return trades