mirror of
https://github.com/artemium428/428-backtester.git
synced 2026-09-15 10:26:19 +00:00
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