Add Python strategy engine, parameter optimization, and faster Docker builds.
Support builtin and custom generate_signals strategies with SQLite persistence, exhaustive grid scans (VectorBT comb optimization for MA crossover), professional backtest/optimize UI, and split harvester/app requirements with BuildKit pip cache.
This commit is contained in:
parent
9756809b68
commit
627b2326df
18 changed files with 1139 additions and 259 deletions
13
.dockerignore
Normal file
13
.dockerignore
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
__pycache__
|
||||||
|
*.py[cod]
|
||||||
|
*.egg-info
|
||||||
|
.venv
|
||||||
|
venv
|
||||||
|
.env
|
||||||
|
data
|
||||||
|
*.db
|
||||||
|
.streamlit/secrets.toml
|
||||||
|
README.md
|
||||||
|
.agent-tools
|
||||||
10
Dockerfile
10
Dockerfile
|
|
@ -1,8 +1,8 @@
|
||||||
|
# syntax=docker/dockerfile:1
|
||||||
FROM python:3.11-slim-bookworm
|
FROM python:3.11-slim-bookworm
|
||||||
|
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
PIP_NO_CACHE_DIR=1 \
|
|
||||||
PARQUET_DIR=/data/parquet \
|
PARQUET_DIR=/data/parquet \
|
||||||
STRATEGY_DB_PATH=/data/strategies/strategies.db
|
STRATEGY_DB_PATH=/data/strategies/strategies.db
|
||||||
|
|
||||||
|
|
@ -12,10 +12,12 @@ RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends bash curl \
|
&& apt-get install -y --no-install-recommends bash curl \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY requirements.txt .
|
COPY requirements-app.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||||
|
pip install -r requirements-app.txt
|
||||||
|
|
||||||
COPY telemetry.py auth.py strategy_db.py backtest.py app.py sync.py ./
|
COPY telemetry.py auth.py strategy_db.py metrics.py engine.py backtest.py app.py sync.py ./
|
||||||
|
COPY strategies ./strategies
|
||||||
COPY .streamlit /app/.streamlit
|
COPY .streamlit /app/.streamlit
|
||||||
|
|
||||||
EXPOSE 8501
|
EXPOSE 8501
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
|
# syntax=docker/dockerfile:1
|
||||||
FROM python:3.11-slim-bookworm
|
FROM python:3.11-slim-bookworm
|
||||||
|
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
PIP_NO_CACHE_DIR=1 \
|
|
||||||
PARQUET_DIR=/data/parquet \
|
PARQUET_DIR=/data/parquet \
|
||||||
TZ=America/New_York
|
TZ=America/New_York
|
||||||
|
|
||||||
|
|
@ -12,8 +12,9 @@ RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends bash cron curl \
|
&& apt-get install -y --no-install-recommends bash cron curl \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY requirements.txt .
|
COPY requirements-harvester.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||||
|
pip install -r requirements-harvester.txt
|
||||||
|
|
||||||
COPY telemetry.py sync.py ./
|
COPY telemetry.py sync.py ./
|
||||||
COPY scripts/harvester-entrypoint.sh /usr/local/bin/harvester-entrypoint.sh
|
COPY scripts/harvester-entrypoint.sh /usr/local/bin/harvester-entrypoint.sh
|
||||||
|
|
|
||||||
29
README.md
29
README.md
|
|
@ -62,7 +62,34 @@ streamlit run app.py
|
||||||
|
|
||||||
For full OIDC locally, set `OIDC_CLIENT_SECRET` and register `http://localhost:8501` as a redirect URI in Authentik.
|
For full OIDC locally, set `OIDC_CLIENT_SECRET` and register `http://localhost:8501` as a redirect URI in Authentik.
|
||||||
|
|
||||||
## Manual sync
|
## Research workflow
|
||||||
|
|
||||||
|
1. **Backtest** — run a single parameter set and inspect equity curve, drawdown, trades.
|
||||||
|
2. **Optimize** — exhaustive parameter scan (grid search) ranked by Sharpe, Sortino, return, or drawdown.
|
||||||
|
3. **Python** — view builtin source or author custom strategies with `generate_signals()`.
|
||||||
|
4. **Library** — save/load strategies per user (SQLite), including custom Python source code.
|
||||||
|
|
||||||
|
### Custom Python strategy contract
|
||||||
|
|
||||||
|
```python
|
||||||
|
PARAM_GRID = {"fast_window": list(range(10, 41, 5)), "slow_window": list(range(50, 151, 10))}
|
||||||
|
DEFAULT_PARAMS = {"fast_window": 20, "slow_window": 50}
|
||||||
|
|
||||||
|
def generate_signals(close, high, low, volume, **params):
|
||||||
|
# return boolean entry/exit Series aligned to close
|
||||||
|
return entries, exits
|
||||||
|
```
|
||||||
|
|
||||||
|
Builtins: `ma_crossover` (vectorized VectorBT comb scan), `rsi_reversion` (grid scan).
|
||||||
|
|
||||||
|
## Docker build speed
|
||||||
|
|
||||||
|
- Harvester image installs only `requirements-harvester.txt` (no VectorBT/Streamlit).
|
||||||
|
- App image uses BuildKit pip cache (`RUN --mount=type=cache`).
|
||||||
|
- `.dockerignore` keeps git/cache out of build context.
|
||||||
|
|
||||||
|
Enable BuildKit on Coolify/build host for cache mounts.
|
||||||
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python sync.py --seed # full history
|
python sync.py --seed # full history
|
||||||
|
|
|
||||||
471
app.py
471
app.py
|
|
@ -1,183 +1,404 @@
|
||||||
"""QuantTrade Streamlit dashboard."""
|
"""QuantTrade research workstation."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
import plotly.express as px
|
||||||
import plotly.graph_objects as go
|
import plotly.graph_objects as go
|
||||||
import streamlit as st
|
import streamlit as st
|
||||||
from plotly.subplots import make_subplots
|
from plotly.subplots import make_subplots
|
||||||
|
|
||||||
from auth import get_current_user, logout
|
from auth import get_current_user, logout
|
||||||
from backtest import load_ohlcv, run_ma_crossover
|
from engine import (
|
||||||
|
get_strategy_source,
|
||||||
|
load_ohlcv,
|
||||||
|
run_backtest,
|
||||||
|
run_optimization,
|
||||||
|
)
|
||||||
|
from strategies.executor import CUSTOM_TEMPLATE
|
||||||
|
from strategies.registry import list_builtins
|
||||||
from strategy_db import delete_strategy, init_db, list_strategies, load_strategy, save_strategy
|
from strategy_db import delete_strategy, init_db, list_strategies, load_strategy, save_strategy
|
||||||
from telemetry import capture_exception, init_telemetry
|
from telemetry import capture_exception, init_telemetry
|
||||||
|
|
||||||
init_telemetry("quant-streamlit")
|
init_telemetry("quant-streamlit")
|
||||||
init_db()
|
init_db()
|
||||||
|
|
||||||
st.set_page_config(
|
st.set_page_config(page_title="QuantTrade", page_icon="📈", layout="wide")
|
||||||
page_title="QuantTrade",
|
|
||||||
page_icon="📈",
|
|
||||||
layout="wide",
|
|
||||||
)
|
|
||||||
|
|
||||||
DEFAULT_TICKERS = os.environ.get(
|
DEFAULT_TICKERS = [
|
||||||
"CORE_TICKERS",
|
t.strip().upper()
|
||||||
"SPY,QQQ,AAPL,MSFT,GOOGL,AMZN,NVDA,META,IWM,TLT",
|
for t in os.environ.get(
|
||||||
).split(",")
|
"CORE_TICKERS",
|
||||||
|
"SPY,QQQ,AAPL,MSFT,GOOGL,AMZN,NVDA,META,IWM,TLT",
|
||||||
|
).split(",")
|
||||||
|
if t.strip()
|
||||||
|
]
|
||||||
|
|
||||||
|
METRICS = {
|
||||||
|
"sharpe_ratio": "Sharpe Ratio",
|
||||||
|
"sortino_ratio": "Sortino Ratio",
|
||||||
|
"total_return": "Total Return",
|
||||||
|
"max_drawdown": "Max Drawdown (minimize)",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def render_equity_chart(result) -> None:
|
def sidebar_account(user: str) -> None:
|
||||||
|
st.subheader("Account")
|
||||||
|
st.write(f"**{user}**")
|
||||||
|
if st.button("Logout", use_container_width=True):
|
||||||
|
logout()
|
||||||
|
st.rerun()
|
||||||
|
|
||||||
|
|
||||||
|
def sidebar_market() -> tuple[str, float, float]:
|
||||||
|
st.subheader("Market")
|
||||||
|
ticker = st.selectbox("Ticker", options=DEFAULT_TICKERS)
|
||||||
|
init_cash = st.number_input("Initial capital ($)", min_value=1000.0, value=10_000.0, step=1000.0)
|
||||||
|
fees = st.number_input("Fees (per trade, fraction)", min_value=0.0, max_value=0.05, value=0.001, step=0.0005)
|
||||||
|
return ticker, init_cash, fees
|
||||||
|
|
||||||
|
|
||||||
|
def sidebar_strategy_picker() -> str:
|
||||||
|
st.subheader("Strategy")
|
||||||
|
builtin_options = {b.key: b.display_name for b in list_builtins()}
|
||||||
|
kind = st.radio("Type", options=["Built-in", "Custom Python"], horizontal=True)
|
||||||
|
if kind == "Built-in":
|
||||||
|
return st.selectbox(
|
||||||
|
"Model",
|
||||||
|
options=list(builtin_options.keys()),
|
||||||
|
format_func=lambda k: builtin_options[k],
|
||||||
|
)
|
||||||
|
if "custom_code" not in st.session_state:
|
||||||
|
st.session_state.custom_code = CUSTOM_TEMPLATE
|
||||||
|
return "custom"
|
||||||
|
|
||||||
|
|
||||||
|
def render_metrics_row(result) -> None:
|
||||||
|
c1, c2, c3, c4, c5, c6 = st.columns(6)
|
||||||
|
c1.metric("Sharpe", f"{result.sharpe_ratio:.2f}")
|
||||||
|
c2.metric("Sortino", f"{result.sortino_ratio:.2f}")
|
||||||
|
c3.metric("Return", f"{result.total_return:.1%}")
|
||||||
|
c4.metric("Max DD", f"{result.max_drawdown:.1%}")
|
||||||
|
c5.metric("Win rate", f"{result.win_rate:.1%}")
|
||||||
|
c6.metric("Trades", f"{result.total_trades:,}")
|
||||||
|
|
||||||
|
|
||||||
|
def render_backtest_chart(result) -> None:
|
||||||
fig = make_subplots(
|
fig = make_subplots(
|
||||||
rows=2,
|
rows=3,
|
||||||
cols=1,
|
cols=1,
|
||||||
shared_xaxes=True,
|
shared_xaxes=True,
|
||||||
vertical_spacing=0.08,
|
vertical_spacing=0.05,
|
||||||
row_heights=[0.65, 0.35],
|
row_heights=[0.5, 0.25, 0.25],
|
||||||
subplot_titles=(f"{result.ticker} Price", "Strategy Equity"),
|
subplot_titles=(f"{result.ticker} — price & signals", "Equity curve", "Position"),
|
||||||
)
|
)
|
||||||
|
|
||||||
fig.add_trace(
|
fig.add_trace(
|
||||||
go.Scatter(x=result.price.index, y=result.price.values, name="Close", line=dict(color="#60a5fa")),
|
go.Scatter(x=result.price.index, y=result.price, name="Close", line=dict(color="#60a5fa")),
|
||||||
|
row=1,
|
||||||
|
col=1,
|
||||||
|
)
|
||||||
|
buys = result.entries & ~result.entries.shift(1, fill_value=False)
|
||||||
|
sells = result.exits & ~result.exits.shift(1, fill_value=False)
|
||||||
|
fig.add_trace(
|
||||||
|
go.Scatter(
|
||||||
|
x=result.price.index[buys],
|
||||||
|
y=result.price[buys],
|
||||||
|
mode="markers",
|
||||||
|
name="Entry",
|
||||||
|
marker=dict(color="#34d399", size=8, symbol="triangle-up"),
|
||||||
|
),
|
||||||
row=1,
|
row=1,
|
||||||
col=1,
|
col=1,
|
||||||
)
|
)
|
||||||
fig.add_trace(
|
fig.add_trace(
|
||||||
go.Scatter(
|
go.Scatter(
|
||||||
x=result.equity_curve.index,
|
x=result.price.index[sells],
|
||||||
y=result.equity_curve.values,
|
y=result.price[sells],
|
||||||
name="Equity",
|
mode="markers",
|
||||||
line=dict(color="#34d399"),
|
name="Exit",
|
||||||
|
marker=dict(color="#f87171", size=8, symbol="triangle-down"),
|
||||||
),
|
),
|
||||||
|
row=1,
|
||||||
|
col=1,
|
||||||
|
)
|
||||||
|
fig.add_trace(
|
||||||
|
go.Scatter(x=result.equity_curve.index, y=result.equity_curve, name="Equity", line=dict(color="#a78bfa")),
|
||||||
row=2,
|
row=2,
|
||||||
col=1,
|
col=1,
|
||||||
)
|
)
|
||||||
fig.update_layout(height=640, template="plotly_dark", margin=dict(l=20, r=20, t=40, b=20))
|
position = result.entries.astype(int).replace(0, -1).cumsum().clip(lower=0)
|
||||||
|
fig.add_trace(
|
||||||
|
go.Scatter(x=position.index, y=position, name="In market", fill="tozeroy", line=dict(color="#22d3ee")),
|
||||||
|
row=3,
|
||||||
|
col=1,
|
||||||
|
)
|
||||||
|
fig.update_layout(height=760, template="plotly_dark", margin=dict(l=12, r=12, t=40, b=12), showlegend=False)
|
||||||
st.plotly_chart(fig, use_container_width=True)
|
st.plotly_chart(fig, use_container_width=True)
|
||||||
|
|
||||||
|
|
||||||
|
def render_heatmap(results: pd.DataFrame, x: str, y: str, metric: str) -> None:
|
||||||
|
if x not in results.columns or y not in results.columns:
|
||||||
|
return
|
||||||
|
pivot = results.pivot_table(index=y, columns=x, values="score", aggfunc="mean")
|
||||||
|
fig = px.imshow(
|
||||||
|
pivot,
|
||||||
|
labels=dict(x=x, y=y, color="Score"),
|
||||||
|
color_continuous_scale="Viridis",
|
||||||
|
aspect="auto",
|
||||||
|
title=f"Parameter surface — {METRICS.get(metric, metric)}",
|
||||||
|
)
|
||||||
|
fig.update_layout(template="plotly_dark", height=420)
|
||||||
|
st.plotly_chart(fig, use_container_width=True)
|
||||||
|
|
||||||
|
|
||||||
|
def tab_backtest(user: str, ticker: str, init_cash: float, fees: float, strategy_key: str, source_code: str) -> None:
|
||||||
|
st.markdown("### Single run")
|
||||||
|
st.caption("Validate one parameter set before running a full scan.")
|
||||||
|
|
||||||
|
params: dict = {"init_cash": init_cash, "fees": fees}
|
||||||
|
if strategy_key != "custom":
|
||||||
|
builtin = next(b for b in list_builtins() if b.key == strategy_key)
|
||||||
|
st.info(builtin.description)
|
||||||
|
for key, default in builtin.default_params.items():
|
||||||
|
if isinstance(default, int):
|
||||||
|
params[key] = st.number_input(key, value=int(default), step=1)
|
||||||
|
else:
|
||||||
|
params[key] = st.number_input(key, value=float(default))
|
||||||
|
else:
|
||||||
|
params.update(st.session_state.get("custom_defaults", {}))
|
||||||
|
|
||||||
|
if st.button("Run backtest", type="primary"):
|
||||||
|
try:
|
||||||
|
load_ohlcv(ticker)
|
||||||
|
result = run_backtest(
|
||||||
|
ticker=ticker,
|
||||||
|
strategy_key=strategy_key,
|
||||||
|
params=params,
|
||||||
|
source_code=source_code if strategy_key == "custom" else None,
|
||||||
|
)
|
||||||
|
st.session_state["last_backtest"] = result
|
||||||
|
except Exception as exc:
|
||||||
|
capture_exception(exc)
|
||||||
|
st.error(str(exc))
|
||||||
|
|
||||||
|
result = st.session_state.get("last_backtest")
|
||||||
|
if result and result.ticker == ticker.upper():
|
||||||
|
render_metrics_row(result)
|
||||||
|
st.json(result.params)
|
||||||
|
render_backtest_chart(result)
|
||||||
|
|
||||||
|
|
||||||
|
def tab_optimize(user: str, ticker: str, init_cash: float, fees: float, strategy_key: str, source_code: str) -> None:
|
||||||
|
st.markdown("### Parameter scan")
|
||||||
|
st.caption("Exhaustively test parameter combinations and rank by objective.")
|
||||||
|
|
||||||
|
metric = st.selectbox("Objective", options=list(METRICS.keys()), format_func=lambda m: METRICS[m])
|
||||||
|
|
||||||
|
if strategy_key != "custom":
|
||||||
|
builtin = next(b for b in list_builtins() if b.key == strategy_key)
|
||||||
|
st.write("**Search space**")
|
||||||
|
st.json(builtin.param_grid)
|
||||||
|
combo_hint = len(builtin.param_grid.get("window_pool", []))
|
||||||
|
if strategy_key == "ma_crossover":
|
||||||
|
n = len(builtin.param_grid["window_pool"])
|
||||||
|
combo_hint = n * (n - 1) // 2
|
||||||
|
elif strategy_key == "rsi_reversion":
|
||||||
|
import itertools
|
||||||
|
|
||||||
|
combo_hint = sum(
|
||||||
|
1
|
||||||
|
for p, os, ob in itertools.product(
|
||||||
|
builtin.param_grid["rsi_period"],
|
||||||
|
builtin.param_grid["oversold"],
|
||||||
|
builtin.param_grid["overbought"],
|
||||||
|
)
|
||||||
|
if os < ob
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
import itertools
|
||||||
|
|
||||||
|
combo_hint = sum(1 for _ in itertools.product(*builtin.param_grid.values()))
|
||||||
|
st.write(f"~**{combo_hint:,}** combinations")
|
||||||
|
else:
|
||||||
|
st.write("Uses `PARAM_GRID` defined in your Python strategy.")
|
||||||
|
|
||||||
|
if st.button("Run optimization", type="primary"):
|
||||||
|
with st.spinner("Scanning parameter space…"):
|
||||||
|
try:
|
||||||
|
load_ohlcv(ticker)
|
||||||
|
opt = run_optimization(
|
||||||
|
ticker=ticker,
|
||||||
|
strategy_key=strategy_key,
|
||||||
|
metric=metric,
|
||||||
|
init_cash=init_cash,
|
||||||
|
fees=fees,
|
||||||
|
source_code=source_code if strategy_key == "custom" else None,
|
||||||
|
)
|
||||||
|
st.session_state["last_optimization"] = opt
|
||||||
|
except Exception as exc:
|
||||||
|
capture_exception(exc)
|
||||||
|
st.error(str(exc))
|
||||||
|
|
||||||
|
opt = st.session_state.get("last_optimization")
|
||||||
|
if opt and opt.ticker == ticker.upper() and opt.strategy_key == strategy_key:
|
||||||
|
st.success(
|
||||||
|
f"Tested **{opt.combinations_tested:,}** combinations · "
|
||||||
|
f"Best {METRICS[opt.metric]}: **{opt.best_score:.3f}**"
|
||||||
|
)
|
||||||
|
st.write("**Optimal parameters**")
|
||||||
|
st.json(opt.best_params)
|
||||||
|
|
||||||
|
top = opt.results.head(25)
|
||||||
|
st.dataframe(
|
||||||
|
top.style.format(
|
||||||
|
{
|
||||||
|
"score": "{:.3f}",
|
||||||
|
"sharpe_ratio": "{:.2f}",
|
||||||
|
"sortino_ratio": "{:.2f}",
|
||||||
|
"total_return": "{:.1%}",
|
||||||
|
"max_drawdown": "{:.1%}",
|
||||||
|
"win_rate": "{:.1%}",
|
||||||
|
},
|
||||||
|
na_rep="—",
|
||||||
|
),
|
||||||
|
use_container_width=True,
|
||||||
|
height=360,
|
||||||
|
)
|
||||||
|
|
||||||
|
if strategy_key == "ma_crossover":
|
||||||
|
render_heatmap(opt.results, "fast_window", "slow_window", opt.metric)
|
||||||
|
elif strategy_key == "rsi_reversion":
|
||||||
|
render_heatmap(opt.results, "rsi_period", "oversold", opt.metric)
|
||||||
|
|
||||||
|
if st.button("Apply best params to backtest"):
|
||||||
|
st.session_state["apply_best_params"] = opt.best_params
|
||||||
|
st.toast("Best parameters saved — switch to Backtest tab.")
|
||||||
|
|
||||||
|
|
||||||
|
def tab_editor(strategy_key: str, source_code: str) -> str:
|
||||||
|
st.markdown("### Strategy code")
|
||||||
|
st.caption(
|
||||||
|
"Write Python that defines `generate_signals(close, high, low, volume, **params)` "
|
||||||
|
"returning `(entries, exits)` booleans. Optional: `PARAM_GRID` and `DEFAULT_PARAMS`."
|
||||||
|
)
|
||||||
|
|
||||||
|
if strategy_key == "custom":
|
||||||
|
code = st.text_area("Python strategy", value=source_code, height=420, label_visibility="collapsed")
|
||||||
|
st.session_state["custom_code"] = code
|
||||||
|
return code
|
||||||
|
|
||||||
|
st.code(get_strategy_source(strategy_key), language="python")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def tab_library(user: str, ticker: str, strategy_key: str, source_code: str, params: dict) -> None:
|
||||||
|
st.markdown("### Saved strategies")
|
||||||
|
saved = list_strategies(user)
|
||||||
|
names = [s.name for s in saved]
|
||||||
|
pick = st.selectbox("Load saved", ["—"] + names)
|
||||||
|
|
||||||
|
name = st.text_input("Save as", placeholder="SPY MA sweep v1")
|
||||||
|
c1, c2 = st.columns(2)
|
||||||
|
with c1:
|
||||||
|
if st.button("Save", use_container_width=True):
|
||||||
|
if not name.strip():
|
||||||
|
st.error("Name required.")
|
||||||
|
else:
|
||||||
|
save_strategy(
|
||||||
|
user,
|
||||||
|
name.strip(),
|
||||||
|
ticker,
|
||||||
|
strategy_key,
|
||||||
|
params,
|
||||||
|
source_code if strategy_key == "custom" else None,
|
||||||
|
)
|
||||||
|
st.success(f"Saved **{name.strip()}**")
|
||||||
|
st.rerun()
|
||||||
|
with c2:
|
||||||
|
if st.button("Delete", use_container_width=True) and pick != "—":
|
||||||
|
delete_strategy(user, pick)
|
||||||
|
st.success(f"Deleted **{pick}**")
|
||||||
|
st.rerun()
|
||||||
|
|
||||||
|
if pick != "—":
|
||||||
|
loaded = load_strategy(user, pick)
|
||||||
|
if loaded and st.button("Apply loaded strategy", type="primary"):
|
||||||
|
st.session_state.active_strategy_key = loaded.strategy_key
|
||||||
|
st.session_state.active_ticker = loaded.ticker
|
||||||
|
st.session_state.active_params = loaded.params
|
||||||
|
if loaded.source_code:
|
||||||
|
st.session_state.custom_code = loaded.source_code
|
||||||
|
st.session_state.apply_best_params = {
|
||||||
|
k: v for k, v in loaded.params.items() if k not in ("init_cash", "fees")
|
||||||
|
}
|
||||||
|
st.rerun()
|
||||||
|
|
||||||
|
if saved:
|
||||||
|
st.dataframe(
|
||||||
|
pd.DataFrame(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": s.name,
|
||||||
|
"ticker": s.ticker,
|
||||||
|
"strategy": s.strategy_key,
|
||||||
|
"updated": s.created_at[:19],
|
||||||
|
}
|
||||||
|
for s in saved
|
||||||
|
]
|
||||||
|
),
|
||||||
|
use_container_width=True,
|
||||||
|
hide_index=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
user = get_current_user()
|
user = get_current_user()
|
||||||
if not user:
|
if not user:
|
||||||
return
|
return
|
||||||
|
|
||||||
st.title("QuantTrade")
|
st.title("QuantTrade")
|
||||||
st.caption("VectorBT backtests on local Parquet market data")
|
st.caption("Research desk · Python strategies · VectorBT parameter scans · Parquet data")
|
||||||
|
|
||||||
with st.sidebar:
|
with st.sidebar:
|
||||||
st.subheader("Account")
|
sidebar_account(user)
|
||||||
st.write(f"Signed in as **{user}**")
|
|
||||||
if st.button("Logout", use_container_width=True):
|
|
||||||
logout()
|
|
||||||
st.rerun()
|
|
||||||
|
|
||||||
st.divider()
|
st.divider()
|
||||||
st.subheader("Strategy")
|
ticker, init_cash, fees = sidebar_market()
|
||||||
ticker = st.selectbox(
|
if "active_ticker" in st.session_state:
|
||||||
"Ticker",
|
ticker = st.session_state.active_ticker
|
||||||
options=[t.strip().upper() for t in DEFAULT_TICKERS if t.strip()],
|
|
||||||
index=0,
|
|
||||||
)
|
|
||||||
fast_window = st.slider("Fast MA", min_value=5, max_value=100, value=20, step=1)
|
|
||||||
slow_window = st.slider("Slow MA", min_value=20, max_value=250, value=50, step=1)
|
|
||||||
init_cash = st.number_input("Initial cash", min_value=1000.0, value=10_000.0, step=1000.0)
|
|
||||||
fees = st.number_input("Fees (fraction)", min_value=0.0, max_value=0.05, value=0.001, step=0.0005)
|
|
||||||
|
|
||||||
run_clicked = st.button("Run Backtest", type="primary", use_container_width=True)
|
|
||||||
|
|
||||||
st.divider()
|
st.divider()
|
||||||
st.subheader("Saved Strategies")
|
strategy_key = sidebar_strategy_picker()
|
||||||
saved = list_strategies(user)
|
if "active_strategy_key" in st.session_state:
|
||||||
saved_names = [s.name for s in saved]
|
strategy_key = st.session_state.active_strategy_key
|
||||||
selected_name = st.selectbox("Load strategy", options=["—"] + saved_names)
|
|
||||||
|
|
||||||
strategy_name = st.text_input("Strategy name", placeholder="My SPY crossover")
|
source_code = st.session_state.get("custom_code", CUSTOM_TEMPLATE)
|
||||||
col_save, col_delete = st.columns(2)
|
params: dict = {"init_cash": init_cash, "fees": fees}
|
||||||
with col_save:
|
if strategy_key != "custom":
|
||||||
save_clicked = st.button("Save Strategy", use_container_width=True)
|
builtin = next(b for b in list_builtins() if b.key == strategy_key)
|
||||||
with col_delete:
|
params.update(builtin.default_params)
|
||||||
delete_clicked = st.button("Delete", use_container_width=True)
|
|
||||||
|
|
||||||
params = {
|
if "active_params" in st.session_state:
|
||||||
"fast_window": fast_window,
|
params.update(st.session_state.active_params)
|
||||||
"slow_window": slow_window,
|
|
||||||
"init_cash": init_cash,
|
|
||||||
"fees": fees,
|
|
||||||
}
|
|
||||||
|
|
||||||
if save_clicked:
|
best = st.session_state.pop("apply_best_params", None)
|
||||||
if not strategy_name.strip():
|
if best:
|
||||||
st.sidebar.error("Enter a strategy name before saving.")
|
params.update(best)
|
||||||
else:
|
|
||||||
save_strategy(user, strategy_name.strip(), ticker, params)
|
|
||||||
st.sidebar.success(f"Saved '{strategy_name.strip()}'.")
|
|
||||||
st.rerun()
|
|
||||||
|
|
||||||
if delete_clicked and selected_name != "—":
|
tab_bt, tab_opt, tab_code, tab_save = st.tabs(["Backtest", "Optimize", "Python", "Library"])
|
||||||
delete_strategy(user, selected_name)
|
|
||||||
st.sidebar.success(f"Deleted '{selected_name}'.")
|
|
||||||
st.rerun()
|
|
||||||
|
|
||||||
active_ticker = ticker
|
with tab_code:
|
||||||
active_params = dict(params)
|
source_code = tab_editor(strategy_key, source_code)
|
||||||
|
st.session_state.custom_code = source_code
|
||||||
|
|
||||||
if selected_name != "—":
|
with tab_save:
|
||||||
loaded = load_strategy(user, selected_name)
|
tab_library(user, ticker, strategy_key, source_code, params)
|
||||||
if loaded:
|
|
||||||
active_ticker = loaded.ticker
|
|
||||||
active_params.update(loaded.params)
|
|
||||||
st.info(f"Loaded strategy **{loaded.name}** ({loaded.ticker}). Adjust sliders or run.")
|
|
||||||
|
|
||||||
if run_clicked or selected_name != "—":
|
with tab_bt:
|
||||||
try:
|
tab_backtest(user, ticker, init_cash, fees, strategy_key, source_code)
|
||||||
load_ohlcv(active_ticker)
|
|
||||||
result = run_ma_crossover(
|
|
||||||
ticker=active_ticker,
|
|
||||||
fast_window=int(active_params["fast_window"]),
|
|
||||||
slow_window=int(active_params["slow_window"]),
|
|
||||||
init_cash=float(active_params.get("init_cash", init_cash)),
|
|
||||||
fees=float(active_params.get("fees", fees)),
|
|
||||||
)
|
|
||||||
|
|
||||||
c1, c2, c3, c4 = st.columns(4)
|
with tab_opt:
|
||||||
c1.metric("Sharpe Ratio", f"{result.sharpe_ratio:.2f}")
|
tab_optimize(user, ticker, init_cash, fees, strategy_key, source_code)
|
||||||
c2.metric("Max Drawdown", f"{result.max_drawdown:.1%}")
|
|
||||||
c3.metric("Total Return", f"{result.total_return:.1%}")
|
|
||||||
c4.metric("Bars", f"{len(result.price):,}")
|
|
||||||
|
|
||||||
render_equity_chart(result)
|
|
||||||
|
|
||||||
with st.expander("Raw stats"):
|
|
||||||
st.write(
|
|
||||||
pd.DataFrame(
|
|
||||||
{
|
|
||||||
"Metric": ["Ticker", "Fast MA", "Slow MA", "Sharpe", "Max DD", "Return"],
|
|
||||||
"Value": [
|
|
||||||
result.ticker,
|
|
||||||
result.fast_window,
|
|
||||||
result.slow_window,
|
|
||||||
result.sharpe_ratio,
|
|
||||||
result.max_drawdown,
|
|
||||||
result.total_return,
|
|
||||||
],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
except FileNotFoundError:
|
|
||||||
st.warning(
|
|
||||||
f"No Parquet data for **{active_ticker}** yet. "
|
|
||||||
"Wait for the harvester seed job or check container logs."
|
|
||||||
)
|
|
||||||
except ValueError as exc:
|
|
||||||
st.error(str(exc))
|
|
||||||
except Exception as exc:
|
|
||||||
capture_exception(exc)
|
|
||||||
st.error("Backtest failed. The error was reported to Bugsink.")
|
|
||||||
st.exception(exc)
|
|
||||||
else:
|
|
||||||
st.info("Configure parameters in the sidebar and click **Run Backtest**.")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
|
||||||
89
backtest.py
89
backtest.py
|
|
@ -1,88 +1,5 @@
|
||||||
"""VectorBT backtest engine reading local Parquet OHLCV data."""
|
"""Backward-compatible exports."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from engine import BacktestResult, load_ohlcv, run_backtest
|
||||||
|
|
||||||
import os
|
__all__ = ["BacktestResult", "load_ohlcv", "run_backtest"]
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pandas as pd
|
|
||||||
import vectorbt as vbt
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class BacktestResult:
|
|
||||||
ticker: str
|
|
||||||
fast_window: int
|
|
||||||
slow_window: int
|
|
||||||
sharpe_ratio: float
|
|
||||||
max_drawdown: float
|
|
||||||
total_return: float
|
|
||||||
equity_curve: pd.Series
|
|
||||||
price: pd.Series
|
|
||||||
|
|
||||||
|
|
||||||
def parquet_dir() -> Path:
|
|
||||||
return Path(os.environ.get("PARQUET_DIR", "/data/parquet"))
|
|
||||||
|
|
||||||
|
|
||||||
def load_ohlcv(ticker: str) -> pd.DataFrame:
|
|
||||||
path = parquet_dir() / f"{ticker.upper()}.parquet"
|
|
||||||
if not path.exists():
|
|
||||||
raise FileNotFoundError(f"No Parquet file for {ticker.upper()} at {path}")
|
|
||||||
|
|
||||||
df = pd.read_parquet(path)
|
|
||||||
if "Date" in df.columns:
|
|
||||||
df = df.set_index("Date")
|
|
||||||
df.index = pd.to_datetime(df.index)
|
|
||||||
df = df.sort_index()
|
|
||||||
return df
|
|
||||||
|
|
||||||
|
|
||||||
def run_ma_crossover(
|
|
||||||
ticker: str,
|
|
||||||
fast_window: int,
|
|
||||||
slow_window: int,
|
|
||||||
init_cash: float = 10_000.0,
|
|
||||||
fees: float = 0.001,
|
|
||||||
) -> BacktestResult:
|
|
||||||
if fast_window >= slow_window:
|
|
||||||
raise ValueError("Fast MA window must be smaller than slow MA window")
|
|
||||||
|
|
||||||
ohlcv = load_ohlcv(ticker)
|
|
||||||
close = ohlcv["Close"].astype(float)
|
|
||||||
|
|
||||||
fast_ma = vbt.MA.run(close, fast_window, short_name="fast")
|
|
||||||
slow_ma = vbt.MA.run(close, slow_window, short_name="slow")
|
|
||||||
|
|
||||||
entries = fast_ma.ma_crossed_above(slow_ma)
|
|
||||||
exits = fast_ma.ma_crossed_below(slow_ma)
|
|
||||||
|
|
||||||
portfolio = vbt.Portfolio.from_signals(
|
|
||||||
close,
|
|
||||||
entries=entries,
|
|
||||||
exits=exits,
|
|
||||||
init_cash=init_cash,
|
|
||||||
fees=fees,
|
|
||||||
freq="1D",
|
|
||||||
)
|
|
||||||
|
|
||||||
stats = portfolio.stats()
|
|
||||||
sharpe = float(stats.get("Sharpe Ratio", 0.0) or 0.0)
|
|
||||||
max_dd = float(stats.get("Max Drawdown [%]", 0.0) or 0.0) / 100.0
|
|
||||||
total_return = float(stats.get("Total Return [%]", 0.0) or 0.0) / 100.0
|
|
||||||
|
|
||||||
equity = portfolio.value()
|
|
||||||
if isinstance(equity, pd.DataFrame):
|
|
||||||
equity = equity.iloc[:, 0]
|
|
||||||
|
|
||||||
return BacktestResult(
|
|
||||||
ticker=ticker.upper(),
|
|
||||||
fast_window=fast_window,
|
|
||||||
slow_window=slow_window,
|
|
||||||
sharpe_ratio=sharpe,
|
|
||||||
max_drawdown=max_dd,
|
|
||||||
total_return=total_return,
|
|
||||||
equity_curve=equity,
|
|
||||||
price=close,
|
|
||||||
)
|
|
||||||
|
|
|
||||||
185
engine.py
Normal file
185
engine.py
Normal file
|
|
@ -0,0 +1,185 @@
|
||||||
|
"""Backtest and optimization engine."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
import vectorbt as vbt
|
||||||
|
|
||||||
|
from strategies.executor import (
|
||||||
|
CUSTOM_TEMPLATE,
|
||||||
|
StrategyError,
|
||||||
|
load_custom_strategy,
|
||||||
|
optimize_custom,
|
||||||
|
run_builtin_signals,
|
||||||
|
run_custom_signals,
|
||||||
|
)
|
||||||
|
from strategies.registry import BuiltinStrategy, get_builtin
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class BacktestResult:
|
||||||
|
ticker: str
|
||||||
|
strategy_key: str
|
||||||
|
params: dict[str, Any]
|
||||||
|
sharpe_ratio: float
|
||||||
|
sortino_ratio: float
|
||||||
|
max_drawdown: float
|
||||||
|
total_return: float
|
||||||
|
win_rate: float
|
||||||
|
total_trades: int
|
||||||
|
equity_curve: pd.Series
|
||||||
|
price: pd.Series
|
||||||
|
entries: pd.Series
|
||||||
|
exits: pd.Series
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class OptimizationResult:
|
||||||
|
ticker: str
|
||||||
|
strategy_key: str
|
||||||
|
metric: str
|
||||||
|
best_params: dict[str, Any]
|
||||||
|
best_score: float
|
||||||
|
results: pd.DataFrame
|
||||||
|
combinations_tested: int
|
||||||
|
|
||||||
|
|
||||||
|
def parquet_dir() -> Path:
|
||||||
|
return Path(os.environ.get("PARQUET_DIR", "/data/parquet"))
|
||||||
|
|
||||||
|
|
||||||
|
def load_ohlcv(ticker: str) -> pd.DataFrame:
|
||||||
|
path = parquet_dir() / f"{ticker.upper()}.parquet"
|
||||||
|
if not path.exists():
|
||||||
|
raise FileNotFoundError(f"No Parquet file for {ticker.upper()} at {path}")
|
||||||
|
|
||||||
|
df = pd.read_parquet(path)
|
||||||
|
if "Date" in df.columns:
|
||||||
|
df = df.set_index("Date")
|
||||||
|
df.index = pd.to_datetime(df.index)
|
||||||
|
return df.sort_index()
|
||||||
|
|
||||||
|
|
||||||
|
from metrics import run_from_signals, safe_float as _safe_float
|
||||||
|
def _portfolio_from_signals(
|
||||||
|
close: pd.Series,
|
||||||
|
entries: pd.Series,
|
||||||
|
exits: pd.Series,
|
||||||
|
init_cash: float,
|
||||||
|
fees: float,
|
||||||
|
) -> vbt.Portfolio:
|
||||||
|
return vbt.Portfolio.from_signals(
|
||||||
|
close,
|
||||||
|
entries=entries,
|
||||||
|
exits=exits,
|
||||||
|
init_cash=init_cash,
|
||||||
|
fees=fees,
|
||||||
|
freq="1D",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run_backtest(
|
||||||
|
ticker: str,
|
||||||
|
strategy_key: str,
|
||||||
|
params: dict[str, Any],
|
||||||
|
source_code: str | None = None,
|
||||||
|
init_cash: float = 10_000.0,
|
||||||
|
fees: float = 0.001,
|
||||||
|
) -> BacktestResult:
|
||||||
|
ohlcv = load_ohlcv(ticker)
|
||||||
|
close = ohlcv["Close"].astype(float)
|
||||||
|
|
||||||
|
runtime_params = {k: v for k, v in params.items() if k not in ("init_cash", "fees")}
|
||||||
|
init_cash = float(params.get("init_cash", init_cash))
|
||||||
|
fees = float(params.get("fees", fees))
|
||||||
|
|
||||||
|
if strategy_key == "custom":
|
||||||
|
if not source_code:
|
||||||
|
raise StrategyError("Custom strategy requires source_code")
|
||||||
|
entries, exits, _, merged = run_custom_signals(source_code, ohlcv, runtime_params)
|
||||||
|
params = merged
|
||||||
|
else:
|
||||||
|
builtin = get_builtin(strategy_key)
|
||||||
|
entries, exits = run_builtin_signals(builtin, ohlcv, runtime_params)
|
||||||
|
params = {**builtin.default_params, **runtime_params}
|
||||||
|
|
||||||
|
portfolio = _portfolio_from_signals(close, entries, exits, init_cash, fees)
|
||||||
|
stats = portfolio.stats()
|
||||||
|
equity = portfolio.value()
|
||||||
|
if isinstance(equity, pd.DataFrame):
|
||||||
|
equity = equity.iloc[:, 0]
|
||||||
|
|
||||||
|
return BacktestResult(
|
||||||
|
ticker=ticker.upper(),
|
||||||
|
strategy_key=strategy_key,
|
||||||
|
params=params,
|
||||||
|
sharpe_ratio=_safe_float(stats.get("Sharpe Ratio")),
|
||||||
|
sortino_ratio=_safe_float(stats.get("Sortino Ratio")),
|
||||||
|
max_drawdown=_safe_float(stats.get("Max Drawdown [%]")) / 100.0,
|
||||||
|
total_return=_safe_float(stats.get("Total Return [%]")) / 100.0,
|
||||||
|
win_rate=_safe_float(stats.get("Win Rate [%]")) / 100.0,
|
||||||
|
total_trades=int(stats.get("Total Trades", 0) or 0),
|
||||||
|
equity_curve=equity,
|
||||||
|
price=close,
|
||||||
|
entries=entries,
|
||||||
|
exits=exits,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run_optimization(
|
||||||
|
ticker: str,
|
||||||
|
strategy_key: str,
|
||||||
|
metric: str = "sharpe_ratio",
|
||||||
|
init_cash: float = 10_000.0,
|
||||||
|
fees: float = 0.001,
|
||||||
|
source_code: str | None = None,
|
||||||
|
param_grid: dict | None = None,
|
||||||
|
) -> OptimizationResult:
|
||||||
|
ohlcv = load_ohlcv(ticker)
|
||||||
|
close = ohlcv["Close"].astype(float)
|
||||||
|
|
||||||
|
if strategy_key == "custom":
|
||||||
|
if not source_code:
|
||||||
|
raise StrategyError("Custom strategy requires source_code")
|
||||||
|
results = optimize_custom(
|
||||||
|
source_code,
|
||||||
|
close,
|
||||||
|
ohlcv,
|
||||||
|
init_cash=init_cash,
|
||||||
|
fees=fees,
|
||||||
|
metric=metric,
|
||||||
|
param_grid=param_grid,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
builtin = get_builtin(strategy_key)
|
||||||
|
results = builtin.optimize(close, init_cash, fees, metric, grid_override=param_grid)
|
||||||
|
|
||||||
|
if results.empty:
|
||||||
|
raise StrategyError("Optimization produced no valid parameter combinations.")
|
||||||
|
|
||||||
|
best = results.iloc[0]
|
||||||
|
param_cols = [c for c in results.columns if c not in {
|
||||||
|
"score", "sharpe_ratio", "sortino_ratio", "max_drawdown", "total_return", "win_rate", "total_trades",
|
||||||
|
}]
|
||||||
|
best_params = {col: best[col] for col in param_cols}
|
||||||
|
|
||||||
|
return OptimizationResult(
|
||||||
|
ticker=ticker.upper(),
|
||||||
|
strategy_key=strategy_key,
|
||||||
|
metric=metric,
|
||||||
|
best_params=best_params,
|
||||||
|
best_score=float(best["score"]),
|
||||||
|
results=results,
|
||||||
|
combinations_tested=len(results),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_strategy_source(strategy_key: str, source_code: str | None = None) -> str:
|
||||||
|
if strategy_key == "custom":
|
||||||
|
return source_code or CUSTOM_TEMPLATE
|
||||||
|
return get_builtin(strategy_key).source_code
|
||||||
62
metrics.py
Normal file
62
metrics.py
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
"""Portfolio metric helpers shared by engine and optimizers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
import vectorbt as vbt
|
||||||
|
|
||||||
|
|
||||||
|
def safe_float(value: Any) -> float:
|
||||||
|
try:
|
||||||
|
if value is None or (isinstance(value, float) and value != value):
|
||||||
|
return 0.0
|
||||||
|
return float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def run_from_signals(
|
||||||
|
close: pd.Series,
|
||||||
|
entries: pd.Series,
|
||||||
|
exits: pd.Series,
|
||||||
|
init_cash: float,
|
||||||
|
fees: float,
|
||||||
|
params: dict[str, Any],
|
||||||
|
metric: str = "sharpe_ratio",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
portfolio = vbt.Portfolio.from_signals(
|
||||||
|
close,
|
||||||
|
entries=entries,
|
||||||
|
exits=exits,
|
||||||
|
init_cash=init_cash,
|
||||||
|
fees=fees,
|
||||||
|
freq="1D",
|
||||||
|
)
|
||||||
|
stats = portfolio.stats()
|
||||||
|
|
||||||
|
sharpe = safe_float(stats.get("Sharpe Ratio"))
|
||||||
|
sortino = safe_float(stats.get("Sortino Ratio"))
|
||||||
|
max_dd = safe_float(stats.get("Max Drawdown [%]")) / 100.0
|
||||||
|
total_return = safe_float(stats.get("Total Return [%]")) / 100.0
|
||||||
|
win_rate = safe_float(stats.get("Win Rate [%]")) / 100.0
|
||||||
|
total_trades = int(stats.get("Total Trades", 0) or 0)
|
||||||
|
|
||||||
|
score_map = {
|
||||||
|
"sharpe_ratio": sharpe,
|
||||||
|
"sortino_ratio": sortino,
|
||||||
|
"total_return": total_return,
|
||||||
|
"max_drawdown": -max_dd,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
**params,
|
||||||
|
"sharpe_ratio": sharpe,
|
||||||
|
"sortino_ratio": sortino,
|
||||||
|
"max_drawdown": max_dd,
|
||||||
|
"total_return": total_return,
|
||||||
|
"win_rate": win_rate,
|
||||||
|
"total_trades": total_trades,
|
||||||
|
"score": score_map.get(metric, sharpe),
|
||||||
|
}
|
||||||
6
requirements-app.txt
Normal file
6
requirements-app.txt
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
-r requirements-harvester.txt
|
||||||
|
streamlit>=1.32.0
|
||||||
|
vectorbt>=0.26.0,<1.0.0
|
||||||
|
plotly>=5.18.0
|
||||||
|
authlib>=1.3.0
|
||||||
|
requests>=2.31.0
|
||||||
5
requirements-harvester.txt
Normal file
5
requirements-harvester.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
yfinance>=0.2.36
|
||||||
|
pandas>=2.1.0
|
||||||
|
numpy>=1.26.0
|
||||||
|
pyarrow>=15.0.0
|
||||||
|
sentry-sdk>=2.0.0
|
||||||
|
|
@ -1,10 +1,4 @@
|
||||||
streamlit>=1.32.0
|
# App image: pip install -r requirements-app.txt
|
||||||
vectorbt>=0.26.0
|
# Harvester image: pip install -r requirements-harvester.txt (no VectorBT/Streamlit)
|
||||||
yfinance>=0.2.36
|
|
||||||
pandas>=2.1.0
|
-r requirements-app.txt
|
||||||
numpy>=1.26.0
|
|
||||||
pyarrow>=15.0.0
|
|
||||||
plotly>=5.18.0
|
|
||||||
sentry-sdk>=2.0.0
|
|
||||||
authlib>=1.3.0
|
|
||||||
requests>=2.31.0
|
|
||||||
|
|
|
||||||
5
strategies/__init__.py
Normal file
5
strategies/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
"""Strategy registry and execution."""
|
||||||
|
|
||||||
|
from strategies.registry import BUILTIN_STRATEGIES, get_builtin, list_builtins
|
||||||
|
|
||||||
|
__all__ = ["BUILTIN_STRATEGIES", "get_builtin", "list_builtins"]
|
||||||
0
strategies/builtin/__init__.py
Normal file
0
strategies/builtin/__init__.py
Normal file
93
strategies/builtin/ma_crossover.py
Normal file
93
strategies/builtin/ma_crossover.py
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
"""Moving-average crossover — predefined Python strategy."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
import vectorbt as vbt
|
||||||
|
|
||||||
|
STRATEGY_KEY = "ma_crossover"
|
||||||
|
DISPLAY_NAME = "MA Crossover"
|
||||||
|
DESCRIPTION = "Enter when fast MA crosses above slow MA; exit on cross below."
|
||||||
|
|
||||||
|
PARAM_GRID = {
|
||||||
|
"window_pool": list(range(5, 101, 5)),
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULT_PARAMS = {
|
||||||
|
"fast_window": 20,
|
||||||
|
"slow_window": 50,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def generate_signals(
|
||||||
|
close: pd.Series,
|
||||||
|
high: pd.Series,
|
||||||
|
low: pd.Series,
|
||||||
|
volume: pd.Series,
|
||||||
|
fast_window: int = 20,
|
||||||
|
slow_window: int = 50,
|
||||||
|
**_kwargs,
|
||||||
|
) -> tuple[pd.Series, pd.Series]:
|
||||||
|
if fast_window >= slow_window:
|
||||||
|
raise ValueError("fast_window must be smaller than slow_window")
|
||||||
|
|
||||||
|
fast_ma = vbt.MA.run(close, fast_window).ma
|
||||||
|
slow_ma = vbt.MA.run(close, slow_window).ma
|
||||||
|
entries = fast_ma.vbt.crossed_above(slow_ma).fillna(False)
|
||||||
|
exits = fast_ma.vbt.crossed_below(slow_ma).fillna(False)
|
||||||
|
return entries, exits
|
||||||
|
|
||||||
|
|
||||||
|
def optimize_vectorized(
|
||||||
|
close: pd.Series,
|
||||||
|
window_pool: list[int] | None = None,
|
||||||
|
init_cash: float = 10_000.0,
|
||||||
|
fees: float = 0.001,
|
||||||
|
metric: str = "sharpe_ratio",
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
"""VectorBT combinatorial scan across all fast/slow pairs (fast < slow)."""
|
||||||
|
pool = np.array(window_pool or PARAM_GRID["window_pool"], dtype=int)
|
||||||
|
fast_ma, slow_ma = vbt.MA.run_combs(close, pool, r=2, short_names=["fast", "slow"])
|
||||||
|
entries = fast_ma.ma_crossed_above(slow_ma)
|
||||||
|
exits = fast_ma.ma_crossed_below(slow_ma)
|
||||||
|
|
||||||
|
portfolio = vbt.Portfolio.from_signals(
|
||||||
|
close,
|
||||||
|
entries=entries,
|
||||||
|
exits=exits,
|
||||||
|
init_cash=init_cash,
|
||||||
|
fees=fees,
|
||||||
|
freq="1D",
|
||||||
|
)
|
||||||
|
|
||||||
|
metric_fn = {
|
||||||
|
"sharpe_ratio": portfolio.sharpe_ratio,
|
||||||
|
"sortino_ratio": portfolio.sortino_ratio,
|
||||||
|
"total_return": portfolio.total_return,
|
||||||
|
"max_drawdown": lambda **_: portfolio.max_drawdown(group_by=False),
|
||||||
|
}.get(metric, portfolio.sharpe_ratio)
|
||||||
|
|
||||||
|
scores = metric_fn(group_by=False)
|
||||||
|
max_dd = portfolio.max_drawdown(group_by=False)
|
||||||
|
total_ret = portfolio.total_return(group_by=False)
|
||||||
|
sortino = portfolio.sortino_ratio(group_by=False)
|
||||||
|
trades = portfolio.trades.count(group_by=False)
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for idx, score in scores.items():
|
||||||
|
fast_w, slow_w = int(idx[0]), int(idx[1])
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"fast_window": fast_w,
|
||||||
|
"slow_window": slow_w,
|
||||||
|
"score": float(score) if score == score else float("nan"),
|
||||||
|
"sharpe_ratio": float(scores[idx]) if scores[idx] == scores[idx] else float("nan"),
|
||||||
|
"sortino_ratio": float(sortino[idx]) if sortino[idx] == sortino[idx] else float("nan"),
|
||||||
|
"total_return": float(total_ret[idx]),
|
||||||
|
"max_drawdown": float(max_dd[idx]),
|
||||||
|
"total_trades": int(trades[idx]),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return pd.DataFrame(rows).sort_values("score", ascending=False, na_position="last")
|
||||||
79
strategies/builtin/rsi_reversion.py
Normal file
79
strategies/builtin/rsi_reversion.py
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
"""RSI mean-reversion — predefined Python strategy."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import itertools
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
import vectorbt as vbt
|
||||||
|
|
||||||
|
STRATEGY_KEY = "rsi_reversion"
|
||||||
|
DISPLAY_NAME = "RSI Mean Reversion"
|
||||||
|
DESCRIPTION = "Buy when RSI is oversold; sell when RSI is overbought."
|
||||||
|
|
||||||
|
PARAM_GRID = {
|
||||||
|
"rsi_period": list(range(7, 22, 2)),
|
||||||
|
"oversold": list(range(20, 36, 5)),
|
||||||
|
"overbought": list(range(65, 81, 5)),
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULT_PARAMS = {
|
||||||
|
"rsi_period": 14,
|
||||||
|
"oversold": 30,
|
||||||
|
"overbought": 70,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def generate_signals(
|
||||||
|
close: pd.Series,
|
||||||
|
high: pd.Series,
|
||||||
|
low: pd.Series,
|
||||||
|
volume: pd.Series,
|
||||||
|
rsi_period: int = 14,
|
||||||
|
oversold: float = 30,
|
||||||
|
overbought: float = 70,
|
||||||
|
**_kwargs,
|
||||||
|
) -> tuple[pd.Series, pd.Series]:
|
||||||
|
if oversold >= overbought:
|
||||||
|
raise ValueError("oversold must be less than overbought")
|
||||||
|
|
||||||
|
rsi = vbt.RSI.run(close, window=rsi_period).rsi
|
||||||
|
entries = (rsi < oversold).fillna(False)
|
||||||
|
exits = (rsi > overbought).fillna(False)
|
||||||
|
return entries, exits
|
||||||
|
|
||||||
|
|
||||||
|
def optimize_grid(
|
||||||
|
close: pd.Series,
|
||||||
|
param_grid: dict | None = None,
|
||||||
|
init_cash: float = 10_000.0,
|
||||||
|
fees: float = 0.001,
|
||||||
|
metric: str = "sharpe_ratio",
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
"""Exhaustive grid over RSI parameter space."""
|
||||||
|
from metrics import run_from_signals
|
||||||
|
|
||||||
|
grid = param_grid or PARAM_GRID
|
||||||
|
keys = list(grid.keys())
|
||||||
|
rows = []
|
||||||
|
|
||||||
|
for values in itertools.product(*(grid[k] for k in keys)):
|
||||||
|
params = dict(zip(keys, values))
|
||||||
|
if params["oversold"] >= params["overbought"]:
|
||||||
|
continue
|
||||||
|
entries, exits = generate_signals(close, close, close, close, **params)
|
||||||
|
result = run_from_signals(
|
||||||
|
close=close,
|
||||||
|
entries=entries,
|
||||||
|
exits=exits,
|
||||||
|
init_cash=init_cash,
|
||||||
|
fees=fees,
|
||||||
|
params=params,
|
||||||
|
metric=metric,
|
||||||
|
)
|
||||||
|
rows.append(result)
|
||||||
|
|
||||||
|
frame = pd.DataFrame(rows)
|
||||||
|
if frame.empty:
|
||||||
|
return frame
|
||||||
|
return frame.sort_values("score", ascending=False, na_position="last")
|
||||||
178
strategies/executor.py
Normal file
178
strategies/executor.py
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
"""Execute builtin and user-authored Python strategies."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import itertools
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
import vectorbt as vbt
|
||||||
|
|
||||||
|
from strategies.registry import BuiltinStrategy, get_builtin
|
||||||
|
|
||||||
|
SAFE_GLOBALS: dict[str, Any] = {
|
||||||
|
"__builtins__": {
|
||||||
|
"range": range,
|
||||||
|
"len": len,
|
||||||
|
"min": min,
|
||||||
|
"max": max,
|
||||||
|
"abs": abs,
|
||||||
|
"float": float,
|
||||||
|
"int": int,
|
||||||
|
"bool": bool,
|
||||||
|
"list": list,
|
||||||
|
"dict": dict,
|
||||||
|
"tuple": tuple,
|
||||||
|
"zip": zip,
|
||||||
|
"enumerate": enumerate,
|
||||||
|
"sum": sum,
|
||||||
|
"round": round,
|
||||||
|
},
|
||||||
|
"np": np,
|
||||||
|
"pd": pd,
|
||||||
|
"vbt": vbt,
|
||||||
|
}
|
||||||
|
|
||||||
|
CUSTOM_TEMPLATE = '''import pandas as pd
|
||||||
|
import vectorbt as vbt
|
||||||
|
|
||||||
|
# Optional: define PARAM_GRID for optimization scans
|
||||||
|
PARAM_GRID = {
|
||||||
|
"fast_window": list(range(10, 41, 5)),
|
||||||
|
"slow_window": list(range(50, 151, 10)),
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULT_PARAMS = {
|
||||||
|
"fast_window": 20,
|
||||||
|
"slow_window": 50,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def generate_signals(close, high, low, volume, **params):
|
||||||
|
"""Return (entries, exits) as boolean Series aligned to close."""
|
||||||
|
fast_w = int(params.get("fast_window", 20))
|
||||||
|
slow_w = int(params.get("slow_window", 50))
|
||||||
|
if fast_w >= slow_w:
|
||||||
|
raise ValueError("fast_window must be < slow_window")
|
||||||
|
|
||||||
|
fast_ma = vbt.MA.run(close, fast_w, short_name="fast")
|
||||||
|
slow_ma = vbt.MA.run(close, slow_w, short_name="slow")
|
||||||
|
entries = fast_ma.ma_crossed_above(slow_ma).fillna(False)
|
||||||
|
exits = fast_ma.ma_crossed_below(slow_ma).fillna(False)
|
||||||
|
return entries, exits
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
class StrategyError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def load_custom_strategy(source_code: str) -> tuple[Any, dict, dict]:
|
||||||
|
namespace: dict[str, Any] = {}
|
||||||
|
try:
|
||||||
|
exec(source_code, SAFE_GLOBALS, namespace)
|
||||||
|
except Exception as exc:
|
||||||
|
raise StrategyError(f"Strategy compile error: {exc}") from exc
|
||||||
|
|
||||||
|
generate = namespace.get("generate_signals")
|
||||||
|
if not callable(generate):
|
||||||
|
raise StrategyError("Custom strategy must define generate_signals(close, high, low, volume, **params)")
|
||||||
|
|
||||||
|
param_grid = namespace.get("PARAM_GRID", {})
|
||||||
|
default_params = namespace.get("DEFAULT_PARAMS", {})
|
||||||
|
if not isinstance(param_grid, dict):
|
||||||
|
raise StrategyError("PARAM_GRID must be a dict of param -> list of values")
|
||||||
|
if not isinstance(default_params, dict):
|
||||||
|
raise StrategyError("DEFAULT_PARAMS must be a dict")
|
||||||
|
|
||||||
|
return generate, param_grid, default_params
|
||||||
|
|
||||||
|
|
||||||
|
def run_builtin_signals(
|
||||||
|
builtin: BuiltinStrategy,
|
||||||
|
ohlcv: pd.DataFrame,
|
||||||
|
params: dict[str, Any],
|
||||||
|
) -> tuple[pd.Series, pd.Series]:
|
||||||
|
close = ohlcv["Close"].astype(float)
|
||||||
|
high = ohlcv.get("High", close).astype(float)
|
||||||
|
low = ohlcv.get("Low", close).astype(float)
|
||||||
|
volume = ohlcv.get("Volume", pd.Series(0, index=close.index)).astype(float)
|
||||||
|
entries, exits = builtin.generate_signals(close, high, low, volume, **params)
|
||||||
|
return _coerce_signals(entries, exits, close.index)
|
||||||
|
|
||||||
|
|
||||||
|
def run_custom_signals(
|
||||||
|
source_code: str,
|
||||||
|
ohlcv: pd.DataFrame,
|
||||||
|
params: dict[str, Any],
|
||||||
|
) -> tuple[pd.Series, pd.Series, dict, dict]:
|
||||||
|
generate, param_grid, defaults = load_custom_strategy(source_code)
|
||||||
|
merged = {**defaults, **params}
|
||||||
|
close = ohlcv["Close"].astype(float)
|
||||||
|
high = ohlcv.get("High", close).astype(float)
|
||||||
|
low = ohlcv.get("Low", close).astype(float)
|
||||||
|
volume = ohlcv.get("Volume", pd.Series(0, index=close.index)).astype(float)
|
||||||
|
entries, exits = generate(close, high, low, volume, **merged)
|
||||||
|
return _coerce_signals(entries, exits, close.index), param_grid, merged
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_signals(entries, exits, index: pd.Index) -> tuple[pd.Series, pd.Series]:
|
||||||
|
e = pd.Series(entries, index=index).fillna(False).astype(bool)
|
||||||
|
x = pd.Series(exits, index=index).fillna(False).astype(bool)
|
||||||
|
return e, x
|
||||||
|
|
||||||
|
|
||||||
|
def optimize_custom(
|
||||||
|
source_code: str,
|
||||||
|
close: pd.Series,
|
||||||
|
ohlcv: pd.DataFrame,
|
||||||
|
init_cash: float,
|
||||||
|
fees: float,
|
||||||
|
metric: str,
|
||||||
|
param_grid: dict | None = None,
|
||||||
|
max_combos: int = 2_500,
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
from metrics import run_from_signals
|
||||||
|
|
||||||
|
generate, grid, defaults = load_custom_strategy(source_code)
|
||||||
|
grid = param_grid or grid
|
||||||
|
if not grid:
|
||||||
|
raise StrategyError("Define PARAM_GRID in your strategy to run optimization.")
|
||||||
|
|
||||||
|
keys = list(grid.keys())
|
||||||
|
combos = list(itertools.product(*(grid[k] for k in keys)))
|
||||||
|
if len(combos) > max_combos:
|
||||||
|
raise StrategyError(
|
||||||
|
f"Grid has {len(combos):,} combinations (max {max_combos:,}). "
|
||||||
|
"Widen step sizes or narrow ranges in PARAM_GRID."
|
||||||
|
)
|
||||||
|
|
||||||
|
high = ohlcv.get("High", close).astype(float)
|
||||||
|
low = ohlcv.get("Low", close).astype(float)
|
||||||
|
volume = ohlcv.get("Volume", pd.Series(0, index=close.index)).astype(float)
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for values in combos:
|
||||||
|
params = {**defaults, **dict(zip(keys, values))}
|
||||||
|
try:
|
||||||
|
entries, exits = generate(close, high, low, volume, **params)
|
||||||
|
entries, exits = _coerce_signals(entries, exits, close.index)
|
||||||
|
rows.append(
|
||||||
|
run_from_signals(
|
||||||
|
close=close,
|
||||||
|
entries=entries,
|
||||||
|
exits=exits,
|
||||||
|
init_cash=init_cash,
|
||||||
|
fees=fees,
|
||||||
|
params=params,
|
||||||
|
metric=metric,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
frame = pd.DataFrame(rows)
|
||||||
|
if frame.empty:
|
||||||
|
return frame
|
||||||
|
return frame.sort_values("score", ascending=False, na_position="last")
|
||||||
72
strategies/registry.py
Normal file
72
strategies/registry.py
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
"""Builtin strategy registry."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from strategies.builtin import ma_crossover, rsi_reversion
|
||||||
|
|
||||||
|
SignalFn = Callable[..., tuple[Any, Any]]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class BuiltinStrategy:
|
||||||
|
key: str
|
||||||
|
display_name: str
|
||||||
|
description: str
|
||||||
|
module: Any
|
||||||
|
generate_signals: SignalFn
|
||||||
|
default_params: dict[str, Any]
|
||||||
|
param_grid: dict[str, list[Any]]
|
||||||
|
source_code: str
|
||||||
|
|
||||||
|
def optimize(self, close, init_cash: float, fees: float, metric: str, grid_override: dict | None = None):
|
||||||
|
if self.key == "ma_crossover":
|
||||||
|
pool = (grid_override or {}).get("window_pool", self.param_grid.get("window_pool"))
|
||||||
|
return self.module.optimize_vectorized(
|
||||||
|
close,
|
||||||
|
window_pool=pool,
|
||||||
|
init_cash=init_cash,
|
||||||
|
fees=fees,
|
||||||
|
metric=metric,
|
||||||
|
)
|
||||||
|
if self.key == "rsi_reversion":
|
||||||
|
return self.module.optimize_grid(
|
||||||
|
close,
|
||||||
|
param_grid=grid_override or self.param_grid,
|
||||||
|
init_cash=init_cash,
|
||||||
|
fees=fees,
|
||||||
|
metric=metric,
|
||||||
|
)
|
||||||
|
raise NotImplementedError(f"No optimizer for {self.key}")
|
||||||
|
|
||||||
|
|
||||||
|
def _register(module) -> BuiltinStrategy:
|
||||||
|
return BuiltinStrategy(
|
||||||
|
key=module.STRATEGY_KEY,
|
||||||
|
display_name=module.DISPLAY_NAME,
|
||||||
|
description=module.DESCRIPTION,
|
||||||
|
module=module,
|
||||||
|
generate_signals=module.generate_signals,
|
||||||
|
default_params=dict(module.DEFAULT_PARAMS),
|
||||||
|
param_grid=dict(module.PARAM_GRID),
|
||||||
|
source_code=inspect.getsource(module),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
BUILTIN_STRATEGIES: dict[str, BuiltinStrategy] = {
|
||||||
|
ma_crossover.STRATEGY_KEY: _register(ma_crossover),
|
||||||
|
rsi_reversion.STRATEGY_KEY: _register(rsi_reversion),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def list_builtins() -> list[BuiltinStrategy]:
|
||||||
|
return list(BUILTIN_STRATEGIES.values())
|
||||||
|
|
||||||
|
|
||||||
|
def get_builtin(key: str) -> BuiltinStrategy:
|
||||||
|
if key not in BUILTIN_STRATEGIES:
|
||||||
|
raise KeyError(f"Unknown builtin strategy: {key}")
|
||||||
|
return BUILTIN_STRATEGIES[key]
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""SQLite persistence for user-saved strategies."""
|
"""SQLite persistence for user-saved strategies (builtin + custom Python)."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -17,7 +17,9 @@ class SavedStrategy:
|
||||||
username: str
|
username: str
|
||||||
name: str
|
name: str
|
||||||
ticker: str
|
ticker: str
|
||||||
|
strategy_key: str
|
||||||
params: dict[str, Any]
|
params: dict[str, Any]
|
||||||
|
source_code: str | None
|
||||||
created_at: str
|
created_at: str
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -25,6 +27,16 @@ def _db_path() -> str:
|
||||||
return os.environ.get("STRATEGY_DB_PATH", "/data/strategies/strategies.db")
|
return os.environ.get("STRATEGY_DB_PATH", "/data/strategies/strategies.db")
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate(conn: sqlite3.Connection) -> None:
|
||||||
|
cols = {row[1] for row in conn.execute("PRAGMA table_info(strategies)")}
|
||||||
|
if "strategy_key" not in cols:
|
||||||
|
conn.execute(
|
||||||
|
"ALTER TABLE strategies ADD COLUMN strategy_key TEXT NOT NULL DEFAULT 'ma_crossover'"
|
||||||
|
)
|
||||||
|
if "source_code" not in cols:
|
||||||
|
conn.execute("ALTER TABLE strategies ADD COLUMN source_code TEXT")
|
||||||
|
|
||||||
|
|
||||||
def init_db() -> None:
|
def init_db() -> None:
|
||||||
path = _db_path()
|
path = _db_path()
|
||||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||||
|
|
@ -36,12 +48,15 @@ def init_db() -> None:
|
||||||
username TEXT NOT NULL,
|
username TEXT NOT NULL,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
ticker TEXT NOT NULL,
|
ticker TEXT NOT NULL,
|
||||||
|
strategy_key TEXT NOT NULL DEFAULT 'ma_crossover',
|
||||||
params_json TEXT NOT NULL,
|
params_json TEXT NOT NULL,
|
||||||
|
source_code TEXT,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
UNIQUE(username, name)
|
UNIQUE(username, name)
|
||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
_migrate(conn)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -55,24 +70,51 @@ def _connect():
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_strategy(row: sqlite3.Row) -> SavedStrategy:
|
||||||
|
return SavedStrategy(
|
||||||
|
id=row["id"],
|
||||||
|
username=row["username"],
|
||||||
|
name=row["name"],
|
||||||
|
ticker=row["ticker"],
|
||||||
|
strategy_key=row["strategy_key"] if "strategy_key" in row.keys() else "ma_crossover",
|
||||||
|
params=json.loads(row["params_json"]),
|
||||||
|
source_code=row["source_code"] if "source_code" in row.keys() else None,
|
||||||
|
created_at=row["created_at"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def save_strategy(
|
def save_strategy(
|
||||||
username: str,
|
username: str,
|
||||||
name: str,
|
name: str,
|
||||||
ticker: str,
|
ticker: str,
|
||||||
|
strategy_key: str,
|
||||||
params: dict[str, Any],
|
params: dict[str, Any],
|
||||||
|
source_code: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
created_at = datetime.now(timezone.utc).isoformat()
|
created_at = datetime.now(timezone.utc).isoformat()
|
||||||
with _connect() as conn:
|
with _connect() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO strategies (username, name, ticker, params_json, created_at)
|
INSERT INTO strategies (
|
||||||
VALUES (?, ?, ?, ?, ?)
|
username, name, ticker, strategy_key, params_json, source_code, created_at
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(username, name) DO UPDATE SET
|
ON CONFLICT(username, name) DO UPDATE SET
|
||||||
ticker = excluded.ticker,
|
ticker = excluded.ticker,
|
||||||
|
strategy_key = excluded.strategy_key,
|
||||||
params_json = excluded.params_json,
|
params_json = excluded.params_json,
|
||||||
|
source_code = excluded.source_code,
|
||||||
created_at = excluded.created_at
|
created_at = excluded.created_at
|
||||||
""",
|
""",
|
||||||
(username, name.strip(), ticker.upper(), json.dumps(params), created_at),
|
(
|
||||||
|
username,
|
||||||
|
name.strip(),
|
||||||
|
ticker.upper(),
|
||||||
|
strategy_key,
|
||||||
|
json.dumps(params),
|
||||||
|
source_code,
|
||||||
|
created_at,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
|
@ -81,49 +123,27 @@ def list_strategies(username: str) -> list[SavedStrategy]:
|
||||||
with _connect() as conn:
|
with _connect() as conn:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT id, username, name, ticker, params_json, created_at
|
SELECT id, username, name, ticker, strategy_key, params_json, source_code, created_at
|
||||||
FROM strategies
|
FROM strategies
|
||||||
WHERE username = ?
|
WHERE username = ?
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
""",
|
""",
|
||||||
(username,),
|
(username,),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
return [_row_to_strategy(row) for row in rows]
|
||||||
return [
|
|
||||||
SavedStrategy(
|
|
||||||
id=row["id"],
|
|
||||||
username=row["username"],
|
|
||||||
name=row["name"],
|
|
||||||
ticker=row["ticker"],
|
|
||||||
params=json.loads(row["params_json"]),
|
|
||||||
created_at=row["created_at"],
|
|
||||||
)
|
|
||||||
for row in rows
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def load_strategy(username: str, name: str) -> SavedStrategy | None:
|
def load_strategy(username: str, name: str) -> SavedStrategy | None:
|
||||||
with _connect() as conn:
|
with _connect() as conn:
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT id, username, name, ticker, params_json, created_at
|
SELECT id, username, name, ticker, strategy_key, params_json, source_code, created_at
|
||||||
FROM strategies
|
FROM strategies
|
||||||
WHERE username = ? AND name = ?
|
WHERE username = ? AND name = ?
|
||||||
""",
|
""",
|
||||||
(username, name),
|
(username, name),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
return _row_to_strategy(row) if row else None
|
||||||
if row is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return SavedStrategy(
|
|
||||||
id=row["id"],
|
|
||||||
username=row["username"],
|
|
||||||
name=row["name"],
|
|
||||||
ticker=row["ticker"],
|
|
||||||
params=json.loads(row["params_json"]),
|
|
||||||
created_at=row["created_at"],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def delete_strategy(username: str, name: str) -> None:
|
def delete_strategy(username: str, name: str) -> None:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue