sandtable 0.1.0__tar.gz

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.
Files changed (77) hide show
  1. sandtable-0.1.0/.coverage +0 -0
  2. sandtable-0.1.0/.env.example +20 -0
  3. sandtable-0.1.0/.github/workflows/publish.yml +185 -0
  4. sandtable-0.1.0/.github/workflows/tests.yml +103 -0
  5. sandtable-0.1.0/.gitignore +21 -0
  6. sandtable-0.1.0/LICENSE +21 -0
  7. sandtable-0.1.0/PKG-INFO +307 -0
  8. sandtable-0.1.0/README.md +266 -0
  9. sandtable-0.1.0/data/download_spy_data.py +35 -0
  10. sandtable-0.1.0/data/sample_ohlcv.csv +502 -0
  11. sandtable-0.1.0/examples/advanced/run_ma_crossover.py +79 -0
  12. sandtable-0.1.0/examples/advanced/visualize_backtest.py +105 -0
  13. sandtable-0.1.0/examples/backtest_demo.ipynb +226 -0
  14. sandtable-0.1.0/examples/compare_strategies.py +84 -0
  15. sandtable-0.1.0/examples/generate_tearsheet.py +52 -0
  16. sandtable-0.1.0/examples/multi_asset.py +90 -0
  17. sandtable-0.1.0/examples/quick_start.py +76 -0
  18. sandtable-0.1.0/pyproject.toml +51 -0
  19. sandtable-0.1.0/src/sandtable/__init__.py +61 -0
  20. sandtable-0.1.0/src/sandtable/api.py +219 -0
  21. sandtable-0.1.0/src/sandtable/config.py +96 -0
  22. sandtable-0.1.0/src/sandtable/core/__init__.py +27 -0
  23. sandtable-0.1.0/src/sandtable/core/backtest.py +275 -0
  24. sandtable-0.1.0/src/sandtable/core/event_queue.py +162 -0
  25. sandtable-0.1.0/src/sandtable/core/events.py +176 -0
  26. sandtable-0.1.0/src/sandtable/core/result.py +107 -0
  27. sandtable-0.1.0/src/sandtable/data_handlers/__init__.py +17 -0
  28. sandtable-0.1.0/src/sandtable/data_handlers/abstract_data_handler.py +57 -0
  29. sandtable-0.1.0/src/sandtable/data_handlers/csv_data_handler.py +93 -0
  30. sandtable-0.1.0/src/sandtable/data_handlers/multi_handler.py +129 -0
  31. sandtable-0.1.0/src/sandtable/data_handlers/single_symbol_handler.py +93 -0
  32. sandtable-0.1.0/src/sandtable/data_handlers/yfinance_handler.py +107 -0
  33. sandtable-0.1.0/src/sandtable/execution/__init__.py +19 -0
  34. sandtable-0.1.0/src/sandtable/execution/impact.py +136 -0
  35. sandtable-0.1.0/src/sandtable/execution/simulator.py +169 -0
  36. sandtable-0.1.0/src/sandtable/execution/slippage.py +136 -0
  37. sandtable-0.1.0/src/sandtable/metrics/__init__.py +23 -0
  38. sandtable-0.1.0/src/sandtable/metrics/performance.py +465 -0
  39. sandtable-0.1.0/src/sandtable/portfolio/__init__.py +7 -0
  40. sandtable-0.1.0/src/sandtable/portfolio/portfolio.py +449 -0
  41. sandtable-0.1.0/src/sandtable/report/__init__.py +8 -0
  42. sandtable-0.1.0/src/sandtable/report/comparison.py +123 -0
  43. sandtable-0.1.0/src/sandtable/report/tearsheet.py +160 -0
  44. sandtable-0.1.0/src/sandtable/strategy/__init__.py +9 -0
  45. sandtable-0.1.0/src/sandtable/strategy/abstract_strategy.py +143 -0
  46. sandtable-0.1.0/src/sandtable/strategy/ma_crossover.py +147 -0
  47. sandtable-0.1.0/src/sandtable/strategy/mean_reversion.py +94 -0
  48. sandtable-0.1.0/src/sandtable/utils/__init__.py +7 -0
  49. sandtable-0.1.0/src/sandtable/utils/cli.py +27 -0
  50. sandtable-0.1.0/src/sandtable/utils/logger.py +27 -0
  51. sandtable-0.1.0/src/sandtable/viz/__init__.py +8 -0
  52. sandtable-0.1.0/src/sandtable/viz/animation.py +199 -0
  53. sandtable-0.1.0/src/sandtable/viz/charts.py +122 -0
  54. sandtable-0.1.0/tests/__init__.py +0 -0
  55. sandtable-0.1.0/tests/api/__init__.py +0 -0
  56. sandtable-0.1.0/tests/api/test_api.py +180 -0
  57. sandtable-0.1.0/tests/core/__init__.py +0 -0
  58. sandtable-0.1.0/tests/core/test_event_queue.py +242 -0
  59. sandtable-0.1.0/tests/core/test_result.py +127 -0
  60. sandtable-0.1.0/tests/data_handlers/__init__.py +0 -0
  61. sandtable-0.1.0/tests/data_handlers/test_multi_handler.py +282 -0
  62. sandtable-0.1.0/tests/data_handlers/test_no_lookahead.py +259 -0
  63. sandtable-0.1.0/tests/data_handlers/test_single_symbol_handler.py +203 -0
  64. sandtable-0.1.0/tests/data_handlers/test_yfinance_handler.py +131 -0
  65. sandtable-0.1.0/tests/execution/__init__.py +0 -0
  66. sandtable-0.1.0/tests/execution/test_execution_models.py +339 -0
  67. sandtable-0.1.0/tests/metrics/__init__.py +0 -0
  68. sandtable-0.1.0/tests/metrics/test_metrics.py +361 -0
  69. sandtable-0.1.0/tests/portfolio/__init__.py +0 -0
  70. sandtable-0.1.0/tests/portfolio/test_portfolio.py +382 -0
  71. sandtable-0.1.0/tests/report/__init__.py +0 -0
  72. sandtable-0.1.0/tests/report/test_tearsheet.py +102 -0
  73. sandtable-0.1.0/tests/strategy/__init__.py +0 -0
  74. sandtable-0.1.0/tests/strategy/test_abstract_strategy.py +246 -0
  75. sandtable-0.1.0/tests/strategy/test_ma_crossover.py +315 -0
  76. sandtable-0.1.0/tests/strategy/test_mean_reversion.py +210 -0
  77. sandtable-0.1.0/uv.lock +950 -0
Binary file
@@ -0,0 +1,20 @@
1
+ # .env.example
2
+
3
+ # Backtester config
4
+ # copy this file to .env and edit as needed
5
+ #unset variables fall back to the defaults shown below
6
+
7
+ # Python logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
8
+ BACKTESTER_LOG_LEVEL=INFO
9
+
10
+ # Annual risk-free rate for Sharpe / Sortino ratios (decimal, e.g. 0.05 = 5%)
11
+ BACKTESTER_RISK_FREE_RATE=0.0
12
+
13
+ # Trading days per year for annualisation (252 = US equity, 365 = crypto)
14
+ BACKTESTER_TRADING_DAYS=252
15
+
16
+ # Default cache directory for downloaded data (leave empty to disable caching)
17
+ BACKTESTER_CACHE_DIR=
18
+
19
+ # Directory for script outputs (tearsheets, charts, etc.)
20
+ BACKTESTER_OUTPUT_DIR=outputs
@@ -0,0 +1,185 @@
1
+ # .github/workflows/publish.yml
2
+
3
+ name: Publish to PyPI
4
+
5
+ on:
6
+ release:
7
+ types: [published]
8
+
9
+ pull_request:
10
+ paths:
11
+ - 'pyproject.toml'
12
+ - 'src/sandtable/**'
13
+ - '.github/workflows/publish.yml'
14
+
15
+ push:
16
+ branches: [main]
17
+ paths:
18
+ - 'pyproject.toml'
19
+ - 'src/sandtable/**'
20
+ - '.github/workflows/publish.yml'
21
+
22
+ workflow_dispatch:
23
+
24
+ jobs:
25
+ # PR + Release: build & test
26
+ build:
27
+ name: Build & test wheel
28
+ runs-on: ubuntu-latest
29
+ outputs:
30
+ version: ${{ steps.get-version.outputs.version }}
31
+ steps:
32
+ - uses: actions/checkout@v4
33
+
34
+ - uses: actions/setup-python@v5
35
+ with:
36
+ python-version: '3.12'
37
+
38
+ - name: Install build deps
39
+ run: |
40
+ python -m pip install --upgrade pip
41
+ pip install build hatchling
42
+
43
+ - name: Build package
44
+ run: python -m build
45
+
46
+ - name: Extract version
47
+ id: get-version
48
+ run: |
49
+ VERSION=$(ls dist/*.whl | sed 's/.*sandtable-\(.*\)-py3.*/\1/')
50
+ echo "version=$VERSION" >> $GITHUB_OUTPUT
51
+
52
+ - name: Verify wheel installs
53
+ run: |
54
+ pip install dist/*.whl
55
+ python -c "import sandtable"
56
+
57
+ - uses: actions/upload-artifact@v4
58
+ with:
59
+ name: dist
60
+ path: dist/
61
+
62
+ # -------------------------
63
+ # Release only: TestPyPI
64
+ # -------------------------
65
+ publish-testpypi:
66
+ name: Publish to TestPyPI
67
+ if: github.event_name == 'release'
68
+ needs: build
69
+ runs-on: ubuntu-latest
70
+ environment: sandtable
71
+ permissions:
72
+ id-token: write
73
+ outputs:
74
+ test_version: ${{ steps.get-version.outputs.version }}
75
+ steps:
76
+ - uses: actions/checkout@v4
77
+
78
+ - uses: actions/setup-python@v5
79
+ with:
80
+ python-version: '3.12'
81
+
82
+ - name: Install build deps
83
+ run: |
84
+ python -m pip install --upgrade pip
85
+ pip install build hatchling
86
+
87
+ - name: Add dev suffix for TestPyPI
88
+ run: |
89
+ TIMESTAMP=$(date +%Y%m%d%H%M%S)
90
+ VERSION=$(python - <<'PY'
91
+ import tomllib
92
+ print(tomllib.load(open("pyproject.toml","rb"))["project"]["version"])
93
+ PY
94
+ )
95
+ DEV_VERSION="${VERSION}.dev${TIMESTAMP}"
96
+ sed -i "s/version = \"$VERSION\"/version = \"$DEV_VERSION\"/" pyproject.toml
97
+ echo "DEV_VERSION=$DEV_VERSION" >> $GITHUB_ENV
98
+
99
+ - name: Clean dist
100
+ run: rm -rf dist
101
+
102
+ - name: Build TestPyPI artifacts
103
+ run: python -m build
104
+
105
+ - name: Extract TestPyPI version
106
+ id: get-version
107
+ run: |
108
+ VERSION=$(ls dist/*.whl | sed 's/.*sandtable-\(.*\)-py3.*/\1/')
109
+ echo "version=$VERSION" >> $GITHUB_OUTPUT
110
+
111
+ - uses: pypa/gh-action-pypi-publish@release/v1
112
+ with:
113
+ repository-url: https://test.pypi.org/legacy/
114
+
115
+ test-install-testpypi:
116
+ name: Verify install from TestPyPI
117
+ if: github.event_name == 'release'
118
+ needs: publish-testpypi
119
+ runs-on: ubuntu-latest
120
+ steps:
121
+ - uses: actions/setup-python@v5
122
+ with:
123
+ python-version: '3.12'
124
+
125
+ - name: Install uv
126
+ run: pip install uv
127
+
128
+ - name: Install from TestPyPI
129
+ run: |
130
+ VERSION="${{ needs.publish-testpypi.outputs.test_version }}"
131
+ for i in {1..10}; do
132
+ # Package from TestPyPI, deps from PyPI
133
+ if uv pip install --system --pre \
134
+ --index-url https://test.pypi.org/simple/ \
135
+ --extra-index-url https://pypi.org/simple/ \
136
+ --index-strategy unsafe-best-match \
137
+ "sandtable==$VERSION"; then
138
+ python -c "import sandtable"
139
+ exit 0
140
+ fi
141
+ sleep 30
142
+ done
143
+ exit 1
144
+
145
+ # Release only: PyPI
146
+ publish-pypi:
147
+ name: Publish to PyPI
148
+ if: github.event_name == 'release'
149
+ needs: test-install-testpypi
150
+ runs-on: ubuntu-latest
151
+ environment: sandtable
152
+ permissions:
153
+ id-token: write
154
+ steps:
155
+ - uses: actions/download-artifact@v4
156
+ with:
157
+ name: dist
158
+ path: dist/
159
+
160
+ - uses: pypa/gh-action-pypi-publish@release/v1
161
+
162
+ test-install-pypi:
163
+ name: Verify install from PyPI
164
+ if: github.event_name == 'release'
165
+ needs: [build, publish-pypi]
166
+ runs-on: ubuntu-latest
167
+ steps:
168
+ - uses: actions/setup-python@v5
169
+ with:
170
+ python-version: '3.12'
171
+
172
+ - name: Install uv
173
+ run: pip install uv
174
+
175
+ - name: Install from PyPI
176
+ run: |
177
+ VERSION="${{ needs.build.outputs.version }}"
178
+ for i in {1..10}; do
179
+ if uv pip install --system "sandtable==$VERSION"; then
180
+ python -c "import sandtable"
181
+ exit 0
182
+ fi
183
+ sleep 30
184
+ done
185
+ exit 1
@@ -0,0 +1,103 @@
1
+ # .github/workflows/tests.yml
2
+
3
+ name: Tests
4
+
5
+ on:
6
+ push:
7
+ branches: [main]
8
+ pull_request:
9
+ types: [opened, synchronize, reopened]
10
+
11
+ permissions:
12
+ contents: read
13
+
14
+ jobs:
15
+ tests:
16
+ runs-on: ubuntu-latest
17
+ defaults:
18
+ run:
19
+ shell: bash -l {0}
20
+
21
+ steps:
22
+ - uses: actions/checkout@v4
23
+
24
+ - name: Install uv
25
+ uses: astral-sh/setup-uv@v3
26
+ with:
27
+ version: "latest"
28
+
29
+ - name: Cache uv dependencies
30
+ uses: actions/cache@v4
31
+ with:
32
+ path: |
33
+ ~/.cache/uv
34
+ key: ${{ runner.os }}-uv-${{ hashFiles('pyproject.toml') }}
35
+
36
+ - name: Install dependencies
37
+ run: uv sync --extra dev
38
+
39
+ - name: Lint
40
+ run: uv run ruff check .
41
+
42
+ - name: Run tests
43
+ run: uv run pytest tests/ -v
44
+
45
+ test-install:
46
+ name: Test package install mechanism
47
+ runs-on: ubuntu-latest
48
+ steps:
49
+ - uses: actions/checkout@v4
50
+
51
+ - name: Set up Python
52
+ uses: actions/setup-python@v5
53
+ with:
54
+ python-version: '3.12'
55
+
56
+ - name: Install build tools
57
+ run: |
58
+ python -m pip install --upgrade pip
59
+ pip install build uv
60
+
61
+ - name: Build package
62
+ run: python -m build
63
+
64
+ - name: Test --no-deps installs only the package
65
+ run: |
66
+ # Create fresh venv
67
+ python -m venv test-venv
68
+ source test-venv/bin/activate
69
+
70
+ # Count packages before install (pip is pre-installed)
71
+ BEFORE_COUNT=$(pip list --format=freeze | wc -l)
72
+
73
+ # Install with --no-deps
74
+ uv pip install dist/*.whl --no-deps
75
+
76
+ # Count packages after install (should be exactly 1 more: sandtable)
77
+ AFTER_COUNT=$(pip list --format=freeze | wc -l)
78
+ INSTALLED=$((AFTER_COUNT - BEFORE_COUNT))
79
+
80
+ if [[ "$INSTALLED" -ne 1 ]]; then
81
+ echo "ERROR: Expected 1 new package, got $INSTALLED"
82
+ pip list
83
+ exit 1
84
+ fi
85
+ echo "✓ --no-deps installed exactly 1 package (no dependencies)"
86
+
87
+ - name: Test full install from PyPI deps
88
+ run: |
89
+ source test-venv/bin/activate
90
+
91
+ # Now install normally - deps should come from PyPI
92
+ uv pip install dist/*.whl
93
+
94
+ # Verify import works
95
+ python -c "import sandtable; print('✓ sandtable imported successfully')"
96
+
97
+ # Verify deps were installed
98
+ PACKAGE_COUNT=$(pip list --format=freeze | wc -l)
99
+ if [[ "$PACKAGE_COUNT" -lt 10 ]]; then
100
+ echo "ERROR: Expected many packages, got $PACKAGE_COUNT"
101
+ exit 1
102
+ fi
103
+ echo "✓ Full install succeeded with $PACKAGE_COUNT packages"
@@ -0,0 +1,21 @@
1
+ # Environment
2
+ .env
3
+ .venv/
4
+
5
+ # Python
6
+ __pycache__/
7
+ *.py[cod]
8
+ *.egg-info/
9
+ dist/
10
+ build/
11
+
12
+ # Cache
13
+ .cache/
14
+ .pytest_cache/
15
+
16
+ # IDE
17
+ .vscode/
18
+ .idea/
19
+
20
+ # Generated outputs
21
+ outputs/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Alex Wilcox
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,307 @@
1
+ Metadata-Version: 2.4
2
+ Name: sandtable
3
+ Version: 0.1.0
4
+ Summary: Event-driven backtesting framework with realistic execution modeling
5
+ Project-URL: Repository, https://github.com/westimator/sandtable
6
+ Author: Westimator
7
+ License: MIT License
8
+
9
+ Copyright (c) 2026 Alex Wilcox
10
+
11
+ Permission is hereby granted, free of charge, to any person obtaining a copy
12
+ of this software and associated documentation files (the "Software"), to deal
13
+ in the Software without restriction, including without limitation the rights
14
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
15
+ copies of the Software, and to permit persons to whom the Software is
16
+ furnished to do so, subject to the following conditions:
17
+
18
+ The above copyright notice and this permission notice shall be included in all
19
+ copies or substantial portions of the Software.
20
+
21
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
22
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
23
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
24
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
25
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
26
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
27
+ SOFTWARE.
28
+ License-File: LICENSE
29
+ Requires-Python: <3.13,>=3.12.1
30
+ Requires-Dist: matplotlib>=3.8
31
+ Requires-Dist: numpy>=1.24
32
+ Requires-Dist: pandas>=2.0
33
+ Requires-Dist: python-dotenv>=1.0
34
+ Requires-Dist: tabulate>=0.9
35
+ Requires-Dist: yfinance>=0.2
36
+ Provides-Extra: dev
37
+ Requires-Dist: ipykernel>=6.29; extra == 'dev'
38
+ Requires-Dist: pytest>=8.0; extra == 'dev'
39
+ Requires-Dist: ruff>=0.4; extra == 'dev'
40
+ Description-Content-Type: text/markdown
41
+
42
+ # sandtable
43
+
44
+ A Python backtesting framework where all components communicate exclusively through a central event queue. This design enforces temporal causality and prevents look-ahead bias by construction.
45
+
46
+ ## Why event-driven?
47
+
48
+ Traditional backtesting frameworks often allow direct access to future data, making it easy to accidentally introduce look-ahead bias. This framework prevents that by design:
49
+
50
+ 1. <ins>Temporal causality:</ins> Events are processed in strict timestamp order via a priority queue
51
+ 2. <ins>No future data access:</ins> The `DataHandler` only exposes historical data up to the current bar
52
+ 3. <ins>Realistic execution:</ins> Orders are filled with configurable slippage, market impact, and commissions
53
+ 4. <ins>Clear data flow:</ins> Events flow in one direction: `MARKET_DATA → SIGNAL → ORDER → FILL`
54
+
55
+ ```
56
+ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
57
+ │ DataHandler │────▶│ Strategy │────▶│ Portfolio │────▶│ Executor │
58
+ │ (bars) │ │ (signals) │ │ (orders) │ │ (fills) │
59
+ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘
60
+ │ │ │ │
61
+ └───────────────────┴───────────────────┴───────────────────┘
62
+
63
+ ┌────────▼────────┐
64
+ │ Event Queue │
65
+ │ (priority by │
66
+ │ timestamp) │
67
+ └─────────────────┘
68
+ ```
69
+
70
+ ## Setup
71
+
72
+ Requires Python 3.12 and [uv](https://docs.astral.sh/uv/).
73
+
74
+ ```bash
75
+ # install uv (if you don't have it)
76
+ curl -LsSf https://astral.sh/uv/install.sh | sh
77
+
78
+ # create venv and install dependencies
79
+ uv sync
80
+
81
+ # install with all extras (yfinance, matplotlib, plotly)
82
+ uv pip install -e ".[all]"
83
+
84
+ # or with dev dependencies (pytest, ruff, viz, reports)
85
+ uv pip install -e ".[dev]"
86
+ ```
87
+
88
+ ## Quick start
89
+
90
+ ### One-liner API
91
+
92
+ ```python
93
+ from sandtable import run_backtest, AbstractStrategy, SignalEvent, MarketDataEvent, Direction, FixedSlippage
94
+
95
+ class MeanReversion(AbstractStrategy):
96
+ lookback: int = 20
97
+ threshold: float = 2.0
98
+
99
+ def generate_signal(self, bar: MarketDataEvent) -> SignalEvent | None:
100
+ closes = self.get_historical_closes(self.lookback)
101
+ if len(closes) < self.lookback:
102
+ return None
103
+ mean = sum(closes) / len(closes)
104
+ std = (sum((c - mean) ** 2 for c in closes) / len(closes)) ** 0.5
105
+ if std == 0:
106
+ return None
107
+ z_score = (bar.close - mean) / std
108
+ if z_score < -self.threshold:
109
+ return SignalEvent(
110
+ timestamp=bar.timestamp, symbol=bar.symbol,
111
+ direction=Direction.LONG, strength=1.0,
112
+ )
113
+ return None
114
+
115
+ result = run_backtest(
116
+ strategy=MeanReversion(),
117
+ symbols="SPY",
118
+ start="2022-01-01", end="2023-12-31",
119
+ slippage=FixedSlippage(bps=5),
120
+ commission=0.005,
121
+ )
122
+ print(result.metrics)
123
+ result.tearsheet("tearsheet.html")
124
+ ```
125
+
126
+ ### Parameter sweep
127
+
128
+ ```python
129
+ from sandtable import Metric, run_parameter_sweep
130
+
131
+ sweep = run_parameter_sweep(
132
+ strategy_class=MeanReversion,
133
+ param_grid={"lookback": [10, 20, 30], "threshold": [1.5, 2.0, 2.5]},
134
+ symbols="SPY",
135
+ start="2022-01-01", end="2023-12-31",
136
+ metric=Metric.SHARPE_RATIO,
137
+ )
138
+ print(sweep.best_params)
139
+ print(sweep.to_dataframe())
140
+ ```
141
+
142
+ ### Run the example
143
+
144
+ ```bash
145
+ uv run python examples/quick_start.py
146
+ ```
147
+
148
+ ## Usage
149
+
150
+ ### Basic backtest (manual wiring)
151
+
152
+ ```python
153
+ from sandtable import CSVDataHandler, MACrossoverStrategy
154
+ from sandtable.core import Backtest
155
+ from sandtable.execution import ExecutionConfig, ExecutionSimulator, FixedSlippage
156
+ from sandtable.portfolio import Portfolio
157
+
158
+ # set up components
159
+ data = CSVDataHandler("data/sample_ohlcv.csv", "SPY")
160
+ strategy = MACrossoverStrategy(fast_period=10, slow_period=30)
161
+ portfolio = Portfolio(initial_capital=100_000)
162
+ executor = ExecutionSimulator(
163
+ config=ExecutionConfig(commission_per_share=0.005),
164
+ slippage_model=FixedSlippage(bps=5),
165
+ )
166
+
167
+ # run backtest
168
+ backtest = Backtest(data, strategy, portfolio, executor)
169
+ metrics = backtest.run()
170
+ print(metrics)
171
+ ```
172
+
173
+ ### Custom strategy
174
+
175
+ ```python
176
+ from sandtable import AbstractStrategy, MarketDataEvent, SignalEvent, Direction
177
+
178
+ class MyStrategy(AbstractStrategy):
179
+ def generate_signal(self, bar: MarketDataEvent) -> SignalEvent | None:
180
+ closes = self.get_historical_closes(20)
181
+ if len(closes) < 20:
182
+ return None # warmup period
183
+
184
+ # [your logic here]
185
+ if closes[-1] > sum(closes) / len(closes):
186
+ return SignalEvent(
187
+ timestamp=bar.timestamp,
188
+ symbol=bar.symbol,
189
+ direction=Direction.LONG,
190
+ strength=1.0,
191
+ )
192
+ return None
193
+ ```
194
+
195
+ ### Multi-symbol backtest
196
+
197
+ ```python
198
+ from sandtable import run_backtest
199
+
200
+ result = run_backtest(
201
+ strategy=MyStrategy(),
202
+ symbols=["SPY", "QQQ", "IWM"],
203
+ start="2022-01-01", end="2023-12-31",
204
+ )
205
+ ```
206
+
207
+ ### Tearsheet and comparison
208
+
209
+ ```python
210
+ # Single strategy tearsheet
211
+ result.tearsheet("tearsheet.html")
212
+
213
+ # Compare multiple strategies
214
+ from sandtable import compare_strategies
215
+
216
+ compare_strategies(
217
+ {"Strategy A": result_a, "Strategy B": result_b},
218
+ output_path="comparison.html",
219
+ )
220
+ ```
221
+
222
+ ### Execution models
223
+
224
+ ```python
225
+ from sandtable.execution import (
226
+ ExecutionConfig, ExecutionSimulator,
227
+ ZeroSlippage, FixedSlippage, SpreadSlippage,
228
+ NoMarketImpact, SquareRootImpactModel,
229
+ )
230
+
231
+ # no transaction costs (unrealistic baseline)
232
+ executor = ExecutionSimulator(
233
+ slippage_model=ZeroSlippage(),
234
+ impact_model=NoMarketImpact(),
235
+ )
236
+
237
+ # realistic costs
238
+ executor = ExecutionSimulator(
239
+ config=ExecutionConfig(
240
+ commission_per_share=0.005,
241
+ commission_minimum=1.0,
242
+ ),
243
+ slippage_model=FixedSlippage(bps=5),
244
+ impact_model=SquareRootImpactModel(eta=0.1),
245
+ )
246
+ ```
247
+
248
+ ## Project structure
249
+
250
+ ```
251
+ sandtable/
252
+ ├── src/sandtable/
253
+ │ ├── __init__.py # Public API exports
254
+ │ ├── api.py # run_backtest(), run_parameter_sweep()
255
+ │ ├── core/ # Events, queue, backtest engine, result
256
+ │ ├── data_handlers/ # DataHandler protocol, CSV, yfinance, multi-symbol
257
+ │ ├── strategy/ # Strategy base class and implementations
258
+ │ ├── execution/ # Slippage, impact, and fill simulation
259
+ │ ├── portfolio/ # Position and cash management
260
+ │ ├── metrics/ # Performance calculation
261
+ │ ├── report/ # HTML tearsheet and strategy comparison
262
+ │ └── viz/ # matplotlib charts and animation
263
+ ├── tests/ # Unit tests
264
+ ├── data/ # Sample OHLCV data
265
+ ├── examples/ # Example scripts
266
+ └── pyproject.toml
267
+ ```
268
+
269
+ ## Running tests
270
+
271
+ ```bash
272
+ # run all tests
273
+ uv run python -m pytest
274
+
275
+ # run with verbose output
276
+ uv run python -m pytest -v
277
+
278
+ # run specific test file
279
+ uv run python -m pytest tests/core/test_event_queue.py
280
+
281
+ # run with coverage
282
+ uv run python -m coverage run --include="src/sandtable/*" -m pytest tests/
283
+ uv run python -m coverage report --show-missing
284
+ ```
285
+
286
+ ## Design decisions
287
+
288
+ 1. <ins>Lookahead Prevention:</ins> `DataHandler.get_historical_bars(n)` only returns data before the current index
289
+ 2. <ins>Event Ordering:</ins> Priority queue with `(timestamp, counter)` ensures correct ordering and FIFO for same-timestamp events
290
+ 3. <ins>Fill Price Bounds:</ins> Fill prices are clamped to the bar's `[low, high]` range
291
+ 4. <ins>Short Positions:</ins> Cash increases on short sale, decreases on cover, with correct P&L tracking
292
+ 5. <ins>Warmup Period:</ins> Strategies return `None` until they have enough data for their indicators
293
+ 6. <ins>Multi-symbol:</ins> `MultiDataHandler` merges bars from multiple sources via min-heap for correct temporal ordering
294
+
295
+ ## Performance metrics
296
+
297
+ The `PerformanceMetrics` dataclass includes:
298
+
299
+ | Category | Metrics |
300
+ |----------|---------|
301
+ | Returns | `total_return`, `cagr` |
302
+ | Risk | `sharpe_ratio`, `sortino_ratio`, `max_drawdown` |
303
+ | Trades | `num_trades`, `win_rate`, `profit_factor`, `avg_trade_pnl` |
304
+
305
+ ## License
306
+
307
+ See [LICENSE](LICENSE) file.