From e5c8d3d1369a8d5c4ae59a41b1bce9c25e786838 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Thu, 14 May 2026 05:52:04 +0000 Subject: [PATCH] CodeRabbit Generated Unit Tests: Generate unit tests for PR changes --- .../src-python/tests/test_config_internals.py | 291 ++++++++++++++++++ .../src-python/tests/test_ipc_auth.py | 47 +++ .../tests/test_signal_generator_metadata.py | 204 ++++++++++++ .../tests/test_trading_core_validate.py | 284 +++++++++++++++++ 4 files changed, 826 insertions(+) create mode 100644 money-machine/src-python/tests/test_config_internals.py create mode 100644 money-machine/src-python/tests/test_signal_generator_metadata.py create mode 100644 money-machine/src-python/tests/test_trading_core_validate.py diff --git a/money-machine/src-python/tests/test_config_internals.py b/money-machine/src-python/tests/test_config_internals.py new file mode 100644 index 0000000..64f1858 --- /dev/null +++ b/money-machine/src-python/tests/test_config_internals.py @@ -0,0 +1,291 @@ +""" +Tests for the private helpers in utils/config.py that were introduced in this PR: + + - _without_secrets(value): strips keys that appear in SECRET_KEYS from any + depth of a nested structure and from list elements. + - _deep_merge(base, update): recursively merges two dicts in-place, falling + back to simple overwrite for non-dict values. + - save_config() returns False when the destination path is unwritable. + +The public load_config() and save_config() integration behaviour is already +covered in test_config_security.py. This file focuses on the helpers. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path +from typing import Any, Dict + +import pytest + +SRC_PYTHON = Path(__file__).resolve().parent.parent +if str(SRC_PYTHON) not in sys.path: + sys.path.insert(0, str(SRC_PYTHON)) + +from utils.config import ( # noqa: E402 + SECRET_KEYS, + _deep_merge, + _without_secrets, + save_config, +) + + +# --------------------------------------------------------------------------- +# _without_secrets – scalar pass-through +# --------------------------------------------------------------------------- + + +def test_without_secrets_passthrough_integer() -> None: + assert _without_secrets(42) == 42 + + +def test_without_secrets_passthrough_string() -> None: + assert _without_secrets("hello") == "hello" + + +def test_without_secrets_passthrough_none() -> None: + assert _without_secrets(None) is None + + +def test_without_secrets_passthrough_float() -> None: + assert _without_secrets(3.14) == 3.14 + + +def test_without_secrets_passthrough_bool() -> None: + assert _without_secrets(True) is True + + +# --------------------------------------------------------------------------- +# _without_secrets – empty/flat dict +# --------------------------------------------------------------------------- + + +def test_without_secrets_empty_dict_returns_empty_dict() -> None: + result = _without_secrets({}) + assert result == {} + + +def test_without_secrets_dict_without_secrets_is_unchanged() -> None: + d = {"exchange_name": "binance", "ipc_port": 19284} + assert _without_secrets(d) == d + + +def test_without_secrets_removes_api_key() -> None: + result = _without_secrets({"api_key": "secret", "name": "binance"}) + assert "api_key" not in result + assert result["name"] == "binance" + + +def test_without_secrets_removes_secret() -> None: + result = _without_secrets({"secret": "s3cr3t", "sandbox": True}) + assert "secret" not in result + assert result["sandbox"] is True + + +def test_without_secrets_removes_gemini_api_key() -> None: + result = _without_secrets({"gemini_api_key": "gk-xxx", "model": "gemini-1.5-flash"}) + assert "gemini_api_key" not in result + assert result["model"] == "gemini-1.5-flash" + + +def test_without_secrets_case_insensitive_removal() -> None: + # SECRET_KEYS comparison is done with key.lower() + result = _without_secrets({"API_KEY": "upper", "Secret": "mixed", "name": "ok"}) + assert "API_KEY" not in result + assert "Secret" not in result + assert result["name"] == "ok" + + +# --------------------------------------------------------------------------- +# _without_secrets – nested dicts +# --------------------------------------------------------------------------- + + +def test_without_secrets_nested_dict() -> None: + data = { + "exchange": { + "name": "binance", + "api_key": "should-be-gone", + "secret": "also-gone", + }, + "max_risk_per_trade": 0.02, + } + result = _without_secrets(data) + assert result["exchange"] == {"name": "binance"} + assert result["max_risk_per_trade"] == 0.02 + + +def test_without_secrets_deeply_nested() -> None: + data = { + "level1": { + "level2": { + "api_key": "deep-secret", + "allowed": "value", + } + } + } + result = _without_secrets(data) + assert result["level1"]["level2"] == {"allowed": "value"} + + +def test_without_secrets_returns_new_dict_not_mutating_original() -> None: + data = {"api_key": "secret", "name": "test"} + result = _without_secrets(data) + # Original is unmodified + assert data["api_key"] == "secret" + assert "api_key" not in result + + +# --------------------------------------------------------------------------- +# _without_secrets – lists +# --------------------------------------------------------------------------- + + +def test_without_secrets_list_of_scalars_passthrough() -> None: + assert _without_secrets([1, 2, 3]) == [1, 2, 3] + + +def test_without_secrets_list_of_dicts_strips_secrets() -> None: + data = [ + {"name": "a", "api_key": "key-a"}, + {"name": "b", "secret": "sec-b"}, + ] + result = _without_secrets(data) + assert result == [{"name": "a"}, {"name": "b"}] + + +def test_without_secrets_nested_list_inside_dict() -> None: + data = { + "providers": [ + {"name": "binance", "api_key": "k1"}, + {"name": "kraken", "api_key": "k2"}, + ] + } + result = _without_secrets(data) + assert result == {"providers": [{"name": "binance"}, {"name": "kraken"}]} + + +def test_without_secrets_empty_list() -> None: + assert _without_secrets([]) == [] + + +# --------------------------------------------------------------------------- +# _without_secrets – SECRET_KEYS constant +# --------------------------------------------------------------------------- + + +def test_secret_keys_contains_expected_members() -> None: + assert "api_key" in SECRET_KEYS + assert "secret" in SECRET_KEYS + assert "gemini_api_key" in SECRET_KEYS + + +def test_secret_keys_is_frozenset() -> None: + assert isinstance(SECRET_KEYS, frozenset) + + +# --------------------------------------------------------------------------- +# _deep_merge – flat merge +# --------------------------------------------------------------------------- + + +def test_deep_merge_adds_new_keys_to_base() -> None: + base: Dict[str, Any] = {"a": 1} + _deep_merge(base, {"b": 2}) + assert base == {"a": 1, "b": 2} + + +def test_deep_merge_overwrites_existing_scalar() -> None: + base: Dict[str, Any] = {"a": 1} + _deep_merge(base, {"a": 99}) + assert base["a"] == 99 + + +def test_deep_merge_modifies_base_in_place() -> None: + base: Dict[str, Any] = {"x": 10} + update = {"y": 20} + _deep_merge(base, update) + assert base == {"x": 10, "y": 20} + assert update == {"y": 20} # update is not modified + + +# --------------------------------------------------------------------------- +# _deep_merge – nested dict recursion +# --------------------------------------------------------------------------- + + +def test_deep_merge_recurses_into_nested_dict() -> None: + base: Dict[str, Any] = {"exchange": {"name": "binance", "sandbox": True}} + _deep_merge(base, {"exchange": {"sandbox": False}}) + assert base["exchange"]["name"] == "binance" # preserved + assert base["exchange"]["sandbox"] is False # updated + + +def test_deep_merge_adds_new_nested_key() -> None: + base: Dict[str, Any] = {"exchange": {"name": "binance"}} + _deep_merge(base, {"exchange": {"region": "eu"}}) + assert base["exchange"]["name"] == "binance" + assert base["exchange"]["region"] == "eu" + + +def test_deep_merge_nested_three_levels() -> None: + base: Dict[str, Any] = {"a": {"b": {"c": 1}}} + _deep_merge(base, {"a": {"b": {"d": 2}}}) + assert base == {"a": {"b": {"c": 1, "d": 2}}} + + +# --------------------------------------------------------------------------- +# _deep_merge – non-dict value overwrites (lists, scalars replacing dicts) +# --------------------------------------------------------------------------- + + +def test_deep_merge_list_is_replaced_not_merged() -> None: + base: Dict[str, Any] = {"items": [1, 2, 3]} + _deep_merge(base, {"items": [4, 5]}) + assert base["items"] == [4, 5] # lists are not extended + + +def test_deep_merge_dict_replaced_by_scalar() -> None: + base: Dict[str, Any] = {"section": {"key": "value"}} + _deep_merge(base, {"section": 42}) + assert base["section"] == 42 # scalar wins over dict + + +def test_deep_merge_scalar_replaced_by_dict() -> None: + base: Dict[str, Any] = {"section": 42} + _deep_merge(base, {"section": {"key": "value"}}) + assert base["section"] == {"key": "value"} + + +def test_deep_merge_empty_update_leaves_base_unchanged() -> None: + base: Dict[str, Any] = {"a": 1, "b": 2} + _deep_merge(base, {}) + assert base == {"a": 1, "b": 2} + + +def test_deep_merge_empty_base_receives_all_update_keys() -> None: + base: Dict[str, Any] = {} + _deep_merge(base, {"x": 1, "y": {"z": 2}}) + assert base == {"x": 1, "y": {"z": 2}} + + +# --------------------------------------------------------------------------- +# save_config – error path returns False +# --------------------------------------------------------------------------- + + +def test_save_config_returns_false_on_unwritable_path(monkeypatch) -> None: + """save_config() should return False (not raise) when the path is unwritable.""" + import utils.config as config_module + + # Point __file__ at a path inside a non-existent directory so the open() + # call inside save_config() will fail with a FileNotFoundError. + monkeypatch.setattr( + config_module, + "__file__", + "/nonexistent_directory/sub/config.py", + ) + result = save_config({"max_risk_per_trade": 0.02}) + assert result is False \ No newline at end of file diff --git a/money-machine/src-python/tests/test_ipc_auth.py b/money-machine/src-python/tests/test_ipc_auth.py index f5bf244..1441c49 100644 --- a/money-machine/src-python/tests/test_ipc_auth.py +++ b/money-machine/src-python/tests/test_ipc_auth.py @@ -237,6 +237,53 @@ async def scenario() -> None: _run(scenario()) +def test_oversized_auth_header_returns_413() -> None: + """An auth header that exceeds MAX_AUTH_LINE_BYTES should be rejected with 413.""" + async def scenario() -> None: + server, task, (host, port) = await _start_server() + try: + # Build an auth line longer than the server's MAX_AUTH_LINE_BYTES limit. + oversized_token = "x" * (IPCServer.MAX_AUTH_LINE_BYTES + 1) + oversized_auth = f"X-Auth-Token: {oversized_token}\n".encode("utf-8") + response = await _send_raw(host, port, oversized_auth) + assert response.get("code") == 413, response + finally: + await _shutdown(server, task) + + _run(scenario()) + + +def test_custom_read_timeout_is_applied_to_server() -> None: + """IPCServer should store the custom read_timeout_seconds value.""" + server = IPCServer( + command_handler=_echo_handler, + host="127.0.0.1", + port=0, + auth_token=TEST_TOKEN, + read_timeout_seconds=2.5, + ) + assert server.read_timeout_seconds == 2.5 + + +def test_default_read_timeout_equals_class_constant() -> None: + """When no timeout is given, the instance should use DEFAULT_READ_TIMEOUT_SECONDS.""" + server = IPCServer( + command_handler=_echo_handler, + host="127.0.0.1", + port=0, + auth_token=TEST_TOKEN, + ) + assert server.read_timeout_seconds == IPCServer.DEFAULT_READ_TIMEOUT_SECONDS + + +def test_max_body_bytes_class_constant() -> None: + assert IPCServer.MAX_BODY_BYTES == 64 * 1024 + + +def test_max_auth_line_bytes_class_constant() -> None: + assert IPCServer.MAX_AUTH_LINE_BYTES == 256 + + if __name__ == "__main__": # Allow running this file directly: `python tests/test_ipc_auth.py`. import traceback diff --git a/money-machine/src-python/tests/test_signal_generator_metadata.py b/money-machine/src-python/tests/test_signal_generator_metadata.py new file mode 100644 index 0000000..0e8ab6d --- /dev/null +++ b/money-machine/src-python/tests/test_signal_generator_metadata.py @@ -0,0 +1,204 @@ +""" +Tests for the signal_generator.py changes introduced in this PR. + +Key changes: + 1. TradingSignal is now imported from engine.strategies.base (the canonical + location) rather than being defined locally in signal_generator.py. + 2. The amount field was replaced with metadata={"amount_pct": ...} so the + pipeline contract is compatible with the canonical TradingSignal dataclass. + +Tests here verify: + - TradingSignal is NOT defined in signal_generator (it was removed). + - The canonical TradingSignal from engine.strategies.base has a metadata field. + - _parse_json_response() populates metadata["amount_pct"] from the AI payload. + - When amount_pct is absent from the AI payload, metadata["amount_pct"] is None. + - Fallback (rule-based) signals have an empty metadata dict (the dataclass default). + - Both modules reference the exact same TradingSignal class object. +""" + +from __future__ import annotations + +import sys +from pathlib import Path +from typing import List +from unittest.mock import MagicMock + +import pytest + +SRC_PYTHON = Path(__file__).resolve().parent.parent +if str(SRC_PYTHON) not in sys.path: + sys.path.insert(0, str(SRC_PYTHON)) + +# pandas is a required dependency; skip the entire module if it is absent +# so the test runner stays green in minimal environments (same pattern used +# by test_mt5_adapter.py for the cryptography package). +pandas = pytest.importorskip("pandas") + +import engine.signal_generator as sg_module # noqa: E402 +from engine.strategies.base import TradingSignal # noqa: E402 + + +# --------------------------------------------------------------------------- +# TradingSignal is NOT defined locally in signal_generator +# --------------------------------------------------------------------------- + + +def test_trading_signal_not_defined_locally_in_signal_generator() -> None: + """After the PR, signal_generator.py must NOT own TradingSignal; it imports it.""" + # The module-level dict must not contain a TradingSignal class that is + # different from the one in engine.strategies.base. + local_cls = vars(sg_module).get("TradingSignal") + if local_cls is not None: + # It may be re-exported (imported name), but it must be the same object. + assert local_cls is TradingSignal, ( + "signal_generator.TradingSignal must be the canonical class from " + "engine.strategies.base, not a separate local definition." + ) + + +def test_signal_generator_module_uses_canonical_trading_signal() -> None: + """The TradingSignal accessible via signal_generator is the base class.""" + from engine.signal_generator import TradingSignal as sg_ts # type: ignore[attr-defined] + assert sg_ts is TradingSignal + + +# --------------------------------------------------------------------------- +# TradingSignal canonical dataclass – metadata field +# --------------------------------------------------------------------------- + + +def test_canonical_trading_signal_has_metadata_field() -> None: + signal = TradingSignal(symbol="EURUSD", action="HOLD", confidence=0.5) + assert hasattr(signal, "metadata") + assert isinstance(signal.metadata, dict) + + +def test_canonical_trading_signal_default_metadata_is_empty_dict() -> None: + signal = TradingSignal(symbol="BTCUSDT", action="BUY", confidence=0.7) + assert signal.metadata == {} + + +def test_canonical_trading_signal_metadata_accepts_amount_pct() -> None: + signal = TradingSignal( + symbol="BTCUSDT", + action="BUY", + confidence=0.8, + metadata={"amount_pct": 0.02}, + ) + assert signal.metadata["amount_pct"] == pytest.approx(0.02) + + +def test_canonical_trading_signal_metadata_is_mutable_dict() -> None: + signal = TradingSignal(symbol="EURUSD", action="HOLD", confidence=0.0) + signal.metadata["extra"] = "value" + assert signal.metadata["extra"] == "value" + + +# --------------------------------------------------------------------------- +# _parse_json_response – metadata["amount_pct"] population +# --------------------------------------------------------------------------- + +# Minimal fake market data (OHLCV): [timestamp, open, high, low, close, volume] +_MARKET_DATA: List[List] = [[1_700_000_000_000, 50_000, 51_000, 49_000, 50_500, 10.0]] + + +def _make_generator() -> sg_module.SignalGenerator: + """Return a SignalGenerator without a real Gemini client.""" + gen = sg_module.SignalGenerator.__new__(sg_module.SignalGenerator) + gen.gemini_client = None + gen.model = None + gen.market_context = sg_module.MarketContext() + gen._last_signals: dict = {} + return gen + + +def test_parse_json_response_sets_amount_pct_in_metadata() -> None: + gen = _make_generator() + json_text = '{"action":"BUY","confidence":0.75,"amount_pct":0.02,"reasoning":"test"}' + signal = gen._parse_json_response("BTCUSDT", json_text, _MARKET_DATA) + assert isinstance(signal, TradingSignal) + assert signal.metadata.get("amount_pct") == pytest.approx(0.02) + + +def test_parse_json_response_amount_pct_none_when_missing() -> None: + gen = _make_generator() + json_text = '{"action":"SELL","confidence":0.6,"reasoning":"no size given"}' + signal = gen._parse_json_response("EURUSD", json_text, _MARKET_DATA) + assert isinstance(signal, TradingSignal) + # amount_pct was not in the payload → should be None (data.get("amount_pct")) + assert signal.metadata.get("amount_pct") is None + + +def test_parse_json_response_amount_pct_null_in_payload() -> None: + gen = _make_generator() + json_text = '{"action":"BUY","confidence":0.5,"amount_pct":null}' + signal = gen._parse_json_response("BTCUSDT", json_text, _MARKET_DATA) + assert isinstance(signal, TradingSignal) + assert signal.metadata.get("amount_pct") is None + + +def test_parse_json_response_amount_pct_not_in_top_level_amount_field() -> None: + """The old code used signal.amount; the new code stores it in metadata. + Ensure the old 'amount' field is NOT the attribute we're checking.""" + gen = _make_generator() + json_text = '{"action":"BUY","confidence":0.7,"amount_pct":0.03}' + signal = gen._parse_json_response("EURUSD", json_text, _MARKET_DATA) + # The canonical TradingSignal has no 'amount' field; amount_pct must live in metadata. + assert not hasattr(signal, "amount"), ( + "TradingSignal must not have an 'amount' field; use metadata['amount_pct']" + ) + assert signal.metadata["amount_pct"] == pytest.approx(0.03) + + +def test_parse_json_response_fallback_on_invalid_json() -> None: + gen = _make_generator() + signal = gen._parse_json_response("BTCUSDT", "NOT_VALID_JSON", _MARKET_DATA) + assert isinstance(signal, TradingSignal) + assert signal.action == "HOLD" + # Fallback signal has default empty metadata + assert signal.metadata == {} + + +def test_parse_json_response_action_is_uppercased() -> None: + gen = _make_generator() + json_text = '{"action":"buy","confidence":0.6,"amount_pct":0.01}' + signal = gen._parse_json_response("EURUSD", json_text, _MARKET_DATA) + assert signal.action == "BUY" + + +def test_parse_json_response_sets_correct_symbol() -> None: + gen = _make_generator() + json_text = '{"action":"HOLD","confidence":0.5}' + signal = gen._parse_json_response("USDJPY", json_text, _MARKET_DATA) + assert signal.symbol == "USDJPY" + + +def test_parse_json_response_uses_entry_price_from_market_data_when_missing() -> None: + gen = _make_generator() + json_text = '{"action":"BUY","confidence":0.7}' + # Last OHLCV close is at index [4] of the last candle. + signal = gen._parse_json_response("BTCUSDT", json_text, _MARKET_DATA) + assert signal.entry_price == pytest.approx(_MARKET_DATA[-1][4]) + + +def test_parse_json_response_stop_loss_and_take_profit_passthrough() -> None: + gen = _make_generator() + json_text = ( + '{"action":"SELL","confidence":0.8,' + '"stop_loss":51000.0,"take_profit":48000.0}' + ) + signal = gen._parse_json_response("BTCUSDT", json_text, _MARKET_DATA) + assert signal.stop_loss == pytest.approx(51_000.0) + assert signal.take_profit == pytest.approx(48_000.0) + + +# --------------------------------------------------------------------------- +# TradingSignal is the same object in both modules (import identity) +# --------------------------------------------------------------------------- + + +def test_trading_signal_class_identity_across_modules() -> None: + from engine.strategies.base import TradingSignal as base_ts + # Importing through signal_generator should give the same class. + from engine.signal_generator import TradingSignal as sg_ts # type: ignore[attr-defined] + assert sg_ts is base_ts \ No newline at end of file diff --git a/money-machine/src-python/tests/test_trading_core_validate.py b/money-machine/src-python/tests/test_trading_core_validate.py new file mode 100644 index 0000000..f9317c7 --- /dev/null +++ b/money-machine/src-python/tests/test_trading_core_validate.py @@ -0,0 +1,284 @@ +""" +Comprehensive tests for validate_config_update() in engine.trading_core. + +The function is new in this PR and enforces a whitelist + numeric range +policy on runtime config mutations. Tests here cover: + + - Happy path: all three allowed keys, individually and combined. + - Type enforcement: booleans, strings, and non-dict inputs are rejected. + - Integer values are accepted and coerced to float. + - Boundary values for each key, probing both inclusive and exclusive limits. + - Error messages contain the offending key name. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +SRC_PYTHON = Path(__file__).resolve().parent.parent +if str(SRC_PYTHON) not in sys.path: + sys.path.insert(0, str(SRC_PYTHON)) + +from engine.trading_core import CONFIG_LIMITS, validate_config_update # noqa: E402 + + +# --------------------------------------------------------------------------- +# Happy path +# --------------------------------------------------------------------------- + + +def test_empty_dict_returns_empty_dict() -> None: + assert validate_config_update({}) == {} + + +def test_valid_initial_balance_at_minimum() -> None: + result = validate_config_update({"initial_balance": 100.0}) + assert result == {"initial_balance": 100.0} + + +def test_valid_initial_balance_at_maximum() -> None: + result = validate_config_update({"initial_balance": 1_000_000.0}) + assert result == {"initial_balance": 1_000_000.0} + + +def test_valid_initial_balance_midrange() -> None: + result = validate_config_update({"initial_balance": 50_000.0}) + assert result == {"initial_balance": 50_000.0} + + +def test_valid_max_risk_per_trade_small_positive() -> None: + result = validate_config_update({"max_risk_per_trade": 0.001}) + assert result == {"max_risk_per_trade": 0.001} + + +def test_valid_max_risk_per_trade_at_maximum() -> None: + result = validate_config_update({"max_risk_per_trade": 0.1}) + assert result == {"max_risk_per_trade": 0.1} + + +def test_valid_max_daily_loss_small_positive() -> None: + result = validate_config_update({"max_daily_loss": 0.001}) + assert result == {"max_daily_loss": 0.001} + + +def test_valid_max_daily_loss_at_maximum() -> None: + result = validate_config_update({"max_daily_loss": 0.2}) + assert result == {"max_daily_loss": 0.2} + + +def test_multiple_valid_keys_in_single_call() -> None: + payload = { + "initial_balance": 5000.0, + "max_risk_per_trade": 0.02, + "max_daily_loss": 0.05, + } + result = validate_config_update(payload) + assert result == { + "initial_balance": 5000.0, + "max_risk_per_trade": 0.02, + "max_daily_loss": 0.05, + } + + +def test_integer_value_is_accepted_and_coerced_to_float() -> None: + result = validate_config_update({"initial_balance": 500}) + assert result == {"initial_balance": 500.0} + assert isinstance(result["initial_balance"], float) + + +def test_integer_value_for_risk_coerced_to_float() -> None: + # While 0 is out of range for max_risk_per_trade, 1 would be too; use a + # valid integer that coerces to a valid float. + # There is no valid integer for max_risk_per_trade because the only + # integer ≤ 0.1 is 0, which violates the exclusive minimum. + # Use initial_balance instead, which accepts integer 100 (min = 100.0 inclusive). + result = validate_config_update({"initial_balance": 100}) + assert isinstance(result["initial_balance"], float) + assert result["initial_balance"] == 100.0 + + +# --------------------------------------------------------------------------- +# Type rejection +# --------------------------------------------------------------------------- + + +def test_non_dict_list_raises_value_error() -> None: + with pytest.raises(ValueError, match="object"): + validate_config_update([("initial_balance", 500.0)]) # type: ignore[arg-type] + + +def test_non_dict_string_raises_value_error() -> None: + with pytest.raises(ValueError, match="object"): + validate_config_update("initial_balance=500") # type: ignore[arg-type] + + +def test_non_dict_none_raises_value_error() -> None: + with pytest.raises(ValueError, match="object"): + validate_config_update(None) # type: ignore[arg-type] + + +def test_bool_true_rejected_even_though_isinstance_int() -> None: + with pytest.raises(ValueError): + validate_config_update({"initial_balance": True}) + + +def test_bool_false_rejected() -> None: + with pytest.raises(ValueError): + validate_config_update({"max_risk_per_trade": False}) + + +def test_string_numeric_rejected() -> None: + with pytest.raises(ValueError): + validate_config_update({"initial_balance": "500.0"}) + + +def test_none_value_rejected() -> None: + with pytest.raises(ValueError): + validate_config_update({"initial_balance": None}) + + +def test_dict_value_rejected() -> None: + # A nested dict is not numeric. + with pytest.raises(ValueError): + validate_config_update({"initial_balance": {"value": 500}}) + + +# --------------------------------------------------------------------------- +# Key whitelist +# --------------------------------------------------------------------------- + + +def test_unknown_key_raises_value_error() -> None: + with pytest.raises(ValueError): + validate_config_update({"unknown_key": 1.0}) + + +def test_exchange_nested_key_raises_value_error() -> None: + # "exchange" itself is not in CONFIG_LIMITS + with pytest.raises(ValueError): + validate_config_update({"exchange": 1.0}) + + +def test_gemini_api_key_rejected() -> None: + with pytest.raises(ValueError): + validate_config_update({"gemini_api_key": 1.0}) + + +def test_error_message_contains_key_name() -> None: + with pytest.raises(ValueError, match="unsupported_key"): + validate_config_update({"unsupported_key": 1.0}) + + +# --------------------------------------------------------------------------- +# Boundary values – initial_balance (inclusive minimum 100.0, max 1_000_000.0) +# --------------------------------------------------------------------------- + + +def test_initial_balance_below_minimum_raises() -> None: + with pytest.raises(ValueError): + validate_config_update({"initial_balance": 99.99}) + + +def test_initial_balance_zero_raises() -> None: + with pytest.raises(ValueError): + validate_config_update({"initial_balance": 0.0}) + + +def test_initial_balance_negative_raises() -> None: + with pytest.raises(ValueError): + validate_config_update({"initial_balance": -1.0}) + + +def test_initial_balance_above_maximum_raises() -> None: + with pytest.raises(ValueError): + validate_config_update({"initial_balance": 1_000_001.0}) + + +def test_initial_balance_at_minimum_accepted() -> None: + assert validate_config_update({"initial_balance": 100.0}) == { + "initial_balance": 100.0 + } + + +# --------------------------------------------------------------------------- +# Boundary values – max_risk_per_trade (exclusive minimum 0.0, max 0.1) +# --------------------------------------------------------------------------- + + +def test_max_risk_per_trade_at_exclusive_minimum_raises() -> None: + with pytest.raises(ValueError): + validate_config_update({"max_risk_per_trade": 0.0}) + + +def test_max_risk_per_trade_above_maximum_raises() -> None: + with pytest.raises(ValueError): + validate_config_update({"max_risk_per_trade": 0.10001}) + + +def test_max_risk_per_trade_much_above_maximum_raises() -> None: + with pytest.raises(ValueError): + validate_config_update({"max_risk_per_trade": 0.5}) + + +def test_max_risk_per_trade_negative_raises() -> None: + with pytest.raises(ValueError): + validate_config_update({"max_risk_per_trade": -0.01}) + + +# --------------------------------------------------------------------------- +# Boundary values – max_daily_loss (exclusive minimum 0.0, max 0.2) +# --------------------------------------------------------------------------- + + +def test_max_daily_loss_at_exclusive_minimum_raises() -> None: + with pytest.raises(ValueError): + validate_config_update({"max_daily_loss": 0.0}) + + +def test_max_daily_loss_above_maximum_raises() -> None: + with pytest.raises(ValueError): + validate_config_update({"max_daily_loss": 0.201}) + + +def test_max_daily_loss_negative_raises() -> None: + with pytest.raises(ValueError): + validate_config_update({"max_daily_loss": -0.1}) + + +def test_max_daily_loss_at_maximum_accepted() -> None: + assert validate_config_update({"max_daily_loss": 0.2}) == {"max_daily_loss": 0.2} + + +# --------------------------------------------------------------------------- +# Return value is always a new dict (not mutated from input) +# --------------------------------------------------------------------------- + + +def test_returned_dict_is_independent_from_input() -> None: + original = {"initial_balance": 500.0} + result = validate_config_update(original) + result["initial_balance"] = 999.0 + assert original["initial_balance"] == 500.0 + + +# --------------------------------------------------------------------------- +# CONFIG_LIMITS constant structure +# --------------------------------------------------------------------------- + + +def test_config_limits_has_expected_keys() -> None: + assert set(CONFIG_LIMITS.keys()) == {"initial_balance", "max_risk_per_trade", "max_daily_loss"} + + +def test_config_limits_initial_balance_is_inclusive_minimum() -> None: + _min, _max, exclusive = CONFIG_LIMITS["initial_balance"] + assert exclusive is False # inclusive lower bound + + +def test_config_limits_risk_and_loss_are_exclusive_minimum() -> None: + for key in ("max_risk_per_trade", "max_daily_loss"): + _min, _max, exclusive = CONFIG_LIMITS[key] + assert exclusive is True, f"{key} should have exclusive minimum"