PRICE-02 round 2: require now, undo the test loosening, analyse ties
- TariffPlan.arbitrage(now=...) is required (keyword-only, no default); so are homeview.trade_verdict/refusal_key's now. Existing test calls pass one. - test_arbitrage.py: no module-level decide wrapper. Fixtures that probed a one-day plan after its extremes now hold two days (as a site does); the flap day gets a unique cheapest and dearest hour; the replay breaks its ties without direction. The flap bound is <= 4 and the replay check is len(_trades) < 365 again. 228 checks pass on the old and the new code. - test_price02.py part 6: ties (equal quarters, a real 15-minute day, alternating hourly prices) and the required now. - Identity pins: the masked digest is only for the older-tree comparison; this tree's own digests are pinned with nothing hidden. - DECISIONS: the tie analysis with numbers. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude
parent
2ff9be710c
commit
891ed7bdd7
@@ -93,11 +93,9 @@ from app.control import Tuning, compute
|
||||
from app.p1 import QUARTER_S, QuarterAverager, make_sample, split_signed
|
||||
from app.tariff import PeakTracker
|
||||
from app.arbitrage import (
|
||||
CHARGE, DISCHARGE, ArbitrageDecision, claim, enabled, soc_bounds,
|
||||
CHARGE, DISCHARGE, ArbitrageDecision, claim, decide, enabled, soc_bounds,
|
||||
threshold_eur_kwh,
|
||||
)
|
||||
from app.arbitrage import decide as _decide_now
|
||||
from app.price import _cover_table as _cover
|
||||
# SAFETY-47 section 3d. The shipped shadow-mode row, not a transcription of it.
|
||||
from app import shadow as _shadow
|
||||
from app.price import (
|
||||
@@ -111,30 +109,14 @@ from app.validate import validate_startup_config
|
||||
|
||||
UTC = timezone.utc
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
# PRICE-02: `TariffPlan.arbitrage(now=...)` is required. This instant is before every
|
||||
# point these fixtures build, so no period has ended and the whole series counts.
|
||||
from datetime import datetime as _DT, timezone as _TZ # noqa: E402
|
||||
LONG_AGO = _DT(2000, 1, 1, tzinfo=_TZ.utc)
|
||||
fails = []
|
||||
|
||||
|
||||
def decide(plan, now, **kw):
|
||||
"""`arbitrage.decide()` on the plan as a running site holds it at `now`.
|
||||
|
||||
PRICE-02: the verdict drops price periods that have ended. These fixtures
|
||||
are whole-day plans probed at every hour, so the periods before `now` are
|
||||
moved one day on (the next day's prices are the same): the plan then
|
||||
starts at `now`, as a refreshed plan does, and every check keeps asking
|
||||
what it asked before. Nothing before `now` is left to drop. The new
|
||||
behaviour itself is graded in test_price02.py."""
|
||||
if plan is None or plan.read.kind != "series":
|
||||
return _decide_now(plan, now, **kw)
|
||||
r = plan.read
|
||||
pts = [PricePoint(start=brussels(p.start + timedelta(hours=24)),
|
||||
eur_per_kwh=p.eur_per_kwh, provisional=p.provisional)
|
||||
if end <= now else p for _s, end, p in _cover(r.points)]
|
||||
pts.sort(key=lambda p: p.start)
|
||||
rolled = TariffPlan(PriceRead.series(pts, r.product, r.provider,
|
||||
r.fetched_at), plan.profile, plan.origin)
|
||||
return _decide_now(rolled, now, **kw)
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
@@ -217,13 +199,22 @@ NO_WEATHER = frozenset({Uncertainty.NO_WEATHER})
|
||||
|
||||
|
||||
def plan_of(prices, *, product=PriceProduct.DAY_AHEAD, start=D0,
|
||||
provisional=False, provider="generic_dayahead"):
|
||||
provisional=False, provider="generic_dayahead", days=2):
|
||||
"""A TariffPlan over consecutive hourly prices starting at `start`.
|
||||
|
||||
`provisional` may be a single bool or a per-hour sequence.
|
||||
|
||||
PRICE-02: the verdict skips price periods that have ended, so a plan
|
||||
probed at 18:30 no longer sees the cheap hour at 03:00 of the same day. A
|
||||
running site holds 24-48 h, so `days=2` repeats the day (tomorrow's
|
||||
prices are today's): probes in day one keep their extremes, and the
|
||||
first cheapest/dearest hour still ahead is today's. Pass `days=1` for a
|
||||
plan that must be exactly the given hours.
|
||||
"""
|
||||
prices = list(prices) * days
|
||||
flags = ([bool(provisional)] * len(prices)
|
||||
if isinstance(provisional, bool) else list(provisional))
|
||||
if isinstance(provisional, bool)
|
||||
else list(provisional) * days)
|
||||
pts = [PricePoint(start=brussels(start + timedelta(hours=i)),
|
||||
eur_per_kwh=round(float(p), 6), provisional=f)
|
||||
for i, (p, f) in enumerate(zip(prices, flags))]
|
||||
@@ -367,7 +358,7 @@ check(f"a realistic Belgian day/night profile has a spread of "
|
||||
f"{_st_spread!r} EUR/kWh - the number the product decision is about",
|
||||
near(_st_spread, 0.08))
|
||||
_shipped = PRICE_OPTION_DEFAULTS["cycle_cost_threshold_eur"]
|
||||
_st_verdict = derived(lambda: _st_plan.arbitrage(
|
||||
_st_verdict = derived(lambda: _st_plan.arbitrage(now=LONG_AGO,
|
||||
peak_guard_active=False, min_spread_eur_kwh=_shipped,
|
||||
uncertainty=NO_WEATHER))
|
||||
check(f"...and at the SHIPPED threshold of {_shipped} EUR/kWh that site does "
|
||||
@@ -384,7 +375,7 @@ check(f"...and it is the SPREAD rung that refuses it, not a flat-tariff or a "
|
||||
check("...while the SAME site trades once the threshold is set below its "
|
||||
"spread, so the refusal above is about the number and not about static "
|
||||
"tariffs being unsupported",
|
||||
derived(lambda: _st_plan.arbitrage(
|
||||
derived(lambda: _st_plan.arbitrage(now=LONG_AGO,
|
||||
peak_guard_active=False,
|
||||
min_spread_eur_kwh=_st_spread / widen_factor(NO_WEATHER) - 1e-4,
|
||||
uncertainty=NO_WEATHER).kind) == "allowed")
|
||||
@@ -707,12 +698,12 @@ check("...and the interlock is REQUIRED rather than defaulted: "
|
||||
"caller cannot reach a spread without having answered it",
|
||||
all(p.default is inspect.Parameter.empty
|
||||
for n, p in inspect.signature(TariffPlan.arbitrage).parameters.items()
|
||||
if n not in ("self", "now"))) # PRICE-02: now=None keeps the old reach
|
||||
if n != "self"))
|
||||
check("...nor does arbitrage.decide(): every argument of it is keyword-only "
|
||||
"with no default, for the same reason",
|
||||
all(p.default is inspect.Parameter.empty
|
||||
and (p.kind is inspect.Parameter.KEYWORD_ONLY or n in ("plan", "now"))
|
||||
for n, p in inspect.signature(_decide_now).parameters.items()))
|
||||
for n, p in inspect.signature(decide).parameters.items()))
|
||||
|
||||
# MECHANISM 2: the arbiter. Even with the interlock bypassed, the peak claim
|
||||
# binds the arbitrage claim - because P_PEAK > P_ARBITRAGE and clause 2 applies
|
||||
@@ -1667,7 +1658,7 @@ check("a FULLY carried series trades nothing at all, whatever its spread - "
|
||||
# standing if a future caller ever hands `_act` a plan the verdict did not come
|
||||
# from.
|
||||
_probe = _arb._point_at(PLAN, hour(CHEAP_H))
|
||||
_verdict = PLAN.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0,
|
||||
_verdict = PLAN.arbitrage(now=LONG_AGO, peak_guard_active=False, min_spread_eur_kwh=0.0,
|
||||
uncertainty=frozenset())
|
||||
check("the point matched for the buy hour IS the verdict's own cheapest point, "
|
||||
"by identity - which is why `_act`'s provisional guard is unreachable "
|
||||
@@ -1688,6 +1679,14 @@ print("4b. §10.2 hysteresis - a price on the boundary cannot flap at 30 s")
|
||||
# threshold repeatedly, hour to hour, which is the shape that makes a
|
||||
# per-tick price-vs-threshold comparison flap.
|
||||
FLAP = [0.20 + (0.101 if i % 2 else 0.0) for i in range(24)]
|
||||
# PRICE-02: the verdict skips periods that have ended, and this day had ties
|
||||
# (0.20 at every even hour, 0.301 at every odd one). With ties the first hour
|
||||
# still ahead is the extreme, so the verdict moves on every hour - once per
|
||||
# hour, never per tick, which test_price02.py part 6 grades. This check is
|
||||
# about a verdict that stays put while the prices keep crossing the threshold,
|
||||
# so the day gets one cheapest and one dearest hour at its end (22: 0.60,
|
||||
# 23: 0.05), unique and ahead of every probe until they come.
|
||||
FLAP[22], FLAP[23] = 0.60, 0.05
|
||||
_flap_plan = plan_of(FLAP)
|
||||
check("the flapping fixture really does straddle the threshold hour by hour - "
|
||||
"every neighbouring pair differs by more than it",
|
||||
@@ -1705,7 +1704,7 @@ check(f"over {len(_ticks)} ticks of a day whose price crosses the threshold "
|
||||
f"every hour, the strategy changes what it is doing {_transitions} "
|
||||
f"times - not once per tick, because the decision is 'which HOUR is "
|
||||
f"this', and an hour is 3600 s wide",
|
||||
_transitions <= 24)
|
||||
_transitions <= 4)
|
||||
check("...and it is not zero, so the bound above is a bound on a strategy that "
|
||||
"acted rather than on one that did nothing",
|
||||
_transitions > 0 and CHARGE in _ticks and DISCHARGE in _ticks)
|
||||
@@ -1862,18 +1861,22 @@ CAP_WH = 10000.0 # the pack the replay moves energy through
|
||||
STEP_PCT = 100.0 * MAX_W / CAP_WH # one hour at the rail, in percent
|
||||
|
||||
|
||||
TRADE_HOURS = [] # (day, hour) the strategy acted in (PRICE-02)
|
||||
|
||||
|
||||
def replay(threshold, unc, *, soc0=50.0, floor=20.0, ceiling=95.0):
|
||||
"""Run `decide()` over a synthetic year. Returns the ledger, nothing else."""
|
||||
soc = soc0
|
||||
trades, holdings, bad = [], [], []
|
||||
neg_sells = 0
|
||||
TRADE_HOURS.clear()
|
||||
for n in range(365):
|
||||
day0 = REPLAY_START + timedelta(days=n)
|
||||
prices = synth_day(n)
|
||||
# PRICE-02: no two hours share a price. The synthetic day is
|
||||
# symmetric, so it prints the same maximum twice; the verdict now takes
|
||||
# the first one still AHEAD, where it used to take the first of the
|
||||
# day (a tie, graded on its own in test_price02.py part 6). So the
|
||||
# ties are broken by a jitter of at most 5e-5 EUR/kWh that has no
|
||||
# direction: not later-is-dearer, which would send every day to a
|
||||
# buy-then-sell and hide the selectivity this replay checks.
|
||||
prices = [round(p + 1e-6 * ((37 * h + 101 * n) % 50), 6)
|
||||
for h, p in enumerate(synth_day(n))]
|
||||
plan = plan_of(prices, start=day0)
|
||||
open_buy = None
|
||||
for h in range(24):
|
||||
@@ -1881,7 +1884,6 @@ def replay(threshold, unc, *, soc0=50.0, floor=20.0, ceiling=95.0):
|
||||
ceiling=ceiling, start=day0)
|
||||
if d.action is None:
|
||||
continue
|
||||
TRADE_HOURS.append((n, h))
|
||||
price = prices[h]
|
||||
if d.action == CHARGE:
|
||||
soc = min(ceiling, soc + STEP_PCT)
|
||||
@@ -1910,12 +1912,9 @@ check(f"the replay is 365 days long and actually traded ({len(_trades)} "
|
||||
check("...and every trade carries the spread and the threshold it cleared, so "
|
||||
"the ledger is auditable rather than a count",
|
||||
_bad == [])
|
||||
# PRICE-02: the plan now rolls with the clock, so a day can hold several round
|
||||
# trips and every day may trade once. Selective is judged per hour instead.
|
||||
check(f"...and the strategy acted in {len(TRADE_HOURS)} of {365 * 24} hours, a "
|
||||
f"minority, so it is selective rather than unconditional - a replay in "
|
||||
f"which everything trades grades nothing",
|
||||
0 < len(TRADE_HOURS) < 365 * 24 // 4)
|
||||
check("...and NOT every day traded, so the strategy is selective rather than "
|
||||
"unconditional - a replay in which everything trades grades nothing",
|
||||
len(_trades) < 365)
|
||||
_losses = [(b, s) for b, s, _ in _trades if s - b < 0.05]
|
||||
check(f"EVERY completed round trip cleared the threshold it was run at: sell "
|
||||
f"minus buy >= 0.05 EUR/kWh on all {len(_trades)} of them "
|
||||
@@ -2475,7 +2474,7 @@ check("...and every one of its points is provisional, so firm_points() is "
|
||||
"a fresh observation",
|
||||
isinstance(_t12_fb, TariffPlan) and _t12_fb.provisional is True
|
||||
and _t12_fb.firm_points() == ()
|
||||
and _t12_fb.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0,
|
||||
and _t12_fb.arbitrage(now=LONG_AGO, peak_guard_active=False, min_spread_eur_kwh=0.0,
|
||||
uncertainty=NO_WEATHER).kind == "refused")
|
||||
_t12_f.tariff_plan, _t12_f.tariff_at = _t12_good, datetime.now(UTC) - \
|
||||
timedelta(seconds=1800)
|
||||
@@ -2820,7 +2819,7 @@ check(f"the shipped rule and this one give DIFFERENT points at both probes - "
|
||||
|
||||
print()
|
||||
print("10b. the buy/sell identity test matches the actual extremum interval")
|
||||
_qv = QPLAN.arbitrage(peak_guard_active=False, min_spread_eur_kwh=_shipped,
|
||||
_qv = QPLAN.arbitrage(now=LONG_AGO, peak_guard_active=False, min_spread_eur_kwh=_shipped,
|
||||
uncertainty=NO_WEATHER)
|
||||
check("the verdict's cheapest and dearest are the two QUARTERS, not the hours "
|
||||
"containing them - `static_tariff` reduces over every firm point and "
|
||||
@@ -2952,7 +2951,7 @@ check(f"...and its firm half's extremes are quarters {_mix_cheap_i} and "
|
||||
f"{_mix_dear_i}, neither on a whole hour, so the identity test below is "
|
||||
f"about the quarter and not about the hour",
|
||||
(_mix_cheap_i - MIX_N_H) % 4 != 0 and (_mix_dear_i - MIX_N_H) % 4 != 0)
|
||||
_mv = MIX_PLAN.arbitrage(peak_guard_active=False, min_spread_eur_kwh=_shipped,
|
||||
_mv = MIX_PLAN.arbitrage(now=LONG_AGO, peak_guard_active=False, min_spread_eur_kwh=_shipped,
|
||||
uncertainty=NO_WEATHER)
|
||||
_mix_buy_t = qprobe(_mix_cheap_i, gaps=MIX_GAPS)
|
||||
_mix_sell_t = qprobe(_mix_dear_i, gaps=MIX_GAPS)
|
||||
@@ -2964,14 +2963,19 @@ check("the verdict over a MIXED series still names firm quarters, and "
|
||||
is MIX_PTS[_mix_cheap_i]
|
||||
and derived(lambda: _arb._point_at(MIX_PLAN, _mix_sell_t))
|
||||
is MIX_PTS[_mix_dear_i])
|
||||
# PRICE-02: the same day twice, as a running site holds 24-48 h (see plan_of).
|
||||
# The probes stay in day one and keep their extremes: once the cheap quarter is
|
||||
# over, the verdict at the dear quarter needs a cheap one still ahead.
|
||||
MIX2_PLAN = qplan_of(MIX_PRICES * 2, provisional=MIX_FLAGS * 2,
|
||||
gaps=MIX_GAPS + [900] + MIX_GAPS)
|
||||
check("...so the mixed day CHARGES and DISCHARGES in its firm quarters, at "
|
||||
"their own prices",
|
||||
decide(MIX_PLAN, _mix_buy_t, peak_guard_active=False, soc=50.0,
|
||||
decide(MIX2_PLAN, _mix_buy_t, peak_guard_active=False, soc=50.0,
|
||||
min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
|
||||
charge_headroom_w=HEADROOM_CLEAR,
|
||||
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
|
||||
soc_ceiling_pct=95.0).action == CHARGE
|
||||
and decide(MIX_PLAN, _mix_sell_t, peak_guard_active=False, soc=50.0,
|
||||
and decide(MIX2_PLAN, _mix_sell_t, peak_guard_active=False, soc=50.0,
|
||||
min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
|
||||
charge_headroom_w=HEADROOM_CLEAR,
|
||||
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
|
||||
|
||||
Reference in New Issue
Block a user