TARIFF-14: the point whose own interval covers the instant
checks / unit (pull_request) Failing after 2s
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:
co-authored by
Claude Opus 5
parent
88c59a9918
commit
7f2c4e20e1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user