feat: strategy parameter sweep and production param optimization

- Add independent backtest engine (backtester.py) with walk-forward support
- Add backtest sanity check validator (backtest_validator.py)
- Add CLI tools: run_backtest.py, strategy_sweep.py (with --combined mode)
- Fix train-serve skew: unify feature z-score normalization (ml_features.py)
- Add strategy params (SL/TP ATR mult, ADX filter, volume multiplier) to
  config.py, indicators.py, dataset_builder.py, bot.py, backtester.py
- Fix WalkForwardBacktester not propagating strategy params to test folds
- Update production defaults: SL=2.0x, TP=2.0x, ADX=25, Vol=2.5
  (3-symbol combined PF: 0.71 → 1.24, MDD: 65.9% → 17.1%)
- Retrain ML models with new strategy parameters

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
21in7
2026-03-06 23:39:43 +09:00
parent 15fb9c158a
commit 02e41881ac
20 changed files with 2153 additions and 33 deletions

View File

@@ -246,7 +246,7 @@ async def test_process_candle_fetches_oi_and_funding(config, sample_df):
mock_ind.get_signal.return_value = "LONG"
mock_ind_cls.return_value = mock_ind
with patch("src.bot.build_features") as mock_build:
with patch("src.bot.build_features_aligned") as mock_build:
from src.ml_features import FEATURE_COLS
mock_build.return_value = pd.Series({col: 0.0 for col in FEATURE_COLS})
bot.ml_filter.is_model_loaded = MagicMock(return_value=False)

View File

@@ -230,7 +230,7 @@ def signal_producing_df():
def test_hold_negative_labels_are_all_zero(signal_producing_df):
"""HOLD negative 샘플의 label은 전부 0이어야 한다."""
result = generate_dataset_vectorized(signal_producing_df, negative_ratio=3)
result = generate_dataset_vectorized(signal_producing_df, negative_ratio=3, adx_threshold=0, volume_multiplier=1.5)
assert len(result) > 0, "시그널이 발생하지 않아 테스트 불가"
assert "source" in result.columns
hold_neg = result[result["source"] == "hold_negative"]
@@ -241,8 +241,8 @@ def test_hold_negative_labels_are_all_zero(signal_producing_df):
def test_signal_samples_preserved_after_sampling(signal_producing_df):
"""계층적 샘플링 후 source='signal' 샘플이 하나도 버려지지 않아야 한다."""
result_signal_only = generate_dataset_vectorized(signal_producing_df, negative_ratio=0)
result_with_hold = generate_dataset_vectorized(signal_producing_df, negative_ratio=3)
result_signal_only = generate_dataset_vectorized(signal_producing_df, negative_ratio=0, adx_threshold=0, volume_multiplier=1.5)
result_with_hold = generate_dataset_vectorized(signal_producing_df, negative_ratio=3, adx_threshold=0, volume_multiplier=1.5)
assert len(result_signal_only) > 0, "시그널이 발생하지 않아 테스트 불가"
assert "source" in result_with_hold.columns

View File

@@ -54,20 +54,22 @@ def test_adx_column_exists(sample_df):
assert (valid >= 0).all()
def test_adx_low_does_not_block_signal(sample_df):
"""ADX < 25여도 시그널이 차단되지 않는다 (ML에 위임)."""
def test_adx_filter_blocks_low_adx(sample_df):
"""ADX < adx_threshold이면 HOLD 반환."""
ind = Indicators(sample_df)
df = ind.calculate_all()
# 강한 LONG 신호가 나오도록 지표 조작
df.loc[df.index[-1], "rsi"] = 20
df.loc[df.index[-2], "macd"] = -1
df.loc[df.index[-2], "macd_signal"] = 0
df.loc[df.index[-1], "macd"] = 1
df.loc[df.index[-1], "macd_signal"] = 0
df.loc[df.index[-1], "volume"] = df.loc[df.index[-1], "vol_ma20"] * 2
df.loc[df.index[-1], "volume"] = df.loc[df.index[-1], "vol_ma20"] * 3
df["adx"] = 15.0
# 기본 adx_threshold=25이므로 ADX=15은 HOLD
signal = ind.get_signal(df)
# ADX 낮아도 지표 조건 충족 시 LONG 반환 (ML이 최종 판단)
assert signal == "HOLD"
# adx_threshold=0이면 ADX 필터 비활성화 → LONG
signal = ind.get_signal(df, adx_threshold=0)
assert signal == "LONG"