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

@@ -42,7 +42,7 @@ for _p in (ROOT, HERE):
from database import TradeDB # noqa: E402
from kis_trader.backtest.backtest_portfolio_common import ( # noqa: E402
merge_param_search_apply_source,
portfolio_env_patch,
strip_portfolio_keys_from_apply_patch,
session_env_patch,
)
@@ -125,73 +125,52 @@ def _patch_from_momentum_merged(m: Dict[str, Any]) -> Dict[str, str]:
patch["MOMENTUM_RSI_MIN"] = str(gi("mom_rsi_min", 50))
patch["MOMENTUM_RSI_MAX"] = str(gi("mom_rsi_max", 80))
patch["SCALP_MOM_RSI_MIN"] = patch["MOMENTUM_RSI_MIN"]
patch["SCALP_MOM_RSI_MAX"] = patch["MOMENTUM_RSI_MAX"]
patch["MOMENTUM_VOL_MULT"] = str(gf("mom_vol_mult", 1.5))
patch["SCALP_MOM_VOL_MULT"] = patch["MOMENTUM_VOL_MULT"]
patch["MOMENTUM_VOL_WIN"] = str(gi("mom_vol_win", 5))
patch["SCALP_MOM_VOL_WIN"] = patch["MOMENTUM_VOL_WIN"]
patch["MOMENTUM_TIME_END_HM"] = str(gi("mom_time_end_hm", 1430))
patch["SCALP_MOM_TIME_END_HM"] = patch["MOMENTUM_TIME_END_HM"]
ts = m.get("time_start_hm")
if ts not in (None, ""):
patch["MOMENTUM_TIME_START"] = str(int(float(ts)))
# ※ 모멘텀 apply 는 MOMENTUM_* 만 기록. SCALP_* 를 덮으면 스캘핑 실매/웹이 오염되고,
# 엔진은 MOMENTUM_COOLDOWN_SEC 등을 읽는데 SCALP_COOLDOWN 만 써서 apply 가 무효가 된다.
sl_r = str(abs(gf("sl_pct", 1.5)) / 100.0)
tp_r = str(abs(gf("tp_pct", 2.5)) / 100.0)
patch["MOMENTUM_STOP_LOSS_PCT"] = sl_r
patch["SCALP_STOP_LOSS_PCT"] = sl_r
patch["MOMENTUM_TAKE_PROFIT_PCT"] = tp_r
patch["SCALP_TAKE_PROFIT_PCT"] = tp_r
patch["SCALP_ATR_UP_MULT"] = str(abs(gf("trail_trigger", 0.7)) / 100.0)
patch["SCALP_ATR_DOWN_MULT"] = str(abs(gf("trail_stop", 0.4)) / 100.0)
patch["SCALP_COOLDOWN_SEC"] = str(int(float(gf("cooldown_min", 10)) * 60))
patch["MOMENTUM_COOLDOWN_SEC"] = str(int(float(gf("cooldown_min", 10)) * 60))
md = str(gi("max_daily", 5))
patch["MOMENTUM_MAX_DAILY"] = md
patch["SCALP_MAX_DAILY"] = md
sm = m.get("slot_money")
if sm not in (None, ""):
sms = str(int(float(sm)))
patch["MOMENTUM_SLOT_MONEY"] = sms
patch["SLOT_MONEY_DEFAULT"] = sms
patch["MOMENTUM_MAX_BUY_AMOUNT"] = sms
patch["MAX_BUY_AMOUNT_PER_STOCK"] = sms
hc = gf("high_chase_thr", 0.96)
ratio_hc = hc if 0 < hc <= 1 else hc / 100.0
sr = str(ratio_hc)
patch["HIGH_CHASE_THR"] = sr
patch["SCALP_HIGH_PRICE_CHASE_THRESHOLD"] = sr
patch["HIGH_PRICE_CHASE_THRESHOLD"] = sr
patch["MOMENTUM_HIGH_CHASE_THR"] = str(ratio_hc)
vchg = str(gf("max_daily_chg", 20.0))
patch["MAX_DAILY_CHG"] = vchg
patch["SCALP_MAX_DAILY_CHANGE_PCT"] = vchg
patch["MAX_DAILY_CHANGE_PCT"] = vchg
patch["MOMENTUM_MAX_DAILY_CHG"] = str(gf("max_daily_chg", 20.0))
mp = str(gf("min_price", 1000))
patch["MOMENTUM_MIN_PRICE"] = mp
patch["SCALP_MIN_PRICE"] = mp
ml = str(int(float(m.get("max_loss_krw") or 200000)))
patch["MOMENTUM_MAX_LOSS_PER_TRADE_KRW"] = ml
patch["SCALP_MAX_LOSS_PER_TRADE_KRW"] = ml
patch["MAX_LOSS_PER_TRADE_KRW"] = ml
mm = str(gf("min_margin", 0.2))
patch["MOMENTUM_MIN_PROFIT_PCT"] = mm
patch["SCALP_MIN_PROFIT_PCT"] = mm
if "use_defense_filters" in m:
patch["SCALP_USE_DEFENSE_FILTERS"] = _env_bool_10(m.get("use_defense_filters"))
patch["MOMENTUM_USE_DEFENSE_FILTERS"] = _env_bool_10(m.get("use_defense_filters"))
if "mom_max_from_open_pct" in m and m.get("mom_max_from_open_pct") not in (None, ""):
patch["MOMENTUM_MAX_FROM_OPEN_PCT"] = str(float(m["mom_max_from_open_pct"]))
@@ -200,32 +179,39 @@ def _patch_from_momentum_merged(m: Dict[str, Any]) -> Dict[str, str]:
x = m.get("tp_max_pct")
if x not in (None, ""):
sr = str(abs(float(x)) / 100.0)
patch["MOMENTUM_TP_MAX_PCT"] = sr
patch["SCALP_TP_MAX_PCT"] = sr
patch["MOMENTUM_TP_MAX_PCT"] = str(abs(float(x)) / 100.0)
x = m.get("shoulder_min_high")
if x not in (None, ""):
sr = str(abs(float(x)) / 100.0)
patch["MOMENTUM_SHOULDER_MIN_HIGH_PCT"] = sr
patch["SCALP_SHOULDER_MIN_HIGH_PCT"] = sr
patch["SHOULDER_MIN_HIGH_PCT"] = sr
patch["MOMENTUM_SHOULDER_MIN_HIGH_PCT"] = str(abs(float(x)) / 100.0)
x = m.get("shoulder_cut_pct")
if x not in (None, ""):
sr = str(abs(float(x)) / 100.0)
patch["MOMENTUM_SHOULDER_CUT_PCT"] = sr
patch["SCALP_SHOULDER_CUT_PCT"] = sr
patch["SHOULDER_CUT_PCT"] = sr
patch["MOMENTUM_SHOULDER_CUT_PCT"] = str(abs(float(x)) / 100.0)
# 전용 트레일(momentum_engine) — UI 퍼센트(0.5) → 엔진 비율(0.005) 저장.
# (기존엔 trail_trigger/stop=SCALP_ATR_*만 기록돼 MOMENTUM_TRAIL_PCT가 0으로 남던 버그 수정)
x = m.get("trail_pct")
# 전용 트레일(momentum_engine) — UI 퍼센트 → MOMENTUM_TRAIL_* 비율.
# trail_pct/arm 우선. 레거시 trail_trigger/stop 만 있으면 과거 Optuna와 같이 OFF(0).
has_trail_pct = "trail_pct" in m and m.get("trail_pct") not in (None, "")
has_trail_arm = "trail_arm_pct" in m and m.get("trail_arm_pct") not in (None, "")
has_legacy_trail = (
("trail_trigger" in m and m.get("trail_trigger") not in (None, ""))
or ("trail_stop" in m and m.get("trail_stop") not in (None, ""))
)
if has_trail_pct:
patch["MOMENTUM_TRAIL_PCT"] = str(abs(float(m["trail_pct"])) / 100.0)
elif has_legacy_trail and not has_trail_arm:
patch["MOMENTUM_TRAIL_PCT"] = "0"
if has_trail_arm:
patch["MOMENTUM_TRAIL_ARM_PCT"] = str(abs(float(m["trail_arm_pct"])) / 100.0)
elif has_legacy_trail and not has_trail_pct:
patch["MOMENTUM_TRAIL_ARM_PCT"] = "0"
# 래칫·최대보유 — exit Optuna 1위 적용 시 누락되지 않도록 (BREAKOUT_RATCHET_TIERS 와 동일 계열)
if "ratchet_tiers" in m:
patch["MOMENTUM_RATCHET_TIERS"] = str(m.get("ratchet_tiers") or "").strip()
x = m.get("max_hold_bars")
if x not in (None, ""):
patch["MOMENTUM_TRAIL_PCT"] = str(abs(float(x)) / 100.0)
x = m.get("trail_arm_pct")
if x not in (None, ""):
patch["MOMENTUM_TRAIL_ARM_PCT"] = str(abs(float(x)) / 100.0)
patch["MOMENTUM_MAX_HOLD_BARS"] = str(int(float(x)))
if "use_ema_filter" in m:
patch["MOMENTUM_USE_EMA_FILTER"] = _env_bool_10(m.get("use_ema_filter"))
@@ -258,6 +244,9 @@ def _patch_from_momentum_merged(m: Dict[str, Any]) -> Dict[str, str]:
if x not in (None, ""):
patch["MOMENTUM_EMA_SLOW_PERIOD"] = str(int(float(x)))
from kis_trader.engine.orderbook_env import orderbook_params_to_env_patch
patch.update(orderbook_params_to_env_patch("MOMENTUM", m))
return patch
@@ -362,7 +351,8 @@ def _patch_from_breakout_merged(m: Dict[str, Any]) -> Dict[str, str]:
x = gv("max_daily")
if x is not None:
patch["BREAKOUT_MAX_DAILY"] = str(int(float(x)))
# 일일횟수는 포트폴리오성 운영값 — Optuna apply 가 1로 덮지 않음(실매·웹에서 별도)
pass
x = gv("cooldown_min")
if x is not None:
patch["BREAKOUT_COOLDOWN_SEC"] = str(int(float(x) * 60))
@@ -412,6 +402,9 @@ def _patch_from_breakout_merged(m: Dict[str, Any]) -> Dict[str, str]:
if x is not None:
patch["BREAKOUT_EMA_SLOW_PERIOD"] = str(int(float(x)))
from kis_trader.engine.orderbook_env import orderbook_params_to_env_patch
patch.update(orderbook_params_to_env_patch("BREAKOUT", m))
return patch
@@ -584,29 +577,30 @@ def main(argv: Optional[List[str]] = None) -> int:
if strategy == "MOMENTUM":
merged = merge_param_search_apply_source(item, data)
patch = _patch_from_momentum_merged(merged)
patch.update(portfolio_env_patch("MOMENTUM", merged))
patch.update(session_env_patch("MOMENTUM", merged))
patch = strip_portfolio_keys_from_apply_patch(patch, "MOMENTUM")
elif strategy == "BREAKOUT":
merged = merge_param_search_apply_source(item, data)
patch = _patch_from_breakout_merged(merged)
patch.update(portfolio_env_patch("BREAKOUT", merged))
patch.update(session_env_patch("BREAKOUT", merged))
patch = strip_portfolio_keys_from_apply_patch(patch, "BREAKOUT")
elif strategy == "SCALP":
merged = merge_param_search_apply_source(item, data)
ds = item.get("db_snapshot")
if isinstance(ds, dict) and ds:
# 구 JSON db_snapshot 에 포트폴리오(600만 등)가 있어도 apply 시 제외
patch = {str(k): str(v) for k, v in ds.items() if v not in (None, "")}
patch.update(portfolio_env_patch("SCALP", merged))
else:
try:
import param_search_scalping as pss # noqa: WPS433
from kis_trader.backtest import param_search_scalping as pss # noqa: WPS433
patch = pss._params_to_db_snapshot(merged)
except Exception as e:
print(f"❌ SCALP 스냅샷 생성 실패: {e}")
return 9
patch = strip_portfolio_keys_from_apply_patch(patch, "SCALP")
if not patch:
print("❌ 적용할 패치가 비어 있습니다.")