From 54ec33caaa6eb9b8500b3723be86a473e7268a7a Mon Sep 17 00:00:00 2001 From: Tom Medhurst Date: Mon, 20 Apr 2026 11:59:45 +0100 Subject: [PATCH 1/2] fix: use 365-day annualisation for Sharpe ratio Energy markets operate every day of the year, unlike equities where 252 trading days is the convention. Updated the annualisation factor in compute_sharpe() and the corresponding notebook walkthrough. Co-Authored-By: Claude Haiku 4.5 --- .claude/commands/release.md | 2 +- .../02-idc_and_metrics_walkthrough.ipynb | 74 +------------------ src/nexa_backtest/analysis/metrics.py | 5 +- 3 files changed, 8 insertions(+), 73 deletions(-) diff --git a/.claude/commands/release.md b/.claude/commands/release.md index 06382d2..ff78fc9 100644 --- a/.claude/commands/release.md +++ b/.claude/commands/release.md @@ -3,6 +3,6 @@ Create release: $ARGUMENTS 1. Read @README.md and @CONTRIBUTING.md file to understand how to publish this release. 2. Bump version in pyproject.toml 3. Perform the git cli commands -4. Use gh to create the release and publish +4. Use gh to create the release and publish (allow gh to generate notes) NOTE: The PR will run a few guardrail checks, so you'll need to wait for these to complete before merging. diff --git a/notebooks/02-idc_and_metrics_walkthrough.ipynb b/notebooks/02-idc_and_metrics_walkthrough.ipynb index 32d6c5a..41b44a7 100644 --- a/notebooks/02-idc_and_metrics_walkthrough.ipynb +++ b/notebooks/02-idc_and_metrics_walkthrough.ipynb @@ -1807,7 +1807,7 @@ }, { "cell_type": "code", - "execution_count": 14, + "execution_count": null, "metadata": { "execution": { "iopub.execute_input": "2026-04-13T20:35:34.739984Z", @@ -1816,74 +1816,8 @@ "shell.execute_reply": "2026-04-13T20:35:34.777878Z" } }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Returns (% change per snapshot):\n", - " Mean: 0.3757%\n", - " StdDev: 0.0448%\n", - " Min: 0.2515%\n", - " Max: 0.4633%\n", - "\n", - "Annualisation factor (daily DA snapshots): √252 = 15.8745\n", - "\n", - "Sharpe (manual calculation): 133.1102\n", - "Sharpe (from BacktestResult): 133.1102\n", - "(slight difference due to exact timestamp-based annualisation in the engine)\n" - ] - }, - { - "data": { - "image/png": "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3/PVV1+5wEJN0NVMXRdIutD0ApytttrKLf/73//ugisvSFIg59GFjLLtyloqC6sLrdrowlYXNH/7299ccKqMni5o1C/by8gnIpGyRdOFpwJ8XWwpIFZTbV3wKnjTRaIyxdEUbE2aNMkFF23atHH95P/0pz/ZL7/84oKMmiho0oWx6lEXqQoK1QRcF4G6QLzwwgtd2RUo66JbF/leM2EFJfWhmyUKZB5++GEXqOgiVxRk6TOfcsoptnbtWneBq+BI2dRDDz20TvuS6lpZ6eiBtrROBfGql/pk3LwgLTrIVCsN7U+6oFewr6yXMsK6kfTuu++6AFgB53fffWdPPfWU+9687LnqL96NgQ1RnShoVh/p6JsW+g6Vndf+ogt8BQ76HlU2r44VKKoJuAbEU9kUNCiQVbP5Aw44oMZt6kaV6m+HHXaoslw3kv75z3+641b7zu233x5pBaDvdvz48a5lQF3pMyg4U92qrOvXr3f1+eGHH1ZpKZPIfv/xxx+75v065rX/6ntU1wHt9yqjgqdo6rOu71jfp96rY177kfYnj7L5t9xyix1++OFuXAr1odVz7M0c3YDQTRgdswoUN9lkE1cW/f6iRYsiLQTq6qabbnL72qWXXuqa7qssOm42NF7C77//7rqIeEGp9sHXX3/d1bf2BW9wRJ0XFMirHtV1QDcXdA7Q/p4sqg91udENH63bo7Lp3KzPVFxc7G7kRHdxUDljW9DE884777jvTOXX34r777/f3bjR/qibNHWpD891113nstuq94qKihpbIui41E0D7Z9nn322O4cqYNZxGUufXxlp3YxVwK2/cw8++KB71v6usmlf1U0b3UTQuTOalm2++ea2++67R5Yp0Ncy/W3UeRtAhggDQANNmDBB0UH4448/rvE97dq1C2+//faR19dcc437Hc+dd97pXhcVFdW4Dq1f79H2Yu2zzz7uZ+PHj4/7Mz0806ZNc+/deOONw6WlpZHlzz77rFt+9913R5Ztuumm4WHDhm1wnbWVTb+v9Xheeukl997rr7++yvuOPfbYcCAQCP/www+RZXpfTk5OlWWff/65W37vvfeGa3PXXXe59z3xxBORZWvXrg3vvvvu4datW1f57CrfoYceWuv6En2v912+/PLLkWWrV6+u8h6Vo3///uF999232rqj69v7rvTsUfl33XXXKr83adKkau+L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4MGD7Z133qn2nkMOOcReffVV9//bbrvNbr31Vvf/yy67zH2W6G1p3/nwww8tO7vmyzDvWOG6AQDSV6JxXdoE3Q8//LAdfPDBbmTbmowZM8ZGjx5dbXlRUZH7A53OiouLI8+aMgcAmhMNBqU/TBrFXI9MoTJrijMFcwqivFGl9Tfnqaeesk022STyudKJWo8lMmiddzOkoeXXOjRGS21BZF1cdNFF9vrrr7s6bteunV144YV2zDHHxA12Peqedtddd1mvXr1szZo1ds8997hg/ZtvvnHd2XbZZRf75ZdfqgXq06ZNs+22267Kvjl58mTr169f5H0dOnSI/Ez7gurXs2zZMttpp51c+fSeL774wq655hqXTFC9HHXUUS7Y32abbdzPzz77bHezRmqrd/1M34/W36JFiwbUJpqLNRUhK0/SuHvPLnrTVq0vt1bZeXZ81wOr/CyvhVl+bto0lgVSqqysLHOC7p9//tneeustmzRpUq3vGzVqlI0cObJKplsXQ/pj2rZtW0tn3oBxhYWFbvoPAGhOFKTqD5OCsmQFZo1B84nvsMMONm/ePHvllVfslFNOccv1fwXcCvD0Hu8zKUi6+eab7aGHHrLFixfbFltsYVdddZUde+yxkb8Fypor0NPPtY6//vWvLqiMzq6r5deee+5pd9xxhwvwTjjhBBdQ1hR8eRlrZVBvvPFG93dV29J6Lr30UldeZYwVHGqd2267rU2cONFlgsUL0B955BEbNGiQy/Z+8sknLhgVrUd/v95++233cy9r/J///Meuvvpq+/LLL+2NN95wN8YVXObl5bmb6VrvWWed5cqXqBUrVriWA//617/c4Kqi1wqCZ82aFck4xzrttNOqvL7zzjvd73399de23377ue8oero63Qj697//beedd16kXr3vUX+nu3fvHnc7sX/Dn3/+ebdefUf6/R9++MEGDBgQKbv+r2Xbb7+9u3Gz99571/gZomld2rcU8Ks+gQ35vaTSJk1bY8UrG96a6MngVFsdKLaW4UJb++PRkeWFrQN28uB861xAAgmQRM/PaXHloz+K+iN26KGH1vq+3Nxc94ilP0p6pDOvfJlQVgBINp33oqeGzDQab0RB6qmnnhr5u6XgWMGneJ9JA4JqfBI1Re/Tp4/NmDHDBYP6G6dBQpX51M3i5557zgVTarasIFytvI4//vjI9hSU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bt2q3UAFMnHfXrFihW288cZWUVFhWVlZdv/999sBBxzQCCUG/Nu333vvPXv44Yfts88+o5qRcgTdAADUgcYs0KA86uetwXyATNemTRsXmKxcudJlutVvVuNzDBo0KNVFA+qlrKzMTjvtNDcIZseOHalFpBxBdwPpQNZd4d9//73Kcr2uaSApLa/L+4FM2beBpr5v33bbbS7ofuutt2zAgAE+lxRonH1bzXR79+7t/q/xZb755hsbM2YMQTcydt+eN2+eG0Dt8MMPjyxTH3DJzs52XSg233zzRig58AdGL2+gnJwc23HHHd2d4eiDWq933333uL+j5dHvlylTptT4fiBT9m2gKe/bt9xyixtYavLkyW6KJaCpnrf1O2pqDmTqvq1peb/88kvXgsN7HHHEETZ48GD3fw3ABjQmMt1JoGZYw4YNcxdhu+yyi5ubWKOTa8oN0ci26iulu8Zy4YUX2j777GO33367Gx1UzRRnzZplDz74YDKKA6Rs39ZAJ19//XXk/5pOSX/cWrduHcmiAJm4b998883297//3Z588kk3Mr83Bof2bT2ATN239az3KuunQPu1115z83SPGzcuxZ8EqP++ra4//fv3r/L7moteYpcDjYGgOwk0BVhRUZG7INOFmJpmKRPiDfbwyy+/uKZbnj322MNduF111VV2xRVXWJ8+fdz0BZwEkOn79sKFC2377bev0hRXD91kUv9XIFP3bQUgupF07LHHVlmP5ou99tprG738QLL2bQUt55xzji1YsMDy8/NdhvCJJ55w6wEyed8G0gnzdAMAAAAA4BNuBwEAAAAA4BOCbgAAAAAAfELQDQAAAACATwi6AQAAAADwCUE3AAAAAAA+IegGAAAAAMAnBN0AAAAAAPiEoBsAAAAAAJ8QdAMA0MgmTpxoBQUFkdfXXnutbbfdds36e3j44YftwAMPbNA6xo8fb4cffnjSygQAQDIQdAMAkIDTTz/dAoGAe7Ro0cI22mgjO+CAA+yRRx6xUChUpzo84YQT7LvvvkuLetfneemll1JahvLycrv66qvtmmuuiSybMmWKbbHFFta2bVs77bTTbO3atZGfrVixwv3s559/rrKeM844wz755BN79913G7X8AADUhqAbAIAEHXTQQbZo0SKbP3++vf766zZ48GC78MIL7bDDDrP169cnXI/5+fnWuXNnX+t93bp11piig+K6ev75511wveeee7rXuolx8skn29lnn20ffPCBzZo1yx588MHI+y+//HL3s0033bTKenJyctzv3XPPPQ34JAAAJBdBNwAACcrNzbUuXbrYxhtvbDvssINdccUV9vLLL7sAXE3GPXfccYdts8021qpVK+vRo4edc845tnLlyhqbl0ebMWOGy6QvXry4yvKLLrrI9tprr1oz1uPGjbMjjjjCbfeGG25wy1U+lTUvL88222wzGz16dOQGQc+ePd3z0Ucf7X7fe62s/lFHHVVt+4MGDYq81v/PO+88t7xjx442ZMgQmz59ulvP1KlTbaeddrKWLVvaHnvsYXPnzq21Xp9++ukqzcKXLl3qHqq3rbfe2n2mb775xv3s/ffft48//tjd7IhH63nllVdszZo1tW4TAIDGQtANAEAD7LvvvrbtttvapEmT/vfHNRh02davvvrKHn30UXv77bftsssuS2h9e++9twuOH3/88SpZ63/961+u+XRt1DdcAfSXX37p3qtm1kOHDnUB6tdff20PPPCAC/i9gFzBq0yYMMFl8L3XidJnU3b5v//9r+tP7bnyyivt9ttvdxnq7OzsDZb7vffec0G6p1OnTta1a1d78803bfXq1e5zDBgwwNXDX//6V/c5srKy4q5L69FNhY8++qhOnwUAAL8QdAMA0EB9+/Z1Tc49yv6q6bkyxwrKr7/+env22WcTXt+IESNcIOz597//7fo9H3/88bX+nppWDx8+3AXtm2yyictqqyn2sGHD3DL1Qb/uuutc0OoFt6KsuzL43utE9enTx2655Rbbcsst3cOjoH6fffaxfv36ue0rO63yx1NSUuL6aHfr1i2yTNly1ZfKqkz39ttv7wL3m266ydWrsvZqiq5tjh07tsr6lF1v165dtf7eAACkSnbKtgwAQBMRDoddoOh56623bMyYMfbtt99aaWmpy7wq6FTWVkHhhqh591VXXWUffvih7bbbbi47rYBbzcZrE50tls8//9xlob3MtlRWVtapLLXZcccd4y5XVtqjjLUsWbLE3QiI5TUDVyAdbeDAgVUy7xp47rHHHrNPP/3UtQZQ9v7ggw+2/v37u9fR21SfeX0+AADSAUE3AAANpP7GvXr1cv9XxlsDq6kZtILdwsJC13xa2WsNNpZIoKtB1tQ3WdlurVd9xtVfekNig3L1I1e2+5hjjqn23tggN5qax+tGwoYGZqvpJoD6pHu8mxE1jfDeoUMH957ly5dbbc466yzXZF3rUeB93HHHubpURv2dd96pEnQXFxfXOWsPAIBfCLoBAGgA9ddWH+qLL77YvZ49e7YLDBUgKniVujQt9/z5z3+2k046ybp3726bb755ZGTvutAAahrErHfv3jW+RwGyst/RFLDOmTOnyrLPPvusSjCdLOoTrmbo6nNe0zzdmsNbNy80oJoXnHs3AfQcXf558+a5TL6apAMAkA7o0w0AQIIqKircqOK//fabmw/6xhtvtCOPPNJltjVgmSjAVSB477332o8//ugGRIseZCxRGg1c02ipP7j6adfH3//+d9ckW9luDeqmjLxGClfTdY/6nWu0cX0uL6BVP3QNgqbf/f7779382bFBeDLps6o1QDxqlq46UH1K+/btbauttrK77rrLTSemskffkNCga+q/rhsVAACkA4JuAAASNHnyZNdHWYGq5uyeNm2aG6Vc03J5o2lrJHNNGXbzzTe7/sYadVz9u+v8BzoYdH27lcX1Avr6BLOvvvqqGwV85513dv3D77zzzirzWysjP2XKFDe1mZcd1u9dffXVbsR1/V5ZWVm9y5AINb1/7bXX3IBqsdR3+5JLLqky0Jr6uOvmgW52/N///Z8ro+epp56yM88807eyAgBQV4FwbKctAACQFhSMFhUVuXmnmzr10VZz+FGjRtV7HcrmK0uvQdc0gjkAAOmATDcAAGlGGV81t37yySft/PPPt+bg1ltvtdatWzdoHZprXE3iCbgBAOmETDcAAGlm0KBBNnPmTDdit5qDAwCAzEXQDQAAAACAT2heDgAAAACATwi6AQAAAADwCUE3AAAAAAA+IegGAAAAAMAnBN0AAAAAAPiEoBsAAAAAAJ8QdAMAAAAA4BOCbgAAAAAAfELQDQAAAACA+eP/AQXmA8Wogwe1AAAAAElFTkSuQmCC", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "# Walk through the Sharpe calculation manually so you can see what's happening\n", - "\n", - "equity_series = eq_df[\"total_equity\"].values\n", - "\n", - "# Step 1: percentage returns between consecutive snapshots\n", - "returns = np.diff(equity_series) / equity_series[:-1]\n", - "print(f\"Returns (% change per snapshot):\")\n", - "print(f\" Mean: {np.mean(returns)*100:.4f}%\")\n", - "print(f\" StdDev: {np.std(returns, ddof=1)*100:.4f}%\")\n", - "print(f\" Min: {np.min(returns)*100:.4f}%\")\n", - "print(f\" Max: {np.max(returns)*100:.4f}%\")\n", - "\n", - "# Step 2: annualisation factor\n", - "# Our snapshots are ~daily (DA gate closure = 1 snapshot/day).\n", - "# Annualisation: √(252 trading days per year)\n", - "annualisation = np.sqrt(252.0)\n", - "print(f\"\\nAnnualisation factor (daily DA snapshots): √252 = {annualisation:.4f}\")\n", - "\n", - "# Step 3: Sharpe ratio\n", - "sharpe_manual = (np.mean(returns) / np.std(returns, ddof=1)) * annualisation\n", - "print(f\"\\nSharpe (manual calculation): {sharpe_manual:.4f}\")\n", - "print(f\"Sharpe (from BacktestResult): {float(da_result.sharpe_ratio):.4f}\")\n", - "print(\"(slight difference due to exact timestamp-based annualisation in the engine)\")\n", - "\n", - "# Visualise the return distribution\n", - "fig, ax = plt.subplots(figsize=(10, 4))\n", - "ax.hist(returns * 100, bins=15, color=\"#2563eb\", alpha=0.7, edgecolor=\"white\")\n", - "ax.axvline(np.mean(returns) * 100, color=\"#16a34a\", linewidth=2, linestyle=\"--\",\n", - " label=f\"Mean return: {np.mean(returns)*100:.4f}%\")\n", - "ax.axvline(0, color=\"grey\", linewidth=1.0, linestyle=\"-\")\n", - "ax.set_title(\"Distribution of Daily Returns (% change in equity per day)\", fontsize=12)\n", - "ax.set_xlabel(\"Daily return (%)\")\n", - "ax.set_ylabel(\"Frequency\")\n", - "ax.legend(fontsize=10)\n", - "ax.grid(True, alpha=0.3, axis=\"y\")\n", - "plt.tight_layout()\n", - "plt.show()" - ] + "outputs": [], + "source": "# Walk through the Sharpe calculation manually so you can see what's happening\n\nequity_series = eq_df[\"total_equity\"].values\n\n# Step 1: percentage returns between consecutive snapshots\nreturns = np.diff(equity_series) / equity_series[:-1]\nprint(f\"Returns (% change per snapshot):\")\nprint(f\" Mean: {np.mean(returns)*100:.4f}%\")\nprint(f\" StdDev: {np.std(returns, ddof=1)*100:.4f}%\")\nprint(f\" Min: {np.min(returns)*100:.4f}%\")\nprint(f\" Max: {np.max(returns)*100:.4f}%\")\n\n# Step 2: annualisation factor\n# Our snapshots are ~daily (DA gate closure = 1 snapshot/day).\n# Annualisation: √(365 days per year) — energy markets operate every day\nannualisation = np.sqrt(365.0)\nprint(f\"\\nAnnualisation factor (daily DA snapshots): √365 = {annualisation:.4f}\")\n\n# Step 3: Sharpe ratio\nsharpe_manual = (np.mean(returns) / np.std(returns, ddof=1)) * annualisation\nprint(f\"\\nSharpe (manual calculation): {sharpe_manual:.4f}\")\nprint(f\"Sharpe (from BacktestResult): {float(da_result.sharpe_ratio):.4f}\")\nprint(\"(slight difference due to exact timestamp-based annualisation in the engine)\")\n\n# Visualise the return distribution\nfig, ax = plt.subplots(figsize=(10, 4))\nax.hist(returns * 100, bins=15, color=\"#2563eb\", alpha=0.7, edgecolor=\"white\")\nax.axvline(np.mean(returns) * 100, color=\"#16a34a\", linewidth=2, linestyle=\"--\",\n label=f\"Mean return: {np.mean(returns)*100:.4f}%\")\nax.axvline(0, color=\"grey\", linewidth=1.0, linestyle=\"-\")\nax.set_title(\"Distribution of Daily Returns (% change in equity per day)\", fontsize=12)\nax.set_xlabel(\"Daily return (%)\")\nax.set_ylabel(\"Frequency\")\nax.legend(fontsize=10)\nax.grid(True, alpha=0.3, axis=\"y\")\nplt.tight_layout()\nplt.show()" }, { "cell_type": "markdown", @@ -2464,4 +2398,4 @@ }, "nbformat": 4, "nbformat_minor": 4 -} +} \ No newline at end of file diff --git a/src/nexa_backtest/analysis/metrics.py b/src/nexa_backtest/analysis/metrics.py index 601fec6..a2de022 100644 --- a/src/nexa_backtest/analysis/metrics.py +++ b/src/nexa_backtest/analysis/metrics.py @@ -100,7 +100,8 @@ def compute_sharpe(equity_snapshots: list[EquitySnapshot]) -> Decimal | None: Uses percentage returns between consecutive equity snapshots. Annualisation factor is derived from the actual average observation - frequency using ``sqrt(252 * periods_per_day)``. + frequency using ``sqrt(365 * periods_per_day)`` (energy markets + operate every day of the year, unlike equities' 252-day convention). Args: equity_snapshots: Chronological list of equity snapshots. @@ -129,7 +130,7 @@ def compute_sharpe(equity_snapshots: list[EquitySnapshot]) -> Decimal | None: total_days = max(total_seconds / 86400.0, 1.0) n_obs = len(returns) periods_per_day = n_obs / total_days - annualisation = math.sqrt(252.0 * periods_per_day) + annualisation = math.sqrt(365.0 * periods_per_day) sharpe = (mean_ret / std_ret) * annualisation return Decimal(str(round(sharpe, 6))) From 397c42cf947393d12c71a3b1b25d5375a84340b3 Mon Sep 17 00:00:00 2001 From: Tom Medhurst Date: Wed, 22 Apr 2026 11:52:26 +0100 Subject: [PATCH 2/2] Add missing PnL metrics --- src/nexa_backtest/analysis/metrics.py | 71 ++++++++++++++++++++++---- src/nexa_backtest/analysis/pnl.py | 26 ++++++++++ src/nexa_backtest/analysis/report.py | 14 ++++++ src/nexa_backtest/engines/backtest.py | 3 ++ src/nexa_backtest/engines/shared.py | 3 ++ tests/test_analysis/test_metrics.py | 72 +++++++++++++++++++++++++++ tests/test_analysis/test_pnl.py | 54 ++++++++++++++++++++ 7 files changed, 234 insertions(+), 9 deletions(-) diff --git a/src/nexa_backtest/analysis/metrics.py b/src/nexa_backtest/analysis/metrics.py index a2de022..9863d9a 100644 --- a/src/nexa_backtest/analysis/metrics.py +++ b/src/nexa_backtest/analysis/metrics.py @@ -15,7 +15,7 @@ import math from collections import defaultdict from dataclasses import dataclass, field -from datetime import date +from datetime import date, timedelta from decimal import Decimal from pathlib import Path from typing import TYPE_CHECKING @@ -168,6 +168,38 @@ def compute_max_drawdown( return max_dd, max_dd_pct +def compute_time_in_drawdown( + equity_snapshots: list[EquitySnapshot], +) -> timedelta: + """Compute total time spent in drawdown (equity below its running peak). + + Walks the equity curve and accumulates the duration of periods where + equity is strictly below the running peak. + + Args: + equity_snapshots: Chronological list of equity snapshots. + + Returns: + Total time in drawdown as a ``timedelta``. Zero if fewer than two + snapshots or the curve is monotonically increasing. + """ + if len(equity_snapshots) < 2: + return timedelta(0) + + peak = equity_snapshots[0].total_equity + total = timedelta(0) + + for i in range(1, len(equity_snapshots)): + prev = equity_snapshots[i - 1] + curr = equity_snapshots[i] + if prev.total_equity < peak: + total += curr.timestamp - prev.timestamp + if curr.total_equity > peak: + peak = curr.total_equity + + return total + + def compute_profit_factor( fills: list[Fill], market_vwap: Decimal, @@ -218,6 +250,7 @@ class BacktestResult: sharpe_ratio: Annualised Sharpe ratio, or ``None`` if < 2 snapshots. max_drawdown: Largest peak-to-trough drawdown in EUR. max_drawdown_pct: Max drawdown as a fraction of peak equity. + time_in_drawdown: Total time the equity curve spent below its peak. profit_factor: Sum of gains / sum of losses, or ``None`` if no losses. avg_trade_pnl: Mean per-fill PnL in EUR. best_trade: Fill with the highest individual PnL, or ``None``. @@ -243,6 +276,7 @@ class BacktestResult: sharpe_ratio: Decimal | None = None max_drawdown: Decimal = Decimal("0") max_drawdown_pct: Decimal = Decimal("0") + time_in_drawdown: timedelta = field(default_factory=lambda: timedelta(0)) profit_factor: Decimal | None = None avg_trade_pnl: Decimal = Decimal("0") best_trade: Fill | None = None @@ -272,6 +306,8 @@ def summary(self) -> str: total_pnl = p.total_alpha_eur final_equity = self.initial_capital + total_pnl + total_volume = sum((f.volume for f in self.fills), Decimal("0")) + lines: list[str] = [ sep, f" Backtest Results: {self.start} to {self.end} ({self.duration_days} days)", @@ -279,6 +315,7 @@ def summary(self) -> str: sep, "", f" Total PnL: {total_pnl:>+14,.2f} EUR", + f" PnL/MWh: {float(p.pnl_per_mwh):>+14.2f} EUR/MWh", f" Market VWAP: {p.market_vwap:>14.2f} EUR/MWh", ] @@ -292,22 +329,26 @@ def summary(self) -> str: f" Max Drawdown: {-float(self.max_drawdown):>+14,.2f} EUR ({-dd_pct:.1f}%)" ) + dd_hours = self.time_in_drawdown.total_seconds() / 3600 + if dd_hours >= 24: + dd_days = self.time_in_drawdown.days + dd_rem_hours = self.time_in_drawdown.seconds // 3600 + lines.append(f" Time in Drawdown: {dd_days:>10d}d {dd_rem_hours:02d}h") + else: + lines.append(f" Time in Drawdown: {dd_hours:>13.1f}h") + if self.profit_factor is not None: lines.append(f" Profit Factor: {float(self.profit_factor):>14.2f}") else: lines.append(f" Profit Factor: {'N/A (no losses)':>14}") - total_volume = sum((f.volume for f in self.fills), Decimal("0")) - win_rate = ( - (p.buys.win_rate * p.buys.count + p.sells.win_rate * p.sells.count) / len(self.fills) - if self.fills - else 0.0 - ) - lines += [ "", f" Trades: {len(self.fills):>14,d}", - f" Win Rate: {win_rate:>14.1%}", + f" Win Rate: {p.win_rate:>14.1%}", + f" Loss Rate: {p.loss_rate:>14.1%}", + f" Longs: {p.long_pct:>14.1%}", + f" Shorts: {p.short_pct:>14.1%}", f" Avg Trade PnL: {float(self.avg_trade_pnl):>+14,.2f} EUR", ] @@ -585,7 +626,13 @@ def _snap_to_dict(s: EquitySnapshot) -> dict[str, str]: "sharpe_ratio": str(self.sharpe_ratio) if self.sharpe_ratio is not None else None, "max_drawdown": str(self.max_drawdown), "max_drawdown_pct": str(self.max_drawdown_pct), + "time_in_drawdown_seconds": self.time_in_drawdown.total_seconds(), "profit_factor": str(self.profit_factor) if self.profit_factor is not None else None, + "pnl_per_mwh": str(self.pnl.pnl_per_mwh), + "win_rate": self.pnl.win_rate, + "loss_rate": self.pnl.loss_rate, + "long_pct": self.pnl.long_pct, + "short_pct": self.pnl.short_pct, "avg_trade_pnl": str(self.avg_trade_pnl), "trade_count": len(self.fills), "equity_curve": [_snap_to_dict(s) for s in self.equity_curve], @@ -625,7 +672,13 @@ def to_parquet(self, path: str) -> None: "sharpe_ratio": str(self.sharpe_ratio) if self.sharpe_ratio is not None else None, "max_drawdown": str(self.max_drawdown), "max_drawdown_pct": str(self.max_drawdown_pct), + "time_in_drawdown_seconds": self.time_in_drawdown.total_seconds(), "profit_factor": str(self.profit_factor) if self.profit_factor is not None else None, + "pnl_per_mwh": str(self.pnl.pnl_per_mwh), + "win_rate": self.pnl.win_rate, + "loss_rate": self.pnl.loss_rate, + "long_pct": self.pnl.long_pct, + "short_pct": self.pnl.short_pct, "avg_trade_pnl": str(self.avg_trade_pnl), "trade_count": len(self.fills), } diff --git a/src/nexa_backtest/analysis/pnl.py b/src/nexa_backtest/analysis/pnl.py index 9a775a7..405d3c1 100644 --- a/src/nexa_backtest/analysis/pnl.py +++ b/src/nexa_backtest/analysis/pnl.py @@ -51,12 +51,22 @@ class PnlSummary: 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. + pnl_per_mwh: Alpha per MWh traded (``total_alpha_eur / total_volume``). + win_rate: Combined win rate across buys and sells (fill-weighted). + loss_rate: ``1 - win_rate``. + long_pct: Fraction of fills that were buys. + short_pct: Fraction of fills that were sells. """ market_vwap: Decimal buys: SideSummary sells: SideSummary total_alpha_eur: Decimal + pnl_per_mwh: Decimal = Decimal("0") + win_rate: float = 0.0 + loss_rate: float = 1.0 + long_pct: float = 0.0 + short_pct: float = 0.0 def _side_summary( @@ -157,10 +167,26 @@ def compute_pnl( sells = _side_summary(fills, market_vwap, Side.SELL, product_vwaps) total_alpha = buys.total_alpha_eur + sells.total_alpha_eur + total_volume = buys.volume_mwh + sells.volume_mwh + total_count = buys.count + sells.count + + pnl_per_mwh = total_alpha / total_volume if total_volume > 0 else Decimal("0") + win_rate = ( + (buys.win_rate * buys.count + sells.win_rate * sells.count) / total_count + if total_count > 0 + else 0.0 + ) + long_pct = buys.count / total_count if total_count > 0 else 0.0 + short_pct = sells.count / total_count if total_count > 0 else 0.0 return PnlSummary( market_vwap=market_vwap, buys=buys, sells=sells, total_alpha_eur=total_alpha, + pnl_per_mwh=pnl_per_mwh, + win_rate=win_rate, + loss_rate=1.0 - win_rate, + long_pct=long_pct, + short_pct=short_pct, ) diff --git a/src/nexa_backtest/analysis/report.py b/src/nexa_backtest/analysis/report.py index 6b4ba4c..08d8752 100644 --- a/src/nexa_backtest/analysis/report.py +++ b/src/nexa_backtest/analysis/report.py @@ -194,11 +194,20 @@ def _metric_rows(result: BacktestResult) -> list[tuple[str, str]]: final_equity = result.initial_capital + total_pnl total_volume = sum((f.volume for f in result.fills), Decimal("0")) + dd_hours = result.time_in_drawdown.total_seconds() / 3600 + if dd_hours >= 24: + dd_days = result.time_in_drawdown.days + dd_rem_hours = result.time_in_drawdown.seconds // 3600 + dd_str = f"{dd_days}d {dd_rem_hours:02d}h" + else: + dd_str = f"{dd_hours:.1f}h" + rows: list[tuple[str, str]] = [ ("Period", f"{result.start} — {result.end} ({result.duration_days} days)"), ("Exchange", result.exchange), ("Algo", result.algo_name), ("Total PnL", f"{total_pnl:+,.2f} EUR"), + ("PnL/MWh", f"{float(p.pnl_per_mwh):+.2f} EUR/MWh"), ("Market VWAP", f"{p.market_vwap:.2f} EUR/MWh"), ( "Sharpe Ratio", @@ -211,11 +220,16 @@ def _metric_rows(result: BacktestResult) -> list[tuple[str, str]]: f" ({-float(result.max_drawdown_pct) * 100:.1f}%)" ), ), + ("Time in Drawdown", dd_str), ( "Profit Factor", f"{float(result.profit_factor):.2f}" if result.profit_factor is not None else "N/A", ), ("Trade Count", f"{len(result.fills):,d}"), + ("Win Rate", f"{p.win_rate:.1%}"), + ("Loss Rate", f"{p.loss_rate:.1%}"), + ("Longs", f"{p.long_pct:.1%}"), + ("Shorts", f"{p.short_pct:.1%}"), ("Total Volume", f"{float(total_volume):,.1f} MW"), ("Avg Trade PnL", f"{float(result.avg_trade_pnl):+,.2f} EUR"), ("Initial Capital", f"{float(result.initial_capital):,.2f} EUR"), diff --git a/src/nexa_backtest/engines/backtest.py b/src/nexa_backtest/engines/backtest.py index 1450297..4a7e2aa 100644 --- a/src/nexa_backtest/engines/backtest.py +++ b/src/nexa_backtest/engines/backtest.py @@ -41,6 +41,7 @@ compute_max_drawdown, compute_profit_factor, compute_sharpe, + compute_time_in_drawdown, ) from nexa_backtest.analysis.pnl import compute_pnl from nexa_backtest.analysis.vwap import compute_idc_vwaps, compute_market_vwap @@ -888,6 +889,7 @@ def run(self) -> BacktestResult: sharpe = compute_sharpe(equity_snapshots) max_dd, max_dd_pct = compute_max_drawdown(equity_snapshots) + dd_time = compute_time_in_drawdown(equity_snapshots) product_vwaps_for_metrics = per_product_vwap or {} profit_fac = compute_profit_factor( all_fills, @@ -930,6 +932,7 @@ def run(self) -> BacktestResult: sharpe_ratio=sharpe, max_drawdown=max_dd, max_drawdown_pct=max_dd_pct, + time_in_drawdown=dd_time, profit_factor=profit_fac, avg_trade_pnl=avg_trade, best_trade=best_fill, diff --git a/src/nexa_backtest/engines/shared.py b/src/nexa_backtest/engines/shared.py index b4a4b67..beeccd9 100644 --- a/src/nexa_backtest/engines/shared.py +++ b/src/nexa_backtest/engines/shared.py @@ -45,6 +45,7 @@ compute_max_drawdown, compute_profit_factor, compute_sharpe, + compute_time_in_drawdown, ) from nexa_backtest.analysis.pnl import compute_pnl from nexa_backtest.analysis.vwap import compute_idc_vwaps, compute_market_vwap @@ -837,6 +838,7 @@ def _build_result(self, runner: _AlgoRunner, zone: str) -> BacktestResult: sharpe = compute_sharpe(equity_snapshots) max_dd, max_dd_pct = compute_max_drawdown(equity_snapshots) + dd_time = compute_time_in_drawdown(equity_snapshots) product_vwaps_for_metrics = per_product_vwap or {} profit_fac = compute_profit_factor( all_fills, market_vwap, product_vwaps_for_metrics or None @@ -881,6 +883,7 @@ def _build_result(self, runner: _AlgoRunner, zone: str) -> BacktestResult: sharpe_ratio=sharpe, max_drawdown=max_dd, max_drawdown_pct=max_dd_pct, + time_in_drawdown=dd_time, profit_factor=profit_fac, avg_trade_pnl=avg_trade, best_trade=best_fill, diff --git a/tests/test_analysis/test_metrics.py b/tests/test_analysis/test_metrics.py index 1c4fd9a..00b6ad0 100644 --- a/tests/test_analysis/test_metrics.py +++ b/tests/test_analysis/test_metrics.py @@ -201,3 +201,75 @@ def test_uses_product_vwaps_consistently(self) -> None: product_result = compute_profit_factor([fill], Decimal("50"), product_vwaps) assert product_result is None # gain vs product VWAP → no losses + + +class TestComputeTimeInDrawdown: + """Tests for compute_time_in_drawdown.""" + + def test_empty_snapshots_returns_zero(self) -> None: + from datetime import timedelta + + from nexa_backtest.analysis.metrics import compute_time_in_drawdown + + assert compute_time_in_drawdown([]) == timedelta(0) + + def test_single_snapshot_returns_zero(self) -> None: + from datetime import timedelta + + from nexa_backtest.analysis.metrics import compute_time_in_drawdown + from nexa_backtest.types import EquitySnapshot + + snap = EquitySnapshot( + timestamp=datetime(2026, 3, 1, 10, 0, tzinfo=UTC), + realised_pnl=Decimal("100"), + unrealised_pnl=Decimal("0"), + total_equity=Decimal("1100"), + cash=Decimal("1100"), + net_position_mw=Decimal("0"), + ) + assert compute_time_in_drawdown([snap]) == timedelta(0) + + def test_monotonically_increasing_no_drawdown(self) -> None: + from datetime import timedelta + + from nexa_backtest.analysis.metrics import compute_time_in_drawdown + from nexa_backtest.types import EquitySnapshot + + snaps = [ + EquitySnapshot( + timestamp=datetime(2026, 3, 1, h, 0, tzinfo=UTC), + realised_pnl=Decimal(str(h * 10)), + unrealised_pnl=Decimal("0"), + total_equity=Decimal(str(1000 + h * 10)), + cash=Decimal(str(1000 + h * 10)), + net_position_mw=Decimal("0"), + ) + for h in range(10, 15) + ] + assert compute_time_in_drawdown(snaps) == timedelta(0) + + def test_drawdown_period_measured(self) -> None: + from datetime import timedelta + + from nexa_backtest.analysis.metrics import compute_time_in_drawdown + from nexa_backtest.types import EquitySnapshot + + def _snap(hour: int, equity: int) -> EquitySnapshot: + return EquitySnapshot( + timestamp=datetime(2026, 3, 1, hour, 0, tzinfo=UTC), + realised_pnl=Decimal(str(equity - 1000)), + unrealised_pnl=Decimal("0"), + total_equity=Decimal(str(equity)), + cash=Decimal(str(equity)), + net_position_mw=Decimal("0"), + ) + + # Peak at h=10 (1100), dips at h=11 (1050), h=12 (1080), recovers h=13 (1100) + snaps = [ + _snap(10, 1100), # peak + _snap(11, 1050), # below peak → drawdown starts + _snap(12, 1080), # still below peak + _snap(13, 1100), # recovered to peak + ] + # Drawdown time: h11→h12 (1h) + h12→h13 (1h) = 2h + assert compute_time_in_drawdown(snaps) == timedelta(hours=2) diff --git a/tests/test_analysis/test_pnl.py b/tests/test_analysis/test_pnl.py index 7a0c671..4d14254 100644 --- a/tests/test_analysis/test_pnl.py +++ b/tests/test_analysis/test_pnl.py @@ -108,3 +108,57 @@ def test_total_alpha_combines_buys_and_sells(self) -> None: ] result = compute_pnl(fills, df) assert result.total_alpha_eur == result.buys.total_alpha_eur + result.sells.total_alpha_eur + + +class TestPnlSummaryDerivedMetrics: + """Tests for the derived metrics on PnlSummary.""" + + def test_pnl_per_mwh(self) -> None: + df = _market_data([50.0], [100.0]) + fills = [_fill(Side.BUY, 40.0, 10.0)] # alpha = (50-40)*10 = 100 + result = compute_pnl(fills, df) + # pnl_per_mwh = 100 / 10 = 10 + assert float(result.pnl_per_mwh) == pytest.approx(10.0) + + def test_pnl_per_mwh_no_fills(self) -> None: + df = _market_data([50.0], [100.0]) + result = compute_pnl([], df) + assert result.pnl_per_mwh == Decimal("0") + + def test_win_rate_combined(self) -> None: + df = _market_data([50.0], [100.0]) + fills = [ + _fill(Side.BUY, 40.0), # win (below VWAP) + _fill(Side.BUY, 60.0), # loss (above VWAP) + _fill(Side.SELL, 60.0), # win (above VWAP) + ] + result = compute_pnl(fills, df) + # 2 wins out of 3 fills + assert result.win_rate == pytest.approx(2 / 3) + assert result.loss_rate == pytest.approx(1 / 3) + + def test_long_short_pct(self) -> None: + df = _market_data([50.0], [100.0]) + fills = [ + _fill(Side.BUY, 40.0), + _fill(Side.BUY, 45.0), + _fill(Side.SELL, 55.0), + ] + result = compute_pnl(fills, df) + assert result.long_pct == pytest.approx(2 / 3) + assert result.short_pct == pytest.approx(1 / 3) + + def test_all_buys_gives_100_pct_longs(self) -> None: + df = _market_data([50.0], [100.0]) + fills = [_fill(Side.BUY, 40.0)] + result = compute_pnl(fills, df) + assert result.long_pct == pytest.approx(1.0) + assert result.short_pct == pytest.approx(0.0) + + def test_no_fills_gives_zero_pcts(self) -> None: + df = _market_data([50.0], [100.0]) + result = compute_pnl([], df) + assert result.long_pct == 0.0 + assert result.short_pct == 0.0 + assert result.win_rate == 0.0 + assert result.loss_rate == 1.0