from __future__ import annotations import asyncio import os import tempfile import unittest from unittest.mock import patch from fastapi.testclient import TestClient from fastapi import HTTPException import backend.main as main class ApiRegressionTests(unittest.TestCase): @classmethod def setUpClass(cls) -> None: cls.client = TestClient(main.app) cls.symbol_a, cls.symbol_b = list(main.SYMBOLS.keys())[:2] def test_cache_stats_requires_runtime_admin_token(self) -> None: with patch.object(main, "ADMIN_TOKEN", "test-admin-token"): unauthorized = self.client.get("/api/cache/stats") self.assertEqual(unauthorized.status_code, 401) wrong_token = self.client.get( "/api/cache/stats", headers={"X-Admin-Token": "wrong-token"}, ) self.assertEqual(wrong_token.status_code, 401) authorized = self.client.get( "/api/cache/stats", headers={"X-Admin-Token": "test-admin-token"}, ) self.assertEqual(authorized.status_code, 200) def test_watchlist_tickers_deduplicates_and_reports_invalid_symbols(self) -> None: async def fake_fetch_ticker(symbol: str) -> dict[str, object]: return {"symbol": symbol, "price": 123.45} payload = { "symbols": [ self.symbol_a, self.symbol_a.lower(), "BINANCE:" + self.symbol_b, "NOT_A_REAL_SYMBOL", ] } with patch.object(main, "fetch_ticker", side_effect=fake_fetch_ticker): response = self.client.post("/api/watchlist/tickers", json=payload) self.assertEqual(response.status_code, 200) body = response.json() self.assertEqual(body["count"], 2) self.assertEqual(body["duplicate_count"], 1) self.assertEqual(body["invalid_symbols"], ["NOT_A_REAL_SYMBOL"]) self.assertEqual(sorted(body["tickers"].keys()), sorted([self.symbol_a, self.symbol_b])) def test_backtest_rejects_invalid_interval_before_fetch(self) -> None: response = self.client.get(f"/api/backtest/{self.symbol_a}", params={"interval": "13h"}) self.assertEqual(response.status_code, 400) self.assertIn("Unsupported interval", response.json()["detail"]) def test_volume_profile_rejects_invalid_bucket_count(self) -> None: response = self.client.get( f"/api/volume-profile/{self.symbol_a}", params={"buckets": 0}, ) self.assertEqual(response.status_code, 422) def test_ping_exposes_runtime_version(self) -> None: response = self.client.get("/api/ping") self.assertEqual(response.status_code, 200) self.assertEqual(response.json(), {"status": "ok", "version": main.APP_VERSION}) self.assertIn("X-Request-ID", response.headers) self.assertIn("X-Response-Time-Ms", response.headers) def test_real_strength_catalog_groups_dxy_usdx_and_strength_indexes(self) -> None: response = self.client.get("/api/symbols") self.assertEqual(response.status_code, 200) body = response.json() self.assertIn("Real Strength", body["categories"]) symbols = {entry["symbol"]: entry for entry in body["symbols"]} for symbol in ["DXY", "USDX", "EURX", "GBPX", "CHFX", "JPYX", "CADX", "AUDX", "NZDX"]: self.assertIn(symbol, symbols) self.assertEqual(symbols[symbol]["category"], "Real Strength") for symbol in ["USDX", "EURX", "GBPX", "CHFX", "JPYX", "CADX", "AUDX", "NZDX"]: self.assertEqual(symbols[symbol]["sources"], ["synthetic"]) self.assertEqual(symbols["DXY"]["sources"], ["twelvedata", "yfinance"]) def test_forex_source_priority_excludes_binance(self) -> None: self.assertEqual(main._get_source_priority("EURUSD"), ["twelvedata", "yfinance"]) self.assertNotIn("binance", main._get_source_priority("EURUSD")) self.assertEqual(main._get_source_priority("DXY"), ["yfinance", "twelvedata"]) def test_crypto_dynamic_source_mappings_are_derived_for_hf_safe_fallbacks(self) -> None: self.assertEqual(main._get_symbol_mapping("BTCUSD", "twelvedata"), "BTC/USD") self.assertEqual(main._get_symbol_mapping("BTCUSD", "yfinance"), "BTC-USD") self.assertEqual(main._get_symbol_mapping("BTCUSD", "finnhub"), "BINANCE:BTCUSDT") self.assertEqual(main._get_canonical_symbol("BTC-USD"), "BTCUSD") def test_crypto_source_priority_prefers_hf_safe_providers_on_hf(self) -> None: with patch.object(main.settings, "is_hf", True): self.assertEqual( main._get_source_priority("BTCUSD", "1d"), ["yfinance", "twelvedata", "finnhub", "coingecko", "binance", "bybit"], ) self.assertEqual( main._get_source_priority("BTCUSD", "1h"), ["twelvedata", "yfinance", "finnhub", "coingecko", "binance", "bybit"], ) def test_crypto_historical_fetch_on_hf_uses_hf_safe_fallback_first(self) -> None: sample_rows = [ {"time": i, "open": 1.0, "high": 1.1, "low": 0.9, "close": 1.0, "volume": 0.0} for i in range(1, 41) ] attempts: list[str] = [] async def fake_twelvedata(symbol: str, interval: str, limit: int) -> list[dict[str, float]]: attempts.append("twelvedata") return sample_rows[-limit:] async def fake_yfinance(symbol: str, interval: str, limit: int) -> list[dict[str, float]]: attempts.append("yfinance") raise RuntimeError("should not be needed after TwelveData success") with patch.object(main.settings, "is_hf", True), patch.object( main, "fetch_twelvedata", side_effect=fake_twelvedata, ), patch.object( main, "fetch_yfinance", side_effect=fake_yfinance, ): rows, source = asyncio.run( main._run_historical_fetch("BTCUSD", "1h", 40, "hist_btcusd_1h_hf_test") ) self.assertEqual(source, "twelvedata") self.assertEqual(len(rows), 40) self.assertEqual(attempts, ["twelvedata"]) def test_historical_fetch_falls_back_after_provider_http_error(self) -> None: sample_rows = [ {"time": i, "open": 1.0, "high": 1.1, "low": 0.9, "close": 1.0, "volume": 0.0} for i in range(1, 41) ] async def fake_twelvedata(symbol: str, interval: str, limit: int) -> list[dict[str, float]]: raise HTTPException(status_code=429, detail="rate limit") async def fake_yfinance(symbol: str, interval: str, limit: int) -> list[dict[str, float]]: return sample_rows[-limit:] with patch.object(main, "fetch_twelvedata", side_effect=fake_twelvedata), patch.object( main, "fetch_yfinance", side_effect=fake_yfinance, ): rows, source = asyncio.run( main._run_historical_fetch("EURUSD", "1d", 40, "hist_eurusd_1d_test") ) self.assertEqual(source, "yfinance") self.assertEqual(len(rows), 40) def test_clear_all_cache_rejects_unknown_target(self) -> None: with patch.object(main, "ADMIN_TOKEN", "test-admin-token"): response = self.client.delete( "/api/cache", params={"target": "invalid-target"}, headers={"X-Admin-Token": "test-admin-token"}, ) self.assertEqual(response.status_code, 400) self.assertIn("Unsupported cache target", response.json()["detail"]) def test_clear_all_cache_accepts_indicators_target(self) -> None: with patch.object(main, "ADMIN_TOKEN", "test-admin-token"): response = self.client.delete( "/api/cache", params={"target": "indicators"}, headers={"X-Admin-Token": "test-admin-token"}, ) self.assertEqual(response.status_code, 200) self.assertIn("indicators", response.json()["cleared"]) def test_metrics_include_request_observability_snapshot(self) -> None: self.client.get("/api/ping") with patch.object(main, "ADMIN_TOKEN", "test-admin-token"): response = self.client.get( "/api/metrics", headers={"X-Admin-Token": "test-admin-token"}, ) self.assertEqual(response.status_code, 200) body = response.json() self.assertIn("request_metrics", body) self.assertIn("/api/ping", body["request_metrics"]["routes"]) def test_vote_gauge_uses_equal_weight_formula(self) -> None: gauge = main._calc_vote_gauge(buy=4, sell=12, neutral=10) self.assertAlmostEqual(gauge, 34.6153846154, places=6) def test_technical_score_uses_total_votes_without_bonus_bias(self) -> None: tech = main._calc_technical_score_v2( osc_score={ "gauge": 80.0, "buy": 2, "sell": 6, "neutral": 4, "buy_weight": 10.0, "sell_weight": 10.0, "neutral_weight": 10.0, }, ma_score={ "gauge": 20.0, "buy": 2, "sell": 6, "neutral": 6, "buy_weight": 10.0, "sell_weight": 10.0, "neutral_weight": 10.0, "golden_cross": True, "death_cross": False, }, regime="stable_bull", interval="1h", ) self.assertEqual(tech["buy"], 4) self.assertEqual(tech["sell"], 12) self.assertEqual(tech["neutral"], 10) self.assertAlmostEqual(tech["gauge"], 34.6, places=1) def test_ma_score_cross_flags_are_plain_python_bools(self) -> None: ma_score = main._calc_ma_score( ma_data=[ {"name": "EMA1 / EMA5", "key": "ema_1_5", "action": "Mua"}, {"name": "EMA5 / EMA10", "key": "ema_5_10", "action": "Mua"}, ], closes=main.np.linspace(1.0, 10.0, 220), interval="1d", ) self.assertIs(type(ma_score["golden_cross"]), bool) self.assertIs(type(ma_score["death_cross"]), bool) def test_local_verdict_is_fast_and_deterministic(self) -> None: verdict = main._derive_local_verdict( analysis={ "summary": {"bias": "bullish"}, "technicals": {"gauge": 63.0}, "ai_gauge": {"gauge": 61.0, "confidence_pct": 58.0}, "multi_timeframe": {"alignment": True}, }, forecast_pct=1.2, ) self.assertEqual(verdict, "Mua ngay") def test_summary_score_uses_ai_certainty_weighting(self) -> None: summary = main._calc_summary_score_v2( tech_score={ "gauge": 30.0, "buy": 4, "sell": 12, "neutral": 10, "buy_weight": 0.2, "sell_weight": 0.5, "neutral_weight": 0.3, "components": { "oscillators": 28.0, "moving_averages": 32.0, }, }, ai_score={ "gauge": 35.0, "certainty": 40.0, }, interval="1d", ) self.assertEqual(summary["gauge"], 32.0) self.assertEqual(summary["components"]["ai_weight"], 0.4) self.assertEqual(summary["components"]["technical_weight"], 0.6) def test_synthetic_component_candles_support_product_and_ratio(self) -> None: product = main._combine_component_candles( main.SyntheticComponentSpec(name="EURCAD", mode="product", left_symbol="EURUSD", right_symbol="USDCAD"), {"open": 1.1, "high": 1.2, "low": 1.0, "close": 1.15}, {"open": 1.3, "high": 1.4, "low": 1.2, "close": 1.35}, ) ratio = main._combine_component_candles( main.SyntheticComponentSpec(name="EURAUD", mode="ratio", left_symbol="EURUSD", right_symbol="AUDUSD"), {"open": 1.1, "high": 1.2, "low": 1.0, "close": 1.15}, {"open": 0.7, "high": 0.8, "low": 0.6, "close": 0.75}, ) inverse = main._combine_component_candles( main.SyntheticComponentSpec(name="EURGBP", mode="inverse", left_symbol="EURGBP"), {"open": 0.85, "high": 0.86, "low": 0.84, "close": 0.855}, ) self.assertAlmostEqual(product["open"], 1.43, places=6) self.assertAlmostEqual(product["high"], 1.68, places=6) self.assertAlmostEqual(product["low"], 1.2, places=6) self.assertAlmostEqual(ratio["open"], 1.5714285714, places=6) self.assertAlmostEqual(ratio["high"], 2.0, places=6) self.assertAlmostEqual(ratio["low"], 1.25, places=6) self.assertAlmostEqual(inverse["open"], 1 / 0.85, places=6) self.assertAlmostEqual(inverse["high"], 1 / 0.84, places=6) self.assertAlmostEqual(inverse["low"], 1 / 0.86, places=6) def test_synthetic_component_candles_use_body_only_extrema_for_intraday_product_and_ratio(self) -> None: direct = main._combine_component_candles( main.SyntheticComponentSpec(name="EURUSD", mode="direct", left_symbol="EURUSD"), {"open": 1.1, "high": 1.2, "low": 1.0, "close": 1.15}, use_body_only_extrema=True, ) inverse = main._combine_component_candles( main.SyntheticComponentSpec(name="EURGBP", mode="inverse", left_symbol="EURGBP"), {"open": 0.85, "high": 0.86, "low": 0.84, "close": 0.855}, use_body_only_extrema=True, ) product = main._combine_component_candles( main.SyntheticComponentSpec(name="EURCAD", mode="product", left_symbol="EURUSD", right_symbol="USDCAD"), {"open": 1.1, "high": 1.2, "low": 1.0, "close": 1.15}, {"open": 1.3, "high": 1.4, "low": 1.2, "close": 1.35}, use_body_only_extrema=True, ) ratio = main._combine_component_candles( main.SyntheticComponentSpec(name="EURAUD", mode="ratio", left_symbol="EURUSD", right_symbol="AUDUSD"), {"open": 1.1, "high": 1.2, "low": 1.0, "close": 1.15}, {"open": 0.7, "high": 0.8, "low": 0.6, "close": 0.75}, use_body_only_extrema=True, ) self.assertAlmostEqual(direct["open"], 1.1, places=6) self.assertAlmostEqual(direct["close"], 1.15, places=6) self.assertAlmostEqual(direct["high"], 1.15, places=6) self.assertAlmostEqual(direct["low"], 1.1, places=6) self.assertAlmostEqual(inverse["open"], 1 / 0.85, places=6) self.assertAlmostEqual(inverse["close"], 1 / 0.855, places=6) self.assertAlmostEqual(inverse["high"], 1 / 0.85, places=6) self.assertAlmostEqual(inverse["low"], 1 / 0.855, places=6) self.assertAlmostEqual(product["open"], 1.43, places=6) self.assertAlmostEqual(product["close"], 1.5525, places=6) self.assertAlmostEqual(product["high"], 1.5525, places=6) self.assertAlmostEqual(product["low"], 1.43, places=6) self.assertAlmostEqual(ratio["open"], 1.5714285714, places=6) self.assertAlmostEqual(ratio["close"], 1.5333333333, places=6) self.assertAlmostEqual(ratio["high"], 1.5714285714, places=6) self.assertAlmostEqual(ratio["low"], 1.5333333333, places=6) def test_synthetic_eurx_history_builds_from_component_series(self) -> None: base_rows = { "EURUSD": [ {"time": 1, "open": 1.10, "high": 1.11, "low": 1.09, "close": 1.105, "volume": 100.0}, {"time": 2, "open": 1.11, "high": 1.12, "low": 1.10, "close": 1.115, "volume": 100.0}, {"time": 3, "open": 1.12, "high": 1.13, "low": 1.11, "close": 1.125, "volume": 100.0}, ], "EURGBP": [ {"time": 1, "open": 0.85, "high": 0.86, "low": 0.84, "close": 0.855, "volume": 100.0}, {"time": 2, "open": 0.855, "high": 0.865, "low": 0.845, "close": 0.86, "volume": 100.0}, {"time": 3, "open": 0.86, "high": 0.87, "low": 0.85, "close": 0.865, "volume": 100.0}, ], "USDCHF": [ {"time": 1, "open": 0.90, "high": 0.91, "low": 0.89, "close": 0.905, "volume": 100.0}, {"time": 2, "open": 0.905, "high": 0.915, "low": 0.895, "close": 0.91, "volume": 100.0}, {"time": 3, "open": 0.91, "high": 0.92, "low": 0.90, "close": 0.915, "volume": 100.0}, ], "EURJPY": [ {"time": 1, "open": 160.0, "high": 161.0, "low": 159.0, "close": 160.5, "volume": 100.0}, {"time": 2, "open": 160.5, "high": 161.5, "low": 159.5, "close": 161.0, "volume": 100.0}, {"time": 3, "open": 161.0, "high": 162.0, "low": 160.0, "close": 161.5, "volume": 100.0}, ], "USDCAD": [ {"time": 1, "open": 1.35, "high": 1.36, "low": 1.34, "close": 1.355, "volume": 100.0}, {"time": 2, "open": 1.355, "high": 1.365, "low": 1.345, "close": 1.36, "volume": 100.0}, {"time": 3, "open": 1.36, "high": 1.37, "low": 1.35, "close": 1.365, "volume": 100.0}, ], "AUDUSD": [ {"time": 1, "open": 0.66, "high": 0.67, "low": 0.65, "close": 0.665, "volume": 100.0}, {"time": 2, "open": 0.665, "high": 0.675, "low": 0.655, "close": 0.67, "volume": 100.0}, {"time": 3, "open": 0.67, "high": 0.68, "low": 0.66, "close": 0.675, "volume": 100.0}, ], "NZDUSD": [ {"time": 1, "open": 0.61, "high": 0.62, "low": 0.60, "close": 0.615, "volume": 100.0}, {"time": 2, "open": 0.615, "high": 0.625, "low": 0.605, "close": 0.62, "volume": 100.0}, {"time": 3, "open": 0.62, "high": 0.63, "low": 0.61, "close": 0.625, "volume": 100.0}, ], } async def fake_fetch_from_source( source: str, symbol: str, interval: str, limit: int, ) -> tuple[list[dict[str, float]], str]: self.assertIn(source, {"twelvedata", "finnhub", "yfinance"}) return base_rows[symbol][-limit:] with patch.object(main, "_fetch_historical_from_source", side_effect=fake_fetch_from_source): rows, source = asyncio.run( main._build_synthetic_symbol_history("EURX", "1h", 3, "test-cache-key") ) self.assertEqual(len(rows), 3) self.assertEqual(rows[-1]["time"], 3) self.assertEqual(rows[-1]["volume"], 0.0) self.assertEqual(source, "synthetic:twelvedata") self.assertGreater(rows[-1]["close"], 0.0) def test_synthetic_ticker_uses_requested_interval_instead_of_forcing_5m(self) -> None: calls: list[tuple[str, str, int, int]] = [] async def fake_fetch_historical( symbol: str, interval: str, limit: int, min_context: int = 0, **_: object, ) -> tuple[list[dict[str, float]], str]: calls.append((symbol, interval, limit, min_context)) rows = [ {"time": 1, "open": 1.0, "high": 1.1, "low": 0.9, "close": 1.0, "volume": 0.0}, {"time": 2, "open": 1.0, "high": 1.2, "low": 0.95, "close": 1.1, "volume": 0.0}, ] return rows, "synthetic:test" with patch.object(main, "fetch_historical", side_effect=fake_fetch_historical): ticker = asyncio.run(main.fetch_ticker("CHFX", interval="1d")) self.assertEqual(ticker["symbol"], "CHFX") self.assertEqual(calls, [("CHFX", "1d", 2, 2)]) def test_synthetic_history_uses_single_source_and_requested_interval(self) -> None: calls: list[tuple[str, str, str, int]] = [] async def fake_fetch_from_source( source: str, symbol: str, interval: str, limit: int, ) -> list[dict[str, float]]: calls.append((source, symbol, interval, limit)) return [ {"time": 1, "open": 1.0, "high": 1.1, "low": 0.9, "close": 1.0, "volume": 0.0}, {"time": 2, "open": 1.0, "high": 1.2, "low": 0.95, "close": 1.1, "volume": 0.0}, {"time": 3, "open": 1.1, "high": 1.25, "low": 1.0, "close": 1.15, "volume": 0.0}, ] with patch.object(main, "_fetch_historical_from_source", side_effect=fake_fetch_from_source): rows, source = asyncio.run( main._build_synthetic_symbol_history("CHFX", "4h", 3, "synthetic-chfx-4h") ) self.assertEqual(len(rows), 3) self.assertEqual(source, "synthetic:twelvedata") self.assertTrue(calls) self.assertEqual({item[0] for item in calls}, {"twelvedata"}) self.assertEqual({item[2] for item in calls}, {"4h"}) def test_kronos_feature_prep_collapses_prices_to_ohlc4_and_zeroes_volume_channels(self) -> None: df = main.pd.DataFrame( [ {"open": 1.0, "high": 1.1, "low": 0.9, "close": 1.05, "volume": 0.0, "amount": 0.0}, {"open": 1.05, "high": 1.15, "low": 0.95, "close": 1.1, "volume": 0.0, "amount": 0.0}, ] ) prepared = main.KronosForecaster._prepare_feature_frame(df) expected_ohlc4 = df[["open", "high", "low", "close"]].mean(axis=1).astype(main.np.float32) self.assertEqual(list(prepared.columns), ["ohlc4"]) self.assertTrue(main.np.allclose(prepared["ohlc4"].values, expected_ohlc4.values)) def test_kronos_feature_prep_ignores_upstream_volume_and_amount_noise(self) -> None: df = main.pd.DataFrame( [ {"open": 10.0, "high": 11.0, "low": 9.0, "close": 10.5, "volume": 100.0, "amount": 1050.0}, {"open": 11.0, "high": 12.0, "low": 10.0, "close": 11.5, "volume": 200.0, "amount": 2300.0}, ] ) prepared = main.KronosForecaster._prepare_feature_frame(df) expected_ohlc4 = df[["open", "high", "low", "close"]].mean(axis=1).astype(main.np.float32) self.assertEqual(list(prepared.columns), ["ohlc4"]) self.assertTrue(main.np.allclose(prepared["ohlc4"].values, expected_ohlc4.values)) def test_kronos_normalization_uses_std_plus_epsilon_contract(self) -> None: x = main.np.array( [ [1.0, 1.1, 0.9, 1.0, 0.0, 0.0], [1.1, 1.2, 1.0, 1.1, 0.0, 0.0], [1.2, 1.3, 1.1, 1.2, 0.0, 0.0], ], dtype=main.np.float32, ) x_norm, x_mean, x_scale = main.KronosForecaster._normalize_feature_matrix(x, clip=5.0) expected_scale = main.np.std(x, axis=0).astype(main.np.float32) + 1e-5 expected_norm = main.np.clip((x - x_mean) / expected_scale, -5.0, 5.0) self.assertTrue(main.np.allclose(x_scale, expected_scale)) self.assertTrue(main.np.allclose(x_norm, expected_norm)) def test_forecast_payload_schema_guard_rejects_legacy_blended_payload(self) -> None: legacy_payload = { "forecast": [{"time": 1, "p10": 1.0, "p50": 1.1, "p90": 1.2}], "forecast_candles": [], "display": {"mode": "raw_kronos_ohlc_p50"}, "ensemble": {"mode": "kronos_plus_anchor", "confidence": 55.0}, "model": { "input_semantics": { "feature_channels": ["open", "high", "low", "close", "volume", "amount"], "price_mode": "ohlc4_replicated_across_ohlc", "base_signal": "ohlc4", "volume_mode": "forced_zero", "amount_mode": "forced_zero", "active_forecast_channels": ["ohlc4"], } }, } self.assertFalse(main._forecast_payload_is_current(legacy_payload)) def test_forecast_payload_schema_guard_accepts_current_ohlc4_input_mode(self) -> None: current_payload = { "forecast": [{"time": 1, "p10": 1.0, "p50": 1.1, "p90": 1.2}], "display": { "mode": "raw_kronos_ohlc4_line", "channels": ["ohlc4"], "output_mode": "single_future_ohlc4_line", }, "model": { "input_semantics": { "feature_channels": ["ohlc4"], "price_mode": "ohlc4_single_channel", "base_signal": "ohlc4", "volume_mode": "omitted", "amount_mode": "omitted", "active_forecast_channels": ["ohlc4"], "adapter_mode": "tokenizer_6ch_to_1ch_ohlc4", }, "output_semantics": { "forecast_channel": "ohlc4", "forecast_mode": "single_future_ohlc4_line", "candle_projection": "omitted", } }, } self.assertTrue(main._forecast_payload_is_current(current_payload)) def test_forecast_payload_schema_guard_rejects_legacy_forecast_candles_field(self) -> None: stale_payload = { "forecast": [{"time": 1, "p10": 1.0, "p50": 1.1, "p90": 1.2}], "forecast_candles": [], "display": { "mode": "raw_kronos_ohlc4_line", "channels": ["ohlc4"], "output_mode": "single_future_ohlc4_line", }, "model": { "input_semantics": { "feature_channels": ["ohlc4"], "price_mode": "ohlc4_single_channel", "base_signal": "ohlc4", "volume_mode": "omitted", "amount_mode": "omitted", "active_forecast_channels": ["ohlc4"], "adapter_mode": "tokenizer_6ch_to_1ch_ohlc4", }, "output_semantics": { "forecast_channel": "ohlc4", "forecast_mode": "single_future_ohlc4_line", "candle_projection": "omitted", }, }, } self.assertFalse(main._forecast_payload_is_current(stale_payload)) def test_finalize_forecast_error_payload_omits_legacy_forecast_candles_field(self) -> None: payload = { "symbol": self.symbol_a, "interval": "1h", "forecast_rows": [], "error": "offline", "path_checked": "test-path", "ai_runtime": {"mode": "local_only", "model": "offline"}, } response = asyncio.run(main._finalize_forecast_response_payload(payload)) self.assertNotIn("forecast_candles", response) self.assertEqual(response["display"]["output_mode"], "single_future_ohlc4_line") def test_synthetic_component_history_reuses_source_cache(self) -> None: calls: list[tuple[str, str, str, int]] = [] async def fake_fetch_from_source( source: str, symbol: str, interval: str, limit: int, ) -> list[dict[str, float]]: calls.append((source, symbol, interval, limit)) return [ {"time": 1, "open": 1.0, "high": 1.1, "low": 0.9, "close": 1.0, "volume": 0.0}, {"time": 2, "open": 1.0, "high": 1.2, "low": 0.95, "close": 1.1, "volume": 0.0}, {"time": 3, "open": 1.1, "high": 1.25, "low": 1.0, "close": 1.15, "volume": 0.0}, ] main.source_history_cache.clear() main._SOURCE_HISTORY_INFLIGHT.clear() try: with patch.object(main, "_fetch_historical_from_source", side_effect=fake_fetch_from_source): asyncio.run(main._build_synthetic_symbol_history("EURX", "1d", 3, "synthetic-eurx-1")) asyncio.run(main._build_synthetic_symbol_history("EURX", "1d", 3, "synthetic-eurx-2")) finally: main.source_history_cache.clear() main._SOURCE_HISTORY_INFLIGHT.clear() unique_component_symbols = {item[1] for item in calls} self.assertEqual(len(calls), len(unique_component_symbols)) self.assertEqual(unique_component_symbols, {"AUDUSD", "EURGBP", "EURJPY", "EURUSD", "NZDUSD", "USDCAD", "USDCHF"}) def test_ttl_cache_returns_defensive_copy(self) -> None: cache = main.TTLCache() payload = {"forecast": [{"price": 100.0}], "meta": {"source": "memory"}} cache.set("sample", payload, ttl_seconds=30) payload["forecast"][0]["price"] = 999.0 payload["meta"]["source"] = "mutated" cached_once = cache.get("sample") self.assertEqual(cached_once["forecast"][0]["price"], 100.0) self.assertEqual(cached_once["meta"]["source"], "memory") cached_once["forecast"][0]["price"] = 555.0 cached_twice = cache.get("sample") self.assertEqual(cached_twice["forecast"][0]["price"], 100.0) def test_persistent_cache_queue_copies_payload_before_enqueue(self) -> None: class FakeQueue: def __init__(self) -> None: self.item = None def put_nowait(self, item: tuple[str, object, int]) -> None: self.item = item fd, temp_path = tempfile.mkstemp(suffix=".db") os.close(fd) try: cache = main.PersistentCache( db_path=temp_path, cache_version_getter=lambda: "test-version", logger=main.logger, ) queue = FakeQueue() cache._queue = queue payload = {"nested": {"value": 1}} cache.set("queued", payload, ttl=60) payload["nested"]["value"] = 7 self.assertIsNotNone(queue.item) _, queued_payload, queued_ttl = queue.item self.assertEqual(queued_ttl, 60) self.assertEqual(queued_payload["nested"]["value"], 1) finally: if os.path.exists(temp_path): try: os.remove(temp_path) except PermissionError: pass if __name__ == "__main__": unittest.main()