Grid battery arbitrage on NYISO electricity prices. A 10 MW, 40 MWh battery buys energy when it is cheap and sells it back when it is expensive, in each of New York's 11 zones, every day from January 2021 to August 2026. Three operating policies run on the same public data, and the product is the gap between them.
Live demo: https://huggingface.co/spaces/adwitiyashukla/cyclebank
With hindsight, the best possible charge and discharge schedule for a day is not a matter of opinion. It is a linear program with one exact answer. That answer is the ceiling, and it cannot be beaten or fudged. The other two policies are measured against it, all settled at real-time prices.
| policy | knows | revenue 2021 to 2026 | share of ceiling |
|---|---|---|---|
| perfect foresight | tomorrow's real-time prices | $36,165,913 | 100% |
| day-ahead policy | day-ahead prices only | $18,994,416 | 52.5% |
| naive schedule | nothing, fixed hours | $12,546,852 | 34.7% |
The naive schedule charges at fixed overnight hours and discharges at the fixed evening peak. The day-ahead policy optimizes against the prices NYISO publishes the afternoon before, then lives with what real time actually pays. The distance between 34.7% and 52.5% is what knowing the day-ahead market is worth. The distance between 52.5% and 100% is what real-time volatility costs you.
Every parameter lives in config.toml, not in the code. The unit is a standard 4 hour battery: 10 MW power, 40 MWh energy, 85% round-trip efficiency, state of charge kept between 10% and 90%, and a $2 wear cost per MWh through the cells. The efficiency and the wear cost are what separate this from a toy. A 15% round trip loss means a price spread has to clear a real threshold before a trade is worth making at all.
The daily schedule is a linear program over charge and discharge decisions, subject to power limits, energy limits and the state of charge dynamics, with the day ending at the same charge level it started. When prices go negative the program is upgraded to a mixed integer program with one binary per hour, because a pure linear program discovers it can charge and discharge simultaneously to burn energy for profit at negative prices, which a physical battery cannot do.
Negative prices are real. This dataset has 3,717 negative price hours, with real-time touching -$1,086 per MWh. They are when a battery gets paid to charge, so the quality checks count them and never remove them.
Annualised revenue per MW is the number the industry quotes. One battery per zone over 2,048 market days:
| zone | perfect $/MW yr | day-ahead $/MW yr | naive $/MW yr | day-ahead capture |
|---|---|---|---|---|
| A WEST | 48,775 | 22,338 | 13,389 | 45.8% |
| B GENESE | 48,783 | 24,794 | 15,349 | 50.8% |
| C CENTRL | 50,697 | 26,961 | 16,813 | 53.2% |
| D NORTH | 60,653 | 23,443 | 13,345 | 38.7% |
| E MHK VL | 52,733 | 27,838 | 17,270 | 52.8% |
| F CAPITL | 62,767 | 31,151 | 19,482 | 49.6% |
| G HUD VL | 54,214 | 31,044 | 20,446 | 57.3% |
| H MILLWD | 57,127 | 33,148 | 22,646 | 58.0% |
| I DUNWOD | 57,765 | 33,828 | 22,856 | 58.6% |
| J N.Y.C. | 61,063 | 34,760 | 24,471 | 56.9% |
| K LONGIL | 89,984 | 49,220 | 37,548 | 54.7% |
Long Island is the standout: nearly double upstate revenue under every policy. The NORTH zone is the interesting failure. Its ceiling is high but its capture is the worst in the state, because its spreads come from real-time events the day-ahead market never saw coming. A battery there earns less than one in zone B despite a much richer ceiling. Storm days make the same point in one frame: on December 24, 2022 a perfect operator in N.Y.C. earns $84,855 in a single day while the day-ahead market had promised a quiet $1,805.
Run the optimizer with the $2 per MWh wear cost and the battery moves 84 MWh per day on average. Run it without and that rises to 102 MWh. The wear cost does not just shave revenue, it changes the strategy: fewer, deeper cycles instead of chasing every small spread.
The model is a price taker: the battery is assumed too small to move prices, and batteries at the scale where these revenues matter would compress the very spreads they exploit. Each day is optimized on its own with the state of charge returning to its starting level, so no value is carried across midnight. Real-time settlement uses hourly averages of the 5 minute prices. The round trip efficiency is split evenly between charging and discharging. The day-ahead policy executes its committed schedule without real-time adjustment.
ingestion NYISO monthly zip archives, watermarked so a rerun resumes
warehouse bronze parquet -> silver hourly -> DuckDB gold star schema
quality 11 checks: calendar lengths, gaps, spikes, stuck values,
negative prices counted but never filtered
optimize the linear program, the mixed integer upgrade, both policies
backtest every policy, every zone, every day, in parallel processes
api FastAPI with typed responses
app.py Streamlit, the same app that runs in the live demo
Daylight saving is handled explicitly: the calendar says which days have 23 or 25 hours, both markets are aligned on wall clock slots, and the November hour that happens twice stays two separate hours. Real-time feed gaps on 88 of 22,616 zone days are skipped and reported, not papered over.
The optimizer is checked four independent ways. A three hour example small enough to solve on paper must come out to the dollar. A second solver, CBC, agrees with it to within a cent on every tested case, including negative price days. Eight hundred random feasible schedules per test run are all beaten or matched by its answer. And the full backtest produces identical revenue, to the cent, on two different machines from the same public files.
python -m venv .venv
.venv\Scripts\pip install -r requirements.txt
.venv\Scripts\python -m cyclebank ingest
.venv\Scripts\python -m cyclebank build
.venv\Scripts\python -m cyclebank quality
.venv\Scripts\python -m cyclebank backtest
.venv\Scripts\python -m streamlit run app.py
The ingest step downloads about 250 MB of monthly archives from NYISO's public
site, no account or key required, and resumes if interrupted. The API serves
with .venv\Scripts\uvicorn cyclebank.api:app and the same pipeline is wired
as Dagster assets in cyclebank/orchestration. On Linux or Mac replace
.venv\Scripts\ with .venv/bin/.
Measured on a 16 thread laptop: the full backtest solves 90,112 optimization problems in 137 seconds, 656 solves per second across 15 worker processes. The test suite is 72 tests and finishes in three and a half minutes, most of that spent building miniature warehouses from synthetic NYISO files and rendering the app for real.
Day-ahead and real-time zonal prices from NYISO's public MIS at mis.nyiso.com, published as monthly zip archives of daily csv files. Prices are in dollars per MWh. The warehouse holds 542,839 zone hours across 2,056 market days.
MIT