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.
405 lines
14 KiB
Python
405 lines
14 KiB
Python
"""QuantTrade research workstation."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
|
|
import pandas as pd
|
|
import plotly.express as px
|
|
import plotly.graph_objects as go
|
|
import streamlit as st
|
|
from plotly.subplots import make_subplots
|
|
|
|
from auth import get_current_user, logout
|
|
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 telemetry import capture_exception, init_telemetry
|
|
|
|
init_telemetry("quant-streamlit")
|
|
init_db()
|
|
|
|
st.set_page_config(page_title="QuantTrade", page_icon="📈", layout="wide")
|
|
|
|
DEFAULT_TICKERS = [
|
|
t.strip().upper()
|
|
for t in os.environ.get(
|
|
"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 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(
|
|
rows=3,
|
|
cols=1,
|
|
shared_xaxes=True,
|
|
vertical_spacing=0.05,
|
|
row_heights=[0.5, 0.25, 0.25],
|
|
subplot_titles=(f"{result.ticker} — price & signals", "Equity curve", "Position"),
|
|
)
|
|
fig.add_trace(
|
|
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,
|
|
col=1,
|
|
)
|
|
fig.add_trace(
|
|
go.Scatter(
|
|
x=result.price.index[sells],
|
|
y=result.price[sells],
|
|
mode="markers",
|
|
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,
|
|
col=1,
|
|
)
|
|
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)
|
|
|
|
|
|
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:
|
|
user = get_current_user()
|
|
if not user:
|
|
return
|
|
|
|
st.title("QuantTrade")
|
|
st.caption("Research desk · Python strategies · VectorBT parameter scans · Parquet data")
|
|
|
|
with st.sidebar:
|
|
sidebar_account(user)
|
|
st.divider()
|
|
ticker, init_cash, fees = sidebar_market()
|
|
if "active_ticker" in st.session_state:
|
|
ticker = st.session_state.active_ticker
|
|
st.divider()
|
|
strategy_key = sidebar_strategy_picker()
|
|
if "active_strategy_key" in st.session_state:
|
|
strategy_key = st.session_state.active_strategy_key
|
|
|
|
source_code = st.session_state.get("custom_code", CUSTOM_TEMPLATE)
|
|
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)
|
|
params.update(builtin.default_params)
|
|
|
|
if "active_params" in st.session_state:
|
|
params.update(st.session_state.active_params)
|
|
|
|
best = st.session_state.pop("apply_best_params", None)
|
|
if best:
|
|
params.update(best)
|
|
|
|
tab_bt, tab_opt, tab_code, tab_save = st.tabs(["Backtest", "Optimize", "Python", "Library"])
|
|
|
|
with tab_code:
|
|
source_code = tab_editor(strategy_key, source_code)
|
|
st.session_state.custom_code = source_code
|
|
|
|
with tab_save:
|
|
tab_library(user, ticker, strategy_key, source_code, params)
|
|
|
|
with tab_bt:
|
|
tab_backtest(user, ticker, init_cash, fees, strategy_key, source_code)
|
|
|
|
with tab_opt:
|
|
tab_optimize(user, ticker, init_cash, fees, strategy_key, source_code)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|