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.
- sandtable-0.1.0/.coverage +0 -0
- sandtable-0.1.0/.env.example +20 -0
- sandtable-0.1.0/.github/workflows/publish.yml +185 -0
- sandtable-0.1.0/.github/workflows/tests.yml +103 -0
- sandtable-0.1.0/.gitignore +21 -0
- sandtable-0.1.0/LICENSE +21 -0
- sandtable-0.1.0/PKG-INFO +307 -0
- sandtable-0.1.0/README.md +266 -0
- sandtable-0.1.0/data/download_spy_data.py +35 -0
- sandtable-0.1.0/data/sample_ohlcv.csv +502 -0
- sandtable-0.1.0/examples/advanced/run_ma_crossover.py +79 -0
- sandtable-0.1.0/examples/advanced/visualize_backtest.py +105 -0
- sandtable-0.1.0/examples/backtest_demo.ipynb +226 -0
- sandtable-0.1.0/examples/compare_strategies.py +84 -0
- sandtable-0.1.0/examples/generate_tearsheet.py +52 -0
- sandtable-0.1.0/examples/multi_asset.py +90 -0
- sandtable-0.1.0/examples/quick_start.py +76 -0
- sandtable-0.1.0/pyproject.toml +51 -0
- sandtable-0.1.0/src/sandtable/__init__.py +61 -0
- sandtable-0.1.0/src/sandtable/api.py +219 -0
- sandtable-0.1.0/src/sandtable/config.py +96 -0
- sandtable-0.1.0/src/sandtable/core/__init__.py +27 -0
- sandtable-0.1.0/src/sandtable/core/backtest.py +275 -0
- sandtable-0.1.0/src/sandtable/core/event_queue.py +162 -0
- sandtable-0.1.0/src/sandtable/core/events.py +176 -0
- sandtable-0.1.0/src/sandtable/core/result.py +107 -0
- sandtable-0.1.0/src/sandtable/data_handlers/__init__.py +17 -0
- sandtable-0.1.0/src/sandtable/data_handlers/abstract_data_handler.py +57 -0
- sandtable-0.1.0/src/sandtable/data_handlers/csv_data_handler.py +93 -0
- sandtable-0.1.0/src/sandtable/data_handlers/multi_handler.py +129 -0
- sandtable-0.1.0/src/sandtable/data_handlers/single_symbol_handler.py +93 -0
- sandtable-0.1.0/src/sandtable/data_handlers/yfinance_handler.py +107 -0
- sandtable-0.1.0/src/sandtable/execution/__init__.py +19 -0
- sandtable-0.1.0/src/sandtable/execution/impact.py +136 -0
- sandtable-0.1.0/src/sandtable/execution/simulator.py +169 -0
- sandtable-0.1.0/src/sandtable/execution/slippage.py +136 -0
- sandtable-0.1.0/src/sandtable/metrics/__init__.py +23 -0
- sandtable-0.1.0/src/sandtable/metrics/performance.py +465 -0
- sandtable-0.1.0/src/sandtable/portfolio/__init__.py +7 -0
- sandtable-0.1.0/src/sandtable/portfolio/portfolio.py +449 -0
- sandtable-0.1.0/src/sandtable/report/__init__.py +8 -0
- sandtable-0.1.0/src/sandtable/report/comparison.py +123 -0
- sandtable-0.1.0/src/sandtable/report/tearsheet.py +160 -0
- sandtable-0.1.0/src/sandtable/strategy/__init__.py +9 -0
- sandtable-0.1.0/src/sandtable/strategy/abstract_strategy.py +143 -0
- sandtable-0.1.0/src/sandtable/strategy/ma_crossover.py +147 -0
- sandtable-0.1.0/src/sandtable/strategy/mean_reversion.py +94 -0
- sandtable-0.1.0/src/sandtable/utils/__init__.py +7 -0
- sandtable-0.1.0/src/sandtable/utils/cli.py +27 -0
- sandtable-0.1.0/src/sandtable/utils/logger.py +27 -0
- sandtable-0.1.0/src/sandtable/viz/__init__.py +8 -0
- sandtable-0.1.0/src/sandtable/viz/animation.py +199 -0
- sandtable-0.1.0/src/sandtable/viz/charts.py +122 -0
- sandtable-0.1.0/tests/__init__.py +0 -0
- sandtable-0.1.0/tests/api/__init__.py +0 -0
- sandtable-0.1.0/tests/api/test_api.py +180 -0
- sandtable-0.1.0/tests/core/__init__.py +0 -0
- sandtable-0.1.0/tests/core/test_event_queue.py +242 -0
- sandtable-0.1.0/tests/core/test_result.py +127 -0
- sandtable-0.1.0/tests/data_handlers/__init__.py +0 -0
- sandtable-0.1.0/tests/data_handlers/test_multi_handler.py +282 -0
- sandtable-0.1.0/tests/data_handlers/test_no_lookahead.py +259 -0
- sandtable-0.1.0/tests/data_handlers/test_single_symbol_handler.py +203 -0
- sandtable-0.1.0/tests/data_handlers/test_yfinance_handler.py +131 -0
- sandtable-0.1.0/tests/execution/__init__.py +0 -0
- sandtable-0.1.0/tests/execution/test_execution_models.py +339 -0
- sandtable-0.1.0/tests/metrics/__init__.py +0 -0
- sandtable-0.1.0/tests/metrics/test_metrics.py +361 -0
- sandtable-0.1.0/tests/portfolio/__init__.py +0 -0
- sandtable-0.1.0/tests/portfolio/test_portfolio.py +382 -0
- sandtable-0.1.0/tests/report/__init__.py +0 -0
- sandtable-0.1.0/tests/report/test_tearsheet.py +102 -0
- sandtable-0.1.0/tests/strategy/__init__.py +0 -0
- sandtable-0.1.0/tests/strategy/test_abstract_strategy.py +246 -0
- sandtable-0.1.0/tests/strategy/test_ma_crossover.py +315 -0
- sandtable-0.1.0/tests/strategy/test_mean_reversion.py +210 -0
- 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"
|
sandtable-0.1.0/LICENSE
ADDED
|
@@ -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.
|
sandtable-0.1.0/PKG-INFO
ADDED
|
@@ -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.
|