build 048: switch market analysis consumers to canonical Candle model

This commit is contained in:
2026-07-15 18:55:17 +03:00
parent 77e87c0504
commit 3a253d89a9
7 changed files with 1478 additions and 33 deletions

View File

@@ -0,0 +1,136 @@
# app/tests/unit/trading/market_analysis/indicators/test_volatility_candle.py
from __future__ import annotations
from datetime import datetime, timezone
from decimal import Decimal
from src.market_data.acquisition.models.candle import Candle
def _candle(
*,
index: int = 0,
open_price: str = "100",
high_price: str = "110",
low_price: str = "90",
close_price: str = "105",
volume: str = "10",
) -> Candle:
return Candle(
symbol="BTC/USD_LEVERAGE",
interval="1m",
open_time=datetime.fromtimestamp(
1_750_000_000 + index * 60,
tz=timezone.utc,
),
open_price=Decimal(open_price),
high_price=Decimal(high_price),
low_price=Decimal(low_price),
close_price=Decimal(close_price),
volume=Decimal(volume),
source="test",
)
from src.trading.market_analysis.indicators.volatility import (
atr,
atr_percent_baseline,
)
def test_atr_accepts_canonical_candles_with_decimal_values() -> None:
candles = [
_candle(
index=0,
high_price="105",
low_price="95",
close_price="100",
),
_candle(
index=1,
high_price="110",
low_price="95",
close_price="105",
),
_candle(
index=2,
high_price="112",
low_price="100",
close_price="108",
),
]
result = atr(candles, period=2)
assert result == 13.5
assert isinstance(result, float)
def test_atr_returns_none_when_candles_are_insufficient() -> None:
candles = [
_candle(index=0),
_candle(index=1),
]
assert atr(candles, period=2) is None
def test_atr_returns_none_for_non_positive_period() -> None:
candles = [
_candle(index=0),
_candle(index=1),
]
assert atr(candles, period=0) is None
def test_atr_skips_pair_with_non_finite_decimal_value() -> None:
candles = [
_candle(index=0, close_price="100"),
_candle(index=1, high_price="NaN", low_price="95"),
_candle(index=2, high_price="112", low_price="100"),
]
assert atr(candles, period=2) is None
def test_atr_percent_baseline_accepts_decimal_candles() -> None:
candles = [
_candle(
index=index,
open_price=str(100 + index),
high_price=str(105 + index),
low_price=str(95 + index),
close_price=str(101 + index),
)
for index in range(8)
]
result = atr_percent_baseline(
candles=candles,
close_price=108.0,
atr_period=2,
atr_baseline_window=8,
)
assert result is not None
assert isinstance(result, float)
assert result > 0
def test_atr_percent_baseline_returns_none_for_invalid_close_price() -> None:
candles = [
_candle(index=index)
for index in range(5)
]
assert (
atr_percent_baseline(
candles=candles,
close_price=0.0,
atr_period=2,
atr_baseline_window=5,
)
is None
)

View File

@@ -0,0 +1,154 @@
# app/tests/unit/trading/market_analysis/test_quality_candle.py
from __future__ import annotations
from datetime import datetime, timezone
from decimal import Decimal
from src.market_data.acquisition.models.candle import Candle
def _candle(
*,
index: int = 0,
open_price: str = "100",
high_price: str = "110",
low_price: str = "90",
close_price: str = "105",
volume: str = "10",
) -> Candle:
return Candle(
symbol="BTC/USD_LEVERAGE",
interval="1m",
open_time=datetime.fromtimestamp(
1_750_000_000 + index * 60,
tz=timezone.utc,
),
open_price=Decimal(open_price),
high_price=Decimal(high_price),
low_price=Decimal(low_price),
close_price=Decimal(close_price),
volume=Decimal(volume),
source="test",
)
from src.trading.market_analysis.quality import candle_noise_score
def test_candle_noise_score_accepts_decimal_candles() -> None:
candles = [
_candle(
index=0,
open_price="91",
high_price="100",
low_price="90",
close_price="99",
),
_candle(
index=1,
open_price="94",
high_price="100",
low_price="90",
close_price="96",
),
]
result = candle_noise_score(
candles,
candle_noise_window=2,
min_clean_body_ratio=0.5,
)
assert result == 0.5
assert isinstance(result, float)
def test_candle_noise_score_returns_none_for_empty_sequence() -> None:
assert (
candle_noise_score(
[],
candle_noise_window=10,
min_clean_body_ratio=0.5,
)
is None
)
def test_candle_noise_score_skips_zero_range_candle() -> None:
candles = [
_candle(
index=0,
open_price="100",
high_price="100",
low_price="100",
close_price="100",
),
_candle(
index=1,
open_price="91",
high_price="100",
low_price="90",
close_price="99",
),
]
result = candle_noise_score(
candles,
candle_noise_window=2,
min_clean_body_ratio=0.5,
)
assert result == 1.0
def test_candle_noise_score_skips_non_finite_decimal_value() -> None:
candles = [
_candle(
index=0,
high_price="NaN",
),
]
assert (
candle_noise_score(
candles,
candle_noise_window=1,
min_clean_body_ratio=0.5,
)
is None
)
def test_candle_noise_score_uses_requested_tail_window() -> None:
candles = [
_candle(
index=0,
open_price="91",
high_price="100",
low_price="90",
close_price="99",
),
_candle(
index=1,
open_price="94",
high_price="100",
low_price="90",
close_price="96",
),
_candle(
index=2,
open_price="92",
high_price="100",
low_price="90",
close_price="98",
),
]
result = candle_noise_score(
candles,
candle_noise_window=2,
min_clean_body_ratio=0.5,
)
assert result == 0.5

View File

@@ -0,0 +1,170 @@
# app/tests/unit/trading/market_analysis/test_structure_candle.py
from __future__ import annotations
from datetime import datetime, timezone
from decimal import Decimal
from src.market_data.acquisition.models.candle import Candle
def _candle(
*,
index: int = 0,
open_price: str = "100",
high_price: str = "110",
low_price: str = "90",
close_price: str = "105",
volume: str = "10",
) -> Candle:
return Candle(
symbol="BTC/USD_LEVERAGE",
interval="1m",
open_time=datetime.fromtimestamp(
1_750_000_000 + index * 60,
tz=timezone.utc,
),
open_price=Decimal(open_price),
high_price=Decimal(high_price),
low_price=Decimal(low_price),
close_price=Decimal(close_price),
volume=Decimal(volume),
source="test",
)
from src.trading.market_analysis.models import MarketStructure
from src.trading.market_analysis.structure import market_structure
def _structure_candles(
*,
highs: list[float],
lows: list[float],
) -> list[Candle]:
candles: list[Candle] = []
for index, (high, low) in enumerate(zip(highs, lows)):
midpoint = (high + low) / 2
candles.append(
_candle(
index=index,
open_price=str(midpoint),
high_price=str(high),
low_price=str(low),
close_price=str(midpoint),
)
)
return candles
def test_market_structure_detects_higher_highs_and_higher_lows() -> None:
candles = _structure_candles(
highs=[2, 4, 3, 5, 4, 6, 5, 7, 6, 5],
lows=[1, 0, 1, 0.5, 1.5, 1, 2, 1.5, 2.5, 2],
)
result, reason = market_structure(
candles,
atr_percent=0.30,
structure_window=10,
structure_swing_left=1,
structure_swing_right=1,
)
assert result == MarketStructure.HH_HL
assert reason.startswith("HIGHER_HIGH_HIGHER_LOW:")
def test_market_structure_detects_lower_highs_and_lower_lows() -> None:
candles = _structure_candles(
highs=[
8.0,
10.0,
9.0,
9.5,
8.5,
9.0,
8.0,
8.5,
7.5,
7.0,
],
lows=[
7.0,
6.0,
7.0,
5.5,
6.5,
5.0,
6.0,
4.5,
5.5,
5.0,
],
)
result, reason = market_structure(
candles,
atr_percent=0.30,
structure_window=10,
structure_swing_left=1,
structure_swing_right=1,
)
assert result == MarketStructure.LH_LL
assert reason.startswith("LOWER_HIGH_LOWER_LOW:")
def test_market_structure_detects_mixed_structure() -> None:
candles = _structure_candles(
highs=[2, 4, 3, 5, 4, 6, 5, 7, 6, 5],
lows=[5, 4, 5, 3, 4, 2, 3, 1, 2, 1.5],
)
result, reason = market_structure(
candles,
atr_percent=0.30,
structure_window=10,
structure_swing_left=1,
structure_swing_right=1,
)
assert result == MarketStructure.MIXED
assert reason.startswith("MIXED_MARKET_STRUCTURE:")
def test_market_structure_returns_unknown_when_candles_are_insufficient() -> None:
candles = [
_candle(index=index)
for index in range(5)
]
result, reason = market_structure(
candles,
structure_window=10,
structure_swing_left=1,
structure_swing_right=1,
)
assert result == MarketStructure.UNKNOWN
assert reason == "STRUCTURE_NOT_ENOUGH_CANDLES"
def test_market_structure_accepts_decimal_candle_values() -> None:
candles = _structure_candles(
highs=[2, 4, 3, 5, 4, 6, 5, 7, 6, 5],
lows=[1, 0, 1, 0.5, 1.5, 1, 2, 1.5, 2.5, 2],
)
result, _ = market_structure(
candles,
atr_percent=0.30,
structure_window=10,
structure_swing_left=1,
structure_swing_right=1,
)
assert result == MarketStructure.HH_HL