refactor(volatility): migrate to coefficient of variation calculation

Replaced absolute volatility thresholds (ct/øre) with relative coefficient
of variation (CV = std_dev / mean * 100%) for scale-independent volatility
measurement that works across all price levels.

Changes to volatility calculation:
- price_utils.py: Rewrote calculate_volatility_level() to accept price list
  instead of spread value, using statistics.mean() and statistics.stdev()
- sensor.py: Updated volatility sensors to pass price lists (not spread)
- services.py: Modified _get_price_stats() to calculate CV from prices
- period_statistics.py: Extract prices for CV calculation in period summaries
- const.py: Updated default thresholds to 15%/30%/50% (was 5/15/30 ct)
  with comprehensive documentation explaining CV-based approach

Dead code removal:
- period_utils/core.py: Removed filter_periods_by_volatility() function
  (86 lines of code that was never actually called)
- period_utils/__init__.py: Removed dead function export
- period_utils/relaxation.py: Simplified callback signature from
  Callable[[str|None, str|None], bool] to Callable[[str|None], bool]
- coordinator.py: Updated lambda callbacks to match new signature
- const.py: Replaced RELAXATION_VOLATILITY_ANY with RELAXATION_LEVEL_ANY

Bug fix:
- relaxation.py: Added int() conversion for max_relaxation_attempts
  (line 435: attempts = max(1, int(max_relaxation_attempts)))
  Fixes TypeError when config value arrives as float

Configuration UI:
- config_flow.py: Changed volatility threshold unit display from "ct" to "%"

Translations (all 5 languages):
- Updated volatility descriptions to explain coefficient of variation
- Changed threshold labels from "spread ≥ value" to "CV ≥ percentage"
- Languages: de, en, nb, nl, sv

Documentation:
- period-calculation.md: Removed volatility filter section (dead feature)

Impact: Breaking change for users with custom volatility thresholds.
Old absolute values (e.g., 5 ct) will be interpreted as percentages (5%).
However, new defaults (15%/30%/50%) are more conservative and work
universally across all currencies and price levels. No data migration
needed - existing configs continue to work with new interpretation.
This commit is contained in:
Julian Pawlowski 2025-11-14 01:12:47 +00:00
parent 6dc49becb1
commit 07517660e3
16 changed files with 115 additions and 198 deletions

View file

@ -969,7 +969,7 @@ class TibberPricesOptionsFlowHandler(OptionsFlow):
min=0.0,
max=100.0,
step=0.1,
unit_of_measurement="ct",
unit_of_measurement="%",
mode=NumberSelectorMode.BOX,
),
),
@ -986,7 +986,7 @@ class TibberPricesOptionsFlowHandler(OptionsFlow):
min=0.0,
max=100.0,
step=0.1,
unit_of_measurement="ct",
unit_of_measurement="%",
mode=NumberSelectorMode.BOX,
),
),
@ -1003,7 +1003,7 @@ class TibberPricesOptionsFlowHandler(OptionsFlow):
min=0.0,
max=100.0,
step=0.1,
unit_of_measurement="ct",
unit_of_measurement="%",
mode=NumberSelectorMode.BOX,
),
),

View file

@ -69,9 +69,12 @@ DEFAULT_PRICE_RATING_THRESHOLD_LOW = -10 # Default rating threshold low percent
DEFAULT_PRICE_RATING_THRESHOLD_HIGH = 10 # Default rating threshold high percentage
DEFAULT_PRICE_TREND_THRESHOLD_RISING = 5 # Default trend threshold for rising prices (%)
DEFAULT_PRICE_TREND_THRESHOLD_FALLING = -5 # Default trend threshold for falling prices (%, negative value)
DEFAULT_VOLATILITY_THRESHOLD_MODERATE = 5.0 # Default threshold for MODERATE volatility (ct/øre)
DEFAULT_VOLATILITY_THRESHOLD_HIGH = 15.0 # Default threshold for HIGH volatility (ct/øre)
DEFAULT_VOLATILITY_THRESHOLD_VERY_HIGH = 30.0 # Default threshold for VERY_HIGH volatility (ct/øre)
# Default volatility thresholds (relative values using coefficient of variation)
# Coefficient of variation = (standard_deviation / mean) * 100%
# These thresholds are unitless and work across different price levels
DEFAULT_VOLATILITY_THRESHOLD_MODERATE = 15.0 # 15% - moderate price fluctuation
DEFAULT_VOLATILITY_THRESHOLD_HIGH = 30.0 # 30% - high price fluctuation
DEFAULT_VOLATILITY_THRESHOLD_VERY_HIGH = 50.0 # 50% - very high price fluctuation
DEFAULT_BEST_PRICE_MAX_LEVEL = "cheap" # Default: prefer genuinely cheap periods, relax to "any" if needed
DEFAULT_PEAK_PRICE_MIN_LEVEL = "expensive" # Default: prefer genuinely expensive periods, relax to "any" if needed
DEFAULT_BEST_PRICE_MAX_LEVEL_GAP_COUNT = 1 # Default: allow 1 level gap (e.g., CHEAP→NORMAL→CHEAP stays together)
@ -172,7 +175,7 @@ PRICE_RATING_LOW = "LOW"
PRICE_RATING_NORMAL = "NORMAL"
PRICE_RATING_HIGH = "HIGH"
# Price volatility levels (based on spread between min and max)
# Price volatility levels (based on coefficient of variation: std_dev / mean * 100%)
VOLATILITY_LOW = "LOW"
VOLATILITY_MODERATE = "MODERATE"
VOLATILITY_HIGH = "HIGH"
@ -213,7 +216,7 @@ BEST_PRICE_MAX_LEVEL_OPTIONS = [
PRICE_LEVEL_EXPENSIVE.lower(), # Only show if level ≤ EXPENSIVE
]
# Valid options for peak price minimum level filter (AND-linked with volatility filter)
# Valid options for peak price minimum level filter
# Sorted from expensive to cheap: user selects "starting from how expensive"
PEAK_PRICE_MIN_LEVEL_OPTIONS = [
"any", # No filter, allow all price levels
@ -226,8 +229,8 @@ PEAK_PRICE_MIN_LEVEL_OPTIONS = [
# Relaxation level constants (for period filter relaxation)
# These describe which filter relaxation was applied to find a period
RELAXATION_NONE = "none" # No relaxation, normal filters
RELAXATION_VOLATILITY_ANY = "volatility_any" # Volatility filter disabled
RELAXATION_ALL_FILTERS_OFF = "all_filters_off" # All filters disabled (last resort)
RELAXATION_LEVEL_ANY = "level_any" # Level filter disabled
RELAXATION_ALL_FILTERS_OFF = "all_filters_off" # All filters disabled (deprecated, same as level_any)
# Mapping for comparing price levels (used for sorting)
PRICE_LEVEL_MAPPING = {

View file

@ -762,9 +762,6 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""
Check if periods should be shown based on level filter only.
Note: Volatility filtering is now applied per-period after calculation,
not at the daily level. See _filter_periods_by_volatility().
Args:
price_info: Price information dict with today/yesterday/tomorrow data
reverse_sort: If False (best_price), checks max_level filter.
@ -1207,7 +1204,7 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
min_periods=min_periods_best,
relaxation_step_pct=relaxation_step_best,
max_relaxation_attempts=relaxation_attempts_best,
should_show_callback=lambda _vol, lvl: self._should_show_periods(
should_show_callback=lambda lvl: self._should_show_periods(
price_info,
reverse_sort=False,
level_override=lvl,
@ -1279,7 +1276,7 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
min_periods=min_periods_peak,
relaxation_step_pct=relaxation_step_peak,
max_relaxation_attempts=relaxation_attempts_peak,
should_show_callback=lambda _vol, lvl: self._should_show_periods(
should_show_callback=lambda lvl: self._should_show_periods(
price_info,
reverse_sort=True,
level_override=lvl,

View file

@ -17,7 +17,7 @@ All public APIs are re-exported for backwards compatibility.
from __future__ import annotations
# Re-export main API functions
from .core import calculate_periods, filter_periods_by_volatility
from .core import calculate_periods
# Re-export outlier filtering
from .outlier_filtering import filter_price_outliers
@ -56,6 +56,5 @@ __all__ = [
"ThresholdConfig",
"calculate_periods",
"calculate_periods_with_relaxation",
"filter_periods_by_volatility",
"filter_price_outliers",
]

View file

@ -163,88 +163,3 @@ def calculate_periods(
"avg_prices": {k.isoformat(): v for k, v in avg_price_by_day.items()},
},
}
def filter_periods_by_volatility(
periods_data: dict[str, Any],
min_volatility: str,
) -> dict[str, Any]:
"""
Filter calculated periods based on their internal volatility.
This applies period-level volatility filtering AFTER periods have been calculated.
Removes periods that don't meet the minimum volatility requirement based on their
own price spread (volatility attribute), not the daily volatility.
Args:
periods_data: Dict with "periods" and "intervals" lists from calculate_periods_with_relaxation()
min_volatility: Minimum volatility level required ("low", "moderate", "high", "very_high")
Returns:
Filtered periods_data dict with updated periods, intervals, and metadata.
"""
periods = periods_data.get("periods", [])
if not periods:
return periods_data
# "low" means no filtering (accept any volatility level)
if min_volatility == "low":
return periods_data
# Define volatility hierarchy (LOW < MODERATE < HIGH < VERY_HIGH)
volatility_levels = ["LOW", "MODERATE", "HIGH", "VERY_HIGH"]
# Map filter config values to actual level names
config_to_level = {
"low": "LOW",
"moderate": "MODERATE",
"high": "HIGH",
"very_high": "VERY_HIGH",
}
min_level = config_to_level.get(min_volatility, "LOW")
# Filter periods based on their volatility
filtered_periods = []
for period in periods:
period_volatility = period.get("volatility", "MODERATE")
# Check if period's volatility meets or exceeds minimum requirement
try:
period_idx = volatility_levels.index(period_volatility)
min_idx = volatility_levels.index(min_level)
except ValueError:
# If level not found, don't filter out this period
filtered_periods.append(period)
else:
if period_idx >= min_idx:
filtered_periods.append(period)
# If no periods left after filtering, return empty structure
if not filtered_periods:
return {
"periods": [],
"intervals": [],
"metadata": {
"total_intervals": 0,
"total_periods": 0,
"config": periods_data.get("metadata", {}).get("config", {}),
},
}
# Collect intervals from filtered periods
filtered_intervals = []
for period in filtered_periods:
filtered_intervals.extend(period.get("intervals", []))
# Update metadata
return {
"periods": filtered_periods,
"intervals": filtered_intervals,
"metadata": {
"total_intervals": len(filtered_intervals),
"total_periods": len(filtered_periods),
"config": periods_data.get("metadata", {}).get("config", {}),
},
}

View file

@ -185,7 +185,7 @@ def extract_period_summaries(
Returns sensor-ready period summaries with:
- Timestamps and positioning (start, end, hour, minute, time)
- Aggregated price statistics (price_avg, price_min, price_max, price_spread)
- Volatility categorization (low/moderate/high/very_high based on absolute spread)
- Volatility categorization (low/moderate/high/very_high based on coefficient of variation)
- Rating difference percentage (aggregated from intervals)
- Period price differences (period_price_diff_from_daily_min/max)
- Aggregated level and rating_level
@ -264,9 +264,12 @@ def extract_period_summaries(
price_stats["price_avg"], start_time, price_context
)
# Extract prices for volatility calculation (coefficient of variation)
prices_for_volatility = [float(p["total"]) for p in period_price_data if "total" in p]
# Calculate volatility (categorical) and aggregated rating difference (numeric)
volatility = calculate_volatility_level(
price_stats["price_spread"],
prices_for_volatility,
threshold_moderate=thresholds.threshold_volatility_moderate,
threshold_high=thresholds.threshold_volatility_high,
threshold_very_high=thresholds.threshold_volatility_very_high,

View file

@ -167,7 +167,7 @@ def calculate_periods_with_relaxation( # noqa: PLR0913, PLR0915 - Per-day relax
min_periods: int,
relaxation_step_pct: int,
max_relaxation_attempts: int,
should_show_callback: Callable[[str | None, str | None], bool],
should_show_callback: Callable[[str | None], bool],
) -> tuple[dict[str, Any], dict[str, Any]]:
"""
Calculate periods with optional per-day filter relaxation.
@ -179,8 +179,7 @@ def calculate_periods_with_relaxation( # noqa: PLR0913, PLR0915 - Per-day relax
relaxes filters in multiple phases FOR EACH DAY SEPARATELY:
Phase 1: Increase flex threshold step-by-step (up to 4 attempts)
Phase 2: Disable volatility filter (set to "any")
Phase 3: Disable level filter (set to "any")
Phase 2: Disable level filter (set to "any")
Args:
all_prices: All price data points
@ -191,7 +190,7 @@ def calculate_periods_with_relaxation( # noqa: PLR0913, PLR0915 - Per-day relax
step (controls how aggressively flex widens with each attempt)
max_relaxation_attempts: Maximum number of flex levels (attempts) to try per day
before giving up (each attempt runs the full filter matrix)
should_show_callback: Callback function(volatility_override, level_override) -> bool
should_show_callback: Callback function(level_override) -> bool
Returns True if periods should be shown with given filter overrides. Pass None
to use original configured filter values.
@ -388,7 +387,7 @@ def relax_single_day( # noqa: PLR0913 - Comprehensive filter relaxation per day
min_periods: int,
relaxation_step_pct: int,
max_relaxation_attempts: int,
should_show_callback: Callable[[str | None, str | None], bool],
should_show_callback: Callable[[str | None], bool],
baseline_periods: list[dict],
day_label: str,
) -> tuple[dict[str, Any], dict[str, Any]]:
@ -399,14 +398,11 @@ def relax_single_day( # noqa: PLR0913 - Comprehensive filter relaxation per day
This finds solutions faster by relaxing filters first (cheaper than increasing flex).
Per flex level (6.25%, 7.5%, 8.75%, 10%), try in order:
1. Original filters (volatility=configured, level=configured)
2. Relax only volatility (volatility=any, level=configured)
3. Relax only level (volatility=configured, level=any)
4. Relax both (volatility=any, level=any)
1. Original filters (level=configured)
2. Relax level filter (level=any)
This ensures we find the minimal relaxation needed. Example:
- If periods exist at flex=6.25% with level=any, we find them before trying flex=7.5%
- If periods need both filters relaxed, we try that before increasing flex further
Args:
day_prices: Price data for this specific day only
@ -414,7 +410,7 @@ def relax_single_day( # noqa: PLR0913 - Comprehensive filter relaxation per day
min_periods: Minimum periods needed for this day
relaxation_step_pct: Relaxation increment percentage
max_relaxation_attempts: Maximum number of flex levels (attempts) to try for this day
should_show_callback: Filter visibility callback(volatility_override, level_override)
should_show_callback: Filter visibility callback(level_override)
Returns True if periods should be shown with given overrides.
baseline_periods: Periods found with normal filters
day_label: Label for logging (e.g., "2025-11-11")
@ -436,7 +432,7 @@ def relax_single_day( # noqa: PLR0913 - Comprehensive filter relaxation per day
baseline_standalone = len([p for p in baseline_periods if not p.get("is_extension")])
attempts = max(1, max_relaxation_attempts)
attempts = max(1, int(max_relaxation_attempts))
# Flex levels: original + N steps (e.g., 5% → 6.25% → ...)
for flex_step in range(1, attempts + 1):
@ -447,17 +443,15 @@ def relax_single_day( # noqa: PLR0913 - Comprehensive filter relaxation per day
new_flex = -new_flex
# Try filter combinations for this flex level
# Each tuple contains: volatility_override, level_override, label_suffix
# Each tuple contains: level_override, label_suffix
filter_attempts = [
(None, None, ""), # Original config
("any", None, "+volatility_any"), # Relax volatility only
(None, "any", "+level_any"), # Relax level only
("any", "any", "+all_filters_any"), # Relax both
(None, ""), # Original config
("any", "+level_any"), # Relax level filter
]
for vol_override, lvl_override, label_suffix in filter_attempts:
for lvl_override, label_suffix in filter_attempts:
# Check if this combination is allowed by user config
if not should_show_callback(vol_override, lvl_override):
if not should_show_callback(lvl_override):
continue
# Calculate periods with this flex + filter combination

View file

@ -3,6 +3,7 @@
from __future__ import annotations
import logging
import statistics
from datetime import datetime, timedelta
from typing import Any
@ -24,22 +25,25 @@ from .const import (
_LOGGER = logging.getLogger(__name__)
MINUTES_PER_INTERVAL = 15
MIN_PRICES_FOR_VOLATILITY = 2 # Minimum number of price values needed for volatility calculation
def calculate_volatility_level(
spread: float,
prices: list[float],
threshold_moderate: float | None = None,
threshold_high: float | None = None,
threshold_very_high: float | None = None,
) -> str:
"""
Calculate volatility level from price spread.
Calculate volatility level from price list using coefficient of variation.
Volatility indicates how much prices fluctuate during a period, which helps
determine whether active load shifting is worthwhile.
determine whether active load shifting is worthwhile. Uses the coefficient
of variation (CV = std_dev / mean * 100%) for relative comparison that works
across different price levels and period lengths.
Args:
spread: Absolute price difference between max and min (in minor currency units, e.g., ct or øre)
prices: List of price values (in any unit, typically major currency units like EUR or NOK)
threshold_moderate: Custom threshold for MODERATE level (default: use DEFAULT_VOLATILITY_THRESHOLD_MODERATE)
threshold_high: Custom threshold for HIGH level (default: use DEFAULT_VOLATILITY_THRESHOLD_HIGH)
threshold_very_high: Custom threshold for VERY_HIGH level (default: use DEFAULT_VOLATILITY_THRESHOLD_VERY_HIGH)
@ -48,22 +52,40 @@ def calculate_volatility_level(
Volatility level: "LOW", "MODERATE", "HIGH", or "VERY_HIGH" (uppercase)
Examples:
- spread < 5: LOW minimal optimization potential
- 5 spread < 15: MODERATE some optimization worthwhile
- 15 spread < 30: HIGH strong optimization recommended
- spread 30: VERY_HIGH maximum optimization potential
- CV < 15%: LOW minimal optimization potential, prices relatively stable
- 15% CV < 30%: MODERATE some optimization worthwhile, noticeable variation
- 30% CV < 50%: HIGH strong optimization recommended, significant swings
- CV 50%: VERY_HIGH maximum optimization potential, extreme volatility
Note:
Requires at least 2 price values for calculation. Returns LOW if insufficient data.
Works identically for short periods (2-3 intervals) and long periods (96 intervals/day).
"""
# Need at least 2 values for standard deviation
if len(prices) < MIN_PRICES_FOR_VOLATILITY:
return VOLATILITY_LOW
# Use provided thresholds or fall back to constants
t_moderate = threshold_moderate if threshold_moderate is not None else DEFAULT_VOLATILITY_THRESHOLD_MODERATE
t_high = threshold_high if threshold_high is not None else DEFAULT_VOLATILITY_THRESHOLD_HIGH
t_very_high = threshold_very_high if threshold_very_high is not None else DEFAULT_VOLATILITY_THRESHOLD_VERY_HIGH
if spread < t_moderate:
# Calculate coefficient of variation
mean = statistics.mean(prices)
if mean <= 0:
# Avoid division by zero or negative mean (shouldn't happen with prices)
return VOLATILITY_LOW
if spread < t_high:
std_dev = statistics.stdev(prices)
coefficient_of_variation = (std_dev / mean) * 100 # As percentage
# Classify based on thresholds
if coefficient_of_variation < t_moderate:
return VOLATILITY_LOW
if coefficient_of_variation < t_high:
return VOLATILITY_MODERATE
if spread < t_very_high:
if coefficient_of_variation < t_very_high:
return VOLATILITY_HIGH
return VOLATILITY_VERY_HIGH

View file

@ -262,7 +262,7 @@ STATISTICS_SENSORS = (
),
)
# Volatility sensors (price spread analysis)
# Volatility sensors (coefficient of variation analysis)
# NOTE: Enum options are defined inline (not imported from const.py) to avoid
# import timing issues with Home Assistant's entity platform initialization.
# Keep in sync with VOLATILITY_OPTIONS in const.py!
@ -1459,15 +1459,15 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
tomorrow_prices = [float(p["total"]) for p in price_info.get("tomorrow", []) if "total" in p]
if today_prices:
today_vol = calculate_volatility_level(today_prices, **thresholds)
today_spread = (max(today_prices) - min(today_prices)) * 100
today_vol = calculate_volatility_level(today_spread, **thresholds)
self._last_volatility_attributes["today_spread"] = round(today_spread, 2)
self._last_volatility_attributes["today_volatility"] = today_vol
self._last_volatility_attributes["interval_count_today"] = len(today_prices)
if tomorrow_prices:
tomorrow_vol = calculate_volatility_level(tomorrow_prices, **thresholds)
tomorrow_spread = (max(tomorrow_prices) - min(tomorrow_prices)) * 100
tomorrow_vol = calculate_volatility_level(tomorrow_spread, **thresholds)
self._last_volatility_attributes["tomorrow_spread"] = round(tomorrow_spread, 2)
self._last_volatility_attributes["tomorrow_volatility"] = tomorrow_vol
self._last_volatility_attributes["interval_count_tomorrow"] = len(tomorrow_prices)
@ -1479,7 +1479,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
def _get_volatility_value(self, *, volatility_type: str) -> str | None:
"""
Calculate price volatility (spread) for different time periods.
Calculate price volatility using coefficient of variation for different time periods.
Args:
volatility_type: One of "today", "tomorrow", "next_24h", "today_tomorrow"
@ -1506,16 +1506,17 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
if not prices_to_analyze:
return None
# Calculate spread
# Calculate spread and basic statistics
price_min = min(prices_to_analyze)
price_max = max(prices_to_analyze)
spread = price_max - price_min
price_avg = sum(prices_to_analyze) / len(prices_to_analyze)
# Convert to minor currency units (ct/øre) for volatility calculation
# Convert to minor currency units (ct/øre) for display
spread_minor = spread * 100
# Calculate volatility level with custom thresholds
volatility = calculate_volatility_level(spread_minor, **thresholds)
# Calculate volatility level with custom thresholds (pass price list, not spread)
volatility = calculate_volatility_level(prices_to_analyze, **thresholds)
# Store attributes for this sensor
self._last_volatility_attributes = {
@ -1523,7 +1524,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
"price_volatility": volatility,
"price_min": round(price_min * 100, 2),
"price_max": round(price_max * 100, 2),
"price_avg": round((sum(prices_to_analyze) / len(prices_to_analyze)) * 100, 2),
"price_avg": round(price_avg * 100, 2),
"interval_count": len(prices_to_analyze),
}

View file

@ -452,16 +452,16 @@ def _enrich_intervals_with_averages(intervals: list[dict], price_info_by_day: di
def _get_price_stats(merged: list[dict], thresholds: dict) -> PriceStats:
"""Calculate average, min, and max price from merged data."""
if merged:
price_sum = sum(float(interval.get("price", 0)) for interval in merged if "price" in interval)
price_avg = round(price_sum / len(merged), 4)
prices = [float(interval.get("price", 0)) for interval in merged if "price" in interval]
price_avg = round(sum(prices) / len(prices), 4) if prices else 0
else:
prices = []
price_avg = 0
price_min, price_min_interval = _get_price_stat(merged, "min")
price_max, price_max_interval = _get_price_stat(merged, "max")
price_spread = round(price_max - price_min, 4) if price_min is not None and price_max is not None else 0
# Convert spread to minor currency units (ct/øre) for volatility calculation
price_spread_minor = price_spread * 100
price_volatility = calculate_volatility_level(price_spread_minor, **thresholds)
# Calculate volatility from price list (coefficient of variation)
price_volatility = calculate_volatility_level(prices, **thresholds) if prices else "low"
return PriceStats(
price_avg=price_avg,
price_min=price_min,

View file

@ -156,11 +156,11 @@
},
"volatility": {
"title": "Preisvolatilität Schwellenwerte",
"description": "{step_progress}\n\nKonfiguriere Schwellenwerte für die Volatilitätsklassifizierung. Volatilität misst Preisschwankungen (Spanne zwischen Min/Max) in kleinster Währungseinheit. Diese Schwellenwerte werden von Volatilitätssensoren und Periodenfiltern verwendet.",
"description": "{step_progress}\n\nKonfiguriere Schwellenwerte für die Volatilitätsklassifizierung. Volatilität misst relative Preisschwankungen anhand des Variationskoeffizienten (VK = Standardabweichung / Durchschnitt × 100%). Diese Schwellenwerte sind Prozentwerte, die für alle Preisniveaus funktionieren und von Volatilitätssensoren sowie Periodenfiltern verwendet werden.",
"data": {
"volatility_threshold_moderate": "Moderate Schwelle (Spanne ≥ dieser Wert)",
"volatility_threshold_high": "Hohe Schwelle (Spanne ≥ dieser Wert)",
"volatility_threshold_very_high": "Sehr hohe Schwelle (Spanne ≥ dieser Wert)"
"volatility_threshold_moderate": "Moderate Schwelle (VK ≥ dieser %, z.B. 15)",
"volatility_threshold_high": "Hohe Schwelle (VK ≥ dieser %, z.B. 30)",
"volatility_threshold_very_high": "Sehr hohe Schwelle (VK ≥ dieser %, z.B. 50)"
},
"submit": "Weiter zu Schritt 4"
}

View file

@ -156,11 +156,11 @@
},
"volatility": {
"title": "Price Volatility Thresholds",
"description": "{step_progress}\n\nConfigure thresholds for volatility classification. Volatility measures price variation (spread between min/max) in minor currency units. These thresholds are used by volatility sensors and period filters.",
"description": "{step_progress}\n\nConfigure thresholds for volatility classification. Volatility measures relative price variation using the coefficient of variation (CV = standard deviation / mean × 100%). These thresholds are percentage values that work across all price levels and are used by volatility sensors and period filters.",
"data": {
"volatility_threshold_moderate": "Moderate Threshold (spread ≥ this value)",
"volatility_threshold_high": "High Threshold (spread ≥ this value)",
"volatility_threshold_very_high": "Very High Threshold (spread ≥ this value)"
"volatility_threshold_moderate": "Moderate Threshold (CV ≥ this %, e.g., 15)",
"volatility_threshold_high": "High Threshold (CV ≥ this %, e.g., 30)",
"volatility_threshold_very_high": "Very High Threshold (CV ≥ this %, e.g., 50)"
},
"submit": "Next to Step 4"
}

View file

@ -156,11 +156,11 @@
},
"volatility": {
"title": "Prisvolatilitet Terskler",
"description": "{step_progress}\n\nKonfigurer terskler for volatilitetsklassifisering. Volatilitet måler prisvariasjoner (spredning mellom min/maks) i minste valutaenhet (ct/øre). Disse tersklene brukes av volatilitetssensorer og periodefiltre.",
"description": "{step_progress}\n\nKonfigurer terskler for volatilitetsklassifisering. Volatilitet måler relative prisvariasjoner ved hjelp av variasjonskoeffisienten (VK = standardavvik / gjennomsnitt × 100%). Disse tersklene er prosentverdier som fungerer på alle prisnivåer og brukes av volatilitetssensorer og periodefiltre.",
"data": {
"volatility_threshold_moderate": "Moderat terskel (ct/øre, spredning ≥ denne verdien)",
"volatility_threshold_high": "Høy terskel (ct/øre, spredning ≥ denne verdien)",
"volatility_threshold_very_high": "Veldig høy terskel (ct/øre, spredning ≥ denne verdien)"
"volatility_threshold_moderate": "Moderat terskel (VK ≥ denne %, f.eks. 15)",
"volatility_threshold_high": "Høy terskel (VK ≥ denne %, f.eks. 30)",
"volatility_threshold_very_high": "Veldig høy terskel (VK ≥ denne %, f.eks. 50)"
},
"submit": "Neste til steg 4"
}

View file

@ -156,11 +156,11 @@
},
"volatility": {
"title": "Prijsvolatiliteit Drempels",
"description": "{step_progress}\n\nConfigureer drempels voor volatiliteitsclassificatie. Volatiliteit meet prijsschommelingen (spreiding tussen min/max) in kleinste valuta-eenheid (ct/øre). Deze drempels worden gebruikt door volatiliteitssensoren en periodefilters.",
"description": "{step_progress}\n\nConfigureer drempels voor volatiliteitsclassificatie. Volatiliteit meet relatieve prijsschommelingen aan de hand van de variatiecoëfficiënt (VC = standaarddeviatie / gemiddelde × 100%). Deze drempels zijn percentagewaarden die werken op alle prijsniveaus en worden gebruikt door volatiliteitssensoren en periodefilters.",
"data": {
"volatility_threshold_moderate": "Matige drempel (ct/øre, spreiding ≥ deze waarde)",
"volatility_threshold_high": "Hoge drempel (ct/øre, spreiding ≥ deze waarde)",
"volatility_threshold_very_high": "Zeer hoge drempel (ct/øre, spreiding ≥ deze waarde)"
"volatility_threshold_moderate": "Matige drempel (VC ≥ deze %, bijv. 15)",
"volatility_threshold_high": "Hoge drempel (VC ≥ deze %, bijv. 30)",
"volatility_threshold_very_high": "Zeer hoge drempel (VC ≥ deze %, bijv. 50)"
},
"submit": "Volgende naar stap 4"
}

View file

@ -156,11 +156,11 @@
},
"volatility": {
"title": "Prisvolatilitet Trösklar",
"description": "{step_progress}\n\nKonfigurera trösklar för volatilitetsklassificering. Volatilitet mäter prisvariationer (spridning mellan min/max) i minsta valutaenhet (ct/øre). Dessa trösklar används av volatilitetssensorer och periodfilter.",
"description": "{step_progress}\n\nKonfigurera trösklar för volatilitetsklassificering. Volatilitet mäter relativa prisvariationer med hjälp av variationskoefficienten (VK = standardavvikelse / medelvärde × 100%). Dessa trösklar är procentvärden som fungerar på alla prisnivåer och används av volatilitetssensorer och periodfilter.",
"data": {
"volatility_threshold_moderate": "Måttlig tröskel (ct/øre, spridning ≥ detta värde)",
"volatility_threshold_high": "Hög tröskel (ct/øre, spridning ≥ detta värde)",
"volatility_threshold_very_high": "Mycket hög tröskel (ct/øre, spridning ≥ detta värde)"
"volatility_threshold_moderate": "Måttlig tröskel (VK ≥ denna %, t.ex. 15)",
"volatility_threshold_high": "Hög tröskel (VK ≥ denna %, t.ex. 30)",
"volatility_threshold_very_high": "Mycket hög tröskel (VK ≥ detta %, t.ex. 50)"
},
"submit": "Nästa till steg 4"
}

View file

@ -110,7 +110,6 @@ Default: 60 minutes minimum
You can optionally require:
- **Stable prices** (volatility filter) - "Only show if price doesn't fluctuate much"
- **Absolute quality** (level filter) - "Only show if prices are CHEAP/EXPENSIVE (not just below/above average)"
#### 5. Statistical Outlier Filtering
@ -221,19 +220,6 @@ peak_price_min_distance_from_avg: 2
### Optional Filters
#### Volatility Filter (Price Stability)
**What:** Only show periods with stable prices (low fluctuation)
**Default:** `low` (disabled)
**Options:** `low` | `moderate` | `high` | `very_high`
```yaml
best_price_min_volatility: low # Show all periods
best_price_min_volatility: moderate # Only show if price doesn't swing >5 ct
```
**Use case:** "I want predictable prices during the period"
#### Level Filter (Absolute Quality)
**What:** Only show periods with CHEAP/EXPENSIVE intervals (not just below/above average)
@ -298,10 +284,8 @@ For each day, the system tries:
**4 Filter Combinations (per flexibility level):**
1. Original filters (your configured volatility + level)
2. Remove volatility filter (keep level filter)
3. Remove level filter (keep volatility filter)
4. Remove both filters
1. Original filters (your configured level filter)
2. Remove level filter
**Example progression:**
@ -474,11 +458,10 @@ For advanced configuration patterns and technical deep-dive, see:
**Configuration Parameters:**
| Parameter | Default | Range | Purpose |
| ---------------------------------- | ------- | ------------------ | ------------------------------ |
| ---------------------------------- | ------- | ---------------- | ------------------------------ |
| `best_price_flex` | 15% | 0-100% | Search range from daily MIN |
| `best_price_min_period_length` | 60 min | 15-240 | Minimum duration |
| `best_price_min_distance_from_avg` | 2% | 0-20% | Quality threshold |
| `best_price_min_volatility` | low | low/mod/high/vhigh | Stability filter |
| `best_price_max_level` | any | any/cheap/vcheap | Absolute quality |
| `best_price_max_level_gap_count` | 0 | 0-10 | Gap tolerance |
| `enable_min_periods_best` | false | true/false | Enable relaxation |