TARIFF-10: configuring a price provider is what fetches prices
checks / unit (pull_request) Successful in 7m19s

amain() started the background tariff refresh only when arbitrage_enabled was
on, and that option ships false. run_tariff_refresh is the only caller of
resolve_tariff(), which is the only caller of price_history.carry() - so on a
shipped install with a price provider configured nothing was ever fetched: no
plan resolved, /data/price_history.json never created, the Price history status
row reading `empty` for the life of the install, and TARIFF-09's short-horizon
warning unable to fire because there was no fetch to warn about. Measured over
a 40 h two-instance soak on one feed: 0 requests and `present: false` across all
802 samples, against a resolved 132-point plan on the instance with arbitrage
armed.

git log -S puts the refresh task, its arbitrage.enabled() guard AND the DOCS.md
sentence saying a configured provider still fetches in the same commit - OPT-04,
bdd7f18; config.yaml has carried the claim since TARIFF-02, 852fc7c. The prose
and the code disagreed from the moment both existed and nothing compared them.
config.yaml and both translations additionally carried a claim that had rotted
the other way - "nothing consumes the price series yet", untrue since OPT-04
shipped the consumer. All four surfaces now say what the add-on does, and a
check pairs DOCS against the DRIVEN behaviour so they cannot drift again.

The gate is tariff_refresh_wanted() = provider_configured(opts) or
arbitrage.enabled(opts), and the `or` is a STRICT WIDENING: every configuration
that started the task before still starts it. The second term is not there for
symmetry. price_provider: off + a static_profile + arbitrage_enabled: true is a
configuration validate.py explicitly ACCEPTS and it trades - measured, at
cycle_cost_threshold_eur 0.05 on a 0.20/0.284 day/night profile,
ARBITRAGE_CHARGE -5000 W. Gating on the provider alone would leave that site's
tariff_plan at None forever and answer "no tariff plan has been resolved yet" on
every cycle, silently disarming a strategy the UI says is running.

Nothing acquires a periodic HTTPS call. With price_provider off, resolve_tariff()
answers out of the static profile without opening a socket (driven and counted:
0 requests), and the true shipped default - no provider, no trading - starts no
task at all. What decides whether a request leaves the machine is price_provider
and only that. The literal reading of the ticket's AC2 - drop the `or` - is one
token and is recorded in DECISIONS §3 with what it costs, for the owner.

AND NOTHING TRADES THAT DID NOT TRADE BEFORE. cycle()'s
`if arbitrage.enabled(self.o):` is untouched, and that is graded directly rather
than inferred from the gate having moved: Controller.cycle() is driven on a
resolved provider plan with the peak guard asserted clear, and arbitrage_enabled
is the only option that moves between the rows - false/false give +180 W
("loop -> 180 W") on both the cheap-now and dear-now plans, true/true give
-5000 W and +5000 W ("arbitrage -> ...").

TariffPlan.cheap_now() is deliberately NOT wired, decided rather than inherited
(DECISIONS §4). A provider-backed plan now exists on every configured site, so
Cheap window could read it - and Cheap window feeds the maintenance cycle's grid
top-up, the one place a price moves energy without trading being armed. On a
provider-only site static_status() returns False on every cycle, so adopting it
would take a paid grid top-up from never to sometimes. self.cheap stays on
static_status(); what adoption would take is listed rather than left implied.

THE CHECK THAT WOULD HAVE CAUGHT THIS is test_price.py section 13, and it grades
REACHABILITY FROM A CONFIGURATION rather than the shape of an `if` - the `if` was
exactly what somebody meant to write. amain()'s guards are EVALUATED against real
option dicts, whatever they are spelled as; the call graph under app/ is walked
to see whether the tasks those guards start arrive at resolve_tariff(), wherever
it has moved to; and then the fetch is DRIVEN with a counting transport, because
a graph is an argument and a counted request is an observation. What the
instrument cannot see is printed on every run, largest hole first.

Evidence: 29/29 green. 13 mutations in 14 runs - M10 re-run as M10b once it was
closed - each applied to a fresh cp -a of the clean worktree rather than
reverted (MUTATION.md habit 18), purged, :ro, -B. The
shipped defect - the gate back at arbitrage.enabled(opts), -4 B - is red on
seven named checks. Dropping the `or` (-27 B) is red on the strict-widening row;
removing cycle()'s trading gate (+8 B) is red on both AC3 rows. The instrument
is mutated too (habit 16): _reaches() always True and _task_sites() returning []
are both +0 B and both red.

Two findings are recorded rather than rounded off. provider_id() dropping
.strip() (-8 B) was a real SURVIVOR across four price suites - nothing fed a
padded provider id - and is closed in this commit by the padded-id pair; and the
first draft of the AC3 fixture fed five minutes of P1 telegrams, so whether the
peak guard was blind depended on where the wall clock sat inside the billing
quarter and the armed baseline failed to trade on some runs. It is 900 s now,
and the guard's clear state is a check rather than an assumption.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
This commit is contained in:
2026-09-12 20:58:48 +02:00
co-authored by Claude Opus 5
parent 88c59a9918
commit e6c45a61e7
10 changed files with 1188 additions and 21 deletions
+87
View File
@@ -17,6 +17,93 @@ if it has, it needs 0.10.0. Whoever deploys decides.
| _this branch_ | FCST-06 - "cannot persist" stops meaning "has no /data" and starts meaning what it says |
| _this branch_ | TARIFF-08 - the day-ahead feed is read in the shape it actually publishes, and the fixtures stop agreeing with the parser |
| _this branch_ | TARIFF-09 - the day-ahead feed is asked for the horizon it was given, so tomorrow arrives firm instead of carried |
| _this branch_ | TARIFF-10 - configuring a price provider is what fetches prices; trading stays its own switch |
### TARIFF-10 - a configured provider is fetched, whether or not you trade
**`amain()` started the background tariff refresh only when
`arbitrage_enabled` was on, and that option ships `false`.**
`run_tariff_refresh` is the only caller of `resolve_tariff()`, which is the
only caller of `price_history.carry()` - so on a shipped install with a price
provider configured **nothing was ever fetched**: no plan resolved,
`/data/price_history.json` never created, the *Price history* status row
reading `empty` for the life of the install, and TARIFF-09's short-horizon
warning unable to fire because there was no fetch to warn about.
Measured over a 40 h soak of two instances side by side on the same feed:
| | shipped default + provider | same, arbitrage armed |
|---|---|---|
| feed requests | **0** | fetching |
| `/data/price_history.json` | `present: false` on **all 802 samples** | written |
| resolved plan | none | 132 points |
⚠️ **`DOCS.md` has described the fixed behaviour since the commit that wrote
the gate.** OPT-04 (`bdd7f18`) added the refresh task, the
`arbitrage.enabled()` guard on it, and the sentence *"turning a provider on
still fetches, validates and publishes prices and changes no charging
decision"* — all in one change. `config.yaml` has said the same since
TARIFF-02 (`852fc7c`). The documentation and the code have therefore disagreed
for as long as both existed, and nothing compared them. `config.yaml` and both translations carried the same
claim, plus a second one that had gone stale in the other direction (*"nothing
consumes the price series yet"*, untrue since OPT-04). All four now say what
the add-on does.
**The gate is `tariff_refresh_wanted()`, and it is a STRICT WIDENING.**
```
provider_configured(opts) or arbitrage.enabled(opts)
```
Every configuration that started the refresh before still starts it. The second
term is not redundant and is not there for symmetry: `price_provider: off` with
a `static_profile` and `arbitrage_enabled: true` is a configuration
`validate.py` explicitly **accepts**, and it trades - measured, at
`cycle_cost_threshold_eur: 0.05` on a 0.20/0.284 EUR/kWh day/night profile,
`ARBITRAGE_CHARGE -5000 W`. Gating the refresh on the provider alone would
leave that site's `tariff_plan` at `None` forever and answer *"no tariff plan
has been resolved yet"* on every cycle - silently disarming a strategy the UI
says is running.
⚠️ **Nothing acquires a periodic HTTPS call from this.** With `price_provider`
off, `resolve_tariff()` answers out of the static profile without opening a
socket; and the true shipped default - no provider, no trading - starts **no
task at all**. What decides whether a request leaves the machine is
`price_provider`, and only that.
⚠️ **AND NOTHING TRADES THAT DID NOT TRADE BEFORE.** `cycle()`'s
`if arbitrage.enabled(self.o):` is untouched. Graded directly rather than
inferred from the gate having moved: `Controller.cycle()` is driven on a
resolved provider plan whose cheapest firm hour is now, with the peak guard
asserted clear, and `arbitrage_enabled` is the single option that moves between
the rows.
| `arbitrage_enabled` | cheapest hour is now | commanded |
|---|---|---|
| `false` | yes | **+180 W** (`loop -> 180 W`) |
| `false` | no | **+180 W** (`loop -> 180 W`) |
| `true` | yes | -5000 W (`arbitrage -> -5000 W`) |
| `true` | no | +5000 W (`arbitrage -> 5000 W`) |
**`TariffPlan.cheap_now()` is deliberately NOT wired, and that is now a
decision rather than an inheritance.** A provider-backed plan exists on every
configured site from this commit, so *Cheap window* - which feeds the
maintenance cycle's grid top-up, i.e. the one place a price reaches something
that **moves energy** without trading being armed - could now read it. It does
not: `self.cheap` still comes from `static_status()`, and on a provider-only
site (`static_profile` empty) that is `False` on every cycle, so no paid grid
top-up is ever forced. Wiring it would take that from *never* to *sometimes*.
`DECISIONS/TARIFF-10-fetching-is-not-trading.md` §4 carries the argument and
the list of what adopting it would take.
**The check that would have caught this** is `test_price.py` section 13, and it
grades **reachability from a configuration**, not the shape of an `if`. The
guards in `amain()` are *evaluated* against real option dicts, whatever they are
spelled as; the call graph under `app/` is walked to see whether the tasks those
guards start can arrive at `resolve_tariff()`, wherever it has moved to; and
then the fetch is **driven** with a counting transport, because a graph is an
argument and a counted request is an observation. The shipped defect - the gate
back at `arbitrage.enabled(opts)`, **-4 B** - is red on seven named checks.
### TARIFF-09 - the feed is asked for the range the caller wanted