ghosalquant 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ghosalquant/__init__.py +10 -0
- ghosalquant/backtest_ui/__init__.py +7 -0
- ghosalquant/backtest_ui/__main__.py +10 -0
- ghosalquant/backtest_ui/app.py +148 -0
- ghosalquant/backtest_ui/runner.py +109 -0
- ghosalquant/backtester/__init__.py +7 -0
- ghosalquant/backtester/backtester_base.py +129 -0
- ghosalquant/backtester/dataframe_backtester.py +95 -0
- ghosalquant/bench/__init__.py +7 -0
- ghosalquant/bench/benchmark_buy_and_hold.py +51 -0
- ghosalquant/bench/benchmark_trend_vol_target.py +179 -0
- ghosalquant/brokerage/__init__.py +26 -0
- ghosalquant/brokerage/broker_base.py +123 -0
- ghosalquant/brokerage/builder.py +42 -0
- ghosalquant/brokerage/commissions.py +38 -0
- ghosalquant/brokerage/fills.py +470 -0
- ghosalquant/brokerage/ibkr.py +496 -0
- ghosalquant/brokerage/open_orders.py +27 -0
- ghosalquant/brokerage/order_book_sim.py +77 -0
- ghosalquant/brokerage/simulated.py +80 -0
- ghosalquant/brokerage/simulation.py +303 -0
- ghosalquant/compatibility/legacybacktester.py +253 -0
- ghosalquant/compatibility/plotManager.py +71 -0
- ghosalquant/compatibility/test_strategy.py +63 -0
- ghosalquant/compatibility/trade.py +70 -0
- ghosalquant/core/__init__.py +59 -0
- ghosalquant/core/blackscholes.py +169 -0
- ghosalquant/core/contracts.py +155 -0
- ghosalquant/core/metadata_keys.py +59 -0
- ghosalquant/core/types.py +454 -0
- ghosalquant/core/volatility.py +163 -0
- ghosalquant/data_layer/__init__.py +29 -0
- ghosalquant/data_layer/cboe_datasource.py +150 -0
- ghosalquant/data_layer/dataLayer.py +127 -0
- ghosalquant/data_layer/dataframe_datasource.py +76 -0
- ghosalquant/data_layer/datasource_base.py +18 -0
- ghosalquant/data_layer/datasource_factory.py +55 -0
- ghosalquant/data_layer/dolt_datasource.py +159 -0
- ghosalquant/data_layer/listEditor.py +372 -0
- ghosalquant/data_layer/list_datasource.py +27 -0
- ghosalquant/data_layer/live_alpaca_datasource.py +190 -0
- ghosalquant/data_layer/live_ibkr_datasource.py +246 -0
- ghosalquant/data_layer/live_polygon_datasource.py +264 -0
- ghosalquant/data_layer/merge.py +52 -0
- ghosalquant/data_layer/nse_datasource.py +126 -0
- ghosalquant/data_layer/parquet_polygon_datasource.py +108 -0
- ghosalquant/data_layer/recording_datasource.py +251 -0
- ghosalquant/data_layer/shocks.py +99 -0
- ghosalquant/data_layer/staticUpdater.py +408 -0
- ghosalquant/data_layer/yfinance_datasource.py +60 -0
- ghosalquant/data_layer/yfinance_multi_datasource.py +120 -0
- ghosalquant/execution/__init__.py +16 -0
- ghosalquant/execution/context.py +54 -0
- ghosalquant/execution/execution_system.py +533 -0
- ghosalquant/execution/sinks.py +122 -0
- ghosalquant/execution/strategy_base.py +109 -0
- ghosalquant/metrics/__init__.py +25 -0
- ghosalquant/metrics/gates.py +150 -0
- ghosalquant/metrics/risk_adjusted.py +196 -0
- ghosalquant/portfolio/__init__.py +30 -0
- ghosalquant/portfolio/attribution.py +81 -0
- ghosalquant/portfolio/basic_portfolio.py +373 -0
- ghosalquant/portfolio/hedging.py +87 -0
- ghosalquant/portfolio/lots.py +63 -0
- ghosalquant/portfolio/marks.py +58 -0
- ghosalquant/portfolio/portfolio_base.py +104 -0
- ghosalquant/portfolio/valuation.py +694 -0
- ghosalquant/recording/__init__.py +111 -0
- ghosalquant/recording/events.py +204 -0
- ghosalquant/recording/intervals.py +236 -0
- ghosalquant/recording/recorder.py +531 -0
- ghosalquant/recording/storage.py +607 -0
- ghosalquant/risk/__init__.py +56 -0
- ghosalquant/risk/builder.py +151 -0
- ghosalquant/risk/cash_allocation.py +285 -0
- ghosalquant/risk/risk_manager.py +109 -0
- ghosalquant/risk/rules/rule_confidence_sizer.py +32 -0
- ghosalquant/risk/rules/rule_correlation_filter.py +105 -0
- ghosalquant/risk/rules/rule_drawdown_killswitch.py +75 -0
- ghosalquant/risk/rules/rule_integer_quantity.py +27 -0
- ghosalquant/risk/rules/rule_kelly_sizer.py +74 -0
- ghosalquant/risk/rules/rule_max_open_positions.py +27 -0
- ghosalquant/risk/rules/rule_max_symbol_exposure.py +118 -0
- ghosalquant/risk/rules/rule_regime_filter.py +50 -0
- ghosalquant/risk/rules/rule_signal_quantity_cap.py +28 -0
- ghosalquant/risk/rules/rule_symbol_capital_sizer.py +78 -0
- ghosalquant/risk/rules/rule_tick_rounding.py +40 -0
- ghosalquant/risk/sizing.py +46 -0
- ghosalquant/risk/types.py +69 -0
- ghosalquant/state/__init__.py +23 -0
- ghosalquant/state/atomic.py +74 -0
- ghosalquant/state/event_store.py +249 -0
- ghosalquant/state/json_store.py +85 -0
- ghosalquant/util/backtester_utils.py +118 -0
- ghosalquant/util/config.py +522 -0
- ghosalquant/util/deprecation.py +19 -0
- ghosalquant/util/rounding.py +25 -0
- ghosalquant/util/visualisation.py +162 -0
- ghosalquant-0.1.0.dist-info/METADATA +113 -0
- ghosalquant-0.1.0.dist-info/RECORD +104 -0
- ghosalquant-0.1.0.dist-info/WHEEL +5 -0
- ghosalquant-0.1.0.dist-info/entry_points.txt +2 -0
- ghosalquant-0.1.0.dist-info/licenses/LICENSE +21 -0
- ghosalquant-0.1.0.dist-info/top_level.txt +1 -0
ghosalquant/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
from .runner import run_from_config
|
|
5
|
+
|
|
6
|
+
if __name__ == "__main__":
|
|
7
|
+
if len(sys.argv) < 3:
|
|
8
|
+
print("Usage: python -m ghosalquant.backtest_ui <config.yaml> <strategy.py>")
|
|
9
|
+
raise SystemExit(2)
|
|
10
|
+
raise SystemExit(run_from_config(sys.argv[1], sys.argv[2]))
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
import threading
|
|
5
|
+
import subprocess
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from queue import Queue, Empty
|
|
8
|
+
|
|
9
|
+
import customtkinter as ctk
|
|
10
|
+
from tkinter import filedialog, messagebox
|
|
11
|
+
|
|
12
|
+
from . import project_root
|
|
13
|
+
|
|
14
|
+
"""
|
|
15
|
+
WARNING: VIBED because I hate UI development and this is a prototype for now
|
|
16
|
+
|
|
17
|
+
This is a rough experimental prototype of a UI to abstract backtesting from QRs.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class App(ctk.CTk):
|
|
22
|
+
def __init__(self) -> None:
|
|
23
|
+
super().__init__()
|
|
24
|
+
self.title("GhosalQuant Backtest (Prototype)")
|
|
25
|
+
self.geometry("900x600")
|
|
26
|
+
|
|
27
|
+
self.root = project_root()
|
|
28
|
+
self.default_cfg = self.root / "examples" / "config.yaml"
|
|
29
|
+
|
|
30
|
+
self.cfg_var = ctk.StringVar(value=str(self.default_cfg) if self.default_cfg.exists() else "")
|
|
31
|
+
|
|
32
|
+
self.log_q: Queue[str] = Queue()
|
|
33
|
+
|
|
34
|
+
top = ctk.CTkFrame(self)
|
|
35
|
+
top.pack(fill="x", padx=12, pady=12)
|
|
36
|
+
|
|
37
|
+
row1 = ctk.CTkFrame(top)
|
|
38
|
+
row1.pack(fill="x")
|
|
39
|
+
|
|
40
|
+
row2 = ctk.CTkFrame(top)
|
|
41
|
+
row2.pack(fill="x")
|
|
42
|
+
|
|
43
|
+
# Config + Run
|
|
44
|
+
ctk.CTkLabel(row1, text="Config:").pack(side="left", padx=(10, 8), pady=10)
|
|
45
|
+
self.cfg_entry = ctk.CTkEntry(row1, textvariable=self.cfg_var, width=560)
|
|
46
|
+
self.cfg_entry.pack(side="left", padx=8, pady=10)
|
|
47
|
+
ctk.CTkButton(row1, text="Browse…", command=self.browse).pack(side="left", padx=8, pady=10)
|
|
48
|
+
self.run_btn = ctk.CTkButton(row1, text="Run", command=self.run)
|
|
49
|
+
self.run_btn.pack(side="left", padx=8, pady=10)
|
|
50
|
+
|
|
51
|
+
# Strategy
|
|
52
|
+
self.strategy_var = ctk.StringVar(value="")
|
|
53
|
+
ctk.CTkLabel(row2, text="Strategy:").pack(side="left", padx=(10, 8), pady=10)
|
|
54
|
+
self.strategy_entry = ctk.CTkEntry(row2, textvariable=self.strategy_var, width=560)
|
|
55
|
+
self.strategy_entry.pack(side="left", padx=8, pady=10)
|
|
56
|
+
ctk.CTkButton(row2, text="Browse…", command=self.browse_strategy).pack(side="left", padx=8, pady=10)
|
|
57
|
+
|
|
58
|
+
self.log = ctk.CTkTextbox(self, wrap="none")
|
|
59
|
+
self.log.pack(fill="both", expand=True, padx=12, pady=(0, 12))
|
|
60
|
+
|
|
61
|
+
self.after(100, self.drain_logs)
|
|
62
|
+
|
|
63
|
+
def browse(self) -> None:
|
|
64
|
+
p = filedialog.askopenfilename(
|
|
65
|
+
title="Select config.yaml",
|
|
66
|
+
filetypes=[("YAML files", "*.yaml *.yml"), ("All files", "*.*")]
|
|
67
|
+
)
|
|
68
|
+
if p:
|
|
69
|
+
self.cfg_var.set(p)
|
|
70
|
+
|
|
71
|
+
def browse_strategy(self) -> None:
|
|
72
|
+
p = filedialog.askopenfilename(
|
|
73
|
+
title="Select strategy .py",
|
|
74
|
+
filetypes=[("Python files", "*.py"), ("All files", "*.*")]
|
|
75
|
+
)
|
|
76
|
+
if p:
|
|
77
|
+
self.strategy_var.set(p)
|
|
78
|
+
|
|
79
|
+
def append(self, line: str) -> None:
|
|
80
|
+
self.log.insert("end", line + "\n")
|
|
81
|
+
self.log.see("end")
|
|
82
|
+
|
|
83
|
+
def drain_logs(self) -> None:
|
|
84
|
+
try:
|
|
85
|
+
while True:
|
|
86
|
+
self.append(self.log_q.get_nowait())
|
|
87
|
+
except Empty:
|
|
88
|
+
pass
|
|
89
|
+
self.after(100, self.drain_logs)
|
|
90
|
+
|
|
91
|
+
def run(self) -> None:
|
|
92
|
+
cfg = Path(self.cfg_var.get().strip())
|
|
93
|
+
if not cfg.exists():
|
|
94
|
+
messagebox.showerror("Missing config", f"Config not found:\n{cfg}")
|
|
95
|
+
return
|
|
96
|
+
|
|
97
|
+
strategy = Path(self.strategy_var.get().strip())
|
|
98
|
+
if not strategy.exists():
|
|
99
|
+
messagebox.showerror("Missing strategy", f"Strategy not found:\n{strategy}")
|
|
100
|
+
self.run_btn.configure(state="normal")
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
self.run_btn.configure(state="disabled")
|
|
104
|
+
self.append(f"Running with config: {cfg}")
|
|
105
|
+
|
|
106
|
+
strategy = Path(self.strategy_var.get().strip()) # add a strategy picker var in UI
|
|
107
|
+
cmd = [sys.executable, "-m", "ghosalquant.backtest_ui", str(cfg), str(strategy)]
|
|
108
|
+
|
|
109
|
+
def worker() -> None:
|
|
110
|
+
try:
|
|
111
|
+
proc = subprocess.Popen(
|
|
112
|
+
cmd,
|
|
113
|
+
cwd=str(self.root),
|
|
114
|
+
stdout=subprocess.PIPE,
|
|
115
|
+
stderr=subprocess.STDOUT,
|
|
116
|
+
text=True,
|
|
117
|
+
)
|
|
118
|
+
assert proc.stdout is not None
|
|
119
|
+
for line in proc.stdout:
|
|
120
|
+
self.log_q.put(line.rstrip("\n"))
|
|
121
|
+
rc = proc.wait()
|
|
122
|
+
self.log_q.put(f"[done] exit code = {rc}")
|
|
123
|
+
except Exception as e:
|
|
124
|
+
self.log_q.put(f"[error] {e}")
|
|
125
|
+
finally:
|
|
126
|
+
self.log_q.put("__enable__")
|
|
127
|
+
|
|
128
|
+
def enable_later() -> None:
|
|
129
|
+
# wait for the marker then re-enable the button in UI thread
|
|
130
|
+
while True:
|
|
131
|
+
msg = self.log_q.get()
|
|
132
|
+
if msg == "__enable__":
|
|
133
|
+
self.run_btn.configure(state="normal")
|
|
134
|
+
break
|
|
135
|
+
else:
|
|
136
|
+
self.append(msg)
|
|
137
|
+
|
|
138
|
+
threading.Thread(target=worker, daemon=True).start()
|
|
139
|
+
threading.Thread(target=enable_later, daemon=True).start()
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def main() -> None:
|
|
143
|
+
ctk.set_appearance_mode("System")
|
|
144
|
+
App().mainloop()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
if __name__ == "__main__":
|
|
148
|
+
main()
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Type
|
|
6
|
+
import importlib.util
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
import yfinance as yf
|
|
10
|
+
from matplotlib import pyplot as plt
|
|
11
|
+
|
|
12
|
+
from ghosalquant.backtester.dataframe_backtester import DataFrameBacktester
|
|
13
|
+
from ghosalquant.execution.strategy_base import Strategy
|
|
14
|
+
from ghosalquant.util.visualisation import plot_equity_curve
|
|
15
|
+
|
|
16
|
+
from . import project_root
|
|
17
|
+
|
|
18
|
+
def _safe_stem(s: str) -> str:
|
|
19
|
+
# sanitiser
|
|
20
|
+
chars: list[str] = []
|
|
21
|
+
for ch in s:
|
|
22
|
+
if ch.isalnum() or ch in ("-", "_"):
|
|
23
|
+
chars.append(ch)
|
|
24
|
+
else:
|
|
25
|
+
chars.append("_")
|
|
26
|
+
|
|
27
|
+
stem = "".join(chars).strip("_")
|
|
28
|
+
return stem or "run"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _load_strategy_from_file(path: Path) -> Strategy:
|
|
32
|
+
spec = importlib.util.spec_from_file_location(path.stem, str(path))
|
|
33
|
+
if spec is None or spec.loader is None:
|
|
34
|
+
raise RuntimeError(f"Could not import strategy file: {path}")
|
|
35
|
+
|
|
36
|
+
mod = importlib.util.module_from_spec(spec)
|
|
37
|
+
spec.loader.exec_module(mod) # type: ignore[assignment]
|
|
38
|
+
|
|
39
|
+
# Explicit export as STRATEGY_CLASS
|
|
40
|
+
if hasattr(mod, "STRATEGY_CLASS"):
|
|
41
|
+
cls = getattr(mod, "STRATEGY_CLASS")
|
|
42
|
+
if isinstance(cls, type) and issubclass(cls, Strategy):
|
|
43
|
+
return cls()
|
|
44
|
+
raise TypeError("STRATEGY_CLASS exists but is not a Strategy subclass.")
|
|
45
|
+
|
|
46
|
+
# As a fallback, find first Strategy subclass in module
|
|
47
|
+
for obj in mod.__dict__.values():
|
|
48
|
+
if isinstance(obj, type) and issubclass(obj, Strategy) and obj is not Strategy:
|
|
49
|
+
return obj()
|
|
50
|
+
|
|
51
|
+
raise ValueError(
|
|
52
|
+
"No strategy found. Define STRATEGY_CLASS = YourStrategy, "
|
|
53
|
+
"or include a Strategy subclass in the file."
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def run_from_config(config_path: str | Path, strategy_path: str | Path) -> int:
|
|
58
|
+
cfg_path = Path(config_path).resolve()
|
|
59
|
+
strat_path = Path(strategy_path).resolve()
|
|
60
|
+
|
|
61
|
+
if not cfg_path.exists():
|
|
62
|
+
print(f"Config not found: {cfg_path}")
|
|
63
|
+
return 2
|
|
64
|
+
if not strat_path.exists():
|
|
65
|
+
print(f"Strategy not found: {strat_path}")
|
|
66
|
+
return 2
|
|
67
|
+
|
|
68
|
+
cfg: dict[str, Any] = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) or {}
|
|
69
|
+
system = cfg.get("SYSTEM", {}) or {}
|
|
70
|
+
tr = cfg.get("TIME_RANGE", {}) or {}
|
|
71
|
+
|
|
72
|
+
symbols = system.get("symbols", ["AAPL"]) or ["AAPL"]
|
|
73
|
+
symbol = symbols[0] # prototype: single symbol
|
|
74
|
+
|
|
75
|
+
start_date = tr.get("start_date", "2024-01-01")
|
|
76
|
+
end_date = tr.get("end_date", "2025-12-31")
|
|
77
|
+
|
|
78
|
+
print("UI Runner: DataFrameBacktester")
|
|
79
|
+
print(f"Config: {cfg_path}")
|
|
80
|
+
print(f"Strategy: {strat_path}")
|
|
81
|
+
print(f"Symbol: {symbol}")
|
|
82
|
+
print(f"Range: {start_date} -> {end_date}")
|
|
83
|
+
|
|
84
|
+
df = yf.download(symbol, start=start_date, end=end_date, auto_adjust=True, progress=False)
|
|
85
|
+
|
|
86
|
+
strategy = _load_strategy_from_file(strat_path)
|
|
87
|
+
backtester = DataFrameBacktester(strategy=strategy, dataframe=df, config_path=str(cfg_path))
|
|
88
|
+
|
|
89
|
+
kpms = backtester.run()
|
|
90
|
+
print("\nKEY PERFORMANCE METRICS:\n" + str(kpms))
|
|
91
|
+
print("REALISED PNL:", backtester.realised_pnl)
|
|
92
|
+
|
|
93
|
+
# Plot and save to file
|
|
94
|
+
plot_equity_curve(backtester.equity_curve, show_realised_unrealised=True)
|
|
95
|
+
|
|
96
|
+
out_dir = project_root() / "ui_outputs"
|
|
97
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
98
|
+
|
|
99
|
+
strategy_name = _safe_stem(strat_path.stem)
|
|
100
|
+
symbol_name = _safe_stem(symbol)
|
|
101
|
+
now = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
|
102
|
+
out_path = out_dir / f"equity_curve_{now}_{symbol_name}_{strategy_name}.png"
|
|
103
|
+
|
|
104
|
+
plt.savefig(out_path, dpi=150, bbox_inches="tight")
|
|
105
|
+
plt.close()
|
|
106
|
+
|
|
107
|
+
print(f"Saved plot: {out_path}")
|
|
108
|
+
|
|
109
|
+
return 0
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from typing import final, Iterable
|
|
3
|
+
|
|
4
|
+
from ..data_layer.datasource_factory import datasource_from_config
|
|
5
|
+
from ..brokerage.builder import build_broker
|
|
6
|
+
from ..execution.strategy_base import Strategy
|
|
7
|
+
from ..execution.execution_system import ExecutionSystem
|
|
8
|
+
from ..data_layer.datasource_base import DataSource
|
|
9
|
+
from ..execution.sinks import SignalSink
|
|
10
|
+
from ..core.contracts import ContractRegistry
|
|
11
|
+
from ..core.types import Order, BacktestKPMs, EquityPoint
|
|
12
|
+
from ..portfolio.basic_portfolio import BasicPortfolio
|
|
13
|
+
from ..portfolio.portfolio_base import Portfolio
|
|
14
|
+
from ..brokerage.broker_base import Broker
|
|
15
|
+
from ..risk.builder import build_risk_manager
|
|
16
|
+
from ..risk.risk_manager import RiskManager
|
|
17
|
+
from ..util.backtester_utils import calculate_kpms
|
|
18
|
+
from ..util.config import Config, load_config
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Backtester:
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
*,
|
|
25
|
+
strategy: Strategy,
|
|
26
|
+
data_src: DataSource,
|
|
27
|
+
signal_destination: SignalSink | None = None,
|
|
28
|
+
portfolio: Portfolio | None = None,
|
|
29
|
+
risk_manager: RiskManager | None = None,
|
|
30
|
+
broker: Broker | None = None,
|
|
31
|
+
config: Config | None = None,
|
|
32
|
+
contracts: ContractRegistry | None = None,
|
|
33
|
+
) -> None:
|
|
34
|
+
self.config = config
|
|
35
|
+
|
|
36
|
+
if portfolio is None:
|
|
37
|
+
portfolio = BasicPortfolio(init_capital=10_000.0, contracts=contracts)
|
|
38
|
+
|
|
39
|
+
if risk_manager is None and config is not None and config.system.use_risk_manager:
|
|
40
|
+
risk_manager = build_risk_manager(config.risk)
|
|
41
|
+
|
|
42
|
+
self.execution_system = ExecutionSystem(
|
|
43
|
+
data_src = data_src,
|
|
44
|
+
strategy = strategy,
|
|
45
|
+
signal_destination = signal_destination,
|
|
46
|
+
risk_manager = risk_manager,
|
|
47
|
+
portfolio = portfolio,
|
|
48
|
+
broker = broker,
|
|
49
|
+
contracts = contracts,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
self._portfolio = self.execution_system.portfolio
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def from_config(cls,
|
|
56
|
+
*,
|
|
57
|
+
strategy: Strategy,
|
|
58
|
+
config_path: str,
|
|
59
|
+
|
|
60
|
+
# Optional overrides
|
|
61
|
+
signal_destination: SignalSink | None = None,
|
|
62
|
+
portfolio: Portfolio | None = None,
|
|
63
|
+
risk_manager: RiskManager | None = None,
|
|
64
|
+
init_capital: float = 10_000.0,
|
|
65
|
+
contracts: ContractRegistry | None = None,
|
|
66
|
+
) -> "Backtester":
|
|
67
|
+
|
|
68
|
+
cfg = load_config(config_path)
|
|
69
|
+
data_src = datasource_from_config(cfg, contracts=contracts)
|
|
70
|
+
|
|
71
|
+
if portfolio is None:
|
|
72
|
+
portfolio = BasicPortfolio(init_capital=float(init_capital), contracts=contracts)
|
|
73
|
+
|
|
74
|
+
if risk_manager is None and cfg.system.use_risk_manager:
|
|
75
|
+
risk_manager = build_risk_manager(cfg.risk)
|
|
76
|
+
|
|
77
|
+
return cls(
|
|
78
|
+
strategy=strategy,
|
|
79
|
+
data_src=data_src,
|
|
80
|
+
signal_destination=signal_destination,
|
|
81
|
+
portfolio=portfolio,
|
|
82
|
+
risk_manager=risk_manager,
|
|
83
|
+
broker=build_broker(cfg.broker, contracts=contracts),
|
|
84
|
+
config=cfg,
|
|
85
|
+
contracts=contracts,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# ----- Properties ----- #
|
|
89
|
+
@property
|
|
90
|
+
def equity_curve(self) -> Iterable[EquityPoint]:
|
|
91
|
+
return self._portfolio.equity_curve
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def realised_pnl(self) -> float:
|
|
95
|
+
return self._portfolio.realised_pnl
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def unrealised_pnl(self) -> float:
|
|
99
|
+
return self._portfolio.unrealised_pnl
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def total_pnl(self) -> float:
|
|
103
|
+
return self.realised_pnl + self.unrealised_pnl
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def kpms(self) -> BacktestKPMs:
|
|
107
|
+
if not self._portfolio:
|
|
108
|
+
raise ValueError("Cannot produce KPMs without an initialised Portfolio")
|
|
109
|
+
|
|
110
|
+
"""Calculate and return key performance metrics after completing a backtest."""
|
|
111
|
+
return calculate_kpms(self._portfolio)
|
|
112
|
+
|
|
113
|
+
@final
|
|
114
|
+
async def run_async(self) -> BacktestKPMs:
|
|
115
|
+
await self.execution_system.run()
|
|
116
|
+
|
|
117
|
+
if not self._portfolio:
|
|
118
|
+
raise ValueError("Cannot produce KPMs without an initialised Portfolio")
|
|
119
|
+
|
|
120
|
+
return calculate_kpms(self._portfolio)
|
|
121
|
+
|
|
122
|
+
@final
|
|
123
|
+
def run(self) -> BacktestKPMs:
|
|
124
|
+
try:
|
|
125
|
+
asyncio.get_running_loop()
|
|
126
|
+
except RuntimeError:
|
|
127
|
+
return asyncio.run(self.run_async())
|
|
128
|
+
|
|
129
|
+
raise RuntimeError("Cannot run backtest synchronously within an existing event loop. Please use run_async() instead.")
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from typing import Iterable
|
|
3
|
+
|
|
4
|
+
from pandas import DataFrame
|
|
5
|
+
import pandas as pd
|
|
6
|
+
|
|
7
|
+
from ..backtester.backtester_base import Backtester
|
|
8
|
+
from ..brokerage.builder import build_broker
|
|
9
|
+
from ..core.types import Order, EquityPoint, BacktestKPMs
|
|
10
|
+
from ..execution.strategy_base import Strategy
|
|
11
|
+
from ..data_layer.dataframe_datasource import DataFrameDataSource
|
|
12
|
+
from ..execution.sinks import DataFrameSink
|
|
13
|
+
from ..portfolio.basic_portfolio import BasicPortfolio
|
|
14
|
+
from ..util.backtester_utils import output_to_excel, calculate_kpms
|
|
15
|
+
from ..util.config import load_config
|
|
16
|
+
|
|
17
|
+
class DataFrameBacktester(Backtester):
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
strategy: Strategy,
|
|
21
|
+
dataframe: DataFrame,
|
|
22
|
+
config_path: str,
|
|
23
|
+
|
|
24
|
+
timestamp_col: str | None = None,
|
|
25
|
+
# DataFrame format:
|
|
26
|
+
*,
|
|
27
|
+
symbol: str | None = None,
|
|
28
|
+
open_col: str = "Open",
|
|
29
|
+
high_col: str = "High",
|
|
30
|
+
low_col: str = "Low",
|
|
31
|
+
close_col: str = "Close",
|
|
32
|
+
volume_col: str = "Volume",
|
|
33
|
+
|
|
34
|
+
# Additional Columns
|
|
35
|
+
signal_col: str = "Signal",
|
|
36
|
+
confidence_col: str = "Confidence",
|
|
37
|
+
quantity_col: str = "Quantity",
|
|
38
|
+
|
|
39
|
+
# Backtesting
|
|
40
|
+
init_capital: float = 10_000.0
|
|
41
|
+
) -> None:
|
|
42
|
+
config = load_config(config_path)
|
|
43
|
+
|
|
44
|
+
df = dataframe.copy()
|
|
45
|
+
|
|
46
|
+
if isinstance(df.columns, pd.MultiIndex): # Flatten MultiIndex if present
|
|
47
|
+
required = {open_col, high_col, low_col, close_col, volume_col}
|
|
48
|
+
|
|
49
|
+
levels = [set(map(str, df.columns.get_level_values(i))) for i in range(df.columns.nlevels)]
|
|
50
|
+
level_with_required = None
|
|
51
|
+
for i, vals in enumerate(levels):
|
|
52
|
+
if required.issubset(vals):
|
|
53
|
+
level_with_required = i
|
|
54
|
+
break
|
|
55
|
+
|
|
56
|
+
if level_with_required is not None:
|
|
57
|
+
df.columns = df.columns.get_level_values(level_with_required)
|
|
58
|
+
|
|
59
|
+
# Duplicate the initial DataFrame and initialise signal and confidence columns
|
|
60
|
+
self._return_df = df
|
|
61
|
+
self._return_df[signal_col] = pd.NA
|
|
62
|
+
self._return_df[confidence_col] = 0.0
|
|
63
|
+
self._return_df[quantity_col] = 0.0
|
|
64
|
+
|
|
65
|
+
# Backtesting variables
|
|
66
|
+
self._symbols: list[str] = config.system.symbols
|
|
67
|
+
self._portfolio = BasicPortfolio(init_capital=init_capital)
|
|
68
|
+
|
|
69
|
+
super().__init__(
|
|
70
|
+
strategy=strategy,
|
|
71
|
+
data_src=DataFrameDataSource(
|
|
72
|
+
dataframe=df,
|
|
73
|
+
symbol= config.system.symbols[0],
|
|
74
|
+
timestamp_col= timestamp_col,
|
|
75
|
+
open_col= open_col,
|
|
76
|
+
high_col= high_col,
|
|
77
|
+
low_col= low_col,
|
|
78
|
+
close_col= close_col,
|
|
79
|
+
volume_col= volume_col
|
|
80
|
+
),
|
|
81
|
+
signal_destination=DataFrameSink(
|
|
82
|
+
dataframe= self._return_df,
|
|
83
|
+
timestamp_col= timestamp_col,
|
|
84
|
+
signal_col= signal_col,
|
|
85
|
+
confidence_col= confidence_col,
|
|
86
|
+
quantity_col= quantity_col
|
|
87
|
+
),
|
|
88
|
+
portfolio= self._portfolio,
|
|
89
|
+
broker=build_broker(config.broker),
|
|
90
|
+
config= config,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def dataframe(self) -> DataFrame:
|
|
95
|
+
return self._return_df
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from ..execution.strategy_base import Strategy
|
|
2
|
+
from ..core.types import Bar, Signal, SignalType
|
|
3
|
+
|
|
4
|
+
class BenchmarkBuyAndHold(Strategy):
|
|
5
|
+
"""
|
|
6
|
+
Buy and hold benchmark strategy.
|
|
7
|
+
- On a bar received with an unseen symbol, sends a BUY signal.
|
|
8
|
+
- After entering, emits no further signals for that symbol
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
_USE_CATASTROPHIC_SL: bool = True
|
|
12
|
+
_CATASTROPHIC_SL_PERCENT: float = 0.35 # 35% below entry in case of extreme drawdown
|
|
13
|
+
|
|
14
|
+
def __init__(self):
|
|
15
|
+
super().__init__()
|
|
16
|
+
self._entered: dict[str, bool] = {}
|
|
17
|
+
self._entry_price: dict[str, float] = {}
|
|
18
|
+
|
|
19
|
+
def on_start(self):
|
|
20
|
+
...
|
|
21
|
+
|
|
22
|
+
def on_end(self):
|
|
23
|
+
...
|
|
24
|
+
|
|
25
|
+
def on_market_data(self, bar: Bar) -> Signal | None:
|
|
26
|
+
symbol = bar.symbol
|
|
27
|
+
|
|
28
|
+
if not self._entered.get(symbol, False):
|
|
29
|
+
self._entered[symbol] = True
|
|
30
|
+
self._entry_price[symbol] = float(bar.close)
|
|
31
|
+
|
|
32
|
+
confidence = 1.0
|
|
33
|
+
quantity = super().calculate_quantity(confidence=confidence)
|
|
34
|
+
|
|
35
|
+
metadata: dict[str, float] = {}
|
|
36
|
+
|
|
37
|
+
if self._USE_CATASTROPHIC_SL:
|
|
38
|
+
sl = bar.close * (1.0 - self._CATASTROPHIC_SL_PERCENT)
|
|
39
|
+
metadata["stop_loss"] = sl
|
|
40
|
+
|
|
41
|
+
return Signal(
|
|
42
|
+
time_stamp=bar.time_stamp,
|
|
43
|
+
symbol=symbol,
|
|
44
|
+
signal_type=SignalType.BUY,
|
|
45
|
+
ref_price=bar.close,
|
|
46
|
+
confidence=confidence,
|
|
47
|
+
quantity=quantity,
|
|
48
|
+
metadata=metadata if metadata else None,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
return None
|