2026-06-23 20:58:34 +02:00
|
|
|
|
"""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.
|
2026-07-20 13:50:13 +02:00
|
|
|
|
5. Tibber strategy: buy=total, sell=total−energy_tax−sell_fee−sell_adjust.
|
2026-06-23 20:58:34 +02:00
|
|
|
|
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")
|
|
|
|
|
|
|
2026-07-20 13:50:13 +02:00
|
|
|
|
def test_sell_deducts_sell_fee(self, tibber_db) -> None:
|
|
|
|
|
|
"""verkoopvergoeding (sell_fee) is subtracted from the feed-in price.
|
|
|
|
|
|
|
|
|
|
|
|
Under net metering the energy tax is refunded (sell_adjust = −energy_tax),
|
|
|
|
|
|
so sell should equal total − sell_fee. Verifies the fee is a first-class,
|
|
|
|
|
|
always-deducted term and does NOT cancel against the buy-side inkoopvergoeding
|
|
|
|
|
|
that is already baked into total.
|
|
|
|
|
|
"""
|
|
|
|
|
|
t0 = _ts(10, 0)
|
|
|
|
|
|
with Session(tibber_db) as session:
|
|
|
|
|
|
_insert_tibber_price(session, starts_at=_ts(9, 45), total=0.3073)
|
|
|
|
|
|
session.commit()
|
|
|
|
|
|
|
|
|
|
|
|
# Net-metering config: sell_adjust = −energy_tax refunds the tax;
|
|
|
|
|
|
# sell_fee = 0.0248 (Tibber verkoopvergoeding) is still deducted.
|
|
|
|
|
|
values = {
|
|
|
|
|
|
"energy": {
|
|
|
|
|
|
"energy_tax": 0.11085,
|
|
|
|
|
|
"sell_fee": 0.0248,
|
|
|
|
|
|
"sell_adjust": -0.11085,
|
|
|
|
|
|
},
|
|
|
|
|
|
"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("0"), r2=Decimal("1"),
|
|
|
|
|
|
)
|
|
|
|
|
|
result = self._call(deltas, t0, session, values=values)
|
|
|
|
|
|
|
|
|
|
|
|
# sell = 0.3073 − 0.11085 − 0.0248 − (−0.11085) = 0.3073 − 0.0248 = 0.2825
|
|
|
|
|
|
expected_sell = (
|
|
|
|
|
|
Decimal("0.3073") - Decimal("0.11085") - Decimal("0.0248") - Decimal("-0.11085")
|
|
|
|
|
|
)
|
|
|
|
|
|
assert expected_sell == Decimal("0.2825")
|
|
|
|
|
|
assert result["export_revenue"] == Decimal("1") * expected_sell
|
|
|
|
|
|
assert result["pricing"]["sell_fee"] == "0.0248"
|
|
|
|
|
|
assert Decimal(result["pricing"]["sell"]) == Decimal("0.2825")
|
|
|
|
|
|
|
|
|
|
|
|
def test_sell_fee_absent_defaults_to_zero(self, tibber_db) -> None:
|
|
|
|
|
|
"""A version without sell_fee (pre-migration) reads it as 0 — no silent deduction."""
|
|
|
|
|
|
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.0}, # no sell_fee key
|
|
|
|
|
|
"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("0"), r2=Decimal("1"),
|
|
|
|
|
|
)
|
|
|
|
|
|
result = self._call(deltas, t0, session, values=values)
|
|
|
|
|
|
# sell = 0.25 − 0.10 − 0 − 0 = 0.15
|
|
|
|
|
|
assert result["export_revenue"] == Decimal("0.15")
|
|
|
|
|
|
assert result["pricing"]["sell_fee"] == "0"
|
|
|
|
|
|
|
2026-06-23 20:58:34 +02:00
|
|
|
|
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?"
|
|
|
|
|
|
)
|