diff --git a/.dockerignore b/.dockerignore index 42920cb..bc17919 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,3 +1,21 @@ -# The image only needs the Dockerfile; the repo is mounted at runtime. +# The image carries the code; docker-compose mounts user_data/ from the host. * !Dockerfile +!panel +!ridgeline +!scripts +!user_data +user_data/config.private.json +user_data/data +user_data/paper +user_data/replays +user_data/logs +user_data/audit +user_data/backtest_results +user_data/hyperopt_results +user_data/research/runs +user_data/research/bias +user_data/plot +user_data/risk_state.json +**/*.sqlite* +**/__pycache__ diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..86d8efb --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +# Windows scripts need CRLF line endings to run reliably from cmd/PowerShell. +*.cmd text eol=crlf +*.bat text eol=crlf +*.ps1 text eol=crlf +*.iss text eol=crlf diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..8d856b3 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,117 @@ +name: Release + +# Pushing a tag like v0.1.0 builds and smoke-tests the Windows installer and +# the Docker image, publishes the image to ghcr.io/devam2001/ridgeline and +# creates the GitHub release with the installer attached. +# Pull requests that touch packaging build and test both without publishing. + +on: + push: + tags: ["v*"] + pull_request: + paths: + - "installer/**" + - "Dockerfile" + - ".dockerignore" + - "docker-compose.yml" + - "requirements.txt" + - ".github/workflows/release.yml" + +permissions: + contents: read + +env: + IMAGE: ghcr.io/devam2001/ridgeline + +jobs: + windows-installer: + runs-on: windows-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Version + shell: pwsh + run: | + $v = (Select-String -Path pyproject.toml -Pattern '^version = "(.+)"').Matches[0].Groups[1].Value + if ($env:GITHUB_REF_TYPE -eq "tag" -and $env:GITHUB_REF_NAME -ne "v$v") { + throw "tag $env:GITHUB_REF_NAME doesn't match pyproject.toml version $v" + } + "VERSION=$v" >> $env:GITHUB_ENV + + - name: Inno Setup + shell: pwsh + run: | + $iscc = Join-Path ${env:ProgramFiles(x86)} "Inno Setup 6\ISCC.exe" + if (-not (Get-Command iscc -ErrorAction SilentlyContinue) -and -not (Test-Path $iscc)) { + choco install innosetup --no-progress -y + } + + - name: Build installer + shell: pwsh + run: ./installer/build.ps1 -Python (Split-Path (Get-Command python).Source) -Version $env:VERSION + + - name: Smoke-test installer + shell: pwsh + run: ./installer/smoke-test.ps1 -Installer "build/installer/Ridgeline-Setup-$env:VERSION.exe" + + - uses: actions/upload-artifact@v4 + with: + name: windows-installer + path: build/installer/*.exe + if-no-files-found: error + + docker: + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + + - name: Version + run: echo "VERSION=$(sed -n 's/^version = "\(.*\)"/\1/p' pyproject.toml)" >> "$GITHUB_ENV" + + - name: Build + run: docker build --build-arg VERSION="$VERSION" -t "$IMAGE:$VERSION" -t "$IMAGE:latest" . + + - name: Smoke-test image + run: | + docker run --rm "$IMAGE:$VERSION" sh -c ' + python scripts/init_private_config.py > /dev/null && + freqtrade list-strategies --userdir user_data -c user_data/config.json && + python -c "import panel.app; print(\"panel imports ok\")"' + + - name: Publish + if: github.ref_type == 'tag' + run: | + echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin + docker push "$IMAGE:$VERSION" + docker push "$IMAGE:latest" + + release: + if: github.ref_type == 'tag' + needs: [windows-installer, docker] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + + - uses: actions/download-artifact@v4 + with: + name: windows-installer + path: dist + + - name: Create release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release create "$GITHUB_REF_NAME" dist/*.exe "Crypto Trading Bot Case Study.pdf" \ + --title "Ridgeline $GITHUB_REF_NAME" \ + --notes-file "docs/releases/$GITHUB_REF_NAME.md" diff --git a/.gitignore b/.gitignore index b4493fc..9d2189f 100644 --- a/.gitignore +++ b/.gitignore @@ -21,5 +21,7 @@ all_usdt.json reports/ user_data/audit/ user_data/risk_state.json +*.reconcile.json .pytest_cache/ .ruff_cache/ +build/ diff --git a/Crypto Trading Bot Case Study.pdf b/Crypto Trading Bot Case Study.pdf index 223c56f..f5d71b5 100644 Binary files a/Crypto Trading Bot Case Study.pdf and b/Crypto Trading Bot Case Study.pdf differ diff --git a/Dockerfile b/Dockerfile index 1ba6b81..ae6f308 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,24 @@ -# ridgeline runtime: the official Freqtrade image plus the few extras the -# panel and watchdog use. The repository itself is mounted at /ridgeline by -# docker-compose, so data, databases, logs and the audit trail live on the host. +# ridgeline runtime: the official Freqtrade image, the few extras the panel and +# watchdog use, and the ridgeline code itself. Releases publish it as +# ghcr.io/devam2001/ridgeline, so a server can pull it instead of building. +# docker-compose mounts user_data/ from the host: config, strategies, data, +# databases, logs and the audit trail stay there. FROM freqtradeorg/freqtrade:2026.8 +ARG VERSION=dev +LABEL org.opencontainers.image.title="ridgeline" \ + org.opencontainers.image.description="Freqtrade bot with paper trading, research gates, risk controls and reporting" \ + org.opencontainers.image.source="https://github.com/devam2001/ridgeline" \ + org.opencontainers.image.version="${VERSION}" + # joblib 1.6 dropped a module Freqtrade's hyperopt imports -RUN pip install --no-cache-dir "joblib<1.6" httpx psutil +RUN pip install --no-cache-dir "joblib<1.6" "httpx>=0.27" "psutil>=5.9" WORKDIR /ridgeline +COPY panel/ panel/ +COPY ridgeline/ ridgeline/ +COPY scripts/ scripts/ +# Defaults for a container started without the user_data mount. +COPY --chown=ftuser:ftuser user_data/ user_data/ + ENTRYPOINT [] diff --git a/README.md b/README.md index 30ab7ff..a3c0087 100644 --- a/README.md +++ b/README.md @@ -25,12 +25,17 @@ panel cannot start a live bot at all. | `user_data/strategies/TrendRider4h.py` | 4h Donchian breakout trend-follower, BTC bear-market filter, ATR chandelier exit | | `user_data/strategies/TrendDipBuyer1h.py` | 1h buy-the-dip inside an uptrend (mean reversion) | | `user_data/strategies/EmaRsiMomentum.py` | original 1h EMA-crossover + RSI strategy (hyperoptable) | +| `user_data/strategies/MomentumRotation1d.py` | daily rotation into the 3 strongest coins by risk-adjusted momentum while BTC is in an uptrend (candidate) | | `user_data/config.json` | exchange, pairs, risk settings - **no secrets** | | `user_data/config.private.json` | API login, JWT secret, exchange keys - **never share/commit** (loaded automatically via `add_config_files`) | | `panel/` | control panel (FastAPI + plain JS) | | `scripts/research.py` | IS/OOS backtests, walk-forward, fee stress, bias checks, go-live gates | -| `ridgeline/` | shared package: risk limits (`risk.py`, `freqtrade_risk.py`), audit trail, tear-sheet reports, alerts | +| `ridgeline/` | shared package: risk limits (`risk.py`, `freqtrade_risk.py`), audit trail, tear-sheet reports, paper-vs-replay reconciliation, alerts | | `scripts/report.py`, `scripts/watchdog.py` | tear-sheet generator; health/risk watchdog with Telegram alerts | +| `scripts/reconcile.py` | the last go-live gate: compares a paper session with a replay of the same weeks | +| `scripts/update_data.py` | downloads/refreshes market data and the live pair list (monthly) | +| `scripts/case_study.py` | builds the research-format case study PDF from the latest results | +| `installer/`, `.github/workflows/release.yml` | Windows installer and the release pipeline (installer + Docker image) | | `tests/`, `.github/workflows/ci.yml` | test suite (risk, audit, lookahead, universe, panel, hygiene) run by GitHub Actions | | `Dockerfile`, `docker-compose.yml` | 24/7 deployment: bot + monitor-mode panel + watchdog | | `docs/OPERATIONS.md` | runbook: risk limits, kill switch, alerts, VPS deployment, incidents, monthly routine | @@ -38,16 +43,35 @@ panel cannot start a live bot at all. ## 1. Install +### Windows installer (easiest) + +Download `Ridgeline-Setup-.exe` from the +[latest release](https://github.com/devam2001/ridgeline/releases/latest) and run it. +- It installs for your user only, so no admin rights are needed. +- It brings its own Python, Freqtrade and FreqUI. +- It adds **Ridgeline** (the panel) and **Update market data** to the Start menu. +- The installer isn't code-signed, so Windows SmartScreen may warn the first + time. Choose **More info → Run anyway**. +- Your `user_data` (private config, paper sessions, data, audit trail) is kept + when you upgrade or uninstall. + +### Docker (servers) + +A server can pull the released image instead of building it. See +[docs/OPERATIONS.md](docs/OPERATIONS.md#deploying-on-a-vps-docker). + +### From source + ```bash python -m venv .venv .venv\Scripts\activate # macOS/Linux: source .venv/bin/activate -pip install "freqtrade[hyperopt]" "joblib<1.6" +pip install -r requirements.txt freqtrade install-ui # downloads FreqUI -freqtrade list-strategies --userdir user_data # all three should show OK +freqtrade list-strategies --userdir user_data # all four should show OK ``` -`joblib<1.6` is needed because joblib 1.6 removed a module Freqtrade's -hyperopt still imports. +`requirements.txt` pins Freqtrade and `joblib<1.6`. The joblib pin is needed +because joblib 1.6 removed a module Freqtrade's hyperopt still imports. Create the private config (API secrets, gitignored). This never overwrites an existing file: @@ -79,8 +103,9 @@ terminal, stop that one first (Ctrl+C) before using Start. ## 3. Data Research picks pairs **point-in-time**, so it needs daily candles for every -Binance USDT pair, not just today's favourites. Do this once, then top up -occasionally: +Binance USDT pair, not just today's favourites. `python scripts/update_data.py` +runs all of the steps below (it's also the installer's **Update market data** +shortcut). Do this once, then top up monthly: ```bash freqtrade list-pairs --userdir user_data --quote USDT --trading-mode spot --print-json > all_usdt.json @@ -150,6 +175,36 @@ Then it must paper-trade for **4+ weeks** with results close to a replay of the same weeks. If nothing passes, the answer is "don't go live yet" - not "loosen the gates". +### Paper vs replay (the last gate) + +Use the panel's **Paper trading → Paper vs replay** card, or run: + +```bash +python scripts/reconcile.py +``` + +It replays the exact period the session ran, with the same strategy, pairs and +starting balance. Then it matches the trades one by one (same pair, entries +within 1.5 candles). The session is "close to the replay" when all of these hold: + +- it ran for at least 28 days +- at least 80% of trades happened in both +- realised return is within 2 percentage points of the replay +- fills cost at most 0.3% per side compared with the replay's candle prices + +The result also splits the return gap into three parts: +- trades in both, which shows execution differences +- paper-only trades +- replay-only trades + +It flags anything the replay can't reproduce: +- manual pauses, resumes and flattens +- drawdown halts +- strategy code or parameters that changed mid-session + +The Research tab shows the newest result for each strategy. +See [docs/OPERATIONS.md](docs/OPERATIONS.md#paper-vs-replay). + Plain backtests still work as usual: ```bash @@ -208,6 +263,7 @@ The panel has the same: **Performance tear sheet** on Paper trading, and **Open ```bash python scripts/init_private_config.py docker compose up -d --build # bot + panel (monitor mode) + watchdog +# or, without building: docker compose pull && docker compose up -d ``` The watchdog sends Telegram alerts when: @@ -229,6 +285,7 @@ The tests cover: - audit tamper detection - a lookahead check: signals must not change when future candles are removed - point-in-time universe selection +- paper-vs-replay trade matching, gap breakdown and checks - the panel's dry-run guard and kill-switch sequence - repo hygiene: no secrets, and no live config in git diff --git a/RESEARCH.md b/RESEARCH.md index dff261a..2f09747 100644 --- a/RESEARCH.md +++ b/RESEARCH.md @@ -2,6 +2,9 @@ **Generated:** 2026-10-11 by `python scripts/research.py gates`. Raw data is in `user_data/research/latest.json`, and the panel's **Research** tab shows the same numbers. +The same run with a 65 USDT (≈100 AUD) account is in `latest-65.json` +(`research.py gates --balance 65`). The case study PDF is built from both +(`python scripts/case_study.py`). **Setup** - Long-only Binance spot, starting with $1,000, at most 3 trades open at once. @@ -27,7 +30,8 @@ | Strategy | IS return | OOS return | OOS max DD | OOS profit factor | OOS trades | Walk-forward | Gates | |---|---|---|---|---|---|---|---| -| **TrendRider4h** | +5.6% | **+17.3%** | **9.8%** | **1.43** | 99 | 64% | **8 / 9** | +| **TrendRider4h** | +5.6% | **+17.3%** | 9.8% | 1.43 | 99 | 64% | **8 / 9** | +| MomentumRotation1d (new) | +24.4% | +13.1% | **5.2%** | **1.59** | 63 | 57% | 7 / 9 | | EmaRsiMomentum | +7.7% | +5.1% | 18.1% | 1.13 | 74 | 43% | 6 / 9 | | TrendDipBuyer1h | −8.0% | −17.8% | 19.2% | 0.73 | 300 | 36% | 4 / 9 | @@ -52,6 +56,95 @@ TrendRider4h fails only the sample-size gate, by **one trade**: 99 against 100. The gates stay as they are. Each new month of data adds about 6 OOS trades, so the next monthly re-run will settle it either way. +MomentumRotation1d fails the sample-size gate (63 trades) and the walk-forward +gate (57% against 60%). + +## Small accounts (≈100 AUD) + +Same gates, starting with 65 USDT (about 100 AUD at an assumed 0.65 USD per AUD): + +| Strategy | OOS balance (USDT) | OOS return | at 1,000 USDT | OOS max DD | Trades | Gates | +|---|---|---|---|---|---|---| +| TrendRider4h | 65 → 76.35 | +17.5% | +17.3% | 9.7% | 99 | 8 / 9 | +| MomentumRotation1d | 65 → 74.08 | +14.0% | +13.1% | 7.1% | 64 | 7 / 9 | +| EmaRsiMomentum | 65 → 68.29 | +5.1% | +5.1% | 18.1% | 74 | 6 / 9 | +| TrendDipBuyer1h | 65 → 53.49 | −17.7% | −17.8% | 19.1% | 300 | 4 / 9 | + +- **A 100 AUD account works.** TrendRider4h's positions came out at about + 6–26 USDT (median 8), above Binance's minimum order value. So it placed the + same trades as the 1,000 USDT account. +- **Fees don't penalise it.** They are proportional, and there's no subscription. +- **The absolute profit is small:** +17% of 100 AUD is about 17 AUD over 16 months. +- **The rotation's walk-forward is weaker at 65 USDT:** 50%, against 57% at + 1,000. Minimum order sizes change a few of its trades. It fails the same two + gates either way. + +## More risk per trade + +Can the profit be raised by taking more risk? TrendRider4h at 65 USDT, with +the portfolio caps loosened in proportion: + +| Risk per trade | Caps (position / open risk / daily loss / halt) | IS | OOS | OOS max DD | OOS profit factor | +|---|---|---|---|---|---| +| 1.5% (default) | 35% / 4.5% / 3% / 15% | +5.8% | **+17.5%** | 9.7% | 1.44 | +| 3.0% | 50% / 9% / 6% / 25% | +26.9% | +20.7% | 19.2% | 1.26 | +| 4.5% | 70% / 13.5% / 9% / 30% | **−20.3%** | +13.1% | 27.1% | 1.14 | + +- **Doubling the risk** added about 3 points of OOS return. It doubled the + drawdown and pushed the profit factor below the 1.3 gate. +- **Tripling it** lost money in-sample. + +More risk doesn't create more edge, so the default stays at 1.5%. + +## MomentumRotation1d (new candidate) + +**What it does:** +- Every day it ranks the whole point-in-time universe by 14-day + volatility-adjusted momentum. +- It holds the top 3 while BTC is above its daily EMA200. +- It sells a coin when it drops out of the top 6, or on an ATR trailing stop. + +**How it was built:** +- Its settings were chosen on in-sample data only, then the OOS was looked at once. +- After that, one bug flagged by the stability check was fixed: the BTC EMA200 + warm-up went from 210 to 360 days. + +**Results:** +- **Lowest drawdown and best profit factor out of sample:** 5.2% and 1.59, + with +13.1% return. Still +12.4% with fees raised by half. +- **Fragile.** In-sample return swung with the momentum lookback: + + | Lookback | 7d | 10d | 14d | 21d | 30d | 60d | + |---|---|---|---|---|---|---| + | IS return (65 USDT, before the warm-up fix) | −15.4% | +25.1% | **+37.2%** | −4.0% | −7.6% | −5.2% | + + A peak that narrow means part of the 14-day result is probably luck. +- **The design idea isn't supported.** It was meant to hold the leaders + through rallies. In the +98% quarter (2024-09 → 12) it made only +0.8%, + against +30.7% for TrendRider4h. In the −41% quarter it lost 10.7%. + +## Changes to the checks in this run + +Both changes measure the same thing more correctly. Neither changes any +strategy's eligibility. + +**Lookahead check for cross-sectional strategies.** Freqtrade's +lookahead-analysis re-runs each signal with only that one pair loaded, so a +ranking across pairs always looked "biased" (rank 5 in the full run against 1 +when alone). + +Strategies that declare `cross_sectional = True` are now checked differently: +- `research.py` backtests 2024 again with later data removed from every pair at once. +- Every earlier trade must be identical. +- For MomentumRotation1d, all 132 compared trades matched. +- A unit test also checks this on synthetic data. + +**Indicator stability is measured at the strategy's own warm-up or longer.** +The old check also counted runs with fewer warm-up candles than the strategy +declares, which never happens: Freqtrade always loads the declared warm-up. +- MomentumRotation1d: 0.39% at its 360 candles (was reported as 6.0%, from the 199-candle column). +- TrendRider4h: now 0.0% (was 0.2%, from columns below its 1,260 candles). + ## Why this run replaces the earlier ones 1. **Hand-picked pairs (15).** OOS +21.1%, IS +82.5%. @@ -91,7 +184,7 @@ The gap between the IS results is the most important finding: - **Robustness:** - Still +15.4% OOS with fees raised by half. - Freqtrade's lookahead check finds no bias. - - Indicators are stable whatever the warm-up length (worst deviation 0.2%). + - Indicators are stable at its warm-up length (0.0% deviation at 1,260 candles or more). - 4h vs 1h intra-candle detail gives near-identical results (+35.7% vs +36.2%, measured on the earlier 26-pair set). - **It trades rarely:** 5–7 trades a month. The stats are noisy at this sample size. @@ -113,10 +206,17 @@ The gap between the IS results is the most important finding: ## Next steps 1. **Paper-trade TrendRider4h for 4+ weeks** from the panel. Use Reset wallet and pick TrendRider4h. `config.json` now holds today's point-in-time top 25. + - Then run **Paper vs replay** on that session, or + `python scripts/reconcile.py `. + - That's the last gate: the paper results must be close to a replay of + the same weeks. 2. **Re-run `research.py gates` monthly.** New data adds OOS trades, and the 100-trade gate will be settled honestly. 3. **Optionally re-hyperopt TrendRider4h** on point-in-time IS universes. Treat it as a new candidate and judge it only on OOS. +4. **Keep MomentumRotation1d as a candidate, not a replacement.** It needs more + OOS trades and a better walk-forward record. Don't re-tune its lookback on + the OOS period. ## Reproduce ```bash diff --git a/docker-compose.yml b/docker-compose.yml index 775e8be..bda8677 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,12 @@ # 24/7 deployment: trading bot + monitor-mode panel + watchdog. # # python scripts/init_private_config.py # once: API secrets (gitignored) -# docker compose up -d --build +# docker compose up -d --build # build from this checkout, or: +# docker compose pull && docker compose up -d # use the released image +# +# The code comes from the image; only user_data/ (config, strategies, data, +# databases, logs, audit trail) is mounted from the host. RIDGELINE_VERSION +# picks a released image tag (default: latest). # # Trades in whatever mode user_data/config.json says - dry_run: true (paper) # unless you deliberately change it. Ports are published on 127.0.0.1 only; @@ -9,10 +14,10 @@ x-ridgeline: &ridgeline build: . - image: ridgeline:latest + image: ghcr.io/devam2001/ridgeline:${RIDGELINE_VERSION:-latest} restart: unless-stopped volumes: - - .:/ridgeline + - ./user_data:/ridgeline/user_data working_dir: /ridgeline logging: driver: json-file diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 9301d58..24cbe00 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -10,6 +10,7 @@ How to run ridgeline 24/7, watch it, and act when something goes wrong. - [Deploying on a VPS (Docker)](#deploying-on-a-vps-docker) - [Incident checklist](#incident-checklist) - [Monthly routine](#monthly-routine) +- [Paper vs replay](#paper-vs-replay) - [Reports](#reports) --- @@ -105,10 +106,16 @@ sudo apt-get update && sudo apt-get install -y docker.io docker-compose-v2 git git clone https://github.com/devam2001/ridgeline.git && cd ridgeline python3 scripts/init_private_config.py # API secrets; then add Telegram keys mkdir -p user_data/logs && sudo chown -R 1000:1000 user_data # the image runs as uid 1000 -docker compose up -d --build +docker compose pull # the released image, ghcr.io/devam2001/ridgeline +docker compose up -d # (or: docker compose up -d --build to build from the checkout) docker compose ps # bot should become "healthy" within ~3 min ``` +The image contains the code. Only `user_data/` is mounted from the host: +config, strategies, data, databases, logs and the audit trail. To pin a +release, set `RIDGELINE_VERSION=0.1.0` (in `.env` or the shell). The default +is `latest`. + Data and the strategy's pair list (see the README's Data section) can be downloaded inside the container: @@ -134,7 +141,8 @@ Then the panel is at http://127.0.0.1:8090 and FreqUI at http://127.0.0.1:8080. **Updating:** ```bash -git pull && docker compose up -d --build +git pull && docker compose pull && docker compose up -d # released image +git pull && docker compose up -d --build # or build from the checkout ``` ## Incident checklist @@ -161,11 +169,73 @@ git pull && docker compose up -d --build python scripts/research.py universe --download --write-config # refresh data + live pair list python scripts/research.py gates # re-test every strategy on new OOS data python scripts/report.py --db user_data/paper/.sqlite --month YYYY-MM +python scripts/reconcile.py # paper vs replay of the same weeks ``` If you change the pair list or the strategy, restart the bot: `docker compose restart bot`, or Reset in the panel. +## Paper vs replay + +The last go-live gate: 4+ weeks of paper trading whose results are close to a +replay (backtest) of the same weeks. Run it from **Paper trading → Paper vs +replay** in the panel, or from the command line: + +```bash +python scripts/reconcile.py # a panel session +python scripts/reconcile.py --db user_data/tradesv3.docker.sqlite --log user_data/logs/bot.log # the Docker bot +``` + +**What it does.** +1. Tops up candle data for the session's pairs. +2. Replays the session's period, starting at the candle its first decision + used and using the same strategy, pairs, starting balance and max open trades. +3. Matches trades: same pair, entries at most 1.5 candles apart. + +The result is saved next to the session's database as +`.reconcile.json`. The Research tab shows the newest result for each +strategy. + +**Where the settings come from.** A session started from the panel records +its strategy, pair list, balance, start time and a fingerprint of the +strategy code in `.meta.json`. That matters because `config.json`'s +pair list changes monthly. Older sessions and the Docker bot fall back to the +bot's log and database. `--strategy`, `--pairs`, `--balance`, `--start` and +`--end` override any of them. + +**Checks.** All must pass: + +| Check | Target | Why | +|---|---|---| +| Length | ≥ 28 days | Long enough to see a few trades and some market change | +| Trades in both | ≥ 80% | Signals fire the same way live as in the backtest | +| Return gap | ≤ 2 percentage points | The backtest's results are reachable | +| Fill cost | ≤ 0.3% per side | Fills land near the candle prices the backtest assumes | + +Fill cost is the average difference between paper and replay prices. It's +positive when paper paid more on entry or received less on exit. Exits only +count when both sides left for the same reason. + +**Reading a gap.** The return gap is split into three parts: +- **trades in both**: execution, meaning fills, stop timing and exit prices +- **paper-only trades** +- **replay-only trades** + +One-sided trades usually come from: +- an entry order that didn't fill within its 10-minute timeout +- a different equity, so a risk limit or the open-trade cap blocked one side +- signals right at the start or end of the period + +The result notes things the replay can't reproduce, so check those first: +- manual pause, resume or flatten (from the audit trail) +- drawdown halts, which need a manual resume live but resume automatically in backtests +- strategy code or parameters changed during the session +- paper trades newer than the latest closed candle + +**If it fails.** Don't loosen the targets. Find out why the two diverged. If +the backtest assumptions are wrong (for example, real fills are consistently +worse), fix the research model and re-run the gates before trying again. + ## Reports Two ways to get a tear sheet: diff --git a/docs/releases/v0.1.0.md b/docs/releases/v0.1.0.md new file mode 100644 index 0000000..deddf04 --- /dev/null +++ b/docs/releases/v0.1.0.md @@ -0,0 +1,56 @@ +First release of Ridgeline: a Freqtrade bot for Binance spot with paper trading, an honest research pipeline, portfolio risk controls and reporting. + +**Paper money only.** The panel runs Freqtrade in dry-run mode with a fake wallet and cannot start a live bot. No strategy is eligible for real money yet (see below). + +## Install + +- **Windows:** download `Ridgeline-Setup-0.1.0.exe` below and run it. + - It installs for your user only (no admin rights) with its own Python, Freqtrade and FreqUI. + - Start **Ridgeline** from the Start menu; the panel opens at http://127.0.0.1:8090. + - Run **Update market data** once before using Replay or Research. It downloads several GB. + - The installer isn't code-signed, so SmartScreen may warn: choose *More info → Run anyway*. +- **Docker (servers):** `docker compose pull && docker compose up -d` uses `ghcr.io/devam2001/ridgeline:0.1.0`. See `docs/OPERATIONS.md`. +- **From source:** `pip install -r requirements.txt`. See the README. + +## What's included + +- **Control panel** + - Live paper trading with a fake wallet you choose. + - Fast historical replay. + - The research gate checklist. + - Risk controls with a kill switch. + - A hash-chained audit log. +- **Paper vs replay** (the last go-live gate): replays the exact weeks a paper session ran and matches the trades one by one. It checks four things: + - 28+ days of paper trading + - 80%+ of trades matched + - return within 2 points of the replay + - fills within 0.3% per side +- **Research pipeline**: + - point-in-time pair universes + - in-sample / out-of-sample split and walk-forward windows + - fee stress and bias checks + - nine go-live gates + - `--balance` to run all of it for small accounts + - a lookahead check that also works for strategies that rank pairs against each other +- **Four strategies**: TrendRider4h, MomentumRotation1d (new candidate), EmaRsiMomentum, TrendDipBuyer1h. +- **Portfolio risk limits**, enforced in backtests too: 35% max position, 4.5% max open risk, 3% daily loss limit, 15% drawdown halt. +- **Reports**: investor-style tear sheets, and the case study as a research paper (PDF, attached). +- **Operations**: watchdog with Telegram alerts, Docker deployment, CI. + +## Research results (out of sample, 2025-06-01 → 2026-10-10) + +The market fell 32.8% (equal-weight basket of the same coins) in this period. + +| Strategy | Return | Max drawdown | Gates | +|---|---|---|---| +| TrendRider4h | +17.3% | 9.8% | 8 / 9 (99 trades, needs 100) | +| MomentumRotation1d | +13.1% | 5.2% | 7 / 9 | +| EmaRsiMomentum | +5.1% | 18.1% | 6 / 9 | +| TrendDipBuyer1h | −17.8% | 19.2% | 4 / 9 | + +- **100 AUD (≈65 USDT) accounts work.** Percentage results match the 1,000 USDT runs. +- **More risk didn't help.** Doubling the risk per trade added ~3 points of return and doubled the drawdown. + +Details are in `RESEARCH.md` and the attached case study. + +Backtests are not a promise of future results. This is software, not financial advice. diff --git a/installer/INSTALL-NOTES.txt b/installer/INSTALL-NOTES.txt new file mode 100644 index 0000000..15891d3 --- /dev/null +++ b/installer/INSTALL-NOTES.txt @@ -0,0 +1,23 @@ +Ridgeline - paper-trading bot, research pipeline and control panel + +PAPER MONEY ONLY. The panel runs Freqtrade in dry-run mode with a fake +wallet. It cannot start a live bot, and nothing it does reaches your +exchange account. + +After installing: + 1. Start "Ridgeline" from the Start menu or desktop. The panel opens at + http://127.0.0.1:8090 in your browser. + 2. Paper trading works straight away: pick a starting balance and a + strategy, then press Start. + 3. Replay and research need historical candles. Run "Update market data" + from the Start menu once (it downloads several GB), then monthly. + +Your files live in the install folder's user_data\ directory: + - config.private.json: your own API credentials, created on first start + - paper\: paper-trading sessions + - data\: downloaded candles + - audit\: the audit trail +They are kept when you upgrade or uninstall. + +Backtests and paper results are not a promise of future profits. This is +software, not financial advice. Only ever risk money you can afford to lose. diff --git a/installer/Ridgeline.cmd b/installer/Ridgeline.cmd new file mode 100644 index 0000000..fc1c4eb --- /dev/null +++ b/installer/Ridgeline.cmd @@ -0,0 +1,11 @@ +@echo off +rem Starts the Ridgeline panel at http://127.0.0.1:8090 and opens it in your browser. +rem Close this window (or press Ctrl+C) to stop the panel. A paper-trading bot +rem started from the panel keeps running; stop it from the panel first. +title Ridgeline panel +cd /d "%~dp0" +if not exist "user_data\config.private.json" "python\python.exe" scripts\init_private_config.py +start "" /b cmd /c "timeout /t 4 /nobreak >nul & start "" http://127.0.0.1:8090" +echo Ridgeline panel: http://127.0.0.1:8090 (close this window to stop it) +"python\python.exe" -m uvicorn panel.app:app --host 127.0.0.1 --port 8090 +if errorlevel 1 pause diff --git a/installer/Update market data.cmd b/installer/Update market data.cmd new file mode 100644 index 0000000..9304d50 --- /dev/null +++ b/installer/Update market data.cmd @@ -0,0 +1,9 @@ +@echo off +rem Downloads Binance candles and refreshes the live pair list (README, "Data"). +rem The first run downloads several GB and can take a while; later runs only top up. +rem Run it monthly, then restart any paper session to pick up the new pair list. +title Ridgeline - update market data +cd /d "%~dp0" +if not exist "user_data\config.private.json" "python\python.exe" scripts\init_private_config.py +"python\python.exe" scripts\update_data.py +pause diff --git a/installer/build.ps1 b/installer/build.ps1 new file mode 100644 index 0000000..a11e244 --- /dev/null +++ b/installer/build.ps1 @@ -0,0 +1,68 @@ +<# +Builds the Ridgeline Windows installer (build\installer\Ridgeline-Setup-.exe). + + .\installer\build.ps1 -Python C:\Path\To\Python312 -Version 0.1.0 + +-Python folder of a Python 3.12 install to bundle (a full Windows install is + relocatable). Default: the folder of `py -3.12`. +-Version version shown in the installer (default: pyproject.toml's). + +Needs Inno Setup 6 (iscc.exe on PATH or in its default folder). GitHub's +Windows runners have it; the release workflow runs this script. +#> +param( + [string]$Python, + [string]$Version +) +$ErrorActionPreference = "Stop" +$root = Split-Path $PSScriptRoot -Parent +$build = Join-Path $root "build" +$stage = Join-Path $build "stage" + +if (-not $Version) { + $Version = (Select-String -Path (Join-Path $root "pyproject.toml") -Pattern '^version = "(.+)"').Matches[0].Groups[1].Value +} +if (-not $Python) { + $Python = Split-Path (& py -3.12 -c "import sys; print(sys.executable)") +} +Write-Host "Building Ridgeline $Version with Python from $Python" + +if (Test-Path $stage) { Remove-Item -Recurse -Force $stage } +New-Item -ItemType Directory -Force $stage | Out-Null + +# 1. Private Python with every dependency and FreqUI. +# Copy the folder itself: copying "\*" into a path that doesn't exist +# yet turns the first FILE into that path and then fails on the subfolders. +Copy-Item -Recurse $Python (Join-Path $stage "python") +$py = Join-Path $stage "python\python.exe" +& $py -m pip install --disable-pip-version-check --no-warn-script-location -r (Join-Path $root "requirements.txt") +if ($LASTEXITCODE) { throw "pip install failed" } +& $py -m freqtrade install-ui +if ($LASTEXITCODE) { throw "freqtrade install-ui failed" } +# Caches and tests don't need to ship. +Get-ChildItem (Join-Path $stage "python") -Recurse -Directory -Filter "__pycache__" | Remove-Item -Recurse -Force + +# 2. The app. user_data ships only its tracked defaults: config, strategies, +# hyperopt loss, research results. Never private config, data or databases. +foreach ($dir in "panel", "ridgeline", "scripts", "docs") { + Copy-Item -Recurse (Join-Path $root $dir) (Join-Path $stage $dir) +} +foreach ($file in "README.md", "RESEARCH.md", "Crypto Trading Bot Case Study.pdf") { + Copy-Item (Join-Path $root $file) $stage +} +$ud = Join-Path $stage "user_data" +New-Item -ItemType Directory -Force (Join-Path $ud "research"), (Join-Path $ud "hyperopts") | Out-Null +Copy-Item (Join-Path $root "user_data\config.json") $ud +Copy-Item -Recurse (Join-Path $root "user_data\strategies") $ud +Copy-Item (Join-Path $root "user_data\hyperopts\*.py") (Join-Path $ud "hyperopts") +Copy-Item (Join-Path $root "user_data\research\*.json") (Join-Path $ud "research") +Get-ChildItem $stage -Recurse -Directory -Filter "__pycache__" | Remove-Item -Recurse -Force +Copy-Item (Join-Path $PSScriptRoot "Ridgeline.cmd"), (Join-Path $PSScriptRoot "Update market data.cmd"), (Join-Path $PSScriptRoot "INSTALL-NOTES.txt") $stage + +# 3. Compile. +$iscc = (Get-Command iscc -ErrorAction SilentlyContinue).Source +if (-not $iscc) { $iscc = Join-Path ${env:ProgramFiles(x86)} "Inno Setup 6\ISCC.exe" } +if (-not (Test-Path $iscc)) { throw "Inno Setup 6 not found (iscc.exe)" } +& $iscc "/DAppVersion=$Version" "/DStageDir=$stage" "/DOutputDir=$(Join-Path $build 'installer')" (Join-Path $PSScriptRoot "ridgeline.iss") +if ($LASTEXITCODE) { throw "Inno Setup failed" } +Get-ChildItem (Join-Path $build "installer") -Filter "*.exe" | ForEach-Object { Write-Host ("{0} {1:N0} MB" -f $_.FullName, ($_.Length / 1MB)) } diff --git a/installer/ridgeline.iss b/installer/ridgeline.iss new file mode 100644 index 0000000..7c5f406 --- /dev/null +++ b/installer/ridgeline.iss @@ -0,0 +1,71 @@ +; Inno Setup script for the Ridgeline Windows installer. +; Build it with installer\build.ps1, which stages a private Python with every +; dependency, FreqUI and the app files, then compiles this script. +; +; Installs per user (no admin rights) into %LOCALAPPDATA%\Programs\Ridgeline. +; Everything under user_data\ that you create (private config, paper +; sessions, market data, audit trail) is kept on upgrade and on uninstall. + +#ifndef AppVersion + #define AppVersion "0.0.0-dev" +#endif +#ifndef StageDir + #define StageDir "..\build\stage" +#endif +#ifndef OutputDir + #define OutputDir "..\build\installer" +#endif + +[Setup] +AppId={{6F7C2B1E-3A54-4F0B-9C8D-2E51A9D47B10} +AppName=Ridgeline +AppVersion={#AppVersion} +AppVerName=Ridgeline {#AppVersion} +AppPublisher=devam2001 +AppPublisherURL=https://github.com/devam2001/ridgeline +AppSupportURL=https://github.com/devam2001/ridgeline/issues +DefaultDirName={localappdata}\Programs\Ridgeline +DisableProgramGroupPage=yes +PrivilegesRequired=lowest +ArchitecturesAllowed=x64 +ArchitecturesInstallIn64BitMode=x64 +OutputDir={#OutputDir} +OutputBaseFilename=Ridgeline-Setup-{#AppVersion} +Compression=lzma2/ultra64 +SolidCompression=yes +WizardStyle=modern +UninstallDisplayName=Ridgeline {#AppVersion} +UninstallDisplayIcon={app}\python\python.exe +InfoBeforeFile={#StageDir}\INSTALL-NOTES.txt + +[Tasks] +Name: "desktopicon"; Description: "Create a desktop shortcut"; GroupDescription: "Shortcuts:" + +[InstallDelete] +; Start each version with a clean Python environment. +Type: filesandordirs; Name: "{app}\python" + +[Files] +Source: "{#StageDir}\*"; DestDir: "{app}"; Excludes: "\user_data\config.json"; Flags: recursesubdirs createallsubdirs ignoreversion +; config.json holds your live pair list and settings: install it once, never overwrite it. +Source: "{#StageDir}\user_data\config.json"; DestDir: "{app}\user_data"; Flags: onlyifdoesntexist uninsneveruninstall + +[Icons] +Name: "{autoprograms}\Ridgeline\Ridgeline"; Filename: "{app}\Ridgeline.cmd"; WorkingDir: "{app}"; Comment: "Start the Ridgeline panel (paper trading, replay, research)" +Name: "{autoprograms}\Ridgeline\Update market data"; Filename: "{app}\Update market data.cmd"; WorkingDir: "{app}"; Comment: "Download candles and refresh the live pair list (monthly)" +Name: "{autoprograms}\Ridgeline\Ridgeline folder"; Filename: "{app}" +Name: "{autoprograms}\Ridgeline\Uninstall Ridgeline"; Filename: "{uninstallexe}" +Name: "{autodesktop}\Ridgeline"; Filename: "{app}\Ridgeline.cmd"; WorkingDir: "{app}"; Tasks: desktopicon + +[Run] +; Creates user_data\config.private.json with random API credentials (never overwrites). +Filename: "{app}\python\python.exe"; Parameters: "scripts\init_private_config.py"; WorkingDir: "{app}"; Flags: runhidden; StatusMsg: "Creating your private settings..." +Filename: "{app}\Ridgeline.cmd"; WorkingDir: "{app}"; Description: "Start Ridgeline now"; Flags: postinstall nowait skipifsilent shellexec + +[UninstallDelete] +; Files Python creates at runtime; your user_data is left alone. +Type: filesandordirs; Name: "{app}\python" +Type: filesandordirs; Name: "{app}\panel\__pycache__" +Type: filesandordirs; Name: "{app}\ridgeline\__pycache__" +Type: filesandordirs; Name: "{app}\scripts\__pycache__" +Type: filesandordirs; Name: "{app}\user_data\strategies\__pycache__" diff --git a/installer/smoke-test.ps1 b/installer/smoke-test.ps1 new file mode 100644 index 0000000..cc48988 --- /dev/null +++ b/installer/smoke-test.ps1 @@ -0,0 +1,54 @@ +<# +Installs a built Ridgeline installer silently into a temporary folder and +checks that it works: private config created, FreqUI bundled, strategies +load, the panel starts and answers, and uninstalling keeps user_data. + + .\installer\smoke-test.ps1 -Installer build\installer\Ridgeline-Setup-0.1.0.exe + +The release workflow runs this on every build. +#> +param([Parameter(Mandatory = $true)][string]$Installer) +$ErrorActionPreference = "Stop" +$dir = Join-Path ([IO.Path]::GetTempPath()) "ridgeline-smoke" +if (Test-Path $dir) { Remove-Item -Recurse -Force $dir } + +$setup = Start-Process -Wait -PassThru (Resolve-Path $Installer) -ArgumentList "/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART", "/TASKS=", "/DIR=`"$dir`"" +if ($setup.ExitCode) { throw "installer exited with $($setup.ExitCode)" } + +$py = Join-Path $dir "python\python.exe" +foreach ($path in "user_data\config.private.json", "user_data\config.json", "Ridgeline.cmd", + "python\Lib\site-packages\freqtrade\rpc\api_server\ui\installed\index.html") { + if (-not (Test-Path (Join-Path $dir $path))) { throw "missing after install: $path" } +} + +Push-Location $dir +try { + & $py -m freqtrade list-strategies --userdir user_data -c user_data\config.json + if ($LASTEXITCODE) { throw "strategies failed to load" } + + $panel = Start-Process -PassThru -NoNewWindow -WorkingDirectory $dir $py -ArgumentList "-m", "uvicorn", "panel.app:app", "--host", "127.0.0.1", "--port", "8090" + try { + $strategies = $null + for ($i = 0; $i -lt 45 -and -not $strategies; $i++) { + Start-Sleep -Seconds 2 + try { $strategies = Invoke-RestMethod "http://127.0.0.1:8090/api/strategies" } catch { } + } + if ($strategies -notcontains "TrendRider4h") { throw "panel did not list the strategies: $strategies" } + $mode = Invoke-RestMethod "http://127.0.0.1:8090/api/mode" + Write-Host "panel ok, version $($mode.version): $($strategies -join ', ')" + } + finally { + Stop-Process -Id $panel.Id -Force -ErrorAction SilentlyContinue + } +} +finally { + Pop-Location +} + +$uninstall = Start-Process -Wait -PassThru (Join-Path $dir "unins000.exe") -ArgumentList "/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART" +if ($uninstall.ExitCode) { throw "uninstaller exited with $($uninstall.ExitCode)" } +# The uninstaller hands over to a helper process and returns early: wait for it. +for ($i = 0; $i -lt 60 -and (Test-Path (Join-Path $dir "python")); $i++) { Start-Sleep -Seconds 2 } +if (Test-Path (Join-Path $dir "python")) { throw "uninstall left the Python environment behind" } +if (-not (Test-Path (Join-Path $dir "user_data\config.private.json"))) { throw "uninstall removed the private config" } +Write-Host "install, panel and uninstall ok" diff --git a/panel/app.py b/panel/app.py index 89d202f..709a99f 100644 --- a/panel/app.py +++ b/panel/app.py @@ -12,8 +12,9 @@ from panel.bot import MONITOR_MODE, PaperBot, PaperBotError from panel.common import USERDIR, list_strategies, load_json -from panel.replay import REPLAY_DIR, ReplayJobs -from ridgeline import audit +from panel.replay import REPLAY_DIR, BacktestError, ReplayJobs, SessionReplays +from ridgeline import __version__, audit +from ridgeline.reconcile import session_info from ridgeline.report import build_tearsheet, load_trades_backtest, load_trades_db, session_balance STATIC = Path(__file__).parent / "static" @@ -23,6 +24,7 @@ bot = PaperBot() replays = ReplayJobs() +session_replays = SessionReplays() class PaperStart(BaseModel): @@ -100,7 +102,7 @@ def paper_reset(req: PaperReset): @app.get("/api/mode") def mode(): - return {"monitor": MONITOR_MODE} + return {"monitor": MONITOR_MODE, "version": __version__} @app.get("/api/risk") @@ -149,11 +151,30 @@ def _valid_month(month: Optional[str]) -> Optional[str]: return month or None -@app.get("/api/report", response_class=HTMLResponse) -def paper_report(session: str, month: Optional[str] = None): - db = USERDIR / "paper" / f"{_valid_id(session)}.sqlite" +def _session_db(session_id: str) -> Path: + db = USERDIR / "paper" / f"{_valid_id(session_id)}.sqlite" if not db.exists(): raise HTTPException(404, "No such paper session") + return db + + +@app.post("/api/paper/sessions/{session_id}/reconcile") +def reconcile_start(session_id: str): + db = _session_db(session_id) + try: + return session_replays.submit(session_info(db)) + except (ValueError, BacktestError) as e: + raise HTTPException(409, str(e)) + + +@app.get("/api/paper/sessions/{session_id}/reconcile") +def reconcile_get(session_id: str): + return session_replays.get(session_id, _session_db(session_id)) or {"status": "none"} + + +@app.get("/api/report", response_class=HTMLResponse) +def paper_report(session: str, month: Optional[str] = None): + db = _session_db(session) try: trades = load_trades_db(db) return build_tearsheet(trades, session_balance(db), title="Ridgeline - paper trading report", @@ -202,6 +223,25 @@ def replay_history(): return replays.history() +def _latest_reconciliations() -> dict: + """Newest paper-vs-replay result per strategy (panel sessions and the + Docker bot's database), for the last go-live gate.""" + out = {} + for path in [*(USERDIR / "paper").glob("*.reconcile.json"), *USERDIR.glob("*.reconcile.json")]: + r = load_json(path, None) or {} + if r.get("status") != "done": + continue + strategy = r["session"]["strategy"] + if strategy not in out or r["generated"] > out[strategy]["generated"]: + out[strategy] = {"session_id": r["session"]["session_id"], "generated": r["generated"], + "days": r["period"]["days"], "close_to_replay": r["close_to_replay"], + "passed": sum(c["pass"] for c in r["checks"]), "checks": len(r["checks"])} + return out + + @app.get("/api/research") def research(): - return load_json(USERDIR / "research" / "latest.json", None) or {} + report = load_json(USERDIR / "research" / "latest.json", None) or {} + if report: + report["paper_checks"] = _latest_reconciliations() + return report diff --git a/panel/bot.py b/panel/bot.py index dbcf5e3..5f232ca 100644 --- a/panel/bot.py +++ b/panel/bot.py @@ -29,6 +29,7 @@ from panel.common import FREQTRADE, ROOT, USERDIR, config_args, load_json, merged_config from ridgeline import audit +from ridgeline.reconcile import strategy_fingerprint from ridgeline.risk import RiskLimits, build_state, limits_summary, load_halt, set_halt MONITOR_MODE = os.environ.get("PANEL_MODE", "").lower() == "monitor" @@ -110,6 +111,18 @@ def start(self, balance: float, strategy: str) -> dict: }, indent=2)) args = [*config_args(), "-c", str(session_cfg)] self._verify_dry_run(args) + # What this session runs, for reconciling it with a replay later: + # config.json's pair list changes monthly, and code can change mid-session. + cfg = merged_config() + (PAPER_DIR / f"{session_id}.meta.json").write_text(json.dumps({ + "session_id": session_id, + "strategy": strategy, + "balance": balance, + "pairs": cfg.get("exchange", {}).get("pair_whitelist", []), + "max_open_trades": cfg.get("max_open_trades"), + "started": datetime.now(timezone.utc).isoformat(timespec="seconds"), + "code": strategy_fingerprint(strategy), + }, indent=2)) log_path = PAPER_DIR / f"{session_id}.log" cmd = [*FREQTRADE, "trade", *args, "--strategy", strategy, "--logfile", str(log_path)] @@ -305,6 +318,7 @@ def sessions(self) -> list[dict]: rows = [] for db in sorted(PAPER_DIR.glob("*.sqlite"), reverse=True): cfg = load_json(db.with_suffix(".config.json"), {}) or {} + meta = load_json(db.with_suffix(".meta.json"), {}) or {} rows.append({"session_id": db.stem, "balance": cfg.get("dry_run_wallet"), - "size_kb": round(db.stat().st_size / 1024, 1)}) + "strategy": meta.get("strategy"), "size_kb": round(db.stat().st_size / 1024, 1)}) return rows diff --git a/panel/replay.py b/panel/replay.py index 1846300..0c57f1c 100644 --- a/panel/replay.py +++ b/panel/replay.py @@ -1,6 +1,8 @@ # Fast historical replay: run a Freqtrade backtest with a chosen starting # balance and date range, then reduce the result to what the UI and the # research scripts need (metrics, equity curve vs buy-and-hold, trades). +# Also replays a paper session's exact period to reconcile it against paper +# trading (ridgeline/reconcile.py has the comparison itself). import json import re @@ -15,8 +17,12 @@ import pandas as pd from panel.common import DATADIR, FREQTRADE, USERDIR, config_args +from ridgeline import audit +from ridgeline.reconcile import SessionInfo, load_paper_trades, load_replay_trades, reconcile, strategy_timeframe REPLAY_DIR = USERDIR / "replays" +# Daily EMA200 filters need ~200 days of history before the first candle. +WARMUP_DAYS = 250 class BacktestError(RuntimeError): @@ -67,6 +73,92 @@ def run_backtest( return summarize(outdir, strategy) +def download_data(pairs: list[str], since: pd.Timestamp, log_path: Path, + timeframes: tuple[str, ...] = ("1h", "4h", "1d")) -> None: + """Top up candle data for these pairs to the latest closed candle.""" + cmd = [*FREQTRADE, "download-data", *config_args(), "--pairs", *pairs, + "--timeframes", *timeframes, "--timerange", f"{since:%Y%m%d}-"] + with open(log_path, "w", encoding="utf-8", errors="replace") as log: + proc = subprocess.run(cmd, stdout=log, stderr=subprocess.STDOUT) + if proc.returncode != 0: + tail = log_path.read_text(encoding="utf-8", errors="replace").splitlines()[-15:] + raise BacktestError("Data download failed:\n" + "\n".join(tail)) + + +def replay_window(info: SessionInfo) -> tuple[str, pd.Timestamp]: + """The strategy's timeframe and the replay's first candle: the one the + session's first decision was based on. Raises if it hasn't closed yet.""" + timeframe = strategy_timeframe(info.strategy) + start = info.start.floor(pd.Timedelta(timeframe)) + first_close = start + pd.Timedelta(timeframe) + if first_close > pd.Timestamp.now(tz="UTC"): + raise BacktestError(f"Nothing to replay yet: the session's first {timeframe} candle closes at " + f"{first_close:%Y-%m-%d %H:%M} UTC.") + return timeframe, start + + +def replay_session(info: SessionInfo, outdir: Optional[Path] = None, download: bool = True) -> dict: + """Replay a paper session's exact period (same strategy, pairs, balance) + and reconcile it with what paper trading did.""" + timeframe, start = replay_window(info) + outdir = Path(outdir or REPLAY_DIR / f"session-{info.session_id}") + outdir.mkdir(parents=True, exist_ok=True) + if download: + # BTC/USDT feeds the strategies' market-regime filter even when it isn't traded. + download_data(sorted({*info.pairs, "BTC/USDT"}), start - pd.Timedelta(days=WARMUP_DAYS), + outdir / "download.log") + run_backtest(info.strategy, f"{int(start.timestamp())}-{int(info.end.timestamp())}", info.balance, + pairs=info.pairs, max_open_trades=info.max_open_trades, outdir=outdir) + replay, meta = load_replay_trades(outdir) + return reconcile(load_paper_trades(info.db), replay, info=info, replay_end=meta["end"], + timeframe=timeframe, events=audit.tail(100_000)) + + +def reconcile_path(db: Path) -> Path: + """Where a session's latest reconciliation is kept: next to its database.""" + return Path(db).with_suffix(".reconcile.json") + + +class SessionReplays: + """Paper-vs-replay reconciliations for the panel, one at a time. The result + is written next to the session's database, so it outlives the panel.""" + + def __init__(self): + self._pool = ThreadPoolExecutor(max_workers=1) + self._lock = threading.Lock() + self._running: dict[str, dict] = {} + + def submit(self, info: SessionInfo) -> dict: + replay_window(info) # fail fast if there's nothing to replay yet + with self._lock: + if info.session_id in self._running: + return self._running[info.session_id] + job = {"status": "running", "session_id": info.session_id, + "started": datetime.now(timezone.utc).isoformat(timespec="seconds")} + self._running[info.session_id] = job + self._pool.submit(self._run, info) + return job + + def _run(self, info: SessionInfo) -> None: + try: + result = replay_session(info) + except Exception as e: # surfaced to the UI as-is + result = {"status": "error", "error": str(e), "session": info.to_dict(), + "generated": datetime.now(timezone.utc).isoformat(timespec="seconds")} + try: + reconcile_path(info.db).write_text(json.dumps(result, indent=2, default=str)) + finally: + with self._lock: + self._running.pop(info.session_id, None) + + def get(self, session_id: str, db: Path) -> Optional[dict]: + with self._lock: + if session_id in self._running: + return self._running[session_id] + path = reconcile_path(db) + return json.loads(path.read_text()) if path.exists() else None + + def summarize(outdir: Path, strategy: str) -> dict: from freqtrade.data.btanalysis import load_backtest_stats diff --git a/panel/static/app.js b/panel/static/app.js index c2a0330..6a673c9 100644 --- a/panel/static/app.js +++ b/panel/static/app.js @@ -108,6 +108,10 @@ async function refreshPaper() { } else if (session) { rows.push(["Strategy", esc(session.strategy)], ["Starting balance", fmtMoney(session.balance)]); } + if (session && session.session_id !== knownSessionId) { + knownSessionId = session.session_id; + loadReconSessions(knownSessionId); + } if (session) { rows.push([st.managed ? "Session" : "Last session", `${esc(session.session_id)}`]); rows.push(["Report", `Performance tear sheet ↗`]); @@ -198,6 +202,124 @@ $("#paper-reset").addEventListener("click", () => { paperAction(() => api("/api/paper/reset", { method: "POST", body: { balance: bal, strategy: $("#paper-strategy").value } })); }); +// ===================================================================== +// Paper vs replay +// ===================================================================== +let knownSessionId = null; +let reconPolling = null; + +const checkMark = (pass) => `${pass ? "✓" : "✗"}`; +const fmtPts = (v) => (v == null || isNaN(v) ? "–" : `${v > 0 ? "+" : ""}${Number(v).toFixed(2)} pts`); + +async function loadReconSessions(preferred) { + const sel = $("#recon-session"); + const rows = await api("/api/paper/sessions").catch(() => []); + const want = preferred || sel.value; + sel.innerHTML = rows.map((s) => ``).join(""); + if (want && rows.some((s) => s.session_id === want)) sel.value = want; + $("#recon-run").disabled = !rows.length; + if (!rows.length) { + $("#recon-status").textContent = "No paper sessions yet."; + return; + } + loadRecon(); +} + +async function loadRecon() { + const id = $("#recon-session").value; + if (!id) return; + const r = await api(`/api/paper/sessions/${encodeURIComponent(id)}/reconcile`) + .catch((e) => ({ status: "error", error: e.message })); + if (id !== $("#recon-session").value) return; // selection changed meanwhile + renderRecon(r); + if (r.status === "running") pollRecon(id); +} + +async function pollRecon(id) { + if (reconPolling === id) return; + reconPolling = id; + while (reconPolling === id && $("#recon-session").value === id) { + await new Promise((res) => setTimeout(res, 2000)); + const r = await api(`/api/paper/sessions/${encodeURIComponent(id)}/reconcile`).catch(() => null); + if (!r || $("#recon-session").value !== id) break; + renderRecon(r); + if (r.status !== "running") break; + } + if (reconPolling === id) reconPolling = null; +} + +function renderRecon(r) { + const status = $("#recon-status"); + const verdict = $("#recon-verdict"); + const result = $("#recon-result"); + $("#recon-run").disabled = r.status === "running"; + verdict.hidden = r.status !== "done"; + result.hidden = r.status !== "done"; + if (r.status === "none") { + status.textContent = "Not compared yet."; + return; + } + if (r.status === "running") { + status.textContent = "Replaying… (tops up candle data first; usually under a minute)"; + return; + } + if (r.status === "error") { + status.innerHTML = `Comparison failed: ${esc(r.error).slice(0, 600)}`; + return; + } + + verdict.className = `pill ${r.close_to_replay ? "on" : "ext"}`; + verdict.textContent = r.close_to_replay ? "Close to replay" : "Not close yet"; + status.textContent = `Compared ${r.generated.replace("T", " ").slice(0, 16)} UTC · ` + + `${r.period.start.slice(0, 16).replace("T", " ")} → ${r.period.end.slice(0, 16).replace("T", " ")} UTC (${r.period.days} days)`; + + $("#recon-checks").innerHTML = r.checks.map((c) => + `
  • ${checkMark(c.pass)}${esc(c.label)} (${c.value == null ? "n/a" : esc(c.value)})
  • `).join(""); + + const p = r.paper, rp = r.replay, m = r.matching, g = r.gap, f = r.fills; + const total = m.matched + m.paper_only + m.replay_only; + $("#recon-tiles").innerHTML = [ + tile("Paper return (realised)", fmtPct(p.return_pct), signClass(p.return_pct), `${p.closed} closed, ${p.open} open`), + tile("Replay return (realised)", fmtPct(rp.return_pct), signClass(rp.return_pct), `${rp.closed} closed, ${rp.open} open`), + tile("Gap (paper − replay)", fmtPts(g.return_gap_pp), signClass(g.return_gap_pp), + `matched ${fmtMoney(g.matched_abs, "")} · paper-only ${fmtMoney(g.paper_only_abs, "")} · replay-only ${fmtMoney(g.replay_only_abs, "")}`), + tile("Trades in both", `${m.matched} / ${total}`, "", `${m.paper_only} paper-only, ${m.replay_only} replay-only`), + tile("Fill cost per side", fmtPct(f.fill_cost_pct, 3), f.fill_cost_pct > 0 ? "neg" : "", + `entry ${fmtPct(f.entry_cost_pct, 3)} · exit ${fmtPct(f.exit_cost_pct, 3)}`), + ].join(""); + + $("#recon-warnings").innerHTML = r.warnings.map((w) => `
  • ${esc(w)}
  • `).join(""); + + const label = { matched: "Both", paper_only: "Paper only", replay_only: "Replay only" }; + table($("#recon-trades"), [ + { label: "Pair", key: "pair" }, + { label: "In", render: (t) => esc(label[t.status]), cls: (t) => (t.status === "matched" ? "" : "neg") }, + { label: "Paper entry (UTC)", render: (t) => esc(t.paper_open ?? "–") }, + { label: "Replay entry (UTC)", render: (t) => esc(t.replay_open ?? "–") }, + { label: "Entry cost", num: true, render: (t) => fmtPct(t.entry_cost_pct, 3), cls: (t) => signClass(-t.entry_cost_pct) }, + { label: "Exit (paper / replay)", render: (t) => `${esc(t.paper_exit ?? "–")} / ${esc(t.replay_exit ?? "–")}` }, + { label: "Exit cost", num: true, render: (t) => fmtPct(t.exit_cost_pct, 3), cls: (t) => signClass(-t.exit_cost_pct) }, + { label: "Paper P&L", num: true, render: (t) => fmtPct(t.paper_profit_pct), cls: (t) => signClass(t.paper_profit_pct) }, + { label: "Replay P&L", num: true, render: (t) => fmtPct(t.replay_profit_pct), cls: (t) => signClass(t.replay_profit_pct) }, + ], r.trades, { empty: "No trades on either side in this period." }); +} + +$("#recon-session").addEventListener("change", () => loadRecon()); +$("#recon-run").addEventListener("click", async () => { + const id = $("#recon-session").value; + if (!id) return; + $("#recon-run").disabled = true; + $("#recon-status").textContent = "Starting…"; + try { + renderRecon(await api(`/api/paper/sessions/${encodeURIComponent(id)}/reconcile`, { method: "POST" })); + pollRecon(id); + } catch (e) { + $("#recon-status").innerHTML = `${esc(e.message)}`; + $("#recon-run").disabled = false; + } +}); + // ===================================================================== // Replay // ===================================================================== @@ -353,6 +475,12 @@ async function loadResearch() { const entries = Object.entries(r.strategies).sort((a, b) => b[1].oos.calmar - a[1].oos.calmar); for (const [name, s] of entries) { const passed = s.gates.filter((g) => g.pass).length; + const pc = (r.paper_checks || {})[name]; + const paperGate = pc + ? `
  • ${checkMark(pc.close_to_replay)}4+ weeks of paper trading close to replay ` + + `(session ${esc(pc.session_id)}: ${pc.days} days, ${pc.passed}/${pc.checks} checks)
  • ` + : `
  • ○4+ weeks of paper trading close to replay ` + + `(not compared yet: Paper trading → Paper vs replay)
  • `; const badge = s.eligible_for_paper ? `Eligible for 4-week paper test` : `${passed}/${s.gates.length} gates passed`; @@ -366,7 +494,7 @@ async function loadResearch() {
      ${s.gates.map((g) => `
    • ${g.pass ? "✓" : "✗"}${esc(g.label)} (${esc(g.value)})
    • `).join("")} -
    • ○4+ weeks of paper trading close to replay (manual)
    • + ${paperGate}
    @@ -458,12 +586,15 @@ $("#risk-flatten").addEventListener("click", () => { // ---------- boot ---------- (async function init() { - monitorMode = (await api("/api/mode").catch(() => ({}))).monitor === true; + const appMode = await api("/api/mode").catch(() => ({})); + monitorMode = appMode.monitor === true; + if (appMode.version) $("#app-version").textContent = `v${appMode.version}`; if (monitorMode) { $("#paper-form").insertAdjacentHTML("beforeend", `

    Monitor mode: the bot runs as a Docker service, so Start/Stop/Reset are disabled here. Risk controls still work.

    `); } await loadStrategies().catch(() => {}); + loadReconSessions(); let tab = "paper"; try { tab = localStorage.getItem("tab") || "paper"; } catch {} document.querySelector(`.tab[data-tab="${tab}"]`)?.click(); diff --git a/panel/static/index.html b/panel/static/index.html index 243d908..a99af99 100644 --- a/panel/static/index.html +++ b/panel/static/index.html @@ -12,6 +12,7 @@
    ridgeline + PAPER MONEY ONLY
    +
    +

    Paper vs replay

    +

    Replays the exact period a session ran (same strategy, pairs and balance) and matches + the trades one by one. The last go-live gate needs 4+ weeks of paper trading with every check passing.

    +
    + + + +
    + +

    Audit log

    Every control action and risk event, hash-chained so edits or deletions are detectable.

    diff --git a/panel/static/style.css b/panel/static/style.css index 869fcce..5d69db6 100644 --- a/panel/static/style.css +++ b/panel/static/style.css @@ -158,6 +158,10 @@ tr.clickable:hover td { background: var(--surface-2); } .gate-list { list-style: none; padding: 0; margin: 0 0 12px; display: grid; gap: 4px; } .gate-list li { display: flex; gap: 8px; } .gate-list .mark { width: 16px; font-weight: 700; } +.recon-controls { margin: 12px 0; } +.recon-controls select { max-width: 100%; } +.notes { margin: 0 0 14px; padding-left: 18px; color: var(--warn); font-size: 12px; display: grid; gap: 4px; } +.notes:empty { display: none; } .wf-bars { display: flex; gap: 3px; align-items: center; height: 60px; margin-top: 6px; } .wf-bar { flex: 1; min-width: 6px; border-radius: 2px; } diff --git a/pyproject.toml b/pyproject.toml index d5029bb..3fd3010 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ ignore = ["E731"] # small lambdas for axis formatters read fine [tool.ruff.lint.per-file-ignores] "ridgeline/report.py" = ["E501"] # the HTML/CSS template reads better unwrapped +"scripts/case_study.py" = ["E501"] # same: the paper is one HTML template [tool.ruff.lint.isort] known-first-party = ["ridgeline", "panel", "research", "conftest"] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b017933 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +# Runtime dependencies (the Windows installer and the README's install step use this). +freqtrade[hyperopt]==2026.8 +# joblib 1.6 dropped a module Freqtrade's hyperopt imports +joblib<1.6 +httpx>=0.27 +psutil>=5.9 diff --git a/ridgeline/__init__.py b/ridgeline/__init__.py index 8c9f4ec..e62c9b8 100644 --- a/ridgeline/__init__.py +++ b/ridgeline/__init__.py @@ -2,5 +2,8 @@ from pathlib import Path +# Keep in step with pyproject.toml; release tags are v. +__version__ = "0.1.0" + ROOT = Path(__file__).resolve().parent.parent USERDIR = ROOT / "user_data" diff --git a/ridgeline/reconcile.py b/ridgeline/reconcile.py new file mode 100644 index 0000000..30dcf22 --- /dev/null +++ b/ridgeline/reconcile.py @@ -0,0 +1,419 @@ +"""Paper vs replay reconciliation. + +The last go-live gate: a strategy paper-trades for 4+ weeks, and the paper +results must be close to a replay (backtest) of the same weeks, run with the +same strategy, pairs and starting balance. Passing it means the backtests the +research relies on hold up in near-live execution: + - signals fire on time + - fills land near the candle prices the backtest assumes + - stops and exits behave the same way + + session_info() what a paper session ran: strategy, pairs, balance, start, end + load_paper_trades() / load_replay_trades() + match_trades() pair trades up by pair and entry time + reconcile() summary, return-gap breakdown, checks and a verdict + +Running the replay itself (data download + backtest) is replay_session() in +panel/replay.py; scripts/reconcile.py and the panel both call it. +""" + +import ast +import hashlib +import json +import re +import sqlite3 +from collections import Counter +from contextlib import closing +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Optional + +import pandas as pd + +from ridgeline import ROOT, USERDIR + +STRATEGY_DIR = USERDIR / "strategies" + +MIN_DAYS = 28 +# Paper and replay entries for the same signal can be up to a candle apart: +# a session that starts mid-candle acts on the last closed candle at once, +# and an unfilled entry order is retried on the next candle. +MATCH_TOLERANCE_CANDLES = 1.5 + +CHECKS = [ + # (key, label, comparison, target) + ("days", f"Paper-traded for at least {MIN_DAYS} days", ">=", MIN_DAYS), + ("match_rate_pct", "At least 80% of trades happened in both", ">=", 80), + ("return_gap_pp", "Realised return within 2 points of the replay", "<=", 2.0), + ("fill_cost_pct", "Fills cost at most 0.3% per side vs the replay", "<=", 0.3), +] + +# Audit events the replay can't reproduce: manual actions, and drawdown halts +# (a human resumes them live; the backtest resumes after a fixed review period). +UNMODELLED_EVENTS = {"entries_paused", "trading_resumed", "flatten", "risk_halt"} + +TRADE_COLUMNS = ["pair", "open_date", "close_date", "open_rate", "close_rate", "stake", + "profit_ratio", "profit_abs", "exit_reason", "is_open"] +CLOSE_COLUMNS = ["close_date", "close_rate", "profit_ratio", "profit_abs", "exit_reason"] + +_WHITELIST_RE = re.compile(r"Whitelist with \d+ pairs: (\[[^\]]*\])") +_STRATEGY_RE = re.compile(r"Using resolved strategy (\w+)") +_TIMEFRAME_RE = re.compile(r"^\s+timeframe\s*=\s*[\"'](\w+)[\"']", re.M) + + +# ---------------------------------------------------------------- session + +@dataclass +class SessionInfo: + session_id: str + db: Path + strategy: str + pairs: list[str] + balance: float + start: pd.Timestamp + end: pd.Timestamp + max_open_trades: Optional[int] = None + warnings: list[str] = field(default_factory=list) + + def to_dict(self) -> dict: + return {"session_id": self.session_id, "strategy": self.strategy, "pairs": self.pairs, + "balance": self.balance, "start": self.start.isoformat(), "end": self.end.isoformat(), + "max_open_trades": self.max_open_trades} + + +def strategy_fingerprint(strategy: str, strategy_dir: Path = STRATEGY_DIR) -> dict[str, Optional[str]]: + """Hashes of everything that decides a strategy's trades: its code, its + hyperopt parameters and the shared risk layer.""" + files = [Path(strategy_dir) / f"{strategy}.py", Path(strategy_dir) / f"{strategy}.json", + ROOT / "ridgeline" / "risk.py", ROOT / "ridgeline" / "freqtrade_risk.py"] + return {p.name: hashlib.sha256(p.read_bytes()).hexdigest()[:16] if p.exists() else None for p in files} + + +def strategy_timeframe(strategy: str, strategy_dir: Path = STRATEGY_DIR) -> str: + match = _TIMEFRAME_RE.search((Path(strategy_dir) / f"{strategy}.py").read_text(encoding="utf-8")) + if not match: + raise ValueError(f"Couldn't find the timeframe of {strategy}") + return match.group(1) + + +def session_info(db: Path, *, log: Optional[Path] = None, strategy: Optional[str] = None, + pairs: Optional[list[str]] = None, balance: Optional[float] = None, + start: Optional[pd.Timestamp] = None, end: Optional[pd.Timestamp] = None) -> SessionInfo: + """What a session ran, from the best source available. Explicit arguments win. + After them, in order: + - .meta.json, written by the panel when a session starts + - the bot's log (log, or .log, with its rotated backups) + - the database itself + """ + db = Path(db) + meta = _load_json(db.with_suffix(".meta.json")) + logs = _log_files(Path(log) if log else db.with_suffix(".log")) + warnings = [] + + strategy = strategy or meta.get("strategy") or _first_match(logs, _STRATEGY_RE) or _db_strategy(db) + if not strategy: + raise ValueError(f"Couldn't tell which strategy {db.name} ran. Pass it explicitly.") + + if not pairs: + pairs = meta.get("pairs") + if not pairs: + lists = [ast.literal_eval(m) for m in _all_matches(logs, _WHITELIST_RE)] + if not lists: + raise ValueError(f"Couldn't tell which pairs {db.name} traded (no meta file and no whitelist " + "in its log). Pass them explicitly.") + pairs = lists[0] + if any(set(other) != set(pairs) for other in lists[1:]): + warnings.append("The pair list changed during the session (a restart with a new config?). " + "The replay uses the first one.") + + if balance is None: + balance = meta.get("balance") or _load_json(db.with_suffix(".config.json")).get("dry_run_wallet") + if balance is None: + balance = _load_json(USERDIR / "config.json").get("dry_run_wallet", 1000) + warnings.append(f"Starting balance not recorded for this session; assuming {balance} from config.json.") + + start = _utc(start) or _utc(meta.get("started")) or _db_start(db) + if start is None: + raise ValueError(f"Couldn't tell when {db.name} started. Pass the start explicitly.") + if end is None: + # The bot logs a heartbeat every minute, so the newest log write is when + # the session was last alive (about now, if it's still running). + if logs: + end = pd.Timestamp(max(p.stat().st_mtime for p in logs), unit="s", tz="UTC") + else: + end = pd.Timestamp.now(tz="UTC") + warnings.append("No bot log found, so the session is assumed to still be running.") + end = _utc(end) + + recorded = meta.get("code") + if recorded: + current = strategy_fingerprint(strategy) + changed = [name for name, digest in recorded.items() if current.get(name) != digest] + if changed: + warnings.append(f"Changed since the session started: {', '.join(changed)}. " + "The replay runs the current version, so some differences are expected.") + + return SessionInfo(session_id=db.stem, db=db, strategy=strategy, pairs=list(pairs), balance=float(balance), + start=start, end=end, max_open_trades=meta.get("max_open_trades"), warnings=warnings) + + +def _load_json(path: Path) -> dict: + try: + data = json.loads(Path(path).read_text()) + return data if isinstance(data, dict) else {} + except (OSError, ValueError): + return {} + + +def _utc(value) -> Optional[pd.Timestamp]: + if value is None or value == "": + return None + ts = pd.Timestamp(value) + return ts.tz_localize("UTC") if ts.tzinfo is None else ts.tz_convert("UTC") + + +def _log_files(log: Path) -> list[Path]: + """The log and its rotated backups (log.1 is newer than log.2), oldest first.""" + backups = sorted(log.parent.glob(log.name + ".*"), key=lambda p: -int(p.suffix[1:]) if p.suffix[1:].isdigit() else 0) + return [p for p in [*backups, log] if p.is_file()] + + +def _all_matches(logs: list[Path], regex: re.Pattern) -> list[str]: + return [m.group(1) for p in logs for m in regex.finditer(p.read_text(encoding="utf-8", errors="replace"))] + + +def _first_match(logs: list[Path], regex: re.Pattern) -> Optional[str]: + for p in logs: + m = regex.search(p.read_text(encoding="utf-8", errors="replace")) + if m: + return m.group(1) + return None + + +def _db_query(db: Path, sql: str): + if not Path(db).exists(): + return None + with closing(sqlite3.connect(f"file:{Path(db).as_posix()}?mode=ro", uri=True)) as con: + try: + row = con.execute(sql).fetchone() + except sqlite3.Error: + return None + return row[0] if row else None + + +def _db_strategy(db: Path) -> Optional[str]: + return _db_query(db, "SELECT strategy FROM trades WHERE strategy IS NOT NULL LIMIT 1") + + +def _db_start(db: Path) -> Optional[pd.Timestamp]: + # Freqtrade records its first start in the database (naive UTC). + return _utc(_db_query(db, "SELECT datetime_value FROM KeyValueStore WHERE key = 'bot_start_time'")) + + +# ---------------------------------------------------------------- trades + +def load_paper_trades(db: Path) -> pd.DataFrame: + with closing(sqlite3.connect(f"file:{Path(db).as_posix()}?mode=ro", uri=True)) as con: + df = pd.read_sql_query( + "SELECT pair, open_date, close_date, open_rate, close_rate, stake_amount AS stake, " + "close_profit AS profit_ratio, close_profit_abs AS profit_abs, exit_reason, is_open FROM trades", con) + return _normalise(df) + + +def load_replay_trades(directory: Path) -> tuple[pd.DataFrame, dict]: + """Trades from a backtest result directory, plus its start, end and timeframe. + Trades the backtest force-closed at its last candle are still open in + reality, so they come back open, the same way paper shows them.""" + from freqtrade.data.btanalysis import load_backtest_stats + + strat = next(iter(load_backtest_stats(directory)["strategy"].values())) + df = pd.DataFrame(strat["trades"]).reindex(columns=[*TRADE_COLUMNS, "stake_amount"]) + df["stake"] = df.pop("stake_amount") + df = _normalise(df) + end = pd.Timestamp(strat["backtest_end"], tz="UTC") + left_open = (df["exit_reason"] == "force_exit") & (df["close_date"] >= end) + df.loc[left_open, CLOSE_COLUMNS] = None + df.loc[left_open, "is_open"] = True + return df, {"start": pd.Timestamp(strat["backtest_start"], tz="UTC"), "end": end, + "timeframe": strat["timeframe"], "starting_balance": float(strat["starting_balance"])} + + +def _normalise(df: pd.DataFrame) -> pd.DataFrame: + df = df.copy() + for col in ("open_date", "close_date"): + df[col] = pd.to_datetime(df[col], utc=True, errors="coerce") + for col in ("open_rate", "close_rate", "stake", "profit_ratio", "profit_abs"): + df[col] = pd.to_numeric(df[col], errors="coerce") + df["exit_reason"] = df["exit_reason"].astype(object) + df["is_open"] = df["is_open"].fillna(False).astype(bool) + return df[TRADE_COLUMNS].sort_values("open_date").reset_index(drop=True) + + +def match_trades(paper: pd.DataFrame, replay: pd.DataFrame, + tolerance: pd.Timedelta) -> tuple[list[tuple[int, int]], list[int], list[int]]: + """Pair each paper trade with the replay trade on the same pair whose entry + is closest in time, within `tolerance`. Closest pairs are matched first. + Returns (matched index pairs, paper-only indexes, replay-only indexes).""" + candidates = [] + for i, p in paper.iterrows(): + same_pair = replay[replay["pair"] == p["pair"]] + for j, opened in same_pair["open_date"].items(): + gap = abs(p["open_date"] - opened) + if gap <= tolerance: + candidates.append((gap, i, j)) + matched, used_p, used_r = [], set(), set() + for _, i, j in sorted(candidates): + if i not in used_p and j not in used_r: + matched.append((i, j)) + used_p.add(i) + used_r.add(j) + return (sorted(matched), [i for i in paper.index if i not in used_p], + [j for j in replay.index if j not in used_r]) + + +# ---------------------------------------------------------------- compare + +def _check(op: str, value, target) -> bool: + if value is None: + return False + return {">=": value >= target, "<=": value <= target}[op] + + +def _realised(t) -> float: + return 0.0 if t["is_open"] or pd.isna(t["profit_abs"]) else float(t["profit_abs"]) + + +def _r(value, digits: int = 2): + return None if value is None or pd.isna(value) else round(float(value), digits) + + +def _when(ts) -> Optional[str]: + return None if pd.isna(ts) else f"{ts:%Y-%m-%d %H:%M}" + + +def reconcile(paper: pd.DataFrame, replay: pd.DataFrame, *, info: SessionInfo, replay_end: pd.Timestamp, + timeframe: str, events: Optional[list[dict]] = None) -> dict: + """Compare a paper session's trades with a replay of the same period. + + `replay_end` is the open time of the replay's last candle. Paper trades + opened after that candle closes can't be in the replay yet: they're + left out, and paper trades closed after it count as still open. + Fill costs are positive when paper did worse than the replay: it paid more + on entry or received less on exit. + """ + tf = pd.Timedelta(timeframe) + start = info.start.floor(tf) + cutoff = min(info.end, replay_end + tf) + paper, replay = _normalise(paper), _normalise(replay) + + too_recent = paper["open_date"] >= cutoff + paper = paper[~too_recent].copy() + late = paper["close_date"] >= cutoff + paper.loc[late, CLOSE_COLUMNS] = None + paper.loc[late, "is_open"] = True + + matched, paper_only, replay_only = match_trades(paper, replay, tf * MATCH_TOLERANCE_CANDLES) + + rows, entry_costs, exit_costs = [], [], [] + both_closed = same_exit = 0 + gap_matched = 0.0 + for i, j in matched: + p, r = paper.loc[i], replay.loc[j] + entry_cost = (p["open_rate"] / r["open_rate"] - 1) * 100 + entry_costs.append(entry_cost) + exit_cost = None + if not p["is_open"] and not r["is_open"]: + both_closed += 1 + # Exit prices are only comparable when both sides left for the same reason. + if p["exit_reason"] == r["exit_reason"]: + same_exit += 1 + exit_cost = (1 - p["close_rate"] / r["close_rate"]) * 100 + exit_costs.append(exit_cost) + gap_matched += _realised(p) - _realised(r) + rows.append(_row("matched", p, r, entry_cost, exit_cost)) + rows += [_row("paper_only", paper.loc[i], None) for i in paper_only] + rows += [_row("replay_only", None, replay.loc[j]) for j in replay_only] + rows.sort(key=lambda row: row["paper_open"] or row["replay_open"]) + + def side(df: pd.DataFrame) -> dict: + closed = df[~df["is_open"]] + realised = float(closed["profit_abs"].sum()) + return {"trades": len(df), "closed": len(closed), "open": int(df["is_open"].sum()), + "realised_abs": round(realised, 2), "return_pct": round(realised / info.balance * 100, 2)} + + paper_side, replay_side = side(paper), side(replay) + total = len(matched) + len(paper_only) + len(replay_only) + costs = entry_costs + exit_costs + gap = paper_side["return_pct"] - replay_side["return_pct"] + values = { + "days": round((cutoff - info.start).total_seconds() / 86400, 1), + "match_rate_pct": round(100 * len(matched) / total, 1) if total else None, + "return_gap_pp": round(abs(gap), 2), + "fill_cost_pct": _r(sum(costs) / len(costs), 3) if costs else None, + } + checks = [{"key": key, "label": label, "value": values[key], "pass": _check(op, values[key], target)} + for key, label, op, target in CHECKS] + + warnings = list(info.warnings) + if too_recent.any(): + warnings.append(f"{int(too_recent.sum())} paper trade(s) opened after the replay's last candle " + f"({_when(cutoff)} UTC) and aren't compared yet.") + if info.end - (replay_end + tf) > tf: + warnings.append(f"The replay's data stops at {_when(replay_end + tf)} UTC, before the session's last " + f"activity ({_when(info.end)} UTC). Download fresh data and run it again.") + if not total: + warnings.append("No trades on either side yet, so there's nothing to compare.") + unmodelled = [e for e in events or [] if e.get("event") in UNMODELLED_EVENTS + and info.start <= pd.Timestamp(e["ts"]) <= info.end] + if unmodelled: + listed = ", ".join(f"{name} x{n}" for name, n in Counter(e["event"] for e in unmodelled).items()) + warnings.append(f"During the session: {listed}. The replay can't reproduce manual actions or " + "the manual resume after a halt, so expect differences around them.") + + return { + "status": "done", + "generated": datetime.now(timezone.utc).isoformat(timespec="seconds"), + "session": info.to_dict(), + "timeframe": timeframe, + "period": {"start": start.isoformat(), "end": cutoff.isoformat(), "days": values["days"]}, + "paper": paper_side, + "replay": replay_side, + "matching": {"matched": len(matched), "paper_only": len(paper_only), "replay_only": len(replay_only), + "match_rate_pct": values["match_rate_pct"], + "tolerance_hours": tf.total_seconds() * MATCH_TOLERANCE_CANDLES / 3600}, + "fills": { + "entry_cost_pct": _r(sum(entry_costs) / len(entry_costs), 3) if entry_costs else None, + "exit_cost_pct": _r(sum(exit_costs) / len(exit_costs), 3) if exit_costs else None, + "fill_cost_pct": values["fill_cost_pct"], + "same_exit_reason_pct": round(100 * same_exit / both_closed, 1) if both_closed else None, + }, + # Paper minus replay, in stake currency: the sum of the three parts is the total. + "gap": {"total_abs": round(paper_side["realised_abs"] - replay_side["realised_abs"], 2), + "matched_abs": round(gap_matched, 2), + "paper_only_abs": round(sum(_realised(paper.loc[i]) for i in paper_only), 2), + "replay_only_abs": round(-sum(_realised(replay.loc[j]) for j in replay_only), 2), + "return_gap_pp": round(gap, 2)}, + "checks": checks, + "close_to_replay": all(c["pass"] for c in checks), + "warnings": warnings, + "trades": rows, + } + + +def _row(status: str, p, r, entry_cost=None, exit_cost=None) -> dict: + """One line of the trade-by-trade comparison; p or r is None for a one-sided trade.""" + def side(t) -> dict: + if t is None: + return {"open": None, "entry": None, "exit": None, "profit_pct": None} + return {"open": _when(t["open_date"]), "entry": _r(t["open_rate"], 8), + "exit": "open" if t["is_open"] else t["exit_reason"], "profit_pct": _r(t["profit_ratio"] * 100)} + + ps, rs = side(p), side(r) + return { + "pair": (p if p is not None else r)["pair"], "status": status, + "paper_open": ps["open"], "replay_open": rs["open"], + "paper_entry": ps["entry"], "replay_entry": rs["entry"], "entry_cost_pct": _r(entry_cost, 3), + "paper_exit": ps["exit"], "replay_exit": rs["exit"], "exit_cost_pct": _r(exit_cost, 3), + "paper_profit_pct": ps["profit_pct"], "replay_profit_pct": rs["profit_pct"], + } diff --git a/scripts/case_study.py b/scripts/case_study.py new file mode 100644 index 0000000..872382d --- /dev/null +++ b/scripts/case_study.py @@ -0,0 +1,650 @@ +"""Build the case study (research-paper format, black theme) as a PDF. + +The market background is the earlier case study's analysis, condensed, with its +sources kept. The empirical sections are generated from the latest research +results, so re-running this after the monthly `research.py gates` keeps the +paper current: + - user_data/research/latest.json gates at 1000 USDT + - user_data/research/latest-65.json gates at 65 USDT (~100 AUD), if present + +Usage (from the project root, with the venv Python): + python scripts/case_study.py [--browser PATH] [-o "Crypto Trading Bot Case Study.pdf"] + +Needs Microsoft Edge, Google Chrome or Chromium to print the PDF. The HTML is +kept in build/case-study/. +""" + +import argparse +import html +import json +import os +import shutil +import subprocess +import sys +import tempfile +from datetime import date +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) + +from ridgeline import USERDIR, __version__ # noqa: E402 + +RESEARCH = USERDIR / "research" +OUT = ROOT / "Crypto Trading Bot Case Study.pdf" +BUILD = ROOT / "build" / "case-study" + +# One-off experiments run at 65 USDT, recorded in RESEARCH.md. +RISK_SCALING = [ # risk per trade, portfolio caps, IS %, OOS %, OOS max DD %, OOS PF + ("1.5% (default)", "35% / 4.5% / 3% / 15%", 5.76, 17.47, 9.66, 1.44), + ("3.0%", "50% / 9% / 6% / 25%", 26.92, 20.73, 19.15, 1.26), + ("4.5%", "70% / 13.5% / 9% / 30%", -20.33, 13.09, 27.05, 1.14), +] +ROTATION_LOOKBACK = [(7, -15.36), (10, 25.10), (14, 37.17), (21, -3.96), (30, -7.56), (60, -5.23)] + +BLUE, ORANGE = "#3987e5", "#d95926" # validated on the black surface +GOOD, BAD = "#0ca30c", "#d03b3b" + +REFERENCES = [ + ("Top 7 Crypto Trading Bots in 2026 - TradeSanta", "https://tradesanta.com/top-crypto-bots/"), + ("Best Crypto Trading Bots 2026: Types & How to Choose | Bitsgap blog", "https://bitsgap.com/blog/best-crypto-trading-bots-in-2026-strategies-types-and-how-to-choose"), + ("Best Crypto Trading Bots for Beginners (2026 Guide)", "https://www.tv-hub.org/guide/crypto-trading-bots-beginners"), + ("AI Crypto Trading Bots Comparison 2026: 6 Bots Ranked - Tacavar", "https://tacavar.com/blog/ai-crypto-trading-bot-comparison-table-2026/"), + ("Best Automated Trading Platforms for Crypto in 2026: Bots vs Strategy Builders", "https://www.coinquant.ai/compare/best-automated-trading-platforms-for-crypto-in-2026-bots-vs-strategy-builders-ranked"), + ("Explained: The 3Commas Breach (December 2022) - Halborn", "https://www.halborn.com/blog/post/explained-the-3commas-breach-december-2022"), + ("8 Significant API Breaches of Recent Years - Nordic APIs", "https://nordicapis.com/8-significant-api-breaches-of-recent-years/"), + ("Digital assets: Financial products and services - ASIC", "https://www.asic.gov.au/regulatory-resources/digital-transformation/digital-assets-financial-products-and-services"), + ("Best Automated Trading Platforms Australia 2026 | EAs & Bots", "https://www.compareforexbrokers.com.au/platforms/automated-trading/"), + ("Keeping crypto records | Australian Taxation Office", "https://www.ato.gov.au/individuals-and-families/investments-and-assets/crypto-asset-investments/keeping-crypto-records"), + ("Best Crypto Trading Bots in 2026 | altFINS", "https://altfins.com/best/crypto-trading-and-investing/crypto-trading-bots/"), + ("3Commas Review 2026: Trading Bots, Pricing, Security and AI Tools", "https://coinbureau.com/review/3commas-review"), + ("AI Trading Bot Risk Management: Complete 2025 Guide - 3Commas", "https://3commas.io/blog/ai-trading-bot-risk-management-guide-2025"), + ("3Commas Review - Gainium", "https://gainium.io/review/3commas"), + ("Exploring Signal Bots and DCA Bots on 3Commas", "https://3commas.io/blog/exploring-signal-bots-and-dca-bots-on-3commas-guide"), + ("2026 Cryptohopper Review - Features & How To Get Started", "https://coinsutra.com/cryptohopper-review/"), + ("Cryptohopper Review - Gainium", "https://gainium.io/review/cryptohopper"), + ("Cryptohopper Review, Pricing, Features and Alternatives - Growlonix", "https://www.growlonix.com/support/article/cryptohopper-review-pricing-features-and-alternatives"), + ("3Commas: Crypto Trading Bots & Automation Platform", "https://3commas.io/"), + ("We're Improving Our Subscription Plans - 3Commas", "https://3commas.io/blog/were-improving-our-subscription-plans"), + ("AI Trading Bots: Features & Analysis for Different Trading Styles", "https://3commas.io/blog/essential-features-of-ai-trading-bots-in-2025-comp"), + ("Cryptohopper Review: The Dutch Cloud Bot for Strategy Tinkerers", "https://airdropalert.com/blogs/cryptohopper-review/"), + ("Cryptohopper Reviews 2026: Details, Pricing, & Features - G2", "https://www.g2.com/products/cryptohopper/reviews"), + ("Cryptohopper Reviews - Honest User Feedback & Ratings", "https://www.cryptohopper.com/reviews"), + ("Pionex Review 2026: Features, Fees, and Free Trading Bots", "https://coinbureau.com/review/pionex-review"), + ("Pionex Review 2026: 12 Free Bots, 0.05% Fees - Is It Worth It?", "https://gainium.io/review/pionex"), + ("Is Pionex Safe? (Read Before You Trade) - CoinLedger", "https://coinledger.io/learn/is-pionex-safe"), + ("Read Customer Service Reviews of bitsgap.com - Trustpilot Reviews", "https://au.trustpilot.com/review/bitsgap.com"), + ("Bitsgap Review 2026 - FAQs, Pricing, Discount & My Experience", "https://coinsutra.com/bitsgap-review/"), + ("Bitsgap Review 2025: Honest Look at Bots, Features, and Competitors", "https://finestel.com/blog/bitsgap-review/"), + ("2026 Bitsgap Review: Pros, Cons, Pricing, and More! - BitDegree.org", "https://www.bitdegree.org/crypto/bitsgap-review"), + ("The 10 Best Crypto Trading Bots in 2026 (Reviewed) - CoinLedger", "https://coinledger.io/tools/best-crypto-trading-bots"), + ("Crypto Trading Bot Database - Every Bot Ranked & Compared", "https://gainium.io/bots"), + ("3Commas API leak FAQ for customers", "https://3commas.io/blog/api-security-incident-faq"), + ("3Commas CEO Confirms API Database Leak - Binance", "https://www.binance.com/en/square/post/12-29-2022-3commas-ceo-confirms-api-database-leak-140737"), + ("Update on investigation into API keys and attacks on exchanges", "https://3commas.io/blog/december-10-update-on-investigation-api-key-exchange-attacks"), + ("RE: False Rumors on API Leaks or Exposure of our Database", "https://3commas.io/blog/response-to-false-rumors-api-leaks"), + ("Notice on API data disclosure incident - 3Commas", "https://3commas.io/blog/notice-on-api-data-disclosure-incident"), + ("The FBI is reportedly probing 3Commas after API keys leaked", "https://www.bitdefender.com/en-us/blog/hotforsecurity/the-fbi-is-reportedly-probing-3commas-after-api-keys-leaked-on-twitter"), + ("3Commas hit with second security breach in a year - Blockworks", "https://blockworks.com/news/3commas-security-breach"), + ("Are you providing digital assets financial services unlicensed? Act now", "https://hamiltonlocke.com.au/are-you-providing-digital-assets-financial-services-unlicensed-act-now/"), + ("Does Your Crypto Business Need an AFS Licence? A 101 Guide", "https://afslhouse.com.au/insights/does-your-crypto-business-need-an-australian-financial-services-afs-licence/"), + ("ASIC extends no-action position for digital asset businesses to 30 September 2026", "https://www.asic.gov.au/about-asic/news-centre/news-items/asic-extends-no-action-position-for-digital-asset-businesses-to-30-september-2026"), + ("Deadline looms for digital asset businesses to apply for a licence", "https://www.asic.gov.au/about-asic/news-centre/news-items/deadline-looms-for-digital-asset-businesses-to-apply-for-a-licence"), + ("ASIC strengthens AI trading safeguards and streamlines market integrity rules", "https://www.asic.gov.au/about-asic/news-centre/find-a-media-release/2026-releases/26-226mr-asic-strengthens-ai-trading-safeguards-and-streamlines-market-integrity-rules"), + ("7 Best AI Trading Bots Australia 2026 | ASIC Regulated", "https://theinvestorscentre.com/best-trading-platforms/best-ai-trading-bots-australia/"), + ("Crypto Tax Australia Guide 2026: ATO Rules - Count On Sheep", "https://countonsheep.com/crypto-tax-by-country/australia/crypto-tax-guide"), + ("How to work out and report CGT on crypto | Australian Taxation Office", "https://www.ato.gov.au/individuals-and-families/investments-and-assets/crypto-asset-investments/how-to-work-out-and-report-cgt-on-crypto"), + ("MSB license : r/Pionex - Reddit", "https://www.reddit.com/r/Pionex/comments/17izuv4/msb_license/"), + ("Disclaimer - Pionex Blog", "https://www.pionex.com/blog/disclaimer/"), + ("Freqtrade documentation", "https://www.freqtrade.io/en/stable/"), + ("Ridgeline source code and research results", "https://github.com/devam2001/ridgeline"), +] +FREQTRADE_REF, REPO_REF = 51, 52 + + +# ---------------------------------------------------------------- formatting + +def esc(text) -> str: + return html.escape(str(text)) + + +def pct(v, digits: int = 1, sign: bool = True) -> str: + if v is None: + return "–" + return f"{v:+.{digits}f}%" if sign else f"{v:.{digits}f}%" + + +def cite(*numbers: int) -> str: + return f'[{", ".join(str(n) for n in numbers)}]' + + +def table(head: list[str], rows: list[list], *, num_from: int = 1, caption: str = "", cls: str = "") -> str: + cells = "".join(f'= num_from else ""}">{h}' for i, h in enumerate(head)) + body = "".join("" + "".join(f'= num_from else ""}">{c}' for i, c in enumerate(r)) + + "" for r in rows) + cap = f"{caption}" if caption else "" + return f'{cap}{cells}{body}
    ' + + +def mark(passed: bool) -> str: + return (f'✓' if passed + else f'✗') + + +# ---------------------------------------------------------------- charts + +def _hbar(x0: float, x1: float, y: float, h: float, color: str, r: float = 4) -> str: + """Horizontal bar from baseline x0 to x1, rounded at the data end only.""" + left, right = min(x0, x1), max(x0, x1) + r = min(r, (right - left) / 2, h / 2) + if x1 >= x0: # rounded on the right + d = (f"M{left:.1f},{y:.1f} H{right - r:.1f} Q{right:.1f},{y:.1f} {right:.1f},{y + r:.1f} " + f"V{y + h - r:.1f} Q{right:.1f},{y + h:.1f} {right - r:.1f},{y + h:.1f} H{left:.1f} Z") + else: # rounded on the left + d = (f"M{right:.1f},{y:.1f} H{left + r:.1f} Q{left:.1f},{y:.1f} {left:.1f},{y + r:.1f} " + f"V{y + h - r:.1f} Q{left:.1f},{y + h:.1f} {left + r:.1f},{y + h:.1f} H{right:.1f} Z") + return f'' + + +def _vbar(x: float, base: float, top: float, w: float, color: str, r: float = 4) -> str: + """Vertical bar from baseline y=base to y=top, rounded at the data end only.""" + hi, lo = min(base, top), max(base, top) + r = min(r, (lo - hi) / 2, w / 2) + if top <= base: # positive: rounded at the top + d = (f"M{x:.1f},{lo:.1f} V{hi + r:.1f} Q{x:.1f},{hi:.1f} {x + r:.1f},{hi:.1f} " + f"H{x + w - r:.1f} Q{x + w:.1f},{hi:.1f} {x + w:.1f},{hi + r:.1f} V{lo:.1f} Z") + else: # negative: rounded at the bottom + d = (f"M{x:.1f},{hi:.1f} V{lo - r:.1f} Q{x:.1f},{lo:.1f} {x + r:.1f},{lo:.1f} " + f"H{x + w - r:.1f} Q{x + w:.1f},{lo:.1f} {x + w:.1f},{lo - r:.1f} V{hi:.1f} Z") + return f'' + + +def chart_oos_returns(rows: list[tuple[str, float]], refs: list[tuple[str, float]]) -> str: + """Out-of-sample return per strategy, with buy-and-hold reference lines.""" + width, label_w, pad_r, bar_h, gap = 720, 150, 60, 22, 14 + plot_bottom = 30 + len(rows) * (bar_h + gap) + height = plot_bottom + 14 * len(refs) + 8 + lo = min([0.0, *(v for _, v in rows), *(v for _, v in refs)]) - 5 + hi = max([0.0, *(v for _, v in rows)]) + 5 + + def x(v): + return label_w + (width - label_w - pad_r) * (v - lo) / (hi - lo) + + parts = [f''] + for t in range(int(lo // 10) * 10, int(hi) + 1, 10): + if t < lo: + continue + parts.append(f'' + f'{t:+d}%') + parts.append(f'') + for k, (name, v) in enumerate(refs): + label_y = plot_bottom + 12 + 14 * k + parts.append(f'' + f'{esc(name)} {v:+.1f}%') + for i, (name, v) in enumerate(rows): + y = 34 + i * (bar_h + gap) + parts.append(f'{esc(name)}') + parts.append(_hbar(x(0), x(v), y, bar_h, BLUE)) + # positive: label past the bar end; negative: inside the bar end, clear of the reference lines + tx = x(v) + 6 + parts.append(f'{v:+.1f}%') + parts.append("") + return "".join(parts) + + +def chart_walkforward(windows: list[str], a: tuple[str, list[float]], b: tuple[str, list[float]], + market: list[float]) -> str: + """Return per walk-forward window for two strategies (grouped bars).""" + width, pad_l, pad_r, top, plot_h = 720, 44, 8, 34, 190 + height = top + plot_h + 54 + values = a[1] + b[1] + lo, hi = min(0.0, *values) - 2, max(0.0, *values) + 2 + n = len(windows) + band = (width - pad_l - pad_r) / n + bar_w = min(12.0, (band - 8) / 2) + + def y(v): + return top + plot_h * (hi - v) / (hi - lo) + + parts = [f''] + parts.append(f'' + f'{esc(a[0])}' + f'' + f'{esc(b[0])}') + step = 10 if hi - lo > 40 else 5 + for t in range(int(lo // step) * step, int(hi) + 1, step): + if t < lo: + continue + parts.append(f'' + f'{t:+d}%') + parts.append(f'') + for i, label in enumerate(windows): + cx = pad_l + band * i + band / 2 + parts.append(_vbar(cx - bar_w - 1, y(0), y(a[1][i]), bar_w, BLUE)) + parts.append(_vbar(cx + 1, y(0), y(b[1][i]), bar_w, ORANGE)) + parts.append(f'{esc(label)}' + f'{market[i]:+.0f}%') + parts.append(f'mkt') + parts.append("") + return "".join(parts) + + +# ---------------------------------------------------------------- document + +CSS = """ +@page { size: A4; margin: 17mm 16mm 18mm; background: #000; + @bottom-center { content: "Ridgeline case study · page " counter(page) " of " counter(pages); + color: #8a8a86; font: 8pt "Segoe UI", system-ui, sans-serif; } } +html, body { background: #000; } +* { -webkit-print-color-adjust: exact; print-color-adjust: exact; box-sizing: border-box; } +body { margin: 0; color: #e4e4e1; font: 10.4pt/1.55 Cambria, Georgia, "Times New Roman", serif; } +h1, h2, h3, .sans, caption, table, .chart text, .meta, .abstract h2 { font-family: "Segoe UI", system-ui, sans-serif; } +h1 { font-size: 21pt; line-height: 1.2; margin: 0 0 6pt; color: #fff; font-weight: 650; } +.subtitle { font-size: 12pt; color: #c3c2b7; margin: 0 0 14pt; } +.meta { color: #a3a29a; font-size: 9pt; margin-bottom: 16pt; } +h2 { font-size: 13pt; color: #fff; margin: 20pt 0 6pt; padding-bottom: 3pt; border-bottom: 1px solid #333; break-after: avoid; } +h3 { font-size: 10.8pt; color: #f2f2ee; margin: 13pt 0 4pt; break-after: avoid; } +p { margin: 0 0 7pt; text-align: justify; hyphens: auto; } +ul, ol { margin: 0 0 8pt; padding-left: 18pt; } li { margin-bottom: 2pt; } +a { color: #86b6ef; text-decoration: none; overflow-wrap: anywhere; } +.cite { color: #86b6ef; font-size: 7pt; } +.abstract { border: 1px solid #333; border-radius: 6px; padding: 10pt 13pt; margin: 0 0 12pt; background: #0d0d0d; } +.abstract h2 { margin: 0 0 4pt; border: 0; font-size: 10pt; text-transform: uppercase; letter-spacing: .06em; color: #c3c2b7; } +.keywords { font-size: 9pt; color: #a3a29a; margin-top: 6pt; } +table { width: 100%; border-collapse: collapse; margin: 6pt 0 12pt; font-size: 8.6pt; break-inside: avoid; } +caption { caption-side: top; text-align: left; color: #c3c2b7; font-size: 8.6pt; padding-bottom: 4pt; } +th, td { padding: 3.5pt 6pt; border-bottom: 1px solid #262626; text-align: left; vertical-align: top; } +th { color: #a3a29a; font-weight: 500; border-bottom-color: #444; } +td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; } +.mark { font-weight: 700; } +figure { margin: 8pt 0 14pt; break-inside: avoid; } +figcaption { font: 8.6pt "Segoe UI", system-ui, sans-serif; color: #c3c2b7; margin-top: 4pt; } +.chart { width: 100%; height: auto; } +.chart .grid { stroke: #262626; } .chart .zero { stroke: #8a8a86; } +.chart .ref { stroke: #a3a29a; stroke-dasharray: 4 3; } +.chart .ref-label, .chart .axis { font-size: 10px; fill: #a3a29a; } .chart .muted { fill: #6f6e69; } +.chart .label { font-size: 11px; fill: #e4e4e1; } .chart .value { font-size: 11px; fill: #fff; font-weight: 600; } +.callout { border-left: 3px solid #3987e5; background: #0d0d0d; padding: 7pt 11pt; margin: 8pt 0 12pt; } +.small { font-size: 8.6pt; color: #a3a29a; } +.refs { font-size: 8.2pt; color: #c3c2b7; padding-left: 22pt; } .refs li { margin-bottom: 2.5pt; } +.pagebreak { break-before: page; } +""" + + +def build_html(report: dict, small: dict | None) -> str: + strategies = report["strategies"] + bm = report["benchmarks"] + is_range, oos_range = report["periods"]["is"], report["periods"]["oos"] + order = sorted(strategies, key=lambda s: -sum(g["pass"] for g in strategies[s]["gates"])) + best = order[0] + b = strategies[best] + n_gates = len(b["gates"]) + eligible = [s for s in order if strategies[s]["eligible_for_paper"]] + rot = strategies.get("MomentumRotation1d") + today = date.today().strftime("%d %B %Y") + + def passed(s): + return sum(g["pass"] for g in strategies[s]["gates"]) + + summary_rows = [[f"{esc(s)}" if s == best else esc(s), + pct(strategies[s]["is"]["profit_pct"]), pct(strategies[s]["oos"]["profit_pct"]), + pct(strategies[s]["oos"]["max_drawdown_pct"], sign=False), f'{strategies[s]["oos"]["profit_factor"]:.2f}', + strategies[s]["oos"]["trades"], pct(strategies[s]["walkforward"]["pct_profitable"], 0, sign=False), + f"{passed(s)} / {n_gates}"] for s in order] + gate_rows = [] + for i, g in enumerate(b["gates"]): + gate_rows.append([esc(g["label"])] + [f'{mark(strategies[s]["gates"][i]["pass"])} {esc(_short(strategies[s]["gates"][i]["value"]))}' + for s in order]) + wf_windows = [w["window"] for w in b["walkforward"]["windows"]] + wf_labels = [_window_label(w) for w in wf_windows] + wf_table = [[esc(w.replace(" -> ", " → "))] + [pct(strategies[s]["walkforward"]["windows"][i]["profit_pct"]) for s in order] + + [pct(b["walkforward"]["windows"][i]["market_change_pct"], 0)] for i, w in enumerate(wf_windows)] + + small_section = "" + if small: + rows = [] + for s in order: + if s not in small["strategies"]: + continue + o = small["strategies"][s]["oos"] + rows.append([esc(s), f'{o["starting_balance"]:.0f} → {o["final_balance"]:.2f}', pct(o["profit_pct"]), + pct(strategies[s]["oos"]["profit_pct"]), pct(o["max_drawdown_pct"], sign=False), o["trades"], + f'{sum(g["pass"] for g in small["strategies"][s]["gates"])} / {n_gates}']) + small_section = table(["Strategy", "OOS balance (USDT)", "OOS return at 65", "OOS return at 1000", "Max DD", "Trades", "Gates"], + rows, caption="Table 6. The same out-of-sample test with a 65 USDT (≈100 AUD) account.") + + figure1 = chart_oos_returns([(s, strategies[s]["oos"]["profit_pct"]) for s in order], + [("Equal-weight buy & hold", bm["oos"]["equal_weight_pct"]), ("BTC", bm["oos"]["btc_pct"])]) + figure2 = "" + if rot is not None and best != "MomentumRotation1d": + figure2 = chart_walkforward( + wf_labels, (best, [w["profit_pct"] for w in b["walkforward"]["windows"]]), + ("MomentumRotation1d", [w["profit_pct"] for w in rot["walkforward"]["windows"]]), + [w["market_change_pct"] for w in b["walkforward"]["windows"]]) + + wf_b = b["walkforward"]["windows"] + up = max(range(len(wf_b)), key=lambda i: wf_b[i]["market_change_pct"]) + down = min(range(len(wf_b)), key=lambda i: wf_b[i]["market_change_pct"]) + regime_text = (f"{esc(best)}'s edge is mainly not losing money in downturns: in the market's worst quarter " + f"({wf_b[down]['window'][:7]}, {pct(wf_b[down]['market_change_pct'], 0)}) it returned {pct(wf_b[down]['profit_pct'])}, " + f"because the BTC regime filter keeps it out of broad declines. In the strongest quarter " + f"({wf_b[up]['window'][:7]}, {pct(wf_b[up]['market_change_pct'], 0)}) it made {pct(wf_b[up]['profit_pct'])}, " + "catching only part of the move.") + if rot is not None: + wf_r = rot["walkforward"]["windows"] + regime_text += (f" MomentumRotation1d was designed to hold the leaders through rallies, but the walk-forward does not show " + f"that: it made only {pct(wf_r[up]['profit_pct'])} in that strongest quarter (and {pct(wf_r[down]['profit_pct'])} " + "in the worst one). Its rally-capture rationale is not supported by these results.") + + verdict = (f"{len(eligible)} of {len(strategies)} strategies passed every gate" if eligible + else f"none of the {len(strategies)} strategies passed every gate, so none is eligible for real money") + rot_text = "" + if rot is not None: + rot_text = (f"A new cross-sectional momentum-rotation candidate returned {pct(rot['oos']['profit_pct'])} out of sample " + f"with a {pct(rot['oos']['max_drawdown_pct'], sign=False)} maximum drawdown and passed {passed('MomentumRotation1d')} of " + f"{n_gates} gates, but its in-sample results were highly sensitive to its one key parameter. ") + + return f""" +Ridgeline case study + +

    Automated Cryptocurrency Trading for Retail Accounts

    +

    A market case study and an out-of-sample evaluation of an open-source, self-hosted trading system

    +
    devam2001 · Ridgeline project{cite(REPO_REF)} · {today} · software v{__version__}
    +Research format revision of the earlier product case study “The 2026 Landscape of Automated Cryptocurrency Trading Platforms”.
    + +

    Abstract

    +

    Algorithmic execution has become mainstream in cryptocurrency markets: the trading-bot sector was valued at an estimated +US$47.43 billion in 2026, with roughly 42% of active traders automating to some degree{cite(1)}. Commercial platforms sell +infrastructure, templates and strategy marketplaces, but their fixed subscription costs weigh heavily on small accounts, and +marketplace strategies are often curve-fitted to past data{cite(4, 17)}. This paper first condenses a landscape analysis of bot +architectures, platforms, security failures and Australian regulation. It then evaluates Ridgeline, an open-source system built on +Freqtrade{cite(FREQTRADE_REF)}, under a deliberately strict protocol: point-in-time pair selection, a strict in-sample / +out-of-sample split ({esc(is_range[0])} to {esc(is_range[1])} / {esc(oos_range[0])} to {esc(oos_range[1])}), quarterly walk-forward windows, +fee stress, lookahead and warm-up checks, and nine pass/fail go-live gates, all under portfolio risk limits. Over an +out-of-sample period in which an equal-weight basket of the same coins fell {pct(bm['oos']['equal_weight_pct'])}, the best +strategy, {esc(best)}, returned {pct(b['oos']['profit_pct'])} with a {pct(b['oos']['max_drawdown_pct'], sign=False)} maximum drawdown. +It passed {passed(best)} of {n_gates} gates. {rot_text}Overall, {verdict}. An account of about 100 AUD trades the same +signals with the same percentage results, but doubling the risk per trade added little return while doubling drawdown.

    +

    Keywords: algorithmic trading, cryptocurrency, backtesting, out-of-sample testing, walk-forward analysis, +point-in-time universe, risk management, Freqtrade, retail investors, Australia

    + +

    1. Introduction

    +

    Unlike equity and foreign-exchange venues, cryptocurrency exchanges trade continuously across a fragmented global liquidity +network{cite(2)}. Manual traders face fatigue and well-documented behavioural failures (panic selling in drawdowns, chasing +parabolic rallies, revenge trading after losses), which automated, rule-based execution removes{cite(2)}. The industry has grown +from open-source command-line scripts into software-as-a-service ecosystems with visual strategy builders, backtesting engines and, +increasingly, agentic AI layers{cite(4)}. That growth brought second-order problems: centralised storage of exchange API keys created +large attack surfaces{cite(6)}, and retail automation now meets financial-services licensing, tax and anti-money-laundering rules{cite(8)}.

    +

    Marketing for trading bots tends to report backtests, which are easy to make look good. This study asks whether a strategy still +shows an edge when it is tested the way a sceptical reviewer would test it, and whether that holds for a very small account. +Four questions guide it:

    +
      +
    1. RQ1. How is the 2026 automated-trading market structured, and what risks does it carry for retail users?
    2. +
    3. RQ2. Does any of four rule-based strategies show an edge that survives point-in-time, out-of-sample testing and nine go-live gates?
    4. +
    5. RQ3. Can the system trade an account of about 100 AUD, and with what results?
    6. +
    7. RQ4. Does taking more risk per trade produce proportionally more profit?
    8. +
    + +

    2. Background: the automated-trading landscape

    +

    2.1 Algorithmic architectures

    +

    Bots are specialised tools that suit particular market regimes{cite(2)}. Grid bots layer limit orders around a price and harvest +range-bound oscillation, but in a sustained trend they either accumulate a falling asset or sell out of a rally too early{cite(2, 3, 12)}. +Dollar-cost-averaging (DCA) bots add geometrically scaled “safety orders” as price moves against a position; this lowers the average +entry but can consume capital rapidly in a crash, so professional use needs drawdown thresholds and caps on safety orders{cite(12, 13, 14)}. +Signal bots execute alerts from external charting platforms and depend on fragile webhook plumbing{cite(3, 12)}. Arbitrage and +market-making bots chase spreads that institutional high-frequency firms close in milliseconds, which makes them the highest-risk +category for retail users{cite(3, 17)}. Trend-following bots ride momentum but are whipsawed in sideways markets{cite(2)}.

    +{table(["Architecture", "Mechanism", "Best market", "Main systemic risk", "Complexity"], [ + ["Grid", "Layered limit orders above and below price", "Sideways, ranging", "Breakouts into a one-way trend", "Low to medium"], + ["DCA", "Scaled buying into adverse moves", "Choppy, mildly bearish", "Runaway capital use in crashes", "Low"], + ["Signal", "Executes external webhook alerts", "Depends on the signal", "Latency and delivery failures", "Medium"], + ["Arbitrage", "Cross-venue price spreads", "Volatile, inefficient", "Spreads eroded by fees and latency", "Very high"], + ["Trend-following", "Breakouts and moving averages", "Strong directional trends", "Whipsaw losses in sideways chop", "Medium to high"], +], num_from=99, caption="Table 1. Taxonomy of trading-bot architectures (after the original case study" + cite(2, 3, 12, 13) + ").")} + +

    2.2 Commercial platforms

    +

    The commercial market has stratified by user experience, custody model and pricing{cite(4)}. 3Commas, the most widely adopted +API-driven platform (over 624,000 registered traders), restructured its plans in 2025–26 into Starter, Pro and Expert tiers at +US$20, US$50 and US$140 a month, gating full developer API access behind the top tier, and launched the agentic QuantPilot +environment{cite(4, 11, 14)}. Cryptohopper differentiates on a no-code designer and a strategy marketplace whose incentives +reward curve-fitted strategies{cite(4, 16, 17)}. Pionex embeds free bots in its own exchange, earning a 0.05% trading fee at the +cost of ecosystem lock-in{cite(4, 11, 25)}. Bitsgap focuses on multi-exchange portfolio management and a futures “COMBO” bot{cite(2, 4, 11)}. +At the technical extreme, HaasOnline offers walk-forward backtesting (up to US$749 a month), Gunbot is self-hosted on a one-time +licence, and open-source frameworks such as Freqtrade are free but demand programming skill{cite(4, 11, 33)}.

    +
    Fixed costs and small accounts. A US$29 monthly subscription on a US$1,000 account needs a 34.8% annual +net return just to break even on software{cite(17)}. On a 100 AUD account no subscription is viable at all, which is why this study +evaluates a self-hosted, open-source system with no fixed cost.
    + +

    2.3 Security

    +

    Cloud bots hold exchange API keys centrally, which makes them single points of failure{cite(22)}. In late 2022 3Commas users +reported unauthorised trades. The company first attributed them to phishing, until a threat actor published about 100,000 API keys +on 28 December 2022 and the CEO confirmed the leak{cite(34, 35, 36)}. Estimated losses exceeded US$20 million. Because the keys +could not withdraw, attackers used “contra-trading”: they forced victim accounts to buy illiquid coins at inflated prices from the +attackers' own sell orders{cite(6)}. The industry responded with OAuth-style “Fast Connect” authorisation and mandatory IP +whitelisting, yet 3Commas suffered a further breach in October 2023{cite(36, 38, 40)}. Self-hosting keeps keys on the user's own +machine, which is the model Ridgeline follows. Its setup guide restricts keys to spot trading with withdrawals disabled, and keys are +needed only for live trading.

    + +

    2.4 Regulation and tax in Australia

    +

    Running a bot on one's own account is generally permitted without a licence, but selling algorithms with performance claims, +offering copy-trading, or pooling client funds can require an Australian Financial Services Licence and may form a managed +investment scheme{cite(8, 9, 42)}. ASIC's no-action position for digital-asset businesses required licence applications by 30 June +2026 and ended on 30 September 2026{cite(41, 43, 44)}, and ASIC has updated its market-integrity rules to strengthen safeguards +around AI-enabled trading{cite(45)}. The ATO treats crypto as a capital-gains-tax asset, so every automated trade is a separate CGT +event that must be recorded{cite(10, 48)}. In the United States, platforms that transmit customer funds must register with FinCEN +and hold state licences; Pionex.US reached consent orders with four states in May 2025{cite(25, 27)}.

    + +

    2.5 Toward agentic AI

    +

    LLM-based agents can read news, policy statements and on-chain flows, but they can also hallucinate, for example mistaking satire for a +bankruptcy and liquidating a portfolio{cite(4, 13)}. The emerging consensus is that deterministic risk controls (hard stops, drawdown +thresholds and volume filters) must govern final execution whatever proposes the trade{cite(4, 32)}. Ridgeline applies that principle +to rule-based strategies: a separate risk layer can veto or shrink any trade.

    + +

    3. System design

    +

    Ridgeline{cite(REPO_REF)} is built on Freqtrade{cite(FREQTRADE_REF)} and trades Binance spot USDT pairs, long only. It has four parts:

    +
      +
    • Strategies. Each strategy sizes positions so that a stop-out loses about 1.5% of equity, using ATR-based stops.
    • +
    • Portfolio risk layer. Shared by every strategy and enforced in backtests too: +
        +
      • at most 35% of equity in one position
      • +
      • at most 4.5% of equity at risk across open stops
      • +
      • a 3% daily loss limit
      • +
      • a 15% drawdown halt that only a human can lift
      • +
    • +
    • Operations. +
        +
      • a paper-trading control panel with a kill switch
      • +
      • a hash-chained audit trail
      • +
      • investor-style tear sheets
      • +
      • a watchdog with alerts
      • +
      • Docker deployment and a Windows installer
      • +
    • +
    • Research pipeline (Section 4). It ends with the gates in Table 2 and a paper-versus-replay check.
    • +
    +

    The panel can only run dry-run (paper) bots. Live trading is a separate, manual step that the software never takes on its own.

    + +

    4. Methodology

    +

    4.1 Data and universe

    +

    The study uses daily, 4-hour and 1-hour Binance spot candles from June 2022 to {esc(oos_range[1])}. Choosing pairs by today's +trading volume leaks the future into a backtest, because coins that later rallied are over-represented. In an earlier run, a +today's-top-26 list inflated the out-of-sample return to +35.7%{cite(REPO_REF)}. Each backtest window therefore trades the top 25 USDT +pairs by quote volume over the 30 days before it starts. Stablecoins (by name, or price within ±5% of US$1 for 90 days), wrapped and +leveraged tokens, and coins listed less than 210 days earlier are excluded. Delisted coins cannot be downloaded, so some survivorship +bias remains.

    +

    4.2 Protocol

    +

    The in-sample period ({esc(is_range[0])} to {esc(is_range[1])}) is the only data used for design and tuning. The out-of-sample period +({esc(oos_range[0])} to {esc(oos_range[1])}) is used only to judge the result. Every backtest:

    +
      +
    • starts with 1,000 USDT
    • +
    • allows at most three open trades
    • +
    • pays a 0.1% fee per side
    • +
    • has Freqtrade's protections and the portfolio risk limits switched on
    • +
    +

    Walk-forward analysis repeats the test in rolling three-month windows, each with its own point-in-time universe.

    +

    The bias checks are:

    +
      +
    • Lookahead. Freqtrade's lookahead analysis.
    • +
    • Indicator warm-up stability. Freqtrade's recursive analysis, measured at the strategy's own warm-up length or longer.
    • +
    • Lookahead for cross-sectional strategies. The Freqtrade tool reloads one pair at a time, which cannot evaluate a ranking +across pairs. For those strategies, the backtest is re-run with later data removed from every pair at once, and every earlier +trade must be identical.
    • +
    +{table(["Gate", "Target"], [[esc(g[0]), esc(g[1])] for g in [ + ("Out-of-sample profit", "> 0%"), ("Out-of-sample profit factor", "≥ 1.3"), ("Out-of-sample maximum drawdown", "≤ 20%"), + ("Out-of-sample Calmar ratio", "≥ 1.0"), ("Out-of-sample trades", "≥ 100"), ("Profitable walk-forward windows", "≥ 60%"), + ("Out-of-sample profit with fees +50%", "> 0%"), ("Lookahead bias", "none"), ("Indicator deviation at the strategy's warm-up", "≤ 1%"), + ("Paper trading (4+ weeks) vs replay of the same weeks", "4 checks: ≥ 28 days, ≥ 80% trades matched, return within 2 points, fills ≤ 0.3% per side"), +]], num_from=99, caption="Table 2. Go-live gates. A strategy must pass all nine backtest gates, then the paper-trading check, before any real money is used.")} +

    Two further experiments address RQ3 and RQ4. The full gate suite is re-run with a 65 USDT starting balance (about 100 AUD at roughly +0.65 USD per AUD, an assumed rate). The best strategy is also re-run at two and three times the risk per trade, with proportionally +looser portfolio caps.

    + +

    4.3 Strategies

    +
      +
    • TrendRider4h: a 4-hour Donchian breakout with an ADX trend filter. It trades only when the coin is above its daily EMA200 and +BTC is above its own daily EMA200, and exits on an ATR “chandelier” trailing stop with no profit cap.
    • +
    • MomentumRotation1d (new): each day it ranks the whole universe by 14-day volatility-adjusted momentum and holds the top +three while BTC is in an uptrend. A position is sold when its coin drops out of the top six, or on its trailing stop. All settings +were chosen on in-sample data only.
    • +
    • EmaRsiMomentum: a 1-hour EMA crossover with RSI, ADX and 4-hour EMA200 filters and a take-profit ladder.
    • +
    • TrendDipBuyer1h: buys 1-hour pullbacks inside an established uptrend (mean reversion).
    • +
    + +

    5. Results

    +

    5.1 Headline results

    +

    The two periods were very different markets. In-sample, an equal-weight buy-and-hold of the same point-in-time coins returned +{pct(bm['is']['equal_weight_pct'])} and BTC {pct(bm['is']['btc_pct'])}. Out of sample, the equal-weight basket lost +{pct(bm['oos']['equal_weight_pct'])} and BTC {pct(bm['oos']['btc_pct'])}.

    +{table(["Strategy", "IS return", "OOS return", "OOS max DD", "OOS PF", "OOS trades", "WF profitable", "Gates"], summary_rows, + caption="Table 3. In-sample (IS) and out-of-sample (OOS) results with portfolio risk limits (1,000 USDT start). PF = profit factor; WF = walk-forward.")} +
    {figure1} +
    Figure 1. Out-of-sample return by strategy. Dashed lines mark buy-and-hold of the same coins over the same period.
    + +

    5.2 Go-live gates

    +{table(["Gate"] + [esc(s) for s in order], gate_rows, caption="Table 4. Gate results (✓ pass, ✗ fail), with the measured value.", cls="gates")} +

    {esc(best)} fails only the sample-size gate ({b['oos']['trades']} trades against 100). The gate stays as it is. Each additional +month of data adds roughly six out-of-sample trades, so the monthly re-run will settle it honestly. +{"MomentumRotation1d fails the sample-size and walk-forward gates." if rot is not None else ""}

    + +

    5.3 Consistency across market regimes

    +{f'
    {figure2}
    Figure 2. Return per three-month walk-forward window. The row under the labels shows the equal-weight market change (“mkt”). The full table is in Appendix A.
    ' if figure2 else ""} +

    {regime_text}

    + +

    5.4 Small accounts (RQ3)

    +

    Binance requires a minimum order value. With 65 USDT, the risk-based position sizes ranged from about 6 to 26 USDT (median about 8), +which stayed above that minimum, so the strategies placed almost exactly the same trades as with 1,000 USDT and percentage +results are nearly identical. Exchange fees are proportional, and a self-hosted system has no subscription, so a small account carries no +structural cost penalty here. In absolute terms, though, +17% over the 16-month out-of-sample period on 100 AUD is about 17 AUD.

    +{small_section} + +

    5.5 More risk per trade (RQ4)

    +{table(["Risk per trade", "Caps (position / open risk / daily loss / halt)", "IS return", "OOS return", "OOS max DD", "OOS PF"], + [[esc(r[0]), esc(r[1]), pct(r[2]), pct(r[3]), pct(r[4], sign=False), f"{r[5]:.2f}"] for r in RISK_SCALING], + num_from=2, caption="Table 5. TrendRider4h at higher risk (65 USDT start).")} +

    Doubling the risk added about three points of out-of-sample return but doubled the drawdown and pushed the profit factor +below the 1.3 gate. Tripling it lost money in-sample. More risk did not create more edge. It mostly scaled losses and triggered the +drawdown halts.

    + +

    5.6 Parameter sensitivity of the rotation strategy

    +{table(["Momentum lookback (days)"] + [str(n) for n, _ in ROTATION_LOOKBACK], [["In-sample return"] + [pct(v) for _, v in ROTATION_LOOKBACK]], + caption="Table 7. MomentumRotation1d in-sample return by lookback (top 3, 65 USDT start, measured before the warm-up fix).")} +

    The 14-day setting was the in-sample best, but neighbouring settings fell sharply, from +37% at 14 days to −4% at 21 days. A +peak this narrow suggests part of the in-sample result is luck, so its out-of-sample figure should be read with that caution.

    + +

    6. Discussion

    +

    RQ1. The market sells infrastructure rather than proven edges. Fixed fees, curve-fitted marketplace strategies and centralised +key storage all work against small retail users{cite(4, 6, 17)}, and Australian rules leave own-account automation largely untouched +but regulate selling it to others{cite(8, 9)}.

    +

    RQ2. Under strict testing, edges are small and fragile. The protocol itself mattered more than any single strategy idea:

    +
      +
    • moving from hand-picked to point-in-time pairs cut TrendRider4h's in-sample return from +82.5% to +10.8% (before risk limits)
    • +
    • the risk layer then cost some return on the good strategy and halved the losses of the bad one{cite(REPO_REF)}
    • +
    +

    The best strategy's out-of-sample result is respectable against a falling market, but it rests on about 100 trades.

    +

    RQ3. A 100 AUD account is technically viable on Binance spot with this design. What it cannot do is produce large absolute +profits. RQ4. Raising risk is not a shortcut to “high profit”: return per unit of drawdown got worse.

    + +

    7. Limitations

    +
      +
    • Survivorship bias. Coins delisted before the data was downloaded are missing.
    • +
    • No slippage model. Backtests fill at candle prices plus fees. Paper trading and the paper-versus-replay gate exist to measure the gap.
    • +
    • Small samples and in-sample selection. Both are discussed above. TrendRider4h's parameters were also tuned on an earlier +hand-picked pair list.
    • +
    • Untested assumptions. The 65 USDT conversion uses an assumed exchange rate.
    • +
    • Unverified market sources. The figures in Section 2 come from the secondary web sources listed and were not independently verified.
    • +
    + +

    8. Conclusion

    +

    Automated trading moves execution from a person to a machine; it does not remove market risk. Measured honestly, with +point-in-time pairs, untouched out-of-sample data, walk-forward windows, stress and bias checks, {verdict}. The best candidate +is close, and the remaining steps are procedural:

    +
      +
    1. keep re-running the gates monthly
    2. +
    3. paper-trade it for at least four weeks
    4. +
    5. require the paper results to match a replay of the same weeks
    6. +
    +

    For small accounts, the practical lessons are to avoid fixed costs, keep risk per trade small, and judge any strategy on data it was +not designed on.

    +

    This document describes software and research results. It is not financial advice, and backtests do not predict +future returns. Only risk money you can afford to lose.

    + +

    Appendix A. Walk-forward windows

    +{table(["Window"] + [esc(s) for s in order] + ["Market"], wf_table, caption="Table A1. Return per three-month window (point-in-time pairs, risk limits on, 1,000 USDT start).")} + +

    References

    +
      {"".join(f'
    1. {esc(t)}. {esc(u)}
    2. ' for t, u in REFERENCES)}
    +""" + + +def _short(value) -> str: + if isinstance(value, bool): + return "yes" if value else "no" + if isinstance(value, float): + return f"{value:g}" + return str(value) + + +def _window_label(window: str) -> str: + start = window.split(" -> ")[0] + year, month = start[2:4], int(start[5:7]) + return f"Q{(month - 1) // 3 + 1} {year}" + + +def find_browser(explicit: str | None) -> str: + candidates = [explicit] if explicit else [ + os.path.expandvars(r"%ProgramFiles(x86)%\Microsoft\Edge\Application\msedge.exe"), + os.path.expandvars(r"%ProgramFiles%\Microsoft\Edge\Application\msedge.exe"), + os.path.expandvars(r"%ProgramFiles%\Google\Chrome\Application\chrome.exe"), + shutil.which("google-chrome") or "", shutil.which("chromium") or "", shutil.which("chromium-browser") or "", + ] + for c in candidates: + if c and Path(c).exists(): + return c + sys.exit("No Edge/Chrome/Chromium found to print the PDF; pass --browser PATH.") + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--browser", help="Edge, Chrome or Chromium executable") + ap.add_argument("-o", "--out", type=Path, default=OUT) + args = ap.parse_args() + + report = json.loads((RESEARCH / "latest.json").read_text()) + small_path = RESEARCH / "latest-65.json" + small = json.loads(small_path.read_text()) if small_path.exists() else None + BUILD.mkdir(parents=True, exist_ok=True) + page = BUILD / "case-study.html" + page.write_text(build_html(report, small), encoding="utf-8") + + browser = find_browser(args.browser) + with tempfile.TemporaryDirectory() as profile: + subprocess.run([browser, "--headless=new", "--disable-gpu", "--no-pdf-header-footer", f"--user-data-dir={profile}", + f"--print-to-pdf={args.out.resolve()}", page.resolve().as_uri()], + check=True, capture_output=True, timeout=180) + print(args.out) + + +if __name__ == "__main__": + main() diff --git a/scripts/reconcile.py b/scripts/reconcile.py new file mode 100644 index 0000000..e220011 --- /dev/null +++ b/scripts/reconcile.py @@ -0,0 +1,82 @@ +"""Compare a paper-trading session with a replay of the same weeks. + +The last go-live gate: 4+ weeks of paper trading whose results are close to +a replay (backtest) of the same period, with the same strategy, pairs and +balance. This tops up candle data, runs that replay, matches the trades and +checks how close they are. + +Usage (from the project root, with the venv Python): + python scripts/reconcile.py 20261011-010946 a panel session in user_data/paper/ + python scripts/reconcile.py --db user_data/tradesv3.docker.sqlite --log user_data/logs/bot.log + the Docker bot + +What the session ran is read from its files; --strategy, --pairs, --balance, +--start and --end override it. --no-download uses the data already on disk. + +Writes .reconcile.json next to the database; the panel's Research +tab shows the newest result per strategy. +""" + +import argparse +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) + +from panel.common import USERDIR # noqa: E402 +from panel.replay import reconcile_path, replay_session # noqa: E402 +from ridgeline.reconcile import session_info # noqa: E402 + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + src = ap.add_mutually_exclusive_group(required=True) + src.add_argument("session", nargs="?", help="panel paper session id (user_data/paper/.sqlite)") + src.add_argument("--db", type=Path, help="any paper/dry-run Freqtrade SQLite database") + ap.add_argument("--log", type=Path, help="the bot's log file (default: .log)") + ap.add_argument("--strategy") + ap.add_argument("--pairs", nargs="+") + ap.add_argument("--balance", type=float) + ap.add_argument("--start", help="UTC, e.g. 2026-10-01 or '2026-10-01 14:00'") + ap.add_argument("--end", help="UTC (default: the bot's last log write)") + ap.add_argument("--no-download", action="store_true", help="don't top up candle data first") + args = ap.parse_args() + + db = args.db or USERDIR / "paper" / f"{args.session}.sqlite" + if not db.exists(): + sys.exit(f"No database at {db}") + info = session_info(db, log=args.log, strategy=args.strategy, pairs=args.pairs, balance=args.balance, + start=args.start, end=args.end) + print(f"{info.session_id}: {info.strategy}, {len(info.pairs)} pairs, {info.balance:g} starting balance, " + f"{info.start:%Y-%m-%d %H:%M} -> {info.end:%Y-%m-%d %H:%M} UTC") + print("Replaying the same period...") + result = replay_session(info, download=not args.no_download) + reconcile_path(db).write_text(json.dumps(result, indent=2, default=str)) + + p, r, m, g = result["paper"], result["replay"], result["matching"], result["gap"] + print(f"\n{'':10}{'trades':>8}{'closed':>8}{'realised':>11}{'return':>9}") + for name, s in (("paper", p), ("replay", r)): + print(f"{name:10}{s['trades']:>8}{s['closed']:>8}{s['realised_abs']:>11.2f}{s['return_pct']:>8.2f}%") + print(f"\nmatched {m['matched']}, paper only {m['paper_only']}, replay only {m['replay_only']}") + print(f"return gap {g['return_gap_pp']:+.2f} points = matched trades {g['matched_abs']:+.2f} " + f"+ paper-only {g['paper_only_abs']:+.2f} + replay-only {g['replay_only_abs']:+.2f} (stake currency)") + for row in result["trades"]: + print(f" {row['status']:<12}{row['pair']:<14}{row['paper_open'] or '-':<18}{row['replay_open'] or '-':<18}" + f"entry {_fmt(row['entry_cost_pct'])} exit {row['paper_exit'] or '-'} / {row['replay_exit'] or '-'}") + print() + for c in result["checks"]: + print(f" [{'PASS' if c['pass'] else 'FAIL'}] {c['label']} (got {c['value']})") + for w in result["warnings"]: + print(f" note: {w}") + verdict = "CLOSE to the replay" if result["close_to_replay"] else "NOT close to the replay (yet)" + print(f"\nPaper trading is {verdict}.\nwritten to {reconcile_path(db)}") + + +def _fmt(cost) -> str: + return "-" if cost is None else f"{cost:+.3f}%" + + +if __name__ == "__main__": + main() diff --git a/scripts/research.py b/scripts/research.py index 60938b6..69517dd 100644 --- a/scripts/research.py +++ b/scripts/research.py @@ -58,6 +58,7 @@ r"PAXG|WBTC|WBETH|BNSOL|.*UP|.*DOWN|.*BULL|.*BEAR)/" ) STATIC_UNIVERSE = False # set by --static +BALANCE = 1000.0 # set by --balance; 65 USDT is about 100 AUD GATES = [ # (key, label, comparison, target) @@ -124,6 +125,9 @@ def tr(start: str, end: str = "") -> str: def bt(strategy, start, end="", tag="", **kw) -> dict: out = RESEARCH_DIR / "runs" / f"{strategy}-{tag or tr(start, end)}" kw.setdefault("pairs", list(universe_at(start))) + kw.setdefault("balance", BALANCE) + if BALANCE != 1000: + out = out.with_name(f"{out.name}-bal{BALANCE:g}") return run_backtest(strategy, tr(start, end), outdir=out, **kw)["metrics"] @@ -181,7 +185,66 @@ def stress(strategies: list[str]) -> dict: return {s: f.result() for s, f in futures.items()} +def is_cross_sectional(strategy: str) -> bool: + """Strategies whose signals depend on other pairs (e.g. a ranking) say so + with `cross_sectional = True`.""" + source = (USERDIR / "strategies" / f"{strategy}.py").read_text(encoding="utf-8") + return re.search(r"^\s+cross_sectional\s*=\s*True", source, re.M) is not None + + +TRUNCATION_RANGE = ("2024-01-01", "2025-01-01") +TRUNCATION_CUTS = ("2024-04-01", "2024-07-01", "2024-10-01") + + +def truncation_check(strategy: str) -> dict: + """Lookahead check that keeps every pair loaded: backtest the full range, + then the same start with later data removed (from all pairs at once). + Every trade that closed before the cut must be identical in both.""" + start, end = TRUNCATION_RANGE + pairs = list(universe_at(start)) + full = bt(strategy, start, end, tag="bias-full", pairs=pairs) + with ThreadPoolExecutor(WORKERS) as pool: + cuts = {c: pool.submit(bt, strategy, start, c, tag=f"bias-cut-{c}", pairs=pairs) for c in TRUNCATION_CUTS} + keys = ("pair", "open_date", "close_date", "open_rate", "close_rate") + + def closed_before(trades, cut): + # The cut run force-closes whatever is still open on its last candle, + # so only compare trades that closed two days before the cut. + limit = (pd.Timestamp(cut) - pd.Timedelta(days=2)).strftime("%Y-%m-%d") + return [tuple(t[k] for k in keys) for t in trades if t["close_date"] < limit] + + full_trades = run_trades(strategy, "bias-full") + checked, mismatched = 0, [] + for c in cuts: + cuts[c].result() + want = closed_before(full_trades, c) + got = closed_before(run_trades(strategy, f"bias-cut-{c}"), c) + checked += len(want) + if want != got: + mismatched.append(c) + return { + "lookahead_clean": not mismatched and checked > 0, + "lookahead_note": f"cross-sectional: {checked} trades compared with later data removed from all pairs " + f"({full['trades']} in the full run); " + + (f"differences at cuts {', '.join(mismatched)}" if mismatched else "no differences"), + } + + +def run_trades(strategy: str, tag: str) -> list[dict]: + from freqtrade.data.btanalysis import load_backtest_stats + + out = RESEARCH_DIR / "runs" / f"{strategy}-{tag}" + if BALANCE != 1000: + out = out.with_name(f"{out.name}-bal{BALANCE:g}") + trades = load_backtest_stats(out)["strategy"][strategy]["trades"] + return [{**t, "open_date": str(t["open_date"]), "close_date": str(t["close_date"])} for t in trades] + + def lookahead(strategy: str) -> dict: + # Freqtrade's lookahead-analysis re-runs each signal with only that one + # pair loaded, so a cross-sectional ranking always looks "biased" there. + if is_cross_sectional(strategy): + return truncation_check(strategy) out = RESEARCH_DIR / "bias" / strategy out.mkdir(parents=True, exist_ok=True) csv_path = out / "lookahead.csv" @@ -218,14 +281,37 @@ def recursive(strategy: str) -> dict: out = RESEARCH_DIR / "bias" / strategy out.mkdir(parents=True, exist_ok=True) (out / "recursive.log").write_text(text) - # Table rows look like "| rsi | 0.005% | ..." - take the worst deviation. - pcts = [abs(float(m)) for m in re.findall(r"(-?\d+\.\d+)%", text)] if log.returncode != 0: return {"recursive_max_diff_pct": None, "recursive_note": "analysis failed, see recursive.log"} - if not pcts and "No variance on indicator" in text: + worst = worst_recursive_deviation(text) + if worst is None and "No variance on indicator" in text: return {"recursive_max_diff_pct": 0.0, "recursive_note": "no variance"} - return {"recursive_max_diff_pct": round(max(pcts), 4) if pcts else 0.0, - "recursive_note": "worst indicator deviation with fewer startup candles"} + return {"recursive_max_diff_pct": worst or 0.0, + "recursive_note": "worst indicator deviation at the strategy's startup candle count or more"} + + +def worst_recursive_deviation(text: str): + """Worst indicator deviation in recursive-analysis output, at the + strategy's own startup candle count or more. Columns with fewer candles + than the strategy declares test a setup that never runs: Freqtrade always + loads the declared startup candles first.""" + rows = [[c.strip() for c in line.strip().strip("|").split("|")] + for line in text.splitlines() if line.startswith("|")] + header = next((r for r in rows if r and r[0] == "Indicators"), None) + if header is None: + return None + counts = [int(re.match(r"\d+", h).group()) for h in header[1:]] + own = next((c for h, c in zip(header[1:], counts) if "from strategy" in h), min(counts)) + keep = [i + 1 for i, c in enumerate(counts) if c >= own] + worst = 0.0 + for row in rows: + if row is header or len(row) != len(header): + continue + for i in keep: + m = re.fullmatch(r"(-?\d+\.\d+)%", row[i]) + if m: + worst = max(worst, abs(float(m.group(1)))) + return round(worst, 4) def bias(strategies: list[str]) -> dict: @@ -288,9 +374,11 @@ def gates(strategies: list[str]) -> dict: "gates": results, "eligible_for_paper": all(g["pass"] for g in results), } + report["balance"] = BALANCE RESEARCH_DIR.mkdir(parents=True, exist_ok=True) stamp = date.today().isoformat() - for name in ("latest.json", f"report-{stamp}.json"): + names = ("latest.json", f"report-{stamp}.json") if BALANCE == 1000 else (f"latest-{BALANCE:g}.json",) + for name in names: (RESEARCH_DIR / name).write_text(json.dumps(report, indent=2, default=str)) return report @@ -310,12 +398,16 @@ def main() -> None: ap.add_argument("--spaces", nargs="+", default=["buy", "sell"]) ap.add_argument("--loss", default="CalmarHyperOptLoss") ap.add_argument("--static", action="store_true", help="use config.json pairs instead of point-in-time universes") + ap.add_argument("--balance", type=float, default=1000.0, + help="starting balance for backtests (65 USDT is about 100 AUD); gates then writes " + "latest-.json instead of latest.json") ap.add_argument("--download", action="store_true", help="with 'universe': download 1h/4h data for every listed pair") ap.add_argument("--write-config", action="store_true", help="with 'universe': set config.json's pair_whitelist to today's universe (for paper/live)") args = ap.parse_args() - global STATIC_UNIVERSE + global STATIC_UNIVERSE, BALANCE STATIC_UNIVERSE = args.static + BALANCE = args.balance strategies = args.strategies or list_strategies() if args.command == "universe": @@ -359,7 +451,8 @@ def main() -> None: print(f"\n{s}: {verdict}") for g in r["gates"]: print(f" [{'PASS' if g['pass'] else 'FAIL'}] {g['label']} (got {g['value']})") - print(f"\nwritten to {RESEARCH_DIR / 'latest.json'}") + name = "latest.json" if BALANCE == 1000 else f"latest-{BALANCE:g}.json" + print(f"\nwritten to {RESEARCH_DIR / name}") if __name__ == "__main__": diff --git a/scripts/update_data.py b/scripts/update_data.py new file mode 100644 index 0000000..255d79e --- /dev/null +++ b/scripts/update_data.py @@ -0,0 +1,44 @@ +"""Download or refresh market data (README, "Data"). + +1. Daily candles for every Binance USDT spot pair. Point-in-time research + picks each window's pairs from the whole market, not today's favourites. +2. 1h/4h/1d candles for every pair the research windows use, and + config.json's live pair list set to today's top 25 by volume + (`research.py universe --download --write-config`). + +Run it monthly. The first run downloads several GB; later runs only top up. + +Usage (from the project root, with the venv Python): + python scripts/update_data.py +""" + +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) + +from panel.common import FREQTRADE, USERDIR, config_args # noqa: E402 + +SINCE = "20220601" + + +def run(cmd: list[str], **kw) -> subprocess.CompletedProcess: + print("\n>", " ".join(str(c) for c in cmd), flush=True) + return subprocess.run(cmd, cwd=ROOT, check=True, **kw) + + +def main() -> None: + pairs_file = USERDIR / "all_usdt.json" + listed = run([*FREQTRADE, "list-pairs", *config_args(), "--quote", "USDT", "--trading-mode", "spot", + "--print-json"], stdout=subprocess.PIPE, text=True) + pairs_file.write_text(listed.stdout) + run([*FREQTRADE, "download-data", *config_args(), "--pairs-file", str(pairs_file), + "--timeframes", "1d", "--timerange", f"{SINCE}-"]) + run([sys.executable, str(ROOT / "scripts" / "research.py"), "universe", "--download", "--write-config"]) + print("\nDone. Restart any running paper session to pick up the new pair list.") + + +if __name__ == "__main__": + main() diff --git a/tests/test_panel_api.py b/tests/test_panel_api.py index 056d097..283c394 100644 --- a/tests/test_panel_api.py +++ b/tests/test_panel_api.py @@ -1,5 +1,6 @@ """Panel API: input validation, the dry-run safety guard, and the kill switch.""" +import json from types import SimpleNamespace import pytest @@ -66,3 +67,71 @@ def fake_call(method, path, **kw): def test_report_rejects_path_tricks(client): assert client.get("/api/report", params={"session": "../config"}).status_code == 400 + + +# ---------------------------------------------------------------- paper vs replay + +@pytest.fixture +def userdir(tmp_path, monkeypatch): + monkeypatch.setattr(app_mod, "USERDIR", tmp_path) + (tmp_path / "paper").mkdir(exist_ok=True) + return tmp_path + + +def make_session(userdir, session_id, meta=None): + import sqlite3 + + with sqlite3.connect(userdir / "paper" / f"{session_id}.sqlite") as con: + con.execute("CREATE TABLE trades (pair TEXT, strategy TEXT)") + con.close() + if meta: + (userdir / "paper" / f"{session_id}.meta.json").write_text(json.dumps(meta)) + + +def test_reconcile_rejects_bad_and_unknown_sessions(client, userdir): + assert client.post("/api/paper/sessions/bad.id/reconcile").status_code == 400 + assert client.get("/api/paper/sessions/20990101-000000/reconcile").status_code == 404 + + +def test_reconcile_needs_to_know_the_pairs(client, userdir): + make_session(userdir, "s1") + r = client.post("/api/paper/sessions/s1/reconcile") + assert r.status_code == 409 and "which" in r.json()["detail"] + + +def test_reconcile_submits_what_the_session_ran(client, userdir, monkeypatch): + make_session(userdir, "s2", {"strategy": "TrendRider4h", "pairs": ["BTC/USDT"], "balance": 300, + "started": "2026-09-01T00:00:00+00:00"}) + submitted = [] + monkeypatch.setattr(app_mod.session_replays, "submit", + lambda info: submitted.append(info) or {"status": "running", "session_id": info.session_id}) + assert client.post("/api/paper/sessions/s2/reconcile").json()["status"] == "running" + assert (submitted[0].strategy, submitted[0].pairs, submitted[0].balance) == ("TrendRider4h", ["BTC/USDT"], 300) + assert client.get("/api/paper/sessions/s2/reconcile").json() == {"status": "none"} + + +def test_research_shows_newest_reconciliation_per_strategy(client, userdir): + (userdir / "research").mkdir() + (userdir / "research" / "latest.json").write_text(json.dumps({"strategies": {}})) + + def result(session_id, generated, ok, status="done"): + return {"status": status, "generated": generated, "close_to_replay": ok, + "session": {"session_id": session_id, "strategy": "TrendRider4h"}, "period": {"days": 30.0}, + "checks": [{"pass": ok}, {"pass": True}]} + + for sid, gen, ok, status in [("old", "2026-10-01T00:00:00+00:00", False, "done"), + ("new", "2026-10-09T00:00:00+00:00", True, "done"), + ("broken", "2026-10-10T00:00:00+00:00", True, "error")]: + (userdir / "paper" / f"{sid}.reconcile.json").write_text(json.dumps(result(sid, gen, ok, status))) + checks = client.get("/api/research").json()["paper_checks"] + assert checks["TrendRider4h"]["session_id"] == "new" and checks["TrendRider4h"]["passed"] == 2 + + +def test_reconcile_refuses_until_the_first_candle_closes(client, userdir): + from datetime import datetime, timezone + + make_session(userdir, "s3", {"strategy": "TrendRider4h", "pairs": ["BTC/USDT"], "balance": 300, + "started": datetime.now(timezone.utc).isoformat()}) + r = client.post("/api/paper/sessions/s3/reconcile") + assert r.status_code == 409 and "Nothing to replay yet" in r.json()["detail"] + assert not (userdir / "paper" / "s3.reconcile.json").exists() diff --git a/tests/test_reconcile.py b/tests/test_reconcile.py new file mode 100644 index 0000000..852fa7a --- /dev/null +++ b/tests/test_reconcile.py @@ -0,0 +1,199 @@ +"""Paper vs replay reconciliation: session discovery, trade matching, checks.""" + +import json +import sqlite3 + +import pandas as pd +import pytest + +from ridgeline.reconcile import ( + SessionInfo, + load_paper_trades, + load_replay_trades, + match_trades, + reconcile, + session_info, + strategy_fingerprint, + strategy_timeframe, +) + +T = lambda s: pd.Timestamp(s, tz="UTC") # noqa: E731 +H4 = pd.Timedelta("4h") + + +def trade(pair, opened, closed=None, open_rate=100.0, close_rate=None, profit_abs=None, exit_reason=None, stake=100.0): + is_open = closed is None + return {"pair": pair, "open_date": T(opened), "close_date": None if is_open else T(closed), + "open_rate": open_rate, "close_rate": close_rate, "stake": stake, + "profit_ratio": None if is_open else profit_abs / stake, "profit_abs": profit_abs, + "exit_reason": exit_reason, "is_open": is_open} + + +def frame(*trades): + df = pd.DataFrame(list(trades), columns=["pair", "open_date", "close_date", "open_rate", "close_rate", "stake", + "profit_ratio", "profit_abs", "exit_reason", "is_open"]) + for col in ("open_date", "close_date"): + df[col] = pd.to_datetime(df[col], utc=True) + return df + + +def info(start="2026-09-01 14:09", end="2026-10-05 10:00", balance=1000.0): + return SessionInfo(session_id="s1", db=None, strategy="TrendRider4h", pairs=["BTC/USDT", "ETH/USDT"], + balance=balance, start=T(start), end=T(end)) + + +def test_matching_is_by_pair_and_nearest_entry(): + paper = frame(trade("BTC/USDT", "2026-09-02 08:00:20"), trade("ETH/USDT", "2026-09-03 00:00:30"), + trade("BTC/USDT", "2026-09-10 12:01")) + replay = frame(trade("BTC/USDT", "2026-09-02 08:00"), trade("BTC/USDT", "2026-09-02 04:00"), + trade("SOL/USDT", "2026-09-03 00:00"), trade("BTC/USDT", "2026-09-10 00:00")) + matched, paper_only, replay_only = match_trades(paper, replay, H4 * 1.5) + assert matched == [(0, 0)] # the 08:00 entry, not the 04:00 one + assert paper_only == [1, 2] # ETH has no partner; 12h apart is too far + assert replay_only == [1, 2, 3] + + +def test_reconcile_breaks_down_the_gap(): + paper = frame( + # same signal, paper paid 0.2% more and got 0.1% less on the same exit + trade("BTC/USDT", "2026-09-02 08:00:20", "2026-09-05 12:00:05", 100.2, 109.89, 9.0, "exit_signal"), + trade("ETH/USDT", "2026-09-12 00:00:30", "2026-09-14 04:00:10", 50.0, 49.0, -2.0, "stop_loss"), + # opened after the replay's data ends: not compared yet + trade("BTC/USDT", "2026-10-05 09:00"), + ) + replay = frame( + trade("BTC/USDT", "2026-09-02 08:00", "2026-09-05 12:00", 100.0, 110.0, 10.0, "exit_signal"), + trade("SOL/USDT", "2026-09-20 00:00", "2026-09-22 00:00", 20.0, 21.0, 5.0, "exit_signal"), + trade("ETH/USDT", "2026-10-04 20:00"), # left open at the end of the replay + ) + events = [{"ts": "2026-09-15T10:00:00+00:00", "event": "entries_paused"}, + {"ts": "2026-09-15T12:00:00+00:00", "event": "trading_resumed"}, + {"ts": "2026-09-15T12:00:00+00:00", "event": "stake_capped"}, # modelled by the backtest + {"ts": "2026-08-01T00:00:00+00:00", "event": "flatten"}] # before the session + r = reconcile(paper, replay, info=info(), replay_end=T("2026-10-05 04:00"), timeframe="4h", events=events) + + assert r["period"]["start"].startswith("2026-09-01T12:00") # floored to the 4h candle + assert r["matching"] == {"matched": 1, "paper_only": 1, "replay_only": 2, "match_rate_pct": 25.0, "tolerance_hours": 6.0} + assert r["paper"]["trades"] == 2 and r["paper"]["realised_abs"] == 7.0 + assert r["replay"]["open"] == 1 and r["replay"]["realised_abs"] == 15.0 + g = r["gap"] + assert g["total_abs"] == -8.0 + assert g["matched_abs"] + g["paper_only_abs"] + g["replay_only_abs"] == pytest.approx(g["total_abs"]) + assert g["return_gap_pp"] == pytest.approx(-0.8) + assert r["fills"]["entry_cost_pct"] == pytest.approx(0.2) and r["fills"]["exit_cost_pct"] == pytest.approx(0.1) + assert r["fills"]["fill_cost_pct"] == pytest.approx(0.15) + + checks = {c["key"]: c for c in r["checks"]} + assert checks["days"]["pass"] and checks["return_gap_pp"]["pass"] and checks["fill_cost_pct"]["pass"] + assert not checks["match_rate_pct"]["pass"] and r["close_to_replay"] is False + assert any("1 paper trade(s) opened after" in w for w in r["warnings"]) + assert any("entries_paused x1, trading_resumed x1" in w for w in r["warnings"]) + assert not any("flatten" in w or "stake_capped" in w for w in r["warnings"]) + assert [row["status"] for row in r["trades"]] == ["matched", "paper_only", "replay_only", "replay_only"] + assert r["trades"][-1]["replay_exit"] == "open" + + +def test_a_faithful_month_passes(): + days = range(2, 28, 3) + paper = frame(*(trade("BTC/USDT", f"2026-09-{d:02d} 04:00:30", f"2026-09-{d + 1:02d} 08:00:10", + 100.05, 101.0, 1.0, "exit_signal") for d in days)) + replay = frame(*(trade("BTC/USDT", f"2026-09-{d:02d} 04:00", f"2026-09-{d + 1:02d} 08:00", + 100.0, 101.0, 1.05, "exit_signal") for d in days)) + r = reconcile(paper, replay, info=info(start="2026-09-01 00:05"), replay_end=T("2026-10-05 04:00"), timeframe="4h") + assert r["matching"]["match_rate_pct"] == 100.0 + assert r["fills"]["same_exit_reason_pct"] == 100.0 + assert r["close_to_replay"] is True, r["checks"] + + +def test_short_session_without_trades_fails_honestly(): + r = reconcile(frame(), frame(), info=info(start="2026-10-01 00:00"), replay_end=T("2026-10-05 04:00"), timeframe="4h") + assert r["close_to_replay"] is False + assert {c["key"]: c["value"] for c in r["checks"]}["match_rate_pct"] is None + assert any("nothing to compare" in w for w in r["warnings"]) + + +def test_stale_replay_data_is_flagged(): + r = reconcile(frame(), frame(), info=info(), replay_end=T("2026-09-20 00:00"), timeframe="4h") + assert any("Download fresh data" in w for w in r["warnings"]) + + +# ---------------------------------------------------------------- session files + +def make_db(path, start="2026-09-01 14:09:52.1", strategy="TrendRider4h"): + with sqlite3.connect(path) as con: + con.execute("CREATE TABLE KeyValueStore (id INTEGER, key TEXT, value_type TEXT, string_value TEXT, " + "datetime_value DATETIME, float_value FLOAT, int_value INTEGER)") + con.execute("INSERT INTO KeyValueStore VALUES (1, 'bot_start_time', 'datetime', NULL, ?, NULL, NULL)", (start,)) + con.execute("CREATE TABLE trades (pair TEXT, open_date DATETIME, close_date DATETIME, open_rate FLOAT, " + "close_rate FLOAT, stake_amount FLOAT, close_profit FLOAT, close_profit_abs FLOAT, " + "exit_reason TEXT, is_open BOOLEAN, strategy TEXT)") + con.execute("INSERT INTO trades VALUES ('BTC/USDT', '2026-09-02 08:00:20.5', '2026-09-05 12:00:05', " + "100.2, 109.89, 100, 0.09, 9.0, 'exit_signal', 0, ?)", (strategy,)) + con.execute("INSERT INTO trades VALUES ('ETH/USDT', '2026-09-12 00:00:30', NULL, 50, NULL, 100, NULL, NULL, " + "NULL, 1, ?)", (strategy,)) + con.close() + + +def test_session_info_from_log_and_database(tmp_path): + db = tmp_path / "20260901-010946.sqlite" + make_db(db) + (tmp_path / "20260901-010946.config.json").write_text(json.dumps({"dry_run_wallet": 500})) + (tmp_path / "20260901-010946.log.1").write_text( + "... - INFO - Using resolved strategy TrendRider4h from 'x'...\n" + "... - INFO - Whitelist with 2 pairs: ['BTC/USDT', 'ETH/USDT']\n") + (tmp_path / "20260901-010946.log").write_text("... - INFO - Whitelist with 1 pairs: ['BTC/USDT']\n") + s = session_info(db) + assert (s.strategy, s.pairs, s.balance) == ("TrendRider4h", ["BTC/USDT", "ETH/USDT"], 500) # oldest log first + assert s.start == T("2026-09-01 14:09:52.1") + assert abs(s.end - pd.Timestamp.now(tz="UTC")) < pd.Timedelta(minutes=5) # last log write + assert any("pair list changed" in w for w in s.warnings) + + trades = load_paper_trades(db) + assert list(trades["is_open"]) == [False, True] and trades["open_date"].dt.tz is not None + + +def test_meta_file_wins_and_code_changes_are_flagged(tmp_path): + db = tmp_path / "s.sqlite" + make_db(db) + code = strategy_fingerprint("TrendRider4h") + code["TrendRider4h.py"] = "0" * 16 + (tmp_path / "s.meta.json").write_text(json.dumps({ + "strategy": "TrendRider4h", "pairs": ["SOL/USDT"], "balance": 250, "max_open_trades": 2, + "started": "2026-09-03T00:00:00+00:00", "code": code})) + s = session_info(db, end="2026-10-01") + assert (s.pairs, s.balance, s.max_open_trades, s.start) == (["SOL/USDT"], 250, 2, T("2026-09-03")) + assert s.end == T("2026-10-01") + assert any("TrendRider4h.py" in w for w in s.warnings) + assert not any("TrendRider4h.json" in w for w in s.warnings) + + +def test_missing_pairs_need_to_be_given(tmp_path): + db = tmp_path / "s.sqlite" + make_db(db) + with pytest.raises(ValueError, match="which pairs"): + session_info(db) + assert session_info(db, pairs=["BTC/USDT"]).pairs == ["BTC/USDT"] + + +def test_strategy_timeframes(): + assert strategy_timeframe("TrendRider4h") == "4h" + assert strategy_timeframe("EmaRsiMomentum") == "1h" + + +def test_replay_trades_left_open_come_back_open(tmp_path): + def bt_trade(pair, opened, closed, reason): + return {"pair": pair, "open_date": opened, "close_date": closed, "open_rate": 1.0, "close_rate": 1.1, + "stake_amount": 100.0, "profit_ratio": 0.1, "profit_abs": 10.0, "exit_reason": reason, "is_open": False} + + result = {"strategy": {"TrendRider4h": { + "backtest_start": "2026-09-01 12:00:00", "backtest_end": "2026-10-05 04:00:00", "timeframe": "4h", + "starting_balance": 1000, "trades": [ + bt_trade("BTC/USDT", "2026-09-02 08:00:00+00:00", "2026-09-05 12:00:00+00:00", "force_exit"), # flatten-like, mid-run + bt_trade("ETH/USDT", "2026-10-04 20:00:00+00:00", "2026-10-05 04:00:00+00:00", "force_exit"), + ]}}} + (tmp_path / "backtest-result-x.json").write_text(json.dumps(result)) + (tmp_path / ".last_result.json").write_text(json.dumps({"latest_backtest": "backtest-result-x.json"})) + df, meta = load_replay_trades(tmp_path) + assert list(df["is_open"]) == [False, True] + assert pd.isna(df.loc[1, "profit_abs"]) and df.loc[0, "profit_abs"] == 10.0 + assert meta["end"] == T("2026-10-05 04:00") and meta["timeframe"] == "4h" diff --git a/tests/test_repo_hygiene.py b/tests/test_repo_hygiene.py index 101f13d..b2da08c 100644 --- a/tests/test_repo_hygiene.py +++ b/tests/test_repo_hygiene.py @@ -33,3 +33,13 @@ def test_nothing_private_is_tracked(): tracked = subprocess.run(["git", "ls-files"], cwd=ROOT, capture_output=True, text=True).stdout.splitlines() bad = [f for f in tracked if f.endswith((".docx", ".sqlite")) or f.endswith("config.private.json")] assert not bad, f"private files tracked by git: {bad}" + + +def test_version_is_consistent(): + import re + + from ridgeline import __version__ + + pyproject = (ROOT / "pyproject.toml").read_text() + assert re.search(r'^version = "(.+)"', pyproject, re.M).group(1) == __version__ + assert (ROOT / "docs" / "releases" / f"v{__version__}.md").exists(), "write release notes for this version" diff --git a/tests/test_strategies.py b/tests/test_strategies.py index 53bcfb2..a8ea163 100644 --- a/tests/test_strategies.py +++ b/tests/test_strategies.py @@ -90,3 +90,57 @@ def test_stake_capped_and_skipped_below_minimum(strategy, monkeypatch): assert strat.apply_risk_caps("BTC/USDT", 300.0, 0.05, NOW, min_stake=150.0) == 0.0 fixed_state(monkeypatch, strat) assert strat.apply_risk_caps("BTC/USDT", 900.0, 0.01, NOW) == pytest.approx(350.0) # 35% position cap + + +# ---------------------------------------------------------------- cross-sectional + +class FakeDataProvider: + """Just enough of Freqtrade's DataProvider for a strategy that ranks pairs.""" + + def __init__(self, frames): + self.frames = frames + + def current_whitelist(self): + return list(self.frames) + + def get_pair_dataframe(self, pair, timeframe): + return self.frames[pair].copy() + + +def rotation_signals(strat, frames): + strat.dp = FakeDataProvider(frames) + out = {} + for pair, df in frames.items(): + meta = {"pair": pair} + res = strat.populate_exit_trend(strat.populate_entry_trend(strat.populate_indicators(df.copy(), meta), meta), meta) + out[pair] = res[["enter_long", "exit_long", "rank"]].fillna(0).to_numpy() + return out + + +@pytest.fixture +def rotation_frames(ohlcv): + frames = {f"P{i}/USDT": ohlcv(n=700, freq="1d", seed=i) for i in range(5)} + # a pair listed 150 days later than the rest has no rank before then + frames["LATE/USDT"] = ohlcv(n=550, freq="1d", start="2023-05-31", seed=9) + return {p: with_informatives(df, ["btc_close_1d", "btc_ema200_1d"]) for p, df in frames.items()} + + +def test_rotation_ranks_pairs_against_each_other(strategy, rotation_frames): + sig = rotation_signals(strategy("MomentumRotation1d"), rotation_frames) + ranks = np.concatenate([s[:, 2] for s in sig.values()]) + assert set(np.unique(ranks[ranks > 0])) == {1, 2, 3, 4, 5, 6} + assert sum(s[:, 0].sum() for s in sig.values()) > 0, "no entries at all" + assert (sig["LATE/USDT"][:14, 2] == 0).all() # unranked until it has 14 days of history + + +def test_rotation_no_lookahead_across_pairs(strategy, rotation_frames): + """Freqtrade's lookahead-analysis reloads one pair at a time, which can't + check a ranking. Remove later days from EVERY pair at once: no earlier + signal or rank may change.""" + strat = strategy("MomentumRotation1d") + full = rotation_signals(strat, rotation_frames) + for cut_date in ("2023-12-01", "2024-06-01", "2024-11-30"): + cut = {p: df[df["date"] < cut_date].reset_index(drop=True) for p, df in rotation_frames.items()} + part = rotation_signals(strat, cut) + for pair, sig in part.items(): + assert np.array_equal(sig, full[pair][:len(sig)]), f"{pair}: signals before {cut_date} changed" diff --git a/tests/test_universe.py b/tests/test_universe.py index 4dadb19..c5ee9f6 100644 --- a/tests/test_universe.py +++ b/tests/test_universe.py @@ -48,3 +48,21 @@ def test_filters(datadir): assert "USDC/USDT" not in universe # blacklisted assert "NEW/USDT" not in universe # < 210 days of history assert "DEAD/USDT" not in universe # not trading in the lookback window + + +RECURSIVE_TABLE = """ +| Indicators | 199 | 360 (from strategy) | 399 | 499 | 999 | 1999 | +|---------------+---------+---------------------+---------+---------+--------+--------| +| btc_ema200_1d | -5.978% | 0.392% | -0.118% | -0.148% | 0.041% | 0.041% | +| ema50 | -0.003% | -0.000% | 0.000% | -0.000% | 0.000% | 0.000% | +| atr | -0.000% | 0.000% | 0.000% | - | - | - | +| score | - | - | nan% | 0.000% | 0.000% | 0.000% | +""" + + +def test_recursive_gate_ignores_fewer_startup_candles_than_the_strategy_uses(): + import research + + # 5.978% is at 199 candles, below the 360 the strategy always gets. + assert research.worst_recursive_deviation(RECURSIVE_TABLE) == 0.392 + assert research.worst_recursive_deviation("no table here") is None diff --git a/user_data/research/latest-65.json b/user_data/research/latest-65.json new file mode 100644 index 0000000..dd3cde7 --- /dev/null +++ b/user_data/research/latest-65.json @@ -0,0 +1,1027 @@ +{ + "generated": "2026-10-10T15:41:02+00:00", + "periods": { + "is": [ + "2023-06-01", + "2025-06-01" + ], + "oos": [ + "2025-06-01", + "2026-10-10" + ] + }, + "benchmarks": { + "is": { + "equal_weight_pct": 89.11, + "btc_pct": 293.93 + }, + "oos": { + "equal_weight_pct": -32.82, + "btc_pct": -21.78 + } + }, + "universe": { + "method": "point-in-time top 25 by 30d volume", + "is": [ + "BTC/USDT", + "ETH/USDT", + "XRP/USDT", + "BNB/USDT", + "LTC/USDT", + "SOL/USDT", + "CFX/USDT", + "DOGE/USDT", + "ARPA/USDT", + "ADA/USDT", + "GALA/USDT", + "RAD/USDT", + "APT/USDT", + "OP/USDT", + "TRX/USDT", + "INJ/USDT", + "LDO/USDT", + "AVAX/USDT", + "FIL/USDT", + "SHIB/USDT", + "MASK/USDT", + "LINK/USDT", + "FET/USDT", + "JASMY/USDT", + "STX/USDT" + ], + "oos": [ + "BTC/USDT", + "ETH/USDT", + "SOL/USDT", + "PEPE/USDT", + "XRP/USDT", + "DOGE/USDT", + "SUI/USDT", + "BNB/USDT", + "TRX/USDT", + "WIF/USDT", + "ADA/USDT", + "NEIRO/USDT", + "ENA/USDT", + "WLD/USDT", + "AVAX/USDT", + "LINK/USDT", + "LTC/USDT", + "BONK/USDT", + "UNI/USDT", + "AAVE/USDT", + "ETHFI/USDT", + "TAO/USDT", + "SHIB/USDT", + "NEAR/USDT", + "ARB/USDT" + ] + }, + "strategies": { + "EmaRsiMomentum": { + "is": { + "strategy": "EmaRsiMomentum", + "timerange": "20230601-20250601", + "start": "2023-06-01 00:00:00", + "end": "2025-06-01 00:00:00", + "starting_balance": 65.0, + "final_balance": 70.09, + "profit_pct": 7.82, + "market_change_pct": 85.52, + "max_drawdown_pct": 21.42, + "profit_factor": 1.07, + "winrate_pct": 52.9, + "trades": 189, + "calmar": 0.95, + "sharpe": 0.14, + "cagr_pct": 3.83, + "exit_reasons": { + "roi": { + "count": 110, + "profit_abs": 73.38 + }, + "trailing_stop_loss": { + "count": 79, + "profit_abs": -68.29 + } + } + }, + "oos": { + "strategy": "EmaRsiMomentum", + "timerange": "20250601-20261010", + "start": "2025-06-01 00:00:00", + "end": "2026-10-10 00:00:00", + "starting_balance": 65.0, + "final_balance": 68.29, + "profit_pct": 5.07, + "market_change_pct": -31.67, + "max_drawdown_pct": 18.09, + "profit_factor": 1.13, + "winrate_pct": 58.1, + "trades": 74, + "calmar": 1.08, + "sharpe": 0.15, + "cagr_pct": 3.7, + "exit_reasons": { + "roi": { + "count": 43, + "profit_abs": 28.76 + }, + "trailing_stop_loss": { + "count": 31, + "profit_abs": -25.47 + } + } + }, + "stress": { + "strategy": "EmaRsiMomentum", + "timerange": "20250601-20261010", + "start": "2025-06-01 00:00:00", + "end": "2026-10-10 00:00:00", + "starting_balance": 65.0, + "final_balance": 67.0, + "profit_pct": 3.08, + "market_change_pct": -31.67, + "max_drawdown_pct": 18.34, + "profit_factor": 1.08, + "winrate_pct": 52.7, + "trades": 74, + "calmar": 0.65, + "sharpe": 0.09, + "cagr_pct": 2.26, + "exit_reasons": { + "roi": { + "count": 43, + "profit_abs": 27.43 + }, + "trailing_stop_loss": { + "count": 31, + "profit_abs": -25.43 + } + } + }, + "walkforward": { + "windows": [ + { + "window": "2023-06-01 -> 2023-09-01", + "profit_pct": -5.74, + "trades": 18, + "market_change_pct": -20.06 + }, + { + "window": "2023-09-01 -> 2023-12-01", + "profit_pct": 6.28, + "trades": 23, + "market_change_pct": 51.5 + }, + { + "window": "2023-12-01 -> 2024-03-01", + "profit_pct": 8.97, + "trades": 47, + "market_change_pct": 57.02 + }, + { + "window": "2024-03-01 -> 2024-06-01", + "profit_pct": -7.06, + "trades": 23, + "market_change_pct": 20.51 + }, + { + "window": "2024-06-01 -> 2024-09-01", + "profit_pct": -8.41, + "trades": 17, + "market_change_pct": -37.21 + }, + { + "window": "2024-09-01 -> 2024-12-01", + "profit_pct": 1.33, + "trades": 22, + "market_change_pct": 97.44 + }, + { + "window": "2024-12-01 -> 2025-03-01", + "profit_pct": -2.17, + "trades": 14, + "market_change_pct": -42.39 + }, + { + "window": "2025-03-01 -> 2025-06-01", + "profit_pct": -1.86, + "trades": 18, + "market_change_pct": 5.71 + }, + { + "window": "2025-06-01 -> 2025-09-01", + "profit_pct": 4.7, + "trades": 20, + "market_change_pct": 19.44 + }, + { + "window": "2025-09-01 -> 2025-12-01", + "profit_pct": -2.56, + "trades": 13, + "market_change_pct": -42.63 + }, + { + "window": "2025-12-01 -> 2026-03-01", + "profit_pct": -7.76, + "trades": 7, + "market_change_pct": -33.34 + }, + { + "window": "2026-03-01 -> 2026-06-01", + "profit_pct": -6.13, + "trades": 16, + "market_change_pct": 7.44 + }, + { + "window": "2026-06-01 -> 2026-09-01", + "profit_pct": 1.74, + "trades": 10, + "market_change_pct": -0.87 + }, + { + "window": "2026-09-01 -> 2026-10-10", + "profit_pct": 13.7, + "trades": 15, + "market_change_pct": 19.98 + } + ], + "pct_profitable": 42.9, + "worst_pct": -8.41 + }, + "bias": { + "lookahead_clean": true, + "lookahead_note": "20 signals checked; biased indicators: none", + "recursive_max_diff_pct": 0.001, + "recursive_note": "worst indicator deviation at the strategy's startup candle count or more" + }, + "gates": [ + { + "label": "OOS profit > 0%", + "value": 5.07, + "pass": true + }, + { + "label": "OOS profit factor >= 1.3", + "value": 1.13, + "pass": false + }, + { + "label": "OOS max drawdown <= 20%", + "value": 18.09, + "pass": true + }, + { + "label": "OOS Calmar >= 1.0", + "value": 1.08, + "pass": true + }, + { + "label": "OOS trades >= 100", + "value": 74, + "pass": false + }, + { + "label": "Profitable in >= 60% of walk-forward windows", + "value": 42.9, + "pass": false + }, + { + "label": "OOS still profitable with +50% fees", + "value": 3.08, + "pass": true + }, + { + "label": "No lookahead bias", + "value": true, + "pass": true + }, + { + "label": "Indicators stable vs startup candles (<= 1%)", + "value": 0.001, + "pass": true + } + ], + "eligible_for_paper": false + }, + "MomentumRotation1d": { + "is": { + "strategy": "MomentumRotation1d", + "timerange": "20230601-20250601", + "start": "2023-06-01 00:00:00", + "end": "2025-06-01 00:00:00", + "starting_balance": 65.0, + "final_balance": 86.86, + "profit_pct": 33.64, + "market_change_pct": 89.11, + "max_drawdown_pct": 12.79, + "profit_factor": 1.41, + "winrate_pct": 43.9, + "trades": 198, + "calmar": 6.87, + "sharpe": 0.56, + "cagr_pct": 15.58, + "exit_reasons": { + "exit_signal": { + "count": 149, + "profit_abs": 20.39 + }, + "trailing_stop_loss": { + "count": 42, + "profit_abs": 10.34 + }, + "stop_loss": { + "count": 7, + "profit_abs": -8.87 + } + } + }, + "oos": { + "strategy": "MomentumRotation1d", + "timerange": "20250601-20261010", + "start": "2025-06-01 00:00:00", + "end": "2026-10-09 00:00:00", + "starting_balance": 65.0, + "final_balance": 74.08, + "profit_pct": 13.98, + "market_change_pct": -32.82, + "max_drawdown_pct": 7.06, + "profit_factor": 1.62, + "winrate_pct": 43.8, + "trades": 64, + "calmar": 7.64, + "sharpe": 0.34, + "cagr_pct": 10.13, + "exit_reasons": { + "exit_signal": { + "count": 44, + "profit_abs": 12.79 + }, + "force_exit": { + "count": 1, + "profit_abs": -0.44 + }, + "trailing_stop_loss": { + "count": 17, + "profit_abs": -1.23 + }, + "stop_loss": { + "count": 2, + "profit_abs": -2.03 + } + } + }, + "stress": { + "strategy": "MomentumRotation1d", + "timerange": "20250601-20261010", + "start": "2025-06-01 00:00:00", + "end": "2026-10-09 00:00:00", + "starting_balance": 65.0, + "final_balance": 73.43, + "profit_pct": 12.97, + "market_change_pct": -32.82, + "max_drawdown_pct": 6.6, + "profit_factor": 1.59, + "winrate_pct": 45.2, + "trades": 62, + "calmar": 7.58, + "sharpe": 0.32, + "cagr_pct": 9.41, + "exit_reasons": { + "exit_signal": { + "count": 41, + "profit_abs": 12.46 + }, + "trailing_stop_loss": { + "count": 17, + "profit_abs": -0.45 + }, + "force_exit": { + "count": 2, + "profit_abs": -1.55 + }, + "stop_loss": { + "count": 2, + "profit_abs": -2.04 + } + } + }, + "walkforward": { + "windows": [ + { + "window": "2023-06-01 -> 2023-09-01", + "profit_pct": -3.36, + "trades": 25, + "market_change_pct": -21.36 + }, + { + "window": "2023-09-01 -> 2023-12-01", + "profit_pct": 5.23, + "trades": 23, + "market_change_pct": 57.28 + }, + { + "window": "2023-12-01 -> 2024-03-01", + "profit_pct": 13.98, + "trades": 29, + "market_change_pct": 59.77 + }, + { + "window": "2024-03-01 -> 2024-06-01", + "profit_pct": -4.3, + "trades": 26, + "market_change_pct": 9.47 + }, + { + "window": "2024-06-01 -> 2024-09-01", + "profit_pct": -9.31, + "trades": 24, + "market_change_pct": -40.81 + }, + { + "window": "2024-09-01 -> 2024-12-01", + "profit_pct": 3.11, + "trades": 27, + "market_change_pct": 114.33 + }, + { + "window": "2024-12-01 -> 2025-03-01", + "profit_pct": -15.39, + "trades": 24, + "market_change_pct": -43.91 + }, + { + "window": "2025-03-01 -> 2025-06-01", + "profit_pct": -4.05, + "trades": 23, + "market_change_pct": 5.5 + }, + { + "window": "2025-06-01 -> 2025-09-01", + "profit_pct": 7.24, + "trades": 23, + "market_change_pct": 15.51 + }, + { + "window": "2025-09-01 -> 2025-12-01", + "profit_pct": 1.77, + "trades": 15, + "market_change_pct": -42.47 + }, + { + "window": "2025-12-01 -> 2026-03-01", + "profit_pct": 0.0, + "trades": 0, + "market_change_pct": -33.6 + }, + { + "window": "2026-03-01 -> 2026-06-01", + "profit_pct": -0.53, + "trades": 2, + "market_change_pct": 7.9 + }, + { + "window": "2026-06-01 -> 2026-09-01", + "profit_pct": 4.84, + "trades": 4, + "market_change_pct": -0.46 + }, + { + "window": "2026-09-01 -> 2026-10-10", + "profit_pct": 13.23, + "trades": 12, + "market_change_pct": 19.59 + } + ], + "pct_profitable": 50.0, + "worst_pct": -15.39 + }, + "bias": { + "lookahead_clean": true, + "lookahead_note": "cross-sectional: 129 trades compared with later data removed from all pairs (87 in the full run); no differences", + "recursive_max_diff_pct": 0.392, + "recursive_note": "worst indicator deviation at the strategy's startup candle count or more" + }, + "gates": [ + { + "label": "OOS profit > 0%", + "value": 13.98, + "pass": true + }, + { + "label": "OOS profit factor >= 1.3", + "value": 1.62, + "pass": true + }, + { + "label": "OOS max drawdown <= 20%", + "value": 7.06, + "pass": true + }, + { + "label": "OOS Calmar >= 1.0", + "value": 7.64, + "pass": true + }, + { + "label": "OOS trades >= 100", + "value": 64, + "pass": false + }, + { + "label": "Profitable in >= 60% of walk-forward windows", + "value": 50.0, + "pass": false + }, + { + "label": "OOS still profitable with +50% fees", + "value": 12.97, + "pass": true + }, + { + "label": "No lookahead bias", + "value": true, + "pass": true + }, + { + "label": "Indicators stable vs startup candles (<= 1%)", + "value": 0.392, + "pass": true + } + ], + "eligible_for_paper": false + }, + "TrendDipBuyer1h": { + "is": { + "strategy": "TrendDipBuyer1h", + "timerange": "20230601-20250601", + "start": "2023-06-01 00:00:00", + "end": "2025-06-01 00:00:00", + "starting_balance": 65.0, + "final_balance": 59.8, + "profit_pct": -7.99, + "market_change_pct": 85.52, + "max_drawdown_pct": 26.28, + "profit_factor": 0.96, + "winrate_pct": 64.3, + "trades": 932, + "calmar": -0.79, + "sharpe": -0.35, + "cagr_pct": -4.07, + "exit_reasons": { + "exit_signal": { + "count": 732, + "profit_abs": 56.07 + }, + "roi": { + "count": 92, + "profit_abs": 35.53 + }, + "trailing_stop_loss": { + "count": 108, + "profit_abs": -96.79 + } + } + }, + "oos": { + "strategy": "TrendDipBuyer1h", + "timerange": "20250601-20261010", + "start": "2025-06-01 00:00:00", + "end": "2026-10-10 00:00:00", + "starting_balance": 65.0, + "final_balance": 53.49, + "profit_pct": -17.71, + "market_change_pct": -31.67, + "max_drawdown_pct": 19.11, + "profit_factor": 0.73, + "winrate_pct": 62.7, + "trades": 300, + "calmar": -3.57, + "sharpe": -1.28, + "cagr_pct": -13.37, + "exit_reasons": { + "roi": { + "count": 25, + "profit_abs": 8.09 + }, + "exit_signal": { + "count": 242, + "profit_abs": 7.82 + }, + "trailing_stop_loss": { + "count": 33, + "profit_abs": -27.42 + } + } + }, + "stress": { + "strategy": "TrendDipBuyer1h", + "timerange": "20250601-20261010", + "start": "2025-06-01 00:00:00", + "end": "2026-10-10 00:00:00", + "starting_balance": 65.0, + "final_balance": 49.02, + "profit_pct": -24.59, + "market_change_pct": -31.67, + "max_drawdown_pct": 25.69, + "profit_factor": 0.62, + "winrate_pct": 59.2, + "trades": 284, + "calmar": -3.69, + "sharpe": -1.83, + "cagr_pct": -18.75, + "exit_reasons": { + "roi": { + "count": 22, + "profit_abs": 6.99 + }, + "exit_signal": { + "count": 228, + "profit_abs": 4.25 + }, + "trailing_stop_loss": { + "count": 34, + "profit_abs": -27.23 + } + } + }, + "walkforward": { + "windows": [ + { + "window": "2023-06-01 -> 2023-09-01", + "profit_pct": -7.65, + "trades": 116, + "market_change_pct": -20.06 + }, + { + "window": "2023-09-01 -> 2023-12-01", + "profit_pct": 3.33, + "trades": 119, + "market_change_pct": 51.5 + }, + { + "window": "2023-12-01 -> 2024-03-01", + "profit_pct": 7.12, + "trades": 171, + "market_change_pct": 57.02 + }, + { + "window": "2024-03-01 -> 2024-06-01", + "profit_pct": -0.35, + "trades": 135, + "market_change_pct": 20.51 + }, + { + "window": "2024-06-01 -> 2024-09-01", + "profit_pct": -5.6, + "trades": 79, + "market_change_pct": -37.21 + }, + { + "window": "2024-09-01 -> 2024-12-01", + "profit_pct": 7.33, + "trades": 140, + "market_change_pct": 97.44 + }, + { + "window": "2024-12-01 -> 2025-03-01", + "profit_pct": 6.54, + "trades": 116, + "market_change_pct": -42.39 + }, + { + "window": "2025-03-01 -> 2025-06-01", + "profit_pct": -2.38, + "trades": 93, + "market_change_pct": 5.71 + }, + { + "window": "2025-06-01 -> 2025-09-01", + "profit_pct": -8.81, + "trades": 135, + "market_change_pct": 19.44 + }, + { + "window": "2025-09-01 -> 2025-12-01", + "profit_pct": -4.8, + "trades": 74, + "market_change_pct": -42.63 + }, + { + "window": "2025-12-01 -> 2026-03-01", + "profit_pct": 0.0, + "trades": 0, + "market_change_pct": -33.34 + }, + { + "window": "2026-03-01 -> 2026-06-01", + "profit_pct": 0.28, + "trades": 1, + "market_change_pct": 7.44 + }, + { + "window": "2026-06-01 -> 2026-09-01", + "profit_pct": -0.81, + "trades": 18, + "market_change_pct": -0.87 + }, + { + "window": "2026-09-01 -> 2026-10-10", + "profit_pct": -0.11, + "trades": 86, + "market_change_pct": 19.98 + } + ], + "pct_profitable": 35.7, + "worst_pct": -8.81 + }, + "bias": { + "lookahead_clean": true, + "lookahead_note": "20 signals checked; biased indicators: none", + "recursive_max_diff_pct": 0.0, + "recursive_note": "worst indicator deviation at the strategy's startup candle count or more" + }, + "gates": [ + { + "label": "OOS profit > 0%", + "value": -17.71, + "pass": false + }, + { + "label": "OOS profit factor >= 1.3", + "value": 0.73, + "pass": false + }, + { + "label": "OOS max drawdown <= 20%", + "value": 19.11, + "pass": true + }, + { + "label": "OOS Calmar >= 1.0", + "value": -3.57, + "pass": false + }, + { + "label": "OOS trades >= 100", + "value": 300, + "pass": true + }, + { + "label": "Profitable in >= 60% of walk-forward windows", + "value": 35.7, + "pass": false + }, + { + "label": "OOS still profitable with +50% fees", + "value": -24.59, + "pass": false + }, + { + "label": "No lookahead bias", + "value": true, + "pass": true + }, + { + "label": "Indicators stable vs startup candles (<= 1%)", + "value": 0.0, + "pass": true + } + ], + "eligible_for_paper": false + }, + "TrendRider4h": { + "is": { + "strategy": "TrendRider4h", + "timerange": "20230601-20250601", + "start": "2023-06-01 00:00:00", + "end": "2025-06-01 00:00:00", + "starting_balance": 65.0, + "final_balance": 68.74, + "profit_pct": 5.76, + "market_change_pct": 85.76, + "max_drawdown_pct": 15.08, + "profit_factor": 1.06, + "winrate_pct": 35.8, + "trades": 274, + "calmar": 1.0, + "sharpe": 0.11, + "cagr_pct": 2.83, + "exit_reasons": { + "exit_signal": { + "count": 242, + "profit_abs": 12.32 + }, + "trailing_stop_loss": { + "count": 26, + "profit_abs": -2.56 + }, + "stop_loss": { + "count": 6, + "profit_abs": -6.02 + } + } + }, + "oos": { + "strategy": "TrendRider4h", + "timerange": "20250601-20261010", + "start": "2025-06-01 00:00:00", + "end": "2026-10-10 00:00:00", + "starting_balance": 65.0, + "final_balance": 76.35, + "profit_pct": 17.47, + "market_change_pct": -31.1, + "max_drawdown_pct": 9.66, + "profit_factor": 1.44, + "winrate_pct": 34.3, + "trades": 99, + "calmar": 6.97, + "sharpe": 0.49, + "cagr_pct": 12.58, + "exit_reasons": { + "exit_signal": { + "count": 93, + "profit_abs": 14.33 + }, + "trailing_stop_loss": { + "count": 6, + "profit_abs": -2.97 + } + } + }, + "stress": { + "strategy": "TrendRider4h", + "timerange": "20250601-20261010", + "start": "2025-06-01 00:00:00", + "end": "2026-10-10 00:00:00", + "starting_balance": 65.0, + "final_balance": 74.93, + "profit_pct": 15.28, + "market_change_pct": -31.1, + "max_drawdown_pct": 9.9, + "profit_factor": 1.38, + "winrate_pct": 34.3, + "trades": 99, + "calmar": 5.94, + "sharpe": 0.43, + "cagr_pct": 11.03, + "exit_reasons": { + "exit_signal": { + "count": 93, + "profit_abs": 12.95 + }, + "trailing_stop_loss": { + "count": 6, + "profit_abs": -3.01 + } + } + }, + "walkforward": { + "windows": [ + { + "window": "2023-06-01 -> 2023-09-01", + "profit_pct": -6.95, + "trades": 35, + "market_change_pct": -19.69 + }, + { + "window": "2023-09-01 -> 2023-12-01", + "profit_pct": 0.74, + "trades": 34, + "market_change_pct": 54.45 + }, + { + "window": "2023-12-01 -> 2024-03-01", + "profit_pct": 22.22, + "trades": 48, + "market_change_pct": 54.6 + }, + { + "window": "2024-03-01 -> 2024-06-01", + "profit_pct": 2.41, + "trades": 47, + "market_change_pct": 21.27 + }, + { + "window": "2024-06-01 -> 2024-09-01", + "profit_pct": -5.45, + "trades": 25, + "market_change_pct": -37.95 + }, + { + "window": "2024-09-01 -> 2024-12-01", + "profit_pct": 30.46, + "trades": 43, + "market_change_pct": 97.89 + }, + { + "window": "2024-12-01 -> 2025-03-01", + "profit_pct": 0.62, + "trades": 32, + "market_change_pct": -41.03 + }, + { + "window": "2025-03-01 -> 2025-06-01", + "profit_pct": 0.75, + "trades": 21, + "market_change_pct": 3.16 + }, + { + "window": "2025-06-01 -> 2025-09-01", + "profit_pct": 5.53, + "trades": 46, + "market_change_pct": 18.99 + }, + { + "window": "2025-09-01 -> 2025-12-01", + "profit_pct": -1.0, + "trades": 27, + "market_change_pct": -42.56 + }, + { + "window": "2025-12-01 -> 2026-03-01", + "profit_pct": 0.0, + "trades": 0, + "market_change_pct": -31.3 + }, + { + "window": "2026-03-01 -> 2026-06-01", + "profit_pct": -0.85, + "trades": 1, + "market_change_pct": 5.11 + }, + { + "window": "2026-06-01 -> 2026-09-01", + "profit_pct": 6.04, + "trades": 7, + "market_change_pct": -0.76 + }, + { + "window": "2026-09-01 -> 2026-10-10", + "profit_pct": 0.24, + "trades": 21, + "market_change_pct": 20.38 + } + ], + "pct_profitable": 64.3, + "worst_pct": -6.95 + }, + "bias": { + "lookahead_clean": true, + "lookahead_note": "20 signals checked; biased indicators: none", + "recursive_max_diff_pct": 0.0, + "recursive_note": "worst indicator deviation at the strategy's startup candle count or more" + }, + "gates": [ + { + "label": "OOS profit > 0%", + "value": 17.47, + "pass": true + }, + { + "label": "OOS profit factor >= 1.3", + "value": 1.44, + "pass": true + }, + { + "label": "OOS max drawdown <= 20%", + "value": 9.66, + "pass": true + }, + { + "label": "OOS Calmar >= 1.0", + "value": 6.97, + "pass": true + }, + { + "label": "OOS trades >= 100", + "value": 99, + "pass": false + }, + { + "label": "Profitable in >= 60% of walk-forward windows", + "value": 64.3, + "pass": true + }, + { + "label": "OOS still profitable with +50% fees", + "value": 15.28, + "pass": true + }, + { + "label": "No lookahead bias", + "value": true, + "pass": true + }, + { + "label": "Indicators stable vs startup candles (<= 1%)", + "value": 0.0, + "pass": true + } + ], + "eligible_for_paper": false + } + }, + "balance": 65.0 +} \ No newline at end of file diff --git a/user_data/research/latest.json b/user_data/research/latest.json index bd69fa7..2da0dbf 100644 --- a/user_data/research/latest.json +++ b/user_data/research/latest.json @@ -1,5 +1,5 @@ { - "generated": "2026-10-10T14:06:21+00:00", + "generated": "2026-10-10T15:37:03+00:00", "periods": { "is": [ "2023-06-01", @@ -253,8 +253,8 @@ "bias": { "lookahead_clean": true, "lookahead_note": "20 signals checked; biased indicators: none", - "recursive_max_diff_pct": 0.338, - "recursive_note": "worst indicator deviation with fewer startup candles" + "recursive_max_diff_pct": 0.001, + "recursive_note": "worst indicator deviation at the strategy's startup candle count or more" }, "gates": [ { @@ -299,7 +299,246 @@ }, { "label": "Indicators stable vs startup candles (<= 1%)", - "value": 0.338, + "value": 0.001, + "pass": true + } + ], + "eligible_for_paper": false + }, + "MomentumRotation1d": { + "is": { + "strategy": "MomentumRotation1d", + "timerange": "20230601-20250601", + "start": "2023-06-01 00:00:00", + "end": "2025-06-01 00:00:00", + "starting_balance": 1000.0, + "final_balance": 1244.39, + "profit_pct": 24.44, + "market_change_pct": 89.11, + "max_drawdown_pct": 14.77, + "profit_factor": 1.29, + "winrate_pct": 40.8, + "trades": 201, + "calmar": 4.32, + "sharpe": 0.41, + "cagr_pct": 11.54, + "exit_reasons": { + "exit_signal": { + "count": 149, + "profit_abs": 222.64 + }, + "trailing_stop_loss": { + "count": 45, + "profit_abs": 149.39 + }, + "stop_loss": { + "count": 7, + "profit_abs": -127.63 + } + } + }, + "oos": { + "strategy": "MomentumRotation1d", + "timerange": "20250601-20261010", + "start": "2025-06-01 00:00:00", + "end": "2026-10-09 00:00:00", + "starting_balance": 1000.0, + "final_balance": 1131.24, + "profit_pct": 13.12, + "market_change_pct": -32.82, + "max_drawdown_pct": 5.21, + "profit_factor": 1.59, + "winrate_pct": 42.9, + "trades": 63, + "calmar": 9.72, + "sharpe": 0.32, + "cagr_pct": 9.52, + "exit_reasons": { + "exit_signal": { + "count": 44, + "profit_abs": 202.89 + }, + "force_exit": { + "count": 2, + "profit_abs": -20.52 + }, + "trailing_stop_loss": { + "count": 17, + "profit_abs": -51.13 + } + } + }, + "stress": { + "strategy": "MomentumRotation1d", + "timerange": "20250601-20261010", + "start": "2025-06-01 00:00:00", + "end": "2026-10-09 00:00:00", + "starting_balance": 1000.0, + "final_balance": 1123.73, + "profit_pct": 12.37, + "market_change_pct": -32.82, + "max_drawdown_pct": 5.35, + "profit_factor": 1.54, + "winrate_pct": 42.9, + "trades": 63, + "calmar": 8.92, + "sharpe": 0.3, + "cagr_pct": 8.98, + "exit_reasons": { + "exit_signal": { + "count": 44, + "profit_abs": 197.48 + }, + "force_exit": { + "count": 2, + "profit_abs": -20.53 + }, + "trailing_stop_loss": { + "count": 17, + "profit_abs": -53.22 + } + } + }, + "walkforward": { + "windows": [ + { + "window": "2023-06-01 -> 2023-09-01", + "profit_pct": -1.77, + "trades": 25, + "market_change_pct": -21.36 + }, + { + "window": "2023-09-01 -> 2023-12-01", + "profit_pct": 5.56, + "trades": 23, + "market_change_pct": 57.28 + }, + { + "window": "2023-12-01 -> 2024-03-01", + "profit_pct": 13.85, + "trades": 30, + "market_change_pct": 59.77 + }, + { + "window": "2024-03-01 -> 2024-06-01", + "profit_pct": 3.75, + "trades": 24, + "market_change_pct": 9.47 + }, + { + "window": "2024-06-01 -> 2024-09-01", + "profit_pct": -8.01, + "trades": 25, + "market_change_pct": -40.81 + }, + { + "window": "2024-09-01 -> 2024-12-01", + "profit_pct": 0.75, + "trades": 29, + "market_change_pct": 114.33 + }, + { + "window": "2024-12-01 -> 2025-03-01", + "profit_pct": -10.7, + "trades": 26, + "market_change_pct": -43.91 + }, + { + "window": "2025-03-01 -> 2025-06-01", + "profit_pct": -3.86, + "trades": 24, + "market_change_pct": 5.5 + }, + { + "window": "2025-06-01 -> 2025-09-01", + "profit_pct": 9.29, + "trades": 24, + "market_change_pct": 15.51 + }, + { + "window": "2025-09-01 -> 2025-12-01", + "profit_pct": 2.03, + "trades": 15, + "market_change_pct": -42.47 + }, + { + "window": "2025-12-01 -> 2026-03-01", + "profit_pct": 0.0, + "trades": 0, + "market_change_pct": -33.6 + }, + { + "window": "2026-03-01 -> 2026-06-01", + "profit_pct": -0.69, + "trades": 3, + "market_change_pct": 7.9 + }, + { + "window": "2026-06-01 -> 2026-09-01", + "profit_pct": 4.91, + "trades": 4, + "market_change_pct": -0.46 + }, + { + "window": "2026-09-01 -> 2026-10-10", + "profit_pct": 10.19, + "trades": 13, + "market_change_pct": 19.59 + } + ], + "pct_profitable": 57.1, + "worst_pct": -10.7 + }, + "bias": { + "lookahead_clean": true, + "lookahead_note": "cross-sectional: 132 trades compared with later data removed from all pairs (94 in the full run); no differences", + "recursive_max_diff_pct": 0.392, + "recursive_note": "worst indicator deviation at the strategy's startup candle count or more" + }, + "gates": [ + { + "label": "OOS profit > 0%", + "value": 13.12, + "pass": true + }, + { + "label": "OOS profit factor >= 1.3", + "value": 1.59, + "pass": true + }, + { + "label": "OOS max drawdown <= 20%", + "value": 5.21, + "pass": true + }, + { + "label": "OOS Calmar >= 1.0", + "value": 9.72, + "pass": true + }, + { + "label": "OOS trades >= 100", + "value": 63, + "pass": false + }, + { + "label": "Profitable in >= 60% of walk-forward windows", + "value": 57.1, + "pass": false + }, + { + "label": "OOS still profitable with +50% fees", + "value": 12.37, + "pass": true + }, + { + "label": "No lookahead bias", + "value": true, + "pass": true + }, + { + "label": "Indicators stable vs startup candles (<= 1%)", + "value": 0.392, "pass": true } ], @@ -492,8 +731,8 @@ "bias": { "lookahead_clean": true, "lookahead_note": "20 signals checked; biased indicators: none", - "recursive_max_diff_pct": 0.338, - "recursive_note": "worst indicator deviation with fewer startup candles" + "recursive_max_diff_pct": 0.0, + "recursive_note": "worst indicator deviation at the strategy's startup candle count or more" }, "gates": [ { @@ -538,7 +777,7 @@ }, { "label": "Indicators stable vs startup candles (<= 1%)", - "value": 0.338, + "value": 0.0, "pass": true } ], @@ -723,8 +962,8 @@ "bias": { "lookahead_clean": true, "lookahead_note": "20 signals checked; biased indicators: none", - "recursive_max_diff_pct": 0.196, - "recursive_note": "worst indicator deviation with fewer startup candles" + "recursive_max_diff_pct": 0.0, + "recursive_note": "worst indicator deviation at the strategy's startup candle count or more" }, "gates": [ { @@ -769,11 +1008,12 @@ }, { "label": "Indicators stable vs startup candles (<= 1%)", - "value": 0.196, + "value": 0.0, "pass": true } ], "eligible_for_paper": false } - } + }, + "balance": 1000.0 } \ No newline at end of file diff --git a/user_data/research/report-2026-10-11.json b/user_data/research/report-2026-10-11.json index bd69fa7..2da0dbf 100644 --- a/user_data/research/report-2026-10-11.json +++ b/user_data/research/report-2026-10-11.json @@ -1,5 +1,5 @@ { - "generated": "2026-10-10T14:06:21+00:00", + "generated": "2026-10-10T15:37:03+00:00", "periods": { "is": [ "2023-06-01", @@ -253,8 +253,8 @@ "bias": { "lookahead_clean": true, "lookahead_note": "20 signals checked; biased indicators: none", - "recursive_max_diff_pct": 0.338, - "recursive_note": "worst indicator deviation with fewer startup candles" + "recursive_max_diff_pct": 0.001, + "recursive_note": "worst indicator deviation at the strategy's startup candle count or more" }, "gates": [ { @@ -299,7 +299,246 @@ }, { "label": "Indicators stable vs startup candles (<= 1%)", - "value": 0.338, + "value": 0.001, + "pass": true + } + ], + "eligible_for_paper": false + }, + "MomentumRotation1d": { + "is": { + "strategy": "MomentumRotation1d", + "timerange": "20230601-20250601", + "start": "2023-06-01 00:00:00", + "end": "2025-06-01 00:00:00", + "starting_balance": 1000.0, + "final_balance": 1244.39, + "profit_pct": 24.44, + "market_change_pct": 89.11, + "max_drawdown_pct": 14.77, + "profit_factor": 1.29, + "winrate_pct": 40.8, + "trades": 201, + "calmar": 4.32, + "sharpe": 0.41, + "cagr_pct": 11.54, + "exit_reasons": { + "exit_signal": { + "count": 149, + "profit_abs": 222.64 + }, + "trailing_stop_loss": { + "count": 45, + "profit_abs": 149.39 + }, + "stop_loss": { + "count": 7, + "profit_abs": -127.63 + } + } + }, + "oos": { + "strategy": "MomentumRotation1d", + "timerange": "20250601-20261010", + "start": "2025-06-01 00:00:00", + "end": "2026-10-09 00:00:00", + "starting_balance": 1000.0, + "final_balance": 1131.24, + "profit_pct": 13.12, + "market_change_pct": -32.82, + "max_drawdown_pct": 5.21, + "profit_factor": 1.59, + "winrate_pct": 42.9, + "trades": 63, + "calmar": 9.72, + "sharpe": 0.32, + "cagr_pct": 9.52, + "exit_reasons": { + "exit_signal": { + "count": 44, + "profit_abs": 202.89 + }, + "force_exit": { + "count": 2, + "profit_abs": -20.52 + }, + "trailing_stop_loss": { + "count": 17, + "profit_abs": -51.13 + } + } + }, + "stress": { + "strategy": "MomentumRotation1d", + "timerange": "20250601-20261010", + "start": "2025-06-01 00:00:00", + "end": "2026-10-09 00:00:00", + "starting_balance": 1000.0, + "final_balance": 1123.73, + "profit_pct": 12.37, + "market_change_pct": -32.82, + "max_drawdown_pct": 5.35, + "profit_factor": 1.54, + "winrate_pct": 42.9, + "trades": 63, + "calmar": 8.92, + "sharpe": 0.3, + "cagr_pct": 8.98, + "exit_reasons": { + "exit_signal": { + "count": 44, + "profit_abs": 197.48 + }, + "force_exit": { + "count": 2, + "profit_abs": -20.53 + }, + "trailing_stop_loss": { + "count": 17, + "profit_abs": -53.22 + } + } + }, + "walkforward": { + "windows": [ + { + "window": "2023-06-01 -> 2023-09-01", + "profit_pct": -1.77, + "trades": 25, + "market_change_pct": -21.36 + }, + { + "window": "2023-09-01 -> 2023-12-01", + "profit_pct": 5.56, + "trades": 23, + "market_change_pct": 57.28 + }, + { + "window": "2023-12-01 -> 2024-03-01", + "profit_pct": 13.85, + "trades": 30, + "market_change_pct": 59.77 + }, + { + "window": "2024-03-01 -> 2024-06-01", + "profit_pct": 3.75, + "trades": 24, + "market_change_pct": 9.47 + }, + { + "window": "2024-06-01 -> 2024-09-01", + "profit_pct": -8.01, + "trades": 25, + "market_change_pct": -40.81 + }, + { + "window": "2024-09-01 -> 2024-12-01", + "profit_pct": 0.75, + "trades": 29, + "market_change_pct": 114.33 + }, + { + "window": "2024-12-01 -> 2025-03-01", + "profit_pct": -10.7, + "trades": 26, + "market_change_pct": -43.91 + }, + { + "window": "2025-03-01 -> 2025-06-01", + "profit_pct": -3.86, + "trades": 24, + "market_change_pct": 5.5 + }, + { + "window": "2025-06-01 -> 2025-09-01", + "profit_pct": 9.29, + "trades": 24, + "market_change_pct": 15.51 + }, + { + "window": "2025-09-01 -> 2025-12-01", + "profit_pct": 2.03, + "trades": 15, + "market_change_pct": -42.47 + }, + { + "window": "2025-12-01 -> 2026-03-01", + "profit_pct": 0.0, + "trades": 0, + "market_change_pct": -33.6 + }, + { + "window": "2026-03-01 -> 2026-06-01", + "profit_pct": -0.69, + "trades": 3, + "market_change_pct": 7.9 + }, + { + "window": "2026-06-01 -> 2026-09-01", + "profit_pct": 4.91, + "trades": 4, + "market_change_pct": -0.46 + }, + { + "window": "2026-09-01 -> 2026-10-10", + "profit_pct": 10.19, + "trades": 13, + "market_change_pct": 19.59 + } + ], + "pct_profitable": 57.1, + "worst_pct": -10.7 + }, + "bias": { + "lookahead_clean": true, + "lookahead_note": "cross-sectional: 132 trades compared with later data removed from all pairs (94 in the full run); no differences", + "recursive_max_diff_pct": 0.392, + "recursive_note": "worst indicator deviation at the strategy's startup candle count or more" + }, + "gates": [ + { + "label": "OOS profit > 0%", + "value": 13.12, + "pass": true + }, + { + "label": "OOS profit factor >= 1.3", + "value": 1.59, + "pass": true + }, + { + "label": "OOS max drawdown <= 20%", + "value": 5.21, + "pass": true + }, + { + "label": "OOS Calmar >= 1.0", + "value": 9.72, + "pass": true + }, + { + "label": "OOS trades >= 100", + "value": 63, + "pass": false + }, + { + "label": "Profitable in >= 60% of walk-forward windows", + "value": 57.1, + "pass": false + }, + { + "label": "OOS still profitable with +50% fees", + "value": 12.37, + "pass": true + }, + { + "label": "No lookahead bias", + "value": true, + "pass": true + }, + { + "label": "Indicators stable vs startup candles (<= 1%)", + "value": 0.392, "pass": true } ], @@ -492,8 +731,8 @@ "bias": { "lookahead_clean": true, "lookahead_note": "20 signals checked; biased indicators: none", - "recursive_max_diff_pct": 0.338, - "recursive_note": "worst indicator deviation with fewer startup candles" + "recursive_max_diff_pct": 0.0, + "recursive_note": "worst indicator deviation at the strategy's startup candle count or more" }, "gates": [ { @@ -538,7 +777,7 @@ }, { "label": "Indicators stable vs startup candles (<= 1%)", - "value": 0.338, + "value": 0.0, "pass": true } ], @@ -723,8 +962,8 @@ "bias": { "lookahead_clean": true, "lookahead_note": "20 signals checked; biased indicators: none", - "recursive_max_diff_pct": 0.196, - "recursive_note": "worst indicator deviation with fewer startup candles" + "recursive_max_diff_pct": 0.0, + "recursive_note": "worst indicator deviation at the strategy's startup candle count or more" }, "gates": [ { @@ -769,11 +1008,12 @@ }, { "label": "Indicators stable vs startup candles (<= 1%)", - "value": 0.196, + "value": 0.0, "pass": true } ], "eligible_for_paper": false } - } + }, + "balance": 1000.0 } \ No newline at end of file diff --git a/user_data/strategies/MomentumRotation1d.py b/user_data/strategies/MomentumRotation1d.py new file mode 100644 index 0000000..b05c38c --- /dev/null +++ b/user_data/strategies/MomentumRotation1d.py @@ -0,0 +1,223 @@ +# MomentumRotation1d.py +# Cross-sectional momentum rotation for Freqtrade (long-only, spot) +# +# Idea: in crypto uptrends the strongest coins tend to keep leading for a +# while. TrendRider4h waits for each coin's own breakout and so catches only +# part of a bull run; this strategy instead ranks the whole whitelist every day +# and holds the leaders. +# +# Logic: +# Score: momentum over `mom_period` days divided by the volatility over the +# same window (a t-stat-like, risk-adjusted momentum), ranked across +# every pair in the whitelist on each day. +# Entry: the pair ranks in the top `top_n`, its momentum is positive, it is +# above its own daily EMA50, and BTC is above its daily EMA200 +# (crypto-wide risk-on regime). +# Exit: the pair drops below rank `exit_rank` (hysteresis, so it isn't sold +# the moment it slips one place), BTC turns risk-off, or the ATR +# chandelier trailing stop is hit. +# Sizing: risk-based, like TrendRider4h: a stop-out loses ~risk_per_trade of +# equity, under the shared portfolio risk limits. +# +# Daily candles and a handful of positions mean low turnover (small fee drag) +# and positions large enough for Binance's ~5 USDT minimum order even on a +# ~100 AUD account. +# +# Starting point for backtesting, not a profit guarantee. + +import sys +from datetime import datetime +from pathlib import Path +from typing import Optional + +import numpy as np +import pandas as pd +import talib.abstract as ta +from freqtrade.persistence import Trade +from freqtrade.strategy import informative, stoploss_from_absolute +from pandas import DataFrame + +_ROOT = str(Path(__file__).resolve().parents[2]) +if _ROOT not in sys.path: + sys.path.insert(0, _ROOT) + +from ridgeline.freqtrade_risk import RiskMixin # noqa: E402 + + +class MomentumRotation1d(RiskMixin): + INTERFACE_VERSION = 3 + + timeframe = "1d" + # BTC's daily EMA200 is the slowest indicator. EMAs carry a trace of their + # starting value: 210 days left it ~4% off; 360 settles it to ~0.1%. + startup_candle_count = 360 + process_only_new_candles = True + # Signals depend on other pairs (the ranking). research.py checks these + # strategies for lookahead with all pairs loaded; see research.lookahead(). + cross_sectional = True + + minimal_roi = {"0": 100} + # Hard floor; must be at least as wide as atr_stop_max. + stoploss = -0.25 + trailing_stop = False + use_custom_stoploss = True + + risk_per_trade = 0.015 + + # Ranking and holding rules. Plain attributes, not hyperopt parameters: + # the score of every pair feeds every other pair's rank, so they were + # chosen by hand on in-sample data (2023-06 -> 2025-06) only. In-sample + # returns were very sensitive to the lookback (7d -15%, 10d +25%, + # 14d +37%, 21d -4%, 30d -8%), so treat the 14-day choice as fragile. + mom_period = 14 + top_n = 3 + exit_rank = 6 + + atr_period = 14 + chandelier_mult = 3.0 + atr_stop_min = 0.05 + atr_stop_max = 0.25 + + @property + def protections(self): + return [ + {"method": "CooldownPeriod", "stop_duration_candles": 1}, + { + "method": "StoplossGuard", + "lookback_period_candles": 14, + "trade_limit": 2, + "stop_duration_candles": 7, + "only_per_pair": True, + }, + { + "method": "MaxDrawdown", + "lookback_period_candles": 14, + "trade_limit": 4, + "stop_duration_candles": 3, + "max_allowed_drawdown": 0.10, + }, + ] + + @informative("1d", "BTC/{stake}", fmt="btc_{column}_{timeframe}") + def populate_indicators_btc_1d(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + dataframe["ema200"] = ta.EMA(dataframe, timeperiod=200) + return dataframe + + def _score(self, dataframe: DataFrame) -> pd.Series: + n = self.mom_period + momentum = dataframe["close"] / dataframe["close"].shift(n) - 1 + volatility = dataframe["close"].pct_change().rolling(n).std() * np.sqrt(n) + return momentum / volatility.replace(0, np.nan) + + def _rank(self, dataframe: DataFrame, pair: str) -> pd.Series: + """This pair's rank (1 = strongest) among the whitelist on each day. + Only same-day scores are compared, and each score only uses closes up + to that day, so the ranking never looks ahead. Pairs that didn't trade + yet on a day have no score and aren't ranked.""" + scores = {pair: pd.Series(self._score(dataframe).values, index=dataframe["date"])} + for other in self.dp.current_whitelist(): + if other in scores: + continue + df = self.dp.get_pair_dataframe(pair=other, timeframe=self.timeframe) + if df is None or df.empty: + continue + scores[other] = pd.Series(self._score(df).values, index=df["date"]) + ranks = pd.DataFrame(scores).rank(axis=1, ascending=False, method="min")[pair] + return dataframe["date"].map(ranks) + + def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + dataframe["ema50"] = ta.EMA(dataframe, timeperiod=50) + dataframe["atr"] = ta.ATR(dataframe, timeperiod=self.atr_period) + dataframe["atr_pct"] = dataframe["atr"] / dataframe["close"] + dataframe["score"] = self._score(dataframe) + dataframe["rank"] = self._rank(dataframe, metadata["pair"]) + dataframe["btc_risk_on"] = dataframe["btc_close_1d"] > dataframe["btc_ema200_1d"] + return dataframe + + def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + dataframe.loc[ + ( + (dataframe["rank"] <= self.top_n) + & (dataframe["score"] > 0) + & (dataframe["close"] > dataframe["ema50"]) + & dataframe["btc_risk_on"] + & (dataframe["volume"] > 0) + ), + "enter_long", + ] = 1 + return dataframe + + def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + dataframe.loc[ + ( + ((dataframe["rank"] > self.exit_rank) | ~dataframe["btc_risk_on"]) + & (dataframe["volume"] > 0) + ), + "exit_long", + ] = 1 + return dataframe + + def _entry_stop_distance(self, pair: str) -> float: + """Initial stop distance (fraction of price) from the latest ATR.""" + try: + dataframe, _ = self.dp.get_analyzed_dataframe(pair=pair, timeframe=self.timeframe) + atr_pct = dataframe["atr_pct"].iloc[-1] + except Exception: + atr_pct = None + if atr_pct is None or atr_pct != atr_pct: + return abs(self.stoploss) + return max(self.atr_stop_min, min(self.atr_stop_max, atr_pct * self.chandelier_mult)) + + def custom_stake_amount( + self, + pair: str, + current_time: datetime, + current_rate: float, + proposed_stake: float, + min_stake: Optional[float], + max_stake: float, + leverage: float, + entry_tag: Optional[str], + side: str, + **kwargs, + ) -> float: + """Position size such that the initial stop loses `risk_per_trade` of equity.""" + try: + total_equity = self.wallets.get_total_stake_amount() + except Exception: + return proposed_stake + distance = self._entry_stop_distance(pair) + if distance <= 0 or total_equity <= 0: + return proposed_stake + stake = total_equity * self.risk_per_trade / distance + if min_stake is not None: + stake = max(stake, min_stake) + stake = min(stake, max_stake) + return self.apply_risk_caps(pair, stake, distance, current_time, min_stake) + + def custom_stoploss( + self, + pair: str, + trade: Trade, + current_time: datetime, + current_rate: float, + current_profit: float, + after_fill: bool, + **kwargs, + ) -> Optional[float]: + """Chandelier stop: max(initial stop, highest high since entry - k*ATR). + The initial distance is pinned at the first call so it matches the + distance custom_stake_amount() sized the position against.""" + pinned = trade.get_custom_data(key="entry_stop_distance") + if pinned is None: + pinned = self._entry_stop_distance(pair) + trade.set_custom_data(key="entry_stop_distance", value=pinned) + stop_price = trade.open_rate * (1 - pinned) + + dataframe, _ = self.dp.get_analyzed_dataframe(pair=pair, timeframe=self.timeframe) + since_entry = dataframe.loc[dataframe["date"] >= trade.open_date_utc] + if not since_entry.empty: + atr = since_entry["atr"].iloc[-1] + stop_price = max(stop_price, since_entry["high"].max() - self.chandelier_mult * atr) + + return stoploss_from_absolute(stop_price, current_rate)