Dmitry Beresnev commited on
Commit
ad9d2ff
·
1 Parent(s): bf23d1f

add backtesting

Browse files
Files changed (7) hide show
  1. Dockerfile +2 -1
  2. README.md +3 -0
  3. app.py +155 -0
  4. pyproject.toml +4 -0
  5. tools/README.md +5 -0
  6. tools/__init__.py +1 -0
  7. tools/backtesting_runner.py +222 -0
Dockerfile CHANGED
@@ -1,4 +1,4 @@
1
- FROM python:3.13-slim
2
 
3
  WORKDIR /app
4
 
@@ -11,6 +11,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
11
  && curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
12
  && apt-get install -y --no-install-recommends nodejs \
13
  && (npm install -g openclaw || npm install -g clawdbot) \
 
14
  && rm -rf /var/lib/apt/lists/*
15
 
16
  # Python dependencies via uv + pyproject.toml.
 
1
+ FROM python:3.11-slim
2
 
3
  WORKDIR /app
4
 
 
11
  && curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
12
  && apt-get install -y --no-install-recommends nodejs \
13
  && (npm install -g openclaw || npm install -g clawdbot) \
14
+ && (openclaw --help >/dev/null 2>&1 || clawdbot --help >/dev/null 2>&1) \
15
  && rm -rf /var/lib/apt/lists/*
16
 
17
  # Python dependencies via uv + pyproject.toml.
README.md CHANGED
@@ -20,6 +20,7 @@ This Space hosts the OpenClaw trading bot (paper-only). The LLM runs in a separa
20
  - OpenClaw bot Space (this repo) calls an external LLM Space for analysis and signal generation.
21
  - Paper trading via Alpaca API.
22
  - Trade logs stored to HF Hub storage (dataset repo).
 
23
 
24
  **LLM Space (external)**
25
  - Expected to expose a simple HTTP inference endpoint.
@@ -28,6 +29,8 @@ This Space hosts the OpenClaw trading bot (paper-only). The LLM runs in a separa
28
 
29
  **Key Files**
30
  - `openclaw.json` defines providers, routing, tools, memory, and safety.
 
 
31
  - `config/openclaw.env.example` lists all required env vars.
32
  - `skills/` contains architecture-only SKILL specs.
33
  - `tools/README.md` defines the tool surface to implement later.
 
20
  - OpenClaw bot Space (this repo) calls an external LLM Space for analysis and signal generation.
21
  - Paper trading via Alpaca API.
22
  - Trade logs stored to HF Hub storage (dataset repo).
23
+ - Streamlit control center includes a built-in backtesting lab (backtesting.py + backtrader).
24
 
25
  **LLM Space (external)**
26
  - Expected to expose a simple HTTP inference endpoint.
 
29
 
30
  **Key Files**
31
  - `openclaw.json` defines providers, routing, tools, memory, and safety.
32
+ - `app.py` provides gateway controls and strategy backtesting UI.
33
+ - `tools/backtesting_runner.py` implements SMA crossover test runners for `backtesting.py` and `backtrader`.
34
  - `config/openclaw.env.example` lists all required env vars.
35
  - `skills/` contains architecture-only SKILL specs.
36
  - `tools/README.md` defines the tool surface to implement later.
app.py CHANGED
@@ -2,10 +2,17 @@ import json
2
  import os
3
  import shutil
4
  import subprocess
 
5
  from pathlib import Path
6
 
 
7
  import requests
8
  import streamlit as st
 
 
 
 
 
9
 
10
  HF_PORT = int(os.getenv("PORT", "7860"))
11
  OPENCLAW_PORT = int(os.getenv("OPENCLAW_PORT", "8787"))
@@ -31,6 +38,10 @@ def init_state() -> None:
31
  st.session_state.setdefault("gateway_logs", [])
32
  st.session_state.setdefault("config_editor_text", load_config_text())
33
  st.session_state.setdefault("auto_started", False)
 
 
 
 
34
 
35
 
36
  def load_config_text() -> str:
@@ -157,6 +168,66 @@ def test_gateway(query: str) -> str:
157
  return f"Gateway request failed: {exc}"
158
 
159
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
160
  st.set_page_config(page_title="OpenClaw Control Center", layout="wide")
161
  st.title("OpenClaw Control Center")
162
  st.caption("Manage gateway runtime, config, environment, and test calls from one UI.")
@@ -245,3 +316,87 @@ with test_col:
245
  with logs_col:
246
  st.subheader("Gateway Logs")
247
  st.code("\n".join(st.session_state["gateway_logs"][-LOG_MAX_LINES:]) or "No logs yet.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  import os
3
  import shutil
4
  import subprocess
5
+ from datetime import date
6
  from pathlib import Path
7
 
8
+ import pandas as pd
9
  import requests
10
  import streamlit as st
11
+ from tools.backtesting_runner import (
12
+ load_price_data_from_csv_text,
13
+ load_price_data_from_yfinance,
14
+ run_backtest,
15
+ )
16
 
17
  HF_PORT = int(os.getenv("PORT", "7860"))
18
  OPENCLAW_PORT = int(os.getenv("OPENCLAW_PORT", "8787"))
 
38
  st.session_state.setdefault("gateway_logs", [])
39
  st.session_state.setdefault("config_editor_text", load_config_text())
40
  st.session_state.setdefault("auto_started", False)
41
+ st.session_state.setdefault("backtest_result", None)
42
+ st.session_state.setdefault("backtest_data", None)
43
+ st.session_state.setdefault("backtest_params", None)
44
+ st.session_state.setdefault("backtest_error", "")
45
 
46
 
47
  def load_config_text() -> str:
 
168
  return f"Gateway request failed: {exc}"
169
 
170
 
171
+ def build_strategy_frame(data: pd.DataFrame, fast_period: int, slow_period: int) -> pd.DataFrame:
172
+ frame = pd.DataFrame(index=data.index)
173
+ frame["Close"] = data["Close"]
174
+ frame["FastMA"] = data["Close"].rolling(window=fast_period).mean()
175
+ frame["SlowMA"] = data["Close"].rolling(window=slow_period).mean()
176
+ frame["Signal"] = 0
177
+ frame.loc[frame["FastMA"] > frame["SlowMA"], "Signal"] = 1
178
+ frame["SignalChange"] = frame["Signal"].diff().fillna(0)
179
+ return frame
180
+
181
+
182
+ def render_backtest_visuals(result, data: pd.DataFrame, params: dict) -> None:
183
+ st.success(f"Backtest completed with {result.engine} ({result.input_rows} bars).")
184
+
185
+ metrics = result.metrics or {}
186
+ if metrics:
187
+ metric_cols = st.columns(max(1, min(4, len(metrics))))
188
+ for idx, (metric_name, metric_value) in enumerate(metrics.items()):
189
+ col = metric_cols[idx % len(metric_cols)]
190
+ val = f"{metric_value:.3f}" if isinstance(metric_value, float) else str(metric_value)
191
+ col.metric(metric_name, val)
192
+
193
+ viz = build_strategy_frame(data, params["fast_period"], params["slow_period"])
194
+ st.markdown("**Price + Moving Averages**")
195
+ st.line_chart(viz[["Close", "FastMA", "SlowMA"]], use_container_width=True)
196
+
197
+ signal_events = viz[viz["SignalChange"] != 0][["Close", "SignalChange"]].copy()
198
+ if not signal_events.empty:
199
+ signal_events["Event"] = signal_events["SignalChange"].map({1: "BUY", -1: "SELL"}).fillna("HOLD")
200
+ st.markdown("**Crossover Events**")
201
+ st.dataframe(signal_events[["Event", "Close"]], use_container_width=True)
202
+
203
+ if isinstance(result.equity_curve, pd.DataFrame) and not result.equity_curve.empty:
204
+ equity = result.equity_curve.copy()
205
+ if "Equity" not in equity.columns:
206
+ numeric_cols = equity.select_dtypes(include=["number"]).columns
207
+ if len(numeric_cols) > 0:
208
+ equity = equity.rename(columns={numeric_cols[0]: "Equity"})
209
+ if "Equity" in equity.columns:
210
+ st.markdown("**Equity Curve**")
211
+ st.line_chart(equity["Equity"], use_container_width=True)
212
+
213
+ drawdown = (equity["Equity"] / equity["Equity"].cummax() - 1.0) * 100.0
214
+ st.markdown("**Drawdown (%)**")
215
+ st.area_chart(drawdown, use_container_width=True)
216
+
217
+ returns = equity["Equity"].pct_change().dropna()
218
+ if not returns.empty:
219
+ st.markdown("**Return Distribution**")
220
+ hist = pd.DataFrame({"return": returns})
221
+ hist["bin"] = pd.cut(hist["return"], bins=30)
222
+ hist = hist.groupby("bin", observed=False).size().rename("count").reset_index()
223
+ hist["bin"] = hist["bin"].astype(str)
224
+ st.bar_chart(hist.set_index("bin")["count"], use_container_width=True)
225
+
226
+ if isinstance(result.trades, pd.DataFrame) and not result.trades.empty:
227
+ st.markdown("**Trades**")
228
+ st.dataframe(result.trades, use_container_width=True)
229
+
230
+
231
  st.set_page_config(page_title="OpenClaw Control Center", layout="wide")
232
  st.title("OpenClaw Control Center")
233
  st.caption("Manage gateway runtime, config, environment, and test calls from one UI.")
 
316
  with logs_col:
317
  st.subheader("Gateway Logs")
318
  st.code("\n".join(st.session_state["gateway_logs"][-LOG_MAX_LINES:]) or "No logs yet.")
319
+
320
+ st.divider()
321
+ st.subheader("Backtesting Lab")
322
+ st.caption(
323
+ "Run SMA crossover tests with popular engines: backtesting.py and backtrader."
324
+ )
325
+
326
+ source_col, params_col = st.columns([3, 2])
327
+ with source_col:
328
+ data_source = st.radio(
329
+ "Data Source",
330
+ options=["yfinance", "csv_upload"],
331
+ horizontal=True,
332
+ )
333
+ if data_source == "yfinance":
334
+ symbol = st.text_input("Symbol", value="AAPL")
335
+ date_col1, date_col2, interval_col = st.columns([1, 1, 1])
336
+ start_date = date_col1.date_input("Start", value=date(2022, 1, 1))
337
+ end_date = date_col2.date_input("End", value=date.today())
338
+ interval = interval_col.selectbox("Interval", options=["1d", "1h", "30m", "15m"], index=0)
339
+ uploaded_file = None
340
+ else:
341
+ uploaded_file = st.file_uploader("Upload CSV (Date/Open/High/Low/Close/Volume)", type=["csv"])
342
+ symbol = ""
343
+ start_date = None
344
+ end_date = None
345
+ interval = "1d"
346
+
347
+ with params_col:
348
+ engine = st.selectbox("Engine", options=["backtesting.py", "backtrader"], index=0)
349
+ fast_period = st.number_input("Fast MA", min_value=2, max_value=200, value=10)
350
+ slow_period = st.number_input("Slow MA", min_value=3, max_value=400, value=30)
351
+ initial_cash = st.number_input("Initial Cash", min_value=1000.0, value=10000.0, step=1000.0)
352
+ commission = st.number_input(
353
+ "Commission (fraction)", min_value=0.0, max_value=0.1, value=0.001, format="%.5f"
354
+ )
355
+ run_bt = st.button("Run Backtest", use_container_width=True)
356
+
357
+ if run_bt:
358
+ try:
359
+ if data_source == "yfinance":
360
+ data = load_price_data_from_yfinance(
361
+ symbol=symbol.strip().upper(),
362
+ start=str(start_date),
363
+ end=str(end_date),
364
+ interval=interval,
365
+ )
366
+ else:
367
+ if uploaded_file is None:
368
+ raise ValueError("Upload a CSV file first.")
369
+ data = load_price_data_from_csv_text(uploaded_file.getvalue().decode("utf-8"))
370
+
371
+ result = run_backtest(
372
+ engine=engine,
373
+ data=data,
374
+ fast_period=int(fast_period),
375
+ slow_period=int(slow_period),
376
+ initial_cash=float(initial_cash),
377
+ commission=float(commission),
378
+ )
379
+ st.session_state["backtest_result"] = result
380
+ st.session_state["backtest_data"] = data
381
+ st.session_state["backtest_params"] = {
382
+ "engine": engine,
383
+ "fast_period": int(fast_period),
384
+ "slow_period": int(slow_period),
385
+ }
386
+ st.session_state["backtest_error"] = ""
387
+ except Exception as exc:
388
+ st.session_state["backtest_error"] = str(exc)
389
+
390
+ if st.session_state["backtest_error"]:
391
+ st.error(f"Backtest failed: {st.session_state['backtest_error']}")
392
+
393
+ if (
394
+ st.session_state["backtest_result"] is not None
395
+ and isinstance(st.session_state["backtest_data"], pd.DataFrame)
396
+ and st.session_state["backtest_params"] is not None
397
+ ):
398
+ render_backtest_visuals(
399
+ st.session_state["backtest_result"],
400
+ st.session_state["backtest_data"],
401
+ st.session_state["backtest_params"],
402
+ )
pyproject.toml CHANGED
@@ -3,8 +3,12 @@ name = "openclaw-space"
3
  version = "0.1.0"
4
  requires-python = ">=3.11"
5
  dependencies = [
 
 
 
6
  "requests==2.32.3",
7
  "streamlit==1.41.1",
 
8
  ]
9
 
10
  [tool.uv]
 
3
  version = "0.1.0"
4
  requires-python = ">=3.11"
5
  dependencies = [
6
+ "backtesting==0.3.3",
7
+ "backtrader==1.9.78.123",
8
+ "pandas==2.2.3",
9
  "requests==2.32.3",
10
  "streamlit==1.41.1",
11
+ "yfinance==0.2.54",
12
  ]
13
 
14
  [tool.uv]
tools/README.md CHANGED
@@ -8,6 +8,11 @@ Expected modules:
8
  - `submit_order(signal)`
9
  - `list_positions()`
10
  - Safety: refuse non-paper base URL
 
 
 
 
 
11
  - `tools/hf_storage.py`
12
  - `append_trade(record)`
13
  - `append_report(record)`
 
8
  - `submit_order(signal)`
9
  - `list_positions()`
10
  - Safety: refuse non-paper base URL
11
+ - `tools/backtesting_runner.py`
12
+ - `load_price_data_from_yfinance(...)`
13
+ - `load_price_data_from_csv_text(...)`
14
+ - `run_backtest(...)`
15
+ - Engines: `backtesting.py`, `backtrader`
16
  - `tools/hf_storage.py`
17
  - `append_trade(record)`
18
  - `append_report(record)`
tools/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Tools package for OpenClaw local helpers.
tools/backtesting_runner.py ADDED
@@ -0,0 +1,222 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from io import StringIO
5
+ from typing import Any
6
+
7
+ import backtrader as bt
8
+ import pandas as pd
9
+ import yfinance as yf
10
+ from backtesting import Backtest, Strategy
11
+
12
+
13
+ @dataclass
14
+ class BacktestResult:
15
+ engine: str
16
+ metrics: dict[str, Any]
17
+ equity_curve: pd.DataFrame
18
+ trades: pd.DataFrame
19
+ input_rows: int
20
+
21
+
22
+ def _normalize_ohlcv_columns(df: pd.DataFrame) -> pd.DataFrame:
23
+ if isinstance(df.columns, pd.MultiIndex):
24
+ df.columns = df.columns.get_level_values(0)
25
+
26
+ rename_map = {}
27
+ for col in df.columns:
28
+ lower = str(col).lower()
29
+ if lower == "open":
30
+ rename_map[col] = "Open"
31
+ elif lower == "high":
32
+ rename_map[col] = "High"
33
+ elif lower == "low":
34
+ rename_map[col] = "Low"
35
+ elif lower == "close":
36
+ rename_map[col] = "Close"
37
+ elif lower == "volume":
38
+ rename_map[col] = "Volume"
39
+ normalized = df.rename(columns=rename_map).copy()
40
+ for required in ["Open", "High", "Low", "Close"]:
41
+ if required not in normalized.columns:
42
+ raise ValueError(f"Missing required OHLC column: {required}")
43
+ if "Volume" not in normalized.columns:
44
+ normalized["Volume"] = 0
45
+ if not isinstance(normalized.index, pd.DatetimeIndex):
46
+ normalized.index = pd.to_datetime(normalized.index, utc=True, errors="coerce")
47
+ if normalized.index.tz is not None:
48
+ normalized.index = normalized.index.tz_convert("UTC").tz_localize(None)
49
+ normalized = normalized.dropna(subset=["Open", "High", "Low", "Close"])
50
+ normalized = normalized.sort_index()
51
+ return normalized
52
+
53
+
54
+ def load_price_data_from_yfinance(
55
+ symbol: str,
56
+ start: str,
57
+ end: str,
58
+ interval: str = "1d",
59
+ ) -> pd.DataFrame:
60
+ df = yf.download(symbol, start=start, end=end, interval=interval, auto_adjust=False)
61
+ if df is None or df.empty:
62
+ raise ValueError(f"No market data returned for symbol: {symbol}")
63
+ return _normalize_ohlcv_columns(df)
64
+
65
+
66
+ def load_price_data_from_csv_text(csv_text: str) -> pd.DataFrame:
67
+ df = pd.read_csv(StringIO(csv_text))
68
+ lowered = {str(c).lower(): c for c in df.columns}
69
+ if "date" in lowered:
70
+ date_col = lowered["date"]
71
+ df[date_col] = pd.to_datetime(df[date_col], utc=True, errors="coerce")
72
+ df = df.set_index(date_col)
73
+ elif "datetime" in lowered:
74
+ dt_col = lowered["datetime"]
75
+ df[dt_col] = pd.to_datetime(df[dt_col], utc=True, errors="coerce")
76
+ df = df.set_index(dt_col)
77
+ return _normalize_ohlcv_columns(df)
78
+
79
+
80
+ class SmaCrossBacktestingPy(Strategy):
81
+ fast_period = 10
82
+ slow_period = 30
83
+
84
+ def init(self) -> None:
85
+ close = pd.Series(self.data.Close)
86
+ self.fast = self.I(lambda x: pd.Series(x).rolling(self.fast_period).mean(), close)
87
+ self.slow = self.I(lambda x: pd.Series(x).rolling(self.slow_period).mean(), close)
88
+
89
+ def next(self) -> None:
90
+ if self.fast[-1] > self.slow[-1] and not self.position:
91
+ self.buy()
92
+ elif self.fast[-1] < self.slow[-1] and self.position:
93
+ self.position.close()
94
+
95
+
96
+ class SmaCrossBacktrader(bt.Strategy):
97
+ params = (("fast_period", 10), ("slow_period", 30))
98
+
99
+ def __init__(self) -> None:
100
+ self.fast = bt.indicators.SimpleMovingAverage(
101
+ self.data.close, period=self.params.fast_period
102
+ )
103
+ self.slow = bt.indicators.SimpleMovingAverage(
104
+ self.data.close, period=self.params.slow_period
105
+ )
106
+ self.crossover = bt.indicators.CrossOver(self.fast, self.slow)
107
+ self.equity_points: list[tuple[pd.Timestamp, float]] = []
108
+
109
+ def next(self) -> None:
110
+ if self.crossover > 0 and not self.position:
111
+ self.buy()
112
+ elif self.crossover < 0 and self.position:
113
+ self.close()
114
+ dt = self.data.datetime.datetime(0)
115
+ self.equity_points.append((pd.Timestamp(dt, tz="UTC"), self.broker.getvalue()))
116
+
117
+
118
+ def run_backtesting_py(
119
+ data: pd.DataFrame,
120
+ fast_period: int,
121
+ slow_period: int,
122
+ initial_cash: float,
123
+ commission: float,
124
+ ) -> BacktestResult:
125
+ strategy_cls = type(
126
+ "ConfiguredSmaCrossBacktestingPy",
127
+ (SmaCrossBacktestingPy,),
128
+ {"fast_period": fast_period, "slow_period": slow_period},
129
+ )
130
+ bt_obj = Backtest(data, strategy_cls, cash=initial_cash, commission=commission)
131
+ stats = bt_obj.run()
132
+ equity_curve = stats.get("_equity_curve", pd.DataFrame())
133
+ trades = stats.get("_trades", pd.DataFrame())
134
+ metrics = {
135
+ "Return [%]": float(stats.get("Return [%]", 0.0)),
136
+ "Buy & Hold Return [%]": float(stats.get("Buy & Hold Return [%]", 0.0)),
137
+ "Sharpe Ratio": float(stats.get("Sharpe Ratio", 0.0) or 0.0),
138
+ "Max Drawdown [%]": float(stats.get("Max. Drawdown [%]", 0.0)),
139
+ "# Trades": int(stats.get("# Trades", 0)),
140
+ "Win Rate [%]": float(stats.get("Win Rate [%]", 0.0)),
141
+ }
142
+ return BacktestResult(
143
+ engine="backtesting.py",
144
+ metrics=metrics,
145
+ equity_curve=equity_curve,
146
+ trades=trades,
147
+ input_rows=len(data),
148
+ )
149
+
150
+
151
+ def run_backtrader(
152
+ data: pd.DataFrame,
153
+ fast_period: int,
154
+ slow_period: int,
155
+ initial_cash: float,
156
+ commission: float,
157
+ ) -> BacktestResult:
158
+ cerebro = bt.Cerebro()
159
+ configured = type(
160
+ "ConfiguredSmaCrossBacktrader",
161
+ (SmaCrossBacktrader,),
162
+ {"params": (("fast_period", fast_period), ("slow_period", slow_period))},
163
+ )
164
+ cerebro.addstrategy(configured)
165
+ feed = bt.feeds.PandasData(dataname=data)
166
+ cerebro.adddata(feed)
167
+ cerebro.broker.setcash(initial_cash)
168
+ cerebro.broker.setcommission(commission=commission)
169
+ cerebro.addanalyzer(bt.analyzers.DrawDown, _name="drawdown")
170
+ cerebro.addanalyzer(bt.analyzers.SharpeRatio, _name="sharpe", timeframe=bt.TimeFrame.Days)
171
+ cerebro.addanalyzer(bt.analyzers.TradeAnalyzer, _name="trades")
172
+
173
+ starting_value = cerebro.broker.getvalue()
174
+ run_result = cerebro.run()
175
+ ending_value = cerebro.broker.getvalue()
176
+ strategy = run_result[0]
177
+
178
+ dd = strategy.analyzers.drawdown.get_analysis()
179
+ sharpe = strategy.analyzers.sharpe.get_analysis()
180
+ ta = strategy.analyzers.trades.get_analysis()
181
+
182
+ total_closed = int(getattr(ta.total, "closed", 0) if hasattr(ta, "total") else 0)
183
+ won_total = int(getattr(ta.won, "total", 0) if hasattr(ta, "won") else 0)
184
+ win_rate = (won_total / total_closed * 100) if total_closed else 0.0
185
+ ret_pct = ((ending_value - starting_value) / starting_value * 100) if starting_value else 0.0
186
+
187
+ equity_curve = pd.DataFrame(strategy.equity_points, columns=["Time", "Equity"])
188
+ if not equity_curve.empty:
189
+ equity_curve = equity_curve.set_index("Time")
190
+
191
+ metrics = {
192
+ "Return [%]": float(ret_pct),
193
+ "Sharpe Ratio": float(sharpe.get("sharperatio", 0.0) or 0.0),
194
+ "Max Drawdown [%]": float(getattr(dd.max, "drawdown", 0.0) if hasattr(dd, "max") else 0.0),
195
+ "# Trades": total_closed,
196
+ "Win Rate [%]": float(win_rate),
197
+ "Final Equity": float(ending_value),
198
+ }
199
+ return BacktestResult(
200
+ engine="backtrader",
201
+ metrics=metrics,
202
+ equity_curve=equity_curve,
203
+ trades=pd.DataFrame(),
204
+ input_rows=len(data),
205
+ )
206
+
207
+
208
+ def run_backtest(
209
+ engine: str,
210
+ data: pd.DataFrame,
211
+ fast_period: int,
212
+ slow_period: int,
213
+ initial_cash: float,
214
+ commission: float,
215
+ ) -> BacktestResult:
216
+ if fast_period >= slow_period:
217
+ raise ValueError("fast_period must be smaller than slow_period.")
218
+ if engine == "backtesting.py":
219
+ return run_backtesting_py(data, fast_period, slow_period, initial_cash, commission)
220
+ if engine == "backtrader":
221
+ return run_backtrader(data, fast_period, slow_period, initial_cash, commission)
222
+ raise ValueError(f"Unsupported engine: {engine}")