"""Unit tests for services.signal_engine.config. Covers: - Default values and fail-safe behaviour - DB row parsing and application - Environment variable overrides - Sub-config derivation properties - load_config() with mocked asyncpg pool """ from __future__ import annotations import json import os from unittest.mock import AsyncMock, patch import pytest from services.signal_engine.config import ( ExitConfig, HardFilterConfig, HeuristicConfig, ProbabilisticConfig, SignalEngineConfig, _apply_db_rows, _apply_env_overrides, _parse_value, load_config, ) # --------------------------------------------------------------------------- # Defaults # --------------------------------------------------------------------------- class TestDefaults: """SignalEngineConfig defaults match the design spec.""" def test_dual_pipeline_disabled_by_default(self): cfg = SignalEngineConfig() assert cfg.dual_pipeline_enabled is False def test_both_pipelines_enabled_by_default(self): cfg = SignalEngineConfig() assert cfg.heuristic_pipeline_enabled is True assert cfg.probabilistic_pipeline_enabled is True def test_shadow_mode_off_by_default(self): cfg = SignalEngineConfig() assert cfg.shadow_mode is False def test_timeframe_weights_default(self): cfg = SignalEngineConfig() expected = { "M30": 0.03, "H1": 0.07, "H4": 0.15, "D": 0.30, "W": 0.30, "M": 0.15, } assert cfg.timeframe_weights == expected def test_hard_filter_defaults(self): cfg = SignalEngineConfig() assert cfg.hard_filter_valuation_min == 0.3 assert cfg.hard_filter_earnings_days == 5 assert cfg.hard_filter_macro_bias_skip == -1.0 def test_heuristic_threshold_defaults(self): cfg = SignalEngineConfig() assert cfg.heuristic_buy_confidence == 0.70 assert cfg.heuristic_buy_s_total == 1.2 assert cfg.heuristic_buy_valuation_min == 0.5 assert cfg.heuristic_watch_confidence == 0.55 def test_probabilistic_threshold_defaults(self): cfg = SignalEngineConfig() assert cfg.prob_buy_p_up == 0.60 assert cfg.prob_buy_entropy_max == 0.90 assert cfg.prob_buy_ev_r_min == 1.5 assert cfg.prob_buy_valuation_min == 0.5 assert cfg.prob_watch_p_up == 0.55 assert cfg.prob_watch_entropy_max == 0.95 assert cfg.prob_entropy_skip == 0.95 def test_regime_prior_defaults(self): cfg = SignalEngineConfig() assert cfg.regime_prior_bull == 0.58 assert cfg.regime_prior_range == 0.50 assert cfg.regime_prior_bear == 0.42 def test_exit_and_polling_defaults(self): cfg = SignalEngineConfig() assert cfg.trailing_stop_atr_multiplier == 2.0 assert cfg.polling_interval_seconds == 30 # --------------------------------------------------------------------------- # Sub-config derivation # --------------------------------------------------------------------------- class TestSubConfigs: """Properties derive correct sub-config instances.""" def test_hard_filter_config(self): cfg = SignalEngineConfig( hard_filter_valuation_min=0.4, hard_filter_earnings_days=7, hard_filter_macro_bias_skip=-0.5, ) hf = cfg.hard_filter_config assert isinstance(hf, HardFilterConfig) assert hf.valuation_min == 0.4 assert hf.earnings_days == 7 assert hf.macro_bias_skip == -0.5 def test_heuristic_config(self): cfg = SignalEngineConfig( heuristic_buy_confidence=0.80, heuristic_buy_s_total=1.5, heuristic_buy_valuation_min=0.6, heuristic_watch_confidence=0.60, hard_filter_earnings_days=10, ) hc = cfg.heuristic_config assert isinstance(hc, HeuristicConfig) assert hc.buy_confidence == 0.80 assert hc.buy_s_total == 1.5 assert hc.buy_valuation_min == 0.6 assert hc.watch_confidence == 0.60 assert hc.macro_bias_threshold == 0.0 assert hc.earnings_days_threshold == 10 def test_probabilistic_config(self): cfg = SignalEngineConfig( prob_buy_p_up=0.65, regime_prior_bull=0.60, ) pc = cfg.probabilistic_config assert isinstance(pc, ProbabilisticConfig) assert pc.buy_p_up == 0.65 assert pc.regime_prior_bull == 0.60 assert pc.macro_bias_threshold == 0.0 def test_exit_config(self): cfg = SignalEngineConfig(trailing_stop_atr_multiplier=3.0) ec = cfg.exit_config assert isinstance(ec, ExitConfig) assert ec.trailing_stop_atr_multiplier == 3.0 # --------------------------------------------------------------------------- # _parse_value # --------------------------------------------------------------------------- class TestParseValue: def test_bool_true_variants(self): for v in ("true", "True", "TRUE", "1", "yes"): assert _parse_value(v, bool) is True def test_bool_false_variants(self): for v in ("false", "False", "0", "no", "anything"): assert _parse_value(v, bool) is False def test_int(self): assert _parse_value("42", int) == 42 def test_float(self): assert _parse_value("0.75", float) == 0.75 def test_dict_json(self): raw = json.dumps({"D": 0.30, "W": 0.30}) result = _parse_value(raw, dict) assert result == {"D": 0.30, "W": 0.30} def test_invalid_int_raises(self): with pytest.raises(ValueError): _parse_value("not_a_number", int) def test_invalid_json_raises(self): with pytest.raises(json.JSONDecodeError): _parse_value("{bad json", dict) # --------------------------------------------------------------------------- # _apply_db_rows # --------------------------------------------------------------------------- class TestApplyDbRows: def test_applies_known_keys(self): cfg = SignalEngineConfig() rows = [ ("signal_engine_dual_pipeline_enabled", "true"), ("signal_engine_prob_buy_p_up", "0.65"), ("signal_engine_polling_interval_seconds", "60"), ] _apply_db_rows(cfg, rows) assert cfg.dual_pipeline_enabled is True assert cfg.prob_buy_p_up == 0.65 assert cfg.polling_interval_seconds == 60 def test_ignores_unknown_keys(self): cfg = SignalEngineConfig() rows = [("signal_engine_unknown_field", "whatever")] _apply_db_rows(cfg, rows) # should not raise def test_invalid_value_keeps_default(self): cfg = SignalEngineConfig() rows = [("signal_engine_hard_filter_earnings_days", "not_a_number")] _apply_db_rows(cfg, rows) assert cfg.hard_filter_earnings_days == 5 # default preserved def test_timeframe_weights_from_json(self): cfg = SignalEngineConfig() new_weights = {"D": 0.50, "W": 0.50} rows = [ ("signal_engine_timeframe_weights", json.dumps(new_weights)), ] _apply_db_rows(cfg, rows) assert cfg.timeframe_weights == new_weights # --------------------------------------------------------------------------- # _apply_env_overrides # --------------------------------------------------------------------------- class TestApplyEnvOverrides: def test_env_override_bool(self): cfg = SignalEngineConfig() with patch.dict(os.environ, {"SIGNAL_ENGINE_DUAL_PIPELINE_ENABLED": "true"}): _apply_env_overrides(cfg) assert cfg.dual_pipeline_enabled is True def test_env_override_float(self): cfg = SignalEngineConfig() with patch.dict(os.environ, {"SIGNAL_ENGINE_PROB_BUY_P_UP": "0.70"}): _apply_env_overrides(cfg) assert cfg.prob_buy_p_up == 0.70 def test_env_override_int(self): cfg = SignalEngineConfig() with patch.dict(os.environ, {"SIGNAL_ENGINE_POLLING_INTERVAL_SECONDS": "120"}): _apply_env_overrides(cfg) assert cfg.polling_interval_seconds == 120 def test_env_ignores_unrelated_vars(self): cfg = SignalEngineConfig() with patch.dict(os.environ, {"UNRELATED_VAR": "hello"}): _apply_env_overrides(cfg) # No change — just verifying no crash assert cfg.dual_pipeline_enabled is False def test_invalid_env_value_keeps_previous(self): cfg = SignalEngineConfig() cfg.hard_filter_earnings_days = 10 with patch.dict(os.environ, {"SIGNAL_ENGINE_HARD_FILTER_EARNINGS_DAYS": "bad"}): _apply_env_overrides(cfg) assert cfg.hard_filter_earnings_days == 10 # unchanged # --------------------------------------------------------------------------- # load_config (async) # --------------------------------------------------------------------------- class TestLoadConfig: @pytest.mark.asyncio async def test_load_with_db_rows(self): """DB rows are applied over defaults.""" pool = AsyncMock() pool.fetch = AsyncMock( return_value=[ {"key": "signal_engine_dual_pipeline_enabled", "value": "true"}, {"key": "signal_engine_shadow_mode", "value": "true"}, ] ) cfg = await load_config(pool) assert cfg.dual_pipeline_enabled is True assert cfg.shadow_mode is True @pytest.mark.asyncio async def test_load_with_empty_db(self): """Empty DB result returns safe defaults.""" pool = AsyncMock() pool.fetch = AsyncMock(return_value=[]) cfg = await load_config(pool) assert cfg.dual_pipeline_enabled is False assert cfg.heuristic_pipeline_enabled is True @pytest.mark.asyncio async def test_load_db_failure_failsafe(self): """DB error falls back to disabled (fail-safe).""" pool = AsyncMock() pool.fetch = AsyncMock(side_effect=Exception("connection refused")) cfg = await load_config(pool) assert cfg.dual_pipeline_enabled is False @pytest.mark.asyncio async def test_env_overrides_db_values(self): """Environment variables take precedence over DB values.""" pool = AsyncMock() pool.fetch = AsyncMock( return_value=[ {"key": "signal_engine_prob_buy_p_up", "value": "0.55"}, ] ) with patch.dict(os.environ, {"SIGNAL_ENGINE_PROB_BUY_P_UP": "0.70"}): cfg = await load_config(pool) assert cfg.prob_buy_p_up == 0.70 # env wins @pytest.mark.asyncio async def test_env_overrides_applied_after_db_failure(self): """Env overrides still apply even when DB read fails.""" pool = AsyncMock() pool.fetch = AsyncMock(side_effect=Exception("timeout")) with patch.dict( os.environ, {"SIGNAL_ENGINE_DUAL_PIPELINE_ENABLED": "true"} ): cfg = await load_config(pool) # Env override can re-enable even after DB failure assert cfg.dual_pipeline_enabled is True