"""Tests for app/integrations/pricing/strategies.py. Acceptance criteria covered ---------------------------- 1. ``get_strategy("manual")`` / ``get_strategy("tibber")`` return registered callables. 2. Manual strategy: dual-tariff import/export/net calculated correctly (hand-verified). 3. Manual strategy: Decimal precision — no float binary rounding errors. 4. Tibber strategy: queries the most recent TibberPrice with starts_at ≤ t0. 5. Tibber strategy: buy=total, sell=total−energy_tax−sell_adjust. 6. Tibber strategy: negative total → negative export_revenue (not clamped). 7. Tibber strategy: raises TibberPriceNotFoundError when no matching row exists. 8. ``register_strategy`` / ``get_strategy`` round-trip works. 9. ``get_strategy`` raises KeyError for unknown kinds. """ from __future__ import annotations from datetime import datetime, timezone from decimal import Decimal from pathlib import Path import pytest from alembic import command from alembic.config import Config from sqlalchemy import create_engine from sqlalchemy.orm import Session from app.integrations.pricing.strategies import ( PeriodDeltas, TibberPriceNotFoundError, get_strategy, register_strategy, ) from app.models.energy import TibberPrice # --------------------------------------------------------------------------- # Fixtures: in-memory / temp-file SQLite with energy tables # --------------------------------------------------------------------------- def _make_app_alembic_config(database_url: str) -> Config: cfg = Config("alembic_app.ini") cfg.set_main_option("sqlalchemy.url", database_url) return cfg @pytest.fixture() def tibber_db(tmp_path: Path): """Temporary SQLite DB upgraded to head (has tibber_price table).""" db_path = tmp_path / "tibber_strategy_test.db" db_url = f"sqlite:///{db_path}" alembic_cfg = _make_app_alembic_config(db_url) command.upgrade(alembic_cfg, "head") engine = create_engine(db_url, connect_args={"check_same_thread": False}) yield engine engine.dispose() # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- _UTC = timezone.utc def _ts(hour: int, minute: int = 0) -> datetime: """Return a UTC datetime on 2026-06-23 at the given hour:minute.""" return datetime(2026, 6, 23, hour, minute, 0, tzinfo=_UTC) def _insert_tibber_price( session: Session, starts_at: datetime, total: float, energy: float = 0.08, tax: float | None = None, currency: str = "EUR", ) -> TibberPrice: """Insert and flush a TibberPrice row; return the ORM instance.""" if tax is None: tax = round(total - energy, 6) row = TibberPrice( starts_at=starts_at, resolution="QUARTER_HOURLY", energy=energy, tax=tax, total=total, level="NORMAL", currency=currency, fetched_at=datetime.now(_UTC), ) session.add(row) session.flush() return row # --------------------------------------------------------------------------- # 1. Registry round-trip # --------------------------------------------------------------------------- class TestRegistry: def test_manual_strategy_registered(self) -> None: fn = get_strategy("manual") assert callable(fn) def test_tibber_strategy_registered(self) -> None: fn = get_strategy("tibber") assert callable(fn) def test_unknown_kind_raises_key_error(self) -> None: with pytest.raises(KeyError, match="no_such_kind"): get_strategy("no_such_kind") def test_register_and_retrieve_custom_strategy(self) -> None: def _my_fn(deltas, t0, values, session): return {} register_strategy("_test_custom", _my_fn) assert get_strategy("_test_custom") is _my_fn # Cleanup to avoid polluting other tests. from app.integrations.pricing.strategies import _REGISTRY _REGISTRY.pop("_test_custom", None) # --------------------------------------------------------------------------- # 2-3. Manual strategy # --------------------------------------------------------------------------- # Dummy session (manual strategy does not use the session). _NO_SESSION = None # type: ignore[assignment] class TestManualStrategy: """Verify manual dual-tariff price calculations.""" # Contract values for all manual tests. _VALUES = { "energy": { "buy": {"normal": 0.133, "dal": 0.127}, "sell": {"normal": 0.05, "dal": 0.05}, "energy_tax": 0.11, "ode": 0.0, }, "standing": {"network_fee": 9.87, "management_fee": 9.87}, "credits": {"heffingskorting": 600.0}, } def _call(self, deltas: PeriodDeltas) -> dict: fn = get_strategy("manual") return fn(deltas, _ts(10), self._VALUES, _NO_SESSION) # --- hand-calculated expected values --- # buy_dal = 0.127 + 0.11 + 0.0 = 0.237 # buy_normal = 0.133 + 0.11 + 0.0 = 0.243 # sell_dal = 0.05 # sell_normal = 0.05 # # With Δd1=2, Δd2=3, Δr1=1, Δr2=4: # import_cost = 2×0.237 + 3×0.243 = 0.474 + 0.729 = 1.203 # export_revenue = 1×0.05 + 4×0.05 = 0.05 + 0.20 = 0.25 # net_cost = 1.203 − 0.25 = 0.953 def test_import_cost_dual_tariff(self) -> None: deltas = PeriodDeltas( d1=Decimal("2"), d2=Decimal("3"), r1=Decimal("1"), r2=Decimal("4"), ) result = self._call(deltas) expected = Decimal("2") * Decimal("0.237") + Decimal("3") * Decimal("0.243") assert result["import_cost"] == expected def test_export_revenue_dual_tariff(self) -> None: deltas = PeriodDeltas( d1=Decimal("2"), d2=Decimal("3"), r1=Decimal("1"), r2=Decimal("4"), ) result = self._call(deltas) expected = Decimal("1") * Decimal("0.05") + Decimal("4") * Decimal("0.05") assert result["export_revenue"] == expected def test_net_cost_equals_import_minus_export(self) -> None: deltas = PeriodDeltas( d1=Decimal("2"), d2=Decimal("3"), r1=Decimal("1"), r2=Decimal("4"), ) result = self._call(deltas) assert result["net_cost"] == result["import_cost"] - result["export_revenue"] def test_hand_calculated_values(self) -> None: """Verify exact hand-calculated result for the reference deltas.""" deltas = PeriodDeltas( d1=Decimal("2"), d2=Decimal("3"), r1=Decimal("1"), r2=Decimal("4"), ) result = self._call(deltas) assert result["import_cost"] == Decimal("1.203") assert result["export_revenue"] == Decimal("0.25") assert result["net_cost"] == Decimal("0.953") def test_zero_deltas_yields_zero_costs(self) -> None: deltas = PeriodDeltas( d1=Decimal("0"), d2=Decimal("0"), r1=Decimal("0"), r2=Decimal("0"), ) result = self._call(deltas) assert result["import_cost"] == Decimal("0") assert result["export_revenue"] == Decimal("0") assert result["net_cost"] == Decimal("0") def test_ode_included_in_buy_price(self) -> None: """When ode > 0 it is added to the effective buy price.""" values = { "energy": { "buy": {"normal": 0.10, "dal": 0.10}, "sell": {"normal": 0.05, "dal": 0.05}, "energy_tax": 0.10, "ode": 0.01, # non-zero ode }, "standing": {"network_fee": 0.0, "management_fee": 0.0}, "credits": {"heffingskorting": 0.0}, } deltas = PeriodDeltas( d1=Decimal("1"), d2=Decimal("0"), r1=Decimal("0"), r2=Decimal("0"), ) fn = get_strategy("manual") result = fn(deltas, _ts(10), values, _NO_SESSION) # buy_dal = 0.10 + 0.10 + 0.01 = 0.21; import_cost = 1 × 0.21 = 0.21 assert result["import_cost"] == Decimal("0.21") def test_import_export_kept_separate(self) -> None: """import_cost and export_revenue must not be netted before assignment.""" deltas = PeriodDeltas( d1=Decimal("5"), d2=Decimal("5"), r1=Decimal("3"), r2=Decimal("3"), ) result = self._call(deltas) # Both must be individually non-zero. assert result["import_cost"] > 0 assert result["export_revenue"] > 0 def test_pricing_snapshot_present(self) -> None: deltas = PeriodDeltas( d1=Decimal("1"), d2=Decimal("1"), r1=Decimal("0"), r2=Decimal("0"), ) result = self._call(deltas) snapshot = result["pricing"] assert snapshot["kind"] == "manual" assert "buy_dal" in snapshot assert "buy_normal" in snapshot assert "sell_dal" in snapshot assert "sell_normal" in snapshot # --------------------------------------------------------------------------- # 3. Decimal precision — float-error exposure test # --------------------------------------------------------------------------- class TestManualDecimalPrecision: """Use values known to produce binary float errors if float arithmetic is used.""" def test_no_float_rounding_error(self) -> None: """0.1 + 0.2 in float gives 0.30000000000000004; Decimal must be exact.""" values = { "energy": { "buy": {"normal": 0.1, "dal": 0.2}, "sell": {"normal": 0.1, "dal": 0.1}, "energy_tax": 0.0, "ode": 0.0, }, "standing": {"network_fee": 0.0, "management_fee": 0.0}, "credits": {"heffingskorting": 0.0}, } deltas = PeriodDeltas( d1=Decimal("1"), d2=Decimal("1"), r1=Decimal("0"), r2=Decimal("0"), ) fn = get_strategy("manual") result = fn(deltas, _ts(10), values, _NO_SESSION) # import_cost = 1×0.2 + 1×0.1 = 0.3 exactly assert result["import_cost"] == Decimal("0.3"), ( f"Expected Decimal('0.3'), got {result['import_cost']!r} — " "float arithmetic leaking in?" ) def test_result_values_are_decimal_type(self) -> None: values = { "energy": { "buy": {"normal": 0.133, "dal": 0.127}, "sell": {"normal": 0.05, "dal": 0.05}, "energy_tax": 0.11, "ode": 0.0, }, "standing": {"network_fee": 9.87, "management_fee": 9.87}, "credits": {"heffingskorting": 600.0}, } deltas = PeriodDeltas( d1=Decimal("1"), d2=Decimal("1"), r1=Decimal("1"), r2=Decimal("1"), ) fn = get_strategy("manual") result = fn(deltas, _ts(10), values, _NO_SESSION) assert isinstance(result["import_cost"], Decimal) assert isinstance(result["export_revenue"], Decimal) assert isinstance(result["net_cost"], Decimal) # --------------------------------------------------------------------------- # 4-7. Tibber strategy # --------------------------------------------------------------------------- class TestTibberStrategy: """Verify Tibber price-lookup and billing calculations.""" _VALUES = { "energy": { "energy_tax": 0.10, "sell_adjust": 0.0, }, "standing": {"management_fee": 5.99, "network_fee": 9.87}, "credits": {"heffingskorting": 600.0}, } def _call( self, deltas: PeriodDeltas, t0: datetime, session: Session, values: dict | None = None, ) -> dict: fn = get_strategy("tibber") return fn(deltas, t0, values or self._VALUES, session) def test_buy_equals_total(self, tibber_db) -> None: t0 = _ts(10, 0) with Session(tibber_db) as session: _insert_tibber_price(session, starts_at=_ts(9, 45), total=0.25) session.commit() with Session(tibber_db) as session: deltas = PeriodDeltas( d1=Decimal("1"), d2=Decimal("1"), r1=Decimal("0"), r2=Decimal("0"), ) result = self._call(deltas, t0, session) # buy = total = 0.25; import_cost = 2 × 0.25 = 0.50 assert result["import_cost"] == Decimal("2") * Decimal("0.25") def test_sell_equals_total_minus_energy_tax_minus_adjust(self, tibber_db) -> None: t0 = _ts(10, 0) with Session(tibber_db) as session: _insert_tibber_price(session, starts_at=_ts(9, 45), total=0.25) session.commit() values = { "energy": {"energy_tax": 0.10, "sell_adjust": 0.02}, "standing": {"management_fee": 5.99, "network_fee": 9.87}, "credits": {"heffingskorting": 600.0}, } with Session(tibber_db) as session: deltas = PeriodDeltas( d1=Decimal("0"), d2=Decimal("0"), r1=Decimal("1"), r2=Decimal("1"), ) result = self._call(deltas, t0, session, values=values) # sell = 0.25 - 0.10 - 0.02 = 0.13; export_revenue = 2 × 0.13 = 0.26 assert result["export_revenue"] == Decimal("2") * Decimal("0.13") def test_uses_most_recent_price_before_t0(self, tibber_db) -> None: """Correct row: starts_at ≤ t0, most recent wins.""" t0 = _ts(10, 0) with Session(tibber_db) as session: # Older price (should NOT be used) _insert_tibber_price(session, starts_at=_ts(9, 0), total=0.10) # Closer price (starts_at ≤ t0, should be used) _insert_tibber_price(session, starts_at=_ts(9, 45), total=0.30) # Future price (starts_at > t0, must NOT be used) _insert_tibber_price(session, starts_at=_ts(10, 15), total=0.99) session.commit() with Session(tibber_db) as session: deltas = PeriodDeltas( d1=Decimal("1"), d2=Decimal("0"), r1=Decimal("0"), r2=Decimal("0"), ) result = self._call(deltas, t0, session) # Only the 09:45 price (total=0.30) should be used. snapshot = result["pricing"] assert Decimal(snapshot["total"]) == Decimal("0.30") def test_tibber_sums_both_tariff_registers(self, tibber_db) -> None: """Tibber does not split dal/normal; import_cost = (d1+d2) × buy.""" t0 = _ts(10, 0) with Session(tibber_db) as session: _insert_tibber_price(session, starts_at=_ts(9, 45), total=0.20) session.commit() with Session(tibber_db) as session: deltas = PeriodDeltas( d1=Decimal("3"), d2=Decimal("2"), r1=Decimal("0"), r2=Decimal("0"), ) result = self._call(deltas, t0, session) # import_cost = (3+2) × 0.20 = 1.00 assert result["import_cost"] == Decimal("1.00") def test_negative_total_gives_negative_export_revenue(self, tibber_db) -> None: """When total is negative, selling electricity costs money (correct behaviour).""" t0 = _ts(10, 0) with Session(tibber_db) as session: # total = -0.05; sell = -0.05 - 0.10 - 0.0 = -0.15 _insert_tibber_price(session, starts_at=_ts(9, 45), total=-0.05) session.commit() with Session(tibber_db) as session: deltas = PeriodDeltas( d1=Decimal("0"), d2=Decimal("0"), r1=Decimal("2"), r2=Decimal("2"), ) result = self._call(deltas, t0, session) # sell = -0.05 - 0.10 - 0.0 = -0.15; export_revenue = 4 × (-0.15) = -0.60 assert result["export_revenue"] < 0 assert result["export_revenue"] == Decimal("4") * ( Decimal("-0.05") - Decimal("0.10") - Decimal("0.0") ) def test_negative_total_exact_calculation(self, tibber_db) -> None: """Full hand-calculation for negative-total scenario.""" t0 = _ts(10, 0) total = Decimal("-0.05") energy_tax = Decimal("0.10") sell_adjust = Decimal("0.0") with Session(tibber_db) as session: _insert_tibber_price(session, starts_at=_ts(9, 45), total=float(total)) session.commit() with Session(tibber_db) as session: deltas = PeriodDeltas( d1=Decimal("1"), d2=Decimal("1"), # delivered = 2 kWh r1=Decimal("1"), r2=Decimal("1"), # returned = 2 kWh ) result = self._call(deltas, t0, session) buy = total # -0.05 sell = total - energy_tax - sell_adjust # -0.15 expected_import = Decimal("2") * buy # -0.10 expected_export = Decimal("2") * sell # -0.30 expected_net = expected_import - expected_export # 0.20 assert result["import_cost"] == expected_import assert result["export_revenue"] == expected_export assert result["net_cost"] == expected_net def test_no_price_before_t0_raises(self, tibber_db) -> None: """When no TibberPrice exists with starts_at ≤ t0, TibberPriceNotFoundError is raised.""" t0 = _ts(10, 0) with Session(tibber_db) as session: # Only a future price — starts_at > t0. _insert_tibber_price(session, starts_at=_ts(10, 15), total=0.25) session.commit() fn = get_strategy("tibber") with Session(tibber_db) as session: deltas = PeriodDeltas( d1=Decimal("1"), d2=Decimal("0"), r1=Decimal("0"), r2=Decimal("0"), ) with pytest.raises(TibberPriceNotFoundError): fn(deltas, t0, self._VALUES, session) def test_empty_tibber_table_raises(self, tibber_db) -> None: """Empty tibber_price table raises TibberPriceNotFoundError.""" fn = get_strategy("tibber") with Session(tibber_db) as session: deltas = PeriodDeltas( d1=Decimal("1"), d2=Decimal("0"), r1=Decimal("0"), r2=Decimal("0"), ) with pytest.raises(TibberPriceNotFoundError): fn(deltas, _ts(10), self._VALUES, session) def test_pricing_snapshot_contains_expected_keys(self, tibber_db) -> None: t0 = _ts(10, 0) with Session(tibber_db) as session: _insert_tibber_price(session, starts_at=_ts(9, 45), total=0.20) session.commit() with Session(tibber_db) as session: deltas = PeriodDeltas( d1=Decimal("1"), d2=Decimal("0"), r1=Decimal("0"), r2=Decimal("0"), ) result = self._call(deltas, t0, session) snapshot = result["pricing"] assert snapshot["kind"] == "tibber" assert "tibber_price_starts_at" in snapshot assert "buy" in snapshot assert "sell" in snapshot def test_result_values_are_decimal_type(self, tibber_db) -> None: t0 = _ts(10, 0) with Session(tibber_db) as session: _insert_tibber_price(session, starts_at=_ts(9, 45), total=0.20) session.commit() with Session(tibber_db) as session: deltas = PeriodDeltas( d1=Decimal("1"), d2=Decimal("1"), r1=Decimal("1"), r2=Decimal("1"), ) result = self._call(deltas, t0, session) assert isinstance(result["import_cost"], Decimal) assert isinstance(result["export_revenue"], Decimal) assert isinstance(result["net_cost"], Decimal) # --------------------------------------------------------------------------- # Tibber precision test # --------------------------------------------------------------------------- class TestTibberDecimalPrecision: """Ensure Tibber arithmetic is exact Decimal (no float leakage).""" def test_no_float_rounding_for_tricky_values(self, tibber_db) -> None: """total=0.1, energy_tax=0.2 — float gives 0.1+0.2 error; Decimal must be exact.""" t0 = _ts(10, 0) with Session(tibber_db) as session: _insert_tibber_price(session, starts_at=_ts(9, 45), total=0.3) session.commit() values = { "energy": {"energy_tax": 0.1, "sell_adjust": 0.2}, "standing": {"management_fee": 5.99, "network_fee": 9.87}, "credits": {"heffingskorting": 600.0}, } fn = get_strategy("tibber") with Session(tibber_db) as session: deltas = PeriodDeltas( d1=Decimal("0"), d2=Decimal("0"), r1=Decimal("1"), r2=Decimal("0"), ) result = fn(deltas, t0, values, session) # sell = 0.3 - 0.1 - 0.2 = 0.0 exactly (float gives ~2.8e-17) assert result["export_revenue"] == Decimal("0"), ( f"Expected Decimal('0'), got {result['export_revenue']!r} — " "float arithmetic leaking in?" )