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/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/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..f91c3dc --- /dev/null +++ b/src/nexa_backtest/engines/backtest.py @@ -0,0 +1,533 @@ +"""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 + + +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. + + 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 _zero_position(product_id) + + 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 _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 + + 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: + context._record_fill(result.fill) + all_fills.append(result.fill) + self._algo.on_fill(context, result.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..d4349c9 --- /dev/null +++ b/src/nexa_backtest/signals/csv_loader.py @@ -0,0 +1,246 @@ +"""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) + + 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 + # ------------------------------------------------------------------ + + @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}." + ) + + 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)``. + + 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) + + return [self._row_to_signal_value(row) for _, row in visible.iterrows()] 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 0000000..9f62ee7 Binary files /dev/null and b/tests/fixtures/da_prices.parquet differ 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..8d1888f --- /dev/null +++ b/tests/test_cli/test_main.py @@ -0,0 +1,220 @@ +"""Tests for cli/main.py: nexa run command.""" + +from __future__ import annotations + +import textwrap +from pathlib import Path +from unittest.mock import patch + +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_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), + 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 + 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..df3f158 --- /dev/null +++ b/tests/test_engines/test_backtest.py @@ -0,0 +1,707 @@ +"""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_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") + 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") + + 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 +# --------------------------------------------------------------------------- + + +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