diff --git a/custom_components/tibber_prices/config_flow_handlers/options_flow.py b/custom_components/tibber_prices/config_flow_handlers/options_flow.py index 0d2a240..4829aa0 100644 --- a/custom_components/tibber_prices/config_flow_handlers/options_flow.py +++ b/custom_components/tibber_prices/config_flow_handlers/options_flow.py @@ -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]): diff --git a/custom_components/tibber_prices/config_flow_handlers/schemas.py b/custom_components/tibber_prices/config_flow_handlers/schemas.py index 9dabcb5..73dec8d 100644 --- a/custom_components/tibber_prices/config_flow_handlers/schemas.py +++ b/custom_components/tibber_prices/config_flow_handlers/schemas.py @@ -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, diff --git a/custom_components/tibber_prices/config_flow_handlers/validators.py b/custom_components/tibber_prices/config_flow_handlers/validators.py index 0c6ef84..a935b40 100644 --- a/custom_components/tibber_prices/config_flow_handlers/validators.py +++ b/custom_components/tibber_prices/config_flow_handlers/validators.py @@ -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. diff --git a/custom_components/tibber_prices/const.py b/custom_components/tibber_prices/const.py index 81792bd..b0d1677 100644 --- a/custom_components/tibber_prices/const.py +++ b/custom_components/tibber_prices/const.py @@ -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, diff --git a/custom_components/tibber_prices/coordinator/period_handlers/level_filtering.py b/custom_components/tibber_prices/coordinator/period_handlers/level_filtering.py index 7eaca09..40c1c08 100644 --- a/custom_components/tibber_prices/coordinator/period_handlers/level_filtering.py +++ b/custom_components/tibber_prices/coordinator/period_handlers/level_filtering.py @@ -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 diff --git a/custom_components/tibber_prices/translations/de.json b/custom_components/tibber_prices/translations/de.json index 6442b5b..45cf011 100644 --- a/custom_components/tibber_prices/translations/de.json +++ b/custom_components/tibber_prices/translations/de.json @@ -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" -} +} \ No newline at end of file diff --git a/custom_components/tibber_prices/translations/en.json b/custom_components/tibber_prices/translations/en.json index 60246eb..d032d24 100644 --- a/custom_components/tibber_prices/translations/en.json +++ b/custom_components/tibber_prices/translations/en.json @@ -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" -} +} \ No newline at end of file diff --git a/custom_components/tibber_prices/translations/nb.json b/custom_components/tibber_prices/translations/nb.json index f9c6431..5af59a5 100644 --- a/custom_components/tibber_prices/translations/nb.json +++ b/custom_components/tibber_prices/translations/nb.json @@ -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" -} +} \ No newline at end of file diff --git a/custom_components/tibber_prices/translations/nl.json b/custom_components/tibber_prices/translations/nl.json index a8f5b71..51ef8e8 100644 --- a/custom_components/tibber_prices/translations/nl.json +++ b/custom_components/tibber_prices/translations/nl.json @@ -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" -} +} \ No newline at end of file diff --git a/custom_components/tibber_prices/translations/sv.json b/custom_components/tibber_prices/translations/sv.json index 670ed4d..d9b553c 100644 --- a/custom_components/tibber_prices/translations/sv.json +++ b/custom_components/tibber_prices/translations/sv.json @@ -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" -} +} \ No newline at end of file diff --git a/tests/test_config_validators.py b/tests/test_config_validators.py new file mode 100644 index 0000000..c09ec16 --- /dev/null +++ b/tests/test_config_validators.py @@ -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"]) diff --git a/tests/test_midnight_turnover.py b/tests/test_midnight_turnover.py index d39a8de..c4bb2ed 100644 --- a/tests/test_midnight_turnover.py +++ b/tests/test_midnight_turnover.py @@ -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,