feat: add signal score detail to bot logs for HOLD reason debugging

get_signal() now returns (signal, detail) tuple with long/short scores,
ADX value, volume surge status, and HOLD reason for easier diagnosis.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
21in7
2026-03-07 02:20:44 +09:00
parent c577019793
commit 0a8748913e
5 changed files with 46 additions and 19 deletions

View File

@@ -92,7 +92,7 @@ async def test_bot_processes_signal(config, sample_df):
with patch("src.bot.Indicators") as MockInd:
mock_ind = MagicMock()
mock_ind.calculate_all.return_value = sample_df
mock_ind.get_signal.return_value = "LONG"
mock_ind.get_signal.return_value = ("LONG", {"long": 3, "short": 0, "vol_surge": True, "adx": 30.0, "hold_reason": ""})
mock_ind.get_atr_stop.return_value = (0.48, 0.56)
MockInd.return_value = mock_ind
await bot.process_candle(sample_df)
@@ -178,7 +178,7 @@ async def test_process_candle_calls_close_and_reenter_on_reverse_signal(config,
with patch("src.bot.Indicators") as MockInd:
mock_ind = MagicMock()
mock_ind.calculate_all.return_value = sample_df
mock_ind.get_signal.return_value = "SHORT" # 현재 LONG 포지션에 반대 시그널
mock_ind.get_signal.return_value = ("SHORT", {"long": 0, "short": 3, "vol_surge": True, "adx": 30.0, "hold_reason": ""}) # 현재 LONG 포지션에 반대 시그널
MockInd.return_value = mock_ind
await bot.process_candle(sample_df)
@@ -207,7 +207,7 @@ async def test_process_candle_passes_raw_signal_to_close_and_reenter_even_if_ml_
with patch("src.bot.Indicators") as MockInd:
mock_ind = MagicMock()
mock_ind.calculate_all.return_value = sample_df
mock_ind.get_signal.return_value = "SHORT"
mock_ind.get_signal.return_value = ("SHORT", {"long": 0, "short": 3, "vol_surge": True, "adx": 30.0, "hold_reason": ""})
MockInd.return_value = mock_ind
await bot.process_candle(sample_df)
@@ -243,7 +243,7 @@ async def test_process_candle_fetches_oi_and_funding(config, sample_df):
with patch("src.bot.Indicators") as mock_ind_cls:
mock_ind = MagicMock()
mock_ind.calculate_all.return_value = sample_df
mock_ind.get_signal.return_value = "LONG"
mock_ind.get_signal.return_value = ("LONG", {"long": 3, "short": 0, "vol_surge": True, "adx": 30.0, "hold_reason": ""})
mock_ind_cls.return_value = mock_ind
with patch("src.bot.build_features_aligned") as mock_build:

View File

@@ -66,10 +66,11 @@ def test_adx_filter_blocks_low_adx(sample_df):
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)
signal, detail = ind.get_signal(df)
assert signal == "HOLD"
assert "ADX" in detail["hold_reason"]
# adx_threshold=0이면 ADX 필터 비활성화 → LONG
signal = ind.get_signal(df, adx_threshold=0)
signal, detail = ind.get_signal(df, adx_threshold=0)
assert signal == "LONG"
@@ -78,13 +79,15 @@ def test_adx_nan_falls_through(sample_df):
ind = Indicators(sample_df)
df = ind.calculate_all()
df["adx"] = float("nan")
signal = ind.get_signal(df)
signal, detail = ind.get_signal(df)
# NaN이면 차단하지 않고 기존 로직 실행 → LONG/SHORT/HOLD 중 하나
assert signal in ("LONG", "SHORT", "HOLD")
assert detail["adx"] is None
def test_signal_returns_direction(sample_df):
ind = Indicators(sample_df)
df = ind.calculate_all()
signal = ind.get_signal(df)
signal, detail = ind.get_signal(df)
assert signal in ("LONG", "SHORT", "HOLD")
assert "long" in detail and "short" in detail