From 328c0cefd32165f492e08e2b035eb3f3751d08a8 Mon Sep 17 00:00:00 2001 From: Tom Medhurst Date: Mon, 6 Apr 2026 15:10:33 +0100 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20implement=20spec-02=20=E2=80=94=20s?= =?UTF-8?q?ignals,=20CSV=20loader,=20BacktestEngine,=20and=20CLI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the signal system with look-ahead bias prevention via publication_offset, CsvSignalProvider, SignalRegistry wired into BacktestEngine and TradingContext, the `nexa run` CLI, full PnL/VWAP/ metrics analysis, and the DA data loading layer. Co-Authored-By: Claude Sonnet 4.6 --- .claude/specs/spec-02.md | 5 +- .vscode/settings.json | 4 + examples/simple_da_algo.py | 77 + poetry.lock | 171 +- pyproject.toml | 7 +- src/nexa_backtest/__init__.py | 19 +- src/nexa_backtest/algo.py | 113 + src/nexa_backtest/analysis/__init__.py | 1 + src/nexa_backtest/analysis/metrics.py | 97 + src/nexa_backtest/analysis/pnl.py | 129 + src/nexa_backtest/analysis/vwap.py | 38 + src/nexa_backtest/cli/__init__.py | 1 + src/nexa_backtest/cli/main.py | 178 ++ src/nexa_backtest/data/__init__.py | 1 + src/nexa_backtest/data/loader.py | 119 + src/nexa_backtest/data/schema.py | 31 + src/nexa_backtest/engines/backtest.py | 535 ++++ src/nexa_backtest/signals/__init__.py | 1 + src/nexa_backtest/signals/base.py | 108 + src/nexa_backtest/signals/csv_loader.py | 249 ++ src/nexa_backtest/signals/registry.py | 68 + tests/fixtures/da_prices.parquet | Bin 0 -> 82550 bytes tests/fixtures/generate.py | 147 + tests/fixtures/signals/price_forecast.csv | 2977 +++++++++++++++++++++ tests/test_analysis/__init__.py | 0 tests/test_analysis/test_metrics.py | 118 + tests/test_analysis/test_pnl.py | 110 + tests/test_analysis/test_vwap.py | 44 + tests/test_cli/__init__.py | 0 tests/test_cli/test_main.py | 208 ++ tests/test_data/__init__.py | 0 tests/test_data/test_loader.py | 68 + tests/test_engines/test_backtest.py | 675 +++++ tests/test_exchanges/__init__.py | 0 tests/test_exchanges/test_base.py | 53 + tests/test_signals/__init__.py | 0 tests/test_signals/test_base.py | 70 + tests/test_signals/test_csv_loader.py | 271 ++ tests/test_signals/test_registry.py | 88 + 39 files changed, 6769 insertions(+), 12 deletions(-) create mode 100644 examples/simple_da_algo.py create mode 100644 src/nexa_backtest/algo.py create mode 100644 src/nexa_backtest/analysis/__init__.py create mode 100644 src/nexa_backtest/analysis/metrics.py create mode 100644 src/nexa_backtest/analysis/pnl.py create mode 100644 src/nexa_backtest/analysis/vwap.py create mode 100644 src/nexa_backtest/cli/__init__.py create mode 100644 src/nexa_backtest/cli/main.py create mode 100644 src/nexa_backtest/data/__init__.py create mode 100644 src/nexa_backtest/data/loader.py create mode 100644 src/nexa_backtest/data/schema.py create mode 100644 src/nexa_backtest/engines/backtest.py create mode 100644 src/nexa_backtest/signals/__init__.py create mode 100644 src/nexa_backtest/signals/base.py create mode 100644 src/nexa_backtest/signals/csv_loader.py create mode 100644 src/nexa_backtest/signals/registry.py create mode 100644 tests/fixtures/da_prices.parquet create mode 100644 tests/fixtures/generate.py create mode 100644 tests/fixtures/signals/price_forecast.csv create mode 100644 tests/test_analysis/__init__.py create mode 100644 tests/test_analysis/test_metrics.py create mode 100644 tests/test_analysis/test_pnl.py create mode 100644 tests/test_analysis/test_vwap.py create mode 100644 tests/test_cli/__init__.py create mode 100644 tests/test_cli/test_main.py create mode 100644 tests/test_data/__init__.py create mode 100644 tests/test_data/test_loader.py create mode 100644 tests/test_engines/test_backtest.py create mode 100644 tests/test_exchanges/__init__.py create mode 100644 tests/test_exchanges/test_base.py create mode 100644 tests/test_signals/__init__.py create mode 100644 tests/test_signals/test_base.py create mode 100644 tests/test_signals/test_csv_loader.py create mode 100644 tests/test_signals/test_registry.py diff --git a/.claude/specs/spec-02.md b/.claude/specs/spec-02.md index db7786b..87ed1ef 100644 --- a/.claude/specs/spec-02.md +++ b/.claude/specs/spec-02.md @@ -65,6 +65,7 @@ This is the simplest way for a customer to bring their own data without implementing `SignalProvider` from scratch. Expected CSV format: + ```csv timestamp,value 2026-03-15T00:00:00+01:00,42.31 @@ -164,6 +165,7 @@ nexa run examples/simple_da_algo.py \ ``` The CLI needs to: + - Accept a path to a Python file containing an algo - Import the file and find the algo (look for a subclass of `SimpleAlgo` or a function decorated with `@algo`) @@ -208,6 +210,7 @@ synthetic forecast values (slightly noisy version of the actual clearing prices from the test fixture, offset by the publication delay). The example should be runnable via: + ```bash nexa run examples/simple_da_algo.py \ --exchange nordpool \ @@ -225,7 +228,7 @@ nexa run examples/simple_da_algo.py \ For this task, signal CSV files are discovered by convention. The engine looks in `{data_dir}/signals/` for CSV files matching the signal name: -``` +```text data_dir/ signals/ price_forecast.csv diff --git a/.vscode/settings.json b/.vscode/settings.json index 02c727a..3022d61 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -7,6 +7,7 @@ "docstrings", "EPEX", "intraday", + "lookback", "marketdata", "matplotlib", "Mypy", @@ -23,6 +24,9 @@ "quants", "Scikit", "sklearn", + "teardown", + "timedelta", + "timezone", "VWAP", "Zipline" ] diff --git a/examples/simple_da_algo.py b/examples/simple_da_algo.py new file mode 100644 index 0000000..359bce0 --- /dev/null +++ b/examples/simple_da_algo.py @@ -0,0 +1,77 @@ +"""Example DA algo: buy when the clearing price is below a price forecast. + +Demonstrates the signal system: the algo subscribes to a price forecast CSV +and places buy orders only when the forecast is meaningfully above the +current clearing price, suggesting the market is undervaluing the delivery +period. + +Run via:: + + nexa run examples/simple_da_algo.py \\ + --exchange nordpool \\ + --start 2026-03-01 \\ + --end 2026-03-31 \\ + --products NO1_DA \\ + --data-dir tests/fixtures \\ + --capital 100000 +""" + +from __future__ import annotations + +from decimal import Decimal + +from nexa_backtest.algo import SimpleAlgo +from nexa_backtest.context import TradingContext +from nexa_backtest.exceptions import SignalError +from nexa_backtest.types import AuctionInfo, Fill, Order + + +class ForecastAlgo(SimpleAlgo): + """Buy when the DA clearing price is below the price forecast minus a threshold. + + The algo subscribes to a ``price_forecast`` signal (loaded from + ``{data_dir}/signals/price_forecast.csv``) and places a buy order for + each delivery product where the forecast indicates the price will be at + least ``threshold`` EUR/MWh above the clearing price. + + Because we fill at the clearing price (price-taker assumption), the order + is placed at ``forecast - threshold``. If that bid is >= the clearing + price, we fill. This means the algo selects delivery periods it expects to + be cheap relative to the forecast, accumulating positive VWAP alpha. + """ + + def on_setup(self, ctx: TradingContext) -> None: + """Subscribe to the price forecast signal and set the bid threshold.""" + self.subscribe_signal("price_forecast") + self.threshold: Decimal = Decimal("5.0") + self.volume_mw: Decimal = Decimal("10") + + def on_auction_open(self, ctx: TradingContext, auction: AuctionInfo) -> None: + """Place a buy order when the forecast exceeds clearing + threshold.""" + try: + signal = ctx.get_signal("price_forecast") + except SignalError: + # No forecast available yet for this period — skip + return + + forecast = Decimal(str(signal.value)) + bid_price = forecast - self.threshold + + ctx.place_order( + Order.buy( + product_id=auction.product_id, + volume_mw=self.volume_mw, + price_eur_mwh=bid_price, + ) + ) + + def on_fill(self, ctx: TradingContext, fill: Fill) -> None: + """Log each fill for visibility.""" + ctx.log( + f"FILL {fill.side} {fill.volume} MW @ {fill.price} EUR/MWh " + f"[{fill.product_id}]" + ) + + def on_teardown(self, ctx: TradingContext) -> None: + """Log the final unrealised PnL.""" + ctx.log(f"Backtest complete. Unrealised PnL: {ctx.get_unrealised_pnl()} EUR") diff --git a/poetry.lock b/poetry.lock index 8f3ac6b..e55c695 100644 --- a/poetry.lock +++ b/poetry.lock @@ -12,18 +12,33 @@ files = [ {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, ] +[[package]] +name = "click" +version = "8.3.2" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d"}, + {file = "click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + [[package]] name = "colorama" version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -groups = ["dev"] -markers = "sys_platform == \"win32\"" +groups = ["main", "dev"] files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +markers = {main = "platform_system == \"Windows\"", dev = "sys_platform == \"win32\""} [[package]] name = "coverage" @@ -343,7 +358,7 @@ version = "2.4.4" description = "Fundamental package for array computing in Python" optional = false python-versions = ">=3.11" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "numpy-2.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f983334aea213c99992053ede6168500e5f086ce74fbc4acc3f2b00f5762e9db"}, {file = "numpy-2.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:72944b19f2324114e9dc86a159787333b77874143efcf89a5167ef83cfee8af0"}, @@ -431,6 +446,113 @@ files = [ {file = "packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4"}, ] +[[package]] +name = "pandas" +version = "3.0.2" +description = "Powerful data structures for data analysis, time series, and statistics" +optional = false +python-versions = ">=3.11" +groups = ["main"] +files = [ + {file = "pandas-3.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a727a73cbdba2f7458dc82449e2315899d5140b449015d822f515749a46cbbe0"}, + {file = "pandas-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dbbd4aa20ca51e63b53bbde6a0fa4254b1aaabb74d2f542df7a7959feb1d760c"}, + {file = "pandas-3.0.2-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:339dda302bd8369dedeae979cb750e484d549b563c3f54f3922cb8ff4978c5eb"}, + {file = "pandas-3.0.2-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:61c2fd96d72b983a9891b2598f286befd4ad262161a609c92dc1652544b46b76"}, + {file = "pandas-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c934008c733b8bbea273ea308b73b3156f0181e5b72960790b09c18a2794fe1e"}, + {file = "pandas-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:60a80bb4feacbef5e1447a3f82c33209c8b7e07f28d805cfd1fb951e5cb443aa"}, + {file = "pandas-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:ed72cb3f45190874eb579c64fa92d9df74e98fd63e2be7f62bce5ace0ade61df"}, + {file = "pandas-3.0.2-cp311-cp311-win_arm64.whl", hash = "sha256:f12b1a9e332c01e09510586f8ca9b108fd631fd656af82e452d7315ef6df5f9f"}, + {file = "pandas-3.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:232a70ebb568c0c4d2db4584f338c1577d81e3af63292208d615907b698a0f18"}, + {file = "pandas-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:970762605cff1ca0d3f71ed4f3a769ea8f85fc8e6348f6e110b8fea7e6eb5a14"}, + {file = "pandas-3.0.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aff4e6f4d722e0652707d7bcb190c445fe58428500c6d16005b02401764b1b3d"}, + {file = "pandas-3.0.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef8b27695c3d3dc78403c9a7d5e59a62d5464a7e1123b4e0042763f7104dc74f"}, + {file = "pandas-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f8d68083e49e16b84734eb1a4dcae4259a75c90fb6e2251ab9a00b61120c06ab"}, + {file = "pandas-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:32cc41f310ebd4a296d93515fcac312216adfedb1894e879303987b8f1e2b97d"}, + {file = "pandas-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:a4785e1d6547d8427c5208b748ae2efb64659a21bd82bf440d4262d02bfa02a4"}, + {file = "pandas-3.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:08504503f7101300107ecdc8df73658e4347586db5cfdadabc1592e9d7e7a0fd"}, + {file = "pandas-3.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b5918ba197c951dec132b0c5929a00c0bf05d5942f590d3c10a807f6e15a57d3"}, + {file = "pandas-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d606a041c89c0a474a4702d532ab7e73a14fe35c8d427b972a625c8e46373668"}, + {file = "pandas-3.0.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:710246ba0616e86891b58ab95f2495143bb2bc83ab6b06747c74216f583a6ac9"}, + {file = "pandas-3.0.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5d3cfe227c725b1f3dff4278b43d8c784656a42a9325b63af6b1492a8232209e"}, + {file = "pandas-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c3b723df9087a9a9a840e263ebd9f88b64a12075d1bf2ea401a5a42f254f084d"}, + {file = "pandas-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a3096110bf9eac0070b7208465f2740e2d8a670d5cb6530b5bb884eca495fd39"}, + {file = "pandas-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:07a10f5c36512eead51bc578eb3354ad17578b22c013d89a796ab5eee90cd991"}, + {file = "pandas-3.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:5fdbfa05931071aba28b408e59226186b01eb5e92bea2ab78b65863ca3228d84"}, + {file = "pandas-3.0.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:dbc20dea3b9e27d0e66d74c42b2d0c1bed9c2ffe92adea33633e3bedeb5ac235"}, + {file = "pandas-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b75c347eff42497452116ce05ef461822d97ce5b9ff8df6edacb8076092c855d"}, + {file = "pandas-3.0.2-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1478075142e83a5571782ad007fb201ed074bdeac7ebcc8890c71442e96adf7"}, + {file = "pandas-3.0.2-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5880314e69e763d4c8b27937090de570f1fb8d027059a7ada3f7f8e98bdcb677"}, + {file = "pandas-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b5329e26898896f06035241a626d7c335daa479b9bbc82be7c2742d048e41172"}, + {file = "pandas-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:81526c4afd31971f8b62671442a4b2b51e0aa9acc3819c9f0f12a28b6fcf85f1"}, + {file = "pandas-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:7cadd7e9a44ec13b621aec60f9150e744cfc7a3dd32924a7e2f45edff31823b0"}, + {file = "pandas-3.0.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:db0dbfd2a6cdf3770aa60464d50333d8f3d9165b2f2671bcc299b72de5a6677b"}, + {file = "pandas-3.0.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0555c5882688a39317179ab4a0ed41d3ebc8812ab14c69364bbee8fb7a3f6288"}, + {file = "pandas-3.0.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:01f31a546acd5574ef77fe199bc90b55527c225c20ccda6601cf6b0fd5ed597c"}, + {file = "pandas-3.0.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:deeca1b5a931fdf0c2212c8a659ade6d3b1edc21f0914ce71ef24456ca7a6535"}, + {file = "pandas-3.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0f48afd9bb13300ffb5a3316973324c787054ba6665cda0da3fbd67f451995db"}, + {file = "pandas-3.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6c4d8458b97a35717b62469a4ea0e85abd5ed8687277f5ccfc67f8a5126f8c53"}, + {file = "pandas-3.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:b35d14bb5d8285d9494fe93815a9e9307c0876e10f1e8e89ac5b88f728ec8dcf"}, + {file = "pandas-3.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:63d141b56ef686f7f0d714cfb8de4e320475b86bf4b620aa0b7da89af8cbdbbb"}, + {file = "pandas-3.0.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:140f0cffb1fa2524e874dde5b477d9defe10780d8e9e220d259b2c0874c89d9d"}, + {file = "pandas-3.0.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae37e833ff4fed0ba352f6bdd8b73ba3ab3256a85e54edfd1ab51ae40cca0af8"}, + {file = "pandas-3.0.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d888a5c678a419a5bb41a2a93818e8ed9fd3172246555c0b37b7cc27027effd"}, + {file = "pandas-3.0.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b444dc64c079e84df91baa8bf613d58405645461cabca929d9178f2cd392398d"}, + {file = "pandas-3.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4544c7a54920de8eeacaa1466a6b7268ecfbc9bc64ab4dbb89c6bbe94d5e0660"}, + {file = "pandas-3.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:734be7551687c00fbd760dc0522ed974f82ad230d4a10f54bf51b80d44a08702"}, + {file = "pandas-3.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:57a07209bebcbcf768d2d13c9b78b852f9a15978dac41b9e6421a81ad4cdd276"}, + {file = "pandas-3.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:5371b72c2d4d415d08765f32d689217a43227484e81b2305b52076e328f6f482"}, + {file = "pandas-3.0.2.tar.gz", hash = "sha256:f4753e73e34c8d83221ba58f232433fca2748be8b18dbca02d242ed153945043"}, +] + +[package.dependencies] +numpy = [ + {version = ">=1.26.0", markers = "python_version < \"3.14\""}, + {version = ">=2.3.3", markers = "python_version >= \"3.14\""}, +] +python-dateutil = ">=2.8.2" +tzdata = {version = "*", markers = "sys_platform == \"win32\" or sys_platform == \"emscripten\""} + +[package.extras] +all = ["PyQt5 (>=5.15.9)", "SQLAlchemy (>=2.0.36)", "adbc-driver-postgresql (>=1.2.0)", "adbc-driver-sqlite (>=1.2.0)", "beautifulsoup4 (>=4.12.3)", "bottleneck (>=1.4.2)", "fastparquet (>=2024.11.0)", "fsspec (>=2024.10.0)", "gcsfs (>=2024.10.0)", "html5lib (>=1.1)", "hypothesis (>=6.116.0)", "jinja2 (>=3.1.5)", "lxml (>=5.3.0)", "matplotlib (>=3.9.3)", "numba (>=0.60.0)", "numexpr (>=2.10.2)", "odfpy (>=1.4.1)", "openpyxl (>=3.1.5)", "psycopg2 (>=2.9.10)", "pyarrow (>=13.0.0)", "pyiceberg (>=0.8.1)", "pymysql (>=1.1.1)", "pyreadstat (>=1.2.8)", "pytest (>=8.3.4)", "pytest-xdist (>=3.6.1)", "python-calamine (>=0.3.0)", "pytz (>=2024.2)", "pyxlsb (>=1.0.10)", "qtpy (>=2.4.2)", "s3fs (>=2024.10.0)", "scipy (>=1.14.1)", "tables (>=3.10.1)", "tabulate (>=0.9.0)", "xarray (>=2024.10.0)", "xlrd (>=2.0.1)", "xlsxwriter (>=3.2.0)", "zstandard (>=0.23.0)"] +aws = ["s3fs (>=2024.10.0)"] +clipboard = ["PyQt5 (>=5.15.9)", "qtpy (>=2.4.2)"] +compression = ["zstandard (>=0.23.0)"] +computation = ["scipy (>=1.14.1)", "xarray (>=2024.10.0)"] +excel = ["odfpy (>=1.4.1)", "openpyxl (>=3.1.5)", "python-calamine (>=0.3.0)", "pyxlsb (>=1.0.10)", "xlrd (>=2.0.1)", "xlsxwriter (>=3.2.0)"] +feather = ["pyarrow (>=13.0.0)"] +fss = ["fsspec (>=2024.10.0)"] +gcp = ["gcsfs (>=2024.10.0)"] +hdf5 = ["tables (>=3.10.1)"] +html = ["beautifulsoup4 (>=4.12.3)", "html5lib (>=1.1)", "lxml (>=5.3.0)"] +iceberg = ["pyiceberg (>=0.8.1)"] +mysql = ["SQLAlchemy (>=2.0.36)", "pymysql (>=1.1.1)"] +output-formatting = ["jinja2 (>=3.1.5)", "tabulate (>=0.9.0)"] +parquet = ["pyarrow (>=13.0.0)"] +performance = ["bottleneck (>=1.4.2)", "numba (>=0.60.0)", "numexpr (>=2.10.2)"] +plot = ["matplotlib (>=3.9.3)"] +postgresql = ["SQLAlchemy (>=2.0.36)", "adbc-driver-postgresql (>=1.2.0)", "psycopg2 (>=2.9.10)"] +pyarrow = ["pyarrow (>=13.0.0)"] +spss = ["pyreadstat (>=1.2.8)"] +sql-other = ["SQLAlchemy (>=2.0.36)", "adbc-driver-postgresql (>=1.2.0)", "adbc-driver-sqlite (>=1.2.0)"] +test = ["hypothesis (>=6.116.0)", "pytest (>=8.3.4)", "pytest-xdist (>=3.6.1)"] +timezone = ["pytz (>=2024.2)"] +xml = ["lxml (>=5.3.0)"] + +[[package]] +name = "pandas-stubs" +version = "3.0.0.260204" +description = "Type annotations for pandas" +optional = false +python-versions = ">=3.11" +groups = ["dev"] +files = [ + {file = "pandas_stubs-3.0.0.260204-py3-none-any.whl", hash = "sha256:5ab9e4d55a6e2752e9720828564af40d48c4f709e6a2c69b743014a6fcb6c241"}, + {file = "pandas_stubs-3.0.0.260204.tar.gz", hash = "sha256:bf9294b76352effcffa9cb85edf0bed1339a7ec0c30b8e1ac3d66b4228f1fbc3"}, +] + +[package.dependencies] +numpy = ">=1.23.5" + [[package]] name = "pathspec" version = "1.0.4" @@ -738,6 +860,21 @@ pytest = ">=7" [package.extras] testing = ["process-tests", "pytest-xdist", "virtualenv"] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +description = "Extensions to the standard Python datetime module" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["main"] +files = [ + {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, + {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, +] + +[package.dependencies] +six = ">=1.5" + [[package]] name = "ruff" version = "0.15.9" @@ -766,6 +903,18 @@ files = [ {file = "ruff-0.15.9.tar.gz", hash = "sha256:29cbb1255a9797903f6dde5ba0188c707907ff44a9006eb273b5a17bfa0739a2"}, ] +[[package]] +name = "six" +version = "1.17.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["main"] +files = [ + {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, + {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, +] + [[package]] name = "typing-extensions" version = "4.15.0" @@ -793,12 +942,24 @@ files = [ [package.dependencies] typing-extensions = ">=4.12.0" +[[package]] +name = "tzdata" +version = "2026.1" +description = "Provider of IANA time zone data" +optional = false +python-versions = ">=2" +groups = ["main"] +markers = "sys_platform == \"win32\" or sys_platform == \"emscripten\"" +files = [ + {file = "tzdata-2026.1-py2.py3-none-any.whl", hash = "sha256:4b1d2be7ac37ceafd7327b961aa3a54e467efbdb563a23655fbfe0d39cfc42a9"}, + {file = "tzdata-2026.1.tar.gz", hash = "sha256:67658a1903c75917309e753fdc349ac0efd8c27db7a0cb406a25be4840f87f98"}, +] + [extras] ml = [] -pandas = [] plot = [] [metadata] lock-version = "2.1" python-versions = "^3.11" -content-hash = "d5c46b94cd8e7db4ca8582201b1b04ee0894d01f45f2628020995743026f413c" +content-hash = "ba6a9f8c12f7ddf89207883d4ffa906a297d6a890136827ee6790d3cbadeecbb" diff --git a/pyproject.toml b/pyproject.toml index 28962ca..670d80a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,9 +13,13 @@ python = "^3.11" pydantic = ">=2.0" pyarrow = ">=14.0" numpy = ">=1.26" +pandas = ">=2.0" +click = ">=8.0" + +[tool.poetry.scripts] +nexa = "nexa_backtest.cli.main:cli" [tool.poetry.extras] -pandas = ["pandas"] plot = ["matplotlib"] ml = ["onnxruntime", "scikit-learn"] @@ -24,6 +28,7 @@ pytest = ">=7.4" pytest-cov = ">=4.1" mypy = ">=1.8" ruff = ">=0.3" +pandas-stubs = ">=2.0" [tool.ruff] target-version = "py311" diff --git a/src/nexa_backtest/__init__.py b/src/nexa_backtest/__init__.py index a6ce292..faaee5a 100644 --- a/src/nexa_backtest/__init__.py +++ b/src/nexa_backtest/__init__.py @@ -1,11 +1,10 @@ -"""nexa-backtest: Backtesting framework for European power markets. - -Key public exports for Task 01. Additional exports will be added as -subsequent tasks are implemented. -""" +"""nexa-backtest: Backtesting framework for European power markets.""" from nexa_backtest._version import __version__ +from nexa_backtest.algo import SimpleAlgo +from nexa_backtest.analysis.metrics import BacktestResult from nexa_backtest.context import SignalValue, TradingContext +from nexa_backtest.engines.backtest import BacktestEngine from nexa_backtest.exceptions import ( AlgoError, DataError, @@ -16,6 +15,9 @@ UnsupportedFeatureError, ValidationError, ) +from nexa_backtest.signals.base import SignalProvider, SignalSchema +from nexa_backtest.signals.csv_loader import CsvSignalProvider +from nexa_backtest.signals.registry import SignalRegistry from nexa_backtest.types import ( MTU, AuctionInfo, @@ -34,7 +36,10 @@ "MTU", "AlgoError", "AuctionInfo", + "BacktestEngine", + "BacktestResult", "CancelResult", + "CsvSignalProvider", "DataError", "ExchangeError", "Fill", @@ -48,7 +53,11 @@ "PriceLevel", "Side", "SignalError", + "SignalProvider", + "SignalRegistry", + "SignalSchema", "SignalValue", + "SimpleAlgo", "TradingContext", "UnsupportedFeatureError", "ValidationError", diff --git a/src/nexa_backtest/algo.py b/src/nexa_backtest/algo.py new file mode 100644 index 0000000..6b37a49 --- /dev/null +++ b/src/nexa_backtest/algo.py @@ -0,0 +1,113 @@ +"""SimpleAlgo base class for the hook-based trading algorithm interface. + +Subclass :class:`SimpleAlgo` and override the hooks you need. All hooks are +no-ops by default. The algo must never import engine-specific classes; all +market interaction goes through :class:`~nexa_backtest.context.TradingContext`. +""" + +from __future__ import annotations + +from nexa_backtest.context import SignalValue, TradingContext +from nexa_backtest.types import AuctionInfo, Fill + + +class SimpleAlgo: + """Base class for simple hook-based trading algorithms. + + Override the lifecycle hooks to implement your strategy. Hooks are called + by the engine in the order: :meth:`on_setup`, then auction hooks per + product, then :meth:`on_teardown`. + + Example:: + + class MyAlgo(SimpleAlgo): + def on_setup(self, ctx: TradingContext) -> None: + self.subscribe_signal("wind_forecast") + + def on_auction_open( + self, ctx: TradingContext, auction: AuctionInfo + ) -> None: + signal = ctx.get_signal("wind_forecast") + if signal.value > 1000: + ctx.place_order( + Order.buy( + product_id=auction.product_id, + volume_mw=10, + price_eur_mwh=45.0, + ) + ) + + The algo is engine-agnostic: the same code runs under the backtest, + paper, and live engines without modification. + """ + + def __init__(self) -> None: + self._subscribed_signals: list[str] = [] + + def subscribe_signal(self, name: str) -> None: + """Register interest in a named signal. + + Call this from :meth:`on_setup`. The engine will ensure the signal + provider is registered before the first auction hook fires. + + Args: + name: Signal name to subscribe to, e.g. ``"price_forecast"``. + """ + if name not in self._subscribed_signals: + self._subscribed_signals.append(name) + + def on_setup(self, ctx: TradingContext) -> None: + """Called once before the backtest begins. + + Use this hook to subscribe to signals and initialise any persistent + state (thresholds, counters, etc.). + + Args: + ctx: The trading context. + """ + + def on_auction_open(self, ctx: TradingContext, auction: AuctionInfo) -> None: + """Called once per auction product when the auction opens. + + Place orders by calling + :meth:`~nexa_backtest.context.TradingContext.place_order`. Any order + placed here will be matched against the historical clearing price for + ``auction.product_id``. + + Args: + ctx: The trading context. + auction: Metadata about the auction product opening. + """ + + def on_fill(self, ctx: TradingContext, fill: Fill) -> None: + """Called immediately after one of the algo's orders is filled. + + Args: + ctx: The trading context. + fill: Details of the fill, including price, volume, and side. + """ + + def on_signal(self, ctx: TradingContext, name: str, value: SignalValue) -> None: + """Called when a subscribed signal updates. + + For DA backtesting this is called once per delivery day, before any + :meth:`on_auction_open` calls for that day. Signals can also be + polled directly at any time via + :meth:`~nexa_backtest.context.TradingContext.get_signal`. + + Args: + ctx: The trading context. + name: Signal name matching the name passed to + :meth:`subscribe_signal`. + value: The latest signal value visible at the current simulated + time. + """ + + def on_teardown(self, ctx: TradingContext) -> None: + """Called once after the backtest ends. + + Use this hook to log final positions or perform any cleanup. + + Args: + ctx: The trading context. + """ diff --git a/src/nexa_backtest/analysis/__init__.py b/src/nexa_backtest/analysis/__init__.py new file mode 100644 index 0000000..53dd129 --- /dev/null +++ b/src/nexa_backtest/analysis/__init__.py @@ -0,0 +1 @@ +"""PnL analysis, VWAP benchmarking, and backtest metrics.""" diff --git a/src/nexa_backtest/analysis/metrics.py b/src/nexa_backtest/analysis/metrics.py new file mode 100644 index 0000000..8f557f8 --- /dev/null +++ b/src/nexa_backtest/analysis/metrics.py @@ -0,0 +1,97 @@ +"""BacktestResult and summary formatting. + +:class:`BacktestResult` holds the complete output of a backtest run including +all fills and PnL metrics, and can produce a human-readable text summary. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date + +from nexa_backtest.analysis.pnl import PnlSummary +from nexa_backtest.types import Fill + + +@dataclass(frozen=True) +class BacktestResult: + """Complete result of a backtest run. + + Attributes: + algo_name: Class name of the algo that was run. + exchange: Exchange identifier, e.g. ``"nordpool"``. + start: First delivery date included in the backtest. + end: Last delivery date included in the backtest. + fills: All fills produced during the run, in chronological order. + pnl: Aggregated PnL and VWAP-relative performance metrics. + """ + + algo_name: str + exchange: str + start: date + end: date + fills: tuple[Fill, ...] + pnl: PnlSummary + + def summary(self) -> str: + """Produce a human-readable text summary of the backtest result. + + Returns: + Formatted multi-line string suitable for printing to stdout. + """ + p = self.pnl + sep = "=" * 62 + + lines = [ + sep, + " nexa-backtest Result", + sep, + f" Algo: {self.algo_name}", + f" Exchange: {self.exchange}", + f" Period: {self.start} → {self.end}", + "", + f" Market VWAP: {p.market_vwap:>10.2f} EUR/MWh", + "", + ] + + if p.buys.count > 0: + sign = "-" if p.buys.vwap_alpha >= 0 else "+" + alpha_str = f"{sign}{abs(p.buys.vwap_alpha):.2f}" + note = "(bought above VWAP)" if p.buys.vwap_alpha >= 0 else "(bought below VWAP ✓)" + lines += [ + " Buys", + f" Fills: {p.buys.count:>6d}", + f" Volume: {p.buys.volume_mwh:>10.1f} MWh", + f" Avg Price: {p.buys.avg_price:>10.2f} EUR/MWh", + f" vs VWAP: {alpha_str:>10} EUR/MWh {note}", + f" EUR Alpha: {p.buys.total_alpha_eur:>+10.2f} EUR", + f" Win Rate: {p.buys.win_rate:>9.1%}", + "", + ] + + if p.sells.count > 0: + sign = "+" if p.sells.vwap_alpha >= 0 else "-" + alpha_str = f"{sign}{abs(p.sells.vwap_alpha):.2f}" + note = "(sold above VWAP ✓)" if p.sells.vwap_alpha <= 0 else "(sold below VWAP)" + lines += [ + " Sells", + f" Fills: {p.sells.count:>6d}", + f" Volume: {p.sells.volume_mwh:>10.1f} MWh", + f" Avg Price: {p.sells.avg_price:>10.2f} EUR/MWh", + f" vs VWAP: {alpha_str:>10} EUR/MWh {note}", + f" EUR Alpha: {p.sells.total_alpha_eur:>+10.2f} EUR", + f" Win Rate: {p.sells.win_rate:>9.1%}", + "", + ] + + if p.buys.count == 0 and p.sells.count == 0: + lines.append(" No fills recorded.") + lines.append("") + + lines += [ + f" Total Alpha: {p.total_alpha_eur:>+10.2f} EUR", + f" Total Fills: {len(self.fills):>6d}", + sep, + ] + + return "\n".join(lines) diff --git a/src/nexa_backtest/analysis/pnl.py b/src/nexa_backtest/analysis/pnl.py new file mode 100644 index 0000000..66b2bf4 --- /dev/null +++ b/src/nexa_backtest/analysis/pnl.py @@ -0,0 +1,129 @@ +"""PnL calculation engine. + +Computes realised profit/loss and VWAP-relative performance metrics from a +list of fills and market clearing price data. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from decimal import Decimal + +import pandas as pd + +from nexa_backtest.analysis.vwap import compute_market_vwap +from nexa_backtest.types import Fill, Side + + +@dataclass(frozen=True) +class SideSummary: + """Aggregated statistics for one side (buys or sells) of the strategy. + + Attributes: + count: Number of fills. + volume_mwh: Total filled volume in MWh. + avg_price: Volume-weighted average fill price in EUR/MWh. + Zero if no fills. + vwap_alpha: Difference between the algo's average price and the + market VWAP in EUR/MWh. For buys: ``avg_price - market_vwap`` + (negative = bought below VWAP, good). For sells: + ``market_vwap - avg_price`` (negative = sold above VWAP, good). + total_alpha_eur: ``vwap_alpha * volume_mwh`` in EUR. + win_rate: Fraction of fills where the price beat market VWAP (buys: + price < VWAP; sells: price > VWAP). + """ + + count: int + volume_mwh: Decimal + avg_price: Decimal + vwap_alpha: Decimal + total_alpha_eur: Decimal + win_rate: float + + +@dataclass(frozen=True) +class PnlSummary: + """Aggregated PnL metrics for a completed backtest run. + + Attributes: + market_vwap: Volume-weighted average clearing price across **all** + products in the backtest period (the benchmark). + buys: Statistics for the algo's buy fills. + sells: Statistics for the algo's sell fills. + total_alpha_eur: Combined VWAP alpha across buys and sells in EUR. + """ + + market_vwap: Decimal + buys: SideSummary + sells: SideSummary + total_alpha_eur: Decimal + + +def _side_summary(fills: list[Fill], market_vwap: Decimal, side: Side) -> SideSummary: + """Compute aggregated statistics for one side of fills.""" + side_fills = [f for f in fills if f.side == side] + if not side_fills: + return SideSummary( + count=0, + volume_mwh=Decimal("0"), + avg_price=Decimal("0"), + vwap_alpha=Decimal("0"), + total_alpha_eur=Decimal("0"), + win_rate=0.0, + ) + + total_volume: Decimal = sum((f.volume for f in side_fills), Decimal("0")) + total_cost: Decimal = sum((f.price * f.volume for f in side_fills), Decimal("0")) + avg_price = total_cost / total_volume if total_volume else Decimal("0") + + if side == Side.BUY: + vwap_alpha = avg_price - market_vwap # negative = good (bought below VWAP) + win_count = sum(1 for f in side_fills if f.price < market_vwap) + else: + vwap_alpha = market_vwap - avg_price # negative = sold above VWAP (good) + win_count = sum(1 for f in side_fills if f.price > market_vwap) + + total_alpha = -vwap_alpha * total_volume # EUR benefit vs passive VWAP execution + win_rate = win_count / len(side_fills) + + return SideSummary( + count=len(side_fills), + volume_mwh=total_volume, + avg_price=avg_price, + vwap_alpha=vwap_alpha, + total_alpha_eur=total_alpha, + win_rate=win_rate, + ) + + +def compute_pnl(fills: list[Fill], market_data: pd.DataFrame) -> PnlSummary: + """Compute PnL metrics from fills and market clearing price data. + + The benchmark is the market VWAP: the volume-weighted average clearing + price across *all* products in ``market_data``, not just those traded. + A positive total alpha means the algo executed better than passive + VWAP participation would have. + + Args: + fills: All fills produced during the backtest run. + market_data: DataFrame returned by + :meth:`~nexa_backtest.data.loader.ParquetLoader.load_da_prices`, + containing ``price_eur_mwh`` and ``volume_mwh`` for all products + in the period. + + Returns: + :class:`PnlSummary` with market VWAP and per-side metrics. + """ + market_vwap = compute_market_vwap(market_data) + + buys = _side_summary(fills, market_vwap, Side.BUY) + sells = _side_summary(fills, market_vwap, Side.SELL) + + total_alpha = buys.total_alpha_eur + sells.total_alpha_eur + + return PnlSummary( + market_vwap=market_vwap, + buys=buys, + sells=sells, + total_alpha_eur=total_alpha, + ) diff --git a/src/nexa_backtest/analysis/vwap.py b/src/nexa_backtest/analysis/vwap.py new file mode 100644 index 0000000..6b16b78 --- /dev/null +++ b/src/nexa_backtest/analysis/vwap.py @@ -0,0 +1,38 @@ +"""VWAP benchmark calculation. + +VWAP (Volume-Weighted Average Price) is the primary benchmark for evaluating +execution quality. If an algo cannot beat VWAP, a simple time-weighted passive +execution strategy would achieve better results. +""" + +from __future__ import annotations + +from decimal import Decimal + +import pandas as pd + + +def compute_market_vwap(market_data: pd.DataFrame) -> Decimal: + """Compute the volume-weighted average clearing price across all products. + + This is the benchmark price: the average price a passive participant would + have paid (for buys) or received (for sells) if they executed at each + clearing price proportional to market volume. + + Args: + market_data: DataFrame with ``price_eur_mwh`` (float) and + ``volume_mwh`` (float) columns for all products in the period. + + Returns: + Market VWAP in EUR/MWh, or ``Decimal("0")`` if the data is empty + or has no volume. + """ + if market_data.empty or "volume_mwh" not in market_data.columns: + return Decimal("0") + + total_vol = market_data["volume_mwh"].sum() + if total_vol <= 0: + return Decimal("0") + + price_x_vol = (market_data["price_eur_mwh"] * market_data["volume_mwh"]).sum() + return Decimal(str(round(price_x_vol / total_vol, 6))) diff --git a/src/nexa_backtest/cli/__init__.py b/src/nexa_backtest/cli/__init__.py new file mode 100644 index 0000000..e7bf959 --- /dev/null +++ b/src/nexa_backtest/cli/__init__.py @@ -0,0 +1 @@ +"""Command-line interface for nexa-backtest.""" diff --git a/src/nexa_backtest/cli/main.py b/src/nexa_backtest/cli/main.py new file mode 100644 index 0000000..a2d3b2d --- /dev/null +++ b/src/nexa_backtest/cli/main.py @@ -0,0 +1,178 @@ +"""CLI entry point for nexa-backtest. + +Provides the ``nexa run`` command for running backtests from the command line +without writing a Python driver script. + +Usage:: + + nexa run examples/my_algo.py \\ + --exchange nordpool \\ + --start 2026-03-01 \\ + --end 2026-03-31 \\ + --products NO1_DA \\ + --data-dir ./data \\ + --capital 100000 +""" + +from __future__ import annotations + +import importlib.util +import inspect +import sys +from datetime import datetime +from decimal import Decimal +from pathlib import Path +from types import ModuleType + +import click + +from nexa_backtest.algo import SimpleAlgo +from nexa_backtest.engines.backtest import BacktestEngine +from nexa_backtest.exceptions import NexaBacktestError + + +@click.group() +def cli() -> None: + """nexa-backtest: backtesting framework for European power markets.""" + + +@cli.command("run") +@click.argument("algo_file", type=click.Path(exists=True, dir_okay=False)) +@click.option("--exchange", required=True, help="Exchange identifier, e.g. 'nordpool'.") +@click.option( + "--start", + required=True, + type=click.DateTime(formats=["%Y-%m-%d"]), + help="First delivery date (YYYY-MM-DD).", +) +@click.option( + "--end", + required=True, + type=click.DateTime(formats=["%Y-%m-%d"]), + help="Last delivery date inclusive (YYYY-MM-DD).", +) +@click.option( + "--products", + required=True, + multiple=True, + help="Product spec, e.g. 'NO1_DA'. Repeat for multiple products.", +) +@click.option( + "--data-dir", + required=True, + type=click.Path(exists=True, file_okay=False), + help="Directory containing da_prices.parquet and signals/.", +) +@click.option( + "--capital", + default=100_000.0, + show_default=True, + help="Starting capital in EUR.", +) +def run_command( + algo_file: str, + exchange: str, + start: datetime, + end: datetime, + products: tuple[str, ...], + data_dir: str, + capital: float, +) -> None: + """Run a backtest from ALGO_FILE and print the PnL summary. + + ALGO_FILE must contain exactly one subclass of SimpleAlgo. If it contains + multiple subclasses an error is raised. + """ + try: + algo_class = _load_algo_class(algo_file) + except NexaBacktestError as exc: + raise click.ClickException(str(exc)) from exc + + algo = algo_class() + + engine = BacktestEngine( + algo=algo, + exchange=exchange, + start=start.date(), + end=end.date(), + products=list(products), + data_dir=Path(data_dir), + capital=Decimal(str(capital)), + ) + + try: + result = engine.run() + except NexaBacktestError as exc: + raise click.ClickException(str(exc)) from exc + except Exception as exc: + raise click.ClickException(f"Unexpected error during backtest: {exc}") from exc + + click.echo(result.summary()) + + +# ------------------------------------------------------------------ +# Algo discovery helpers +# ------------------------------------------------------------------ + + +def _load_module(path: str) -> ModuleType: + """Import a Python file as a module using importlib.""" + file_path = Path(path).resolve() + spec = importlib.util.spec_from_file_location("_nexa_user_algo", file_path) + if spec is None or spec.loader is None: + raise click.ClickException(f"Cannot load module from '{path}'.") + + # Add the algo's parent directory to sys.path so relative imports work + parent = str(file_path.parent) + if parent not in sys.path: + sys.path.insert(0, parent) + + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _load_algo_class(path: str) -> type[SimpleAlgo]: + """Find the unique :class:`~nexa_backtest.algo.SimpleAlgo` subclass in ``path``. + + Args: + path: Path to a Python file. + + Returns: + The single :class:`~nexa_backtest.algo.SimpleAlgo` subclass found. + + Raises: + :class:`click.ClickException`: If zero or multiple subclasses are found. + """ + module = _load_module(path) + + subclasses: list[type[SimpleAlgo]] = [ + obj + for _, obj in inspect.getmembers(module, inspect.isclass) + if issubclass(obj, SimpleAlgo) and obj is not SimpleAlgo + ] + + if len(subclasses) == 0: + raise click.ClickException( + f"No SimpleAlgo subclass found in '{path}'. Define a class that extends SimpleAlgo." + ) + if len(subclasses) > 1: + names = ", ".join(c.__name__ for c in subclasses) + raise click.ClickException( + f"Multiple SimpleAlgo subclasses found in '{path}': {names}. " + "Move the unused classes to a separate file." + ) + + return subclasses[0] + + +def find_algo_class(path: str) -> type[SimpleAlgo]: + """Public wrapper around :func:`_load_algo_class` for use in tests. + + Args: + path: Path to a Python file containing a SimpleAlgo subclass. + + Returns: + The discovered :class:`~nexa_backtest.algo.SimpleAlgo` subclass. + """ + return _load_algo_class(path) diff --git a/src/nexa_backtest/data/__init__.py b/src/nexa_backtest/data/__init__.py new file mode 100644 index 0000000..1b698c3 --- /dev/null +++ b/src/nexa_backtest/data/__init__.py @@ -0,0 +1 @@ +"""Data loading utilities for historical market data.""" diff --git a/src/nexa_backtest/data/loader.py b/src/nexa_backtest/data/loader.py new file mode 100644 index 0000000..7fa67ec --- /dev/null +++ b/src/nexa_backtest/data/loader.py @@ -0,0 +1,119 @@ +"""Parquet-based data loader for historical market data. + +DA clearing prices are small enough to load entirely into memory (~1.7 MB per +zone per year). IDC order book data uses windowed replay (Stage 2). +""" + +from __future__ import annotations + +from datetime import date +from pathlib import Path + +import pandas as pd + +from nexa_backtest.data.schema import ( + DA_PRICE_FILENAME, + DA_PRICE_PRICE_COL, + DA_PRICE_REQUIRED_COLUMNS, + DA_PRICE_TIMESTAMP_COL, + DA_PRICE_VOLUME_COL, + DA_PRICE_ZONE_COL, +) +from nexa_backtest.exceptions import DataError + + +class ParquetLoader: + """Loads historical market data from Parquet files. + + DA clearing prices are loaded entirely into memory. The loader validates + the file schema and filters to the requested zone and date range. + + Args: + data_dir: Directory containing market data files. The loader expects + ``da_prices.parquet`` to be present within this directory. + + Example:: + + loader = ParquetLoader(Path("data/")) + df = loader.load_da_prices("NO1", date(2026, 3, 1), date(2026, 3, 31)) + """ + + def __init__(self, data_dir: Path) -> None: + self._data_dir = data_dir + + def load_da_prices(self, zone: str, start: date, end: date) -> pd.DataFrame: + """Load DA clearing prices for a zone and date range. + + Reads ``da_prices.parquet`` from the data directory and filters to the + requested zone and date range (inclusive on both ends). + + Args: + zone: Bidding zone identifier, e.g. ``"NO1"``. + start: First delivery date to include (inclusive). + end: Last delivery date to include (inclusive). + + Returns: + DataFrame with columns: ``product_id`` (str), ``timestamp`` + (tz-aware UTC datetime), ``zone`` (str), ``price_eur_mwh`` + (float64), ``volume_mwh`` (float64). Sorted ascending by + timestamp. + + Raises: + :class:`~nexa_backtest.exceptions.DataError`: If the file does + not exist, is missing required columns, or contains no data + for the requested zone and period. + """ + path = self._data_dir / DA_PRICE_FILENAME + if not path.exists(): + raise DataError( + f"DA prices file not found: {path}. " + f"Expected file: {DA_PRICE_FILENAME} in {self._data_dir}" + ) + + try: + df = pd.read_parquet(path) + except Exception as exc: + raise DataError(f"Failed to read DA prices from {path}: {exc}") from exc + + missing = DA_PRICE_REQUIRED_COLUMNS - set(df.columns) + if missing: + raise DataError( + f"DA prices file {path} is missing required columns: {sorted(missing)}. " + f"Found: {sorted(df.columns)}" + ) + + # Ensure timestamp is timezone-aware UTC + if df[DA_PRICE_TIMESTAMP_COL].dt.tz is None: + df[DA_PRICE_TIMESTAMP_COL] = df[DA_PRICE_TIMESTAMP_COL].dt.tz_localize("UTC") + else: + df[DA_PRICE_TIMESTAMP_COL] = df[DA_PRICE_TIMESTAMP_COL].dt.tz_convert("UTC") + + mask = ( + (df[DA_PRICE_ZONE_COL] == zone) + & (df[DA_PRICE_TIMESTAMP_COL].dt.date >= start) + & (df[DA_PRICE_TIMESTAMP_COL].dt.date <= end) + ) + result = df[mask].copy() + + if result.empty: + raise DataError( + f"No DA prices found for zone '{zone}' between {start} and {end}. " + f"Verify that {path} contains data for this zone and period." + ) + + # Compute product IDs from zone + delivery timestamp + result["product_id"] = ( + result[DA_PRICE_ZONE_COL] + + "_" + + result[DA_PRICE_TIMESTAMP_COL].dt.strftime("%Y%m%dT%H%MZ") + ) + + cols = [ + "product_id", + DA_PRICE_TIMESTAMP_COL, + DA_PRICE_ZONE_COL, + DA_PRICE_PRICE_COL, + DA_PRICE_VOLUME_COL, + ] + out: pd.DataFrame = result[cols].sort_values(DA_PRICE_TIMESTAMP_COL).reset_index(drop=True) + return out diff --git a/src/nexa_backtest/data/schema.py b/src/nexa_backtest/data/schema.py new file mode 100644 index 0000000..c3a2c64 --- /dev/null +++ b/src/nexa_backtest/data/schema.py @@ -0,0 +1,31 @@ +"""Standard column schemas for historical data files. + +These constants define the expected column names and types for Parquet files +used by :class:`~nexa_backtest.data.loader.ParquetLoader`. +""" + +from __future__ import annotations + +# DA clearing price schema +# Parquet columns: timestamp (datetime64[ns, UTC]), zone (str), +# price_eur_mwh (float64), volume_mwh (float64) +DA_PRICE_TIMESTAMP_COL = "timestamp" +DA_PRICE_ZONE_COL = "zone" +DA_PRICE_PRICE_COL = "price_eur_mwh" +DA_PRICE_VOLUME_COL = "volume_mwh" + +DA_PRICE_REQUIRED_COLUMNS: frozenset[str] = frozenset( + { + DA_PRICE_TIMESTAMP_COL, + DA_PRICE_ZONE_COL, + DA_PRICE_PRICE_COL, + DA_PRICE_VOLUME_COL, + } +) + +# Default filename for DA clearing prices within a data directory +DA_PRICE_FILENAME = "da_prices.parquet" + +# Signal CSV schema +SIGNAL_CSV_TIMESTAMP_COL = "timestamp" +SIGNAL_CSV_VALUE_COL = "value" diff --git a/src/nexa_backtest/engines/backtest.py b/src/nexa_backtest/engines/backtest.py new file mode 100644 index 0000000..5288d34 --- /dev/null +++ b/src/nexa_backtest/engines/backtest.py @@ -0,0 +1,535 @@ +"""BacktestEngine: simulated clock, DA auction replay, and signal dispatch. + +The engine loads historical clearing prices, advances a simulated clock +through each delivery day, calls the algo's lifecycle hooks, matches orders +against historical clearing prices, and returns a :class:`BacktestResult`. + +The engine also implements :class:`~nexa_backtest.context.TradingContext` via +an internal :class:`_BacktestContext` object that is passed to the algo. Algo +code never sees the engine directly. +""" + +from __future__ import annotations + +import logging +import uuid +from collections import defaultdict +from datetime import UTC, date, datetime, time, timedelta +from decimal import Decimal +from pathlib import Path +from typing import Any + +from nexa_backtest.algo import SimpleAlgo +from nexa_backtest.analysis.metrics import BacktestResult +from nexa_backtest.analysis.pnl import compute_pnl +from nexa_backtest.context import SignalValue, TradingContext +from nexa_backtest.data.loader import ParquetLoader +from nexa_backtest.engines.clock import SimulatedClock +from nexa_backtest.engines.matching import DAAuctionMatcher +from nexa_backtest.exceptions import DataError, SignalError +from nexa_backtest.signals.base import SignalProvider +from nexa_backtest.signals.csv_loader import CsvSignalProvider +from nexa_backtest.signals.registry import SignalRegistry +from nexa_backtest.types import ( + MTU, + AuctionInfo, + CancelResult, + Fill, + Order, + OrderBook, + OrderResult, + OrderStatus, + Position, + PriceLevel, + Side, +) + +logger = logging.getLogger(__name__) + +# Simulated time of DA auction relative to delivery day midnight UTC. +# We set the clock to D-1 12:00 UTC so publication_offset-based filters work +# correctly for day-ahead forecasts. +_AUCTION_OFFSET = timedelta(days=-1, hours=12) # D-1 12:00 UTC +_GATE_CLOSURE_OFFSET = timedelta(hours=1) # gate closes 1h after auction opens + + +class _BacktestContext: + """Internal :class:`~nexa_backtest.context.TradingContext` implementation. + + Created by :class:`BacktestEngine` for each run. The algo receives this + object via every lifecycle hook but never knows its concrete type. + """ + + def __init__( + self, + clock: SimulatedClock, + signal_registry: SignalRegistry, + ) -> None: + self._clock = clock + self._signal_registry = signal_registry + + # Mutable state updated by the engine between calls + self._pending_orders: dict[str, Order] = {} + self._fills: list[Fill] = [] + self._position_fills: dict[str, list[Fill]] = defaultdict(list) + self._clearing_prices: dict[str, Decimal] = {} + self._gate_closures: dict[str, datetime] = {} + + # ------------------------------------------------------------------ + # Time + # ------------------------------------------------------------------ + + def now(self) -> datetime: + """Return current simulated time.""" + return self._clock.now() + + def time_to_gate_closure(self, product_id: str) -> timedelta: + """Return time remaining until gate closure for ``product_id``.""" + if product_id in self._gate_closures: + remaining = self._gate_closures[product_id] - self._clock.now() + return max(remaining, timedelta(0)) + return timedelta(0) + + def current_mtu(self) -> MTU: + """Return the MTU whose delivery period contains ``now()``.""" + now = self._clock.now() + minute_slot = (now.minute // 15) * 15 + start = now.replace(minute=minute_slot, second=0, microsecond=0) + end = start + timedelta(minutes=15) + return MTU(start=start, end=end, zone="") + + # ------------------------------------------------------------------ + # Market data + # ------------------------------------------------------------------ + + def get_orderbook(self, product_id: str) -> OrderBook: + """Return a synthetic order book based on the DA clearing price.""" + price = self._clearing_prices.get(product_id) + ts = self._clock.now() + if price is None: + return OrderBook( + product_id=product_id, + best_bid=None, + best_ask=None, + timestamp=ts, + ) + level = PriceLevel(price=price, volume=Decimal("1000")) + return OrderBook( + product_id=product_id, + best_bid=level, + best_ask=level, + timestamp=ts, + ) + + def get_best_bid(self, product_id: str) -> PriceLevel | None: + """Return the best bid for ``product_id``.""" + return self.get_orderbook(product_id).best_bid + + def get_best_ask(self, product_id: str) -> PriceLevel | None: + """Return the best ask for ``product_id``.""" + return self.get_orderbook(product_id).best_ask + + def get_last_price(self, product_id: str) -> Decimal | None: + """Return the last DA clearing price for ``product_id``.""" + return self._clearing_prices.get(product_id) + + def get_vwap(self, product_id: str) -> Decimal | None: + """Return the DA clearing price as session VWAP for ``product_id``.""" + return self._clearing_prices.get(product_id) + + # ------------------------------------------------------------------ + # Order management + # ------------------------------------------------------------------ + + def place_order(self, order: Order) -> OrderResult: + """Accept an order for deferred matching against the clearing price.""" + self._pending_orders[order.order_id] = order + return OrderResult(order_id=order.order_id, status=OrderStatus.ACCEPTED) + + def cancel_order(self, order_id: str) -> CancelResult: + """Cancel a pending order before it is matched.""" + if order_id in self._pending_orders: + del self._pending_orders[order_id] + return CancelResult(order_id=order_id, status="cancelled") + return CancelResult(order_id=order_id, status="not_found") + + def modify_order(self, order_id: str, **changes: Any) -> OrderResult: + """Cancel and resubmit an order with updated fields.""" + if order_id not in self._pending_orders: + return OrderResult( + order_id=order_id, + status=OrderStatus.REJECTED, + rejection_reason=f"Order '{order_id}' not found in pending orders.", + ) + old_order = self._pending_orders.pop(order_id) + new_data = old_order.model_dump() + new_data.update(changes) + new_data["order_id"] = str(uuid.uuid4()) + try: + new_order = Order.model_validate(new_data) + except Exception as exc: + return OrderResult( + order_id=order_id, + status=OrderStatus.REJECTED, + rejection_reason=str(exc), + ) + self._pending_orders[new_order.order_id] = new_order + return OrderResult(order_id=new_order.order_id, status=OrderStatus.ACCEPTED) + + # ------------------------------------------------------------------ + # Positions + # ------------------------------------------------------------------ + + def get_position(self, product_id: str) -> Position: + """Return the current net position for ``product_id``.""" + fills = self._position_fills.get(product_id, []) + if not fills: + return Position( + product_id=product_id, + net_mw=Decimal("0"), + avg_entry_price=Decimal("0"), + unrealised_pnl=Decimal("0"), + ) + + net_mw = Decimal("0") + total_cost = Decimal("0") + for f in fills: + if f.side == Side.BUY: + net_mw += f.volume + total_cost += f.price * f.volume + else: + net_mw -= f.volume + total_cost -= f.price * f.volume + + if net_mw == 0: + return Position( + product_id=product_id, + net_mw=Decimal("0"), + avg_entry_price=Decimal("0"), + unrealised_pnl=Decimal("0"), + ) + + avg_price = abs(total_cost / net_mw) + mark = self._clearing_prices.get(product_id, avg_price) + unrealised = (mark - avg_price) * net_mw if net_mw > 0 else (avg_price - mark) * abs(net_mw) + + return Position( + product_id=product_id, + net_mw=net_mw, + avg_entry_price=avg_price, + unrealised_pnl=unrealised, + ) + + def get_all_positions(self) -> dict[str, Position]: + """Return all non-zero positions.""" + result: dict[str, Position] = {} + for product_id in self._position_fills: + pos = self.get_position(product_id) + if pos.net_mw != 0: + result[product_id] = pos + return result + + def get_unrealised_pnl(self) -> Decimal: + """Return total unrealised PnL across all positions.""" + return sum( + (p.unrealised_pnl for p in self.get_all_positions().values()), + Decimal("0"), + ) + + # ------------------------------------------------------------------ + # Signals + # ------------------------------------------------------------------ + + def get_signal(self, name: str) -> SignalValue: + """Return the latest signal value visible at the current simulated time.""" + provider = self._signal_registry.get(name) + return provider.get_value(self._clock.now()) + + def get_signal_history(self, name: str, lookback: int) -> list[SignalValue]: + """Return the most recent ``lookback`` values visible at the current time.""" + provider = self._signal_registry.get(name) + return provider.get_history_at(self._clock.now(), lookback) + + # ------------------------------------------------------------------ + # ML models (stub — implemented in Stage 3) + # ------------------------------------------------------------------ + + def predict(self, model_name: str, features: dict[str, Any]) -> Any: + """Run inference on a registered ML model. + + Raises: + NotImplementedError: ML model registry is not yet implemented. + """ + raise NotImplementedError( + "ML model inference is not yet supported. It will be added in a later stage." + ) + + # ------------------------------------------------------------------ + # Logging + # ------------------------------------------------------------------ + + def log(self, message: str, level: str = "info") -> None: + """Emit a log message tagged with the current simulated time.""" + algo_logger = logging.getLogger("nexa_backtest.algo") + log_fn = getattr(algo_logger, level, algo_logger.info) + log_fn("[%s] %s", self._clock.now().isoformat(), message) + + # ------------------------------------------------------------------ + # Engine-internal helpers (not part of TradingContext) + # ------------------------------------------------------------------ + + def _reset_day(self) -> None: + """Clear pending orders and clearing prices for the next auction day.""" + self._pending_orders.clear() + self._clearing_prices.clear() + self._gate_closures.clear() + + def _record_fill(self, fill: Fill) -> None: + """Record a fill in the position tracker.""" + self._fills.append(fill) + self._position_fills[fill.product_id].append(fill) + + +def _check_context_protocol(ctx: TradingContext) -> None: # pragma: no cover + """Compile-time check that _BacktestContext satisfies TradingContext.""" + + +_check_context_protocol(_BacktestContext.__new__(_BacktestContext)) + + +class BacktestEngine: + """Runs a DA backtest against historical clearing price data. + + The engine: + + 1. Loads DA clearing prices from ``{data_dir}/da_prices.parquet``. + 2. Advances the simulated clock to D-1 12:00 UTC for each delivery day. + 3. Dispatches signal updates to the algo for subscribed signals. + 4. Calls :meth:`~nexa_backtest.algo.SimpleAlgo.on_auction_open` once per + delivery product. + 5. Matches all orders placed during those calls against the clearing price + using the price-taker assumption. + 6. Calls :meth:`~nexa_backtest.algo.SimpleAlgo.on_fill` for each fill. + 7. Returns a :class:`~nexa_backtest.analysis.metrics.BacktestResult`. + + **Signal auto-discovery** + + If the algo calls :meth:`~nexa_backtest.algo.SimpleAlgo.subscribe_signal` + during :meth:`~nexa_backtest.algo.SimpleAlgo.on_setup` and no explicit + provider is registered for that name, the engine looks for:: + + {data_dir}/signals/{signal_name}.csv + + A :class:`~nexa_backtest.exceptions.DataError` is raised if the file is + not found. + + Args: + algo: The trading algorithm to run. + exchange: Exchange identifier, e.g. ``"nordpool"``. + start: First delivery date to include. + end: Last delivery date to include (inclusive). + products: Product specifications, e.g. ``["NO1_DA"]``. + Format is ``{zone}_{type}`` where type is ``DA``. + data_dir: Directory containing ``da_prices.parquet`` and optionally a + ``signals/`` subdirectory. + capital: Starting capital in EUR (informational only for now). + signals: Explicitly constructed signal providers. Auto-discovered CSV + providers are added for any subscribed signals not covered here. + + Example:: + + engine = BacktestEngine( + algo=MyAlgo(), + exchange="nordpool", + start=date(2026, 3, 1), + end=date(2026, 3, 31), + products=["NO1_DA"], + data_dir=Path("data/"), + capital=Decimal("100000"), + ) + result = engine.run() + print(result.summary()) + """ + + def __init__( + self, + algo: SimpleAlgo, + exchange: str, + start: date, + end: date, + products: list[str], + data_dir: Path, + capital: Decimal, + signals: list[SignalProvider] | None = None, + ) -> None: + self._algo = algo + self._exchange = exchange + self._start = start + self._end = end + self._products = products + self._data_dir = data_dir + self._capital = capital + self._signals: list[SignalProvider] = signals or [] + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def run(self) -> BacktestResult: + """Execute the backtest and return results. + + Returns: + :class:`~nexa_backtest.analysis.metrics.BacktestResult` containing + all fills, PnL metrics, and VWAP benchmarking. + + Raises: + :class:`~nexa_backtest.exceptions.DataError`: If required data + files are missing or malformed. + """ + zone = self._parse_zone() + + # Load market data + loader = ParquetLoader(self._data_dir) + market_data = loader.load_da_prices(zone, self._start, self._end) + + # Build signal registry from explicitly passed providers + registry = SignalRegistry() + for provider in self._signals: + registry.register(provider) + + # Initialise clock to just before the first auction (D-1 12:00 UTC of start) + first_auction = self._auction_time(self._start) + clock = SimulatedClock(initial_time=first_auction - timedelta(minutes=1)) + context = _BacktestContext(clock=clock, signal_registry=registry) + + # on_setup: algo registers signal subscriptions and sets state + self._algo.on_setup(context) + + # Auto-discover CSVs for any subscribed signals not yet registered + self._discover_signals(registry) + + # Group products by delivery day + market_data["delivery_date"] = market_data["timestamp"].dt.date + + all_fills: list[Fill] = [] + + for delivery_date, day_data in market_data.groupby("delivery_date"): + auction_time = self._auction_time(delivery_date) # type: ignore[arg-type] + clock.advance_to(auction_time) + + # Populate clearing prices and gate closures for this day + context._reset_day() + gate_closure = auction_time + _GATE_CLOSURE_OFFSET + for _, row in day_data.iterrows(): + pid: str = str(row["product_id"]) + context._clearing_prices[pid] = Decimal(str(row["price_eur_mwh"])) + context._gate_closures[pid] = gate_closure + + # Emit on_signal for each subscribed signal + for signal_name in self._algo._subscribed_signals: + if registry.has(signal_name): + try: + value = context.get_signal(signal_name) + self._algo.on_signal(context, signal_name, value) + except SignalError: + logger.debug( + "No value yet for signal '%s' at %s — skipping on_signal.", + signal_name, + auction_time.isoformat(), + ) + + # Call on_auction_open for every product in this day + for _, product_row in day_data.sort_values("timestamp").iterrows(): + auction_info = AuctionInfo( + product_id=str(product_row["product_id"]), + auction_type="DA", + gate_closure_time=gate_closure, + zone=zone, + ) + self._algo.on_auction_open(context, auction_info) + + # Match all pending orders against clearing prices + fill_time = gate_closure + orders = list(context._pending_orders.values()) + context._pending_orders.clear() + + for order in orders: + clearing = context._clearing_prices.get(order.product_id) + if clearing is None: + logger.warning( + "Order for unknown product '%s' rejected — no clearing price.", + order.product_id, + ) + continue + + matcher = DAAuctionMatcher(clearing_price=clearing, fill_timestamp=fill_time) + result = matcher.match(order) + + if result.fill is not None: + fill = result.fill + context._record_fill(fill) + all_fills.append(fill) + self._algo.on_fill(context, fill) + + self._algo.on_teardown(context) + + pnl = compute_pnl(all_fills, market_data) + + return BacktestResult( + algo_name=type(self._algo).__name__, + exchange=self._exchange, + start=self._start, + end=self._end, + fills=tuple(all_fills), + pnl=pnl, + ) + + # ------------------------------------------------------------------ + # Private helpers + # ------------------------------------------------------------------ + + def _parse_zone(self) -> str: + """Extract zone from the first product spec, e.g. ``"NO1_DA"`` -> ``"NO1"``.""" + if not self._products: + raise DataError("No products specified. Pass e.g. products=['NO1_DA'].") + spec = self._products[0] + parts = spec.split("_") + if len(parts) < 2: + raise DataError( + f"Cannot parse product spec '{spec}'. " + "Expected format: {{zone}}_{{type}}, e.g. 'NO1_DA'." + ) + return parts[0] + + @staticmethod + def _auction_time(delivery_date: date) -> datetime: + """Return the simulated clock time for the DA auction of ``delivery_date``. + + Set to D-1 12:00 UTC, which matches Nord Pool DA gate closure and + ensures forecasts with 36h+ publication_offset are visible for all + delivery products on day D. + """ + delivery_dt = datetime.combine(delivery_date, time(0, 0), tzinfo=UTC) + return delivery_dt + _AUCTION_OFFSET + + def _discover_signals(self, registry: SignalRegistry) -> None: + """Auto-register CSV providers for subscribed but unregistered signals.""" + signals_dir = self._data_dir / "signals" + for signal_name in self._algo._subscribed_signals: + if registry.has(signal_name): + continue + csv_path = signals_dir / f"{signal_name}.csv" + if not csv_path.exists(): + raise DataError( + f"Signal '{signal_name}' is subscribed by the algo but no CSV " + f"was found at '{csv_path}'. Either create the file or pass a " + f"SignalProvider explicitly to BacktestEngine." + ) + provider = CsvSignalProvider( + name=signal_name, + path=csv_path, + unit="", + description=f"Auto-loaded from {csv_path.name}", + ) + registry.register(provider) + logger.info("Auto-loaded signal '%s' from %s", signal_name, csv_path) diff --git a/src/nexa_backtest/signals/__init__.py b/src/nexa_backtest/signals/__init__.py new file mode 100644 index 0000000..dd280e2 --- /dev/null +++ b/src/nexa_backtest/signals/__init__.py @@ -0,0 +1 @@ +"""Signal providers and registry.""" diff --git a/src/nexa_backtest/signals/base.py b/src/nexa_backtest/signals/base.py new file mode 100644 index 0000000..42ea0a9 --- /dev/null +++ b/src/nexa_backtest/signals/base.py @@ -0,0 +1,108 @@ +"""SignalProvider protocol and supporting types. + +This module defines the interface that signal data sources must implement. +All signal providers must respect ``publication_offset`` to prevent look-ahead +bias: at simulated time T, only values published before T are visible. +""" + +from __future__ import annotations + +from datetime import datetime, timedelta +from typing import Any, Protocol + +import pandas as pd +from pydantic import BaseModel, ConfigDict + +from nexa_backtest.context import SignalValue + + +class SignalSchema(BaseModel): + """Describes the structure and semantics of a signal. + + Attributes: + name: Signal identifier, e.g. ``"wind_generation_forecast"``. + dtype: Python type of the value field, e.g. ``float``. + frequency: How often the signal updates, e.g. ``timedelta(minutes=15)``. + description: Human-readable description of what the signal represents. + unit: Physical unit of the value, e.g. ``"EUR/MWh"``, ``"MW"``, ``"m/s"``. + """ + + model_config = ConfigDict(frozen=True) + + name: str + dtype: type[Any] + frequency: timedelta + description: str + unit: str + + +class SignalProvider(Protocol): + """Protocol for signal data sources. + + Implementations must respect ``publication_offset`` to prevent look-ahead + bias: ``get_value(t)`` must only return data that was known at time ``t``. + + Custom signal providers only need to implement this protocol. The simplest + path is :class:`~nexa_backtest.signals.csv_loader.CsvSignalProvider`. + """ + + @property + def name(self) -> str: + """Signal name used to register and look up this provider.""" + ... + + @property + def schema(self) -> SignalSchema: + """Metadata describing the signal's content and structure.""" + ... + + def get_value(self, timestamp: datetime) -> SignalValue: + """Return the latest value visible at ``timestamp``. + + Respects ``publication_offset`` so that future values are never + returned: only values whose publication time is <= ``timestamp``. + + Args: + timestamp: The current simulated time. + + Returns: + The most recent :class:`~nexa_backtest.context.SignalValue` + visible at ``timestamp``. + + Raises: + :class:`~nexa_backtest.exceptions.SignalError`: If no value is + available at ``timestamp``. + """ + ... + + def get_range(self, start: datetime, end: datetime) -> pd.Series[Any]: + """Return all values in the half-open interval ``[start, end)``. + + Returns raw values by their delivery timestamp without applying + ``publication_offset`` filtering. + + Args: + start: Start of the range (inclusive). + end: End of the range (exclusive). + + Returns: + A :class:`pandas.Series` of float values indexed by their delivery + timestamps in ascending order. + """ + ... + + def get_history_at(self, timestamp: datetime, lookback: int) -> list[SignalValue]: + """Return the most recent ``lookback`` values visible at ``timestamp``. + + Like :meth:`get_value`, this method respects ``publication_offset`` + so no future values are returned. + + Args: + timestamp: The current simulated time. + lookback: Maximum number of historical values to return. + + Returns: + Signal values in chronological order (oldest first), capped at + ``lookback`` entries. + """ + ... diff --git a/src/nexa_backtest/signals/csv_loader.py b/src/nexa_backtest/signals/csv_loader.py new file mode 100644 index 0000000..0dfe308 --- /dev/null +++ b/src/nexa_backtest/signals/csv_loader.py @@ -0,0 +1,249 @@ +"""CsvSignalProvider: load a CSV file as a signal. + +This is the simplest way for a customer to bring their own forecast data +without implementing :class:`~nexa_backtest.signals.base.SignalProvider` +from scratch. +""" + +from __future__ import annotations + +import logging +from datetime import datetime, timedelta +from pathlib import Path +from typing import Any + +import pandas as pd + +from nexa_backtest.context import SignalValue +from nexa_backtest.data.schema import SIGNAL_CSV_TIMESTAMP_COL, SIGNAL_CSV_VALUE_COL +from nexa_backtest.exceptions import DataError, SignalError +from nexa_backtest.signals.base import SignalSchema + +logger = logging.getLogger(__name__) + + +class CsvSignalProvider: + """Loads a CSV file and serves it as a named signal. + + Expected CSV format:: + + timestamp,value + 2026-03-15T00:00:00+01:00,42.31 + 2026-03-15T00:15:00+01:00,41.87 + + The ``timestamp`` column must contain timezone-aware datetimes in ISO 8601 + format. The ``value`` column must be numeric (parsed as float). Additional + columns are silently ignored. + + **Look-ahead bias and publication_offset** + + Set ``publication_offset`` for forecast data. A value with timestamp T + (the delivery period it describes) was published at T - publication_offset, + so it becomes visible when the simulated clock reaches T - publication_offset. + + In code: ``get_value(current_time)`` returns the latest row where:: + + row.timestamp <= current_time + publication_offset + + Example: a wind forecast published 6 hours ahead (``publication_offset= + timedelta(hours=6)``). At 00:00, only forecasts up to 06:00 are visible. + + If ``publication_offset`` is not set a warning is logged, because values + will be available at their exact timestamp — correct for actuals but + look-ahead bias for forecasts. + + Args: + name: Signal name used for registration and lookup. + path: Path to the CSV file. Resolved at construction time. + unit: Physical unit string, e.g. ``"EUR/MWh"`` or ``"MW"``. + description: Human-readable description of the signal. + frequency: Expected update cadence. Defaults to 1-hour intervals. + publication_offset: How far ahead of delivery the forecast was + published. See above for semantics. Pass ``None`` for actuals. + + Raises: + :class:`~nexa_backtest.exceptions.DataError`: If the file is missing, + lacks required columns, or contains unparseable values. + """ + + def __init__( + self, + name: str, + path: str | Path, + unit: str, + description: str, + frequency: timedelta = timedelta(hours=1), + publication_offset: timedelta | None = None, + ) -> None: + self._name = name + self._path = Path(path) + self._unit = unit + self._description = description + self._frequency = frequency + self._publication_offset = publication_offset + + if publication_offset is None: + logger.warning( + "Signal '%s' has no publication_offset. Values are available at their " + "timestamp. This is correct for actuals but may introduce look-ahead " + "bias for forecasts — consider whether your data is a forecast.", + name, + ) + + self._data = self._load() + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _load(self) -> pd.DataFrame: + """Load, validate, and sort the CSV file.""" + if not self._path.exists(): + raise DataError( + f"Signal CSV not found: {self._path}. Check the file path or the data directory." + ) + + try: + df = pd.read_csv(self._path) + except Exception as exc: + raise DataError(f"Failed to read signal CSV '{self._path}': {exc}") from exc + + for col in (SIGNAL_CSV_TIMESTAMP_COL, SIGNAL_CSV_VALUE_COL): + if col not in df.columns: + raise DataError( + f"Signal CSV '{self._path}' is missing required column '{col}'. " + f"Found columns: {list(df.columns)}" + ) + + try: + df[SIGNAL_CSV_TIMESTAMP_COL] = pd.to_datetime(df[SIGNAL_CSV_TIMESTAMP_COL], utc=True) + except Exception as exc: + raise DataError( + f"Signal CSV '{self._path}' contains invalid timestamps: {exc}" + ) from exc + + try: + df[SIGNAL_CSV_VALUE_COL] = df[SIGNAL_CSV_VALUE_COL].astype(float) + except Exception as exc: + raise DataError( + f"Signal CSV '{self._path}' contains non-numeric values " + f"in column '{SIGNAL_CSV_VALUE_COL}': {exc}" + ) from exc + + result = ( + df[[SIGNAL_CSV_TIMESTAMP_COL, SIGNAL_CSV_VALUE_COL]] + .sort_values(SIGNAL_CSV_TIMESTAMP_COL) + .reset_index(drop=True) + ) + return result + + def _effective_cutoff(self, current_time: datetime) -> pd.Timestamp: + """Return the timestamp cutoff for values visible at ``current_time``.""" + offset = self._publication_offset if self._publication_offset is not None else timedelta(0) + return pd.Timestamp(current_time + offset) + + # ------------------------------------------------------------------ + # SignalProvider protocol + # ------------------------------------------------------------------ + + @property + def name(self) -> str: + """Signal name.""" + return self._name + + @property + def publication_offset(self) -> timedelta | None: + """Publication offset, or ``None`` if unset (actuals mode).""" + return self._publication_offset + + @property + def schema(self) -> SignalSchema: + """Schema metadata describing this signal.""" + return SignalSchema( + name=self._name, + dtype=float, + frequency=self._frequency, + description=self._description, + unit=self._unit, + ) + + def get_value(self, timestamp: datetime) -> SignalValue: + """Return the latest value visible at ``timestamp``. + + Respects ``publication_offset``: only rows whose delivery timestamp + is <= ``timestamp + publication_offset`` are considered. + + Args: + timestamp: Current simulated time. + + Returns: + Most recent :class:`~nexa_backtest.context.SignalValue` visible + at ``timestamp``. + + Raises: + :class:`~nexa_backtest.exceptions.SignalError`: If no value is + visible at ``timestamp`` (e.g. all values are in the future). + """ + cutoff = self._effective_cutoff(timestamp) + mask = self._data[SIGNAL_CSV_TIMESTAMP_COL] <= cutoff + visible = self._data[mask] + + if visible.empty: + first_ts = self._data[SIGNAL_CSV_TIMESTAMP_COL].iloc[0] + raise SignalError( + f"No value available for signal '{self._name}' at time {timestamp}. " + f"Earliest data timestamp: {first_ts}. " + f"publication_offset: {self._publication_offset}." + ) + + row = visible.iloc[-1] + ts: datetime = row[SIGNAL_CSV_TIMESTAMP_COL].to_pydatetime() + val: float = float(row[SIGNAL_CSV_VALUE_COL]) + return SignalValue(name=self._name, timestamp=ts, value=val) + + def get_range(self, start: datetime, end: datetime) -> pd.Series[Any]: + """Return values in the half-open interval ``[start, end)``. + + Returns raw delivery-timestamp values without applying + ``publication_offset``. + + Args: + start: Start of the range (inclusive). + end: End of the range (exclusive). + + Returns: + :class:`pandas.Series` with a UTC datetime index and float values, + sorted ascending. + """ + start_ts = pd.Timestamp(start) + end_ts = pd.Timestamp(end) + + mask = (self._data[SIGNAL_CSV_TIMESTAMP_COL] >= start_ts) & ( + self._data[SIGNAL_CSV_TIMESTAMP_COL] < end_ts + ) + subset = self._data[mask].copy() + return subset.set_index(SIGNAL_CSV_TIMESTAMP_COL)[SIGNAL_CSV_VALUE_COL] + + def get_history_at(self, timestamp: datetime, lookback: int) -> list[SignalValue]: + """Return the most recent ``lookback`` values visible at ``timestamp``. + + Respects ``publication_offset`` so no future values are included. + + Args: + timestamp: Current simulated time. + lookback: Maximum number of historical values to return. + + Returns: + Signal values in chronological order (oldest first), with at most + ``lookback`` entries. + """ + cutoff = self._effective_cutoff(timestamp) + mask = self._data[SIGNAL_CSV_TIMESTAMP_COL] <= cutoff + visible = self._data[mask].tail(lookback) + + result: list[SignalValue] = [] + for _, row in visible.iterrows(): + ts: datetime = row[SIGNAL_CSV_TIMESTAMP_COL].to_pydatetime() + val: float = float(row[SIGNAL_CSV_VALUE_COL]) + result.append(SignalValue(name=self._name, timestamp=ts, value=val)) + return result diff --git a/src/nexa_backtest/signals/registry.py b/src/nexa_backtest/signals/registry.py new file mode 100644 index 0000000..67ff3bc --- /dev/null +++ b/src/nexa_backtest/signals/registry.py @@ -0,0 +1,68 @@ +"""Signal registry for registering and looking up signal providers.""" + +from __future__ import annotations + +from nexa_backtest.exceptions import SignalError +from nexa_backtest.signals.base import SignalProvider + + +class SignalRegistry: + """Registry for :class:`~nexa_backtest.signals.base.SignalProvider` instances. + + Holds registered providers and provides lookup by name. The backtest + engine populates the registry from explicitly-passed providers and from + auto-discovered CSV files. + """ + + def __init__(self) -> None: + self._providers: dict[str, SignalProvider] = {} + + def register(self, provider: SignalProvider) -> None: + """Register a signal provider. + + If a provider with the same name is already registered it is + silently replaced. + + Args: + provider: The provider to register. + """ + self._providers[provider.name] = provider + + def get(self, name: str) -> SignalProvider: + """Return the provider registered under ``name``. + + Args: + name: Signal name. + + Returns: + The registered :class:`~nexa_backtest.signals.base.SignalProvider`. + + Raises: + :class:`~nexa_backtest.exceptions.SignalError`: If no provider + is registered with ``name``. + """ + if name not in self._providers: + available = ", ".join(sorted(self._providers)) or "(none)" + raise SignalError( + f"Signal '{name}' not found in registry. Available signals: {available}" + ) + return self._providers[name] + + def has(self, name: str) -> bool: + """Return ``True`` if a provider is registered under ``name``. + + Args: + name: Signal name to check. + + Returns: + Whether the name is registered. + """ + return name in self._providers + + def list_signals(self) -> list[str]: + """Return a sorted list of all registered signal names. + + Returns: + Alphabetically sorted list of signal names. + """ + return sorted(self._providers) diff --git a/tests/fixtures/da_prices.parquet b/tests/fixtures/da_prices.parquet new file mode 100644 index 0000000000000000000000000000000000000000..9f62ee76b93aa89a2b664252c2e62c0457e186e0 GIT binary patch literal 82550 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

Q84t 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 aL0I(n@GQ7A8T_Ar1RHh&!Ajd&>Hh&NaQngl literal 0 HcmV?d00001 diff --git a/tests/fixtures/generate.py b/tests/fixtures/generate.py new file mode 100644 index 0000000..6a72d5c --- /dev/null +++ b/tests/fixtures/generate.py @@ -0,0 +1,147 @@ +"""Generate synthetic test fixture data for nexa-backtest. + +Run this script to (re)generate the fixture files used by tests and examples:: + + python tests/fixtures/generate.py + +Outputs: + - tests/fixtures/da_prices.parquet + - tests/fixtures/signals/price_forecast.csv +""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from pathlib import Path + +import numpy as np +import pandas as pd + +FIXTURES_DIR = Path(__file__).parent +SIGNALS_DIR = FIXTURES_DIR / "signals" + +# Synthetic data parameters +ZONE = "NO1" +START = datetime(2026, 3, 1, tzinfo=UTC) +END = datetime(2026, 4, 1, tzinfo=UTC) # exclusive +MTU_MINUTES = 15 +BASE_PRICE = 45.0 # EUR/MWh +PRICE_STD = 8.0 +DAILY_AMPLITUDE = 10.0 +SEED = 42 + +# Publication offset for the price_forecast signal: 36h ahead of delivery. +# At auction time D-1 12:00 UTC, get_value returns T <= D-1 12:00 + 36h = D+1 00:00, +# so all day-D forecasts are visible. +FORECAST_PUBLICATION_OFFSET_HOURS = 36 +FORECAST_NOISE_STD = 2.0 + + +def _generate_timestamps(start: datetime, end: datetime, step_minutes: int) -> list[datetime]: + """Generate equally-spaced timezone-aware timestamps.""" + stamps = [] + t = start + while t < end: + stamps.append(t) + t += timedelta(minutes=step_minutes) + return stamps + + +def generate_da_prices( + zone: str = ZONE, + start: datetime = START, + end: datetime = END, + mtu_minutes: int = MTU_MINUTES, + seed: int = SEED, +) -> pd.DataFrame: + """Generate a synthetic DA clearing price DataFrame. + + Prices follow a sinusoidal daily pattern with Gaussian noise. + + Args: + zone: Bidding zone identifier. + start: First delivery timestamp (inclusive). + end: Last delivery timestamp (exclusive). + mtu_minutes: MTU duration in minutes (15 or 60). + seed: Random seed for reproducibility. + + Returns: + DataFrame with columns: timestamp, zone, price_eur_mwh, volume_mwh. + """ + rng = np.random.default_rng(seed) + timestamps = _generate_timestamps(start, end, mtu_minutes) + n = len(timestamps) + + hour_of_day = np.array([ts.hour + ts.minute / 60 for ts in timestamps]) + prices = ( + BASE_PRICE + + DAILY_AMPLITUDE * np.sin(hour_of_day * np.pi / 12 - np.pi / 2) + + rng.normal(0, PRICE_STD, n) + ) + prices = np.maximum(prices, 5.0) + + volumes = rng.uniform(500, 2000, n) + + return pd.DataFrame( + { + "timestamp": timestamps, + "zone": zone, + "price_eur_mwh": prices, + "volume_mwh": volumes, + } + ) + + +def generate_price_forecast( + da_prices: pd.DataFrame, + noise_std: float = FORECAST_NOISE_STD, + seed: int = SEED + 1, +) -> pd.DataFrame: + """Generate a synthetic price forecast CSV with slight noise. + + The forecast timestamps match the DA delivery timestamps. The engine will + use publication_offset=36h so the forecast for day D is visible at D-1 + 12:00 UTC during the DA auction. + + Args: + da_prices: DataFrame from :func:`generate_da_prices`. + noise_std: Standard deviation of forecast error in EUR/MWh. + seed: Random seed. + + Returns: + DataFrame with columns: timestamp, value. + """ + rng = np.random.default_rng(seed) + n = len(da_prices) + noise = rng.normal(0, noise_std, n) + values = da_prices["price_eur_mwh"].to_numpy() + noise + + return pd.DataFrame( + { + "timestamp": da_prices["timestamp"].dt.strftime("%Y-%m-%dT%H:%M:%S+00:00"), + "value": values, + } + ) + + +def main() -> None: + """Generate and write all fixture files.""" + SIGNALS_DIR.mkdir(exist_ok=True) + + print(f"Generating DA prices for {ZONE} {START.date()} - {END.date()} ...") + da_prices = generate_da_prices() + parquet_path = FIXTURES_DIR / "da_prices.parquet" + da_prices.to_parquet(parquet_path, index=False) + print(f" Written: {parquet_path} ({len(da_prices)} rows)") + + print("Generating price_forecast signal CSV ...") + forecast = generate_price_forecast(da_prices) + csv_path = SIGNALS_DIR / "price_forecast.csv" + forecast.to_csv(csv_path, index=False) + print(f" Written: {csv_path} ({len(forecast)} rows)") + + print("Done.") + + +if __name__ == "__main__": + main() diff --git a/tests/fixtures/signals/price_forecast.csv b/tests/fixtures/signals/price_forecast.csv new file mode 100644 index 0000000..f45cf78 --- /dev/null +++ b/tests/fixtures/signals/price_forecast.csv @@ -0,0 +1,2977 @@ +timestamp,value +2026-03-01T00:00:00+00:00,37.926195651378976 +2026-03-01T00:15:00+00:00,28.057894557847714 +2026-03-01T00:30:00+00:00,39.918102190009414 +2026-03-01T00:45:00+00:00,40.89931868084671 +2026-03-01T01:00:00+00:00,15.748783805635897 +2026-03-01T01:15:00+00:00,27.056508614122805 +2026-03-01T01:30:00+00:00,36.81724250137751 +2026-03-01T01:45:00+00:00,33.912794535818115 +2026-03-01T02:00:00+00:00,34.63814694911007 +2026-03-01T02:15:00+00:00,32.31394851495782 +2026-03-01T02:30:00+00:00,45.98805155134631 +2026-03-01T02:45:00+00:00,43.460289645666435 +2026-03-01T03:00:00+00:00,37.34545085281969 +2026-03-01T03:15:00+00:00,46.711088736626415 +2026-03-01T03:30:00+00:00,41.05520936307987 +2026-03-01T03:45:00+00:00,32.62308626521916 +2026-03-01T04:00:00+00:00,41.71767083325889 +2026-03-01T04:15:00+00:00,34.077137332262076 +2026-03-01T04:30:00+00:00,47.48183093748979 +2026-03-01T04:45:00+00:00,40.678283931199644 +2026-03-01T05:00:00+00:00,44.00741664901669 +2026-03-01T05:15:00+00:00,36.15022137950308 +2026-03-01T05:30:00+00:00,50.3392370113008 +2026-03-01T05:45:00+00:00,43.84216323596305 +2026-03-01T06:00:00+00:00,37.4176403998764 +2026-03-01T06:15:00+00:00,43.36572016004704 +2026-03-01T06:30:00+00:00,52.527175690412584 +2026-03-01T06:45:00+00:00,50.014685796582135 +2026-03-01T07:00:00+00:00,50.90860149238552 +2026-03-01T07:15:00+00:00,47.55974397259557 +2026-03-01T07:30:00+00:00,67.07062250173216 +2026-03-01T07:45:00+00:00,46.964151905606315 +2026-03-01T08:00:00+00:00,43.5580626506787 +2026-03-01T08:15:00+00:00,47.871861998705405 +2026-03-01T08:30:00+00:00,53.56741726921072 +2026-03-01T08:45:00+00:00,57.266655769333525 +2026-03-01T09:00:00+00:00,47.53737261080514 +2026-03-01T09:15:00+00:00,45.849088073731664 +2026-03-01T09:30:00+00:00,47.01342617767663 +2026-03-01T09:45:00+00:00,59.40138624749234 +2026-03-01T10:00:00+00:00,58.93156745104095 +2026-03-01T10:15:00+00:00,56.68861151769672 +2026-03-01T10:30:00+00:00,50.600246830819394 +2026-03-01T10:45:00+00:00,54.53142110636125 +2026-03-01T11:00:00+00:00,53.17306432405684 +2026-03-01T11:15:00+00:00,59.35999291285084 +2026-03-01T11:30:00+00:00,62.241021853609325 +2026-03-01T11:45:00+00:00,57.709933413365455 +2026-03-01T12:00:00+00:00,61.68298991386416 +2026-03-01T12:15:00+00:00,54.37562757029853 +2026-03-01T12:30:00+00:00,56.16167863125494 +2026-03-01T12:45:00+00:00,61.139321976871926 +2026-03-01T13:00:00+00:00,41.76770925628993 +2026-03-01T13:15:00+00:00,52.58397294698199 +2026-03-01T13:30:00+00:00,50.29689498491431 +2026-03-01T13:45:00+00:00,46.469161597697486 +2026-03-01T14:00:00+00:00,53.39189756589655 +2026-03-01T14:15:00+00:00,62.69550271329601 +2026-03-01T14:30:00+00:00,46.47281136642595 +2026-03-01T14:45:00+00:00,60.39859426971129 +2026-03-01T15:00:00+00:00,41.1413320735269 +2026-03-01T15:15:00+00:00,46.67512746410753 +2026-03-01T15:30:00+00:00,51.303245407800105 +2026-03-01T15:45:00+00:00,55.04486789145658 +2026-03-01T16:00:00+00:00,56.051506765309014 +2026-03-01T16:15:00+00:00,57.50657907126493 +2026-03-01T16:30:00+00:00,46.65923090504631 +2026-03-01T16:45:00+00:00,44.56530147613499 +2026-03-01T17:00:00+00:00,56.50867308078904 +2026-03-01T17:15:00+00:00,49.61259570653098 +2026-03-01T17:30:00+00:00,33.388721252285464 +2026-03-01T17:45:00+00:00,36.57624040697504 +2026-03-01T18:00:00+00:00,39.163661503958586 +2026-03-01T18:15:00+00:00,48.49473997196145 +2026-03-01T18:30:00+00:00,47.703419500466 +2026-03-01T18:45:00+00:00,47.90463478315726 +2026-03-01T19:00:00+00:00,36.96578101964361 +2026-03-01T19:15:00+00:00,44.79494433534672 +2026-03-01T19:30:00+00:00,40.63208950813996 +2026-03-01T19:45:00+00:00,37.15082318566442 +2026-03-01T20:00:00+00:00,48.38636111848257 +2026-03-01T20:15:00+00:00,34.201377319418974 +2026-03-01T20:30:00+00:00,36.72348340761703 +2026-03-01T20:45:00+00:00,33.449426541250844 +2026-03-01T21:00:00+00:00,28.31584130306125 +2026-03-01T21:15:00+00:00,41.09298408755847 +2026-03-01T21:30:00+00:00,37.02792074356496 +2026-03-01T21:45:00+00:00,34.765092141555336 +2026-03-01T22:00:00+00:00,34.66219194087213 +2026-03-01T22:15:00+00:00,40.41682424119421 +2026-03-01T22:30:00+00:00,44.18431716575367 +2026-03-01T22:45:00+00:00,33.365483718909154 +2026-03-01T23:00:00+00:00,28.307296452805858 +2026-03-01T23:15:00+00:00,32.40875781371899 +2026-03-01T23:30:00+00:00,22.454015195317048 +2026-03-01T23:45:00+00:00,22.319993729526864 +2026-03-02T00:00:00+00:00,24.503157289008353 +2026-03-02T00:15:00+00:00,22.32840300594841 +2026-03-02T00:30:00+00:00,37.243019126982965 +2026-03-02T00:45:00+00:00,29.52484725846419 +2026-03-02T01:00:00+00:00,31.78612059000178 +2026-03-02T01:15:00+00:00,43.876440532377956 +2026-03-02T01:30:00+00:00,35.01495223228441 +2026-03-02T01:45:00+00:00,43.90535410849784 +2026-03-02T02:00:00+00:00,27.93999423303513 +2026-03-02T02:15:00+00:00,33.558137638544665 +2026-03-02T02:30:00+00:00,32.02241376350165 +2026-03-02T02:45:00+00:00,33.669480390959095 +2026-03-02T03:00:00+00:00,44.32467259223149 +2026-03-02T03:15:00+00:00,28.18991269177628 +2026-03-02T03:30:00+00:00,42.76149531970196 +2026-03-02T03:45:00+00:00,40.355341513513665 +2026-03-02T04:00:00+00:00,31.29397436754656 +2026-03-02T04:15:00+00:00,29.079463211316988 +2026-03-02T04:30:00+00:00,41.57702087147586 +2026-03-02T04:45:00+00:00,36.30198077735818 +2026-03-02T05:00:00+00:00,46.12431332817937 +2026-03-02T05:15:00+00:00,40.64108663873407 +2026-03-02T05:30:00+00:00,53.343216423564186 +2026-03-02T05:45:00+00:00,40.8547945843942 +2026-03-02T06:00:00+00:00,36.70319674208916 +2026-03-02T06:15:00+00:00,44.02835702306459 +2026-03-02T06:30:00+00:00,45.647894403256274 +2026-03-02T06:45:00+00:00,56.068177200155425 +2026-03-02T07:00:00+00:00,54.08670967126185 +2026-03-02T07:15:00+00:00,49.52902780621847 +2026-03-02T07:30:00+00:00,62.346170360661795 +2026-03-02T07:45:00+00:00,35.33621700388797 +2026-03-02T08:00:00+00:00,45.52317822695005 +2026-03-02T08:15:00+00:00,41.777662348101344 +2026-03-02T08:30:00+00:00,48.71545176330315 +2026-03-02T08:45:00+00:00,44.34683833355922 +2026-03-02T09:00:00+00:00,56.825861311747275 +2026-03-02T09:15:00+00:00,50.94681923152412 +2026-03-02T09:30:00+00:00,42.651018277253854 +2026-03-02T09:45:00+00:00,42.34846689185888 +2026-03-02T10:00:00+00:00,57.305084337214424 +2026-03-02T10:15:00+00:00,62.118450353825324 +2026-03-02T10:30:00+00:00,69.75551671999415 +2026-03-02T10:45:00+00:00,78.90274214537922 +2026-03-02T11:00:00+00:00,58.048361410228615 +2026-03-02T11:15:00+00:00,45.18240260261998 +2026-03-02T11:30:00+00:00,35.046396934137476 +2026-03-02T11:45:00+00:00,58.90016981452676 +2026-03-02T12:00:00+00:00,51.875709962773186 +2026-03-02T12:15:00+00:00,49.90208911023234 +2026-03-02T12:30:00+00:00,50.57433064247522 +2026-03-02T12:45:00+00:00,51.07512477296377 +2026-03-02T13:00:00+00:00,62.06919547652433 +2026-03-02T13:15:00+00:00,54.55867829437946 +2026-03-02T13:30:00+00:00,50.51669517924812 +2026-03-02T13:45:00+00:00,45.36167974255098 +2026-03-02T14:00:00+00:00,42.075022178703776 +2026-03-02T14:15:00+00:00,50.37533141170439 +2026-03-02T14:30:00+00:00,51.30214946604455 +2026-03-02T14:45:00+00:00,69.34128807262462 +2026-03-02T15:00:00+00:00,53.38768691505477 +2026-03-02T15:15:00+00:00,62.89840045622412 +2026-03-02T15:30:00+00:00,44.322076458941105 +2026-03-02T15:45:00+00:00,46.95524112078165 +2026-03-02T16:00:00+00:00,41.771873500534404 +2026-03-02T16:15:00+00:00,41.103261865378144 +2026-03-02T16:30:00+00:00,63.56977959112672 +2026-03-02T16:45:00+00:00,43.35880773207928 +2026-03-02T17:00:00+00:00,55.957323458648666 +2026-03-02T17:15:00+00:00,41.204391715589225 +2026-03-02T17:30:00+00:00,54.82819244234795 +2026-03-02T17:45:00+00:00,46.332836384618396 +2026-03-02T18:00:00+00:00,43.31423330273265 +2026-03-02T18:15:00+00:00,39.25054111649521 +2026-03-02T18:30:00+00:00,36.5652242214044 +2026-03-02T18:45:00+00:00,47.817886182243925 +2026-03-02T19:00:00+00:00,34.87708213872543 +2026-03-02T19:15:00+00:00,36.59716288487361 +2026-03-02T19:30:00+00:00,33.352608756292824 +2026-03-02T19:45:00+00:00,39.53937596815477 +2026-03-02T20:00:00+00:00,53.95867820104948 +2026-03-02T20:15:00+00:00,41.71524859814062 +2026-03-02T20:30:00+00:00,37.567228419231405 +2026-03-02T20:45:00+00:00,41.75585438915083 +2026-03-02T21:00:00+00:00,47.72838054637354 +2026-03-02T21:15:00+00:00,39.085872069317524 +2026-03-02T21:30:00+00:00,34.7692489970001 +2026-03-02T21:45:00+00:00,46.39488457057427 +2026-03-02T22:00:00+00:00,40.35276726992719 +2026-03-02T22:15:00+00:00,48.94254926220378 +2026-03-02T22:30:00+00:00,39.103101725031415 +2026-03-02T22:45:00+00:00,27.26124648278814 +2026-03-02T23:00:00+00:00,21.982844298918092 +2026-03-02T23:15:00+00:00,46.851013347183425 +2026-03-02T23:30:00+00:00,51.770967541471315 +2026-03-02T23:45:00+00:00,36.68758370401768 +2026-03-03T00:00:00+00:00,33.02952780581861 +2026-03-03T00:15:00+00:00,46.14871554825126 +2026-03-03T00:30:00+00:00,27.85946650631621 +2026-03-03T00:45:00+00:00,29.38937944464181 +2026-03-03T01:00:00+00:00,37.38711092380391 +2026-03-03T01:15:00+00:00,32.50595422792971 +2026-03-03T01:30:00+00:00,32.91676732430264 +2026-03-03T01:45:00+00:00,33.80282867032939 +2026-03-03T02:00:00+00:00,39.10347948953959 +2026-03-03T02:15:00+00:00,51.38589192506649 +2026-03-03T02:30:00+00:00,33.55815536587679 +2026-03-03T02:45:00+00:00,41.920298589350885 +2026-03-03T03:00:00+00:00,22.892531946702846 +2026-03-03T03:15:00+00:00,39.35153060332497 +2026-03-03T03:30:00+00:00,33.86853052793619 +2026-03-03T03:45:00+00:00,27.706465544721517 +2026-03-03T04:00:00+00:00,32.716333011275 +2026-03-03T04:15:00+00:00,36.97900102856539 +2026-03-03T04:30:00+00:00,51.120923493682625 +2026-03-03T04:45:00+00:00,30.898880023078664 +2026-03-03T05:00:00+00:00,42.69298565488451 +2026-03-03T05:15:00+00:00,39.557834462990364 +2026-03-03T05:30:00+00:00,38.45445505439226 +2026-03-03T05:45:00+00:00,53.40322806548854 +2026-03-03T06:00:00+00:00,47.094153162807494 +2026-03-03T06:15:00+00:00,56.71952853402139 +2026-03-03T06:30:00+00:00,43.61826273964964 +2026-03-03T06:45:00+00:00,43.77993184304252 +2026-03-03T07:00:00+00:00,45.8731192694681 +2026-03-03T07:15:00+00:00,54.01625981642119 +2026-03-03T07:30:00+00:00,45.77141797324861 +2026-03-03T07:45:00+00:00,43.49245939073229 +2026-03-03T08:00:00+00:00,52.4439009859319 +2026-03-03T08:15:00+00:00,52.96659503226525 +2026-03-03T08:30:00+00:00,75.64183096279783 +2026-03-03T08:45:00+00:00,63.75209134511795 +2026-03-03T09:00:00+00:00,45.67028664448581 +2026-03-03T09:15:00+00:00,52.713762187796505 +2026-03-03T09:30:00+00:00,42.44039246442003 +2026-03-03T09:45:00+00:00,47.83197693753468 +2026-03-03T10:00:00+00:00,57.48099681146858 +2026-03-03T10:15:00+00:00,62.78511308622337 +2026-03-03T10:30:00+00:00,47.72463043532624 +2026-03-03T10:45:00+00:00,47.912089875443336 +2026-03-03T11:00:00+00:00,39.589465679837105 +2026-03-03T11:15:00+00:00,54.130810334385274 +2026-03-03T11:30:00+00:00,51.448007149604535 +2026-03-03T11:45:00+00:00,52.4369673151682 +2026-03-03T12:00:00+00:00,45.01828635768452 +2026-03-03T12:15:00+00:00,55.82961529416726 +2026-03-03T12:30:00+00:00,38.67501411449287 +2026-03-03T12:45:00+00:00,42.51914930753507 +2026-03-03T13:00:00+00:00,71.97550530573848 +2026-03-03T13:15:00+00:00,41.98915850455498 +2026-03-03T13:30:00+00:00,44.45369328169594 +2026-03-03T13:45:00+00:00,67.31635218695158 +2026-03-03T14:00:00+00:00,77.18261915251598 +2026-03-03T14:15:00+00:00,43.795129661171345 +2026-03-03T14:30:00+00:00,49.77918568884283 +2026-03-03T14:45:00+00:00,58.66348513098766 +2026-03-03T15:00:00+00:00,66.98555319888598 +2026-03-03T15:15:00+00:00,44.5645737043003 +2026-03-03T15:30:00+00:00,51.31998963211353 +2026-03-03T15:45:00+00:00,60.49896526189582 +2026-03-03T16:00:00+00:00,56.27461913738341 +2026-03-03T16:15:00+00:00,44.30354633733937 +2026-03-03T16:30:00+00:00,48.81250967188716 +2026-03-03T16:45:00+00:00,38.762902624248504 +2026-03-03T17:00:00+00:00,47.61018729147062 +2026-03-03T17:15:00+00:00,44.537601046816384 +2026-03-03T17:30:00+00:00,48.306604376661 +2026-03-03T17:45:00+00:00,39.43911773473986 +2026-03-03T18:00:00+00:00,48.03521323521088 +2026-03-03T18:15:00+00:00,50.1148763026981 +2026-03-03T18:30:00+00:00,47.48618560534234 +2026-03-03T18:45:00+00:00,44.82420616488684 +2026-03-03T19:00:00+00:00,40.971686283391165 +2026-03-03T19:15:00+00:00,42.37695575855322 +2026-03-03T19:30:00+00:00,35.61426398275741 +2026-03-03T19:45:00+00:00,41.736553389521745 +2026-03-03T20:00:00+00:00,39.9749919873416 +2026-03-03T20:15:00+00:00,54.937262809546056 +2026-03-03T20:30:00+00:00,52.519162395496245 +2026-03-03T20:45:00+00:00,41.4393026486821 +2026-03-03T21:00:00+00:00,32.25927420064077 +2026-03-03T21:15:00+00:00,27.820193072845083 +2026-03-03T21:30:00+00:00,45.256715359385765 +2026-03-03T21:45:00+00:00,39.29743382514954 +2026-03-03T22:00:00+00:00,37.038559615450524 +2026-03-03T22:15:00+00:00,24.844064887118318 +2026-03-03T22:30:00+00:00,42.269330733525244 +2026-03-03T22:45:00+00:00,43.31941653933937 +2026-03-03T23:00:00+00:00,27.80089030394398 +2026-03-03T23:15:00+00:00,31.23399370254488 +2026-03-03T23:30:00+00:00,35.989499858003036 +2026-03-03T23:45:00+00:00,36.47956629796161 +2026-03-04T00:00:00+00:00,33.588180667414974 +2026-03-04T00:15:00+00:00,35.05576985838538 +2026-03-04T00:30:00+00:00,34.748950406124074 +2026-03-04T00:45:00+00:00,36.6541635930004 +2026-03-04T01:00:00+00:00,49.40275439686887 +2026-03-04T01:15:00+00:00,13.463125539737796 +2026-03-04T01:30:00+00:00,33.45952578184164 +2026-03-04T01:45:00+00:00,38.682403006813146 +2026-03-04T02:00:00+00:00,37.14763569569932 +2026-03-04T02:15:00+00:00,33.79678499374615 +2026-03-04T02:30:00+00:00,26.082677371303934 +2026-03-04T02:45:00+00:00,40.3169745781282 +2026-03-04T03:00:00+00:00,50.80869531153729 +2026-03-04T03:15:00+00:00,28.394404005357803 +2026-03-04T03:30:00+00:00,43.551057196539055 +2026-03-04T03:45:00+00:00,36.1529320024294 +2026-03-04T04:00:00+00:00,41.64111225923044 +2026-03-04T04:15:00+00:00,30.61380662202024 +2026-03-04T04:30:00+00:00,39.0130864823643 +2026-03-04T04:45:00+00:00,52.0462706091123 +2026-03-04T05:00:00+00:00,50.20777519971261 +2026-03-04T05:15:00+00:00,54.063817782031244 +2026-03-04T05:30:00+00:00,52.03111265831647 +2026-03-04T05:45:00+00:00,47.20876581194295 +2026-03-04T06:00:00+00:00,59.6990200389558 +2026-03-04T06:15:00+00:00,50.269858213313995 +2026-03-04T06:30:00+00:00,53.7760335494436 +2026-03-04T06:45:00+00:00,47.41661261998414 +2026-03-04T07:00:00+00:00,49.46450502650333 +2026-03-04T07:15:00+00:00,40.073203184867474 +2026-03-04T07:30:00+00:00,53.853351228275955 +2026-03-04T07:45:00+00:00,38.92149482868015 +2026-03-04T08:00:00+00:00,55.2075546877569 +2026-03-04T08:15:00+00:00,48.156946368948006 +2026-03-04T08:30:00+00:00,62.55499585423137 +2026-03-04T08:45:00+00:00,56.337984712512394 +2026-03-04T09:00:00+00:00,68.85443434499517 +2026-03-04T09:15:00+00:00,60.661990583647594 +2026-03-04T09:30:00+00:00,40.41956468150019 +2026-03-04T09:45:00+00:00,51.73664490422493 +2026-03-04T10:00:00+00:00,46.86033850439983 +2026-03-04T10:15:00+00:00,48.13107948652009 +2026-03-04T10:30:00+00:00,67.28647640721309 +2026-03-04T10:45:00+00:00,60.11468642310674 +2026-03-04T11:00:00+00:00,51.6628087334365 +2026-03-04T11:15:00+00:00,47.41582381602855 +2026-03-04T11:30:00+00:00,55.2190068408135 +2026-03-04T11:45:00+00:00,47.356447714076765 +2026-03-04T12:00:00+00:00,48.87069783660934 +2026-03-04T12:15:00+00:00,58.59946923911052 +2026-03-04T12:30:00+00:00,64.4224193976939 +2026-03-04T12:45:00+00:00,59.74766794325302 +2026-03-04T13:00:00+00:00,33.364071389544335 +2026-03-04T13:15:00+00:00,56.89589149596245 +2026-03-04T13:30:00+00:00,52.79338203139374 +2026-03-04T13:45:00+00:00,58.339849901266845 +2026-03-04T14:00:00+00:00,71.32943479465806 +2026-03-04T14:15:00+00:00,36.35663778097318 +2026-03-04T14:30:00+00:00,50.03753551500603 +2026-03-04T14:45:00+00:00,59.26305316389507 +2026-03-04T15:00:00+00:00,37.11436365053424 +2026-03-04T15:15:00+00:00,64.81728972956613 +2026-03-04T15:30:00+00:00,53.40407958921051 +2026-03-04T15:45:00+00:00,56.448702038306934 +2026-03-04T16:00:00+00:00,47.07335785551079 +2026-03-04T16:15:00+00:00,54.82116029714439 +2026-03-04T16:30:00+00:00,56.35748074974354 +2026-03-04T16:45:00+00:00,51.593425453227916 +2026-03-04T17:00:00+00:00,52.043065882350774 +2026-03-04T17:15:00+00:00,49.24671014809451 +2026-03-04T17:30:00+00:00,40.733038205879886 +2026-03-04T17:45:00+00:00,41.63684882555632 +2026-03-04T18:00:00+00:00,43.57678359761203 +2026-03-04T18:15:00+00:00,48.306967066746246 +2026-03-04T18:30:00+00:00,50.00723177338365 +2026-03-04T18:45:00+00:00,35.86989602776618 +2026-03-04T19:00:00+00:00,41.97599550736018 +2026-03-04T19:15:00+00:00,54.20901650941855 +2026-03-04T19:30:00+00:00,37.59449535443466 +2026-03-04T19:45:00+00:00,36.53012585696412 +2026-03-04T20:00:00+00:00,39.18775625608009 +2026-03-04T20:15:00+00:00,44.88887791876785 +2026-03-04T20:30:00+00:00,40.08048769287507 +2026-03-04T20:45:00+00:00,48.43607395638182 +2026-03-04T21:00:00+00:00,48.763562269931185 +2026-03-04T21:15:00+00:00,46.427880658810615 +2026-03-04T21:30:00+00:00,39.85808072597015 +2026-03-04T21:45:00+00:00,53.794488260535495 +2026-03-04T22:00:00+00:00,32.167564280516764 +2026-03-04T22:15:00+00:00,20.745912032686533 +2026-03-04T22:30:00+00:00,47.38469398484591 +2026-03-04T22:45:00+00:00,30.517384685189924 +2026-03-04T23:00:00+00:00,33.67310311525588 +2026-03-04T23:15:00+00:00,47.44798229884602 +2026-03-04T23:30:00+00:00,20.77356270984744 +2026-03-04T23:45:00+00:00,23.196572563242935 +2026-03-05T00:00:00+00:00,18.416826081471193 +2026-03-05T00:15:00+00:00,29.477831186291656 +2026-03-05T00:30:00+00:00,39.063515075130255 +2026-03-05T00:45:00+00:00,40.84812172679568 +2026-03-05T01:00:00+00:00,40.42195181828335 +2026-03-05T01:15:00+00:00,24.642839149258823 +2026-03-05T01:30:00+00:00,16.59168095697568 +2026-03-05T01:45:00+00:00,33.25853660608627 +2026-03-05T02:00:00+00:00,32.7808499226215 +2026-03-05T02:15:00+00:00,41.065454637415364 +2026-03-05T02:30:00+00:00,43.19614345373765 +2026-03-05T02:45:00+00:00,34.69286474360104 +2026-03-05T03:00:00+00:00,46.48091185434645 +2026-03-05T03:15:00+00:00,41.48256258197427 +2026-03-05T03:30:00+00:00,42.373120091938986 +2026-03-05T03:45:00+00:00,33.23861328472082 +2026-03-05T04:00:00+00:00,34.7226124409591 +2026-03-05T04:15:00+00:00,42.79756547316202 +2026-03-05T04:30:00+00:00,47.050405686125444 +2026-03-05T04:45:00+00:00,36.37484977237831 +2026-03-05T05:00:00+00:00,50.52961234919187 +2026-03-05T05:15:00+00:00,44.012134531696546 +2026-03-05T05:30:00+00:00,44.20215255328632 +2026-03-05T05:45:00+00:00,50.93990789647801 +2026-03-05T06:00:00+00:00,26.572966302098735 +2026-03-05T06:15:00+00:00,35.243854937165025 +2026-03-05T06:30:00+00:00,35.387506505109926 +2026-03-05T06:45:00+00:00,28.06533350716721 +2026-03-05T07:00:00+00:00,42.09154640029576 +2026-03-05T07:15:00+00:00,54.583258879967026 +2026-03-05T07:30:00+00:00,44.26048879318433 +2026-03-05T07:45:00+00:00,52.646508971176544 +2026-03-05T08:00:00+00:00,60.727929868695554 +2026-03-05T08:15:00+00:00,60.316825284681165 +2026-03-05T08:30:00+00:00,49.14580228378245 +2026-03-05T08:45:00+00:00,66.66380731248978 +2026-03-05T09:00:00+00:00,55.090592329008736 +2026-03-05T09:15:00+00:00,41.17930826218962 +2026-03-05T09:30:00+00:00,47.974075156960545 +2026-03-05T09:45:00+00:00,39.430434315460666 +2026-03-05T10:00:00+00:00,44.73165125201548 +2026-03-05T10:15:00+00:00,49.7248754297266 +2026-03-05T10:30:00+00:00,60.753304842138625 +2026-03-05T10:45:00+00:00,67.97165651910181 +2026-03-05T11:00:00+00:00,41.01219337756731 +2026-03-05T11:15:00+00:00,55.777933087142614 +2026-03-05T11:30:00+00:00,42.46973396844887 +2026-03-05T11:45:00+00:00,58.59543705795619 +2026-03-05T12:00:00+00:00,54.48810336752009 +2026-03-05T12:15:00+00:00,42.05361839753256 +2026-03-05T12:30:00+00:00,64.3031002097805 +2026-03-05T12:45:00+00:00,50.16480512466377 +2026-03-05T13:00:00+00:00,49.580340374473394 +2026-03-05T13:15:00+00:00,50.191747443411785 +2026-03-05T13:30:00+00:00,47.151323038703694 +2026-03-05T13:45:00+00:00,54.67825350031131 +2026-03-05T14:00:00+00:00,51.22295703261134 +2026-03-05T14:15:00+00:00,56.333115808580885 +2026-03-05T14:30:00+00:00,58.52901446966993 +2026-03-05T14:45:00+00:00,62.137721871836966 +2026-03-05T15:00:00+00:00,46.0875312359851 +2026-03-05T15:15:00+00:00,41.245031176889555 +2026-03-05T15:30:00+00:00,58.72192891925678 +2026-03-05T15:45:00+00:00,45.76575229386869 +2026-03-05T16:00:00+00:00,57.09124480071495 +2026-03-05T16:15:00+00:00,52.751810613496595 +2026-03-05T16:30:00+00:00,44.24244678057171 +2026-03-05T16:45:00+00:00,53.95353788660077 +2026-03-05T17:00:00+00:00,62.85710000100017 +2026-03-05T17:15:00+00:00,63.65486669330969 +2026-03-05T17:30:00+00:00,48.953156794557565 +2026-03-05T17:45:00+00:00,45.43422791375513 +2026-03-05T18:00:00+00:00,53.72708195109222 +2026-03-05T18:15:00+00:00,37.403245279686004 +2026-03-05T18:30:00+00:00,47.3132794241025 +2026-03-05T18:45:00+00:00,47.72689702212105 +2026-03-05T19:00:00+00:00,42.71955665389204 +2026-03-05T19:15:00+00:00,34.97391989952405 +2026-03-05T19:30:00+00:00,32.68199709881298 +2026-03-05T19:45:00+00:00,22.186468246047593 +2026-03-05T20:00:00+00:00,34.284744108914424 +2026-03-05T20:15:00+00:00,35.929409213479 +2026-03-05T20:30:00+00:00,38.62328830039287 +2026-03-05T20:45:00+00:00,55.02380070750406 +2026-03-05T21:00:00+00:00,47.4108999768683 +2026-03-05T21:15:00+00:00,27.99999764854756 +2026-03-05T21:30:00+00:00,39.54975589478577 +2026-03-05T21:45:00+00:00,34.796188443552886 +2026-03-05T22:00:00+00:00,33.29213564919296 +2026-03-05T22:15:00+00:00,35.34054508658006 +2026-03-05T22:30:00+00:00,38.749891151764274 +2026-03-05T22:45:00+00:00,35.643428709818174 +2026-03-05T23:00:00+00:00,47.04271919007191 +2026-03-05T23:15:00+00:00,24.617803823129744 +2026-03-05T23:30:00+00:00,35.09187892985265 +2026-03-05T23:45:00+00:00,58.28529020895371 +2026-03-06T00:00:00+00:00,37.36911116018999 +2026-03-06T00:15:00+00:00,46.619138549697226 +2026-03-06T00:30:00+00:00,37.05193002702312 +2026-03-06T00:45:00+00:00,37.62081235255363 +2026-03-06T01:00:00+00:00,33.51663327249131 +2026-03-06T01:15:00+00:00,35.69749692718448 +2026-03-06T01:30:00+00:00,29.927923555758284 +2026-03-06T01:45:00+00:00,40.39824579378644 +2026-03-06T02:00:00+00:00,29.710397970122315 +2026-03-06T02:15:00+00:00,39.42842947158378 +2026-03-06T02:30:00+00:00,46.596457906298184 +2026-03-06T02:45:00+00:00,43.01140005543474 +2026-03-06T03:00:00+00:00,36.67213458516295 +2026-03-06T03:15:00+00:00,43.50476231792579 +2026-03-06T03:30:00+00:00,34.56714876948894 +2026-03-06T03:45:00+00:00,46.429995125445004 +2026-03-06T04:00:00+00:00,24.44109877564844 +2026-03-06T04:15:00+00:00,36.88217983528787 +2026-03-06T04:30:00+00:00,24.0087739456301 +2026-03-06T04:45:00+00:00,31.01143055337092 +2026-03-06T05:00:00+00:00,54.42976990496867 +2026-03-06T05:15:00+00:00,51.902807779549086 +2026-03-06T05:30:00+00:00,41.54577313503842 +2026-03-06T05:45:00+00:00,32.67748651935582 +2026-03-06T06:00:00+00:00,22.222309370841597 +2026-03-06T06:15:00+00:00,41.669573524601354 +2026-03-06T06:30:00+00:00,62.444018302349534 +2026-03-06T06:45:00+00:00,48.31395625954831 +2026-03-06T07:00:00+00:00,44.053023523783914 +2026-03-06T07:15:00+00:00,51.06075339968286 +2026-03-06T07:30:00+00:00,36.240929421032654 +2026-03-06T07:45:00+00:00,44.19915971197883 +2026-03-06T08:00:00+00:00,51.374751413095275 +2026-03-06T08:15:00+00:00,49.27714572375274 +2026-03-06T08:30:00+00:00,57.790884268598965 +2026-03-06T08:45:00+00:00,54.73837491509565 +2026-03-06T09:00:00+00:00,54.170740916420456 +2026-03-06T09:15:00+00:00,49.2188214380232 +2026-03-06T09:30:00+00:00,57.58696785351697 +2026-03-06T09:45:00+00:00,65.75103454601022 +2026-03-06T10:00:00+00:00,44.97752149287273 +2026-03-06T10:15:00+00:00,48.93543308961115 +2026-03-06T10:30:00+00:00,65.1451202369619 +2026-03-06T10:45:00+00:00,54.866799538584004 +2026-03-06T11:00:00+00:00,66.30211937042537 +2026-03-06T11:15:00+00:00,51.22429251404306 +2026-03-06T11:30:00+00:00,64.94695639334137 +2026-03-06T11:45:00+00:00,55.09321766659671 +2026-03-06T12:00:00+00:00,57.14547836161659 +2026-03-06T12:15:00+00:00,68.01608963437765 +2026-03-06T12:30:00+00:00,50.73989436594985 +2026-03-06T12:45:00+00:00,50.100354896853204 +2026-03-06T13:00:00+00:00,51.679779120782655 +2026-03-06T13:15:00+00:00,55.875364746768085 +2026-03-06T13:30:00+00:00,44.69323750677266 +2026-03-06T13:45:00+00:00,55.704503015850364 +2026-03-06T14:00:00+00:00,50.14583268771396 +2026-03-06T14:15:00+00:00,61.41493287108311 +2026-03-06T14:30:00+00:00,36.93268244910782 +2026-03-06T14:45:00+00:00,47.90291105884142 +2026-03-06T15:00:00+00:00,36.25447565792649 +2026-03-06T15:15:00+00:00,47.00126250908611 +2026-03-06T15:30:00+00:00,49.03920829932321 +2026-03-06T15:45:00+00:00,50.798607812263384 +2026-03-06T16:00:00+00:00,47.88866159733776 +2026-03-06T16:15:00+00:00,46.33552788150439 +2026-03-06T16:30:00+00:00,52.424065794118235 +2026-03-06T16:45:00+00:00,41.70470862353661 +2026-03-06T17:00:00+00:00,53.63739435584463 +2026-03-06T17:15:00+00:00,52.833016932376495 +2026-03-06T17:30:00+00:00,50.200912753868316 +2026-03-06T17:45:00+00:00,49.40652281301517 +2026-03-06T18:00:00+00:00,48.50061126908232 +2026-03-06T18:15:00+00:00,56.91086036409455 +2026-03-06T18:30:00+00:00,36.241373069456536 +2026-03-06T18:45:00+00:00,41.5277096624571 +2026-03-06T19:00:00+00:00,37.16798787315386 +2026-03-06T19:15:00+00:00,32.19832853349749 +2026-03-06T19:30:00+00:00,29.062917677732944 +2026-03-06T19:45:00+00:00,36.11214115702452 +2026-03-06T20:00:00+00:00,38.41132036972628 +2026-03-06T20:15:00+00:00,40.91370461166131 +2026-03-06T20:30:00+00:00,37.38744693027977 +2026-03-06T20:45:00+00:00,33.88116915105657 +2026-03-06T21:00:00+00:00,40.70980621348207 +2026-03-06T21:15:00+00:00,43.020068323727564 +2026-03-06T21:30:00+00:00,34.35981404910836 +2026-03-06T21:45:00+00:00,31.1744785280336 +2026-03-06T22:00:00+00:00,42.06442186998134 +2026-03-06T22:15:00+00:00,36.561924272735055 +2026-03-06T22:30:00+00:00,23.624284499430008 +2026-03-06T22:45:00+00:00,31.24815338831393 +2026-03-06T23:00:00+00:00,36.34079241778019 +2026-03-06T23:15:00+00:00,25.881907157139064 +2026-03-06T23:30:00+00:00,37.066120761895874 +2026-03-06T23:45:00+00:00,22.911019829342784 +2026-03-07T00:00:00+00:00,44.80729676877353 +2026-03-07T00:15:00+00:00,45.317281096120695 +2026-03-07T00:30:00+00:00,31.264780349284635 +2026-03-07T00:45:00+00:00,35.828352334020735 +2026-03-07T01:00:00+00:00,10.877616153890061 +2026-03-07T01:15:00+00:00,26.12598112716784 +2026-03-07T01:30:00+00:00,33.393998997076864 +2026-03-07T01:45:00+00:00,27.327269366504282 +2026-03-07T02:00:00+00:00,40.87387441395366 +2026-03-07T02:15:00+00:00,54.734745211685286 +2026-03-07T02:30:00+00:00,26.139560843421922 +2026-03-07T02:45:00+00:00,34.33143758257678 +2026-03-07T03:00:00+00:00,39.215717794997886 +2026-03-07T03:15:00+00:00,49.626497441679305 +2026-03-07T03:30:00+00:00,28.317476174360557 +2026-03-07T03:45:00+00:00,41.25898849223676 +2026-03-07T04:00:00+00:00,55.29884066062095 +2026-03-07T04:15:00+00:00,31.081427527201853 +2026-03-07T04:30:00+00:00,31.168548590912433 +2026-03-07T04:45:00+00:00,39.788298970556866 +2026-03-07T05:00:00+00:00,40.05156279611294 +2026-03-07T05:15:00+00:00,50.39470714332451 +2026-03-07T05:30:00+00:00,49.4333751975354 +2026-03-07T05:45:00+00:00,29.445613181857738 +2026-03-07T06:00:00+00:00,47.8421547527946 +2026-03-07T06:15:00+00:00,38.56590272788812 +2026-03-07T06:30:00+00:00,55.92289370347285 +2026-03-07T06:45:00+00:00,37.69267741831412 +2026-03-07T07:00:00+00:00,43.70576984243601 +2026-03-07T07:15:00+00:00,51.114381231845556 +2026-03-07T07:30:00+00:00,56.18959567890853 +2026-03-07T07:45:00+00:00,53.68872338996234 +2026-03-07T08:00:00+00:00,34.081575616663436 +2026-03-07T08:15:00+00:00,59.02593977774845 +2026-03-07T08:30:00+00:00,41.24356043414488 +2026-03-07T08:45:00+00:00,51.74702373607625 +2026-03-07T09:00:00+00:00,39.8247946421679 +2026-03-07T09:15:00+00:00,57.00697837790182 +2026-03-07T09:30:00+00:00,44.29736062586572 +2026-03-07T09:45:00+00:00,44.631084958617805 +2026-03-07T10:00:00+00:00,59.61566394010211 +2026-03-07T10:15:00+00:00,55.50446499446477 +2026-03-07T10:30:00+00:00,47.202080106637396 +2026-03-07T10:45:00+00:00,66.2516819358721 +2026-03-07T11:00:00+00:00,50.563455730956186 +2026-03-07T11:15:00+00:00,50.9754126230859 +2026-03-07T11:30:00+00:00,57.65230126333991 +2026-03-07T11:45:00+00:00,48.111306947014405 +2026-03-07T12:00:00+00:00,58.095722788458026 +2026-03-07T12:15:00+00:00,48.9108617891382 +2026-03-07T12:30:00+00:00,50.87386214879709 +2026-03-07T12:45:00+00:00,64.7437545128247 +2026-03-07T13:00:00+00:00,45.52636237114536 +2026-03-07T13:15:00+00:00,34.207943440193056 +2026-03-07T13:30:00+00:00,65.7186572114476 +2026-03-07T13:45:00+00:00,76.32606172589205 +2026-03-07T14:00:00+00:00,51.248763648796086 +2026-03-07T14:15:00+00:00,38.825282034995524 +2026-03-07T14:30:00+00:00,53.007731502914105 +2026-03-07T14:45:00+00:00,49.08045493128019 +2026-03-07T15:00:00+00:00,51.3492622647722 +2026-03-07T15:15:00+00:00,41.5310315746412 +2026-03-07T15:30:00+00:00,53.95150989217013 +2026-03-07T15:45:00+00:00,57.29519418751412 +2026-03-07T16:00:00+00:00,36.248669670571424 +2026-03-07T16:15:00+00:00,59.8898692971509 +2026-03-07T16:30:00+00:00,69.92715301101136 +2026-03-07T16:45:00+00:00,45.19201476245337 +2026-03-07T17:00:00+00:00,46.651062297322 +2026-03-07T17:15:00+00:00,44.05597146532764 +2026-03-07T17:30:00+00:00,52.295026043394124 +2026-03-07T17:45:00+00:00,32.12063537425023 +2026-03-07T18:00:00+00:00,41.17251771237791 +2026-03-07T18:15:00+00:00,41.378769632924346 +2026-03-07T18:30:00+00:00,61.057089788690924 +2026-03-07T18:45:00+00:00,42.099613942278395 +2026-03-07T19:00:00+00:00,59.48292226959552 +2026-03-07T19:15:00+00:00,31.465667334337006 +2026-03-07T19:30:00+00:00,49.636068785855095 +2026-03-07T19:45:00+00:00,43.06562681171319 +2026-03-07T20:00:00+00:00,30.386515730035228 +2026-03-07T20:15:00+00:00,41.93505623079177 +2026-03-07T20:30:00+00:00,52.01395406702264 +2026-03-07T20:45:00+00:00,36.17097568004722 +2026-03-07T21:00:00+00:00,24.832663958704458 +2026-03-07T21:15:00+00:00,37.548453083690205 +2026-03-07T21:30:00+00:00,25.46649475886875 +2026-03-07T21:45:00+00:00,38.00873540287076 +2026-03-07T22:00:00+00:00,39.49381637922206 +2026-03-07T22:15:00+00:00,25.383153688046107 +2026-03-07T22:30:00+00:00,23.698166140298202 +2026-03-07T22:45:00+00:00,39.59108310680868 +2026-03-07T23:00:00+00:00,30.528484171816896 +2026-03-07T23:15:00+00:00,17.843666317589946 +2026-03-07T23:30:00+00:00,43.01783774573206 +2026-03-07T23:45:00+00:00,34.885474328191485 +2026-03-08T00:00:00+00:00,28.363720067632986 +2026-03-08T00:15:00+00:00,25.19832508792786 +2026-03-08T00:30:00+00:00,40.6755853754189 +2026-03-08T00:45:00+00:00,39.39787448167534 +2026-03-08T01:00:00+00:00,58.442988093367816 +2026-03-08T01:15:00+00:00,42.906261963610824 +2026-03-08T01:30:00+00:00,40.27024120039422 +2026-03-08T01:45:00+00:00,32.92245788727267 +2026-03-08T02:00:00+00:00,44.849311115627486 +2026-03-08T02:15:00+00:00,39.25031963829991 +2026-03-08T02:30:00+00:00,46.11519791125637 +2026-03-08T02:45:00+00:00,31.955633701216353 +2026-03-08T03:00:00+00:00,34.002670091616494 +2026-03-08T03:15:00+00:00,35.24111268480128 +2026-03-08T03:30:00+00:00,47.4182626449027 +2026-03-08T03:45:00+00:00,52.07923769852896 +2026-03-08T04:00:00+00:00,33.05903619233604 +2026-03-08T04:15:00+00:00,38.44929691605427 +2026-03-08T04:30:00+00:00,45.31798792754044 +2026-03-08T04:45:00+00:00,41.524601581084234 +2026-03-08T05:00:00+00:00,48.7016011284588 +2026-03-08T05:15:00+00:00,24.71729433465489 +2026-03-08T05:30:00+00:00,36.215477785658514 +2026-03-08T05:45:00+00:00,39.85553416855074 +2026-03-08T06:00:00+00:00,22.715643736340063 +2026-03-08T06:15:00+00:00,37.01599340889763 +2026-03-08T06:30:00+00:00,41.7056998322641 +2026-03-08T06:45:00+00:00,44.40662463874295 +2026-03-08T07:00:00+00:00,59.227201588952106 +2026-03-08T07:15:00+00:00,47.90580753626661 +2026-03-08T07:30:00+00:00,36.794872379993315 +2026-03-08T07:45:00+00:00,47.229350968123455 +2026-03-08T08:00:00+00:00,62.267185017678216 +2026-03-08T08:15:00+00:00,39.31431276379406 +2026-03-08T08:30:00+00:00,42.583646215015854 +2026-03-08T08:45:00+00:00,56.271732497758684 +2026-03-08T09:00:00+00:00,50.82427074944324 +2026-03-08T09:15:00+00:00,60.99543665814146 +2026-03-08T09:30:00+00:00,50.854507227167446 +2026-03-08T09:45:00+00:00,59.18231412204034 +2026-03-08T10:00:00+00:00,46.90858741519592 +2026-03-08T10:15:00+00:00,53.23918113205319 +2026-03-08T10:30:00+00:00,50.73420397428182 +2026-03-08T10:45:00+00:00,43.806206161682375 +2026-03-08T11:00:00+00:00,39.70559174839611 +2026-03-08T11:15:00+00:00,60.25589339967826 +2026-03-08T11:30:00+00:00,54.27399408912285 +2026-03-08T11:45:00+00:00,45.75157966731728 +2026-03-08T12:00:00+00:00,55.56243478530394 +2026-03-08T12:15:00+00:00,59.93568269661013 +2026-03-08T12:30:00+00:00,32.90973621323006 +2026-03-08T12:45:00+00:00,45.31946371945393 +2026-03-08T13:00:00+00:00,46.903905160600516 +2026-03-08T13:15:00+00:00,66.9974021582995 +2026-03-08T13:30:00+00:00,53.49183230738331 +2026-03-08T13:45:00+00:00,58.16610016091472 +2026-03-08T14:00:00+00:00,46.874519870267946 +2026-03-08T14:15:00+00:00,44.23937742544926 +2026-03-08T14:30:00+00:00,57.770413338539534 +2026-03-08T14:45:00+00:00,53.00559697478303 +2026-03-08T15:00:00+00:00,58.78101733896274 +2026-03-08T15:15:00+00:00,48.94782670768911 +2026-03-08T15:30:00+00:00,48.66341404454001 +2026-03-08T15:45:00+00:00,49.86411224010106 +2026-03-08T16:00:00+00:00,57.29459013687912 +2026-03-08T16:15:00+00:00,56.375281406847336 +2026-03-08T16:30:00+00:00,35.30036059453027 +2026-03-08T16:45:00+00:00,32.573123152492734 +2026-03-08T17:00:00+00:00,54.35422822377598 +2026-03-08T17:15:00+00:00,54.0774798185478 +2026-03-08T17:30:00+00:00,39.8607117986913 +2026-03-08T17:45:00+00:00,29.347602713023736 +2026-03-08T18:00:00+00:00,46.23139722144345 +2026-03-08T18:15:00+00:00,48.84074152205776 +2026-03-08T18:30:00+00:00,55.04541161122137 +2026-03-08T18:45:00+00:00,45.822271217207415 +2026-03-08T19:00:00+00:00,43.85976121883499 +2026-03-08T19:15:00+00:00,35.56208874886025 +2026-03-08T19:30:00+00:00,40.67798482043274 +2026-03-08T19:45:00+00:00,37.3331754646984 +2026-03-08T20:00:00+00:00,32.32730248526187 +2026-03-08T20:15:00+00:00,56.09172254959937 +2026-03-08T20:30:00+00:00,54.61859896713024 +2026-03-08T20:45:00+00:00,51.57120522401512 +2026-03-08T21:00:00+00:00,29.893273823363238 +2026-03-08T21:15:00+00:00,49.63125625771183 +2026-03-08T21:30:00+00:00,38.310550821796376 +2026-03-08T21:45:00+00:00,40.666996858969874 +2026-03-08T22:00:00+00:00,44.86766142079054 +2026-03-08T22:15:00+00:00,42.16216379483279 +2026-03-08T22:30:00+00:00,38.988156198541446 +2026-03-08T22:45:00+00:00,38.95806579417356 +2026-03-08T23:00:00+00:00,38.127130238917616 +2026-03-08T23:15:00+00:00,18.16428481955623 +2026-03-08T23:30:00+00:00,42.05016257203033 +2026-03-08T23:45:00+00:00,32.90905691726263 +2026-03-09T00:00:00+00:00,26.417641512989096 +2026-03-09T00:15:00+00:00,49.26585915101614 +2026-03-09T00:30:00+00:00,49.93506241287615 +2026-03-09T00:45:00+00:00,45.772485208547025 +2026-03-09T01:00:00+00:00,37.885376213258546 +2026-03-09T01:15:00+00:00,45.8593526873493 +2026-03-09T01:30:00+00:00,33.74073908702281 +2026-03-09T01:45:00+00:00,37.275779392766985 +2026-03-09T02:00:00+00:00,30.51276818554157 +2026-03-09T02:15:00+00:00,40.52157353281566 +2026-03-09T02:30:00+00:00,40.837351300477685 +2026-03-09T02:45:00+00:00,29.103874750284145 +2026-03-09T03:00:00+00:00,42.10836733081543 +2026-03-09T03:15:00+00:00,38.23576641477649 +2026-03-09T03:30:00+00:00,53.648534337441454 +2026-03-09T03:45:00+00:00,45.783414417083534 +2026-03-09T04:00:00+00:00,42.5665516835394 +2026-03-09T04:15:00+00:00,41.72831957368519 +2026-03-09T04:30:00+00:00,42.853376067834006 +2026-03-09T04:45:00+00:00,36.74961574007391 +2026-03-09T05:00:00+00:00,32.839131954623916 +2026-03-09T05:15:00+00:00,44.75582639248339 +2026-03-09T05:30:00+00:00,38.79662906849623 +2026-03-09T05:45:00+00:00,37.08612908708534 +2026-03-09T06:00:00+00:00,48.0763260646784 +2026-03-09T06:15:00+00:00,48.35796935288759 +2026-03-09T06:30:00+00:00,28.643051178539952 +2026-03-09T06:45:00+00:00,45.97142465162576 +2026-03-09T07:00:00+00:00,57.23574350594355 +2026-03-09T07:15:00+00:00,56.25124823272048 +2026-03-09T07:30:00+00:00,57.244357189596556 +2026-03-09T07:45:00+00:00,47.83883809803613 +2026-03-09T08:00:00+00:00,41.54311401244965 +2026-03-09T08:15:00+00:00,41.9190863469799 +2026-03-09T08:30:00+00:00,55.423970987101555 +2026-03-09T08:45:00+00:00,53.9121913854423 +2026-03-09T09:00:00+00:00,63.80342442193792 +2026-03-09T09:15:00+00:00,63.6616094500496 +2026-03-09T09:30:00+00:00,50.92952508089907 +2026-03-09T09:45:00+00:00,41.052452648956525 +2026-03-09T10:00:00+00:00,46.6028846895558 +2026-03-09T10:15:00+00:00,56.209434485621635 +2026-03-09T10:30:00+00:00,51.162020749787615 +2026-03-09T10:45:00+00:00,50.02835939849439 +2026-03-09T11:00:00+00:00,55.3402933319403 +2026-03-09T11:15:00+00:00,50.280686609497074 +2026-03-09T11:30:00+00:00,50.66307374932975 +2026-03-09T11:45:00+00:00,56.74335405548898 +2026-03-09T12:00:00+00:00,53.841730225915505 +2026-03-09T12:15:00+00:00,55.351280067770176 +2026-03-09T12:30:00+00:00,55.82676823471338 +2026-03-09T12:45:00+00:00,44.87361913784116 +2026-03-09T13:00:00+00:00,51.67621575484416 +2026-03-09T13:15:00+00:00,63.77534911377781 +2026-03-09T13:30:00+00:00,47.246509198936266 +2026-03-09T13:45:00+00:00,33.44990943403064 +2026-03-09T14:00:00+00:00,38.4152067436735 +2026-03-09T14:15:00+00:00,54.24080374262115 +2026-03-09T14:30:00+00:00,50.16231130207983 +2026-03-09T14:45:00+00:00,52.31005956428927 +2026-03-09T15:00:00+00:00,63.44247331004822 +2026-03-09T15:15:00+00:00,30.678704679730433 +2026-03-09T15:30:00+00:00,54.11791539769849 +2026-03-09T15:45:00+00:00,62.86760220383126 +2026-03-09T16:00:00+00:00,40.93631514265265 +2026-03-09T16:15:00+00:00,48.292184046391796 +2026-03-09T16:30:00+00:00,42.27607099591017 +2026-03-09T16:45:00+00:00,40.07353003500843 +2026-03-09T17:00:00+00:00,46.71821254825278 +2026-03-09T17:15:00+00:00,59.556896464594125 +2026-03-09T17:30:00+00:00,63.11390006647247 +2026-03-09T17:45:00+00:00,40.691153459640766 +2026-03-09T18:00:00+00:00,62.474691237118904 +2026-03-09T18:15:00+00:00,44.28472005718871 +2026-03-09T18:30:00+00:00,55.870900973361096 +2026-03-09T18:45:00+00:00,43.83844008237727 +2026-03-09T19:00:00+00:00,44.12646561525341 +2026-03-09T19:15:00+00:00,42.54172188243905 +2026-03-09T19:30:00+00:00,66.13330985402983 +2026-03-09T19:45:00+00:00,48.665630809685254 +2026-03-09T20:00:00+00:00,30.797046418604307 +2026-03-09T20:15:00+00:00,48.28721079493266 +2026-03-09T20:30:00+00:00,35.59918405492927 +2026-03-09T20:45:00+00:00,35.51855329650384 +2026-03-09T21:00:00+00:00,40.743317755303984 +2026-03-09T21:15:00+00:00,40.92678450082464 +2026-03-09T21:30:00+00:00,38.06979628884224 +2026-03-09T21:45:00+00:00,35.62794893688961 +2026-03-09T22:00:00+00:00,38.552374562072956 +2026-03-09T22:15:00+00:00,40.52933447457968 +2026-03-09T22:30:00+00:00,19.980771488527363 +2026-03-09T22:45:00+00:00,43.97165890735523 +2026-03-09T23:00:00+00:00,27.013617984324632 +2026-03-09T23:15:00+00:00,26.943208914175344 +2026-03-09T23:30:00+00:00,34.211860593226916 +2026-03-09T23:45:00+00:00,35.70605830765946 +2026-03-10T00:00:00+00:00,26.268058147260422 +2026-03-10T00:15:00+00:00,40.05929361390453 +2026-03-10T00:30:00+00:00,24.93778371042787 +2026-03-10T00:45:00+00:00,29.029358554245455 +2026-03-10T01:00:00+00:00,34.04094279727453 +2026-03-10T01:15:00+00:00,35.58759721942574 +2026-03-10T01:30:00+00:00,37.2781632513666 +2026-03-10T01:45:00+00:00,27.789188294547657 +2026-03-10T02:00:00+00:00,45.48250509688269 +2026-03-10T02:15:00+00:00,38.75316381181703 +2026-03-10T02:30:00+00:00,18.13096210015784 +2026-03-10T02:45:00+00:00,20.97561576846328 +2026-03-10T03:00:00+00:00,39.66761945700769 +2026-03-10T03:15:00+00:00,34.93647555287735 +2026-03-10T03:30:00+00:00,38.80014228486966 +2026-03-10T03:45:00+00:00,34.67424716387692 +2026-03-10T04:00:00+00:00,40.62712190222676 +2026-03-10T04:15:00+00:00,48.37886092744381 +2026-03-10T04:30:00+00:00,33.73657493075166 +2026-03-10T04:45:00+00:00,31.444132323629425 +2026-03-10T05:00:00+00:00,35.518546235356496 +2026-03-10T05:15:00+00:00,42.38224827927985 +2026-03-10T05:30:00+00:00,53.28049800845673 +2026-03-10T05:45:00+00:00,41.263912907820355 +2026-03-10T06:00:00+00:00,21.841829944890517 +2026-03-10T06:15:00+00:00,34.60738829000124 +2026-03-10T06:30:00+00:00,40.224473782480864 +2026-03-10T06:45:00+00:00,40.274091371049245 +2026-03-10T07:00:00+00:00,45.42769316503145 +2026-03-10T07:15:00+00:00,46.84847575320208 +2026-03-10T07:30:00+00:00,45.02883753595931 +2026-03-10T07:45:00+00:00,55.07457527371236 +2026-03-10T08:00:00+00:00,43.8683907029797 +2026-03-10T08:15:00+00:00,39.25892714082684 +2026-03-10T08:30:00+00:00,55.34990373368915 +2026-03-10T08:45:00+00:00,43.012638048511995 +2026-03-10T09:00:00+00:00,54.29596974285703 +2026-03-10T09:15:00+00:00,48.72766618950523 +2026-03-10T09:30:00+00:00,50.793533851535344 +2026-03-10T09:45:00+00:00,65.88125396723508 +2026-03-10T10:00:00+00:00,48.98624435550398 +2026-03-10T10:15:00+00:00,70.31858739193017 +2026-03-10T10:30:00+00:00,55.47952094099845 +2026-03-10T10:45:00+00:00,43.87562387864212 +2026-03-10T11:00:00+00:00,54.424729206611694 +2026-03-10T11:15:00+00:00,46.895520349386814 +2026-03-10T11:30:00+00:00,49.789464972274686 +2026-03-10T11:45:00+00:00,57.727026492837766 +2026-03-10T12:00:00+00:00,58.95623157176504 +2026-03-10T12:15:00+00:00,48.6978799914055 +2026-03-10T12:30:00+00:00,64.27186226311629 +2026-03-10T12:45:00+00:00,60.957371357574765 +2026-03-10T13:00:00+00:00,66.38305078343151 +2026-03-10T13:15:00+00:00,60.4797255048022 +2026-03-10T13:30:00+00:00,65.03274750530392 +2026-03-10T13:45:00+00:00,35.43710257112066 +2026-03-10T14:00:00+00:00,50.78727281176043 +2026-03-10T14:15:00+00:00,47.83244222295873 +2026-03-10T14:30:00+00:00,54.34181898061292 +2026-03-10T14:45:00+00:00,45.77806993369585 +2026-03-10T15:00:00+00:00,55.312719990112676 +2026-03-10T15:15:00+00:00,68.51216530782024 +2026-03-10T15:30:00+00:00,50.03481564945959 +2026-03-10T15:45:00+00:00,59.41178291045491 +2026-03-10T16:00:00+00:00,47.55653992489969 +2026-03-10T16:15:00+00:00,47.004111959398536 +2026-03-10T16:30:00+00:00,48.54062520252895 +2026-03-10T16:45:00+00:00,51.93596115709134 +2026-03-10T17:00:00+00:00,53.76382562107359 +2026-03-10T17:15:00+00:00,61.51297501921026 +2026-03-10T17:30:00+00:00,50.75280870215644 +2026-03-10T17:45:00+00:00,47.52366494043951 +2026-03-10T18:00:00+00:00,34.49005059136357 +2026-03-10T18:15:00+00:00,41.191194562170544 +2026-03-10T18:30:00+00:00,48.605775877815034 +2026-03-10T18:45:00+00:00,50.85735598449363 +2026-03-10T19:00:00+00:00,44.285081453695014 +2026-03-10T19:15:00+00:00,48.43394183356405 +2026-03-10T19:30:00+00:00,45.71546875553836 +2026-03-10T19:45:00+00:00,49.40206590669339 +2026-03-10T20:00:00+00:00,41.00928949934757 +2026-03-10T20:15:00+00:00,35.79893306080307 +2026-03-10T20:30:00+00:00,41.09969711942282 +2026-03-10T20:45:00+00:00,14.997561567558785 +2026-03-10T21:00:00+00:00,42.014119337585036 +2026-03-10T21:15:00+00:00,6.8266591025246885 +2026-03-10T21:30:00+00:00,22.691269760394015 +2026-03-10T21:45:00+00:00,39.145522548485836 +2026-03-10T22:00:00+00:00,38.90643291790883 +2026-03-10T22:15:00+00:00,28.31164555294775 +2026-03-10T22:30:00+00:00,28.6450515445829 +2026-03-10T22:45:00+00:00,46.18357951837081 +2026-03-10T23:00:00+00:00,29.423303448202997 +2026-03-10T23:15:00+00:00,54.54186509584787 +2026-03-10T23:30:00+00:00,37.27233516577623 +2026-03-10T23:45:00+00:00,38.62497248623811 +2026-03-11T00:00:00+00:00,51.808204183617136 +2026-03-11T00:15:00+00:00,31.8520061003354 +2026-03-11T00:30:00+00:00,25.678336218024064 +2026-03-11T00:45:00+00:00,35.62490868662795 +2026-03-11T01:00:00+00:00,31.93787111701272 +2026-03-11T01:15:00+00:00,24.275445438474833 +2026-03-11T01:30:00+00:00,33.85720014509147 +2026-03-11T01:45:00+00:00,29.595369249540756 +2026-03-11T02:00:00+00:00,43.64353756840996 +2026-03-11T02:15:00+00:00,38.66102824318339 +2026-03-11T02:30:00+00:00,36.9704046374428 +2026-03-11T02:45:00+00:00,33.809102051014676 +2026-03-11T03:00:00+00:00,37.06870547928644 +2026-03-11T03:15:00+00:00,44.228993358754295 +2026-03-11T03:30:00+00:00,28.560753124402073 +2026-03-11T03:45:00+00:00,34.93285030165267 +2026-03-11T04:00:00+00:00,48.63100497144052 +2026-03-11T04:15:00+00:00,37.73941321239446 +2026-03-11T04:30:00+00:00,29.485324814967726 +2026-03-11T04:45:00+00:00,44.60048227355667 +2026-03-11T05:00:00+00:00,44.653855397137555 +2026-03-11T05:15:00+00:00,34.17942255617771 +2026-03-11T05:30:00+00:00,46.25989810270946 +2026-03-11T05:45:00+00:00,46.1955872462089 +2026-03-11T06:00:00+00:00,46.721800451598384 +2026-03-11T06:15:00+00:00,51.77099601538145 +2026-03-11T06:30:00+00:00,35.91050043928776 +2026-03-11T06:45:00+00:00,52.055746108834505 +2026-03-11T07:00:00+00:00,45.704745586925185 +2026-03-11T07:15:00+00:00,43.84312089419349 +2026-03-11T07:30:00+00:00,55.44153597621385 +2026-03-11T07:45:00+00:00,61.840077221144114 +2026-03-11T08:00:00+00:00,65.44296497659474 +2026-03-11T08:15:00+00:00,59.79413692006744 +2026-03-11T08:30:00+00:00,49.55794389927371 +2026-03-11T08:45:00+00:00,55.55444404032715 +2026-03-11T09:00:00+00:00,58.4933381374308 +2026-03-11T09:15:00+00:00,53.27792328104551 +2026-03-11T09:30:00+00:00,55.65826674811973 +2026-03-11T09:45:00+00:00,58.42233278220096 +2026-03-11T10:00:00+00:00,54.39521082819498 +2026-03-11T10:15:00+00:00,50.5813131783535 +2026-03-11T10:30:00+00:00,50.908567894752345 +2026-03-11T10:45:00+00:00,59.36019238732089 +2026-03-11T11:00:00+00:00,52.56890628476979 +2026-03-11T11:15:00+00:00,56.795822873789135 +2026-03-11T11:30:00+00:00,58.952061076373674 +2026-03-11T11:45:00+00:00,53.97952757992027 +2026-03-11T12:00:00+00:00,60.529209662043904 +2026-03-11T12:15:00+00:00,57.43999211374453 +2026-03-11T12:30:00+00:00,47.197654787305275 +2026-03-11T12:45:00+00:00,66.19217487795093 +2026-03-11T13:00:00+00:00,51.75679961675738 +2026-03-11T13:15:00+00:00,40.509841284397886 +2026-03-11T13:30:00+00:00,46.651952140303116 +2026-03-11T13:45:00+00:00,45.44848216078515 +2026-03-11T14:00:00+00:00,56.6497681469537 +2026-03-11T14:15:00+00:00,51.58759956488797 +2026-03-11T14:30:00+00:00,47.43139821356567 +2026-03-11T14:45:00+00:00,56.50182235728465 +2026-03-11T15:00:00+00:00,52.26307293423172 +2026-03-11T15:15:00+00:00,55.20065412407244 +2026-03-11T15:30:00+00:00,55.93893893566772 +2026-03-11T15:45:00+00:00,55.99916019557628 +2026-03-11T16:00:00+00:00,32.12594475209015 +2026-03-11T16:15:00+00:00,44.22971058897302 +2026-03-11T16:30:00+00:00,31.29266218793535 +2026-03-11T16:45:00+00:00,47.169631796503296 +2026-03-11T17:00:00+00:00,49.411036832555965 +2026-03-11T17:15:00+00:00,54.535159226049345 +2026-03-11T17:30:00+00:00,56.72022624479798 +2026-03-11T17:45:00+00:00,46.316685763016075 +2026-03-11T18:00:00+00:00,43.184776160876964 +2026-03-11T18:15:00+00:00,46.19561365561253 +2026-03-11T18:30:00+00:00,38.76165038422631 +2026-03-11T18:45:00+00:00,44.198181167428956 +2026-03-11T19:00:00+00:00,53.65325533128047 +2026-03-11T19:15:00+00:00,34.46078307432585 +2026-03-11T19:30:00+00:00,45.07582962036673 +2026-03-11T19:45:00+00:00,46.936807778393394 +2026-03-11T20:00:00+00:00,35.520701732650004 +2026-03-11T20:15:00+00:00,48.91568532053838 +2026-03-11T20:30:00+00:00,45.9337971241635 +2026-03-11T20:45:00+00:00,37.08804856688137 +2026-03-11T21:00:00+00:00,32.35424115807585 +2026-03-11T21:15:00+00:00,31.630055752474377 +2026-03-11T21:30:00+00:00,33.19065994206722 +2026-03-11T21:45:00+00:00,41.634770325778476 +2026-03-11T22:00:00+00:00,30.20248637057474 +2026-03-11T22:15:00+00:00,34.66124590611088 +2026-03-11T22:30:00+00:00,21.752734308346703 +2026-03-11T22:45:00+00:00,31.518856505115885 +2026-03-11T23:00:00+00:00,36.39073586661323 +2026-03-11T23:15:00+00:00,53.974404734980425 +2026-03-11T23:30:00+00:00,21.39994259851424 +2026-03-11T23:45:00+00:00,50.03753021294439 +2026-03-12T00:00:00+00:00,35.78343256326953 +2026-03-12T00:15:00+00:00,38.70360302741308 +2026-03-12T00:30:00+00:00,37.90036398119704 +2026-03-12T00:45:00+00:00,43.00989519395751 +2026-03-12T01:00:00+00:00,33.99251570527545 +2026-03-12T01:15:00+00:00,47.533863661417634 +2026-03-12T01:30:00+00:00,29.618850891465428 +2026-03-12T01:45:00+00:00,44.99364883266696 +2026-03-12T02:00:00+00:00,28.663499090564592 +2026-03-12T02:15:00+00:00,35.29638770849198 +2026-03-12T02:30:00+00:00,30.01483745037246 +2026-03-12T02:45:00+00:00,41.83725424461374 +2026-03-12T03:00:00+00:00,51.38464542066643 +2026-03-12T03:15:00+00:00,38.290244112591694 +2026-03-12T03:30:00+00:00,39.66836916528001 +2026-03-12T03:45:00+00:00,28.56275128752454 +2026-03-12T04:00:00+00:00,40.59879038644852 +2026-03-12T04:15:00+00:00,26.690755940321854 +2026-03-12T04:30:00+00:00,33.11517351814594 +2026-03-12T04:45:00+00:00,42.97383926884525 +2026-03-12T05:00:00+00:00,56.62757209391698 +2026-03-12T05:15:00+00:00,64.01215187014012 +2026-03-12T05:30:00+00:00,39.964533576709435 +2026-03-12T05:45:00+00:00,56.66524249100477 +2026-03-12T06:00:00+00:00,47.144259967194955 +2026-03-12T06:15:00+00:00,42.862873453746815 +2026-03-12T06:30:00+00:00,47.47366399760732 +2026-03-12T06:45:00+00:00,45.14187560005816 +2026-03-12T07:00:00+00:00,50.64557304364556 +2026-03-12T07:15:00+00:00,43.365287777320724 +2026-03-12T07:30:00+00:00,35.21681337079551 +2026-03-12T07:45:00+00:00,38.981810872483805 +2026-03-12T08:00:00+00:00,56.45204275757354 +2026-03-12T08:15:00+00:00,48.26689824046999 +2026-03-12T08:30:00+00:00,45.2533769699823 +2026-03-12T08:45:00+00:00,46.17911094923452 +2026-03-12T09:00:00+00:00,56.85538264172427 +2026-03-12T09:15:00+00:00,53.59912505622726 +2026-03-12T09:30:00+00:00,45.51623030198768 +2026-03-12T09:45:00+00:00,60.95900505091151 +2026-03-12T10:00:00+00:00,70.38292372329202 +2026-03-12T10:15:00+00:00,54.489272013337825 +2026-03-12T10:30:00+00:00,62.59071655205845 +2026-03-12T10:45:00+00:00,62.770783245579665 +2026-03-12T11:00:00+00:00,64.04598009437795 +2026-03-12T11:15:00+00:00,57.2509949709306 +2026-03-12T11:30:00+00:00,62.052895437366814 +2026-03-12T11:45:00+00:00,58.41963244652704 +2026-03-12T12:00:00+00:00,49.59444466892284 +2026-03-12T12:15:00+00:00,63.27463646253882 +2026-03-12T12:30:00+00:00,42.19974699779604 +2026-03-12T12:45:00+00:00,46.386138592313884 +2026-03-12T13:00:00+00:00,62.417586943544535 +2026-03-12T13:15:00+00:00,59.15123305533803 +2026-03-12T13:30:00+00:00,68.55401343470001 +2026-03-12T13:45:00+00:00,55.00906510650038 +2026-03-12T14:00:00+00:00,43.211424630876394 +2026-03-12T14:15:00+00:00,53.53248718496118 +2026-03-12T14:30:00+00:00,53.60630811757015 +2026-03-12T14:45:00+00:00,52.56376729160515 +2026-03-12T15:00:00+00:00,60.8067639414343 +2026-03-12T15:15:00+00:00,50.25190278192849 +2026-03-12T15:30:00+00:00,49.83952237291349 +2026-03-12T15:45:00+00:00,41.873686898361974 +2026-03-12T16:00:00+00:00,52.33478093043451 +2026-03-12T16:15:00+00:00,51.055129173199425 +2026-03-12T16:30:00+00:00,44.296363444674796 +2026-03-12T16:45:00+00:00,48.33213561605703 +2026-03-12T17:00:00+00:00,47.38026216685441 +2026-03-12T17:15:00+00:00,45.48968419831034 +2026-03-12T17:30:00+00:00,58.818504037727045 +2026-03-12T17:45:00+00:00,45.37969233331932 +2026-03-12T18:00:00+00:00,42.29612114887275 +2026-03-12T18:15:00+00:00,26.505179359677836 +2026-03-12T18:30:00+00:00,32.071690825823886 +2026-03-12T18:45:00+00:00,43.10630786057522 +2026-03-12T19:00:00+00:00,34.968083579027464 +2026-03-12T19:15:00+00:00,52.76607916610818 +2026-03-12T19:30:00+00:00,43.65124029081402 +2026-03-12T19:45:00+00:00,52.70022740651828 +2026-03-12T20:00:00+00:00,39.54649627069685 +2026-03-12T20:15:00+00:00,44.76794311073344 +2026-03-12T20:30:00+00:00,40.30371625419443 +2026-03-12T20:45:00+00:00,36.75602686549374 +2026-03-12T21:00:00+00:00,50.76792224971162 +2026-03-12T21:15:00+00:00,21.02619412545681 +2026-03-12T21:30:00+00:00,22.75690695559787 +2026-03-12T21:45:00+00:00,33.17150755394783 +2026-03-12T22:00:00+00:00,34.04455624575625 +2026-03-12T22:15:00+00:00,41.37284163343154 +2026-03-12T22:30:00+00:00,41.26678283189721 +2026-03-12T22:45:00+00:00,36.289102722557836 +2026-03-12T23:00:00+00:00,43.83878014203256 +2026-03-12T23:15:00+00:00,43.05590742120395 +2026-03-12T23:30:00+00:00,36.13946114172582 +2026-03-12T23:45:00+00:00,43.76891023816742 +2026-03-13T00:00:00+00:00,33.55794803349606 +2026-03-13T00:15:00+00:00,29.732713509830546 +2026-03-13T00:30:00+00:00,40.43120280538723 +2026-03-13T00:45:00+00:00,36.81054756878575 +2026-03-13T01:00:00+00:00,35.22961675010479 +2026-03-13T01:15:00+00:00,46.8308559933659 +2026-03-13T01:30:00+00:00,15.159940263616681 +2026-03-13T01:45:00+00:00,24.852525888036038 +2026-03-13T02:00:00+00:00,27.91224959314393 +2026-03-13T02:15:00+00:00,34.56317840971204 +2026-03-13T02:30:00+00:00,37.02889691273623 +2026-03-13T02:45:00+00:00,24.59462528287427 +2026-03-13T03:00:00+00:00,31.292738886851716 +2026-03-13T03:15:00+00:00,28.479881659305168 +2026-03-13T03:30:00+00:00,57.92855528486452 +2026-03-13T03:45:00+00:00,56.729627110483854 +2026-03-13T04:00:00+00:00,34.94503908827334 +2026-03-13T04:15:00+00:00,39.09986725988893 +2026-03-13T04:30:00+00:00,30.73622837825154 +2026-03-13T04:45:00+00:00,26.798915293409433 +2026-03-13T05:00:00+00:00,38.41442123356328 +2026-03-13T05:15:00+00:00,38.6191009821114 +2026-03-13T05:30:00+00:00,46.067094669328355 +2026-03-13T05:45:00+00:00,30.130346389217852 +2026-03-13T06:00:00+00:00,36.88525544266822 +2026-03-13T06:15:00+00:00,43.63934842453843 +2026-03-13T06:30:00+00:00,49.72506657608618 +2026-03-13T06:45:00+00:00,50.74529163977685 +2026-03-13T07:00:00+00:00,38.22993711020444 +2026-03-13T07:15:00+00:00,55.98785425843534 +2026-03-13T07:30:00+00:00,40.511179820384946 +2026-03-13T07:45:00+00:00,56.69832231712224 +2026-03-13T08:00:00+00:00,53.11200821648593 +2026-03-13T08:15:00+00:00,38.98425281775383 +2026-03-13T08:30:00+00:00,47.44089545540876 +2026-03-13T08:45:00+00:00,54.13418475176445 +2026-03-13T09:00:00+00:00,56.25467164345686 +2026-03-13T09:15:00+00:00,53.199045292834036 +2026-03-13T09:30:00+00:00,67.06170595836285 +2026-03-13T09:45:00+00:00,62.67774642848639 +2026-03-13T10:00:00+00:00,51.04671228772734 +2026-03-13T10:15:00+00:00,60.12638846129448 +2026-03-13T10:30:00+00:00,71.80877169950206 +2026-03-13T10:45:00+00:00,71.46819425741029 +2026-03-13T11:00:00+00:00,45.321232296879735 +2026-03-13T11:15:00+00:00,48.152498058305014 +2026-03-13T11:30:00+00:00,69.14577408555344 +2026-03-13T11:45:00+00:00,45.83409224961855 +2026-03-13T12:00:00+00:00,42.436436872633514 +2026-03-13T12:15:00+00:00,50.45123882483892 +2026-03-13T12:30:00+00:00,62.12873694620789 +2026-03-13T12:45:00+00:00,52.453545998239406 +2026-03-13T13:00:00+00:00,51.96279838412405 +2026-03-13T13:15:00+00:00,46.93314901498596 +2026-03-13T13:30:00+00:00,48.665601337344775 +2026-03-13T13:45:00+00:00,48.327225206719774 +2026-03-13T14:00:00+00:00,71.21896955293727 +2026-03-13T14:15:00+00:00,54.48119205838161 +2026-03-13T14:30:00+00:00,60.911365262852414 +2026-03-13T14:45:00+00:00,46.58503402490427 +2026-03-13T15:00:00+00:00,52.817613103831164 +2026-03-13T15:15:00+00:00,44.76390494382967 +2026-03-13T15:30:00+00:00,30.912003836356014 +2026-03-13T15:45:00+00:00,38.412873245005585 +2026-03-13T16:00:00+00:00,35.33002456671242 +2026-03-13T16:15:00+00:00,31.19895562299154 +2026-03-13T16:30:00+00:00,41.28381496068188 +2026-03-13T16:45:00+00:00,60.04217177960933 +2026-03-13T17:00:00+00:00,44.98120513023054 +2026-03-13T17:15:00+00:00,54.43950084133158 +2026-03-13T17:30:00+00:00,47.28496383724485 +2026-03-13T17:45:00+00:00,50.285827642944795 +2026-03-13T18:00:00+00:00,39.93583765134424 +2026-03-13T18:15:00+00:00,48.901394696238654 +2026-03-13T18:30:00+00:00,51.75486187676706 +2026-03-13T18:45:00+00:00,36.89283586750903 +2026-03-13T19:00:00+00:00,46.35962623974396 +2026-03-13T19:15:00+00:00,45.45994000384153 +2026-03-13T19:30:00+00:00,33.714491125817595 +2026-03-13T19:45:00+00:00,34.035134836324545 +2026-03-13T20:00:00+00:00,28.38034369011957 +2026-03-13T20:15:00+00:00,37.67689062254282 +2026-03-13T20:30:00+00:00,42.14536976426179 +2026-03-13T20:45:00+00:00,34.15309668261813 +2026-03-13T21:00:00+00:00,39.77629271121431 +2026-03-13T21:15:00+00:00,43.093726643050054 +2026-03-13T21:30:00+00:00,45.080028492655075 +2026-03-13T21:45:00+00:00,47.926145048677846 +2026-03-13T22:00:00+00:00,43.215659915587636 +2026-03-13T22:15:00+00:00,29.14213108066442 +2026-03-13T22:30:00+00:00,48.54597911603749 +2026-03-13T22:45:00+00:00,32.562533029401955 +2026-03-13T23:00:00+00:00,47.546882556722224 +2026-03-13T23:15:00+00:00,37.90618098019229 +2026-03-13T23:30:00+00:00,32.49516839754352 +2026-03-13T23:45:00+00:00,19.477854379933937 +2026-03-14T00:00:00+00:00,35.87978175672897 +2026-03-14T00:15:00+00:00,24.727516293181758 +2026-03-14T00:30:00+00:00,44.498176760499106 +2026-03-14T00:45:00+00:00,47.90532798385872 +2026-03-14T01:00:00+00:00,27.224972854165355 +2026-03-14T01:15:00+00:00,38.5625244584744 +2026-03-14T01:30:00+00:00,32.57460434713454 +2026-03-14T01:45:00+00:00,29.688105306122456 +2026-03-14T02:00:00+00:00,43.8813548796052 +2026-03-14T02:15:00+00:00,40.483114849443886 +2026-03-14T02:30:00+00:00,52.436201815255465 +2026-03-14T02:45:00+00:00,34.944594732723864 +2026-03-14T03:00:00+00:00,46.36765550275943 +2026-03-14T03:15:00+00:00,40.79360231425305 +2026-03-14T03:30:00+00:00,51.565812211253586 +2026-03-14T03:45:00+00:00,34.51341473899496 +2026-03-14T04:00:00+00:00,38.34736913436176 +2026-03-14T04:15:00+00:00,40.410743222396256 +2026-03-14T04:30:00+00:00,32.597846125068806 +2026-03-14T04:45:00+00:00,45.55018875371601 +2026-03-14T05:00:00+00:00,45.75125235122387 +2026-03-14T05:15:00+00:00,47.90757531596329 +2026-03-14T05:30:00+00:00,51.992327255433274 +2026-03-14T05:45:00+00:00,54.61810917210942 +2026-03-14T06:00:00+00:00,39.741167581492135 +2026-03-14T06:15:00+00:00,47.84345031159229 +2026-03-14T06:30:00+00:00,42.25829970234571 +2026-03-14T06:45:00+00:00,48.07104382143934 +2026-03-14T07:00:00+00:00,42.99949697003144 +2026-03-14T07:15:00+00:00,62.28694140094394 +2026-03-14T07:30:00+00:00,51.144630408640126 +2026-03-14T07:45:00+00:00,47.52526671011817 +2026-03-14T08:00:00+00:00,58.03416872605271 +2026-03-14T08:15:00+00:00,33.49472832842094 +2026-03-14T08:30:00+00:00,34.30168748838432 +2026-03-14T08:45:00+00:00,54.624901469778024 +2026-03-14T09:00:00+00:00,52.60296185373256 +2026-03-14T09:15:00+00:00,59.01786264601851 +2026-03-14T09:30:00+00:00,39.189782939750295 +2026-03-14T09:45:00+00:00,53.25890991789602 +2026-03-14T10:00:00+00:00,66.10769328667621 +2026-03-14T10:15:00+00:00,63.680343537772075 +2026-03-14T10:30:00+00:00,59.33547804582814 +2026-03-14T10:45:00+00:00,53.11071473522905 +2026-03-14T11:00:00+00:00,54.472147980788556 +2026-03-14T11:15:00+00:00,37.41530354356831 +2026-03-14T11:30:00+00:00,55.585909946209235 +2026-03-14T11:45:00+00:00,59.02167082620366 +2026-03-14T12:00:00+00:00,42.275391757378834 +2026-03-14T12:15:00+00:00,51.47781406478297 +2026-03-14T12:30:00+00:00,46.533669005121304 +2026-03-14T12:45:00+00:00,46.613045236630875 +2026-03-14T13:00:00+00:00,58.46065977947832 +2026-03-14T13:15:00+00:00,35.220383767159795 +2026-03-14T13:30:00+00:00,57.483243520908715 +2026-03-14T13:45:00+00:00,61.87152587158723 +2026-03-14T14:00:00+00:00,47.833539527389995 +2026-03-14T14:15:00+00:00,30.89110743773451 +2026-03-14T14:30:00+00:00,44.68612556692896 +2026-03-14T14:45:00+00:00,63.58458719649647 +2026-03-14T15:00:00+00:00,31.062461494324793 +2026-03-14T15:15:00+00:00,51.18675716675214 +2026-03-14T15:30:00+00:00,42.70153295429984 +2026-03-14T15:45:00+00:00,55.0911837265828 +2026-03-14T16:00:00+00:00,43.784534980391626 +2026-03-14T16:15:00+00:00,51.72248012284987 +2026-03-14T16:30:00+00:00,54.96686675269918 +2026-03-14T16:45:00+00:00,64.29827982784354 +2026-03-14T17:00:00+00:00,45.06461129291025 +2026-03-14T17:15:00+00:00,46.59524723704402 +2026-03-14T17:30:00+00:00,34.78027702487275 +2026-03-14T17:45:00+00:00,52.15317776902983 +2026-03-14T18:00:00+00:00,36.75282624167348 +2026-03-14T18:15:00+00:00,40.15124531924161 +2026-03-14T18:30:00+00:00,43.995808322949145 +2026-03-14T18:45:00+00:00,29.00398704398508 +2026-03-14T19:00:00+00:00,44.24280031794981 +2026-03-14T19:15:00+00:00,50.1737972612709 +2026-03-14T19:30:00+00:00,46.9854355914661 +2026-03-14T19:45:00+00:00,29.316840172151327 +2026-03-14T20:00:00+00:00,37.5034557798454 +2026-03-14T20:15:00+00:00,44.43321126970334 +2026-03-14T20:30:00+00:00,24.471822788678484 +2026-03-14T20:45:00+00:00,31.415773783179883 +2026-03-14T21:00:00+00:00,44.87281241187721 +2026-03-14T21:15:00+00:00,39.391059408736034 +2026-03-14T21:30:00+00:00,43.192817518868196 +2026-03-14T21:45:00+00:00,51.595834876566116 +2026-03-14T22:00:00+00:00,36.08347613426493 +2026-03-14T22:15:00+00:00,46.763126685130985 +2026-03-14T22:30:00+00:00,56.74511008911241 +2026-03-14T22:45:00+00:00,32.84819585488718 +2026-03-14T23:00:00+00:00,29.64708422752781 +2026-03-14T23:15:00+00:00,38.813548078534296 +2026-03-14T23:30:00+00:00,40.23430043047307 +2026-03-14T23:45:00+00:00,30.96778872205225 +2026-03-15T00:00:00+00:00,28.04792238156546 +2026-03-15T00:15:00+00:00,51.93126415449289 +2026-03-15T00:30:00+00:00,33.01485895402301 +2026-03-15T00:45:00+00:00,30.064823289425238 +2026-03-15T01:00:00+00:00,40.55234878835193 +2026-03-15T01:15:00+00:00,31.489282950334218 +2026-03-15T01:30:00+00:00,34.02009870898965 +2026-03-15T01:45:00+00:00,40.759587134414424 +2026-03-15T02:00:00+00:00,26.950012542985323 +2026-03-15T02:15:00+00:00,41.6023720712004 +2026-03-15T02:30:00+00:00,51.973553672752516 +2026-03-15T02:45:00+00:00,37.959458257671045 +2026-03-15T03:00:00+00:00,36.44765467308059 +2026-03-15T03:15:00+00:00,35.546949681202385 +2026-03-15T03:30:00+00:00,33.46809393215064 +2026-03-15T03:45:00+00:00,41.15775617790827 +2026-03-15T04:00:00+00:00,35.32661130598545 +2026-03-15T04:15:00+00:00,49.90387123665391 +2026-03-15T04:30:00+00:00,28.68872588710108 +2026-03-15T04:45:00+00:00,46.6494027770172 +2026-03-15T05:00:00+00:00,38.390367899854475 +2026-03-15T05:15:00+00:00,47.266109579192545 +2026-03-15T05:30:00+00:00,36.75574392987841 +2026-03-15T05:45:00+00:00,53.559181822957854 +2026-03-15T06:00:00+00:00,53.65579812972871 +2026-03-15T06:15:00+00:00,50.761127977208794 +2026-03-15T06:30:00+00:00,36.74633230889982 +2026-03-15T06:45:00+00:00,55.35453021238315 +2026-03-15T07:00:00+00:00,50.58152703430359 +2026-03-15T07:15:00+00:00,32.7836810756549 +2026-03-15T07:30:00+00:00,48.2738602855903 +2026-03-15T07:45:00+00:00,52.31321478425025 +2026-03-15T08:00:00+00:00,55.388044703117224 +2026-03-15T08:15:00+00:00,48.33582977853693 +2026-03-15T08:30:00+00:00,54.81761970394239 +2026-03-15T08:45:00+00:00,62.80813139087041 +2026-03-15T09:00:00+00:00,63.821744818293595 +2026-03-15T09:15:00+00:00,30.676957005830598 +2026-03-15T09:30:00+00:00,48.33132791675669 +2026-03-15T09:45:00+00:00,51.97891728689382 +2026-03-15T10:00:00+00:00,38.649413964357464 +2026-03-15T10:15:00+00:00,55.076657411822346 +2026-03-15T10:30:00+00:00,62.56423249735185 +2026-03-15T10:45:00+00:00,44.83516119935058 +2026-03-15T11:00:00+00:00,56.35644139911008 +2026-03-15T11:15:00+00:00,43.85835858937796 +2026-03-15T11:30:00+00:00,50.434624974169026 +2026-03-15T11:45:00+00:00,70.53213007524757 +2026-03-15T12:00:00+00:00,49.801877404852725 +2026-03-15T12:15:00+00:00,59.51379548149057 +2026-03-15T12:30:00+00:00,39.11954821809665 +2026-03-15T12:45:00+00:00,64.73882848012191 +2026-03-15T13:00:00+00:00,57.51607518957869 +2026-03-15T13:15:00+00:00,56.21818923555272 +2026-03-15T13:30:00+00:00,63.22324476054228 +2026-03-15T13:45:00+00:00,42.66794781083513 +2026-03-15T14:00:00+00:00,61.43842940888983 +2026-03-15T14:15:00+00:00,52.30670880254031 +2026-03-15T14:30:00+00:00,49.423117549690915 +2026-03-15T14:45:00+00:00,49.14931941170469 +2026-03-15T15:00:00+00:00,47.44225772364409 +2026-03-15T15:15:00+00:00,55.59268859372528 +2026-03-15T15:30:00+00:00,54.31856628196335 +2026-03-15T15:45:00+00:00,55.639672284783316 +2026-03-15T16:00:00+00:00,53.41287646800734 +2026-03-15T16:15:00+00:00,55.78052308541204 +2026-03-15T16:30:00+00:00,45.629952767563566 +2026-03-15T16:45:00+00:00,63.464523455414636 +2026-03-15T17:00:00+00:00,33.07429323951025 +2026-03-15T17:15:00+00:00,59.15120972114481 +2026-03-15T17:30:00+00:00,58.84728543427034 +2026-03-15T17:45:00+00:00,36.57671551153811 +2026-03-15T18:00:00+00:00,62.93558504846063 +2026-03-15T18:15:00+00:00,50.766130724330615 +2026-03-15T18:30:00+00:00,49.17972922124939 +2026-03-15T18:45:00+00:00,39.84721728981993 +2026-03-15T19:00:00+00:00,40.39210038719974 +2026-03-15T19:15:00+00:00,39.99921423186341 +2026-03-15T19:30:00+00:00,43.49480133164206 +2026-03-15T19:45:00+00:00,49.275570785052416 +2026-03-15T20:00:00+00:00,42.007168467750496 +2026-03-15T20:15:00+00:00,30.99974583926271 +2026-03-15T20:30:00+00:00,46.87018009626879 +2026-03-15T20:45:00+00:00,46.20059867243781 +2026-03-15T21:00:00+00:00,42.25085440694525 +2026-03-15T21:15:00+00:00,45.24588516680109 +2026-03-15T21:30:00+00:00,33.39545262483111 +2026-03-15T21:45:00+00:00,27.851045362766428 +2026-03-15T22:00:00+00:00,29.49820820288122 +2026-03-15T22:15:00+00:00,24.414182484295612 +2026-03-15T22:30:00+00:00,37.277908914310935 +2026-03-15T22:45:00+00:00,35.086504028822986 +2026-03-15T23:00:00+00:00,32.35209080965998 +2026-03-15T23:15:00+00:00,10.896386896654931 +2026-03-15T23:30:00+00:00,25.61549306138946 +2026-03-15T23:45:00+00:00,44.4240087094563 +2026-03-16T00:00:00+00:00,26.33042386566839 +2026-03-16T00:15:00+00:00,40.50562954581551 +2026-03-16T00:30:00+00:00,26.855506554452276 +2026-03-16T00:45:00+00:00,47.00514764032219 +2026-03-16T01:00:00+00:00,36.260576954990206 +2026-03-16T01:15:00+00:00,38.13969427327751 +2026-03-16T01:30:00+00:00,55.963602460594366 +2026-03-16T01:45:00+00:00,50.17813236795173 +2026-03-16T02:00:00+00:00,33.08559264166451 +2026-03-16T02:15:00+00:00,32.900357687135674 +2026-03-16T02:30:00+00:00,37.251730385046244 +2026-03-16T02:45:00+00:00,43.77104947728259 +2026-03-16T03:00:00+00:00,40.188369380856514 +2026-03-16T03:15:00+00:00,38.19788101235833 +2026-03-16T03:30:00+00:00,51.90164337931779 +2026-03-16T03:45:00+00:00,36.722779010216165 +2026-03-16T04:00:00+00:00,59.687679604933166 +2026-03-16T04:15:00+00:00,43.20875397669825 +2026-03-16T04:30:00+00:00,57.24112876191909 +2026-03-16T04:45:00+00:00,43.551256584073414 +2026-03-16T05:00:00+00:00,51.69218006508656 +2026-03-16T05:15:00+00:00,36.22674143302856 +2026-03-16T05:30:00+00:00,38.39625869077755 +2026-03-16T05:45:00+00:00,51.95376687747513 +2026-03-16T06:00:00+00:00,51.08387741352029 +2026-03-16T06:15:00+00:00,45.247311992299416 +2026-03-16T06:30:00+00:00,36.22536963133722 +2026-03-16T06:45:00+00:00,38.72297205857844 +2026-03-16T07:00:00+00:00,42.794012954142495 +2026-03-16T07:15:00+00:00,35.10885160389775 +2026-03-16T07:30:00+00:00,49.982329863179494 +2026-03-16T07:45:00+00:00,58.17503910753527 +2026-03-16T08:00:00+00:00,46.2336597205952 +2026-03-16T08:15:00+00:00,32.51540421437862 +2026-03-16T08:30:00+00:00,49.911652078575436 +2026-03-16T08:45:00+00:00,53.086877801265345 +2026-03-16T09:00:00+00:00,64.73452146556586 +2026-03-16T09:15:00+00:00,40.73484437262279 +2026-03-16T09:30:00+00:00,50.280502878987015 +2026-03-16T09:45:00+00:00,64.49940147748525 +2026-03-16T10:00:00+00:00,59.76246258297782 +2026-03-16T10:15:00+00:00,58.83286830234416 +2026-03-16T10:30:00+00:00,56.37840379087338 +2026-03-16T10:45:00+00:00,56.817095187667995 +2026-03-16T11:00:00+00:00,60.22297680033567 +2026-03-16T11:15:00+00:00,45.101318022242886 +2026-03-16T11:30:00+00:00,63.3493105250349 +2026-03-16T11:45:00+00:00,46.077769479433506 +2026-03-16T12:00:00+00:00,58.84042036993772 +2026-03-16T12:15:00+00:00,54.790302427395254 +2026-03-16T12:30:00+00:00,48.6916115365218 +2026-03-16T12:45:00+00:00,52.10266231520996 +2026-03-16T13:00:00+00:00,49.228172247535795 +2026-03-16T13:15:00+00:00,48.711060687022126 +2026-03-16T13:30:00+00:00,52.72312150221442 +2026-03-16T13:45:00+00:00,43.740236930814255 +2026-03-16T14:00:00+00:00,53.29550731424319 +2026-03-16T14:15:00+00:00,44.62457512808263 +2026-03-16T14:30:00+00:00,59.55185744027213 +2026-03-16T14:45:00+00:00,42.60328790265306 +2026-03-16T15:00:00+00:00,46.82348596924151 +2026-03-16T15:15:00+00:00,45.51867986815536 +2026-03-16T15:30:00+00:00,61.22316697253186 +2026-03-16T15:45:00+00:00,51.24254504681972 +2026-03-16T16:00:00+00:00,48.77335396717862 +2026-03-16T16:15:00+00:00,54.238144719322804 +2026-03-16T16:30:00+00:00,32.47936815656371 +2026-03-16T16:45:00+00:00,48.113010389771105 +2026-03-16T17:00:00+00:00,66.16257998797836 +2026-03-16T17:15:00+00:00,47.45683299150786 +2026-03-16T17:30:00+00:00,46.882427300739494 +2026-03-16T17:45:00+00:00,53.56468450473158 +2026-03-16T18:00:00+00:00,49.32290702691233 +2026-03-16T18:15:00+00:00,24.314945847320537 +2026-03-16T18:30:00+00:00,31.24286297396557 +2026-03-16T18:45:00+00:00,44.28490057674148 +2026-03-16T19:00:00+00:00,40.50899521959705 +2026-03-16T19:15:00+00:00,54.20692854016996 +2026-03-16T19:30:00+00:00,40.16361919288823 +2026-03-16T19:45:00+00:00,26.724741180100068 +2026-03-16T20:00:00+00:00,27.808838066558284 +2026-03-16T20:15:00+00:00,17.6434496957704 +2026-03-16T20:30:00+00:00,41.14002622885847 +2026-03-16T20:45:00+00:00,34.39539680385298 +2026-03-16T21:00:00+00:00,34.44891345621677 +2026-03-16T21:15:00+00:00,44.027174714066284 +2026-03-16T21:30:00+00:00,47.85648783148359 +2026-03-16T21:45:00+00:00,37.14896340152347 +2026-03-16T22:00:00+00:00,17.394234779723043 +2026-03-16T22:15:00+00:00,40.94172935967362 +2026-03-16T22:30:00+00:00,42.9017978250822 +2026-03-16T22:45:00+00:00,38.16824430989265 +2026-03-16T23:00:00+00:00,41.10694728854642 +2026-03-16T23:15:00+00:00,26.774064778888384 +2026-03-16T23:30:00+00:00,55.063139136386425 +2026-03-16T23:45:00+00:00,24.581376815465774 +2026-03-17T00:00:00+00:00,40.098042023455115 +2026-03-17T00:15:00+00:00,20.93248823721767 +2026-03-17T00:30:00+00:00,28.648810184049275 +2026-03-17T00:45:00+00:00,47.904102555513184 +2026-03-17T01:00:00+00:00,34.41390081832992 +2026-03-17T01:15:00+00:00,45.95878154251693 +2026-03-17T01:30:00+00:00,37.5549646039176 +2026-03-17T01:45:00+00:00,33.50702439704732 +2026-03-17T02:00:00+00:00,47.419929545593824 +2026-03-17T02:15:00+00:00,37.79607926448833 +2026-03-17T02:30:00+00:00,22.463237013844328 +2026-03-17T02:45:00+00:00,32.39778949909845 +2026-03-17T03:00:00+00:00,42.617653978986375 +2026-03-17T03:15:00+00:00,41.19823475946103 +2026-03-17T03:30:00+00:00,25.55310579669262 +2026-03-17T03:45:00+00:00,35.45868179791227 +2026-03-17T04:00:00+00:00,34.32482390701226 +2026-03-17T04:15:00+00:00,37.2428303322322 +2026-03-17T04:30:00+00:00,26.779150769760275 +2026-03-17T04:45:00+00:00,36.29947392594883 +2026-03-17T05:00:00+00:00,36.19012289324056 +2026-03-17T05:15:00+00:00,49.466782403561325 +2026-03-17T05:30:00+00:00,49.05577019072559 +2026-03-17T05:45:00+00:00,40.5452313130174 +2026-03-17T06:00:00+00:00,42.515522505961634 +2026-03-17T06:15:00+00:00,47.83847689666498 +2026-03-17T06:30:00+00:00,38.33308340955204 +2026-03-17T06:45:00+00:00,38.171209621840994 +2026-03-17T07:00:00+00:00,39.05837328456165 +2026-03-17T07:15:00+00:00,42.68447912629895 +2026-03-17T07:30:00+00:00,39.697748656047956 +2026-03-17T07:45:00+00:00,49.52520577159375 +2026-03-17T08:00:00+00:00,53.72307772844477 +2026-03-17T08:15:00+00:00,41.577106174093366 +2026-03-17T08:30:00+00:00,50.86156650471054 +2026-03-17T08:45:00+00:00,50.1812156699887 +2026-03-17T09:00:00+00:00,57.323859970014574 +2026-03-17T09:15:00+00:00,54.5492303506294 +2026-03-17T09:30:00+00:00,39.28697465369421 +2026-03-17T09:45:00+00:00,42.14883205889274 +2026-03-17T10:00:00+00:00,59.694954630449615 +2026-03-17T10:15:00+00:00,62.31033001066264 +2026-03-17T10:30:00+00:00,38.117665553278094 +2026-03-17T10:45:00+00:00,57.77616304509252 +2026-03-17T11:00:00+00:00,63.67593072643156 +2026-03-17T11:15:00+00:00,53.641113046543595 +2026-03-17T11:30:00+00:00,59.811607610042095 +2026-03-17T11:45:00+00:00,49.98938466233293 +2026-03-17T12:00:00+00:00,48.80300635805796 +2026-03-17T12:15:00+00:00,40.930863310817465 +2026-03-17T12:30:00+00:00,34.53208025717758 +2026-03-17T12:45:00+00:00,58.59220830951752 +2026-03-17T13:00:00+00:00,44.902386403086965 +2026-03-17T13:15:00+00:00,46.348107731914226 +2026-03-17T13:30:00+00:00,46.65035788356051 +2026-03-17T13:45:00+00:00,41.623075627156304 +2026-03-17T14:00:00+00:00,51.19146919709789 +2026-03-17T14:15:00+00:00,48.89607941983148 +2026-03-17T14:30:00+00:00,55.40432732872709 +2026-03-17T14:45:00+00:00,57.37531660122148 +2026-03-17T15:00:00+00:00,53.87718003392358 +2026-03-17T15:15:00+00:00,53.24530680880548 +2026-03-17T15:30:00+00:00,62.93048716797419 +2026-03-17T15:45:00+00:00,43.68328652565308 +2026-03-17T16:00:00+00:00,48.37642122721244 +2026-03-17T16:15:00+00:00,53.381834511166176 +2026-03-17T16:30:00+00:00,58.987619760842094 +2026-03-17T16:45:00+00:00,38.71207147124317 +2026-03-17T17:00:00+00:00,43.55976761843104 +2026-03-17T17:15:00+00:00,55.60136768077602 +2026-03-17T17:30:00+00:00,49.06059884027115 +2026-03-17T17:45:00+00:00,42.4749854735732 +2026-03-17T18:00:00+00:00,41.0962520318736 +2026-03-17T18:15:00+00:00,39.664049329582774 +2026-03-17T18:30:00+00:00,47.3154206877072 +2026-03-17T18:45:00+00:00,39.99980316035744 +2026-03-17T19:00:00+00:00,43.418847399720605 +2026-03-17T19:15:00+00:00,39.60855182978268 +2026-03-17T19:30:00+00:00,40.62154189148064 +2026-03-17T19:45:00+00:00,42.99904225765548 +2026-03-17T20:00:00+00:00,61.139203774210394 +2026-03-17T20:15:00+00:00,27.769991762527685 +2026-03-17T20:30:00+00:00,39.17636958114502 +2026-03-17T20:45:00+00:00,32.788033571176086 +2026-03-17T21:00:00+00:00,48.11399271677564 +2026-03-17T21:15:00+00:00,36.70646055402223 +2026-03-17T21:30:00+00:00,39.20149674608171 +2026-03-17T21:45:00+00:00,46.09071574247824 +2026-03-17T22:00:00+00:00,36.43471039781051 +2026-03-17T22:15:00+00:00,43.12359370190895 +2026-03-17T22:30:00+00:00,17.939867668105503 +2026-03-17T22:45:00+00:00,50.19408476365903 +2026-03-17T23:00:00+00:00,39.072179272789114 +2026-03-17T23:15:00+00:00,44.41855812973842 +2026-03-17T23:30:00+00:00,22.196626523970938 +2026-03-17T23:45:00+00:00,27.213001125108413 +2026-03-18T00:00:00+00:00,37.682486233499816 +2026-03-18T00:15:00+00:00,32.545180687178046 +2026-03-18T00:30:00+00:00,38.14490693997181 +2026-03-18T00:45:00+00:00,41.91654493062382 +2026-03-18T01:00:00+00:00,22.90780983064939 +2026-03-18T01:15:00+00:00,41.38409856795208 +2026-03-18T01:30:00+00:00,37.26012017611142 +2026-03-18T01:45:00+00:00,35.82953245400445 +2026-03-18T02:00:00+00:00,24.527532848570623 +2026-03-18T02:15:00+00:00,40.24685210985139 +2026-03-18T02:30:00+00:00,15.128136740754266 +2026-03-18T02:45:00+00:00,39.83408312339594 +2026-03-18T03:00:00+00:00,30.130461043828145 +2026-03-18T03:15:00+00:00,37.03886678386782 +2026-03-18T03:30:00+00:00,33.97849314335309 +2026-03-18T03:45:00+00:00,49.26648547870528 +2026-03-18T04:00:00+00:00,37.39955442713186 +2026-03-18T04:15:00+00:00,35.09004221951254 +2026-03-18T04:30:00+00:00,36.948539424134374 +2026-03-18T04:45:00+00:00,49.398386623202065 +2026-03-18T05:00:00+00:00,42.476294424609094 +2026-03-18T05:15:00+00:00,39.71117374850706 +2026-03-18T05:30:00+00:00,44.02933748992576 +2026-03-18T05:45:00+00:00,30.899536616278073 +2026-03-18T06:00:00+00:00,48.415942712307974 +2026-03-18T06:15:00+00:00,32.94526142771104 +2026-03-18T06:30:00+00:00,50.9795987853031 +2026-03-18T06:45:00+00:00,46.26069694760103 +2026-03-18T07:00:00+00:00,65.3861137056043 +2026-03-18T07:15:00+00:00,41.00417517793471 +2026-03-18T07:30:00+00:00,48.84468466330029 +2026-03-18T07:45:00+00:00,64.18035442653544 +2026-03-18T08:00:00+00:00,56.856756490220995 +2026-03-18T08:15:00+00:00,48.62415183829051 +2026-03-18T08:30:00+00:00,52.862195818869935 +2026-03-18T08:45:00+00:00,50.96159294754669 +2026-03-18T09:00:00+00:00,55.53107967713761 +2026-03-18T09:15:00+00:00,48.13342066108226 +2026-03-18T09:30:00+00:00,51.85631068106679 +2026-03-18T09:45:00+00:00,41.71834097873222 +2026-03-18T10:00:00+00:00,58.51254510743589 +2026-03-18T10:15:00+00:00,58.60686295042175 +2026-03-18T10:30:00+00:00,51.849519092742185 +2026-03-18T10:45:00+00:00,39.30627624251652 +2026-03-18T11:00:00+00:00,60.29774404870047 +2026-03-18T11:15:00+00:00,56.01300885060992 +2026-03-18T11:30:00+00:00,47.745722884712876 +2026-03-18T11:45:00+00:00,50.11766930212358 +2026-03-18T12:00:00+00:00,42.28200300771096 +2026-03-18T12:15:00+00:00,46.34776957612716 +2026-03-18T12:30:00+00:00,48.39327941953295 +2026-03-18T12:45:00+00:00,61.06324678209713 +2026-03-18T13:00:00+00:00,51.24548493693921 +2026-03-18T13:15:00+00:00,60.938320615845 +2026-03-18T13:30:00+00:00,49.155545979453294 +2026-03-18T13:45:00+00:00,55.89709679444951 +2026-03-18T14:00:00+00:00,52.69580264265122 +2026-03-18T14:15:00+00:00,61.60126165856608 +2026-03-18T14:30:00+00:00,49.65532937654799 +2026-03-18T14:45:00+00:00,39.54252315919116 +2026-03-18T15:00:00+00:00,40.000688673414736 +2026-03-18T15:15:00+00:00,45.693694940129774 +2026-03-18T15:30:00+00:00,42.04724682917779 +2026-03-18T15:45:00+00:00,57.312632664841786 +2026-03-18T16:00:00+00:00,46.35531862152812 +2026-03-18T16:15:00+00:00,69.89954849931014 +2026-03-18T16:30:00+00:00,37.096012657318866 +2026-03-18T16:45:00+00:00,49.646396460461446 +2026-03-18T17:00:00+00:00,34.768188910213574 +2026-03-18T17:15:00+00:00,41.59209262617325 +2026-03-18T17:30:00+00:00,49.37289290138387 +2026-03-18T17:45:00+00:00,26.591765967399255 +2026-03-18T18:00:00+00:00,49.058408469610676 +2026-03-18T18:15:00+00:00,57.77964287100392 +2026-03-18T18:30:00+00:00,41.09342361186869 +2026-03-18T18:45:00+00:00,43.55668538864419 +2026-03-18T19:00:00+00:00,49.145481522874405 +2026-03-18T19:15:00+00:00,41.76163835603137 +2026-03-18T19:30:00+00:00,37.72428789331927 +2026-03-18T19:45:00+00:00,19.549811110180478 +2026-03-18T20:00:00+00:00,46.54668055629141 +2026-03-18T20:15:00+00:00,38.665480634060245 +2026-03-18T20:30:00+00:00,32.127898366842935 +2026-03-18T20:45:00+00:00,40.89663639379577 +2026-03-18T21:00:00+00:00,33.67422644537906 +2026-03-18T21:15:00+00:00,35.231669594267 +2026-03-18T21:30:00+00:00,41.024988882113725 +2026-03-18T21:45:00+00:00,27.816912165428754 +2026-03-18T22:00:00+00:00,40.349764750138604 +2026-03-18T22:15:00+00:00,24.525552950964467 +2026-03-18T22:30:00+00:00,29.187271609400977 +2026-03-18T22:45:00+00:00,50.993574382185614 +2026-03-18T23:00:00+00:00,47.17110621923188 +2026-03-18T23:15:00+00:00,38.71612642392319 +2026-03-18T23:30:00+00:00,29.58681765833084 +2026-03-18T23:45:00+00:00,31.01783683054541 +2026-03-19T00:00:00+00:00,31.850961272233732 +2026-03-19T00:15:00+00:00,35.94808190796367 +2026-03-19T00:30:00+00:00,34.81926923342493 +2026-03-19T00:45:00+00:00,33.94161773252259 +2026-03-19T01:00:00+00:00,32.618410692021286 +2026-03-19T01:15:00+00:00,27.398913584763317 +2026-03-19T01:30:00+00:00,33.92698368598032 +2026-03-19T01:45:00+00:00,42.44196249992984 +2026-03-19T02:00:00+00:00,27.08916553218414 +2026-03-19T02:15:00+00:00,36.13675153822215 +2026-03-19T02:30:00+00:00,30.342674827103817 +2026-03-19T02:45:00+00:00,36.93228532572409 +2026-03-19T03:00:00+00:00,35.02780314217692 +2026-03-19T03:15:00+00:00,49.789747612237775 +2026-03-19T03:30:00+00:00,39.203632358565734 +2026-03-19T03:45:00+00:00,28.838953339240074 +2026-03-19T04:00:00+00:00,48.69527988926932 +2026-03-19T04:15:00+00:00,31.95738980882322 +2026-03-19T04:30:00+00:00,37.65047767832334 +2026-03-19T04:45:00+00:00,45.90641240025927 +2026-03-19T05:00:00+00:00,37.37511768751604 +2026-03-19T05:15:00+00:00,51.49847499389539 +2026-03-19T05:30:00+00:00,41.62068334529362 +2026-03-19T05:45:00+00:00,40.25886601498004 +2026-03-19T06:00:00+00:00,40.069744343351694 +2026-03-19T06:15:00+00:00,41.18695518432193 +2026-03-19T06:30:00+00:00,57.169599835237776 +2026-03-19T06:45:00+00:00,52.87981687885918 +2026-03-19T07:00:00+00:00,62.42748113545877 +2026-03-19T07:15:00+00:00,65.13080423448281 +2026-03-19T07:30:00+00:00,39.732087793870214 +2026-03-19T07:45:00+00:00,51.77871604960418 +2026-03-19T08:00:00+00:00,45.07814902285267 +2026-03-19T08:15:00+00:00,46.850946804921826 +2026-03-19T08:30:00+00:00,47.651986442367864 +2026-03-19T08:45:00+00:00,41.57853543881718 +2026-03-19T09:00:00+00:00,44.79841556566064 +2026-03-19T09:15:00+00:00,50.219371664490964 +2026-03-19T09:30:00+00:00,45.78600642756889 +2026-03-19T09:45:00+00:00,59.1224000329099 +2026-03-19T10:00:00+00:00,42.101454171647205 +2026-03-19T10:15:00+00:00,81.66202711371118 +2026-03-19T10:30:00+00:00,51.46933928056745 +2026-03-19T10:45:00+00:00,54.689406188451635 +2026-03-19T11:00:00+00:00,45.30371472085435 +2026-03-19T11:15:00+00:00,65.12916399954223 +2026-03-19T11:30:00+00:00,63.16760332150418 +2026-03-19T11:45:00+00:00,55.45388711151321 +2026-03-19T12:00:00+00:00,42.34258202685629 +2026-03-19T12:15:00+00:00,55.11467232837388 +2026-03-19T12:30:00+00:00,47.454056549889266 +2026-03-19T12:45:00+00:00,66.8689326405818 +2026-03-19T13:00:00+00:00,61.046130971421675 +2026-03-19T13:15:00+00:00,53.9636719316735 +2026-03-19T13:30:00+00:00,33.83061831966108 +2026-03-19T13:45:00+00:00,45.129358792690844 +2026-03-19T14:00:00+00:00,59.8162919413451 +2026-03-19T14:15:00+00:00,64.29114475768824 +2026-03-19T14:30:00+00:00,61.96763106208589 +2026-03-19T14:45:00+00:00,39.60627873265437 +2026-03-19T15:00:00+00:00,56.12895859571767 +2026-03-19T15:15:00+00:00,61.58941577382219 +2026-03-19T15:30:00+00:00,37.088958729056 +2026-03-19T15:45:00+00:00,50.041610530454925 +2026-03-19T16:00:00+00:00,53.671486583744255 +2026-03-19T16:15:00+00:00,49.8822900051835 +2026-03-19T16:30:00+00:00,35.91001622970725 +2026-03-19T16:45:00+00:00,46.89000624451385 +2026-03-19T17:00:00+00:00,45.77346912589514 +2026-03-19T17:15:00+00:00,43.615234729491455 +2026-03-19T17:30:00+00:00,40.055786575938285 +2026-03-19T17:45:00+00:00,42.53007204213977 +2026-03-19T18:00:00+00:00,44.07204391304956 +2026-03-19T18:15:00+00:00,48.83834015497277 +2026-03-19T18:30:00+00:00,42.09379633433494 +2026-03-19T18:45:00+00:00,41.59324624879751 +2026-03-19T19:00:00+00:00,41.31197712063066 +2026-03-19T19:15:00+00:00,26.86050735841894 +2026-03-19T19:30:00+00:00,41.74365011430337 +2026-03-19T19:45:00+00:00,39.89069654175494 +2026-03-19T20:00:00+00:00,42.34466839849774 +2026-03-19T20:15:00+00:00,45.26234435990585 +2026-03-19T20:30:00+00:00,22.948200739060177 +2026-03-19T20:45:00+00:00,20.33104201821186 +2026-03-19T21:00:00+00:00,40.619418047458694 +2026-03-19T21:15:00+00:00,41.54307554789284 +2026-03-19T21:30:00+00:00,35.870720349112226 +2026-03-19T21:45:00+00:00,32.24204735786492 +2026-03-19T22:00:00+00:00,42.94029486704971 +2026-03-19T22:15:00+00:00,45.68644969561238 +2026-03-19T22:30:00+00:00,40.49979663870236 +2026-03-19T22:45:00+00:00,35.1659641034888 +2026-03-19T23:00:00+00:00,16.792103982865562 +2026-03-19T23:15:00+00:00,41.72385853811463 +2026-03-19T23:30:00+00:00,44.36493337611668 +2026-03-19T23:45:00+00:00,38.316067035392344 +2026-03-20T00:00:00+00:00,27.719373314431405 +2026-03-20T00:15:00+00:00,38.8602498120285 +2026-03-20T00:30:00+00:00,39.886788950001076 +2026-03-20T00:45:00+00:00,48.02173871718226 +2026-03-20T01:00:00+00:00,32.15159819709109 +2026-03-20T01:15:00+00:00,31.12518270839808 +2026-03-20T01:30:00+00:00,39.36320720326001 +2026-03-20T01:45:00+00:00,37.00267483697813 +2026-03-20T02:00:00+00:00,24.252986992191815 +2026-03-20T02:15:00+00:00,38.44826529217607 +2026-03-20T02:30:00+00:00,42.70784747889675 +2026-03-20T02:45:00+00:00,36.91989080990497 +2026-03-20T03:00:00+00:00,39.62203551944876 +2026-03-20T03:15:00+00:00,39.593048122942434 +2026-03-20T03:30:00+00:00,51.93794591082272 +2026-03-20T03:45:00+00:00,29.42496775662386 +2026-03-20T04:00:00+00:00,34.09243014959689 +2026-03-20T04:15:00+00:00,44.13784702882939 +2026-03-20T04:30:00+00:00,48.00701976182476 +2026-03-20T04:45:00+00:00,27.13784482064934 +2026-03-20T05:00:00+00:00,32.92697340822631 +2026-03-20T05:15:00+00:00,27.787203400160365 +2026-03-20T05:30:00+00:00,47.640715956217214 +2026-03-20T05:45:00+00:00,36.2899948050896 +2026-03-20T06:00:00+00:00,43.59592993842755 +2026-03-20T06:15:00+00:00,53.57662266927406 +2026-03-20T06:30:00+00:00,38.005604766739076 +2026-03-20T06:45:00+00:00,48.55948427551693 +2026-03-20T07:00:00+00:00,44.71158968168509 +2026-03-20T07:15:00+00:00,53.39578269433477 +2026-03-20T07:30:00+00:00,52.25668022711886 +2026-03-20T07:45:00+00:00,42.998734113627755 +2026-03-20T08:00:00+00:00,50.480085297014625 +2026-03-20T08:15:00+00:00,57.9327550842402 +2026-03-20T08:30:00+00:00,56.901414385138864 +2026-03-20T08:45:00+00:00,38.46658488741814 +2026-03-20T09:00:00+00:00,56.074490542066904 +2026-03-20T09:15:00+00:00,39.38524096453331 +2026-03-20T09:30:00+00:00,61.19617856472519 +2026-03-20T09:45:00+00:00,65.33498180472125 +2026-03-20T10:00:00+00:00,53.53255764155962 +2026-03-20T10:15:00+00:00,55.4152889648605 +2026-03-20T10:30:00+00:00,53.923877230505 +2026-03-20T10:45:00+00:00,60.72941582754856 +2026-03-20T11:00:00+00:00,54.50142022914298 +2026-03-20T11:15:00+00:00,61.44236765535867 +2026-03-20T11:30:00+00:00,61.43849601765763 +2026-03-20T11:45:00+00:00,49.865578417765576 +2026-03-20T12:00:00+00:00,52.172921451521596 +2026-03-20T12:15:00+00:00,44.08419920459734 +2026-03-20T12:30:00+00:00,43.05358855350983 +2026-03-20T12:45:00+00:00,40.80475347942523 +2026-03-20T13:00:00+00:00,59.13713159132069 +2026-03-20T13:15:00+00:00,67.97914060844228 +2026-03-20T13:30:00+00:00,64.33538063001618 +2026-03-20T13:45:00+00:00,40.43459606403504 +2026-03-20T14:00:00+00:00,59.984759377651336 +2026-03-20T14:15:00+00:00,62.79662276039772 +2026-03-20T14:30:00+00:00,55.30482211855499 +2026-03-20T14:45:00+00:00,48.71786860857246 +2026-03-20T15:00:00+00:00,38.660669120196395 +2026-03-20T15:15:00+00:00,53.10119572093854 +2026-03-20T15:30:00+00:00,41.115197035914726 +2026-03-20T15:45:00+00:00,51.672497227211935 +2026-03-20T16:00:00+00:00,39.47741797601816 +2026-03-20T16:15:00+00:00,52.97409249730581 +2026-03-20T16:30:00+00:00,56.75278722653936 +2026-03-20T16:45:00+00:00,41.44545725987816 +2026-03-20T17:00:00+00:00,36.73396005065103 +2026-03-20T17:15:00+00:00,31.90221639868224 +2026-03-20T17:30:00+00:00,45.476794688412035 +2026-03-20T17:45:00+00:00,41.39580821184794 +2026-03-20T18:00:00+00:00,34.6220731792885 +2026-03-20T18:15:00+00:00,49.83199598346065 +2026-03-20T18:30:00+00:00,40.69575206142407 +2026-03-20T18:45:00+00:00,33.970592822379786 +2026-03-20T19:00:00+00:00,37.94644103223299 +2026-03-20T19:15:00+00:00,51.4485662306403 +2026-03-20T19:30:00+00:00,48.39414778658891 +2026-03-20T19:45:00+00:00,39.5651374575484 +2026-03-20T20:00:00+00:00,46.39454061628362 +2026-03-20T20:15:00+00:00,31.395451855907293 +2026-03-20T20:30:00+00:00,36.180378607965146 +2026-03-20T20:45:00+00:00,36.958920373918716 +2026-03-20T21:00:00+00:00,36.924100398640796 +2026-03-20T21:15:00+00:00,40.258177179232206 +2026-03-20T21:30:00+00:00,29.01769411280943 +2026-03-20T21:45:00+00:00,43.7111862279265 +2026-03-20T22:00:00+00:00,38.00521686861281 +2026-03-20T22:15:00+00:00,15.169974771520105 +2026-03-20T22:30:00+00:00,46.64721525287379 +2026-03-20T22:45:00+00:00,11.702388234931679 +2026-03-20T23:00:00+00:00,28.564447952683757 +2026-03-20T23:15:00+00:00,44.91577305911838 +2026-03-20T23:30:00+00:00,31.019715157072795 +2026-03-20T23:45:00+00:00,24.3747527403502 +2026-03-21T00:00:00+00:00,26.63738046905455 +2026-03-21T00:15:00+00:00,43.668002927382396 +2026-03-21T00:30:00+00:00,17.820027616920616 +2026-03-21T00:45:00+00:00,41.58455556368407 +2026-03-21T01:00:00+00:00,34.40274264386023 +2026-03-21T01:15:00+00:00,27.16350126083779 +2026-03-21T01:30:00+00:00,30.331577805025027 +2026-03-21T01:45:00+00:00,24.932035101459256 +2026-03-21T02:00:00+00:00,44.07844190242722 +2026-03-21T02:15:00+00:00,42.754866725803375 +2026-03-21T02:30:00+00:00,23.439873834158277 +2026-03-21T02:45:00+00:00,34.07089218254041 +2026-03-21T03:00:00+00:00,28.434291216029333 +2026-03-21T03:15:00+00:00,47.323927636077784 +2026-03-21T03:30:00+00:00,27.825874100502084 +2026-03-21T03:45:00+00:00,53.812667764725376 +2026-03-21T04:00:00+00:00,40.20838294725645 +2026-03-21T04:15:00+00:00,31.630062500927608 +2026-03-21T04:30:00+00:00,39.97198433656432 +2026-03-21T04:45:00+00:00,35.96165842166683 +2026-03-21T05:00:00+00:00,30.652262713092156 +2026-03-21T05:15:00+00:00,44.215105874310154 +2026-03-21T05:30:00+00:00,28.61266787159483 +2026-03-21T05:45:00+00:00,26.770507250672832 +2026-03-21T06:00:00+00:00,41.553211109575706 +2026-03-21T06:15:00+00:00,28.088152792832197 +2026-03-21T06:30:00+00:00,37.19013615269764 +2026-03-21T06:45:00+00:00,41.139219505056055 +2026-03-21T07:00:00+00:00,40.031776393338376 +2026-03-21T07:15:00+00:00,63.11302059928508 +2026-03-21T07:30:00+00:00,50.422302390699905 +2026-03-21T07:45:00+00:00,51.93427014967437 +2026-03-21T08:00:00+00:00,37.29519024428582 +2026-03-21T08:15:00+00:00,44.148421236540486 +2026-03-21T08:30:00+00:00,53.05354963692235 +2026-03-21T08:45:00+00:00,47.52463009285936 +2026-03-21T09:00:00+00:00,60.15671488866871 +2026-03-21T09:15:00+00:00,42.25661061710929 +2026-03-21T09:30:00+00:00,52.22651643590439 +2026-03-21T09:45:00+00:00,49.94157426012675 +2026-03-21T10:00:00+00:00,58.84006280040311 +2026-03-21T10:15:00+00:00,48.46826328907733 +2026-03-21T10:30:00+00:00,52.61466771409506 +2026-03-21T10:45:00+00:00,54.39225754450182 +2026-03-21T11:00:00+00:00,49.67930016425024 +2026-03-21T11:15:00+00:00,58.046421690202905 +2026-03-21T11:30:00+00:00,59.39854223107091 +2026-03-21T11:45:00+00:00,50.146288977214226 +2026-03-21T12:00:00+00:00,51.75417658571703 +2026-03-21T12:15:00+00:00,61.99032399553544 +2026-03-21T12:30:00+00:00,50.52543252522591 +2026-03-21T12:45:00+00:00,53.21398593697863 +2026-03-21T13:00:00+00:00,54.85887001700799 +2026-03-21T13:15:00+00:00,66.55893931597636 +2026-03-21T13:30:00+00:00,42.706280949338286 +2026-03-21T13:45:00+00:00,63.65730143359403 +2026-03-21T14:00:00+00:00,39.58486217519436 +2026-03-21T14:15:00+00:00,50.8255924569049 +2026-03-21T14:30:00+00:00,66.38168128226908 +2026-03-21T14:45:00+00:00,56.59766090604045 +2026-03-21T15:00:00+00:00,52.38201447976649 +2026-03-21T15:15:00+00:00,46.78468434460994 +2026-03-21T15:30:00+00:00,49.453307895145066 +2026-03-21T15:45:00+00:00,49.328431406033346 +2026-03-21T16:00:00+00:00,42.57534325580463 +2026-03-21T16:15:00+00:00,46.52117694117747 +2026-03-21T16:30:00+00:00,48.34539883728419 +2026-03-21T16:45:00+00:00,45.03755848961485 +2026-03-21T17:00:00+00:00,36.28899093382957 +2026-03-21T17:15:00+00:00,35.40892475774241 +2026-03-21T17:30:00+00:00,43.2895529355379 +2026-03-21T17:45:00+00:00,50.47234406955913 +2026-03-21T18:00:00+00:00,44.640819225921774 +2026-03-21T18:15:00+00:00,52.83373237242552 +2026-03-21T18:30:00+00:00,42.89268863629825 +2026-03-21T18:45:00+00:00,49.95513001339579 +2026-03-21T19:00:00+00:00,47.99653777840905 +2026-03-21T19:15:00+00:00,39.48933213876247 +2026-03-21T19:30:00+00:00,39.42757118101943 +2026-03-21T19:45:00+00:00,59.48121516449639 +2026-03-21T20:00:00+00:00,36.90390431069379 +2026-03-21T20:15:00+00:00,32.29712493425757 +2026-03-21T20:30:00+00:00,41.82340282138723 +2026-03-21T20:45:00+00:00,41.14115239762202 +2026-03-21T21:00:00+00:00,29.117947755791604 +2026-03-21T21:15:00+00:00,44.6547372888105 +2026-03-21T21:30:00+00:00,36.73696666191859 +2026-03-21T21:45:00+00:00,24.979018681139117 +2026-03-21T22:00:00+00:00,47.48332373579061 +2026-03-21T22:15:00+00:00,42.027065417401865 +2026-03-21T22:30:00+00:00,26.332776963175114 +2026-03-21T22:45:00+00:00,50.43419492016075 +2026-03-21T23:00:00+00:00,39.51202280120563 +2026-03-21T23:15:00+00:00,36.1814409972119 +2026-03-21T23:30:00+00:00,48.4770733138581 +2026-03-21T23:45:00+00:00,35.36975801236 +2026-03-22T00:00:00+00:00,33.54598743199397 +2026-03-22T00:15:00+00:00,31.87350930831961 +2026-03-22T00:30:00+00:00,38.6770173365183 +2026-03-22T00:45:00+00:00,40.21253873929154 +2026-03-22T01:00:00+00:00,34.10442859691809 +2026-03-22T01:15:00+00:00,27.966129499871997 +2026-03-22T01:30:00+00:00,38.13247245944103 +2026-03-22T01:45:00+00:00,39.340833035938445 +2026-03-22T02:00:00+00:00,26.1230675768318 +2026-03-22T02:15:00+00:00,24.910057809731587 +2026-03-22T02:30:00+00:00,42.982061272857635 +2026-03-22T02:45:00+00:00,15.017872605317821 +2026-03-22T03:00:00+00:00,39.95683649731563 +2026-03-22T03:15:00+00:00,33.608523851200914 +2026-03-22T03:30:00+00:00,46.46168477809847 +2026-03-22T03:45:00+00:00,38.87660311151476 +2026-03-22T04:00:00+00:00,44.61229915916065 +2026-03-22T04:15:00+00:00,36.04648556081563 +2026-03-22T04:30:00+00:00,41.31455720855205 +2026-03-22T04:45:00+00:00,45.73483707831036 +2026-03-22T05:00:00+00:00,38.102060490877854 +2026-03-22T05:15:00+00:00,45.51191509874239 +2026-03-22T05:30:00+00:00,36.30064311432797 +2026-03-22T05:45:00+00:00,43.36436349450227 +2026-03-22T06:00:00+00:00,48.466467291074665 +2026-03-22T06:15:00+00:00,53.460412509625854 +2026-03-22T06:30:00+00:00,62.91681368280383 +2026-03-22T06:45:00+00:00,38.21182418212753 +2026-03-22T07:00:00+00:00,38.83234117230117 +2026-03-22T07:15:00+00:00,41.1666084870125 +2026-03-22T07:30:00+00:00,53.874038641618924 +2026-03-22T07:45:00+00:00,53.1301041790244 +2026-03-22T08:00:00+00:00,48.848828223549965 +2026-03-22T08:15:00+00:00,40.68757656345338 +2026-03-22T08:30:00+00:00,61.70609965942431 +2026-03-22T08:45:00+00:00,41.561005579775916 +2026-03-22T09:00:00+00:00,59.89062649841042 +2026-03-22T09:15:00+00:00,76.40287472984268 +2026-03-22T09:30:00+00:00,56.35025624253052 +2026-03-22T09:45:00+00:00,55.92176668517887 +2026-03-22T10:00:00+00:00,48.9500370342311 +2026-03-22T10:15:00+00:00,37.171616318404645 +2026-03-22T10:30:00+00:00,53.040270618922605 +2026-03-22T10:45:00+00:00,46.779409443070875 +2026-03-22T11:00:00+00:00,41.06305345129883 +2026-03-22T11:15:00+00:00,68.58497259836355 +2026-03-22T11:30:00+00:00,60.0869037652076 +2026-03-22T11:45:00+00:00,58.60262118155306 +2026-03-22T12:00:00+00:00,52.5660987766754 +2026-03-22T12:15:00+00:00,54.21919954808345 +2026-03-22T12:30:00+00:00,60.545349556556694 +2026-03-22T12:45:00+00:00,55.584716315501055 +2026-03-22T13:00:00+00:00,56.41182721598623 +2026-03-22T13:15:00+00:00,53.65312317166273 +2026-03-22T13:30:00+00:00,54.3611093330757 +2026-03-22T13:45:00+00:00,42.43065867302647 +2026-03-22T14:00:00+00:00,39.0210807972363 +2026-03-22T14:15:00+00:00,49.92524115891275 +2026-03-22T14:30:00+00:00,54.16221911667816 +2026-03-22T14:45:00+00:00,40.22878265055553 +2026-03-22T15:00:00+00:00,46.75994385013622 +2026-03-22T15:15:00+00:00,52.310064469060606 +2026-03-22T15:30:00+00:00,35.09367101061213 +2026-03-22T15:45:00+00:00,42.55127043005862 +2026-03-22T16:00:00+00:00,51.8399128595353 +2026-03-22T16:15:00+00:00,52.279879925918856 +2026-03-22T16:30:00+00:00,40.52326231032174 +2026-03-22T16:45:00+00:00,52.467054062071966 +2026-03-22T17:00:00+00:00,57.59552862529629 +2026-03-22T17:15:00+00:00,54.43825237675101 +2026-03-22T17:30:00+00:00,43.9115117990178 +2026-03-22T17:45:00+00:00,40.67112257606214 +2026-03-22T18:00:00+00:00,34.9438131677483 +2026-03-22T18:15:00+00:00,47.750245094330225 +2026-03-22T18:30:00+00:00,45.66388392035612 +2026-03-22T18:45:00+00:00,51.555413656582665 +2026-03-22T19:00:00+00:00,37.55456020749833 +2026-03-22T19:15:00+00:00,47.623469570001404 +2026-03-22T19:30:00+00:00,39.06798270804482 +2026-03-22T19:45:00+00:00,46.5716302232845 +2026-03-22T20:00:00+00:00,45.474541082143986 +2026-03-22T20:15:00+00:00,37.55345865321305 +2026-03-22T20:30:00+00:00,27.134079645857756 +2026-03-22T20:45:00+00:00,51.1357289796737 +2026-03-22T21:00:00+00:00,40.19932617230133 +2026-03-22T21:15:00+00:00,19.095033416081705 +2026-03-22T21:30:00+00:00,31.38422194565199 +2026-03-22T21:45:00+00:00,42.00209507379474 +2026-03-22T22:00:00+00:00,35.499794912658295 +2026-03-22T22:15:00+00:00,30.454868842872198 +2026-03-22T22:30:00+00:00,44.747000012522165 +2026-03-22T22:45:00+00:00,29.40353907999754 +2026-03-22T23:00:00+00:00,27.586082133216305 +2026-03-22T23:15:00+00:00,37.31248229151766 +2026-03-22T23:30:00+00:00,39.39077677564709 +2026-03-22T23:45:00+00:00,42.88679114393265 +2026-03-23T00:00:00+00:00,42.370597086332154 +2026-03-23T00:15:00+00:00,37.16852681821246 +2026-03-23T00:30:00+00:00,40.86656429462828 +2026-03-23T00:45:00+00:00,35.54475102924976 +2026-03-23T01:00:00+00:00,34.00788174613662 +2026-03-23T01:15:00+00:00,36.69791551842962 +2026-03-23T01:30:00+00:00,40.32006815162002 +2026-03-23T01:45:00+00:00,39.25284144237669 +2026-03-23T02:00:00+00:00,41.99110591371729 +2026-03-23T02:15:00+00:00,30.294636550504816 +2026-03-23T02:30:00+00:00,30.472445506919723 +2026-03-23T02:45:00+00:00,36.95918607476257 +2026-03-23T03:00:00+00:00,38.7538237899201 +2026-03-23T03:15:00+00:00,40.46370653299338 +2026-03-23T03:30:00+00:00,56.40067897604428 +2026-03-23T03:45:00+00:00,37.11632358679209 +2026-03-23T04:00:00+00:00,42.17654572428387 +2026-03-23T04:15:00+00:00,46.99462122021533 +2026-03-23T04:30:00+00:00,38.598886035251745 +2026-03-23T04:45:00+00:00,60.69016114502186 +2026-03-23T05:00:00+00:00,43.73695657090572 +2026-03-23T05:15:00+00:00,46.972637239580166 +2026-03-23T05:30:00+00:00,37.632402085229245 +2026-03-23T05:45:00+00:00,37.42879159262595 +2026-03-23T06:00:00+00:00,49.12338410852538 +2026-03-23T06:15:00+00:00,55.30379249472383 +2026-03-23T06:30:00+00:00,47.71908117966521 +2026-03-23T06:45:00+00:00,54.74482098709984 +2026-03-23T07:00:00+00:00,43.74466941810243 +2026-03-23T07:15:00+00:00,42.37617561057611 +2026-03-23T07:30:00+00:00,71.01485469523857 +2026-03-23T07:45:00+00:00,54.44442615819091 +2026-03-23T08:00:00+00:00,44.91845716424628 +2026-03-23T08:15:00+00:00,48.143886295090276 +2026-03-23T08:30:00+00:00,43.480896251431616 +2026-03-23T08:45:00+00:00,58.39594408144733 +2026-03-23T09:00:00+00:00,48.45751061035264 +2026-03-23T09:15:00+00:00,53.329234211650096 +2026-03-23T09:30:00+00:00,56.831373203946356 +2026-03-23T09:45:00+00:00,60.366975535964784 +2026-03-23T10:00:00+00:00,48.47315482034259 +2026-03-23T10:15:00+00:00,48.48396705386006 +2026-03-23T10:30:00+00:00,54.4798591745171 +2026-03-23T10:45:00+00:00,63.90886199614987 +2026-03-23T11:00:00+00:00,55.40647617967908 +2026-03-23T11:15:00+00:00,49.8888768948855 +2026-03-23T11:30:00+00:00,63.31166803284598 +2026-03-23T11:45:00+00:00,49.4463165380026 +2026-03-23T12:00:00+00:00,51.98198965725148 +2026-03-23T12:15:00+00:00,46.64790496746138 +2026-03-23T12:30:00+00:00,64.38719381657144 +2026-03-23T12:45:00+00:00,60.32235342646286 +2026-03-23T13:00:00+00:00,58.906181044511044 +2026-03-23T13:15:00+00:00,64.26510756896892 +2026-03-23T13:30:00+00:00,77.3280351120652 +2026-03-23T13:45:00+00:00,41.606627195114235 +2026-03-23T14:00:00+00:00,48.08851193650407 +2026-03-23T14:15:00+00:00,46.13014784353943 +2026-03-23T14:30:00+00:00,45.88654553596235 +2026-03-23T14:45:00+00:00,42.5052313099458 +2026-03-23T15:00:00+00:00,50.26752599984394 +2026-03-23T15:15:00+00:00,43.25033926379136 +2026-03-23T15:30:00+00:00,54.74745353034195 +2026-03-23T15:45:00+00:00,51.29822804140137 +2026-03-23T16:00:00+00:00,54.70795034863648 +2026-03-23T16:15:00+00:00,52.200403555971164 +2026-03-23T16:30:00+00:00,56.849714848821534 +2026-03-23T16:45:00+00:00,47.536495464508135 +2026-03-23T17:00:00+00:00,63.090590593340664 +2026-03-23T17:15:00+00:00,48.537918806727035 +2026-03-23T17:30:00+00:00,32.30867212155588 +2026-03-23T17:45:00+00:00,38.49997471977941 +2026-03-23T18:00:00+00:00,31.10506631303359 +2026-03-23T18:15:00+00:00,42.247795865302955 +2026-03-23T18:30:00+00:00,44.795357860465806 +2026-03-23T18:45:00+00:00,40.28646631859013 +2026-03-23T19:00:00+00:00,57.921967602987706 +2026-03-23T19:15:00+00:00,45.42507397510074 +2026-03-23T19:30:00+00:00,44.984464261197886 +2026-03-23T19:45:00+00:00,29.899123556012544 +2026-03-23T20:00:00+00:00,32.16959513212406 +2026-03-23T20:15:00+00:00,42.86883191800775 +2026-03-23T20:30:00+00:00,42.23162261384529 +2026-03-23T20:45:00+00:00,24.016424163841755 +2026-03-23T21:00:00+00:00,38.422744880481524 +2026-03-23T21:15:00+00:00,37.61590946934353 +2026-03-23T21:30:00+00:00,32.42086815695604 +2026-03-23T21:45:00+00:00,26.18447707934104 +2026-03-23T22:00:00+00:00,34.171561630182396 +2026-03-23T22:15:00+00:00,32.4735152230545 +2026-03-23T22:30:00+00:00,38.080897665076556 +2026-03-23T22:45:00+00:00,40.17653595436145 +2026-03-23T23:00:00+00:00,44.8870445892437 +2026-03-23T23:15:00+00:00,32.71445882874595 +2026-03-23T23:30:00+00:00,34.63400797657214 +2026-03-23T23:45:00+00:00,38.85013727503466 +2026-03-24T00:00:00+00:00,39.33296932684945 +2026-03-24T00:15:00+00:00,33.33395276972752 +2026-03-24T00:30:00+00:00,43.691448460486534 +2026-03-24T00:45:00+00:00,43.668626619239234 +2026-03-24T01:00:00+00:00,44.57516823947102 +2026-03-24T01:15:00+00:00,45.812405973992995 +2026-03-24T01:30:00+00:00,29.691204358445894 +2026-03-24T01:45:00+00:00,35.079731765322144 +2026-03-24T02:00:00+00:00,35.641534011740376 +2026-03-24T02:15:00+00:00,36.79624404979552 +2026-03-24T02:30:00+00:00,32.746505473017564 +2026-03-24T02:45:00+00:00,29.41067345278932 +2026-03-24T03:00:00+00:00,38.57471081276169 +2026-03-24T03:15:00+00:00,29.953082902544793 +2026-03-24T03:30:00+00:00,41.38496759253631 +2026-03-24T03:45:00+00:00,36.31155964913997 +2026-03-24T04:00:00+00:00,31.61169400698482 +2026-03-24T04:15:00+00:00,38.90383385377149 +2026-03-24T04:30:00+00:00,40.39697654756662 +2026-03-24T04:45:00+00:00,47.14575490301622 +2026-03-24T05:00:00+00:00,64.0373214244824 +2026-03-24T05:15:00+00:00,47.03032245351456 +2026-03-24T05:30:00+00:00,49.537144414312074 +2026-03-24T05:45:00+00:00,36.33349063929695 +2026-03-24T06:00:00+00:00,30.515030167833103 +2026-03-24T06:15:00+00:00,58.74742648740693 +2026-03-24T06:30:00+00:00,38.263221762028365 +2026-03-24T06:45:00+00:00,34.90178883133469 +2026-03-24T07:00:00+00:00,48.207371116889945 +2026-03-24T07:15:00+00:00,48.18839692351298 +2026-03-24T07:30:00+00:00,41.10485088806928 +2026-03-24T07:45:00+00:00,47.106750319400774 +2026-03-24T08:00:00+00:00,61.76299367972749 +2026-03-24T08:15:00+00:00,40.737865651672735 +2026-03-24T08:30:00+00:00,39.702341209304315 +2026-03-24T08:45:00+00:00,56.65878253075958 +2026-03-24T09:00:00+00:00,57.14742176002367 +2026-03-24T09:15:00+00:00,43.71465120180509 +2026-03-24T09:30:00+00:00,41.08750508532528 +2026-03-24T09:45:00+00:00,46.72853747775634 +2026-03-24T10:00:00+00:00,51.991776168025126 +2026-03-24T10:15:00+00:00,64.95201518020497 +2026-03-24T10:30:00+00:00,48.37046424072763 +2026-03-24T10:45:00+00:00,55.773752119734375 +2026-03-24T11:00:00+00:00,60.04389404301762 +2026-03-24T11:15:00+00:00,39.74839601011742 +2026-03-24T11:30:00+00:00,62.60390246136275 +2026-03-24T11:45:00+00:00,47.213063356412555 +2026-03-24T12:00:00+00:00,57.288297667204205 +2026-03-24T12:15:00+00:00,54.22633927523281 +2026-03-24T12:30:00+00:00,64.33116648677182 +2026-03-24T12:45:00+00:00,58.27766608551411 +2026-03-24T13:00:00+00:00,36.59359565893765 +2026-03-24T13:15:00+00:00,50.91472134866524 +2026-03-24T13:30:00+00:00,57.51173725851104 +2026-03-24T13:45:00+00:00,54.55522111831065 +2026-03-24T14:00:00+00:00,61.0417926042891 +2026-03-24T14:15:00+00:00,58.44129348475656 +2026-03-24T14:30:00+00:00,56.77510682093988 +2026-03-24T14:45:00+00:00,53.056618543304126 +2026-03-24T15:00:00+00:00,38.98787078567734 +2026-03-24T15:15:00+00:00,46.524221853899505 +2026-03-24T15:30:00+00:00,52.78499878199307 +2026-03-24T15:45:00+00:00,42.06180196701252 +2026-03-24T16:00:00+00:00,58.17752960338326 +2026-03-24T16:15:00+00:00,54.805728933706405 +2026-03-24T16:30:00+00:00,48.4656490439893 +2026-03-24T16:45:00+00:00,22.39720211169279 +2026-03-24T17:00:00+00:00,33.51722810758989 +2026-03-24T17:15:00+00:00,64.88849844549061 +2026-03-24T17:30:00+00:00,43.59868525264491 +2026-03-24T17:45:00+00:00,28.039211838539288 +2026-03-24T18:00:00+00:00,55.61024685727422 +2026-03-24T18:15:00+00:00,45.61365410599566 +2026-03-24T18:30:00+00:00,52.56747436126814 +2026-03-24T18:45:00+00:00,42.67988208004539 +2026-03-24T19:00:00+00:00,49.02502736384214 +2026-03-24T19:15:00+00:00,44.49896579891346 +2026-03-24T19:30:00+00:00,48.92716018925356 +2026-03-24T19:45:00+00:00,56.28325676335338 +2026-03-24T20:00:00+00:00,43.820881624270854 +2026-03-24T20:15:00+00:00,28.753962545052115 +2026-03-24T20:30:00+00:00,39.29790067092876 +2026-03-24T20:45:00+00:00,47.882947307908644 +2026-03-24T21:00:00+00:00,45.721804367974286 +2026-03-24T21:15:00+00:00,34.93229683199583 +2026-03-24T21:30:00+00:00,43.77520009259496 +2026-03-24T21:45:00+00:00,45.215310132900406 +2026-03-24T22:00:00+00:00,61.55710377207595 +2026-03-24T22:15:00+00:00,30.02190715973579 +2026-03-24T22:30:00+00:00,34.52125871636005 +2026-03-24T22:45:00+00:00,36.13370202463676 +2026-03-24T23:00:00+00:00,30.554382381015266 +2026-03-24T23:15:00+00:00,29.535248399732176 +2026-03-24T23:30:00+00:00,12.558594363522444 +2026-03-24T23:45:00+00:00,30.724004227697595 +2026-03-25T00:00:00+00:00,39.36533191553386 +2026-03-25T00:15:00+00:00,31.589864167007917 +2026-03-25T00:30:00+00:00,51.79474535806925 +2026-03-25T00:45:00+00:00,34.584678367999565 +2026-03-25T01:00:00+00:00,46.66799625291137 +2026-03-25T01:15:00+00:00,39.5807791794663 +2026-03-25T01:30:00+00:00,42.67788637930607 +2026-03-25T01:45:00+00:00,39.573179149481945 +2026-03-25T02:00:00+00:00,43.30172313404092 +2026-03-25T02:15:00+00:00,37.97334163064182 +2026-03-25T02:30:00+00:00,36.133498811319186 +2026-03-25T02:45:00+00:00,39.069748585567126 +2026-03-25T03:00:00+00:00,36.012997172280045 +2026-03-25T03:15:00+00:00,40.91926616326383 +2026-03-25T03:30:00+00:00,54.07762785168273 +2026-03-25T03:45:00+00:00,38.16709893658639 +2026-03-25T04:00:00+00:00,45.42085670490909 +2026-03-25T04:15:00+00:00,49.69702449996398 +2026-03-25T04:30:00+00:00,51.695374335575266 +2026-03-25T04:45:00+00:00,45.06111450394937 +2026-03-25T05:00:00+00:00,22.076584723499096 +2026-03-25T05:15:00+00:00,45.605417116401895 +2026-03-25T05:30:00+00:00,29.50930768511316 +2026-03-25T05:45:00+00:00,59.12275613467859 +2026-03-25T06:00:00+00:00,47.686567500788684 +2026-03-25T06:15:00+00:00,50.334127402698854 +2026-03-25T06:30:00+00:00,43.76733773267433 +2026-03-25T06:45:00+00:00,34.67159217241429 +2026-03-25T07:00:00+00:00,51.085457283477076 +2026-03-25T07:15:00+00:00,47.840657673724685 +2026-03-25T07:30:00+00:00,44.189730990387865 +2026-03-25T07:45:00+00:00,47.27753336993478 +2026-03-25T08:00:00+00:00,69.80597883304816 +2026-03-25T08:15:00+00:00,55.55035401454371 +2026-03-25T08:30:00+00:00,57.854268673716426 +2026-03-25T08:45:00+00:00,55.63499210836589 +2026-03-25T09:00:00+00:00,63.34190205198228 +2026-03-25T09:15:00+00:00,52.6702744439332 +2026-03-25T09:30:00+00:00,35.888432433087175 +2026-03-25T09:45:00+00:00,51.844031486368884 +2026-03-25T10:00:00+00:00,63.59397487421227 +2026-03-25T10:15:00+00:00,41.24882080280786 +2026-03-25T10:30:00+00:00,53.30788979229111 +2026-03-25T10:45:00+00:00,50.637321067604994 +2026-03-25T11:00:00+00:00,66.927635664955 +2026-03-25T11:15:00+00:00,56.68501736338016 +2026-03-25T11:30:00+00:00,58.551329265443094 +2026-03-25T11:45:00+00:00,60.166155771009564 +2026-03-25T12:00:00+00:00,53.64597294853243 +2026-03-25T12:15:00+00:00,57.416867076426726 +2026-03-25T12:30:00+00:00,44.57623617485972 +2026-03-25T12:45:00+00:00,35.09888060016378 +2026-03-25T13:00:00+00:00,54.25323706552185 +2026-03-25T13:15:00+00:00,54.54757003368387 +2026-03-25T13:30:00+00:00,63.60199953457164 +2026-03-25T13:45:00+00:00,67.91986895111624 +2026-03-25T14:00:00+00:00,65.48930335573267 +2026-03-25T14:15:00+00:00,46.037598270348845 +2026-03-25T14:30:00+00:00,49.03310259339004 +2026-03-25T14:45:00+00:00,56.42568051958973 +2026-03-25T15:00:00+00:00,72.45518296530686 +2026-03-25T15:15:00+00:00,60.0063796005615 +2026-03-25T15:30:00+00:00,61.99362086686301 +2026-03-25T15:45:00+00:00,50.014900270203306 +2026-03-25T16:00:00+00:00,41.280433884608236 +2026-03-25T16:15:00+00:00,54.348564838452816 +2026-03-25T16:30:00+00:00,39.08810508644632 +2026-03-25T16:45:00+00:00,43.39384891265879 +2026-03-25T17:00:00+00:00,44.448379982799736 +2026-03-25T17:15:00+00:00,58.597299332211726 +2026-03-25T17:30:00+00:00,40.19051400095207 +2026-03-25T17:45:00+00:00,24.28311021352178 +2026-03-25T18:00:00+00:00,50.734611114234774 +2026-03-25T18:15:00+00:00,38.16035225267189 +2026-03-25T18:30:00+00:00,42.69536000865814 +2026-03-25T18:45:00+00:00,41.855782312987955 +2026-03-25T19:00:00+00:00,43.40611120601663 +2026-03-25T19:15:00+00:00,49.513831561779234 +2026-03-25T19:30:00+00:00,25.42868863163631 +2026-03-25T19:45:00+00:00,37.07642249951173 +2026-03-25T20:00:00+00:00,46.06351996223083 +2026-03-25T20:15:00+00:00,31.394935489316403 +2026-03-25T20:30:00+00:00,28.077340023340277 +2026-03-25T20:45:00+00:00,43.52817490523644 +2026-03-25T21:00:00+00:00,44.6448836580625 +2026-03-25T21:15:00+00:00,36.871761720390815 +2026-03-25T21:30:00+00:00,18.813137543727038 +2026-03-25T21:45:00+00:00,43.411522462955915 +2026-03-25T22:00:00+00:00,21.936576720345208 +2026-03-25T22:15:00+00:00,36.91766947990647 +2026-03-25T22:30:00+00:00,26.936802545481477 +2026-03-25T22:45:00+00:00,26.485910197165065 +2026-03-25T23:00:00+00:00,32.25539330235074 +2026-03-25T23:15:00+00:00,31.825462428738675 +2026-03-25T23:30:00+00:00,25.436236919366625 +2026-03-25T23:45:00+00:00,38.218322453796134 +2026-03-26T00:00:00+00:00,30.50680956565565 +2026-03-26T00:15:00+00:00,43.09135587549733 +2026-03-26T00:30:00+00:00,36.61888246118633 +2026-03-26T00:45:00+00:00,29.972981354231774 +2026-03-26T01:00:00+00:00,30.241297713888397 +2026-03-26T01:15:00+00:00,20.905036998778286 +2026-03-26T01:30:00+00:00,37.193942359013846 +2026-03-26T01:45:00+00:00,32.15714449079823 +2026-03-26T02:00:00+00:00,16.114695217745783 +2026-03-26T02:15:00+00:00,32.98893465385741 +2026-03-26T02:30:00+00:00,30.41770256017173 +2026-03-26T02:45:00+00:00,53.087550970243036 +2026-03-26T03:00:00+00:00,25.246134350014437 +2026-03-26T03:15:00+00:00,26.53142831879152 +2026-03-26T03:30:00+00:00,44.976452536811784 +2026-03-26T03:45:00+00:00,52.41989256349294 +2026-03-26T04:00:00+00:00,33.26717256446383 +2026-03-26T04:15:00+00:00,35.531572723307555 +2026-03-26T04:30:00+00:00,51.113933124381575 +2026-03-26T04:45:00+00:00,22.406730127875928 +2026-03-26T05:00:00+00:00,28.67163656775852 +2026-03-26T05:15:00+00:00,40.23036377967596 +2026-03-26T05:30:00+00:00,58.078833535098994 +2026-03-26T05:45:00+00:00,42.109113617375925 +2026-03-26T06:00:00+00:00,47.89899730474143 +2026-03-26T06:15:00+00:00,52.42687124025079 +2026-03-26T06:30:00+00:00,64.1319593226434 +2026-03-26T06:45:00+00:00,42.825413245733415 +2026-03-26T07:00:00+00:00,48.384087547689866 +2026-03-26T07:15:00+00:00,59.553875861142956 +2026-03-26T07:30:00+00:00,36.99436566508458 +2026-03-26T07:45:00+00:00,58.205700737129625 +2026-03-26T08:00:00+00:00,43.43522138382742 +2026-03-26T08:15:00+00:00,53.387971980897944 +2026-03-26T08:30:00+00:00,47.032475456557336 +2026-03-26T08:45:00+00:00,40.80518961321836 +2026-03-26T09:00:00+00:00,47.84654985829429 +2026-03-26T09:15:00+00:00,53.343425699839074 +2026-03-26T09:30:00+00:00,63.53665720123902 +2026-03-26T09:45:00+00:00,55.245187793076504 +2026-03-26T10:00:00+00:00,55.96141962783037 +2026-03-26T10:15:00+00:00,60.964304433717025 +2026-03-26T10:30:00+00:00,45.938812396000095 +2026-03-26T10:45:00+00:00,60.617852946674134 +2026-03-26T11:00:00+00:00,62.81894078074655 +2026-03-26T11:15:00+00:00,74.5411849787984 +2026-03-26T11:30:00+00:00,50.44778664362971 +2026-03-26T11:45:00+00:00,50.5101067838894 +2026-03-26T12:00:00+00:00,57.46641664404451 +2026-03-26T12:15:00+00:00,62.78823950664204 +2026-03-26T12:30:00+00:00,50.48647599624141 +2026-03-26T12:45:00+00:00,54.61566887184418 +2026-03-26T13:00:00+00:00,59.85223234945059 +2026-03-26T13:15:00+00:00,51.272161633291795 +2026-03-26T13:30:00+00:00,52.94364890471787 +2026-03-26T13:45:00+00:00,55.73251530434222 +2026-03-26T14:00:00+00:00,64.1421698896964 +2026-03-26T14:15:00+00:00,45.810283693326895 +2026-03-26T14:30:00+00:00,40.298357413813534 +2026-03-26T14:45:00+00:00,37.07786243671571 +2026-03-26T15:00:00+00:00,39.71555693023709 +2026-03-26T15:15:00+00:00,38.7287481919153 +2026-03-26T15:30:00+00:00,51.50117831223478 +2026-03-26T15:45:00+00:00,41.53720717202891 +2026-03-26T16:00:00+00:00,43.46135963818752 +2026-03-26T16:15:00+00:00,38.86599332089643 +2026-03-26T16:30:00+00:00,45.14551676897104 +2026-03-26T16:45:00+00:00,59.854360908882974 +2026-03-26T17:00:00+00:00,50.70648739071523 +2026-03-26T17:15:00+00:00,45.89310520315692 +2026-03-26T17:30:00+00:00,40.65596471259014 +2026-03-26T17:45:00+00:00,44.33039879915879 +2026-03-26T18:00:00+00:00,47.64981839063783 +2026-03-26T18:15:00+00:00,48.177927880166 +2026-03-26T18:30:00+00:00,34.12128185431533 +2026-03-26T18:45:00+00:00,47.02845578618607 +2026-03-26T19:00:00+00:00,24.823884248534217 +2026-03-26T19:15:00+00:00,45.18894993618503 +2026-03-26T19:30:00+00:00,49.20909761326943 +2026-03-26T19:45:00+00:00,37.69088248649531 +2026-03-26T20:00:00+00:00,55.729218921662756 +2026-03-26T20:15:00+00:00,32.30997994670665 +2026-03-26T20:30:00+00:00,38.797861457531354 +2026-03-26T20:45:00+00:00,36.51534265945248 +2026-03-26T21:00:00+00:00,36.88052625700348 +2026-03-26T21:15:00+00:00,38.98424255109614 +2026-03-26T21:30:00+00:00,37.24098221171424 +2026-03-26T21:45:00+00:00,43.70194188140453 +2026-03-26T22:00:00+00:00,49.22039421072969 +2026-03-26T22:15:00+00:00,38.163351350476795 +2026-03-26T22:30:00+00:00,39.85728731743825 +2026-03-26T22:45:00+00:00,35.296658580581 +2026-03-26T23:00:00+00:00,43.44542676402521 +2026-03-26T23:15:00+00:00,28.47452176339629 +2026-03-26T23:30:00+00:00,44.64315964337176 +2026-03-26T23:45:00+00:00,55.435706083365204 +2026-03-27T00:00:00+00:00,32.548640000189096 +2026-03-27T00:15:00+00:00,16.986609666080586 +2026-03-27T00:30:00+00:00,29.603395201117053 +2026-03-27T00:45:00+00:00,42.263947269711785 +2026-03-27T01:00:00+00:00,39.020881894825564 +2026-03-27T01:15:00+00:00,37.09519328502741 +2026-03-27T01:30:00+00:00,41.45363304892014 +2026-03-27T01:45:00+00:00,39.741808113424995 +2026-03-27T02:00:00+00:00,46.23997734914329 +2026-03-27T02:15:00+00:00,43.36112385711623 +2026-03-27T02:30:00+00:00,50.5172674118719 +2026-03-27T02:45:00+00:00,38.05539162762886 +2026-03-27T03:00:00+00:00,46.23154725284971 +2026-03-27T03:15:00+00:00,29.120322953899546 +2026-03-27T03:30:00+00:00,38.52779902882254 +2026-03-27T03:45:00+00:00,53.08587328783239 +2026-03-27T04:00:00+00:00,40.68503264282727 +2026-03-27T04:15:00+00:00,48.24247485377575 +2026-03-27T04:30:00+00:00,41.820484637950436 +2026-03-27T04:45:00+00:00,36.62127375728475 +2026-03-27T05:00:00+00:00,42.440267273211774 +2026-03-27T05:15:00+00:00,55.72743560202759 +2026-03-27T05:30:00+00:00,45.82706372691131 +2026-03-27T05:45:00+00:00,32.09843569352242 +2026-03-27T06:00:00+00:00,42.361142135700476 +2026-03-27T06:15:00+00:00,61.07084670664673 +2026-03-27T06:30:00+00:00,42.86698915351794 +2026-03-27T06:45:00+00:00,30.264159470843264 +2026-03-27T07:00:00+00:00,62.47792459795375 +2026-03-27T07:15:00+00:00,51.50115949767263 +2026-03-27T07:30:00+00:00,47.020943790123674 +2026-03-27T07:45:00+00:00,48.00003024859065 +2026-03-27T08:00:00+00:00,60.07103339299613 +2026-03-27T08:15:00+00:00,45.04574800599839 +2026-03-27T08:30:00+00:00,59.7534896364145 +2026-03-27T08:45:00+00:00,56.720647358223 +2026-03-27T09:00:00+00:00,52.41985110002977 +2026-03-27T09:15:00+00:00,50.23897459642304 +2026-03-27T09:30:00+00:00,49.482604925255316 +2026-03-27T09:45:00+00:00,63.788904118550946 +2026-03-27T10:00:00+00:00,50.10792421822435 +2026-03-27T10:15:00+00:00,41.749800819941484 +2026-03-27T10:30:00+00:00,56.556523530538236 +2026-03-27T10:45:00+00:00,60.20647440967155 +2026-03-27T11:00:00+00:00,59.295564146867214 +2026-03-27T11:15:00+00:00,58.95017089453011 +2026-03-27T11:30:00+00:00,55.83175319562619 +2026-03-27T11:45:00+00:00,63.41535957068169 +2026-03-27T12:00:00+00:00,57.19025447130842 +2026-03-27T12:15:00+00:00,49.71936945068078 +2026-03-27T12:30:00+00:00,51.84234351435062 +2026-03-27T12:45:00+00:00,71.06315034731394 +2026-03-27T13:00:00+00:00,42.456211298108975 +2026-03-27T13:15:00+00:00,58.45678809418011 +2026-03-27T13:30:00+00:00,42.319323748043516 +2026-03-27T13:45:00+00:00,55.221338067084886 +2026-03-27T14:00:00+00:00,51.03260107094035 +2026-03-27T14:15:00+00:00,40.413007554033385 +2026-03-27T14:30:00+00:00,55.66405293738887 +2026-03-27T14:45:00+00:00,50.919884695217256 +2026-03-27T15:00:00+00:00,33.69910696743126 +2026-03-27T15:15:00+00:00,58.406542659902975 +2026-03-27T15:30:00+00:00,64.33368050084543 +2026-03-27T15:45:00+00:00,65.78633739701912 +2026-03-27T16:00:00+00:00,53.44968646963052 +2026-03-27T16:15:00+00:00,52.50092004462148 +2026-03-27T16:30:00+00:00,63.10004236911153 +2026-03-27T16:45:00+00:00,61.73040570750001 +2026-03-27T17:00:00+00:00,59.28088543155592 +2026-03-27T17:15:00+00:00,42.48012291561368 +2026-03-27T17:30:00+00:00,47.58554304543979 +2026-03-27T17:45:00+00:00,50.758276010557076 +2026-03-27T18:00:00+00:00,26.91672430750316 +2026-03-27T18:15:00+00:00,44.241887848651814 +2026-03-27T18:30:00+00:00,59.502270504845214 +2026-03-27T18:45:00+00:00,48.819515415871415 +2026-03-27T19:00:00+00:00,55.61289983675868 +2026-03-27T19:15:00+00:00,39.67764908680524 +2026-03-27T19:30:00+00:00,40.14071578116697 +2026-03-27T19:45:00+00:00,43.327263669277 +2026-03-27T20:00:00+00:00,30.963153047637235 +2026-03-27T20:15:00+00:00,31.083106331856676 +2026-03-27T20:30:00+00:00,51.08167763548167 +2026-03-27T20:45:00+00:00,49.39987714436389 +2026-03-27T21:00:00+00:00,31.992356291544347 +2026-03-27T21:15:00+00:00,46.886640611776336 +2026-03-27T21:30:00+00:00,37.539501725089295 +2026-03-27T21:45:00+00:00,39.18576372679577 +2026-03-27T22:00:00+00:00,40.68277126829138 +2026-03-27T22:15:00+00:00,41.9286780057094 +2026-03-27T22:30:00+00:00,38.94631194953812 +2026-03-27T22:45:00+00:00,50.46747933760523 +2026-03-27T23:00:00+00:00,35.35800120275948 +2026-03-27T23:15:00+00:00,37.92230624196725 +2026-03-27T23:30:00+00:00,28.367546185447683 +2026-03-27T23:45:00+00:00,49.6985314665854 +2026-03-28T00:00:00+00:00,39.89508225013403 +2026-03-28T00:15:00+00:00,38.22370192740534 +2026-03-28T00:30:00+00:00,26.892600974019977 +2026-03-28T00:45:00+00:00,32.5243155795948 +2026-03-28T01:00:00+00:00,46.66926581954492 +2026-03-28T01:15:00+00:00,36.850023468158305 +2026-03-28T01:30:00+00:00,22.88134660099221 +2026-03-28T01:45:00+00:00,43.52611215227055 +2026-03-28T02:00:00+00:00,30.117895360459737 +2026-03-28T02:15:00+00:00,44.02315804379733 +2026-03-28T02:30:00+00:00,34.60769754952452 +2026-03-28T02:45:00+00:00,42.217185244920366 +2026-03-28T03:00:00+00:00,50.30832542885935 +2026-03-28T03:15:00+00:00,32.44122192685687 +2026-03-28T03:30:00+00:00,30.922792178301254 +2026-03-28T03:45:00+00:00,34.90872999917547 +2026-03-28T04:00:00+00:00,45.40104694520307 +2026-03-28T04:15:00+00:00,28.913979725460823 +2026-03-28T04:30:00+00:00,27.461167578474534 +2026-03-28T04:45:00+00:00,46.50413706511836 +2026-03-28T05:00:00+00:00,39.01134595713207 +2026-03-28T05:15:00+00:00,38.49340581224542 +2026-03-28T05:30:00+00:00,27.649472422109973 +2026-03-28T05:45:00+00:00,40.10868441833017 +2026-03-28T06:00:00+00:00,45.839591674099644 +2026-03-28T06:15:00+00:00,55.30653729163786 +2026-03-28T06:30:00+00:00,38.56612084077755 +2026-03-28T06:45:00+00:00,61.93901292084898 +2026-03-28T07:00:00+00:00,38.69855495973504 +2026-03-28T07:15:00+00:00,42.20801794100902 +2026-03-28T07:30:00+00:00,58.967026198353324 +2026-03-28T07:45:00+00:00,50.76170975602487 +2026-03-28T08:00:00+00:00,48.223461930393654 +2026-03-28T08:15:00+00:00,44.72672795910484 +2026-03-28T08:30:00+00:00,64.53989408675274 +2026-03-28T08:45:00+00:00,65.7828881460878 +2026-03-28T09:00:00+00:00,44.61236359939957 +2026-03-28T09:15:00+00:00,50.455525522668026 +2026-03-28T09:30:00+00:00,54.21583999115627 +2026-03-28T09:45:00+00:00,41.59643156539456 +2026-03-28T10:00:00+00:00,55.891610640570065 +2026-03-28T10:15:00+00:00,57.70258331362975 +2026-03-28T10:30:00+00:00,61.752757724419155 +2026-03-28T10:45:00+00:00,62.948485704959545 +2026-03-28T11:00:00+00:00,53.227466138917016 +2026-03-28T11:15:00+00:00,51.442677394180286 +2026-03-28T11:30:00+00:00,58.82650125778986 +2026-03-28T11:45:00+00:00,51.78015638181429 +2026-03-28T12:00:00+00:00,59.622308833913216 +2026-03-28T12:15:00+00:00,55.61754565021713 +2026-03-28T12:30:00+00:00,50.10827638702386 +2026-03-28T12:45:00+00:00,45.36782201101515 +2026-03-28T13:00:00+00:00,66.64759499860452 +2026-03-28T13:15:00+00:00,50.84065528493052 +2026-03-28T13:30:00+00:00,69.35936256288703 +2026-03-28T13:45:00+00:00,49.585610302587334 +2026-03-28T14:00:00+00:00,56.35425490725622 +2026-03-28T14:15:00+00:00,75.27672635023484 +2026-03-28T14:30:00+00:00,41.463754914630506 +2026-03-28T14:45:00+00:00,38.03546224240587 +2026-03-28T15:00:00+00:00,60.10853455020339 +2026-03-28T15:15:00+00:00,45.96285298079345 +2026-03-28T15:30:00+00:00,47.13601895209475 +2026-03-28T15:45:00+00:00,44.9059154765703 +2026-03-28T16:00:00+00:00,51.22812030761266 +2026-03-28T16:15:00+00:00,44.22664550551058 +2026-03-28T16:30:00+00:00,48.175058085785494 +2026-03-28T16:45:00+00:00,49.3711203178945 +2026-03-28T17:00:00+00:00,38.09876716066451 +2026-03-28T17:15:00+00:00,43.49268426322278 +2026-03-28T17:30:00+00:00,42.83452412179876 +2026-03-28T17:45:00+00:00,34.591604817097576 +2026-03-28T18:00:00+00:00,40.601015756672766 +2026-03-28T18:15:00+00:00,47.03158145630266 +2026-03-28T18:30:00+00:00,42.627089819055975 +2026-03-28T18:45:00+00:00,45.31845192671329 +2026-03-28T19:00:00+00:00,40.902164228803606 +2026-03-28T19:15:00+00:00,35.20103761227864 +2026-03-28T19:30:00+00:00,46.98753312537222 +2026-03-28T19:45:00+00:00,51.26036632440076 +2026-03-28T20:00:00+00:00,33.08805757374584 +2026-03-28T20:15:00+00:00,45.13524004513993 +2026-03-28T20:30:00+00:00,39.18625157447625 +2026-03-28T20:45:00+00:00,38.387234384225394 +2026-03-28T21:00:00+00:00,49.47005791168955 +2026-03-28T21:15:00+00:00,43.852049795256626 +2026-03-28T21:30:00+00:00,18.486687128802522 +2026-03-28T21:45:00+00:00,40.5772194412201 +2026-03-28T22:00:00+00:00,46.55995939940646 +2026-03-28T22:15:00+00:00,33.01232110262911 +2026-03-28T22:30:00+00:00,44.37260844161235 +2026-03-28T22:45:00+00:00,30.900872169475996 +2026-03-28T23:00:00+00:00,20.539243259844074 +2026-03-28T23:15:00+00:00,38.377709130331645 +2026-03-28T23:30:00+00:00,20.583466641814617 +2026-03-28T23:45:00+00:00,36.103304529918276 +2026-03-29T00:00:00+00:00,42.45658041291491 +2026-03-29T00:15:00+00:00,18.745006600506905 +2026-03-29T00:30:00+00:00,23.26070517941787 +2026-03-29T00:45:00+00:00,25.7187600935787 +2026-03-29T01:00:00+00:00,39.208162526594045 +2026-03-29T01:15:00+00:00,31.713505548450144 +2026-03-29T01:30:00+00:00,46.90435141286494 +2026-03-29T01:45:00+00:00,43.364551417219886 +2026-03-29T02:00:00+00:00,39.245058221718686 +2026-03-29T02:15:00+00:00,36.527611545858626 +2026-03-29T02:30:00+00:00,41.54552557474149 +2026-03-29T02:45:00+00:00,29.042971599435873 +2026-03-29T03:00:00+00:00,45.521490360016585 +2026-03-29T03:15:00+00:00,30.772616444431975 +2026-03-29T03:30:00+00:00,45.09512515901225 +2026-03-29T03:45:00+00:00,49.502217388038225 +2026-03-29T04:00:00+00:00,40.07835403689675 +2026-03-29T04:15:00+00:00,38.84030599547872 +2026-03-29T04:30:00+00:00,41.025670445146616 +2026-03-29T04:45:00+00:00,17.10224732283424 +2026-03-29T05:00:00+00:00,45.306731635742054 +2026-03-29T05:15:00+00:00,44.92380612849406 +2026-03-29T05:30:00+00:00,44.30662949379963 +2026-03-29T05:45:00+00:00,50.874198594896185 +2026-03-29T06:00:00+00:00,45.89749233157594 +2026-03-29T06:15:00+00:00,34.879585444651916 +2026-03-29T06:30:00+00:00,44.58007107348524 +2026-03-29T06:45:00+00:00,46.62739327565694 +2026-03-29T07:00:00+00:00,44.981011952475754 +2026-03-29T07:15:00+00:00,46.72443384891774 +2026-03-29T07:30:00+00:00,57.8689228873063 +2026-03-29T07:45:00+00:00,43.064091122596366 +2026-03-29T08:00:00+00:00,52.07167197687287 +2026-03-29T08:15:00+00:00,58.61753586569514 +2026-03-29T08:30:00+00:00,39.15913203304058 +2026-03-29T08:45:00+00:00,51.979649855134724 +2026-03-29T09:00:00+00:00,44.66713714648402 +2026-03-29T09:15:00+00:00,50.804403968968316 +2026-03-29T09:30:00+00:00,47.07370950240113 +2026-03-29T09:45:00+00:00,55.58436025753712 +2026-03-29T10:00:00+00:00,67.57790332579212 +2026-03-29T10:15:00+00:00,46.45141629264552 +2026-03-29T10:30:00+00:00,44.51845529680306 +2026-03-29T10:45:00+00:00,45.153925521025826 +2026-03-29T11:00:00+00:00,71.76051024553263 +2026-03-29T11:15:00+00:00,51.953321626899715 +2026-03-29T11:30:00+00:00,56.49628357136656 +2026-03-29T11:45:00+00:00,54.32704899154178 +2026-03-29T12:00:00+00:00,49.89181964679194 +2026-03-29T12:15:00+00:00,51.95843333630319 +2026-03-29T12:30:00+00:00,77.79362049928471 +2026-03-29T12:45:00+00:00,65.69062170511286 +2026-03-29T13:00:00+00:00,46.55242494677486 +2026-03-29T13:15:00+00:00,59.767514503711375 +2026-03-29T13:30:00+00:00,51.33863582596386 +2026-03-29T13:45:00+00:00,38.547713859505066 +2026-03-29T14:00:00+00:00,63.57798990705773 +2026-03-29T14:15:00+00:00,62.04804300047033 +2026-03-29T14:30:00+00:00,65.88580447156812 +2026-03-29T14:45:00+00:00,45.93392676576646 +2026-03-29T15:00:00+00:00,48.04205033274038 +2026-03-29T15:15:00+00:00,54.63428179233424 +2026-03-29T15:30:00+00:00,53.79410128572093 +2026-03-29T15:45:00+00:00,48.1660234611568 +2026-03-29T16:00:00+00:00,34.29564964854497 +2026-03-29T16:15:00+00:00,46.185318865240404 +2026-03-29T16:30:00+00:00,56.403581470913444 +2026-03-29T16:45:00+00:00,40.956488737590675 +2026-03-29T17:00:00+00:00,48.80765562107819 +2026-03-29T17:15:00+00:00,47.75267512226771 +2026-03-29T17:30:00+00:00,54.261225589131065 +2026-03-29T17:45:00+00:00,52.16924074863133 +2026-03-29T18:00:00+00:00,39.569450248798404 +2026-03-29T18:15:00+00:00,28.61599827776738 +2026-03-29T18:30:00+00:00,40.00367903860958 +2026-03-29T18:45:00+00:00,50.773335122409776 +2026-03-29T19:00:00+00:00,39.78193390456371 +2026-03-29T19:15:00+00:00,37.910539718251215 +2026-03-29T19:30:00+00:00,26.38215839245259 +2026-03-29T19:45:00+00:00,55.89229186810936 +2026-03-29T20:00:00+00:00,44.06107396930314 +2026-03-29T20:15:00+00:00,44.67122177474023 +2026-03-29T20:30:00+00:00,48.06727873533808 +2026-03-29T20:45:00+00:00,39.30845299226739 +2026-03-29T21:00:00+00:00,33.44264512365793 +2026-03-29T21:15:00+00:00,34.79257404672636 +2026-03-29T21:30:00+00:00,34.721068406144106 +2026-03-29T21:45:00+00:00,58.58770388077344 +2026-03-29T22:00:00+00:00,38.59495058106962 +2026-03-29T22:15:00+00:00,32.59449291685408 +2026-03-29T22:30:00+00:00,41.96919542719201 +2026-03-29T22:45:00+00:00,29.978823432125058 +2026-03-29T23:00:00+00:00,43.761845601037535 +2026-03-29T23:15:00+00:00,28.988418197493967 +2026-03-29T23:30:00+00:00,41.01314596053893 +2026-03-29T23:45:00+00:00,45.85807848869675 +2026-03-30T00:00:00+00:00,37.29924800276806 +2026-03-30T00:15:00+00:00,35.121613968705425 +2026-03-30T00:30:00+00:00,36.7898610189986 +2026-03-30T00:45:00+00:00,37.96031522964705 +2026-03-30T01:00:00+00:00,29.771669885815367 +2026-03-30T01:15:00+00:00,28.93916966690959 +2026-03-30T01:30:00+00:00,46.83083557811101 +2026-03-30T01:45:00+00:00,36.69061624669202 +2026-03-30T02:00:00+00:00,25.246549495188958 +2026-03-30T02:15:00+00:00,30.550948788495234 +2026-03-30T02:30:00+00:00,46.64231489557794 +2026-03-30T02:45:00+00:00,41.22386235550805 +2026-03-30T03:00:00+00:00,41.82623794770654 +2026-03-30T03:15:00+00:00,36.79231423806177 +2026-03-30T03:30:00+00:00,43.442130032370216 +2026-03-30T03:45:00+00:00,33.68038664521635 +2026-03-30T04:00:00+00:00,36.58522684194672 +2026-03-30T04:15:00+00:00,50.45066201655474 +2026-03-30T04:30:00+00:00,50.6614617945668 +2026-03-30T04:45:00+00:00,36.3476092982581 +2026-03-30T05:00:00+00:00,37.809842539953074 +2026-03-30T05:15:00+00:00,46.092807003269854 +2026-03-30T05:30:00+00:00,55.98101493882051 +2026-03-30T05:45:00+00:00,28.953891221740687 +2026-03-30T06:00:00+00:00,51.18866679978946 +2026-03-30T06:15:00+00:00,31.97365285948574 +2026-03-30T06:30:00+00:00,39.37509254387636 +2026-03-30T06:45:00+00:00,43.75406553250053 +2026-03-30T07:00:00+00:00,43.066504125769974 +2026-03-30T07:15:00+00:00,46.36887549793229 +2026-03-30T07:30:00+00:00,48.0343103352638 +2026-03-30T07:45:00+00:00,43.883664053725205 +2026-03-30T08:00:00+00:00,36.35472587750234 +2026-03-30T08:15:00+00:00,56.31677404020002 +2026-03-30T08:30:00+00:00,56.25437055624856 +2026-03-30T08:45:00+00:00,57.85670142425815 +2026-03-30T09:00:00+00:00,39.4245888584453 +2026-03-30T09:15:00+00:00,54.74543931074227 +2026-03-30T09:30:00+00:00,59.235373226847344 +2026-03-30T09:45:00+00:00,44.231131613648834 +2026-03-30T10:00:00+00:00,56.65401727295377 +2026-03-30T10:15:00+00:00,51.463273845561474 +2026-03-30T10:30:00+00:00,70.94709046487866 +2026-03-30T10:45:00+00:00,70.81812291077861 +2026-03-30T11:00:00+00:00,41.39282733437298 +2026-03-30T11:15:00+00:00,73.90896415901976 +2026-03-30T11:30:00+00:00,46.94122818574831 +2026-03-30T11:45:00+00:00,63.01587737897719 +2026-03-30T12:00:00+00:00,66.23629980197872 +2026-03-30T12:15:00+00:00,49.592380195432916 +2026-03-30T12:30:00+00:00,56.664585483972864 +2026-03-30T12:45:00+00:00,55.641888156766484 +2026-03-30T13:00:00+00:00,58.22609970403358 +2026-03-30T13:15:00+00:00,56.382654675354054 +2026-03-30T13:30:00+00:00,66.14965179776685 +2026-03-30T13:45:00+00:00,53.93965706071896 +2026-03-30T14:00:00+00:00,53.19938132115169 +2026-03-30T14:15:00+00:00,63.688116694441284 +2026-03-30T14:30:00+00:00,56.83758025850426 +2026-03-30T14:45:00+00:00,56.38367360572869 +2026-03-30T15:00:00+00:00,47.242889581479055 +2026-03-30T15:15:00+00:00,43.02513078884509 +2026-03-30T15:30:00+00:00,44.476674352991445 +2026-03-30T15:45:00+00:00,48.61780854434893 +2026-03-30T16:00:00+00:00,60.375317786187736 +2026-03-30T16:15:00+00:00,59.101855550997634 +2026-03-30T16:30:00+00:00,45.14291510486545 +2026-03-30T16:45:00+00:00,62.36197785943228 +2026-03-30T17:00:00+00:00,58.30711195249267 +2026-03-30T17:15:00+00:00,47.51509399661874 +2026-03-30T17:30:00+00:00,37.6736790885392 +2026-03-30T17:45:00+00:00,59.37263414816877 +2026-03-30T18:00:00+00:00,46.83333496225484 +2026-03-30T18:15:00+00:00,44.90578904199344 +2026-03-30T18:30:00+00:00,44.408942758600276 +2026-03-30T18:45:00+00:00,33.06132336135513 +2026-03-30T19:00:00+00:00,27.951806810919944 +2026-03-30T19:15:00+00:00,46.73962661076707 +2026-03-30T19:30:00+00:00,37.6613376899173 +2026-03-30T19:45:00+00:00,64.71718963169653 +2026-03-30T20:00:00+00:00,30.331857790225218 +2026-03-30T20:15:00+00:00,30.26603188910144 +2026-03-30T20:30:00+00:00,44.130532750213675 +2026-03-30T20:45:00+00:00,48.96321556793244 +2026-03-30T21:00:00+00:00,36.59151672032765 +2026-03-30T21:15:00+00:00,41.3951503758925 +2026-03-30T21:30:00+00:00,23.854754405657467 +2026-03-30T21:45:00+00:00,29.647122442191176 +2026-03-30T22:00:00+00:00,29.026471893896925 +2026-03-30T22:15:00+00:00,21.769309758717714 +2026-03-30T22:30:00+00:00,39.01769562995605 +2026-03-30T22:45:00+00:00,17.21756600176996 +2026-03-30T23:00:00+00:00,26.03661447880957 +2026-03-30T23:15:00+00:00,30.142208868067364 +2026-03-30T23:30:00+00:00,26.645776160714203 +2026-03-30T23:45:00+00:00,37.86984315571677 +2026-03-31T00:00:00+00:00,41.46906170244662 +2026-03-31T00:15:00+00:00,39.42066568547176 +2026-03-31T00:30:00+00:00,40.70356934130933 +2026-03-31T00:45:00+00:00,44.43917334245137 +2026-03-31T01:00:00+00:00,35.865462804707654 +2026-03-31T01:15:00+00:00,40.40614962697157 +2026-03-31T01:30:00+00:00,40.24947685716546 +2026-03-31T01:45:00+00:00,37.335286841331495 +2026-03-31T02:00:00+00:00,34.10561807959539 +2026-03-31T02:15:00+00:00,34.29573627268169 +2026-03-31T02:30:00+00:00,32.19152833063188 +2026-03-31T02:45:00+00:00,42.052189196887035 +2026-03-31T03:00:00+00:00,37.17195921687474 +2026-03-31T03:15:00+00:00,35.407826101771235 +2026-03-31T03:30:00+00:00,37.91126990155035 +2026-03-31T03:45:00+00:00,28.949377213092706 +2026-03-31T04:00:00+00:00,47.26802344736851 +2026-03-31T04:15:00+00:00,40.48574604890145 +2026-03-31T04:30:00+00:00,31.179280065517773 +2026-03-31T04:45:00+00:00,49.3036843646532 +2026-03-31T05:00:00+00:00,36.33888409332772 +2026-03-31T05:15:00+00:00,31.13287970049138 +2026-03-31T05:30:00+00:00,48.862629029495935 +2026-03-31T05:45:00+00:00,36.78367090025339 +2026-03-31T06:00:00+00:00,38.444902590983986 +2026-03-31T06:15:00+00:00,45.86024396429315 +2026-03-31T06:30:00+00:00,60.01599021472198 +2026-03-31T06:45:00+00:00,47.62063301632398 +2026-03-31T07:00:00+00:00,37.71064124945623 +2026-03-31T07:15:00+00:00,51.565822986389925 +2026-03-31T07:30:00+00:00,44.86840596140144 +2026-03-31T07:45:00+00:00,63.83040470450165 +2026-03-31T08:00:00+00:00,52.29554955696948 +2026-03-31T08:15:00+00:00,34.16484108044289 +2026-03-31T08:30:00+00:00,55.403876277727406 +2026-03-31T08:45:00+00:00,68.9840576899698 +2026-03-31T09:00:00+00:00,61.20423301089099 +2026-03-31T09:15:00+00:00,66.27875521857372 +2026-03-31T09:30:00+00:00,46.754615343049245 +2026-03-31T09:45:00+00:00,59.902003554975266 +2026-03-31T10:00:00+00:00,45.77018279050409 +2026-03-31T10:15:00+00:00,55.19406349196475 +2026-03-31T10:30:00+00:00,43.17935492868172 +2026-03-31T10:45:00+00:00,51.11524096156482 +2026-03-31T11:00:00+00:00,50.28069804303203 +2026-03-31T11:15:00+00:00,62.14349612100485 +2026-03-31T11:30:00+00:00,60.44678531760847 +2026-03-31T11:45:00+00:00,59.07109810364638 +2026-03-31T12:00:00+00:00,44.163143341931324 +2026-03-31T12:15:00+00:00,52.09088420884633 +2026-03-31T12:30:00+00:00,48.95829402766336 +2026-03-31T12:45:00+00:00,60.45189495346259 +2026-03-31T13:00:00+00:00,65.69977191626923 +2026-03-31T13:15:00+00:00,64.63205978350553 +2026-03-31T13:30:00+00:00,46.17569263381301 +2026-03-31T13:45:00+00:00,34.05791593606701 +2026-03-31T14:00:00+00:00,40.48846419215876 +2026-03-31T14:15:00+00:00,65.17153955638788 +2026-03-31T14:30:00+00:00,50.20343117045676 +2026-03-31T14:45:00+00:00,45.50325763604908 +2026-03-31T15:00:00+00:00,65.3349307563307 +2026-03-31T15:15:00+00:00,59.277757559103165 +2026-03-31T15:30:00+00:00,51.62107032089483 +2026-03-31T15:45:00+00:00,48.40105189434779 +2026-03-31T16:00:00+00:00,47.266691461833446 +2026-03-31T16:15:00+00:00,51.117775511838985 +2026-03-31T16:30:00+00:00,59.4556793778013 +2026-03-31T16:45:00+00:00,52.00536113310344 +2026-03-31T17:00:00+00:00,53.86720828910482 +2026-03-31T17:15:00+00:00,59.07218561730858 +2026-03-31T17:30:00+00:00,38.67215319321676 +2026-03-31T17:45:00+00:00,51.64306211668097 +2026-03-31T18:00:00+00:00,39.78069691243143 +2026-03-31T18:15:00+00:00,28.177660868952707 +2026-03-31T18:30:00+00:00,29.098718230396614 +2026-03-31T18:45:00+00:00,34.38353603921043 +2026-03-31T19:00:00+00:00,46.23963334529 +2026-03-31T19:15:00+00:00,40.61317988154483 +2026-03-31T19:30:00+00:00,41.04067794332372 +2026-03-31T19:45:00+00:00,54.00576204811975 +2026-03-31T20:00:00+00:00,27.86751282428248 +2026-03-31T20:15:00+00:00,35.41505222147833 +2026-03-31T20:30:00+00:00,42.828241767119856 +2026-03-31T20:45:00+00:00,34.16355454608484 +2026-03-31T21:00:00+00:00,41.13553411904821 +2026-03-31T21:15:00+00:00,24.47792083349207 +2026-03-31T21:30:00+00:00,45.91744990383329 +2026-03-31T21:45:00+00:00,33.25386320393709 +2026-03-31T22:00:00+00:00,55.24799817307322 +2026-03-31T22:15:00+00:00,33.032373796079185 +2026-03-31T22:30:00+00:00,40.710175298243364 +2026-03-31T22:45:00+00:00,32.20909164133087 +2026-03-31T23:00:00+00:00,34.483059516191275 +2026-03-31T23:15:00+00:00,27.827651268294 +2026-03-31T23:30:00+00:00,38.673776482647085 +2026-03-31T23:45:00+00:00,36.40042029018594 diff --git a/tests/test_analysis/__init__.py b/tests/test_analysis/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_analysis/test_metrics.py b/tests/test_analysis/test_metrics.py new file mode 100644 index 0000000..56b1623 --- /dev/null +++ b/tests/test_analysis/test_metrics.py @@ -0,0 +1,118 @@ +"""Tests for analysis/metrics.py: BacktestResult.summary().""" + +from __future__ import annotations + +from datetime import UTC, date, datetime +from decimal import Decimal + +from nexa_backtest.analysis.metrics import BacktestResult +from nexa_backtest.analysis.pnl import PnlSummary, SideSummary +from nexa_backtest.types import Fill, Side + + +def _empty_side() -> SideSummary: + return SideSummary( + count=0, + volume_mwh=Decimal("0"), + avg_price=Decimal("0"), + vwap_alpha=Decimal("0"), + total_alpha_eur=Decimal("0"), + win_rate=0.0, + ) + + +def _side( + count: int, + volume: float, + avg_price: float, + vwap_alpha: float, + total_alpha: float, + win_rate: float, +) -> SideSummary: + return SideSummary( + count=count, + volume_mwh=Decimal(str(volume)), + avg_price=Decimal(str(avg_price)), + vwap_alpha=Decimal(str(vwap_alpha)), + total_alpha_eur=Decimal(str(total_alpha)), + win_rate=win_rate, + ) + + +def _fill(side: Side, price: float) -> Fill: + return Fill( + order_id="o1", + product_id="P1", + side=side, + price=Decimal(str(price)), + volume=Decimal("10"), + timestamp=datetime(2026, 3, 1, 12, 0, tzinfo=UTC), + ) + + +def _result(buys: SideSummary, sells: SideSummary, fills: tuple[Fill, ...] = ()) -> BacktestResult: + pnl = PnlSummary( + market_vwap=Decimal("50"), + buys=buys, + sells=sells, + total_alpha_eur=buys.total_alpha_eur + sells.total_alpha_eur, + ) + return BacktestResult( + algo_name="TestAlgo", + exchange="nordpool", + start=date(2026, 3, 1), + end=date(2026, 3, 31), + fills=fills, + pnl=pnl, + ) + + +class TestBacktestResultSummary: + def test_summary_with_only_buys(self) -> None: + buys = _side(3, 30.0, 45.0, -5.0, 150.0, 1.0) + result = _result(buys=buys, sells=_empty_side()) + summary = result.summary() + assert "Buys" in summary + assert "Sells" not in summary + + def test_summary_with_only_sells(self) -> None: + sells = _side(2, 20.0, 55.0, -5.0, 100.0, 1.0) + result = _result(buys=_empty_side(), sells=sells) + summary = result.summary() + assert "Sells" in summary + assert "Buys" not in summary + + def test_summary_with_both_buys_and_sells(self) -> None: + buys = _side(2, 20.0, 45.0, -5.0, 100.0, 1.0) + sells = _side(2, 20.0, 55.0, -5.0, 100.0, 1.0) + result = _result(buys=buys, sells=sells) + summary = result.summary() + assert "Buys" in summary + assert "Sells" in summary + + def test_summary_no_fills_message(self) -> None: + result = _result(buys=_empty_side(), sells=_empty_side()) + summary = result.summary() + assert "No fills recorded" in summary + + def test_summary_contains_algo_name(self) -> None: + result = _result(buys=_empty_side(), sells=_empty_side()) + assert "TestAlgo" in result.summary() + + def test_summary_contains_exchange(self) -> None: + result = _result(buys=_empty_side(), sells=_empty_side()) + assert "nordpool" in result.summary() + + def test_summary_sells_above_vwap_note(self) -> None: + # vwap_alpha <= 0 means sold above VWAP (good) + sells = _side(1, 10.0, 55.0, -5.0, 50.0, 1.0) + result = _result(buys=_empty_side(), sells=sells) + summary = result.summary() + assert "sold above VWAP" in summary + + def test_summary_sells_below_vwap_note(self) -> None: + # vwap_alpha > 0 means sold below VWAP (bad) + sells = _side(1, 10.0, 45.0, 5.0, -50.0, 0.0) + result = _result(buys=_empty_side(), sells=sells) + summary = result.summary() + assert "sold below VWAP" in summary diff --git a/tests/test_analysis/test_pnl.py b/tests/test_analysis/test_pnl.py new file mode 100644 index 0000000..7a0c671 --- /dev/null +++ b/tests/test_analysis/test_pnl.py @@ -0,0 +1,110 @@ +"""Tests for analysis/pnl.py: compute_pnl and _side_summary.""" + +from __future__ import annotations + +from datetime import UTC, datetime +from decimal import Decimal + +import pandas as pd +import pytest + +from nexa_backtest.analysis.pnl import compute_pnl +from nexa_backtest.types import Fill, Side + + +def _fill(side: Side, price: float, volume: float = 10.0, product_id: str = "P1") -> Fill: + return Fill( + order_id="o1", + product_id=product_id, + side=side, + price=Decimal(str(price)), + volume=Decimal(str(volume)), + timestamp=datetime(2026, 3, 1, 12, 0, tzinfo=UTC), + ) + + +def _market_data(prices: list[float], volumes: list[float]) -> pd.DataFrame: + return pd.DataFrame({"price_eur_mwh": prices, "volume_mwh": volumes}) + + +class TestComputePnlEmptyMarketData: + def test_empty_market_data_gives_zero_vwap(self) -> None: + df = pd.DataFrame(columns=["price_eur_mwh", "volume_mwh"]) + result = compute_pnl([], df) + assert result.market_vwap == Decimal("0") + + def test_market_data_without_volume_col_gives_zero_vwap(self) -> None: + df = pd.DataFrame({"price_eur_mwh": [50.0]}) + result = compute_pnl([], df) + assert result.market_vwap == Decimal("0") + + def test_no_fills_all_counts_zero(self) -> None: + df = _market_data([50.0], [100.0]) + result = compute_pnl([], df) + assert result.buys.count == 0 + assert result.sells.count == 0 + assert result.total_alpha_eur == Decimal("0") + + +class TestComputePnlBuys: + def test_buy_below_vwap_is_positive_alpha(self) -> None: + # VWAP = 50, buy at 40 → bought below VWAP → positive alpha + df = _market_data([50.0], [100.0]) + fills = [_fill(Side.BUY, 40.0, 10.0)] + result = compute_pnl(fills, df) + assert result.buys.vwap_alpha < Decimal("0") # alpha = avg - vwap = 40 - 50 = -10 + assert result.buys.total_alpha_eur > Decimal("0") # -alpha * volume = 100 EUR + + def test_buy_above_vwap_is_negative_alpha(self) -> None: + df = _market_data([50.0], [100.0]) + fills = [_fill(Side.BUY, 60.0, 10.0)] + result = compute_pnl(fills, df) + assert result.buys.vwap_alpha > Decimal("0") # 60 - 50 = +10 (bad) + + def test_buy_win_rate_counted_correctly(self) -> None: + # 2 fills: 40 (below vwap=50, win) and 60 (above, loss) + df = _market_data([50.0], [100.0]) + fills = [_fill(Side.BUY, 40.0), _fill(Side.BUY, 60.0)] + result = compute_pnl(fills, df) + assert result.buys.win_rate == pytest.approx(0.5) + + +class TestComputePnlSells: + def test_sell_above_vwap_is_positive_alpha(self) -> None: + # VWAP = 50, sell at 60 → sold above VWAP → positive alpha + df = _market_data([50.0], [100.0]) + fills = [_fill(Side.SELL, 60.0, 10.0)] + result = compute_pnl(fills, df) + # vwap_alpha for sells = market_vwap - avg_price = 50 - 60 = -10 + assert result.sells.vwap_alpha < Decimal("0") + assert result.sells.total_alpha_eur > Decimal("0") # -(-10) * 10 = 100 + + def test_sell_below_vwap_is_negative_alpha(self) -> None: + df = _market_data([50.0], [100.0]) + fills = [_fill(Side.SELL, 40.0, 10.0)] + result = compute_pnl(fills, df) + # vwap_alpha = 50 - 40 = +10 (bad — sold below VWAP) + assert result.sells.vwap_alpha > Decimal("0") + + def test_sell_win_rate_counted_correctly(self) -> None: + # 2 sells: 60 (above vwap=50, win) and 40 (below, loss) + df = _market_data([50.0], [100.0]) + fills = [_fill(Side.SELL, 60.0), _fill(Side.SELL, 40.0)] + result = compute_pnl(fills, df) + assert result.sells.win_rate == pytest.approx(0.5) + + def test_sell_count_and_volume(self) -> None: + df = _market_data([50.0], [100.0]) + fills = [_fill(Side.SELL, 55.0, 20.0), _fill(Side.SELL, 45.0, 10.0)] + result = compute_pnl(fills, df) + assert result.sells.count == 2 + assert result.sells.volume_mwh == Decimal("30") + + def test_total_alpha_combines_buys_and_sells(self) -> None: + df = _market_data([50.0], [100.0]) + fills = [ + _fill(Side.BUY, 40.0, 10.0), + _fill(Side.SELL, 60.0, 10.0), + ] + result = compute_pnl(fills, df) + assert result.total_alpha_eur == result.buys.total_alpha_eur + result.sells.total_alpha_eur diff --git a/tests/test_analysis/test_vwap.py b/tests/test_analysis/test_vwap.py new file mode 100644 index 0000000..1457ce0 --- /dev/null +++ b/tests/test_analysis/test_vwap.py @@ -0,0 +1,44 @@ +"""Tests for analysis/vwap.py: compute_market_vwap.""" + +from __future__ import annotations + +from decimal import Decimal + +import pandas as pd + +from nexa_backtest.analysis.vwap import compute_market_vwap + + +class TestComputeMarketVwap: + def test_empty_dataframe_returns_zero(self) -> None: + df = pd.DataFrame(columns=["price_eur_mwh", "volume_mwh"]) + assert compute_market_vwap(df) == Decimal("0") + + def test_missing_volume_column_returns_zero(self) -> None: + df = pd.DataFrame({"price_eur_mwh": [50.0, 60.0]}) + assert compute_market_vwap(df) == Decimal("0") + + def test_zero_total_volume_returns_zero(self) -> None: + df = pd.DataFrame({"price_eur_mwh": [50.0, 60.0], "volume_mwh": [0.0, 0.0]}) + assert compute_market_vwap(df) == Decimal("0") + + def test_single_row_returns_that_price(self) -> None: + df = pd.DataFrame({"price_eur_mwh": [50.0], "volume_mwh": [100.0]}) + result = compute_market_vwap(df) + assert result == Decimal("50") + + def test_equal_volumes_returns_arithmetic_mean(self) -> None: + df = pd.DataFrame({"price_eur_mwh": [40.0, 60.0], "volume_mwh": [100.0, 100.0]}) + result = compute_market_vwap(df) + assert result == Decimal("50") + + def test_unequal_volumes_weighted_correctly(self) -> None: + # 30 * 200 + 60 * 100 = 6000 + 6000 = 12000 / 300 = 40.0 + df = pd.DataFrame({"price_eur_mwh": [30.0, 60.0], "volume_mwh": [200.0, 100.0]}) + result = compute_market_vwap(df) + assert result == Decimal("40") + + def test_result_is_decimal(self) -> None: + df = pd.DataFrame({"price_eur_mwh": [45.5], "volume_mwh": [100.0]}) + result = compute_market_vwap(df) + assert isinstance(result, Decimal) diff --git a/tests/test_cli/__init__.py b/tests/test_cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_cli/test_main.py b/tests/test_cli/test_main.py new file mode 100644 index 0000000..63e2a1b --- /dev/null +++ b/tests/test_cli/test_main.py @@ -0,0 +1,208 @@ +"""Tests for cli/main.py: nexa run command.""" + +from __future__ import annotations + +import textwrap +from pathlib import Path + +import pandas as pd +import pytest +from click.testing import CliRunner + +from nexa_backtest.algo import SimpleAlgo +from nexa_backtest.cli.main import cli, find_algo_class + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _write_algo(path: Path, code: str) -> Path: + path.write_text(textwrap.dedent(code)) + return path + + +def _write_da_prices(path: Path) -> None: + """Write a minimal single-day DA prices parquet.""" + data = { + "timestamp": pd.to_datetime(["2026-03-01T00:00:00Z", "2026-03-01T00:15:00Z"], utc=True), + "zone": ["NO1", "NO1"], + "price_eur_mwh": [45.0, 50.0], + "volume_mwh": [1000.0, 1000.0], + } + pd.DataFrame(data).to_parquet(path / "da_prices.parquet", index=False) + + +# --------------------------------------------------------------------------- +# Tests: algo discovery +# --------------------------------------------------------------------------- + + +class TestFindAlgoClass: + def test_finds_single_subclass(self, tmp_path: Path) -> None: + code = """ + from nexa_backtest.algo import SimpleAlgo + class MyAlgo(SimpleAlgo): + pass + """ + algo_file = _write_algo(tmp_path / "algo.py", code) + cls = find_algo_class(str(algo_file)) + assert cls.__name__ == "MyAlgo" + assert issubclass(cls, SimpleAlgo) + + def test_no_subclass_raises(self, tmp_path: Path) -> None: + code = "x = 1" + algo_file = _write_algo(tmp_path / "algo.py", code) + import click + + with pytest.raises(click.ClickException, match="No SimpleAlgo"): + find_algo_class(str(algo_file)) + + def test_multiple_subclasses_raises(self, tmp_path: Path) -> None: + code = """ + from nexa_backtest.algo import SimpleAlgo + class AlgoA(SimpleAlgo): + pass + class AlgoB(SimpleAlgo): + pass + """ + algo_file = _write_algo(tmp_path / "algo.py", code) + import click + + with pytest.raises(click.ClickException, match="Multiple"): + find_algo_class(str(algo_file)) + + +# --------------------------------------------------------------------------- +# Tests: nexa run CLI command +# --------------------------------------------------------------------------- + + +class TestRunCommand: + @pytest.fixture + def data_dir(self, tmp_path: Path) -> Path: + _write_da_prices(tmp_path) + return tmp_path + + @pytest.fixture + def algo_file(self, tmp_path: Path) -> Path: + code = """ + from nexa_backtest.algo import SimpleAlgo + class NoOpAlgo(SimpleAlgo): + pass + """ + return _write_algo(tmp_path / "noop_algo.py", code) + + def test_run_succeeds_and_prints_summary(self, algo_file: Path, data_dir: Path) -> None: + runner = CliRunner() + result = runner.invoke( + cli, + [ + "run", + str(algo_file), + "--exchange", + "nordpool", + "--start", + "2026-03-01", + "--end", + "2026-03-01", + "--products", + "NO1_DA", + "--data-dir", + str(data_dir), + "--capital", + "100000", + ], + ) + assert result.exit_code == 0, result.output + assert "nordpool" in result.output + + def test_run_with_missing_parquet_exits_nonzero(self, algo_file: Path, tmp_path: Path) -> None: + runner = CliRunner() + result = runner.invoke( + cli, + [ + "run", + str(algo_file), + "--exchange", + "nordpool", + "--start", + "2026-03-01", + "--end", + "2026-03-01", + "--products", + "NO1_DA", + "--data-dir", + str(tmp_path), # exists but no parquet + "--capital", + "100000", + ], + ) + assert result.exit_code != 0 + + def test_run_loads_algo_finds_subclass(self, data_dir: Path, tmp_path: Path) -> None: + code = """ + from nexa_backtest.algo import SimpleAlgo + from nexa_backtest.context import TradingContext + from nexa_backtest.types import AuctionInfo, Order + from decimal import Decimal + + class BuyAlgo(SimpleAlgo): + def on_auction_open(self, ctx: TradingContext, auction: AuctionInfo) -> None: + ctx.place_order(Order.buy( + product_id=auction.product_id, + volume_mw=Decimal('1'), + price_eur_mwh=Decimal('999'), + )) + """ + algo_file = _write_algo(tmp_path / "buy_algo.py", code) + runner = CliRunner() + result = runner.invoke( + cli, + [ + "run", + str(algo_file), + "--exchange", + "nordpool", + "--start", + "2026-03-01", + "--end", + "2026-03-01", + "--products", + "NO1_DA", + "--data-dir", + str(data_dir), + "--capital", + "100000", + ], + ) + assert result.exit_code == 0, result.output + # Summary should show fills + assert "Fills" in result.output + + def test_run_with_no_subclass_in_algo_file_exits_nonzero( + self, data_dir: Path, tmp_path: Path + ) -> None: + algo_file = _write_algo(tmp_path / "empty.py", "x = 1\n") + runner = CliRunner() + result = runner.invoke( + cli, + [ + "run", + str(algo_file), + "--exchange", + "nordpool", + "--start", + "2026-03-01", + "--end", + "2026-03-01", + "--products", + "NO1_DA", + "--data-dir", + str(data_dir), + "--capital", + "100000", + ], + ) + assert result.exit_code != 0 + assert "SimpleAlgo" in result.output diff --git a/tests/test_data/__init__.py b/tests/test_data/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_data/test_loader.py b/tests/test_data/test_loader.py new file mode 100644 index 0000000..2a631a1 --- /dev/null +++ b/tests/test_data/test_loader.py @@ -0,0 +1,68 @@ +"""Tests for data/loader.py: ParquetLoader error paths.""" + +from __future__ import annotations + +from datetime import date +from pathlib import Path + +import pandas as pd +import pytest + +from nexa_backtest.data.loader import ParquetLoader +from nexa_backtest.exceptions import DataError + + +def _write_parquet(path: Path, df: pd.DataFrame) -> None: + df.to_parquet(path / "da_prices.parquet", index=False) + + +class TestParquetLoaderErrors: + def test_missing_file_raises_data_error(self, tmp_path: Path) -> None: + loader = ParquetLoader(tmp_path) + with pytest.raises(DataError, match="da_prices"): + loader.load_da_prices("NO1", date(2026, 3, 1), date(2026, 3, 31)) + + def test_missing_required_columns_raises_data_error(self, tmp_path: Path) -> None: + # Write a parquet with wrong columns + df = pd.DataFrame({"col_a": [1, 2], "col_b": [3, 4]}) + _write_parquet(tmp_path, df) + loader = ParquetLoader(tmp_path) + with pytest.raises(DataError, match="missing required columns"): + loader.load_da_prices("NO1", date(2026, 3, 1), date(2026, 3, 31)) + + def test_no_data_for_zone_raises_data_error(self, tmp_path: Path) -> None: + df = pd.DataFrame( + { + "timestamp": pd.to_datetime(["2026-03-01T00:00:00Z"], utc=True), + "zone": ["DE"], # wrong zone + "price_eur_mwh": [50.0], + "volume_mwh": [1000.0], + } + ) + _write_parquet(tmp_path, df) + loader = ParquetLoader(tmp_path) + with pytest.raises(DataError, match="No DA prices found"): + loader.load_da_prices("NO1", date(2026, 3, 1), date(2026, 3, 31)) + + def test_tz_naive_timestamps_are_localised_to_utc(self, tmp_path: Path) -> None: + # Write parquet with tz-naive timestamps + df = pd.DataFrame( + { + "timestamp": pd.to_datetime(["2026-03-01T00:00:00"]), # no tz + "zone": ["NO1"], + "price_eur_mwh": [45.0], + "volume_mwh": [1000.0], + } + ) + _write_parquet(tmp_path, df) + loader = ParquetLoader(tmp_path) + result = loader.load_da_prices("NO1", date(2026, 3, 1), date(2026, 3, 1)) + assert result["timestamp"].dt.tz is not None + assert len(result) == 1 + + def test_corrupt_parquet_raises_data_error(self, tmp_path: Path) -> None: + # Write invalid bytes to da_prices.parquet + (tmp_path / "da_prices.parquet").write_bytes(b"not a parquet file") + loader = ParquetLoader(tmp_path) + with pytest.raises(DataError, match="Failed to read DA prices"): + loader.load_da_prices("NO1", date(2026, 3, 1), date(2026, 3, 31)) diff --git a/tests/test_engines/test_backtest.py b/tests/test_engines/test_backtest.py new file mode 100644 index 0000000..ff7b5f9 --- /dev/null +++ b/tests/test_engines/test_backtest.py @@ -0,0 +1,675 @@ +"""Integration tests for the BacktestEngine with signals. + +Tests verify: +- Algo that uses a signal makes signal-informed trading decisions +- Signal value influences fills (algo skips products above threshold) +- Look-ahead bias: signal with publication_offset does not expose future values +- BacktestResult.summary() runs without error +""" + +from __future__ import annotations + +from datetime import UTC, date, datetime, timedelta +from decimal import Decimal +from pathlib import Path + +import pandas as pd +import pytest + +from nexa_backtest.algo import SimpleAlgo +from nexa_backtest.analysis.metrics import BacktestResult +from nexa_backtest.context import TradingContext +from nexa_backtest.engines.backtest import BacktestEngine, _BacktestContext +from nexa_backtest.engines.clock import SimulatedClock +from nexa_backtest.exceptions import DataError, SignalError +from nexa_backtest.signals.csv_loader import CsvSignalProvider +from nexa_backtest.signals.registry import SignalRegistry +from nexa_backtest.types import AuctionInfo, Fill, Order, OrderStatus, Side + +# --------------------------------------------------------------------------- +# Helpers: fixture data generation +# --------------------------------------------------------------------------- + + +def _write_da_prices(path: Path, rows: list[tuple[str, str, float, float]]) -> None: + """Write a minimal da_prices.parquet from (product_id, timestamp_str, price, vol) rows.""" + data = { + "timestamp": pd.to_datetime([r[1] for r in rows], utc=True), + "zone": [r[0] for r in rows], + "price_eur_mwh": [r[2] for r in rows], + "volume_mwh": [r[3] for r in rows], + } + pd.DataFrame(data).to_parquet(path / "da_prices.parquet", index=False) + + +def _write_signal_csv(path: Path, name: str, rows: list[tuple[str, float]]) -> None: + """Write a signal CSV to {path}/signals/{name}.csv.""" + signals_dir = path / "signals" + signals_dir.mkdir(exist_ok=True) + content = "timestamp,value\n" + "".join(f"{ts},{val}\n" for ts, val in rows) + (signals_dir / f"{name}.csv").write_text(content) + + +# --------------------------------------------------------------------------- +# Simple passthrough algo (no signals) +# --------------------------------------------------------------------------- + + +class _AlwaysBuyAlgo(SimpleAlgo): + """Places a buy order at clearing price for every product.""" + + def on_auction_open(self, ctx: TradingContext, auction: AuctionInfo) -> None: + ctx.place_order( + Order.buy( + product_id=auction.product_id, + volume_mw=Decimal("10"), + price_eur_mwh=Decimal("999"), # always fills + ) + ) + + +class _AlwaysSellAlgo(SimpleAlgo): + """Places a sell order at clearing price for every product.""" + + def on_auction_open(self, ctx: TradingContext, auction: AuctionInfo) -> None: + ctx.place_order( + Order.sell( + product_id=auction.product_id, + volume_mw=Decimal("10"), + price_eur_mwh=Decimal("0"), # always fills + ) + ) + + +class _NoOpAlgo(SimpleAlgo): + """Does nothing.""" + + +# --------------------------------------------------------------------------- +# Tests: basic engine operation +# --------------------------------------------------------------------------- + + +class TestBacktestEngineBasic: + @pytest.fixture + def data_dir(self, tmp_path: Path) -> Path: + _write_da_prices( + tmp_path, + [ + ("NO1", "2026-03-01T00:00:00Z", 45.0, 1000.0), + ("NO1", "2026-03-01T00:15:00Z", 50.0, 1000.0), + ("NO1", "2026-03-01T00:30:00Z", 40.0, 1000.0), + ], + ) + return tmp_path + + def _engine(self, algo: SimpleAlgo, data_dir: Path) -> BacktestEngine: + return BacktestEngine( + algo=algo, + exchange="nordpool", + start=date(2026, 3, 1), + end=date(2026, 3, 1), + products=["NO1_DA"], + data_dir=data_dir, + capital=Decimal("100000"), + ) + + def test_no_op_algo_produces_no_fills(self, data_dir: Path) -> None: + result = self._engine(_NoOpAlgo(), data_dir).run() + assert len(result.fills) == 0 + + def test_always_buy_produces_fills(self, data_dir: Path) -> None: + result = self._engine(_AlwaysBuyAlgo(), data_dir).run() + assert len(result.fills) == 3 + + def test_fills_are_at_clearing_price(self, data_dir: Path) -> None: + result = self._engine(_AlwaysBuyAlgo(), data_dir).run() + prices = {float(f.price) for f in result.fills} + assert prices == {45.0, 50.0, 40.0} + + def test_result_is_backtest_result(self, data_dir: Path) -> None: + result = self._engine(_NoOpAlgo(), data_dir).run() + assert isinstance(result, BacktestResult) + + def test_summary_runs_without_error(self, data_dir: Path) -> None: + result = self._engine(_AlwaysBuyAlgo(), data_dir).run() + summary = result.summary() + assert "nordpool" in summary + + def test_missing_data_file_raises_data_error(self, tmp_path: Path) -> None: + with pytest.raises(DataError, match="da_prices"): + self._engine(_NoOpAlgo(), tmp_path).run() + + def test_invalid_product_spec_raises_data_error(self, data_dir: Path) -> None: + engine = BacktestEngine( + algo=_NoOpAlgo(), + exchange="nordpool", + start=date(2026, 3, 1), + end=date(2026, 3, 1), + products=["BADFORMAT"], + data_dir=data_dir, + capital=Decimal("100000"), + ) + with pytest.raises(DataError): + engine.run() + + +# --------------------------------------------------------------------------- +# Tests: signal-driven trading decisions +# --------------------------------------------------------------------------- + + +class _SignalAlgo(SimpleAlgo): + """Buys when forecast > clearing + threshold; never buys otherwise.""" + + threshold: Decimal = Decimal("5.0") + + def on_setup(self, ctx: TradingContext) -> None: + self.subscribe_signal("price_forecast") + self.threshold = Decimal("5.0") + + def on_auction_open(self, ctx: TradingContext, auction: AuctionInfo) -> None: + try: + signal = ctx.get_signal("price_forecast") + except SignalError: + return + bid = Decimal(str(signal.value)) - self.threshold + ctx.place_order( + Order.buy( + product_id=auction.product_id, + volume_mw=Decimal("10"), + price_eur_mwh=bid, + ) + ) + + +class TestSignalDrivenTrading: + @pytest.fixture + def data_dir(self, tmp_path: Path) -> Path: + # Three products with clearing prices 30, 50, 40 + _write_da_prices( + tmp_path, + [ + ("NO1", "2026-03-01T00:00:00Z", 30.0, 1000.0), + ("NO1", "2026-03-01T00:15:00Z", 50.0, 1000.0), + ("NO1", "2026-03-01T00:30:00Z", 40.0, 1000.0), + ], + ) + # Forecast: 60 for all periods, available at auction time (D-1 12:00) + # publication_offset=36h -> at D-1 12:00 we can see T <= D+1 00:00 + # (all day-D products visible) + # Auction time = 2026-02-28T12:00Z + # Visible: T <= 2026-02-28T12:00 + 36h = 2026-03-01T24:00Z → all visible + _write_signal_csv( + tmp_path, + "price_forecast", + [ + ("2026-03-01T00:00:00+00:00", 60.0), + ("2026-03-01T00:15:00+00:00", 60.0), + ("2026-03-01T00:30:00+00:00", 60.0), + ], + ) + return tmp_path + + def _engine(self, data_dir: Path, algo: SimpleAlgo | None = None) -> BacktestEngine: + return BacktestEngine( + algo=algo or _SignalAlgo(), + exchange="nordpool", + start=date(2026, 3, 1), + end=date(2026, 3, 1), + products=["NO1_DA"], + data_dir=data_dir, + capital=Decimal("100000"), + signals=[ + CsvSignalProvider( + name="price_forecast", + path=data_dir / "signals" / "price_forecast.csv", + unit="EUR/MWh", + description="Test forecast", + publication_offset=timedelta(hours=36), + ) + ], + ) + + def test_algo_fills_when_forecast_above_threshold(self, data_dir: Path) -> None: + # forecast=60, threshold=5 -> bid=55. clearing prices: 30, 50, 40. + # Bids 55 >= 30, 55 >= 50, 55 >= 40 → all three fill + result = self._engine(data_dir).run() + assert len(result.fills) == 3 + + def test_algo_skips_when_bid_below_clearing(self, data_dir: Path) -> None: + """When forecast is barely above clearing, orders with bid < clearing reject.""" + # forecast=45, threshold=5 -> bid=40. clearing prices: 30, 50, 40. + # bid 40 >= 30 → fill; bid 40 < 50 → reject; bid 40 >= 40 → fill (at boundary) + _write_signal_csv( + data_dir, + "price_forecast", + [ + ("2026-03-01T00:00:00+00:00", 45.0), + ("2026-03-01T00:15:00+00:00", 45.0), + ("2026-03-01T00:30:00+00:00", 45.0), + ], + ) + result = self._engine(data_dir).run() + # 50 EUR/MWh product should not fill (bid=40 < clearing=50) + fill_prices = {float(f.price) for f in result.fills} + assert 50.0 not in fill_prices + + def test_signal_value_influences_number_of_fills(self, data_dir: Path) -> None: + """Verify fills differ when forecast is high vs low.""" + # high forecast: all fill + result_high = self._engine(data_dir).run() + + # low forecast: bid=5 → almost nothing fills + _write_signal_csv( + data_dir, + "price_forecast", + [ + ("2026-03-01T00:00:00+00:00", 10.0), + ("2026-03-01T00:15:00+00:00", 10.0), + ("2026-03-01T00:30:00+00:00", 10.0), + ], + ) + result_low = self._engine(data_dir).run() + assert len(result_high.fills) > len(result_low.fills) + + def test_subscribed_signal_auto_discovered_from_csv(self, tmp_path: Path) -> None: + """Engine auto-loads signal CSV from data_dir/signals/{name}.csv.""" + _write_da_prices( + tmp_path, + [("NO1", "2026-03-01T00:00:00Z", 45.0, 1000.0)], + ) + # Auction time for 2026-03-01 delivery = 2026-02-28T12:00Z. + # Auto-discovered provider has no publication_offset → values visible + # at their timestamp. Use a timestamp before auction time so it's + # visible without an offset. + _write_signal_csv( + tmp_path, + "price_forecast", + [("2026-02-28T00:00:00+00:00", 99.0)], + ) + engine = BacktestEngine( + algo=_SignalAlgo(), + exchange="nordpool", + start=date(2026, 3, 1), + end=date(2026, 3, 1), + products=["NO1_DA"], + data_dir=tmp_path, + capital=Decimal("100000"), + ) + result = engine.run() + # bid = 99 - 5 = 94, clearing = 45 → fill + assert len(result.fills) == 1 + + def test_missing_signal_csv_raises_data_error(self, tmp_path: Path) -> None: + _write_da_prices( + tmp_path, + [("NO1", "2026-03-01T00:00:00Z", 45.0, 1000.0)], + ) + # No signals/ dir → DataError + engine = BacktestEngine( + algo=_SignalAlgo(), + exchange="nordpool", + start=date(2026, 3, 1), + end=date(2026, 3, 1), + products=["NO1_DA"], + data_dir=tmp_path, + capital=Decimal("100000"), + ) + with pytest.raises(DataError, match="price_forecast"): + engine.run() + + +# --------------------------------------------------------------------------- +# Tests: look-ahead bias prevention +# --------------------------------------------------------------------------- + + +class TestLookAheadBias: + """Verify publication_offset prevents future values from being visible.""" + + def test_value_not_visible_before_publication_time(self, tmp_path: Path) -> None: + """With offset=0, values are only visible at or after their timestamp.""" + _write_da_prices( + tmp_path, + [("NO1", "2026-03-01T00:00:00Z", 45.0, 1000.0)], + ) + # Auction time = 2026-02-28T12:00Z (D-1 12:00 UTC for delivery 2026-03-01) + # Signal row: timestamp=2026-03-01T01:00Z, offset=0h + # At auction time 2026-02-28T12:00: only rows where ts <= 2026-02-28T12:00 visible + # 2026-03-01T01:00 > 2026-02-28T12:00 → NOT visible → SignalError → no fill + _write_signal_csv( + tmp_path, + "price_forecast", + [("2026-03-01T01:00:00+00:00", 99.0)], + ) + provider = CsvSignalProvider( + name="price_forecast", + path=tmp_path / "signals" / "price_forecast.csv", + unit="EUR/MWh", + description="", + publication_offset=timedelta(0), # no offset + ) + engine = BacktestEngine( + algo=_SignalAlgo(), + exchange="nordpool", + start=date(2026, 3, 1), + end=date(2026, 3, 1), + products=["NO1_DA"], + data_dir=tmp_path, + capital=Decimal("100000"), + signals=[provider], + ) + result = engine.run() + # Signal not visible at auction time → SignalError caught → no orders placed + assert len(result.fills) == 0 + + def test_value_visible_with_sufficient_offset(self, tmp_path: Path) -> None: + """With a large offset, the same value becomes visible.""" + _write_da_prices( + tmp_path, + [("NO1", "2026-03-01T00:00:00Z", 45.0, 1000.0)], + ) + # Auction time = 2026-02-28T12:00Z + # Signal: timestamp=2026-03-01T01:00Z, offset=36h + # Visible when: ts <= auction_time + 36h = 2026-02-28T12:00 + 36h = 2026-03-02T00:00 + # 2026-03-01T01:00 <= 2026-03-02T00:00 ✓ → visible + _write_signal_csv( + tmp_path, + "price_forecast", + [("2026-03-01T01:00:00+00:00", 99.0)], + ) + provider = CsvSignalProvider( + name="price_forecast", + path=tmp_path / "signals" / "price_forecast.csv", + unit="EUR/MWh", + description="", + publication_offset=timedelta(hours=36), + ) + engine = BacktestEngine( + algo=_SignalAlgo(), + exchange="nordpool", + start=date(2026, 3, 1), + end=date(2026, 3, 1), + products=["NO1_DA"], + data_dir=tmp_path, + capital=Decimal("100000"), + signals=[provider], + ) + result = engine.run() + # bid = 99 - 5 = 94 >= clearing 45 → fill + assert len(result.fills) == 1 + + +# --------------------------------------------------------------------------- +# Helpers for _BacktestContext unit tests +# --------------------------------------------------------------------------- + + +def _make_context( + initial_time: datetime | None = None, +) -> _BacktestContext: + t = initial_time or datetime(2026, 3, 1, 12, 0, tzinfo=UTC) + clock = SimulatedClock(initial_time=t) + registry = SignalRegistry() + return _BacktestContext(clock=clock, signal_registry=registry) + + +def _make_fill(side: Side, price: float, product_id: str = "P1", volume: float = 10.0) -> Fill: + return Fill( + order_id="o1", + product_id=product_id, + side=side, + price=Decimal(str(price)), + volume=Decimal(str(volume)), + timestamp=datetime(2026, 3, 1, 12, 0, tzinfo=UTC), + ) + + +# --------------------------------------------------------------------------- +# Tests: _BacktestContext methods +# --------------------------------------------------------------------------- + + +class TestBacktestContextTime: + def test_now_returns_clock_time(self) -> None: + t = datetime(2026, 3, 15, 10, 0, tzinfo=UTC) + ctx = _make_context(initial_time=t) + assert ctx.now() == t + + def test_time_to_gate_closure_known_product(self) -> None: + t = datetime(2026, 3, 1, 12, 0, tzinfo=UTC) + ctx = _make_context(initial_time=t) + closure = t + timedelta(hours=2) + ctx._gate_closures["P1"] = closure + remaining = ctx.time_to_gate_closure("P1") + assert remaining == timedelta(hours=2) + + def test_time_to_gate_closure_unknown_product_returns_zero(self) -> None: + ctx = _make_context() + assert ctx.time_to_gate_closure("UNKNOWN") == timedelta(0) + + def test_time_to_gate_closure_past_returns_zero(self) -> None: + t = datetime(2026, 3, 1, 12, 0, tzinfo=UTC) + ctx = _make_context(initial_time=t) + ctx._gate_closures["P1"] = t - timedelta(hours=1) + assert ctx.time_to_gate_closure("P1") == timedelta(0) + + def test_current_mtu_rounds_to_15min_slot(self) -> None: + t = datetime(2026, 3, 1, 9, 37, 45, tzinfo=UTC) + ctx = _make_context(initial_time=t) + mtu = ctx.current_mtu() + assert mtu.start.minute == 30 + assert mtu.end.minute == 45 + + +class TestBacktestContextMarketData: + def test_get_orderbook_known_product(self) -> None: + ctx = _make_context() + ctx._clearing_prices["P1"] = Decimal("50") + ob = ctx.get_orderbook("P1") + assert ob.best_bid is not None + assert ob.best_ask is not None + assert ob.best_bid.price == Decimal("50") + + def test_get_orderbook_unknown_product_returns_empty(self) -> None: + ctx = _make_context() + ob = ctx.get_orderbook("UNKNOWN") + assert ob.best_bid is None + assert ob.best_ask is None + + def test_get_best_bid_returns_price_level(self) -> None: + ctx = _make_context() + ctx._clearing_prices["P1"] = Decimal("45") + bid = ctx.get_best_bid("P1") + assert bid is not None + assert bid.price == Decimal("45") + + def test_get_best_ask_returns_price_level(self) -> None: + ctx = _make_context() + ctx._clearing_prices["P1"] = Decimal("45") + ask = ctx.get_best_ask("P1") + assert ask is not None + assert ask.price == Decimal("45") + + def test_get_last_price_known(self) -> None: + ctx = _make_context() + ctx._clearing_prices["P1"] = Decimal("55") + assert ctx.get_last_price("P1") == Decimal("55") + + def test_get_last_price_unknown_returns_none(self) -> None: + ctx = _make_context() + assert ctx.get_last_price("UNKNOWN") is None + + def test_get_vwap_known(self) -> None: + ctx = _make_context() + ctx._clearing_prices["P1"] = Decimal("48") + assert ctx.get_vwap("P1") == Decimal("48") + + def test_get_vwap_unknown_returns_none(self) -> None: + ctx = _make_context() + assert ctx.get_vwap("UNKNOWN") is None + + +class TestBacktestContextOrderManagement: + def test_cancel_order_not_found(self) -> None: + ctx = _make_context() + result = ctx.cancel_order("nonexistent-id") + assert result.status == "not_found" + + def test_modify_order_not_found_returns_rejected(self) -> None: + ctx = _make_context() + result = ctx.modify_order("nonexistent-id", price_eur_mwh=Decimal("50")) + assert result.status == OrderStatus.REJECTED + assert "not found" in (result.rejection_reason or "") + + def test_modify_order_success(self) -> None: + ctx = _make_context() + order = Order.buy(product_id="P1", volume_mw=Decimal("10"), price_eur_mwh=Decimal("50")) + ctx.place_order(order) + result = ctx.modify_order(order.order_id, price_eur_mwh=Decimal("55")) + assert result.status == OrderStatus.ACCEPTED + # Old order gone, new order present + assert order.order_id not in ctx._pending_orders + new_order = ctx._pending_orders[result.order_id] + assert new_order.price_eur_mwh == Decimal("55") + + def test_modify_order_invalid_data_returns_rejected(self) -> None: + ctx = _make_context() + order = Order.buy(product_id="P1", volume_mw=Decimal("10"), price_eur_mwh=Decimal("50")) + ctx.place_order(order) + # Invalid Side enum value causes pydantic validation failure + result = ctx.modify_order(order.order_id, side="INVALID_SIDE") + assert result.status == OrderStatus.REJECTED + + +class TestBacktestContextPositions: + def test_get_position_no_fills_returns_zero(self) -> None: + ctx = _make_context() + pos = ctx.get_position("P1") + assert pos.net_mw == Decimal("0") + + def test_get_position_after_buy(self) -> None: + ctx = _make_context() + fill = _make_fill(Side.BUY, 50.0, "P1", 10.0) + ctx._record_fill(fill) + pos = ctx.get_position("P1") + assert pos.net_mw == Decimal("10") + + def test_get_position_after_sell(self) -> None: + ctx = _make_context() + fill = _make_fill(Side.SELL, 50.0, "P1", 10.0) + ctx._record_fill(fill) + pos = ctx.get_position("P1") + assert pos.net_mw == Decimal("-10") + + def test_get_position_net_zero_after_round_trip(self) -> None: + ctx = _make_context() + ctx._record_fill(_make_fill(Side.BUY, 50.0, "P1", 10.0)) + ctx._record_fill(_make_fill(Side.SELL, 50.0, "P1", 10.0)) + pos = ctx.get_position("P1") + assert pos.net_mw == Decimal("0") + assert pos.avg_entry_price == Decimal("0") + + def test_get_all_positions_excludes_zero_positions(self) -> None: + ctx = _make_context() + ctx._record_fill(_make_fill(Side.BUY, 50.0, "P1")) + ctx._record_fill(_make_fill(Side.SELL, 50.0, "P1")) # nets to zero + ctx._record_fill(_make_fill(Side.BUY, 45.0, "P2")) + positions = ctx.get_all_positions() + assert "P1" not in positions + assert "P2" in positions + + def test_get_unrealised_pnl_with_open_position(self) -> None: + ctx = _make_context() + ctx._clearing_prices["P1"] = Decimal("55") + ctx._record_fill(_make_fill(Side.BUY, 50.0, "P1", 10.0)) + pnl = ctx.get_unrealised_pnl() + assert pnl == Decimal("50") # (55 - 50) * 10 + + +class TestBacktestContextMisc: + def test_predict_raises_not_implemented(self) -> None: + ctx = _make_context() + with pytest.raises(NotImplementedError, match="ML model"): + ctx.predict("some_model", {}) + + def test_log_does_not_raise(self) -> None: + ctx = _make_context() + ctx.log("test message", level="info") + ctx.log("warning message", level="warning") + + +# --------------------------------------------------------------------------- +# Tests: BacktestEngine edge cases +# --------------------------------------------------------------------------- + + +class TestBacktestEngineEdgeCases: + def test_no_products_raises_data_error(self, tmp_path: Path) -> None: + _write_da_prices( + tmp_path, + [("NO1", "2026-03-01T00:00:00Z", 45.0, 1000.0)], + ) + engine = BacktestEngine( + algo=_NoOpAlgo(), + exchange="nordpool", + start=date(2026, 3, 1), + end=date(2026, 3, 1), + products=[], + data_dir=tmp_path, + capital=Decimal("100000"), + ) + with pytest.raises(DataError, match="No products specified"): + engine.run() + + def test_order_for_unknown_product_is_skipped(self, tmp_path: Path) -> None: + """An order placed for a product not in clearing prices is silently ignored.""" + _write_da_prices( + tmp_path, + [("NO1", "2026-03-01T00:00:00Z", 45.0, 1000.0)], + ) + + class _WrongProductAlgo(SimpleAlgo): + def on_auction_open(self, ctx: TradingContext, auction: AuctionInfo) -> None: + ctx.place_order( + Order.buy( + product_id="NONEXISTENT_PRODUCT", + volume_mw=Decimal("10"), + price_eur_mwh=Decimal("999"), + ) + ) + + engine = BacktestEngine( + algo=_WrongProductAlgo(), + exchange="nordpool", + start=date(2026, 3, 1), + end=date(2026, 3, 1), + products=["NO1_DA"], + data_dir=tmp_path, + capital=Decimal("100000"), + ) + result = engine.run() + assert len(result.fills) == 0 + + def test_always_sell_produces_fills_and_summary(self, tmp_path: Path) -> None: + _write_da_prices( + tmp_path, + [ + ("NO1", "2026-03-01T00:00:00Z", 45.0, 1000.0), + ("NO1", "2026-03-01T00:15:00Z", 50.0, 1000.0), + ], + ) + engine = BacktestEngine( + algo=_AlwaysSellAlgo(), + exchange="nordpool", + start=date(2026, 3, 1), + end=date(2026, 3, 1), + products=["NO1_DA"], + data_dir=tmp_path, + capital=Decimal("100000"), + ) + result = engine.run() + assert len(result.fills) == 2 + summary = result.summary() + assert "Sells" in summary diff --git a/tests/test_exchanges/__init__.py b/tests/test_exchanges/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_exchanges/test_base.py b/tests/test_exchanges/test_base.py new file mode 100644 index 0000000..4aeada0 --- /dev/null +++ b/tests/test_exchanges/test_base.py @@ -0,0 +1,53 @@ +"""Tests for exchanges/base.py: ExchangeCapabilities.""" + +from __future__ import annotations + +from decimal import Decimal + +import pytest +from pydantic import ValidationError + +from nexa_backtest.exchanges.base import ExchangeCapabilities + + +class TestExchangeCapabilities: + def test_defaults(self) -> None: + caps = ExchangeCapabilities(exchange_id="test_exchange") + assert caps.exchange_id == "test_exchange" + assert caps.supports_block_bids is False + assert caps.supports_linked_orders is False + assert caps.supports_market_orders is False + assert caps.supports_partial_fills is False + assert caps.min_volume_mw == Decimal("0.1") + assert caps.max_volume_mw is None + assert caps.min_price_eur_mwh == Decimal("-500") + assert caps.max_price_eur_mwh == Decimal("3000") + assert caps.mtu_duration_minutes == 15 + assert caps.gate_closure_minutes_before_delivery == 60 + + def test_custom_values(self) -> None: + caps = ExchangeCapabilities( + exchange_id="nordpool", + supports_block_bids=True, + supports_partial_fills=True, + min_volume_mw=Decimal("1.0"), + max_volume_mw=Decimal("500.0"), + min_price_eur_mwh=Decimal("-9999"), + max_price_eur_mwh=Decimal("9999"), + mtu_duration_minutes=60, + gate_closure_minutes_before_delivery=30, + ) + assert caps.supports_block_bids is True + assert caps.supports_partial_fills is True + assert caps.max_volume_mw == Decimal("500.0") + assert caps.mtu_duration_minutes == 60 + + def test_frozen_model_prevents_mutation(self) -> None: + caps = ExchangeCapabilities(exchange_id="test") + with pytest.raises(ValidationError): + caps.exchange_id = "other" # type: ignore[misc] + + def test_equality(self) -> None: + a = ExchangeCapabilities(exchange_id="test") + b = ExchangeCapabilities(exchange_id="test") + assert a == b diff --git a/tests/test_signals/__init__.py b/tests/test_signals/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_signals/test_base.py b/tests/test_signals/test_base.py new file mode 100644 index 0000000..997d6cf --- /dev/null +++ b/tests/test_signals/test_base.py @@ -0,0 +1,70 @@ +"""Tests for signals/base.py: SignalSchema and SignalProvider protocol.""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta + +import pytest +from pydantic import ValidationError as PydanticValidationError + +from nexa_backtest.context import SignalValue +from nexa_backtest.signals.base import SignalSchema + + +class TestSignalSchema: + def test_construction(self): + schema = SignalSchema( + name="wind_forecast", + dtype=float, + frequency=timedelta(minutes=15), + description="Wind generation forecast for NO1", + unit="MW", + ) + assert schema.name == "wind_forecast" + assert schema.dtype is float + assert schema.frequency == timedelta(minutes=15) + assert schema.description == "Wind generation forecast for NO1" + assert schema.unit == "MW" + + def test_immutable(self): + schema = SignalSchema( + name="test", + dtype=float, + frequency=timedelta(hours=1), + description="test", + unit="MW", + ) + with pytest.raises(PydanticValidationError): # frozen model + schema.name = "other" # type: ignore[misc] + + def test_int_dtype(self): + schema = SignalSchema( + name="count", + dtype=int, + frequency=timedelta(hours=1), + description="A count", + unit="", + ) + assert schema.dtype is int + + +class TestSignalValue: + """Tests for the SignalValue used by signals (defined in context.py).""" + + def test_construction(self): + ts = datetime(2026, 3, 1, 12, 0, tzinfo=UTC) + sv = SignalValue(name="test_signal", timestamp=ts, value=42.5) + assert sv.name == "test_signal" + assert sv.timestamp == ts + assert sv.value == pytest.approx(42.5) + + def test_immutable(self): + ts = datetime(2026, 3, 1, tzinfo=UTC) + sv = SignalValue(name="s", timestamp=ts, value=1.0) + with pytest.raises(PydanticValidationError): # frozen model + sv.value = 2.0 # type: ignore[misc] + + def test_value_is_float(self): + ts = datetime(2026, 3, 1, tzinfo=UTC) + sv = SignalValue(name="s", timestamp=ts, value=100) + assert isinstance(sv.value, float) diff --git a/tests/test_signals/test_csv_loader.py b/tests/test_signals/test_csv_loader.py new file mode 100644 index 0000000..5701bb8 --- /dev/null +++ b/tests/test_signals/test_csv_loader.py @@ -0,0 +1,271 @@ +"""Tests for signals/csv_loader.py: CsvSignalProvider.""" + +from __future__ import annotations + +import textwrap +from datetime import UTC, datetime, timedelta +from pathlib import Path +from unittest.mock import patch + +import pytest + +from nexa_backtest.exceptions import DataError, SignalError +from nexa_backtest.signals.base import SignalSchema +from nexa_backtest.signals.csv_loader import CsvSignalProvider + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_VALID_CSV = textwrap.dedent("""\ + timestamp,value + 2026-03-01T00:00:00+00:00,40.00 + 2026-03-01T00:15:00+00:00,41.00 + 2026-03-01T00:30:00+00:00,42.00 + 2026-03-01T00:45:00+00:00,43.00 + 2026-03-01T01:00:00+00:00,44.00 +""") + + +def _make_csv(tmp_path: Path, content: str, name: str = "signal.csv") -> Path: + p = tmp_path / name + p.write_text(content) + return p + + +def _make_provider( + tmp_path: Path, + content: str = _VALID_CSV, + publication_offset: timedelta | None = None, +) -> CsvSignalProvider: + path = _make_csv(tmp_path, content) + return CsvSignalProvider( + name="test_signal", + path=path, + unit="EUR/MWh", + description="Test", + publication_offset=publication_offset, + ) + + +# --------------------------------------------------------------------------- +# Construction and loading +# --------------------------------------------------------------------------- + + +class TestCsvSignalProviderLoad: + def test_loads_valid_csv(self, tmp_path): + provider = _make_provider(tmp_path) + assert provider.name == "test_signal" + + def test_missing_file_raises_data_error(self, tmp_path): + with pytest.raises(DataError, match="not found"): + CsvSignalProvider(name="x", path=tmp_path / "missing.csv", unit="MW", description="") + + def test_missing_timestamp_column_raises(self, tmp_path): + csv = "val\n1.0\n2.0\n" + with pytest.raises(DataError, match="timestamp"): + _make_provider(tmp_path, content=csv) + + def test_missing_value_column_raises(self, tmp_path): + csv = "timestamp\n2026-03-01T00:00:00+00:00\n" + with pytest.raises(DataError, match="value"): + _make_provider(tmp_path, content=csv) + + def test_bad_timestamps_raise_data_error(self, tmp_path): + csv = "timestamp,value\nnot-a-date,1.0\n" + with pytest.raises(DataError, match="timestamp"): + _make_provider(tmp_path, content=csv) + + def test_bad_values_raise_data_error(self, tmp_path): + csv = "timestamp,value\n2026-03-01T00:00:00+00:00,abc\n" + with pytest.raises(DataError, match="non-numeric"): + _make_provider(tmp_path, content=csv) + + def test_extra_columns_ignored(self, tmp_path): + csv = "timestamp,value,zone\n2026-03-01T00:00:00+00:00,40.0,NO1\n" + provider = _make_provider(tmp_path, content=csv) + v = provider.get_value(datetime(2026, 3, 1, 1, tzinfo=UTC)) + assert v.value == pytest.approx(40.0) + + def test_warns_when_no_publication_offset(self, tmp_path, caplog): + import logging + + with caplog.at_level(logging.WARNING, logger="nexa_backtest.signals.csv_loader"): + _make_provider(tmp_path, publication_offset=None) + assert "publication_offset" in caplog.text + + def test_read_csv_failure_raises_data_error(self, tmp_path): + path = _make_csv(tmp_path, _VALID_CSV) + with ( + patch("pandas.read_csv", side_effect=OSError("disk error")), + pytest.raises(DataError, match="Failed to read signal CSV"), + ): + CsvSignalProvider(name="x", path=path, unit="MW", description="") + + +class TestCsvSignalProviderProperties: + def test_publication_offset_property(self, tmp_path): + offset = timedelta(hours=6) + provider = _make_provider(tmp_path, publication_offset=offset) + assert provider.publication_offset == offset + + def test_publication_offset_none_when_not_set(self, tmp_path): + provider = _make_provider(tmp_path, publication_offset=None) + assert provider.publication_offset is None + + def test_schema_property(self, tmp_path): + provider = CsvSignalProvider( + name="wind_forecast", + path=_make_csv(tmp_path, _VALID_CSV), + unit="MW", + description="Wind power forecast", + frequency=timedelta(minutes=15), + publication_offset=timedelta(hours=6), + ) + schema = provider.schema + assert isinstance(schema, SignalSchema) + assert schema.name == "wind_forecast" + assert schema.unit == "MW" + assert schema.description == "Wind power forecast" + + +# --------------------------------------------------------------------------- +# get_value +# --------------------------------------------------------------------------- + + +class TestGetValue: + def test_returns_latest_visible_value(self, tmp_path): + provider = _make_provider(tmp_path, publication_offset=timedelta(0)) + # At 00:30 we should see the 00:30 row (3rd) + t = datetime(2026, 3, 1, 0, 30, tzinfo=UTC) + v = provider.get_value(t) + assert v.value == pytest.approx(42.0) + + def test_returns_value_at_exact_timestamp(self, tmp_path): + provider = _make_provider(tmp_path, publication_offset=timedelta(0)) + t = datetime(2026, 3, 1, 0, 15, tzinfo=UTC) + v = provider.get_value(t) + assert v.value == pytest.approx(41.0) + + def test_no_value_before_first_row_raises(self, tmp_path): + provider = _make_provider(tmp_path, publication_offset=timedelta(0)) + t = datetime(2026, 2, 28, 23, 59, tzinfo=UTC) + with pytest.raises(SignalError, match="No value"): + provider.get_value(t) + + def test_signal_value_has_correct_name(self, tmp_path): + provider = _make_provider(tmp_path, publication_offset=timedelta(0)) + t = datetime(2026, 3, 1, 1, tzinfo=UTC) + v = provider.get_value(t) + assert v.name == "test_signal" + + def test_signal_value_timestamp_is_utc_aware(self, tmp_path): + provider = _make_provider(tmp_path, publication_offset=timedelta(0)) + t = datetime(2026, 3, 1, 1, tzinfo=UTC) + v = provider.get_value(t) + assert v.timestamp.tzinfo is not None + + +# --------------------------------------------------------------------------- +# Look-ahead bias (publication_offset) +# --------------------------------------------------------------------------- + + +class TestPublicationOffset: + """Verify that publication_offset correctly gates visibility.""" + + def test_future_value_not_visible_before_publication(self, tmp_path): + # publication_offset=15min: a value with timestamp T is visible at T-15min + # Equivalently: get_value(t) returns rows where timestamp <= t + 15min + provider = _make_provider(tmp_path, publication_offset=timedelta(minutes=15)) + + # At t=00:00, we can see rows with timestamp <= 00:15 + t = datetime(2026, 3, 1, 0, 0, tzinfo=UTC) + v = provider.get_value(t) + assert v.timestamp <= datetime(2026, 3, 1, 0, 15, tzinfo=UTC) + # Latest visible is the 00:15 row (value=41.0) + assert v.value == pytest.approx(41.0) + + def test_value_visible_after_publication_time(self, tmp_path): + # At t=00:30, with offset=15min, we can see up to 00:45 + provider = _make_provider(tmp_path, publication_offset=timedelta(minutes=15)) + t = datetime(2026, 3, 1, 0, 30, tzinfo=UTC) + v = provider.get_value(t) + assert v.value == pytest.approx(43.0) # the 00:45 row + + def test_no_look_ahead_without_offset(self, tmp_path): + """At time T, only rows with timestamp <= T are returned.""" + provider = _make_provider(tmp_path, publication_offset=timedelta(0)) + # At 00:00, only the 00:00 row is visible (not 00:15 or later) + t = datetime(2026, 3, 1, 0, 0, tzinfo=UTC) + v = provider.get_value(t) + assert v.value == pytest.approx(40.0) + + def test_no_value_before_publication_time_raises(self, tmp_path): + """With offset=0 and only future rows, SignalError is raised.""" + csv = "timestamp,value\n2026-03-02T00:00:00+00:00,50.0\n" + provider = _make_provider(tmp_path, content=csv, publication_offset=timedelta(0)) + t = datetime(2026, 3, 1, 0, 0, tzinfo=UTC) + with pytest.raises(SignalError): + provider.get_value(t) + + def test_large_offset_makes_future_values_visible(self, tmp_path): + """With offset=24h, we can see values 24 hours into the future.""" + provider = _make_provider(tmp_path, publication_offset=timedelta(hours=24)) + # At 2026-02-28T00:00, offset=24h -> visible up to 2026-03-01T00:00 + t = datetime(2026, 2, 28, 0, 0, tzinfo=UTC) + v = provider.get_value(t) + assert v.value == pytest.approx(40.0) # the first row at 2026-03-01T00:00 + + +# --------------------------------------------------------------------------- +# get_range +# --------------------------------------------------------------------------- + + +class TestGetRange: + def test_returns_values_in_range(self, tmp_path): + provider = _make_provider(tmp_path, publication_offset=timedelta(0)) + start = datetime(2026, 3, 1, 0, 0, tzinfo=UTC) + end = datetime(2026, 3, 1, 0, 30, tzinfo=UTC) + series = provider.get_range(start, end) + assert len(series) == 2 # 00:00 and 00:15 (00:30 is exclusive) + + def test_empty_range(self, tmp_path): + provider = _make_provider(tmp_path, publication_offset=timedelta(0)) + start = datetime(2026, 3, 2, 0, 0, tzinfo=UTC) + end = datetime(2026, 3, 2, 1, 0, tzinfo=UTC) + series = provider.get_range(start, end) + assert series.empty + + +# --------------------------------------------------------------------------- +# get_history_at +# --------------------------------------------------------------------------- + + +class TestGetHistoryAt: + def test_returns_last_n_visible_values(self, tmp_path): + provider = _make_provider(tmp_path, publication_offset=timedelta(0)) + t = datetime(2026, 3, 1, 1, 0, tzinfo=UTC) # all 5 rows visible + history = provider.get_history_at(t, lookback=3) + assert len(history) == 3 + # Should be chronological: 00:30, 00:45, 01:00 + assert history[0].value == pytest.approx(42.0) + assert history[2].value == pytest.approx(44.0) + + def test_capped_at_available_values(self, tmp_path): + provider = _make_provider(tmp_path, publication_offset=timedelta(0)) + t = datetime(2026, 3, 1, 0, 15, tzinfo=UTC) # only 2 rows visible + history = provider.get_history_at(t, lookback=10) + assert len(history) == 2 + + def test_respects_publication_offset(self, tmp_path): + # offset=15min at t=00:00: visible up to 00:15 → 2 rows + provider = _make_provider(tmp_path, publication_offset=timedelta(minutes=15)) + t = datetime(2026, 3, 1, 0, 0, tzinfo=UTC) + history = provider.get_history_at(t, lookback=10) + assert len(history) == 2 + assert all(sv.timestamp <= datetime(2026, 3, 1, 0, 15, tzinfo=UTC) for sv in history) diff --git a/tests/test_signals/test_registry.py b/tests/test_signals/test_registry.py new file mode 100644 index 0000000..593558d --- /dev/null +++ b/tests/test_signals/test_registry.py @@ -0,0 +1,88 @@ +"""Tests for signals/registry.py.""" + +from __future__ import annotations + +from datetime import datetime, timedelta +from typing import Any + +import pandas as pd +import pytest + +from nexa_backtest.context import SignalValue +from nexa_backtest.exceptions import SignalError +from nexa_backtest.signals.base import SignalSchema +from nexa_backtest.signals.registry import SignalRegistry + + +class _FakeProvider: + """Minimal SignalProvider implementation for registry tests.""" + + def __init__(self, name: str) -> None: + self._name = name + + @property + def name(self) -> str: + return self._name + + @property + def schema(self) -> SignalSchema: + return SignalSchema( + name=self._name, + dtype=float, + frequency=timedelta(hours=1), + description="", + unit="", + ) + + def get_value(self, timestamp: datetime) -> SignalValue: + return SignalValue(name=self._name, timestamp=timestamp, value=0.0) + + def get_range(self, start: datetime, end: datetime) -> pd.Series[Any]: + return pd.Series(dtype=float) + + def get_history_at(self, timestamp: datetime, lookback: int) -> list[SignalValue]: + return [] + + +class TestSignalRegistry: + def test_register_and_get(self): + registry = SignalRegistry() + provider = _FakeProvider("wind") + registry.register(provider) + assert registry.get("wind") is provider + + def test_has(self): + registry = SignalRegistry() + assert not registry.has("wind") + registry.register(_FakeProvider("wind")) + assert registry.has("wind") + + def test_get_missing_raises(self): + registry = SignalRegistry() + with pytest.raises(SignalError, match="not found"): + registry.get("missing") + + def test_get_missing_lists_available(self): + registry = SignalRegistry() + registry.register(_FakeProvider("alpha")) + registry.register(_FakeProvider("beta")) + with pytest.raises(SignalError, match="alpha"): + registry.get("gamma") + + def test_list_signals_sorted(self): + registry = SignalRegistry() + registry.register(_FakeProvider("zebra")) + registry.register(_FakeProvider("alpha")) + assert registry.list_signals() == ["alpha", "zebra"] + + def test_list_signals_empty(self): + registry = SignalRegistry() + assert registry.list_signals() == [] + + def test_register_replaces_existing(self): + registry = SignalRegistry() + p1 = _FakeProvider("sig") + p2 = _FakeProvider("sig") + registry.register(p1) + registry.register(p2) + assert registry.get("sig") is p2 From dba01504df9dfbeb2a82a0efd30c1c4efacafe6b Mon Sep 17 00:00:00 2001 From: Tom Medhurst Date: Mon, 6 Apr 2026 15:16:05 +0100 Subject: [PATCH 2/3] tests: add missing coverage for cancel_order, get_signal_history, and _load_module - cancel_order success path (backtest.py:152-153) was untested - get_signal_history (backtest.py:250-251) was never called in tests - spec_from_file_location returning None (cli/main.py:123) was untested Co-Authored-By: Claude Sonnet 4.6 --- tests/test_cli/test_main.py | 10 +++++++++ tests/test_engines/test_backtest.py | 32 +++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/tests/test_cli/test_main.py b/tests/test_cli/test_main.py index 63e2a1b..eabb36f 100644 --- a/tests/test_cli/test_main.py +++ b/tests/test_cli/test_main.py @@ -4,6 +4,7 @@ import textwrap from pathlib import Path +from unittest.mock import patch import pandas as pd import pytest @@ -58,6 +59,15 @@ def test_no_subclass_raises(self, tmp_path: Path) -> None: with pytest.raises(click.ClickException, match="No SimpleAlgo"): find_algo_class(str(algo_file)) + def test_invalid_spec_raises_click_exception(self, tmp_path: Path) -> None: + code = "x = 1" + algo_file = _write_algo(tmp_path / "algo.py", code) + import click + + with patch("importlib.util.spec_from_file_location", return_value=None): + with pytest.raises(click.ClickException, match="Cannot load module"): + find_algo_class(str(algo_file)) + def test_multiple_subclasses_raises(self, tmp_path: Path) -> None: code = """ from nexa_backtest.algo import SimpleAlgo diff --git a/tests/test_engines/test_backtest.py b/tests/test_engines/test_backtest.py index ff7b5f9..df3f158 100644 --- a/tests/test_engines/test_backtest.py +++ b/tests/test_engines/test_backtest.py @@ -512,6 +512,14 @@ def test_get_vwap_unknown_returns_none(self) -> None: class TestBacktestContextOrderManagement: + def test_cancel_order_success(self) -> None: + ctx = _make_context() + order = Order.buy(product_id="P1", volume_mw=Decimal("10"), price_eur_mwh=Decimal("50")) + ctx.place_order(order) + result = ctx.cancel_order(order.order_id) + assert result.status == "cancelled" + assert order.order_id not in ctx._pending_orders + def test_cancel_order_not_found(self) -> None: ctx = _make_context() result = ctx.cancel_order("nonexistent-id") @@ -599,6 +607,30 @@ def test_log_does_not_raise(self) -> None: ctx.log("test message", level="info") ctx.log("warning message", level="warning") + def test_get_signal_history_returns_list(self, tmp_path: Path) -> None: + _write_signal_csv( + tmp_path, + "test_signal", + [ + ("2026-03-01T00:00:00+00:00", 10.0), + ("2026-03-01T01:00:00+00:00", 20.0), + ("2026-03-01T02:00:00+00:00", 30.0), + ], + ) + provider = CsvSignalProvider( + name="test_signal", + path=tmp_path / "signals" / "test_signal.csv", + unit="EUR/MWh", + description="", + ) + t = datetime(2026, 3, 1, 12, 0, tzinfo=UTC) + clock = SimulatedClock(initial_time=t) + registry = SignalRegistry() + registry.register(provider) + ctx = _BacktestContext(clock=clock, signal_registry=registry) + history = ctx.get_signal_history("test_signal", 2) + assert len(history) == 2 + # --------------------------------------------------------------------------- # Tests: BacktestEngine edge cases From 9e1d607077c2a95510a7648cf0fb0dc460cbc780 Mon Sep 17 00:00:00 2001 From: Tom Medhurst Date: Mon, 6 Apr 2026 15:22:00 +0100 Subject: [PATCH 3/3] fixes --- Makefile | 11 +++++---- src/nexa_backtest/engines/backtest.py | 32 ++++++++++++------------- src/nexa_backtest/signals/csv_loader.py | 17 ++++++------- tests/test_cli/test_main.py | 8 ++++--- 4 files changed, 33 insertions(+), 35 deletions(-) diff --git a/Makefile b/Makefile index 273269b..5b9785e 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,5 @@ -.PHONY: install lint typecheck test ci test-notebooks execute-notebooks +.PHONY: install lint typecheck test ci +# test-notebooks execute-notebooks install: poetry install @@ -15,8 +16,8 @@ test: ci: lint typecheck test -test-notebooks: - poetry run jupyter nbconvert --to notebook --execute notebooks/*.ipynb --output-dir /tmp/ +# test-notebooks: +# poetry run jupyter nbconvert --to notebook --execute notebooks/*.ipynb --output-dir /tmp/ -execute-notebooks: - poetry run jupyter nbconvert --to notebook --execute --inplace notebooks/*.ipynb +# execute-notebooks: +# poetry run jupyter nbconvert --to notebook --execute --inplace notebooks/*.ipynb diff --git a/src/nexa_backtest/engines/backtest.py b/src/nexa_backtest/engines/backtest.py index 5288d34..f91c3dc 100644 --- a/src/nexa_backtest/engines/backtest.py +++ b/src/nexa_backtest/engines/backtest.py @@ -53,6 +53,15 @@ _GATE_CLOSURE_OFFSET = timedelta(hours=1) # gate closes 1h after auction opens +def _zero_position(product_id: str) -> Position: + return Position( + product_id=product_id, + net_mw=Decimal("0"), + avg_entry_price=Decimal("0"), + unrealised_pnl=Decimal("0"), + ) + + class _BacktestContext: """Internal :class:`~nexa_backtest.context.TradingContext` implementation. @@ -184,12 +193,7 @@ def get_position(self, product_id: str) -> Position: """Return the current net position for ``product_id``.""" fills = self._position_fills.get(product_id, []) if not fills: - return Position( - product_id=product_id, - net_mw=Decimal("0"), - avg_entry_price=Decimal("0"), - unrealised_pnl=Decimal("0"), - ) + return _zero_position(product_id) net_mw = Decimal("0") total_cost = Decimal("0") @@ -202,16 +206,11 @@ def get_position(self, product_id: str) -> Position: total_cost -= f.price * f.volume if net_mw == 0: - return Position( - product_id=product_id, - net_mw=Decimal("0"), - avg_entry_price=Decimal("0"), - unrealised_pnl=Decimal("0"), - ) + return _zero_position(product_id) avg_price = abs(total_cost / net_mw) mark = self._clearing_prices.get(product_id, avg_price) - unrealised = (mark - avg_price) * net_mw if net_mw > 0 else (avg_price - mark) * abs(net_mw) + unrealised = (mark - avg_price) * net_mw return Position( product_id=product_id, @@ -466,10 +465,9 @@ def run(self) -> BacktestResult: result = matcher.match(order) if result.fill is not None: - fill = result.fill - context._record_fill(fill) - all_fills.append(fill) - self._algo.on_fill(context, fill) + context._record_fill(result.fill) + all_fills.append(result.fill) + self._algo.on_fill(context, result.fill) self._algo.on_teardown(context) diff --git a/src/nexa_backtest/signals/csv_loader.py b/src/nexa_backtest/signals/csv_loader.py index 0dfe308..d4349c9 100644 --- a/src/nexa_backtest/signals/csv_loader.py +++ b/src/nexa_backtest/signals/csv_loader.py @@ -142,6 +142,11 @@ def _effective_cutoff(self, current_time: datetime) -> pd.Timestamp: offset = self._publication_offset if self._publication_offset is not None else timedelta(0) return pd.Timestamp(current_time + offset) + def _row_to_signal_value(self, row: Any) -> SignalValue: + """Construct a :class:`~nexa_backtest.context.SignalValue` from a DataFrame row.""" + ts: datetime = row[SIGNAL_CSV_TIMESTAMP_COL].to_pydatetime() + return SignalValue(name=self._name, timestamp=ts, value=float(row[SIGNAL_CSV_VALUE_COL])) + # ------------------------------------------------------------------ # SignalProvider protocol # ------------------------------------------------------------------ @@ -196,10 +201,7 @@ def get_value(self, timestamp: datetime) -> SignalValue: f"publication_offset: {self._publication_offset}." ) - row = visible.iloc[-1] - ts: datetime = row[SIGNAL_CSV_TIMESTAMP_COL].to_pydatetime() - val: float = float(row[SIGNAL_CSV_VALUE_COL]) - return SignalValue(name=self._name, timestamp=ts, value=val) + return self._row_to_signal_value(visible.iloc[-1]) def get_range(self, start: datetime, end: datetime) -> pd.Series[Any]: """Return values in the half-open interval ``[start, end)``. @@ -241,9 +243,4 @@ def get_history_at(self, timestamp: datetime, lookback: int) -> list[SignalValue mask = self._data[SIGNAL_CSV_TIMESTAMP_COL] <= cutoff visible = self._data[mask].tail(lookback) - result: list[SignalValue] = [] - for _, row in visible.iterrows(): - ts: datetime = row[SIGNAL_CSV_TIMESTAMP_COL].to_pydatetime() - val: float = float(row[SIGNAL_CSV_VALUE_COL]) - result.append(SignalValue(name=self._name, timestamp=ts, value=val)) - return result + return [self._row_to_signal_value(row) for _, row in visible.iterrows()] diff --git a/tests/test_cli/test_main.py b/tests/test_cli/test_main.py index eabb36f..8d1888f 100644 --- a/tests/test_cli/test_main.py +++ b/tests/test_cli/test_main.py @@ -64,9 +64,11 @@ def test_invalid_spec_raises_click_exception(self, tmp_path: Path) -> None: algo_file = _write_algo(tmp_path / "algo.py", code) import click - with patch("importlib.util.spec_from_file_location", return_value=None): - with pytest.raises(click.ClickException, match="Cannot load module"): - find_algo_class(str(algo_file)) + with ( + patch("importlib.util.spec_from_file_location", return_value=None), + pytest.raises(click.ClickException, match="Cannot load module"), + ): + find_algo_class(str(algo_file)) def test_multiple_subclasses_raises(self, tmp_path: Path) -> None: code = """