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:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user