From 99de26e7f03f6daa1d52eea4ae9f4375d7837b03 Mon Sep 17 00:00:00 2001 From: Artemii Peretiachenko Date: Sun, 2 Aug 2026 23:55:44 +0200 Subject: [PATCH] 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 --- .gitignore | 41 +++ README.md | 106 ++++++++ docker-compose.yml | 18 ++ requirements.txt | 3 + scripts/_env.sh | 21 ++ scripts/apply_hyperopt_params.sh | 83 ++++++ scripts/backtest.sh | 16 ++ scripts/download_data.sh | 15 ++ scripts/hyperopt.sh | 38 +++ scripts/plot.sh | 32 +++ scripts/plot_trades_chart.py | 335 +++++++++++++++++++++++++ user_data/config.json | 60 +++++ user_data/strategies/SampleStrategy.py | 63 +++++ 13 files changed, 831 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100644 requirements.txt create mode 100755 scripts/_env.sh create mode 100755 scripts/apply_hyperopt_params.sh create mode 100755 scripts/backtest.sh create mode 100755 scripts/download_data.sh create mode 100755 scripts/hyperopt.sh create mode 100755 scripts/plot.sh create mode 100644 scripts/plot_trades_chart.py create mode 100644 user_data/config.json create mode 100644 user_data/strategies/SampleStrategy.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8f005bd --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..3df2849 --- /dev/null +++ b/README.md @@ -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/.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. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b0617be --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d4d3700 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +freqtrade[hyperopt]>=2025.1 +plotly>=5.0 +scipy>=1.11 diff --git a/scripts/_env.sh b/scripts/_env.sh new file mode 100755 index 0000000..62aea08 --- /dev/null +++ b/scripts/_env.sh @@ -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 diff --git a/scripts/apply_hyperopt_params.sh b/scripts/apply_hyperopt_params.sh new file mode 100755 index 0000000..c7248d8 --- /dev/null +++ b/scripts/apply_hyperopt_params.sh @@ -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/.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 diff --git a/scripts/backtest.sh b/scripts/backtest.sh new file mode 100755 index 0000000..168abef --- /dev/null +++ b/scripts/backtest.sh @@ -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 \ + "$@" diff --git a/scripts/download_data.sh b/scripts/download_data.sh new file mode 100755 index 0000000..c8e25ec --- /dev/null +++ b/scripts/download_data.sh @@ -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}" diff --git a/scripts/hyperopt.sh b/scripts/hyperopt.sh new file mode 100755 index 0000000..b38ea15 --- /dev/null +++ b/scripts/hyperopt.sh @@ -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[@]}" "$@" diff --git a/scripts/plot.sh b/scripts/plot.sh new file mode 100755 index 0000000..3473442 --- /dev/null +++ b/scripts/plot.sh @@ -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/" diff --git a/scripts/plot_trades_chart.py b/scripts/plot_trades_chart.py new file mode 100644 index 0000000..d7b7030 --- /dev/null +++ b/scripts/plot_trades_chart.py @@ -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}}", + ), + 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}}", + ), + 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}}", + ), + 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()) diff --git a/user_data/config.json b/user_data/config.json new file mode 100644 index 0000000..3aa68be --- /dev/null +++ b/user_data/config.json @@ -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 + } +} diff --git a/user_data/strategies/SampleStrategy.py b/user_data/strategies/SampleStrategy.py new file mode 100644 index 0000000..20061ec --- /dev/null +++ b/user_data/strategies/SampleStrategy.py @@ -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= 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