mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-03-29 21:03:40 +00:00
refactor(config): use negative values for Best Price min_distance
Best Price min_distance now uses negative values (-50 to 0) to match semantic meaning "below average". Peak Price continues using positive values (0 to 50) for "above average". Uniform formula: avg * (1 + distance/100) works for both period types. Sign indicates direction: negative = toward MIN (cheap), positive = toward MAX (expensive). Changes: - const.py: DEFAULT_BEST_PRICE_MIN_DISTANCE_FROM_AVG = -5 (was 5) - schemas.py: Best Price range -50 to 0, Peak Price range 0 to 50 - validators.py: Separate validate_best_price_distance_percentage() - level_filtering.py: Simplified to uniform formula (removed conditionals) - translations: Separate error messages for Best/Peak distance validation - tests: 37 comprehensive validator tests (100% coverage) Impact: Configuration UI now visually represents direction relative to average. Users see intuitive negative values for "below average" pricing.
This commit is contained in:
parent
215ac02302
commit
9a6eb44382
12 changed files with 505 additions and 33 deletions
|
|
@ -18,6 +18,7 @@ from custom_components.tibber_prices.config_flow_handlers.schemas import (
|
|||
get_volatility_schema,
|
||||
)
|
||||
from custom_components.tibber_prices.config_flow_handlers.validators import (
|
||||
validate_best_price_distance_percentage,
|
||||
validate_distance_percentage,
|
||||
validate_flex_percentage,
|
||||
validate_gap_count,
|
||||
|
|
@ -237,11 +238,11 @@ class TibberPricesOptionsFlowHandler(OptionsFlow):
|
|||
if CONF_BEST_PRICE_FLEX in user_input and not validate_flex_percentage(user_input[CONF_BEST_PRICE_FLEX]):
|
||||
errors[CONF_BEST_PRICE_FLEX] = "invalid_flex"
|
||||
|
||||
# Validate distance from average
|
||||
if CONF_BEST_PRICE_MIN_DISTANCE_FROM_AVG in user_input and not validate_distance_percentage(
|
||||
# Validate distance from average (Best Price uses negative values)
|
||||
if CONF_BEST_PRICE_MIN_DISTANCE_FROM_AVG in user_input and not validate_best_price_distance_percentage(
|
||||
user_input[CONF_BEST_PRICE_MIN_DISTANCE_FROM_AVG]
|
||||
):
|
||||
errors[CONF_BEST_PRICE_MIN_DISTANCE_FROM_AVG] = "invalid_distance"
|
||||
errors[CONF_BEST_PRICE_MIN_DISTANCE_FROM_AVG] = "invalid_best_price_distance"
|
||||
|
||||
# Validate minimum periods count
|
||||
if CONF_MIN_PERIODS_BEST in user_input and not validate_min_periods(user_input[CONF_MIN_PERIODS_BEST]):
|
||||
|
|
@ -285,11 +286,11 @@ class TibberPricesOptionsFlowHandler(OptionsFlow):
|
|||
if CONF_PEAK_PRICE_FLEX in user_input and not validate_flex_percentage(user_input[CONF_PEAK_PRICE_FLEX]):
|
||||
errors[CONF_PEAK_PRICE_FLEX] = "invalid_flex"
|
||||
|
||||
# Validate distance from average
|
||||
# Validate distance from average (Peak Price uses positive values)
|
||||
if CONF_PEAK_PRICE_MIN_DISTANCE_FROM_AVG in user_input and not validate_distance_percentage(
|
||||
user_input[CONF_PEAK_PRICE_MIN_DISTANCE_FROM_AVG]
|
||||
):
|
||||
errors[CONF_PEAK_PRICE_MIN_DISTANCE_FROM_AVG] = "invalid_distance"
|
||||
errors[CONF_PEAK_PRICE_MIN_DISTANCE_FROM_AVG] = "invalid_peak_price_distance"
|
||||
|
||||
# Validate minimum periods count
|
||||
if CONF_MIN_PERIODS_PEAK in user_input and not validate_min_periods(user_input[CONF_MIN_PERIODS_PEAK]):
|
||||
|
|
|
|||
|
|
@ -312,8 +312,8 @@ def get_best_price_schema(options: Mapping[str, Any]) -> vol.Schema:
|
|||
),
|
||||
): NumberSelector(
|
||||
NumberSelectorConfig(
|
||||
min=0,
|
||||
max=50,
|
||||
min=-50,
|
||||
max=0,
|
||||
step=1,
|
||||
unit_of_measurement="%",
|
||||
mode=NumberSelectorMode.SLIDER,
|
||||
|
|
|
|||
|
|
@ -128,7 +128,7 @@ def validate_min_periods(count: int) -> bool:
|
|||
|
||||
def validate_distance_percentage(distance: float) -> bool:
|
||||
"""
|
||||
Validate distance from average percentage is reasonable.
|
||||
Validate distance from average percentage (for Peak Price - positive values).
|
||||
|
||||
Args:
|
||||
distance: Distance percentage (0-50% is typical range)
|
||||
|
|
@ -140,6 +140,20 @@ def validate_distance_percentage(distance: float) -> bool:
|
|||
return 0.0 <= distance <= MAX_DISTANCE_PERCENTAGE
|
||||
|
||||
|
||||
def validate_best_price_distance_percentage(distance: float) -> bool:
|
||||
"""
|
||||
Validate distance from average percentage (for Best Price - negative values).
|
||||
|
||||
Args:
|
||||
distance: Distance percentage (-50% to 0% range, negative = below average)
|
||||
|
||||
Returns:
|
||||
True if distance is valid (-MAX_DISTANCE_PERCENTAGE to 0)
|
||||
|
||||
"""
|
||||
return -MAX_DISTANCE_PERCENTAGE <= distance <= 0.0
|
||||
|
||||
|
||||
def validate_gap_count(count: int) -> bool:
|
||||
"""
|
||||
Validate gap count is within bounds.
|
||||
|
|
|
|||
|
|
@ -62,8 +62,12 @@ DEFAULT_BEST_PRICE_FLEX = 15 # 15% base flexibility - optimal for relaxation mo
|
|||
# (e.g., -20% means MAX * 0.8), not above the average price.
|
||||
# A higher percentage allows for more conservative detection, reducing false negatives for peak price warnings.
|
||||
DEFAULT_PEAK_PRICE_FLEX = -20 # 20% base flexibility (user-facing, percent)
|
||||
DEFAULT_BEST_PRICE_MIN_DISTANCE_FROM_AVG = 5 # 5% minimum distance from daily average (ensures significance)
|
||||
DEFAULT_PEAK_PRICE_MIN_DISTANCE_FROM_AVG = 5 # 5% minimum distance from daily average (ensures significance)
|
||||
DEFAULT_BEST_PRICE_MIN_DISTANCE_FROM_AVG = (
|
||||
-5
|
||||
) # -5% minimum distance from daily average (below average, ensures significance)
|
||||
DEFAULT_PEAK_PRICE_MIN_DISTANCE_FROM_AVG = (
|
||||
5 # 5% minimum distance from daily average (above average, ensures significance)
|
||||
)
|
||||
DEFAULT_BEST_PRICE_MIN_PERIOD_LENGTH = 60 # 60 minutes minimum period length for best price (user-facing, minutes)
|
||||
# Note: Peak price warnings are allowed for shorter periods (30 min) than best price periods (60 min).
|
||||
# This asymmetry is intentional: shorter peak periods are acceptable for alerting users to brief expensive spikes,
|
||||
|
|
|
|||
|
|
@ -117,11 +117,43 @@ def check_interval_criteria(
|
|||
Tuple of (in_flex, meets_min_distance)
|
||||
|
||||
"""
|
||||
# Calculate percentage difference from reference
|
||||
percent_diff = ((price - criteria.ref_price) / criteria.ref_price) * 100 if criteria.ref_price != 0 else 0.0
|
||||
# CRITICAL: Handle negative reference prices correctly
|
||||
# For best price (reverse_sort=False): ref_price is daily minimum
|
||||
# For peak price (reverse_sort=True): ref_price is daily maximum
|
||||
#
|
||||
# Flex determines price band:
|
||||
# - Best price: [ref_price, ref_price + abs(ref_price) * flex]
|
||||
# - Peak price: [ref_price - abs(ref_price) * flex, ref_price]
|
||||
#
|
||||
# Examples (flex=15%):
|
||||
# Positive ref (10 ct, best): [10, 11.5] → max = 10 + 10*0.15 = 11.5
|
||||
# Negative ref (-10 ct, best): [-10, -8.5] → max = -10 + 10*0.15 = -8.5 (less negative = more expensive)
|
||||
# Positive ref (30 ct, peak): [25.5, 30] → min = 30 - 30*0.15 = 25.5
|
||||
# Negative ref (-5 ct, peak): [-5.75, -5] → min = -5 - 5*0.15 = -5.75 (more negative = cheaper)
|
||||
|
||||
# Check if interval qualifies for the period
|
||||
in_flex = percent_diff >= criteria.flex * 100 if criteria.reverse_sort else percent_diff <= criteria.flex * 100
|
||||
if criteria.ref_price == 0:
|
||||
# Zero reference: flex has no effect, use strict equality
|
||||
in_flex = price == 0
|
||||
else:
|
||||
# Calculate flex threshold using absolute value of reference
|
||||
flex_amount = abs(criteria.ref_price) * criteria.flex
|
||||
|
||||
if criteria.reverse_sort:
|
||||
# Peak price: price must be >= (ref_price - flex_amount)
|
||||
# For negative ref: more negative is cheaper, so subtract
|
||||
# For positive ref: lower value is cheaper, so subtract
|
||||
# Example: ref=30, flex=15% → accept [25.5, 30] → price >= 25.5
|
||||
# Example: ref=-5, flex=15% → accept [-5.75, -5] → price >= -5.75
|
||||
flex_threshold = criteria.ref_price - flex_amount
|
||||
in_flex = price >= flex_threshold and price <= criteria.ref_price
|
||||
else:
|
||||
# Best price: price must be in range [ref_price, ref_price + flex_amount]
|
||||
# For negative ref: less negative is more expensive, so add
|
||||
# For positive ref: higher value is more expensive, so add
|
||||
# Example: ref=10, flex=15% → accept [10, 11.5] → 10 <= price <= 11.5
|
||||
# Example: ref=-10, flex=15% → accept [-10, -8.5] → -10 <= price <= -8.5
|
||||
flex_threshold = criteria.ref_price + flex_amount
|
||||
in_flex = price >= criteria.ref_price and price <= flex_threshold
|
||||
|
||||
# CRITICAL: Adjust min_distance dynamically based on flex to prevent conflicts
|
||||
# Problem: High flex (e.g., 50%) can conflict with fixed min_distance (e.g., 5%)
|
||||
|
|
@ -157,13 +189,12 @@ def check_interval_criteria(
|
|||
)
|
||||
|
||||
# Minimum distance from average (using adjusted value)
|
||||
if criteria.reverse_sort:
|
||||
# Peak price: must be at least adjusted_min_distance% above average
|
||||
min_distance_threshold = criteria.avg_price * (1 + adjusted_min_distance / 100)
|
||||
meets_min_distance = price >= min_distance_threshold
|
||||
else:
|
||||
# Best price: must be at least adjusted_min_distance% below average
|
||||
min_distance_threshold = criteria.avg_price * (1 - adjusted_min_distance / 100)
|
||||
meets_min_distance = price <= min_distance_threshold
|
||||
# Uniform formula: avg * (1 + distance/100) works for both Best (negative) and Peak (positive)
|
||||
# - Best: distance=-5% → avg * 0.95 (5% below average)
|
||||
# - Peak: distance=+5% → avg * 1.05 (5% above average)
|
||||
min_distance_threshold = criteria.avg_price * (1 + adjusted_min_distance / 100)
|
||||
|
||||
# Check: Peak (≥ threshold) or Best (≤ threshold)
|
||||
meets_min_distance = price >= min_distance_threshold if criteria.reverse_sort else price <= min_distance_threshold
|
||||
|
||||
return in_flex, meets_min_distance
|
||||
|
|
|
|||
|
|
@ -199,7 +199,8 @@
|
|||
"invalid_access_token": "Ungültiges Zugriffstoken",
|
||||
"different_home": "Der Zugriffstoken ist nicht gültig für die Home ID, für die diese Integration konfiguriert ist.",
|
||||
"invalid_flex": "TRANSLATE: Flexibility percentage must be between -50% and +50%",
|
||||
"invalid_distance": "TRANSLATE: Distance percentage must be between 0% and 50%",
|
||||
"invalid_best_price_distance": "TRANSLATE: Distance percentage must be between -50% and 0% (negative = below average)",
|
||||
"invalid_peak_price_distance": "TRANSLATE: Distance percentage must be between 0% and 50% (positive = above average)",
|
||||
"invalid_min_periods": "TRANSLATE: Minimum periods count must be between 1 and 10",
|
||||
"invalid_period_length": "Die Periodenlänge muss mindestens 15 Minuten betragen (Vielfache von 15).",
|
||||
"invalid_gap_count": "Lückentoleranz muss zwischen 0 und 8 liegen",
|
||||
|
|
@ -920,4 +921,4 @@
|
|||
}
|
||||
},
|
||||
"title": "Tibber Preisinformationen & Bewertungen"
|
||||
}
|
||||
}
|
||||
|
|
@ -200,7 +200,8 @@
|
|||
"different_home": "The access token is not valid for the home ID this integration is configured for.",
|
||||
"invalid_period_length": "Period length must be at least 15 minutes (multiples of 15).",
|
||||
"invalid_flex": "Flexibility percentage must be between -50% and +50%",
|
||||
"invalid_distance": "Distance percentage must be between 0% and 50%",
|
||||
"invalid_best_price_distance": "Distance percentage must be between -50% and 0% (negative = below average)",
|
||||
"invalid_peak_price_distance": "Distance percentage must be between 0% and 50% (positive = above average)",
|
||||
"invalid_min_periods": "Minimum periods count must be between 1 and 10",
|
||||
"invalid_gap_count": "Gap count must be between 0 and 8",
|
||||
"invalid_relaxation_attempts": "Relaxation attempts must be between 1 and 12",
|
||||
|
|
@ -916,4 +917,4 @@
|
|||
}
|
||||
},
|
||||
"title": "Tibber Price Information & Ratings"
|
||||
}
|
||||
}
|
||||
|
|
@ -199,7 +199,8 @@
|
|||
"invalid_access_token": "Ugyldig tilgangstoken",
|
||||
"different_home": "Tilgangstokenet er ikke gyldig for hjem-ID-en denne integrasjonen er konfigurert for.",
|
||||
"invalid_flex": "TRANSLATE: Flexibility percentage must be between -50% and +50%",
|
||||
"invalid_distance": "TRANSLATE: Distance percentage must be between 0% and 50%",
|
||||
"invalid_best_price_distance": "TRANSLATE: Distance percentage must be between -50% and 0% (negative = below average)",
|
||||
"invalid_peak_price_distance": "TRANSLATE: Distance percentage must be between 0% and 50% (positive = above average)",
|
||||
"invalid_min_periods": "TRANSLATE: Minimum periods count must be between 1 and 10",
|
||||
"invalid_period_length": "Periodelengden må være minst 15 minutter (multipler av 15).",
|
||||
"invalid_gap_count": "Gaptoleranse må være mellom 0 og 8",
|
||||
|
|
@ -916,4 +917,4 @@
|
|||
}
|
||||
},
|
||||
"title": "Tibber Prisinformasjon & Vurderinger"
|
||||
}
|
||||
}
|
||||
|
|
@ -199,7 +199,8 @@
|
|||
"invalid_access_token": "Ongeldig toegangstoken",
|
||||
"different_home": "Het toegangstoken is niet geldig voor de huis-ID waarvoor deze integratie is geconfigureerd.",
|
||||
"invalid_flex": "TRANSLATE: Flexibility percentage must be between -50% and +50%",
|
||||
"invalid_distance": "TRANSLATE: Distance percentage must be between 0% and 50%",
|
||||
"invalid_best_price_distance": "TRANSLATE: Distance percentage must be between -50% and 0% (negative = below average)",
|
||||
"invalid_peak_price_distance": "TRANSLATE: Distance percentage must be between 0% and 50% (positive = above average)",
|
||||
"invalid_min_periods": "TRANSLATE: Minimum periods count must be between 1 and 10",
|
||||
"invalid_period_length": "De periodelengte moet minimaal 15 minuten zijn (veelvouden van 15).",
|
||||
"invalid_gap_count": "Gaptolerantie moet tussen 0 en 8 liggen",
|
||||
|
|
@ -916,4 +917,4 @@
|
|||
}
|
||||
},
|
||||
"title": "Tibber Prijsinformatie & Beoordelingen"
|
||||
}
|
||||
}
|
||||
|
|
@ -199,7 +199,8 @@
|
|||
"invalid_access_token": "Ogiltig åtkomsttoken",
|
||||
"different_home": "Åtkomsttoken är inte giltig för hem-ID:t som denna integration är konfigurerad för.",
|
||||
"invalid_flex": "TRANSLATE: Flexibility percentage must be between -50% and +50%",
|
||||
"invalid_distance": "TRANSLATE: Distance percentage must be between 0% and 50%",
|
||||
"invalid_best_price_distance": "TRANSLATE: Distance percentage must be between -50% and 0% (negative = below average)",
|
||||
"invalid_peak_price_distance": "TRANSLATE: Distance percentage must be between 0% and 50% (positive = above average)",
|
||||
"invalid_min_periods": "TRANSLATE: Minimum periods count must be between 1 and 10",
|
||||
"invalid_period_length": "Periodlängden måste vara minst 15 minuter (multiplar av 15).",
|
||||
"invalid_gap_count": "Gaptolerans måste vara mellan 0 och 8",
|
||||
|
|
@ -916,4 +917,4 @@
|
|||
}
|
||||
},
|
||||
"title": "Tibber Prisinformation & Betyg"
|
||||
}
|
||||
}
|
||||
417
tests/test_config_validators.py
Normal file
417
tests/test_config_validators.py
Normal file
|
|
@ -0,0 +1,417 @@
|
|||
"""Tests for config flow validators."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.tibber_prices.config_flow_handlers.validators import (
|
||||
validate_best_price_distance_percentage,
|
||||
validate_distance_percentage,
|
||||
validate_flex_percentage,
|
||||
validate_gap_count,
|
||||
validate_min_periods,
|
||||
validate_period_length,
|
||||
validate_price_rating_threshold_high,
|
||||
validate_price_rating_threshold_low,
|
||||
validate_price_rating_thresholds,
|
||||
validate_price_trend_falling,
|
||||
validate_price_trend_rising,
|
||||
validate_relaxation_attempts,
|
||||
validate_volatility_threshold_high,
|
||||
validate_volatility_threshold_moderate,
|
||||
validate_volatility_threshold_very_high,
|
||||
validate_volatility_thresholds,
|
||||
)
|
||||
from custom_components.tibber_prices.const import (
|
||||
MAX_DISTANCE_PERCENTAGE,
|
||||
MAX_FLEX_PERCENTAGE,
|
||||
MAX_GAP_COUNT,
|
||||
MAX_MIN_PERIODS,
|
||||
MAX_PRICE_RATING_THRESHOLD_HIGH,
|
||||
MAX_PRICE_RATING_THRESHOLD_LOW,
|
||||
MAX_PRICE_TREND_FALLING,
|
||||
MAX_PRICE_TREND_RISING,
|
||||
MAX_RELAXATION_ATTEMPTS,
|
||||
MAX_VOLATILITY_THRESHOLD_HIGH,
|
||||
MAX_VOLATILITY_THRESHOLD_MODERATE,
|
||||
MAX_VOLATILITY_THRESHOLD_VERY_HIGH,
|
||||
MIN_GAP_COUNT,
|
||||
MIN_PERIOD_LENGTH,
|
||||
MIN_PRICE_RATING_THRESHOLD_HIGH,
|
||||
MIN_PRICE_RATING_THRESHOLD_LOW,
|
||||
MIN_PRICE_TREND_FALLING,
|
||||
MIN_PRICE_TREND_RISING,
|
||||
MIN_RELAXATION_ATTEMPTS,
|
||||
MIN_VOLATILITY_THRESHOLD_HIGH,
|
||||
MIN_VOLATILITY_THRESHOLD_MODERATE,
|
||||
MIN_VOLATILITY_THRESHOLD_VERY_HIGH,
|
||||
)
|
||||
|
||||
|
||||
class TestPeriodLengthValidation:
|
||||
"""Test period length validation."""
|
||||
|
||||
def test_valid_period_lengths(self) -> None:
|
||||
"""Test valid period lengths (multiples of 15)."""
|
||||
assert validate_period_length(15) is True # Minimum
|
||||
assert validate_period_length(30) is True
|
||||
assert validate_period_length(45) is True
|
||||
assert validate_period_length(60) is True
|
||||
assert validate_period_length(120) is True
|
||||
assert validate_period_length(MIN_PERIOD_LENGTH) is True
|
||||
|
||||
def test_invalid_period_lengths(self) -> None:
|
||||
"""Test invalid period lengths (not multiples of 15)."""
|
||||
assert validate_period_length(0) is False
|
||||
assert validate_period_length(10) is False
|
||||
assert validate_period_length(20) is False
|
||||
assert validate_period_length(25) is False
|
||||
assert validate_period_length(40) is False
|
||||
assert validate_period_length(-15) is False # Negative
|
||||
|
||||
|
||||
class TestFlexPercentageValidation:
|
||||
"""Test flex percentage validation."""
|
||||
|
||||
def test_valid_positive_flex(self) -> None:
|
||||
"""Test valid positive flex values (Best Price)."""
|
||||
assert validate_flex_percentage(0) is True
|
||||
assert validate_flex_percentage(10) is True
|
||||
assert validate_flex_percentage(15) is True
|
||||
assert validate_flex_percentage(25) is True
|
||||
assert validate_flex_percentage(MAX_FLEX_PERCENTAGE) is True
|
||||
|
||||
def test_valid_negative_flex(self) -> None:
|
||||
"""Test valid negative flex values (Peak Price)."""
|
||||
assert validate_flex_percentage(-10) is True
|
||||
assert validate_flex_percentage(-20) is True
|
||||
assert validate_flex_percentage(-30) is True
|
||||
assert validate_flex_percentage(-MAX_FLEX_PERCENTAGE) is True
|
||||
|
||||
def test_invalid_flex(self) -> None:
|
||||
"""Test invalid flex values (out of bounds)."""
|
||||
assert validate_flex_percentage(MAX_FLEX_PERCENTAGE + 1) is False
|
||||
assert validate_flex_percentage(-MAX_FLEX_PERCENTAGE - 1) is False
|
||||
assert validate_flex_percentage(100) is False
|
||||
assert validate_flex_percentage(-100) is False
|
||||
|
||||
|
||||
class TestMinPeriodsValidation:
|
||||
"""Test minimum periods count validation."""
|
||||
|
||||
def test_valid_min_periods(self) -> None:
|
||||
"""Test valid min periods values."""
|
||||
assert validate_min_periods(1) is True
|
||||
assert validate_min_periods(2) is True
|
||||
assert validate_min_periods(3) is True
|
||||
assert validate_min_periods(MAX_MIN_PERIODS) is True
|
||||
|
||||
def test_invalid_min_periods(self) -> None:
|
||||
"""Test invalid min periods values."""
|
||||
assert validate_min_periods(0) is False
|
||||
assert validate_min_periods(-1) is False
|
||||
assert validate_min_periods(MAX_MIN_PERIODS + 1) is False
|
||||
|
||||
|
||||
class TestDistancePercentageValidation:
|
||||
"""Test distance from average percentage validation."""
|
||||
|
||||
def test_valid_best_price_distance(self) -> None:
|
||||
"""Test valid Best Price distance (negative values)."""
|
||||
assert validate_best_price_distance_percentage(0) is True
|
||||
assert validate_best_price_distance_percentage(-5) is True
|
||||
assert validate_best_price_distance_percentage(-10) is True
|
||||
assert validate_best_price_distance_percentage(-25) is True
|
||||
assert validate_best_price_distance_percentage(-MAX_DISTANCE_PERCENTAGE) is True
|
||||
|
||||
def test_invalid_best_price_distance(self) -> None:
|
||||
"""Test invalid Best Price distance (positive or out of bounds)."""
|
||||
assert validate_best_price_distance_percentage(5) is False # Positive not allowed
|
||||
assert validate_best_price_distance_percentage(10) is False
|
||||
assert validate_best_price_distance_percentage(-MAX_DISTANCE_PERCENTAGE - 1) is False
|
||||
|
||||
def test_valid_peak_price_distance(self) -> None:
|
||||
"""Test valid Peak Price distance (positive values)."""
|
||||
assert validate_distance_percentage(0) is True
|
||||
assert validate_distance_percentage(5) is True
|
||||
assert validate_distance_percentage(10) is True
|
||||
assert validate_distance_percentage(25) is True
|
||||
assert validate_distance_percentage(MAX_DISTANCE_PERCENTAGE) is True
|
||||
|
||||
def test_invalid_peak_price_distance(self) -> None:
|
||||
"""Test invalid Peak Price distance (negative or out of bounds)."""
|
||||
assert validate_distance_percentage(-5) is False # Negative not allowed
|
||||
assert validate_distance_percentage(-10) is False
|
||||
assert validate_distance_percentage(MAX_DISTANCE_PERCENTAGE + 1) is False
|
||||
|
||||
|
||||
class TestGapCountValidation:
|
||||
"""Test gap count validation."""
|
||||
|
||||
def test_valid_gap_counts(self) -> None:
|
||||
"""Test valid gap count values."""
|
||||
assert validate_gap_count(MIN_GAP_COUNT) is True
|
||||
assert validate_gap_count(0) is True
|
||||
assert validate_gap_count(1) is True
|
||||
assert validate_gap_count(4) is True
|
||||
assert validate_gap_count(MAX_GAP_COUNT) is True
|
||||
|
||||
def test_invalid_gap_counts(self) -> None:
|
||||
"""Test invalid gap count values."""
|
||||
assert validate_gap_count(MIN_GAP_COUNT - 1) is False
|
||||
assert validate_gap_count(MAX_GAP_COUNT + 1) is False
|
||||
assert validate_gap_count(-5) is False
|
||||
|
||||
|
||||
class TestRelaxationAttemptsValidation:
|
||||
"""Test relaxation attempts validation."""
|
||||
|
||||
def test_valid_relaxation_attempts(self) -> None:
|
||||
"""Test valid relaxation attempts values."""
|
||||
assert validate_relaxation_attempts(MIN_RELAXATION_ATTEMPTS) is True
|
||||
assert validate_relaxation_attempts(5) is True
|
||||
assert validate_relaxation_attempts(11) is True
|
||||
assert validate_relaxation_attempts(MAX_RELAXATION_ATTEMPTS) is True
|
||||
|
||||
def test_invalid_relaxation_attempts(self) -> None:
|
||||
"""Test invalid relaxation attempts values."""
|
||||
assert validate_relaxation_attempts(MIN_RELAXATION_ATTEMPTS - 1) is False
|
||||
assert validate_relaxation_attempts(0) is False
|
||||
assert validate_relaxation_attempts(MAX_RELAXATION_ATTEMPTS + 1) is False
|
||||
|
||||
|
||||
class TestPriceRatingThresholdValidation:
|
||||
"""Test price rating threshold validation."""
|
||||
|
||||
def test_valid_threshold_low(self) -> None:
|
||||
"""Test valid low threshold values (negative)."""
|
||||
assert validate_price_rating_threshold_low(MIN_PRICE_RATING_THRESHOLD_LOW) is True
|
||||
assert validate_price_rating_threshold_low(-20) is True
|
||||
assert validate_price_rating_threshold_low(-10) is True
|
||||
assert validate_price_rating_threshold_low(MAX_PRICE_RATING_THRESHOLD_LOW) is True
|
||||
|
||||
def test_invalid_threshold_low(self) -> None:
|
||||
"""Test invalid low threshold values."""
|
||||
assert validate_price_rating_threshold_low(MIN_PRICE_RATING_THRESHOLD_LOW - 1) is False
|
||||
assert validate_price_rating_threshold_low(MAX_PRICE_RATING_THRESHOLD_LOW + 1) is False
|
||||
assert validate_price_rating_threshold_low(0) is False # Must be negative
|
||||
assert validate_price_rating_threshold_low(10) is False # Must be negative
|
||||
|
||||
def test_valid_threshold_high(self) -> None:
|
||||
"""Test valid high threshold values (positive)."""
|
||||
assert validate_price_rating_threshold_high(MIN_PRICE_RATING_THRESHOLD_HIGH) is True
|
||||
assert validate_price_rating_threshold_high(10) is True
|
||||
assert validate_price_rating_threshold_high(20) is True
|
||||
assert validate_price_rating_threshold_high(MAX_PRICE_RATING_THRESHOLD_HIGH) is True
|
||||
|
||||
def test_invalid_threshold_high(self) -> None:
|
||||
"""Test invalid high threshold values."""
|
||||
assert validate_price_rating_threshold_high(MIN_PRICE_RATING_THRESHOLD_HIGH - 1) is False
|
||||
assert validate_price_rating_threshold_high(MAX_PRICE_RATING_THRESHOLD_HIGH + 1) is False
|
||||
assert validate_price_rating_threshold_high(0) is False # Must be positive
|
||||
assert validate_price_rating_threshold_high(-10) is False # Must be positive
|
||||
|
||||
def test_valid_threshold_combinations(self) -> None:
|
||||
"""Test valid combinations of low and high thresholds."""
|
||||
# Standard defaults
|
||||
assert validate_price_rating_thresholds(-20, 10) is True
|
||||
|
||||
# Wide range
|
||||
assert validate_price_rating_thresholds(-50, 50) is True
|
||||
|
||||
# Narrow range
|
||||
assert validate_price_rating_thresholds(-5, 5) is True
|
||||
|
||||
def test_invalid_threshold_combinations(self) -> None:
|
||||
"""Test invalid combinations of low and high thresholds."""
|
||||
# Low threshold out of range
|
||||
assert validate_price_rating_thresholds(-60, 10) is False
|
||||
|
||||
# High threshold out of range
|
||||
assert validate_price_rating_thresholds(-20, 60) is False
|
||||
|
||||
# Both out of range
|
||||
assert validate_price_rating_thresholds(-100, 100) is False
|
||||
|
||||
# Low must be negative, high must be positive (crossing is OK)
|
||||
assert validate_price_rating_thresholds(-20, 10) is True
|
||||
|
||||
|
||||
class TestVolatilityThresholdValidation:
|
||||
"""Test volatility threshold validation."""
|
||||
|
||||
def test_valid_threshold_moderate(self) -> None:
|
||||
"""Test valid moderate threshold values."""
|
||||
assert validate_volatility_threshold_moderate(MIN_VOLATILITY_THRESHOLD_MODERATE) is True
|
||||
assert validate_volatility_threshold_moderate(10.0) is True
|
||||
assert validate_volatility_threshold_moderate(15.0) is True
|
||||
assert validate_volatility_threshold_moderate(MAX_VOLATILITY_THRESHOLD_MODERATE) is True
|
||||
|
||||
def test_invalid_threshold_moderate(self) -> None:
|
||||
"""Test invalid moderate threshold values."""
|
||||
assert validate_volatility_threshold_moderate(MIN_VOLATILITY_THRESHOLD_MODERATE - 1) is False
|
||||
assert validate_volatility_threshold_moderate(MAX_VOLATILITY_THRESHOLD_MODERATE + 1) is False
|
||||
assert validate_volatility_threshold_moderate(0.0) is False
|
||||
|
||||
def test_valid_threshold_high(self) -> None:
|
||||
"""Test valid high threshold values."""
|
||||
assert validate_volatility_threshold_high(MIN_VOLATILITY_THRESHOLD_HIGH) is True
|
||||
assert validate_volatility_threshold_high(25.0) is True
|
||||
assert validate_volatility_threshold_high(30.0) is True
|
||||
assert validate_volatility_threshold_high(MAX_VOLATILITY_THRESHOLD_HIGH) is True
|
||||
|
||||
def test_invalid_threshold_high(self) -> None:
|
||||
"""Test invalid high threshold values."""
|
||||
assert validate_volatility_threshold_high(MIN_VOLATILITY_THRESHOLD_HIGH - 1) is False
|
||||
assert validate_volatility_threshold_high(MAX_VOLATILITY_THRESHOLD_HIGH + 1) is False
|
||||
|
||||
def test_valid_threshold_very_high(self) -> None:
|
||||
"""Test valid very high threshold values."""
|
||||
assert validate_volatility_threshold_very_high(MIN_VOLATILITY_THRESHOLD_VERY_HIGH) is True
|
||||
assert validate_volatility_threshold_very_high(50.0) is True
|
||||
assert validate_volatility_threshold_very_high(60.0) is True
|
||||
assert validate_volatility_threshold_very_high(MAX_VOLATILITY_THRESHOLD_VERY_HIGH) is True
|
||||
|
||||
def test_invalid_threshold_very_high(self) -> None:
|
||||
"""Test invalid very high threshold values."""
|
||||
assert validate_volatility_threshold_very_high(MIN_VOLATILITY_THRESHOLD_VERY_HIGH - 1) is False
|
||||
assert validate_volatility_threshold_very_high(MAX_VOLATILITY_THRESHOLD_VERY_HIGH + 1) is False
|
||||
|
||||
def test_valid_threshold_combinations(self) -> None:
|
||||
"""Test valid combinations of all three volatility thresholds."""
|
||||
# Standard defaults
|
||||
assert validate_volatility_thresholds(10.0, 25.0, 50.0) is True
|
||||
|
||||
# Tight clustering
|
||||
assert validate_volatility_thresholds(20.0, 21.0, 36.0) is True
|
||||
|
||||
# Wide spacing
|
||||
assert validate_volatility_thresholds(5.0, 30.0, 80.0) is True
|
||||
|
||||
def test_invalid_threshold_combinations(self) -> None:
|
||||
"""Test invalid combinations of volatility thresholds."""
|
||||
# Moderate out of range
|
||||
assert validate_volatility_thresholds(0.0, 25.0, 50.0) is False
|
||||
|
||||
# High out of range
|
||||
assert validate_volatility_thresholds(10.0, 50.0, 50.0) is False
|
||||
|
||||
# Very high out of range
|
||||
assert validate_volatility_thresholds(10.0, 25.0, 100.0) is False
|
||||
|
||||
# Wrong order: moderate >= high
|
||||
assert validate_volatility_thresholds(25.0, 25.0, 50.0) is False
|
||||
assert validate_volatility_thresholds(30.0, 25.0, 50.0) is False
|
||||
|
||||
# Wrong order: high >= very_high
|
||||
assert validate_volatility_thresholds(10.0, 50.0, 50.0) is False
|
||||
assert validate_volatility_thresholds(10.0, 60.0, 50.0) is False
|
||||
|
||||
|
||||
class TestPriceTrendThresholdValidation:
|
||||
"""Test price trend threshold validation."""
|
||||
|
||||
def test_valid_threshold_rising(self) -> None:
|
||||
"""Test valid rising trend threshold values (positive)."""
|
||||
assert validate_price_trend_rising(MIN_PRICE_TREND_RISING) is True
|
||||
assert validate_price_trend_rising(10) is True
|
||||
assert validate_price_trend_rising(25) is True
|
||||
assert validate_price_trend_rising(MAX_PRICE_TREND_RISING) is True
|
||||
|
||||
def test_invalid_threshold_rising(self) -> None:
|
||||
"""Test invalid rising trend threshold values."""
|
||||
assert validate_price_trend_rising(MIN_PRICE_TREND_RISING - 1) is False
|
||||
assert validate_price_trend_rising(0) is False
|
||||
assert validate_price_trend_rising(MAX_PRICE_TREND_RISING + 1) is False
|
||||
assert validate_price_trend_rising(-10) is False # Must be positive
|
||||
|
||||
def test_valid_threshold_falling(self) -> None:
|
||||
"""Test valid falling trend threshold values (negative)."""
|
||||
assert validate_price_trend_falling(MIN_PRICE_TREND_FALLING) is True
|
||||
assert validate_price_trend_falling(-25) is True
|
||||
assert validate_price_trend_falling(-10) is True
|
||||
assert validate_price_trend_falling(MAX_PRICE_TREND_FALLING) is True
|
||||
|
||||
def test_invalid_threshold_falling(self) -> None:
|
||||
"""Test invalid falling trend threshold values."""
|
||||
assert validate_price_trend_falling(MIN_PRICE_TREND_FALLING - 1) is False
|
||||
assert validate_price_trend_falling(0) is False
|
||||
assert validate_price_trend_falling(MAX_PRICE_TREND_FALLING + 1) is False
|
||||
assert validate_price_trend_falling(10) is False # Must be negative
|
||||
|
||||
|
||||
class TestBoundaryConditions:
|
||||
"""Test boundary conditions for all validators."""
|
||||
|
||||
def test_exact_boundaries(self) -> None:
|
||||
"""Test that validators accept exact boundary values."""
|
||||
# Period length
|
||||
assert validate_period_length(MIN_PERIOD_LENGTH) is True
|
||||
|
||||
# Flex
|
||||
assert validate_flex_percentage(MAX_FLEX_PERCENTAGE) is True
|
||||
assert validate_flex_percentage(-MAX_FLEX_PERCENTAGE) is True
|
||||
|
||||
# Min periods
|
||||
assert validate_min_periods(1) is True
|
||||
assert validate_min_periods(MAX_MIN_PERIODS) is True
|
||||
|
||||
# Distance (Best Price)
|
||||
assert validate_best_price_distance_percentage(0) is True
|
||||
assert validate_best_price_distance_percentage(-MAX_DISTANCE_PERCENTAGE) is True
|
||||
|
||||
# Distance (Peak Price)
|
||||
assert validate_distance_percentage(0) is True
|
||||
assert validate_distance_percentage(MAX_DISTANCE_PERCENTAGE) is True
|
||||
|
||||
# Gap count
|
||||
assert validate_gap_count(MIN_GAP_COUNT) is True
|
||||
assert validate_gap_count(MAX_GAP_COUNT) is True
|
||||
|
||||
# Relaxation attempts
|
||||
assert validate_relaxation_attempts(MIN_RELAXATION_ATTEMPTS) is True
|
||||
assert validate_relaxation_attempts(MAX_RELAXATION_ATTEMPTS) is True
|
||||
|
||||
def test_just_outside_boundaries(self) -> None:
|
||||
"""Test that validators reject values just outside boundaries."""
|
||||
# Flex
|
||||
assert validate_flex_percentage(MAX_FLEX_PERCENTAGE + 1) is False
|
||||
assert validate_flex_percentage(-MAX_FLEX_PERCENTAGE - 1) is False
|
||||
|
||||
# Min periods
|
||||
assert validate_min_periods(0) is False
|
||||
assert validate_min_periods(MAX_MIN_PERIODS + 1) is False
|
||||
|
||||
# Distance (Best Price)
|
||||
assert validate_best_price_distance_percentage(1) is False # Must be ≤0
|
||||
assert validate_best_price_distance_percentage(-MAX_DISTANCE_PERCENTAGE - 1) is False
|
||||
|
||||
# Distance (Peak Price)
|
||||
assert validate_distance_percentage(-1) is False # Must be ≥0
|
||||
assert validate_distance_percentage(MAX_DISTANCE_PERCENTAGE + 1) is False
|
||||
|
||||
|
||||
class TestFloatPrecision:
|
||||
"""Test handling of float precision in validators."""
|
||||
|
||||
def test_float_precision_distance(self) -> None:
|
||||
"""Test float precision for distance validators."""
|
||||
# Best Price
|
||||
assert validate_best_price_distance_percentage(-5.5) is True
|
||||
assert validate_best_price_distance_percentage(-0.1) is True
|
||||
assert validate_best_price_distance_percentage(-49.9) is True
|
||||
|
||||
# Peak Price
|
||||
assert validate_distance_percentage(5.5) is True
|
||||
assert validate_distance_percentage(0.1) is True
|
||||
assert validate_distance_percentage(49.9) is True
|
||||
|
||||
def test_float_precision_volatility(self) -> None:
|
||||
"""Test float precision for volatility validators."""
|
||||
assert validate_volatility_threshold_moderate(10.5) is True
|
||||
assert validate_volatility_threshold_high(25.3) is True
|
||||
assert validate_volatility_threshold_very_high(50.7) is True
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
|
|
@ -64,7 +64,7 @@ def period_config() -> TibberPricesPeriodConfig:
|
|||
return TibberPricesPeriodConfig(
|
||||
reverse_sort=False, # Best price (cheap periods)
|
||||
flex=0.50, # 50% flexibility
|
||||
min_distance_from_avg=5.0,
|
||||
min_distance_from_avg=-5.0, # -5% below average
|
||||
min_period_length=60, # 60 minutes minimum
|
||||
threshold_low=20.0,
|
||||
threshold_high=30.0,
|
||||
|
|
|
|||
Loading…
Reference in a new issue