TARIFF-14: the point whose own interval covers the instant
checks / unit (pull_request) Failing after 2s

`_Series.at()` asked `s <= u < s + 1 h` of every point IN LIST ORDER and
returned the FIRST match. On the hourly shape every fixture in this
repository carried before TARIFF-08 that is right by accident. Belgium
moved to a 15-minute MTU on 2025-10-01 and the live day-ahead feed
returns 96 points at 900 s, where it is wrong twice over - measured
against the shipped code at 5059e28:

    series: 02:00=0.10  02:15=0.11  02:30=0.12  02:45=0.13  03:00=0.14

    ask +15min -> 02:00  0.10   <-- WRONG (wanted 02:15)
    ask +60min -> 02:15  0.11   <-- WRONG (wanted 03:00)
    ask +75min -> 02:30  0.12   <-- WRONG (wanted 03:15)

Inside the first hour the :00 point wins because its hour has not run
out. From the second hour on the answer is not even the top of the hour
- the first point still inside its own hour wins - so the error
compounds with depth into the series.

`arbitrage._act()` tests `here.start == v.cheapest.start` /
`== v.dearest.start` to decide whether this interval is the buy one or
the sell one. That identity almost never held, so the strategy idled
through exactly the interval it exists to trade, and read §8.3's
`here.provisional` and §17's `price < 0` off the same wrong point.
`static_tariff.py` emits hourly points, so static sites were never
affected; this is reachable only down the dynamic path TARIFF-08/09
made work.

THE RULE: a point covers from its own start until the next point's
start, and never more than one hour. Quarter-hourly takes 900 s,
hourly is unchanged, and a MIXED series - FCST-04's carry-forward puts
hourly carried points beside quarter-hourly firm ones, spacing
[900, 3600] in 70 % of soak samples - takes each point's OWN gap.

The one-hour cap is load-bearing: FCST-04 leaves an hour OUT when
nothing published it and yesterday has no value, and `at()` raising
Absence for it is what sends the caller to the static profile (§11.4).
Uncapped, "until the next point's start" would stretch the point before
a three-hour hole across the whole hole and answer every instant in it
with a stale price.

THE LAST POINT is given the same span as the point before it (one hour
when it is the only point). A flat one-hour tail would answer 00:30
TOMORROW with today's 23:45 price. The cost - a mixed series ending at
the firm/carried seam loses its last 45 minutes to Absence - is argued
in DECISIONS/TARIFF-14-the-window-a-point-covers.md §2 rather than
buried; the direction is the safe one.

`at()` now reads a point's NEIGHBOURS, so it would depend on
sortedness. It establishes that itself: the lookup index is sorted by
UTC start once, at construction, so no caller's list order reaches it.
A series handed its 96 points in reverse answers identically, and that
is a check. `covers()` shared the defect exactly - it has no rule of
its own, it asks `at()` - and is fixed by the same change; `window()`
did not, and its docstring now says why.

Nothing about §9.3 or a point's `start` changed. PricePoint still has
three fields; the span is derived from the neighbours, never stored.

Graded by test_price.py section 13 (900 s, 3600 s, mixed, holes, the
tail, caller order, duplicate starts, covers()/window(), the no-data
contract) and end to end by test_arbitrage.py section 10, which drives
decide() over a 96-point quarter-hourly day whose cheapest and dearest
quarters are deliberately NOT on a whole hour, plus §17, §8.3 and the
mixed series. Eight mutations, all red, including a ZERO-BYTE one on
the tail rule; byte deltas and what the arbitrage file could not see
are in the decision note §6. All 29 test files green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
This commit is contained in:
glenn schrooyen
2026-09-12 21:22:28 +02:00
co-authored by Claude Opus 5
parent 88c59a9918
commit 7f2c4e20e1
5 changed files with 1142 additions and 9 deletions
+160 -9
View File
@@ -541,6 +541,121 @@ class PriceRead(_Outcome):
return _NoData(reason)
# --------------------------------------------------------------------------- #
# How long ONE point covers - TARIFF-14
# --------------------------------------------------------------------------- #
# ⚠️ A POINT DOES NOT CARRY ITS OWN DURATION AND MUST NOT LEARN TO. §9.3 lists
# THREE fields and `PricePoint` has three; a fourth would change the wire
# format every store, replay, fixture and captured body in this repository is
# written against. The span is DERIVED from the neighbours instead - here,
# once, at construction.
MAX_POINT_SPAN = timedelta(hours=1)
def _cover_table(points) -> tuple:
"""`(start_utc, end_utc, point)` per point, ascending. The `at()` index.
THE DEFECT THIS PREVENTS (TARIFF-14, HIGH). `at()` asked
`s <= u < s + 1 h` of every point IN LIST ORDER and returned the FIRST
match. On an hourly series - the only resolution any fixture carried
before TARIFF-08 - that is right by accident. Belgium moved to a
15-minute MTU on 2025-10-01 and the live day-ahead feed returns 96 points
at 900 s, where it is wrong twice over. Measured on shipped `5059e28`:
series: 02:00=0.10 02:15=0.11 02:30=0.12 02:45=0.13 03:00=0.14
at(02:15) -> 02:00 the :00 point's HOUR has not run out yet
at(03:15) -> 02:30 and from the second hour on it is not even the
top of the hour: the first point still inside
its own hour wins, so the error COMPOUNDS with
depth into the series
`arbitrage._act()` compares `here.start` against `v.cheapest.start` and
`v.dearest.start` to decide whether this interval is the buy or the sell
one. That identity test almost never matched the extremum, so the
strategy idled through exactly the interval it exists to trade - and read
`here.provisional` and §17's `price < 0` guard off the wrong point while
doing it. `static_tariff.py` emits hourly points, so static sites never
saw it; this is reachable only down the dynamic path TARIFF-08/09 made
work.
THE RULE, in one sentence:
A POINT COVERS FROM ITS OWN START UNTIL THE NEXT POINT'S START, AND
NEVER MORE THAN ONE HOUR.
* 900 s -> each point covers its own quarter, so `at(02:15)` is the
02:15 point;
* 3600 s -> each point covers its own hour, unchanged from shipped;
* MIXED -> EACH POINT TAKES ITS OWN GAP. FCST-04's carry-forward emits
HOURLY carried points beside QUARTER-HOURLY firm ones, and the soak
measured spacing `[900, 3600]` in 70 % of samples - so mixed is the
normal steady state, and a window derived from one global spacing
would be wrong THERE rather than in an edge case.
⚠️ WHY THE ONE-HOUR CAP, WHICH IS NOT DECORATION. FCST-04 LEAVES HOLES ON
PURPOSE: an hour with no published price AND no yesterday value is left
OUT of the series, and `price_forecast.py`'s module note names `at()`
raising `Absence` for it as the behaviour that sends the caller to the
static profile (§11.4 for that hour). Uncapped, "until the next point's
start" would stretch the point BEFORE a three-hour hole across the whole
hole and answer every instant in it with a stale price. Filling holes is
the defect this module exists to refuse, and it would have been
introduced by the fix for a different one.
⚠️ THE LAST POINT'S WINDOW IS DECIDED, NOT INHERITED. There is no next
start and nothing in §9.3 says how wide the final interval is, so THE
LAST POINT IS GIVEN THE SAME SPAN AS THE POINT BEFORE IT - one hour when
it is the only point. Three consequences, all intended:
* on a 96-point 900 s day the 23:45 point covers 23:45 .. 00:00, so
`at(23:50)` ANSWERS and `at(00:00)` raises `Absence`. A flat one-hour
tail would have answered 00:30 TOMORROW with today's last price,
which is reaching for a neighbour across a day boundary;
* on an hourly series the tail is an hour, exactly as it shipped, so no
static or hourly site changes behaviour;
* on a MIXED series whose last point is a carried HOURLY one behind
quarter-hourly firm ones, the tail is 900 s and the last 45 minutes
of the horizon raise `Absence`. That is the SAFE direction and it is
accepted rather than tolerated: the caller falls back to the static
profile (§11.4) instead of trading on a span the series never
demonstrated, and a carried point is provisional, so §8.3 forbids any
irreversible action resting on it regardless.
⚠️ ORDER IS ESTABLISHED HERE, NOT ASSUMED - and that is the difference
between this and the rule it replaces. `at()` now reads neighbours, so it
WOULD depend on sortedness; the table is therefore sorted by UTC start at
construction, and no caller's list order can reach it. `_validate_raw()`
rejects out-of-order and duplicate starts on the `load_prices()` path,
`price_history._parse_points()` re-sorts what it reads off disk, and
FCST-04's fill emits ascending - but `PriceRead.series()` itself has
never enforced any of that, and an assumption that holds today and is
unstated is how this class of defect recurs.
⚠️ NOTHING HERE RAISES ON A MALFORMED SERIES. A duplicated start - not
reachable through `load_prices()`, which refuses it - gives the earlier
list entry a ZERO-width window and the later one the real span, so one of
the pair becomes unreachable through `at()` and neither the 30 s control
path nor construction raises.
"""
rows = sorted(((p.start.astimezone(timezone.utc), i, p)
for i, p in enumerate(points)),
key=lambda r: (r[0], r[1]))
out = []
for n, (start, _i, point) in enumerate(rows):
if n + 1 < len(rows):
span = rows[n + 1][0] - start # -> the next point's start
elif n:
span = start - rows[n - 1][0] # -> the tail, see above
else:
span = MAX_POINT_SPAN # -> a series of one point
if span > MAX_POINT_SPAN:
span = MAX_POINT_SPAN
out.append((start, start + span, point))
return tuple(out)
class _Series(PriceRead):
kind = "series"
@@ -564,6 +679,10 @@ class _Series(PriceRead):
self.provider = provider
self.fetched_at = fetched_at
self.regime = volatility_regime(product)
# ⚠️ TARIFF-14. Built ONCE, here, so that `at()` scans an index whose
# order it established itself rather than one it hopes it was handed.
# See `_cover_table()` for the window rule and for what the tail does.
self._cover = _cover_table(self.points)
self.words = (f"{len(self.points)} points from {provider} "
f"({product.value}, {self.regime} thresholds)")
@@ -572,25 +691,47 @@ class _Series(PriceRead):
return {"prices": [p.canonical() for p in self.points]}
def at(self, instant: datetime) -> PricePoint:
"""The point covering `instant`. Raises `Absence` if the series does not.
"""The point whose OWN interval covers `instant`, at ANY resolution.
⚠️ RETURNS A POINT OR RAISES; it never returns None. `series.at(t) or
fallback` is the same defaulting bug one level down, so the falsy
answer does not exist. Ask `covers()` first if you mean to branch.
⚠️ TARIFF-14: THE WINDOW IS NOT ONE HOUR WIDE ANY MORE, AND WAS NEVER
ENTITLED TO BE. It runs to the next point's start, capped at an hour -
`_cover_table()` above holds the rule, the tail decision and the
reproduction on a quarter-hourly series. A fixed `s + 1 h` here
returned the FIRST point still inside its own hour, which on the live
900 s feed is neither the covering point nor, past the first hour,
even the top of the hour.
A linear scan over the index, which is 192 entries on the shipped 48 h
horizon. This runs on the 30 s control path through
`arbitrage._point_at()`; a bisect would be correct and is not worth
the second way to be wrong at this size.
"""
u = instant.astimezone(timezone.utc)
for p in self.points:
s = p.start.astimezone(timezone.utc)
if s <= u < s + timedelta(hours=1):
return p
for start, end, point in self._cover:
if start <= u < end:
return point
raise Absence(
f"no price point covers {u.isoformat()}; the series runs "
f"{self.points[0].start.isoformat()} .. "
f"{self.points[-1].start.isoformat()}. An hour outside the series "
f"is an hour with no price, which is §11.4 for that hour - not a "
f"reason to reach for a neighbouring one.")
f"{self.points[-1].start.isoformat()}. An instant outside the "
f"series - or inside a hole FCST-04 left because nothing "
f"published that hour - is an instant with no price, which is "
f"§11.4 for it, not a reason to reach for a neighbouring one.")
def covers(self, instant: datetime) -> bool:
"""Whether `at(instant)` would answer.
⚠️ SHARED TARIFF-14 EXACTLY, AND IS FIXED BY THE SAME CHANGE. It has
no window rule of its own - it asks `at()` - so on the 900 s feed it
answered True for instants no point covered and False for none of
them, because the hour-wide window is never SHORTER than the truth.
It is kept as a delegation for that reason: a second copy of the rule
is a second place for it to be wrong.
"""
try:
self.at(instant)
return True
@@ -598,7 +739,17 @@ class _Series(PriceRead):
return False
def window(self, since: datetime, until: datetime) -> tuple:
"""Points whose start falls in [since, until). May legitimately be empty."""
"""Points whose START falls in [since, until). May legitimately be empty.
⚠️ DID NOT SHARE TARIFF-14, AND THE REASON IS WORTH STATING RATHER
THAN REDISCOVERING. This grades a point's `start` against a half-open
range and never asks how long a point lasts, so it carries no
resolution assumption to be wrong about - the same call answers the
same set at 900 s, at 3600 s and mixed. It is therefore NOT a
coverage query: a point that starts before `since` and runs into it
is absent from this tuple by design. `at()`/`covers()` are the
coverage questions.
"""
a = since.astimezone(timezone.utc)
b = until.astimezone(timezone.utc)
return tuple(p for p in self.points