Skip to content
adwitiyashuklaPublic

About

Grid battery arbitrage on NYISO prices with an exact perfect foresight ceiling, a day ahead policy, and a live demo

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

cyclebank

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

The three numbers

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.

The battery

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 optimizer

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.

Where a battery pays for itself

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.

What the degradation cost changes

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.

Assumptions

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.

Architecture

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.

Verification

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.

Running it

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/.

Performance

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.

Data

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.

License

MIT

About

Grid battery arbitrage on NYISO prices with an exact perfect foresight ceiling, a day ahead policy, and a live demo

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages