OPT-04: charge cheap, discharge expensive - and only when it pays
checks / unit (pull_request) Successful in 3m16s

Four cards built the price stack and nothing traded on it. app/arbitrage.py is
§10.1's priority 3, and it consumes all four rather than re-deciding anything:
whether a trade is permitted at all, on which two hours, against which widened
threshold is TariffPlan.arbitrage()'s answer, and that answer cannot be
obtained without first saying whether the peak guard is holding. §9.1's "peak
shaving outranks arbitrage" is therefore honoured structurally - there is no
path to a spread that skips the question - and again in the arbiter, where
P_PEAK (50) clamps a claim filed at P_ARBITRAGE (30).

A guard that never ran is not a guard that is clear. On meter_source off the
peak guard is never evaluated; that third state is carried as None and refuses,
because charging at the rail creates the billed quantity while nothing measures
the quarter. validate.py refuses that configuration too, along with arbitrage
armed with no price source and a SoC floor at or above the ceiling.

§17: never export into a negative price. The guard is on the hour being sold
into, not on the spread - a day priced -0.30 .. -0.05 clears any threshold and
its dearest hour is still negative. Buying in a negative hour stays allowed.

resolve_tariff() may open an HTTPS connection and the tick feeds the ESP32
heartbeat, so the fetch runs in its own task at a 30 min cadence and the tick
reads an attribute and an age bound. test_arbitrage.py section 8 asserts that
structurally on the shipped AST: a fetch put back on cycle() is red.

AC4 is NOT met and is not restated - this repository holds no price history.
Section 7 replays a synthetic year and asserts the property that does not need
real prices: every executed round trip cleared its own widened requirement, and
zero discharges landed in the 522 negative-priced hours. At the shipped 0.10
EUR/kWh threshold a realistic Belgian day/night site does not trade at all.
Reported, not tuned: that number and FCST-03's widening factors are one product
decision and it is the owner's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
This commit is contained in:
glenn schrooyen
2026-08-31 01:36:09 +02:00
co-authored by Claude Opus 5
parent a30bde1ab1
commit bdd7f18a54
11 changed files with 1886 additions and 26 deletions
+20
View File
@@ -607,6 +607,26 @@ PRICE_OPTION_DEFAULTS = {
# test_price.py section 1. A second defaults dict in the new module would be
# a fourth place option defaults live, which is exactly the open ticket.
"static_profile": "",
# OPT-04. §10.1 priority 3, and §14's `optimizer.cycle_cost_threshold_eur`.
#
# ⚠️ THEY LIVE HERE FOR CFG-03's SAKE, exactly as `static_profile` does, and
# FCST-03's note above `WIDEN_FACTOR` names this as the place they would
# land. `arbitrage_soc_floor` / `arbitrage_soc_ceiling` are the reserve
# arbitrage may not spend and the headroom it may not overfill; they are
# NOT the maintenance SoC keys, which describe a balancing cycle rather
# than a trading reserve and would silently couple two unrelated policies.
#
# ⚠️ AND `cycle_cost_threshold_eur` IS 0.10 BECAUSE §10.1's NAME FOR IT SAYS
# SO, NOT BECAUSE IT MAKES ANYTHING TRADE. On realistic Belgian day/night
# numbers the static spread is ~0.08 EUR/kWh, so a static-tariff site does
# NOT trade at this default. That is a reported result (test_arbitrage.py
# section 7), and whether 0.10 - or FCST-03's 2.5/1.5 - is right is one
# product decision that belongs to the owner. No number here was chosen to
# make arbitrage happen.
"arbitrage_enabled": False,
"cycle_cost_threshold_eur": 0.10,
"arbitrage_soc_floor": 20,
"arbitrage_soc_ceiling": 95,
}
# ⚠️ The keys the FRAMEWORK owns. A plugin that reads one of these is a plugin