diff --git a/app/market/market_analysis.py b/app/market/market_analysis.py index 1d3e93e..17c8b99 100644 --- a/app/market/market_analysis.py +++ b/app/market/market_analysis.py @@ -187,32 +187,28 @@ def _compute_trend_metrics( # Simple ADX approximation (simplified version) if len(medium) >= 14: - # Directional movement - plus_dm = [] - minus_dm = [] + # Directional movement. The simplified ADX below only uses up to + # the final 14 values, so skip older bars instead of materializing + # full-window arrays for every feature computation. + plus_dm_sum = 0 + minus_dm_sum = 0 - for i in range(1, len(medium)): + for i in range(max(1, len(medium) - 14), len(medium)): up_move = medium[i].high - medium[i-1].high down_move = medium[i-1].low - medium[i].low if up_move > down_move and up_move > 0: - plus_dm.append(up_move) - else: - plus_dm.append(0) + plus_dm_sum += up_move if down_move > up_move and down_move > 0: - minus_dm.append(down_move) - else: - minus_dm.append(0) + minus_dm_sum += down_move # Smooth and compute ADX (simplified) - if plus_dm and minus_dm: - avg_plus = sum(plus_dm[-14:]) / 14 - avg_minus = sum(minus_dm[-14:]) / 14 - - if avg_plus + avg_minus > 0: - dx = 100 * abs(avg_plus - avg_minus) / (avg_plus + avg_minus) - metrics["adx"] = dx + if plus_dm_sum + minus_dm_sum > 0: + avg_plus = plus_dm_sum / 14 + avg_minus = minus_dm_sum / 14 + dx = 100 * abs(avg_plus - avg_minus) / (avg_plus + avg_minus) + metrics["adx"] = dx # Moving average slope (20 period) if len(medium) >= 20: diff --git a/tests/test_market_analysis.py b/tests/test_market_analysis.py new file mode 100644 index 0000000..56eebf8 --- /dev/null +++ b/tests/test_market_analysis.py @@ -0,0 +1,93 @@ +from datetime import datetime, timedelta, timezone + +from app.market.market_analysis import MarketAnalyzer +from app.schemas import Candle + + +def _reference_trend_metrics(recent, medium, long): + metrics = {} + + if len(medium) >= 14: + plus_dm = [] + minus_dm = [] + + for i in range(1, len(medium)): + up_move = medium[i].high - medium[i - 1].high + down_move = medium[i - 1].low - medium[i].low + + if up_move > down_move and up_move > 0: + plus_dm.append(up_move) + else: + plus_dm.append(0) + + if down_move > up_move and down_move > 0: + minus_dm.append(down_move) + else: + minus_dm.append(0) + + if plus_dm and minus_dm: + avg_plus = sum(plus_dm[-14:]) / 14 + avg_minus = sum(minus_dm[-14:]) / 14 + + if avg_plus + avg_minus > 0: + dx = 100 * abs(avg_plus - avg_minus) / (avg_plus + avg_minus) + metrics["adx"] = dx + + if len(medium) >= 20: + price_change = medium[-1].close - medium[-20].close + + if medium[-20].close > 0: + metrics["ma_slope"] = price_change / medium[-20].close + + if len(recent) >= 10 and len(medium) >= 20: + recent_range = max(c.high for c in recent[-10:]) - min(c.low for c in recent[-10:]) + medium_range = max(c.high for c in medium[-20:]) - min(c.low for c in medium[-20:]) + + if medium_range > 0: + range_ratio = recent_range / medium_range + metrics["range_compression_flag"] = range_ratio < 0.7 + metrics["range_expansion_flag"] = range_ratio > 1.3 + + return metrics + + +def _candles(count): + start = datetime(2026, 1, 1, tzinfo=timezone.utc) + candles = [] + close = 100.0 + for i in range(count): + close += ((i % 9) - 4) * 0.23 + candles.append( + Candle( + timestamp=start + timedelta(minutes=15 * i), + open=close - 0.15, + high=close + 1.0 + (i % 4) * 0.17, + low=close - 0.8 - (i % 6) * 0.11, + close=close, + volume=1000 + (i % 23) * 13, + ) + ) + return candles + + +def test_compute_trend_metrics_matches_reference_full_dm_arrays(): + candles = _candles(2016) + recent = candles[-288:] + medium = candles[-2016:] + long = candles + + assert MarketAnalyzer._compute_trend_metrics(recent, medium, long) == _reference_trend_metrics( + recent, + medium, + long, + ) + + +def test_compute_trend_metrics_preserves_short_window_behavior(): + candles = _candles(14) + + assert MarketAnalyzer._compute_trend_metrics(candles, candles, candles) == _reference_trend_metrics( + candles, + candles, + candles, + )