# TARIFF-09 — asking the feed for the horizon, and what `price_feed_url` means Two decisions. The first had no defensible alternative once it was measured; the second did, and the alternative is argued below rather than dismissed. --- ## 0. The measurement this card exists for `generic_dayahead.fetch()` accepted `since` and `until` and requested the bare `https://api.energy-charts.info/price?bzn=BE`. That URL names no range, and the feed answers **one Brussels day**. Measured on the merged tree (`4257e38`) at 2026-09-10 09:00 CEST, 48 h horizon, live feed, yesterday seeded through the shipped read path: ``` 60 firm points (15 whole hours) + 33 carried hours of 48 requested carried: 24 h on 2026-09-11, 9 h on 2026-09-12 arbitrage(0.10): allowed, spread 0.4645, buy 11:45 and sell 19:45 -- BOTH on 2026-09-10 firm local days = [2026-09-10] ``` Everything past today was carry-forward, flagged provisional, and §8.3 drops provisional hours from the trade set. **The optimiser never saw tomorrow as firm, at any hour.** The ceiling was structural rather than incidental: with a one-day window, at hour *H* of the local day a 48 h horizon is at most **24−H hours firm and 24+H carried** — 15/33 at 09:00, and **1/47 at 23:00**, where the remaining firm spread (0.0464 EUR/kWh) no longer even clears the default 0.10 round-trip threshold and arbitrage is refused outright. That is not what FCST-04's fill was built for. FCST-04 covers the pre-publication window until the ~13:00–14:00 CET auction, after which a healthy feed hands over tomorrow in full. **The data exists and the endpoint serves it.** Verified 2026-09-10T07:45:49Z: `?bzn=BE&start=2026-09-09&end=2026-09-10` returns **192 quarter-hours across two local days**, committed unmodified as `scenarios/energy-charts-be-price-2026-09-09_2026-09-10-twoday.json`. The plugin simply never asked. After the change, same code path, same window, a feed serving both days: **39 firm hours and 9 carried**, firm across **two** Brussels days, and the permitted cycle buys on one day and sells on the other. Graded in `test_price_history.py` section 1c against both feed states. --- ## 1. The range is derived on the **Brussels** calendar, from `since`/`until` `since` and `until` are **instants**; the API takes **dates**. Those are not the same question, and the conversion is where the day gets lost. `brussels_date_range()` returns two inclusive `date`s: * the first is `brussels(since).date()`. **Not `since.date()`, and not the UTC date.** 22:30 UTC in summer is already tomorrow in Belgium, so a UTC-derived start asks for the day the controller has just left and drops the day it is standing in — for the last one or two hours of *every* day. This is `price_history._bdate()`'s lesson one layer up, with a worse consequence: `_bdate()` misfiles an hour, this never fetches it. * the last is `brussels(until).date()`, **stepped back a day when `until` falls exactly on local midnight**, because `until` is exclusive and that instant is the first tick of a day nobody asked for. Without it every whole-hour horizon requests one extra day — harmless on this feed, but it makes the request a claim the caller never made. * an empty or inverted window collapses to a single day rather than producing a backwards range, which no feed has a defined answer for. Both ends are graded, and both are driven red by mutation — including the two that swap `brussels()` for the UTC date (`test_price.py` section 2c). --- ## 2. `price_feed_url` is the **exact URL**, used as given. The plugin appends nothing. The alternative — merge our `start`/`end` into whatever query string the operator supplied — was considered and rejected. Three reasons, in the order they decided it: 1. **`start`/`end` are energy-charts' spelling, and this provider declares a second host.** `generic_dayahead.capabilities().egress_hosts` is `("api.energy-charts.info", "web-api.tp.entsoe.eu")`. That second host's day-ahead date parameters have not been verified by this card and are not guessed at here — but they are not `start`/`end`. So an override to the *other declared host* — a case the declaration itself anticipates — is exactly where merging appends two parameters that host ignores, producing the short horizon anyway, with a URL nobody typed as the receipt. 2. **Merging means re-serialising a URL somebody else wrote.** Parsing and rebuilding reorders parameters, collapses duplicates and re-escapes values. "Override the feed" is what the option already says it means, and an override that is silently rewritten is not one. 3. **Blind appending is not on the table at all.** `dated_feed_url()` refuses to add a query string to a base that already has one, so `?bzn=BE?bzn=BE&start=…` cannot be produced by this module even by a later edit that folds the parameters back into the constant. ⚠️ **The cost is real and it is therefore NOT a doc footnote.** An overriding operator gets exactly the short horizon this card exists to fix. So the plugin emits a `WARNING` on the shipped `goodwe.price` logger at **fetch time**, naming the consequence — one Brussels day, the rest carried and flagged, §8.3 dropping them from the trade set — and naming the `start..end` it *would* have asked for. §11.4 is what makes a price defect quiet; an operator who has taken the short horizon has to be able to find out from the log that they did. The warning is graded, with a control that the default path stays silent, and a mutation that downgrades it to `debug()` takes that check red. **Fetch time rather than startup**, because that is where the URL actually in use is known. The trade-off is one line per refresh (the tariff task, not the 30 s control tick). If the status page ever grows a price-source row, this is the sentence it should carry. --- ## 3. The plugin contract: `fetch(self, opts, since, until, transport)` The signature promises every plugin the range, and until this card **none of the four networked plugins used it**. The obvious follow-up is to enforce the promise. It is **not** enforced, and the reason is TARIFF-08's: Three of the five shipped plugins point at endpoints nobody has ever verified (TARIFF-07 is the open decision on withdrawing them). A rule obliging them to pass a date range would be satisfied by an **invented parameter name** — which is the parser-graded-against-its-own-guess defect moved from the response to the request, and it would ship looking like a guarantee. What exists instead is a **roster pin** in `test_price.py` section 2c: which plugins reference their `since`/`until` is asserted as a literal, derived from the registry and the AST. A sixth supplier, or a change to any of the three, has to edit that line and make the decision rather than inherit the silence. It is named as a source-shape pin, not as evidence that any feed parses; `generic_dayahead`'s behaviour is graded separately by the URL it really requests. **Recommendation for whoever picks up TARIFF-07:** enforce it at the moment those three endpoints are either verified or withdrawn, not before. If they are withdrawn, `generic_dayahead` and `manual_csv` are the whole roster and the rule is one line — `manual_csv` reads a whole file and is windowed downstream, so it would be the single documented exemption.