forked from artemium/428-backtester
Add private Freqtrade scaffold for partner backtesting.
Ship SampleStrategy and Integral workflow scripts without proprietary V15 logic, Pine, or run results. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
commit
99de26e7f0
13 changed files with 831 additions and 0 deletions
41
.gitignore
vendored
Normal file
41
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
# Freqtrade runtime / data
|
||||||
|
user_data/data/
|
||||||
|
user_data/logs/
|
||||||
|
user_data/plot/
|
||||||
|
user_data/backtest_results/
|
||||||
|
user_data/hyperopt_results/
|
||||||
|
user_data/hyperopts/
|
||||||
|
user_data/strategies/*.json
|
||||||
|
user_data/notebooks/
|
||||||
|
user_data/*.sqlite
|
||||||
|
user_data/*.sqlite-journal
|
||||||
|
user_data/tradesv3.dryrun.sqlite*
|
||||||
|
user_data/freqtradeservice.json
|
||||||
|
user_data/hyperopt.lock
|
||||||
|
|
||||||
|
# Proprietary strategy + Pine (local only — do not push)
|
||||||
|
user_data/strategies/V15_5_LTF*
|
||||||
|
v15_5_LTF.txt
|
||||||
|
*.bak
|
||||||
|
*.bak_*
|
||||||
|
|
||||||
|
# Local backtest / hyperopt run dumps
|
||||||
|
results/
|
||||||
|
|
||||||
|
# Secrets / local overrides
|
||||||
|
user_data/config_private.json
|
||||||
|
.env
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.egg-info/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
|
||||||
|
# OS / IDE
|
||||||
|
.DS_Store
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
106
README.md
Normal file
106
README.md
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
# 428 Backtester — Integral (Freqtrade)
|
||||||
|
|
||||||
|
Local Freqtrade scaffold for backtesting on **Binance Futures BTC/USDT:USDT**, **15m** (+ **6h** informative data), built around the Integral workflow.
|
||||||
|
|
||||||
|
No Jupyter. Reports come from Freqtrade CLI + HTML plots (`plot-profit`, trade chart).
|
||||||
|
|
||||||
|
Ship a **SampleStrategy** by default. Drop your own strategy under `user_data/strategies/` and point scripts at it with `STRATEGY=YourClassName`.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- **Preferred:** Docker + Docker Compose (`freqtradeorg/freqtrade:stable_plot`)
|
||||||
|
- **Fallback:** Python 3.12 venv with `freqtrade` + `plotly` (scripts use this automatically if Docker is missing)
|
||||||
|
- ~2+ GB disk for OHLCV history
|
||||||
|
|
||||||
|
### Local venv setup (no Docker)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/opt/homebrew/opt/python@3.12/bin/python3.12 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -U pip 'freqtrade[hyperopt]' plotly
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1) Download futures candles (15m + 6h). Default timerange from 2024-07-01.
|
||||||
|
./scripts/download_data.sh
|
||||||
|
|
||||||
|
# 2) Run baseline backtest (SampleStrategy; full history from 2024-07-01)
|
||||||
|
./scripts/backtest.sh
|
||||||
|
|
||||||
|
# 3) Hyperopt buy/sell params on in-sample range (default 20240701-20260101)
|
||||||
|
./scripts/hyperopt.sh
|
||||||
|
# EPOCHS=200 LOSS=SharpeHyperOptLossDaily ./scripts/hyperopt.sh
|
||||||
|
|
||||||
|
# 4) Apply best epoch params, then OOS backtest (default 20260101-)
|
||||||
|
./scripts/apply_hyperopt_params.sh
|
||||||
|
TIMERANGE=20260101- ./scripts/backtest.sh
|
||||||
|
|
||||||
|
# 5) Equity + trade charts (pick a shorter range for readable plots)
|
||||||
|
TIMERANGE=20250101-20250201 ./scripts/plot.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Scripts auto-detect Docker; if absent they use `.venv/bin/freqtrade`.
|
||||||
|
|
||||||
|
### Your strategy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Place YourStrategy.py in user_data/strategies/
|
||||||
|
STRATEGY=YourStrategy ./scripts/backtest.sh
|
||||||
|
STRATEGY=YourStrategy ./scripts/hyperopt.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Or set `"strategy": "YourStrategy"` in `user_data/config.json` / `docker-compose.yml`.
|
||||||
|
|
||||||
|
### Custom timerange
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TIMERANGE=20240101-20250601 ./scripts/download_data.sh
|
||||||
|
TIMERANGE=20240101-20250601 ./scripts/backtest.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Direct docker compose
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose run --rm freqtrade download-data \
|
||||||
|
--config /freqtrade/user_data/config.json \
|
||||||
|
--trading-mode futures -t 15m 6h -p BTC/USDT:USDT --timerange 20240701-
|
||||||
|
|
||||||
|
docker compose run --rm freqtrade backtesting \
|
||||||
|
--config /freqtrade/user_data/config.json \
|
||||||
|
--strategy SampleStrategy --timeframe 15m --timerange 20240701-
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project layout
|
||||||
|
|
||||||
|
| Path | Role |
|
||||||
|
|------|------|
|
||||||
|
| [`user_data/strategies/SampleStrategy.py`](user_data/strategies/SampleStrategy.py) | Placeholder strategy (replace with yours) |
|
||||||
|
| [`user_data/config.json`](user_data/config.json) | Binance futures dry-run / backtest config |
|
||||||
|
| [`scripts/`](scripts/) | download / backtest / hyperopt / plot helpers |
|
||||||
|
| [`docker-compose.yml`](docker-compose.yml) | `freqtradeorg/freqtrade:stable_plot` |
|
||||||
|
|
||||||
|
## Optimization
|
||||||
|
|
||||||
|
Primary tool: **Freqtrade Hyperopt** (Optuna TPE) over strategy `IntParameter` / `DecimalParameter` spaces.
|
||||||
|
|
||||||
|
Default split used by scripts:
|
||||||
|
|
||||||
|
- **IS / hyperopt:** `TIMERANGE=20240701-20260101`
|
||||||
|
- **OOS backtest:** `TIMERANGE=20260101-`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/hyperopt.sh # IS search
|
||||||
|
./scripts/apply_hyperopt_params.sh # write user_data/strategies/<Strategy>.json
|
||||||
|
TIMERANGE=20260101- ./scripts/backtest.sh # OOS with best params
|
||||||
|
# Defaults again: remove the JSON override
|
||||||
|
rm -f user_data/strategies/SampleStrategy.json
|
||||||
|
```
|
||||||
|
|
||||||
|
If optimizable params change `populate_indicators` (not only entry/exit columns), keep `--analyze-per-epoch` (default in `hyperopt.sh`). Without it every epoch can repeat the baseline result.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- **Fees / funding:** `config.json` sets `fee: 0.0005` (5 bps). Funding rates download with futures data when available; treat equity as approximate.
|
||||||
|
- **Private logic:** proprietary strategies and run artifacts stay local (see `.gitignore`). Do not commit them to this repo.
|
||||||
18
docker-compose.yml
Normal file
18
docker-compose.yml
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
---
|
||||||
|
services:
|
||||||
|
freqtrade:
|
||||||
|
# _plot image includes plotly for plot-profit / plot-dataframe
|
||||||
|
image: freqtradeorg/freqtrade:stable_plot
|
||||||
|
restart: "no"
|
||||||
|
container_name: freqtrade-backtester
|
||||||
|
volumes:
|
||||||
|
- "./user_data:/freqtrade/user_data"
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8080:8080"
|
||||||
|
# Default: no long-running trade process; use scripts/*.sh via docker compose run
|
||||||
|
command: >
|
||||||
|
trade
|
||||||
|
--logfile /freqtrade/user_data/logs/freqtrade.log
|
||||||
|
--db-url sqlite:////freqtrade/user_data/tradesv3.sqlite
|
||||||
|
--config /freqtrade/user_data/config.json
|
||||||
|
--strategy SampleStrategy
|
||||||
3
requirements.txt
Normal file
3
requirements.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
freqtrade[hyperopt]>=2025.1
|
||||||
|
plotly>=5.0
|
||||||
|
scipy>=1.11
|
||||||
21
scripts/_env.sh
Executable file
21
scripts/_env.sh
Executable file
|
|
@ -0,0 +1,21 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Resolve freqtrade binary: prefer docker compose, else local .venv
|
||||||
|
set -euo pipefail
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then
|
||||||
|
freqtrade() {
|
||||||
|
docker compose run --rm freqtrade "$@"
|
||||||
|
}
|
||||||
|
elif [[ -x "$ROOT/.venv/bin/freqtrade" ]]; then
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
source "$ROOT/.venv/bin/activate"
|
||||||
|
freqtrade() {
|
||||||
|
"$ROOT/.venv/bin/freqtrade" "$@"
|
||||||
|
}
|
||||||
|
else
|
||||||
|
echo "Neither Docker nor .venv/bin/freqtrade found." >&2
|
||||||
|
echo "Install Docker Desktop, or: python3.12 -m venv .venv && .venv/bin/pip install freqtrade plotly" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
83
scripts/apply_hyperopt_params.sh
Executable file
83
scripts/apply_hyperopt_params.sh
Executable file
|
|
@ -0,0 +1,83 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
source "$(dirname "$0")/_env.sh"
|
||||||
|
|
||||||
|
# Export best (or N-th) hyperopt epoch into strategy params JSON so backtesting
|
||||||
|
# picks them up: user_data/strategies/<Strategy>.json
|
||||||
|
STRATEGY="${STRATEGY:-SampleStrategy}"
|
||||||
|
EPOCH="${EPOCH:--1}"
|
||||||
|
OUT="${OUT:-user_data/strategies/${STRATEGY}.json}"
|
||||||
|
|
||||||
|
TMP="$(mktemp)"
|
||||||
|
trap 'rm -f "$TMP"' EXIT
|
||||||
|
|
||||||
|
freqtrade hyperopt-show \
|
||||||
|
--config user_data/config.json \
|
||||||
|
-n "${EPOCH}" \
|
||||||
|
--print-json \
|
||||||
|
--no-header >"$TMP"
|
||||||
|
|
||||||
|
PYTHON_BIN="${ROOT}/.venv/bin/python"
|
||||||
|
if [[ ! -x "${PYTHON_BIN}" ]]; then
|
||||||
|
PYTHON_BIN="$(command -v python3)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
"${PYTHON_BIN}" - "$TMP" "$STRATEGY" "$OUT" <<'PY'
|
||||||
|
import json, sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
src, strategy, out = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||||
|
raw = Path(src).read_text().strip()
|
||||||
|
# hyperopt-show may print log noise; keep the last JSON object
|
||||||
|
start = raw.rfind("{")
|
||||||
|
if start < 0:
|
||||||
|
raise SystemExit(f"No JSON found in hyperopt-show output:\n{raw[:500]}")
|
||||||
|
payload = json.loads(raw[start:])
|
||||||
|
|
||||||
|
KNOWN = ("buy", "sell", "roi", "stoploss", "trailing", "protection")
|
||||||
|
|
||||||
|
|
||||||
|
def expand_tp_combo(sell: dict) -> dict:
|
||||||
|
"""If sell has tp_rr_combo 'a,b,c', mirror into tp1_rr/tp2_rr/tp3_rr."""
|
||||||
|
combo = sell.get("tp_rr_combo")
|
||||||
|
if combo is None:
|
||||||
|
return sell
|
||||||
|
if isinstance(combo, (list, tuple)) and len(combo) == 3:
|
||||||
|
a, b, c = (float(x) for x in combo)
|
||||||
|
sell["tp_rr_combo"] = f"{a},{b},{c}"
|
||||||
|
elif isinstance(combo, str) and "," in combo:
|
||||||
|
a, b, c = (float(x) for x in combo.split(","))
|
||||||
|
else:
|
||||||
|
return sell
|
||||||
|
sell["tp1_rr"] = a
|
||||||
|
sell["tp2_rr"] = b
|
||||||
|
sell["tp3_rr"] = c
|
||||||
|
return sell
|
||||||
|
|
||||||
|
|
||||||
|
def as_params(obj: dict) -> dict:
|
||||||
|
if "params" in obj and isinstance(obj["params"], dict):
|
||||||
|
raw_params = obj["params"]
|
||||||
|
if any(k in raw_params for k in KNOWN):
|
||||||
|
params = {k: v for k, v in raw_params.items() if k in KNOWN}
|
||||||
|
else:
|
||||||
|
params = {"buy": raw_params} if raw_params else {}
|
||||||
|
elif any(k in obj for k in KNOWN):
|
||||||
|
params = {k: v for k, v in obj.items() if k in KNOWN}
|
||||||
|
else:
|
||||||
|
params = {"buy": obj} if obj else {}
|
||||||
|
if "sell" in params and isinstance(params["sell"], dict):
|
||||||
|
params["sell"] = expand_tp_combo(dict(params["sell"]))
|
||||||
|
return params
|
||||||
|
|
||||||
|
|
||||||
|
params = as_params(payload)
|
||||||
|
doc = {
|
||||||
|
"strategy_name": strategy,
|
||||||
|
"params": params,
|
||||||
|
}
|
||||||
|
Path(out).write_text(json.dumps(doc, indent=2) + "\n")
|
||||||
|
print(f"Wrote {out}")
|
||||||
|
print(json.dumps(params, indent=2))
|
||||||
|
PY
|
||||||
16
scripts/backtest.sh
Executable file
16
scripts/backtest.sh
Executable file
|
|
@ -0,0 +1,16 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
source "$(dirname "$0")/_env.sh"
|
||||||
|
|
||||||
|
TIMERANGE="${TIMERANGE:-20240701-}"
|
||||||
|
STRATEGY="${STRATEGY:-SampleStrategy}"
|
||||||
|
|
||||||
|
freqtrade backtesting \
|
||||||
|
--config user_data/config.json \
|
||||||
|
--strategy "${STRATEGY}" \
|
||||||
|
--timeframe 15m \
|
||||||
|
--timerange "${TIMERANGE}" \
|
||||||
|
--breakdown day \
|
||||||
|
--cache none \
|
||||||
|
"$@"
|
||||||
15
scripts/download_data.sh
Executable file
15
scripts/download_data.sh
Executable file
|
|
@ -0,0 +1,15 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
source "$(dirname "$0")/_env.sh"
|
||||||
|
|
||||||
|
TIMERANGE="${TIMERANGE:-20240701-}"
|
||||||
|
PAIRS="${PAIRS:-BTC/USDT:USDT}"
|
||||||
|
|
||||||
|
freqtrade download-data \
|
||||||
|
--config user_data/config.json \
|
||||||
|
--exchange binance \
|
||||||
|
--trading-mode futures \
|
||||||
|
--pairs ${PAIRS} \
|
||||||
|
--timeframes 15m 6h \
|
||||||
|
--timerange "${TIMERANGE}"
|
||||||
38
scripts/hyperopt.sh
Executable file
38
scripts/hyperopt.sh
Executable file
|
|
@ -0,0 +1,38 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
source "$(dirname "$0")/_env.sh"
|
||||||
|
|
||||||
|
# In-sample range for hyperopt; use a later TIMERANGE for OOS backtest.
|
||||||
|
# --analyze-per-epoch is required when buy/sell params change populate_indicators
|
||||||
|
# (not only entry/exit columns). Set ANALYZE_PER_EPOCH=0 to disable.
|
||||||
|
TIMERANGE="${TIMERANGE:-20240701-20260101}"
|
||||||
|
STRATEGY="${STRATEGY:-SampleStrategy}"
|
||||||
|
EPOCHS="${EPOCHS:-100}"
|
||||||
|
SPACES="${SPACES:-buy sell}"
|
||||||
|
LOSS="${LOSS:-SharpeHyperOptLossDaily}"
|
||||||
|
JOBS="${JOBS:-}"
|
||||||
|
ANALYZE_PER_EPOCH="${ANALYZE_PER_EPOCH:-1}"
|
||||||
|
|
||||||
|
# shellcheck disable=SC2206
|
||||||
|
SPACES_ARR=(${SPACES})
|
||||||
|
|
||||||
|
ARGS=(
|
||||||
|
--config user_data/config.json
|
||||||
|
--strategy "${STRATEGY}"
|
||||||
|
--timeframe 15m
|
||||||
|
--timerange "${TIMERANGE}"
|
||||||
|
--spaces "${SPACES_ARR[@]}"
|
||||||
|
--hyperopt-loss "${LOSS}"
|
||||||
|
-e "${EPOCHS}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if [[ "${ANALYZE_PER_EPOCH}" != "0" ]]; then
|
||||||
|
ARGS+=(--analyze-per-epoch)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "${JOBS}" ]]; then
|
||||||
|
ARGS+=(-j "${JOBS}")
|
||||||
|
fi
|
||||||
|
|
||||||
|
freqtrade hyperopt "${ARGS[@]}" "$@"
|
||||||
32
scripts/plot.sh
Executable file
32
scripts/plot.sh
Executable file
|
|
@ -0,0 +1,32 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
source "$(dirname "$0")/_env.sh"
|
||||||
|
|
||||||
|
TIMERANGE="${TIMERANGE:-20250101-20250201}"
|
||||||
|
PAIR="${PAIR:-BTC/USDT:USDT}"
|
||||||
|
STRATEGY="${STRATEGY:-SampleStrategy}"
|
||||||
|
|
||||||
|
mkdir -p user_data/plot
|
||||||
|
|
||||||
|
echo "==> plot-profit (equity curve)"
|
||||||
|
freqtrade plot-profit \
|
||||||
|
--config user_data/config.json \
|
||||||
|
--strategy "${STRATEGY}" \
|
||||||
|
--timerange "${TIMERANGE}" \
|
||||||
|
--timeframe 15m \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
echo "==> trade chart (price / signals / SL·TP / fills / volume)"
|
||||||
|
PYTHON_BIN="${ROOT}/.venv/bin/python"
|
||||||
|
if [[ ! -x "${PYTHON_BIN}" ]]; then
|
||||||
|
PYTHON_BIN="python3"
|
||||||
|
fi
|
||||||
|
"${PYTHON_BIN}" "$(dirname "$0")/plot_trades_chart.py" \
|
||||||
|
--config user_data/config.json \
|
||||||
|
--strategy "${STRATEGY}" \
|
||||||
|
--pair "${PAIR}" \
|
||||||
|
--timerange "${TIMERANGE}" \
|
||||||
|
--timeframe 15m
|
||||||
|
|
||||||
|
echo "HTML plots are under user_data/plot/"
|
||||||
335
scripts/plot_trades_chart.py
Normal file
335
scripts/plot_trades_chart.py
Normal file
|
|
@ -0,0 +1,335 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Clean trade chart: price, signals, SL/TP levels, filled trades, volume.
|
||||||
|
Price / signals / SL·TP / fills — no indicator clutter.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
import plotly.graph_objects as go
|
||||||
|
from plotly.subplots import make_subplots
|
||||||
|
|
||||||
|
from freqtrade.configuration import Configuration
|
||||||
|
from freqtrade.data.btanalysis import extract_trades_of_period
|
||||||
|
from freqtrade.data.converter import trim_dataframe
|
||||||
|
from freqtrade.data.dataprovider import DataProvider
|
||||||
|
from freqtrade.misc import pair_to_filename
|
||||||
|
from freqtrade.plot.plotting import init_plotscript, store_plot_file
|
||||||
|
from freqtrade.resolvers import ExchangeResolver, StrategyResolver
|
||||||
|
from freqtrade.strategy import IStrategy
|
||||||
|
from freqtrade.strategy.strategy_wrapper import strategy_safe_wrapper
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||||
|
logger = logging.getLogger("plot_trades_chart")
|
||||||
|
|
||||||
|
SIGNAL_SIZE = 16
|
||||||
|
TRADE_SIZE = 14
|
||||||
|
|
||||||
|
LEVEL_STYLES = {
|
||||||
|
"sl": ("SL", "#e74c3c", "solid", 2),
|
||||||
|
"tp1": ("TP1", "#27ae60", "solid", 1.5),
|
||||||
|
"tp2": ("TP2", "#2ecc71", "dash", 1.2),
|
||||||
|
"tp3": ("TP3", "#1abc9c", "dot", 1.2),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _signal_scatter(
|
||||||
|
data: pd.DataFrame, column: str, color: str, direction: str, size: int
|
||||||
|
) -> go.Scatter | None:
|
||||||
|
if column not in data.columns:
|
||||||
|
return None
|
||||||
|
df = data[data[column] == 1]
|
||||||
|
if df.empty:
|
||||||
|
return None
|
||||||
|
return go.Scatter(
|
||||||
|
x=df["date"],
|
||||||
|
y=df["close"],
|
||||||
|
mode="markers",
|
||||||
|
name=column,
|
||||||
|
marker=dict(
|
||||||
|
symbol=f"triangle-{direction}-dot",
|
||||||
|
size=size,
|
||||||
|
line=dict(width=1.5, color=color),
|
||||||
|
color=color,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _levels_for_trade(row: pd.Series, candles: pd.DataFrame) -> dict[str, float] | None:
|
||||||
|
"""Read frozen SL/TP from the entry candle (strategy columns)."""
|
||||||
|
open_ts = pd.Timestamp(row["open_date"])
|
||||||
|
if open_ts.tzinfo is None:
|
||||||
|
open_ts = open_ts.tz_localize("UTC")
|
||||||
|
dates = pd.to_datetime(candles["date"], utc=True)
|
||||||
|
matched = candles.loc[dates <= open_ts]
|
||||||
|
if matched.empty:
|
||||||
|
return None
|
||||||
|
candle = matched.iloc[-1]
|
||||||
|
prefix = "short" if bool(row.get("is_short", False)) else "long"
|
||||||
|
out: dict[str, float] = {}
|
||||||
|
for key in ("sl", "tp1", "tp2", "tp3"):
|
||||||
|
col = f"{prefix}_{key}"
|
||||||
|
if col not in candle.index or pd.isna(candle[col]):
|
||||||
|
return None
|
||||||
|
out[key] = float(candle[col])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _tp1_fill_time(row: pd.Series) -> pd.Timestamp | None:
|
||||||
|
orders = row.get("orders")
|
||||||
|
if not isinstance(orders, list):
|
||||||
|
return None
|
||||||
|
for order in orders:
|
||||||
|
tag = str(order.get("ft_order_tag") or "")
|
||||||
|
if tag == "tp1" or tag.startswith("tp1"):
|
||||||
|
ts = order.get("order_filled_timestamp") or order.get("order_filled_date")
|
||||||
|
if ts is None:
|
||||||
|
return None
|
||||||
|
if isinstance(ts, (int, float)):
|
||||||
|
return pd.to_datetime(ts, unit="ms", utc=True)
|
||||||
|
return pd.to_datetime(ts, utc=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _add_trade_levels(fig: go.Figure, trades: pd.DataFrame, candles: pd.DataFrame) -> None:
|
||||||
|
seen: set[str] = set()
|
||||||
|
for _, row in trades.iterrows():
|
||||||
|
levels = _levels_for_trade(row, candles)
|
||||||
|
if not levels:
|
||||||
|
continue
|
||||||
|
x0, x1 = row["open_date"], row["close_date"]
|
||||||
|
if pd.isna(x1):
|
||||||
|
continue
|
||||||
|
be_from = _tp1_fill_time(row)
|
||||||
|
|
||||||
|
for key, price in levels.items():
|
||||||
|
name, color, dash, width = LEVEL_STYLES[key]
|
||||||
|
show = name not in seen
|
||||||
|
if show:
|
||||||
|
seen.add(name)
|
||||||
|
|
||||||
|
if key == "sl" and be_from is not None and be_from > x0:
|
||||||
|
fig.add_trace(
|
||||||
|
go.Scatter(
|
||||||
|
x=[x0, be_from],
|
||||||
|
y=[price, price],
|
||||||
|
mode="lines",
|
||||||
|
name=name,
|
||||||
|
showlegend=show,
|
||||||
|
line=dict(color=color, width=width, dash=dash),
|
||||||
|
hovertemplate=f"{name}: %{{y:.1f}}<extra></extra>",
|
||||||
|
),
|
||||||
|
row=1,
|
||||||
|
col=1,
|
||||||
|
)
|
||||||
|
be_name = "SL (BE)"
|
||||||
|
be_show = be_name not in seen
|
||||||
|
if be_show:
|
||||||
|
seen.add(be_name)
|
||||||
|
entry = float(row["open_rate"])
|
||||||
|
fig.add_trace(
|
||||||
|
go.Scatter(
|
||||||
|
x=[be_from, x1],
|
||||||
|
y=[entry, entry],
|
||||||
|
mode="lines",
|
||||||
|
name=be_name,
|
||||||
|
showlegend=be_show,
|
||||||
|
line=dict(color="#f39c12", width=2, dash="dash"),
|
||||||
|
hovertemplate=f"{be_name}: %{{y:.1f}}<extra></extra>",
|
||||||
|
),
|
||||||
|
row=1,
|
||||||
|
col=1,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
fig.add_trace(
|
||||||
|
go.Scatter(
|
||||||
|
x=[x0, x1],
|
||||||
|
y=[price, price],
|
||||||
|
mode="lines",
|
||||||
|
name=name,
|
||||||
|
showlegend=show,
|
||||||
|
line=dict(color=color, width=width, dash=dash),
|
||||||
|
hovertemplate=f"{name}: %{{y:.1f}}<extra></extra>",
|
||||||
|
),
|
||||||
|
row=1,
|
||||||
|
col=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _add_filled_trades(fig: go.Figure, trades: pd.DataFrame) -> None:
|
||||||
|
if trades is None or trades.empty:
|
||||||
|
return
|
||||||
|
|
||||||
|
desc = trades.apply(
|
||||||
|
lambda r: (
|
||||||
|
f"{r['profit_ratio']:.2%}, "
|
||||||
|
+ (f"{r['enter_tag']}, " if pd.notna(r.get('enter_tag')) else "")
|
||||||
|
+ f"{r['exit_reason']}, "
|
||||||
|
+ f"{r['trade_duration']} min"
|
||||||
|
),
|
||||||
|
axis=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
fig.add_trace(
|
||||||
|
go.Scatter(
|
||||||
|
x=trades["open_date"],
|
||||||
|
y=trades["open_rate"],
|
||||||
|
mode="markers",
|
||||||
|
name="Trade entry",
|
||||||
|
text=desc,
|
||||||
|
marker=dict(symbol="circle-open", size=TRADE_SIZE, line=dict(width=2.5), color="cyan"),
|
||||||
|
),
|
||||||
|
row=1,
|
||||||
|
col=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
wins = trades["profit_ratio"] > 0
|
||||||
|
losses = ~wins
|
||||||
|
if wins.any():
|
||||||
|
fig.add_trace(
|
||||||
|
go.Scatter(
|
||||||
|
x=trades.loc[wins, "close_date"],
|
||||||
|
y=trades.loc[wins, "close_rate"],
|
||||||
|
mode="markers",
|
||||||
|
name="Exit - Profit",
|
||||||
|
text=desc[wins],
|
||||||
|
marker=dict(
|
||||||
|
symbol="square-open", size=TRADE_SIZE, line=dict(width=2.5), color="green"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
row=1,
|
||||||
|
col=1,
|
||||||
|
)
|
||||||
|
if losses.any():
|
||||||
|
fig.add_trace(
|
||||||
|
go.Scatter(
|
||||||
|
x=trades.loc[losses, "close_date"],
|
||||||
|
y=trades.loc[losses, "close_rate"],
|
||||||
|
mode="markers",
|
||||||
|
name="Exit - Loss",
|
||||||
|
text=desc[losses],
|
||||||
|
marker=dict(
|
||||||
|
symbol="square-open", size=TRADE_SIZE, line=dict(width=2.5), color="red"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
row=1,
|
||||||
|
col=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_figure(pair: str, data: pd.DataFrame, trades: pd.DataFrame) -> go.Figure:
|
||||||
|
fig = make_subplots(
|
||||||
|
rows=2,
|
||||||
|
cols=1,
|
||||||
|
shared_xaxes=True,
|
||||||
|
row_width=[1, 4],
|
||||||
|
vertical_spacing=0.02,
|
||||||
|
)
|
||||||
|
fig.update_layout(
|
||||||
|
title=f"{pair} — trades",
|
||||||
|
xaxis_rangeslider_visible=False,
|
||||||
|
legend=dict(orientation="h", yanchor="bottom", y=1.02, x=0),
|
||||||
|
margin=dict(t=80, b=40),
|
||||||
|
modebar_add=["v1hovermode", "toggleSpikeLines"],
|
||||||
|
)
|
||||||
|
fig.update_yaxes(title_text="Price", row=1, col=1)
|
||||||
|
fig.update_yaxes(title_text="Volume", row=2, col=1)
|
||||||
|
|
||||||
|
fig.add_trace(
|
||||||
|
go.Candlestick(
|
||||||
|
x=data["date"],
|
||||||
|
open=data["open"],
|
||||||
|
high=data["high"],
|
||||||
|
low=data["low"],
|
||||||
|
close=data["close"],
|
||||||
|
name="Price",
|
||||||
|
increasing_line_color="#26a69a",
|
||||||
|
decreasing_line_color="#ef5350",
|
||||||
|
),
|
||||||
|
row=1,
|
||||||
|
col=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
for scatter in (
|
||||||
|
_signal_scatter(data, "enter_long", "#2ecc71", "up", SIGNAL_SIZE),
|
||||||
|
_signal_scatter(data, "exit_long", "#e74c3c", "down", SIGNAL_SIZE),
|
||||||
|
_signal_scatter(data, "enter_short", "#3498db", "down", SIGNAL_SIZE),
|
||||||
|
_signal_scatter(data, "exit_short", "#9b59b6", "up", SIGNAL_SIZE),
|
||||||
|
):
|
||||||
|
if scatter is not None:
|
||||||
|
fig.add_trace(scatter, row=1, col=1)
|
||||||
|
|
||||||
|
if trades is not None and not trades.empty:
|
||||||
|
_add_trade_levels(fig, trades, data)
|
||||||
|
_add_filled_trades(fig, trades)
|
||||||
|
|
||||||
|
fig.add_trace(
|
||||||
|
go.Bar(
|
||||||
|
x=data["date"],
|
||||||
|
y=data["volume"],
|
||||||
|
name="Volume",
|
||||||
|
marker_color="DarkSlateGrey",
|
||||||
|
marker_line_color="DarkSlateGrey",
|
||||||
|
),
|
||||||
|
row=2,
|
||||||
|
col=1,
|
||||||
|
)
|
||||||
|
return fig
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--config", default="user_data/config.json")
|
||||||
|
parser.add_argument("--strategy", default="SampleStrategy")
|
||||||
|
parser.add_argument("--timerange", default=None)
|
||||||
|
parser.add_argument("--pair", default="BTC/USDT:USDT")
|
||||||
|
parser.add_argument("--timeframe", default="15m")
|
||||||
|
parser.add_argument("--outfile", default=None)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
cfg = Configuration.from_files([args.config])
|
||||||
|
cfg["strategy"] = args.strategy
|
||||||
|
cfg["timeframe"] = args.timeframe
|
||||||
|
cfg["pairs"] = [args.pair]
|
||||||
|
if args.timerange:
|
||||||
|
cfg["timerange"] = args.timerange
|
||||||
|
cfg.setdefault("trade_source", "file")
|
||||||
|
|
||||||
|
strategy = StrategyResolver.load_strategy(cfg)
|
||||||
|
exchange = ExchangeResolver.load_exchange(cfg)
|
||||||
|
IStrategy.dp = DataProvider(cfg, exchange)
|
||||||
|
strategy.ft_bot_start()
|
||||||
|
strategy_safe_wrapper(strategy.bot_loop_start)(current_time=datetime.now(UTC))
|
||||||
|
|
||||||
|
plot_elements = init_plotscript(cfg, list(exchange.markets), strategy.startup_candle_count)
|
||||||
|
timerange = plot_elements["timerange"]
|
||||||
|
trades = plot_elements["trades"]
|
||||||
|
|
||||||
|
pair = args.pair
|
||||||
|
if pair not in plot_elements["ohlcv"]:
|
||||||
|
raise SystemExit(f"No OHLCV for {pair}")
|
||||||
|
|
||||||
|
data = strategy.analyze_ticker(plot_elements["ohlcv"][pair], {"pair": pair})
|
||||||
|
data = trim_dataframe(data, timerange)
|
||||||
|
|
||||||
|
if not trades.empty:
|
||||||
|
trades_pair = trades.loc[trades["pair"] == pair]
|
||||||
|
trades_pair = extract_trades_of_period(data, trades_pair)
|
||||||
|
else:
|
||||||
|
trades_pair = trades
|
||||||
|
|
||||||
|
fig = build_figure(pair, data, trades_pair)
|
||||||
|
out_name = args.outfile or f"freqtrade-plot-{pair_to_filename(pair)}-{args.timeframe}.html"
|
||||||
|
store_plot_file(fig, filename=out_name, directory=Path(cfg["user_data_dir"]) / "plot")
|
||||||
|
logger.info("Open: user_data/plot/%s", out_name)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
60
user_data/config.json
Normal file
60
user_data/config.json
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
{
|
||||||
|
"max_open_trades": 1,
|
||||||
|
"stake_currency": "USDT",
|
||||||
|
"stake_amount": "unlimited",
|
||||||
|
"tradable_balance_ratio": 0.99,
|
||||||
|
"fiat_display_currency": "USD",
|
||||||
|
"dry_run": true,
|
||||||
|
"dry_run_wallet": 10000,
|
||||||
|
"cancel_open_orders_on_exit": false,
|
||||||
|
"trading_mode": "futures",
|
||||||
|
"margin_mode": "isolated",
|
||||||
|
"unfilledtimeout": {
|
||||||
|
"entry": 10,
|
||||||
|
"exit": 10,
|
||||||
|
"exit_timeout_count": 0,
|
||||||
|
"unit": "minutes"
|
||||||
|
},
|
||||||
|
"entry_pricing": {
|
||||||
|
"price_side": "same",
|
||||||
|
"use_order_book": true,
|
||||||
|
"order_book_top": 1,
|
||||||
|
"price_last_balance": 0.0,
|
||||||
|
"check_depth_of_market": {
|
||||||
|
"enabled": false,
|
||||||
|
"bids_to_ask_delta": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"exit_pricing": {
|
||||||
|
"price_side": "same",
|
||||||
|
"use_order_book": true,
|
||||||
|
"order_book_top": 1
|
||||||
|
},
|
||||||
|
"exchange": {
|
||||||
|
"name": "binance",
|
||||||
|
"key": "",
|
||||||
|
"secret": "",
|
||||||
|
"ccxt_config": {},
|
||||||
|
"ccxt_async_config": {},
|
||||||
|
"pair_whitelist": [
|
||||||
|
"BTC/USDT:USDT"
|
||||||
|
],
|
||||||
|
"pair_blacklist": []
|
||||||
|
},
|
||||||
|
"pairlists": [
|
||||||
|
{
|
||||||
|
"method": "StaticPairList"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"timeframe": "15m",
|
||||||
|
"dataformat_ohlcv": "feather",
|
||||||
|
"dataformat_trades": "feather",
|
||||||
|
"fee": 0.0005,
|
||||||
|
"strategy": "SampleStrategy",
|
||||||
|
"bot_name": "428-backtester",
|
||||||
|
"initial_state": "running",
|
||||||
|
"force_entry_enable": false,
|
||||||
|
"internals": {
|
||||||
|
"process_throttle_secs": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
63
user_data/strategies/SampleStrategy.py
Normal file
63
user_data/strategies/SampleStrategy.py
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
"""
|
||||||
|
Minimal sample strategy for the 428 / Integral backtester scaffold.
|
||||||
|
|
||||||
|
Replace this file (or add your own under user_data/strategies/) and set
|
||||||
|
STRATEGY=<ClassName> when running scripts. Defaults in config / compose
|
||||||
|
point here so a fresh clone backtests without proprietary logic.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pandas import DataFrame
|
||||||
|
|
||||||
|
from freqtrade.strategy import IStrategy, IntParameter
|
||||||
|
import talib.abstract as ta
|
||||||
|
|
||||||
|
|
||||||
|
class SampleStrategy(IStrategy):
|
||||||
|
"""EMA crossover + RSI filter — placeholder only, not a production system."""
|
||||||
|
|
||||||
|
INTERFACE_VERSION = 3
|
||||||
|
timeframe = "15m"
|
||||||
|
can_short = True
|
||||||
|
|
||||||
|
minimal_roi = {"0": 0.04, "60": 0.02, "180": 0.01, "360": 0}
|
||||||
|
stoploss = -0.03
|
||||||
|
trailing_stop = False
|
||||||
|
process_only_new_candles = True
|
||||||
|
startup_candle_count = 50
|
||||||
|
|
||||||
|
buy_rsi = IntParameter(20, 40, default=30, space="buy", optimize=True)
|
||||||
|
sell_rsi = IntParameter(60, 80, default=70, space="sell", optimize=True)
|
||||||
|
|
||||||
|
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||||
|
dataframe["ema_fast"] = ta.EMA(dataframe, timeperiod=12)
|
||||||
|
dataframe["ema_slow"] = ta.EMA(dataframe, timeperiod=26)
|
||||||
|
dataframe["rsi"] = ta.RSI(dataframe, timeperiod=14)
|
||||||
|
return dataframe
|
||||||
|
|
||||||
|
def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||||
|
dataframe.loc[
|
||||||
|
(dataframe["ema_fast"] > dataframe["ema_slow"])
|
||||||
|
& (dataframe["rsi"] < self.buy_rsi.value)
|
||||||
|
& (dataframe["volume"] > 0),
|
||||||
|
"enter_long",
|
||||||
|
] = 1
|
||||||
|
|
||||||
|
dataframe.loc[
|
||||||
|
(dataframe["ema_fast"] < dataframe["ema_slow"])
|
||||||
|
& (dataframe["rsi"] > self.sell_rsi.value)
|
||||||
|
& (dataframe["volume"] > 0),
|
||||||
|
"enter_short",
|
||||||
|
] = 1
|
||||||
|
return dataframe
|
||||||
|
|
||||||
|
def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||||
|
dataframe.loc[
|
||||||
|
(dataframe["ema_fast"] < dataframe["ema_slow"]) & (dataframe["volume"] > 0),
|
||||||
|
"exit_long",
|
||||||
|
] = 1
|
||||||
|
dataframe.loc[
|
||||||
|
(dataframe["ema_fast"] > dataframe["ema_slow"]) & (dataframe["volume"] > 0),
|
||||||
|
"exit_short",
|
||||||
|
] = 1
|
||||||
|
return dataframe
|
||||||
Loading…
Add table
Reference in a new issue