From c57a752c7b77d943adbd747a5de4d4461b89441d Mon Sep 17 00:00:00 2001 From: devam <56464719+devam2001@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:07:57 +1100 Subject: [PATCH] Institutional operations: risk limits, kill switch, audit trail, reports, CI, Docker Risk & kill switch - ridgeline/risk.py: pure portfolio limits (35% max position, 4.5% max open risk, 3% daily loss, 15% drawdown halt), overridable via "ridgeline_risk" in config.json. ridgeline/freqtrade_risk.py: RiskMixin that all three strategies inherit - vetoes entries and caps stakes in backtests, paper and live alike. Strategy parameters unchanged. - Panel: risk gauges plus Pause / Resume / Flatten (typed FLATTEN confirm; stopentry -> forceexit all -> stop) for any reachable bot. - ridgeline/audit.py: append-only, hash-chained audit log of control actions, risk events and watchdog alerts; tamper detection shown in the panel. Reporting - ridgeline/report.py + scripts/report.py: self-contained HTML tear sheet (monthly grid, equity vs BTC, drawdown, Sharpe/Sortino/Calmar, trade log, disclosures) from paper DBs or backtests; panel links for sessions/replays. Deployment & monitoring - Dockerfile + docker-compose.yml: bot, monitor-mode panel, watchdog; ports on 127.0.0.1 only. scripts/watchdog.py alerts via Telegram (or console) on bot down/stalled/paused/halted and limits >= 80%. - scripts/init_private_config.py generates API secrets for new machines/CI. Tests & CI - 48 pytest tests: limit boundaries, audit tampering, lookahead (prefix invariance) per strategy, point-in-time universe, panel dry-run guard and flatten sequence, report maths, repo hygiene. ruff lint. GitHub Actions. Research re-run with the risk layer: TrendRider4h OOS +17.3% (was +19.3%), still 8/9 gates (99 trades); drawdown halt cut TrendDipBuyer1h's IS loss from -20.7% to -8.0%. Docs: docs/OPERATIONS.md runbook, README sections, note on licensing for managing other people's money. --- .dockerignore | 3 + .github/workflows/ci.yml | 38 +++ .gitignore | 5 + Dockerfile | 10 + README.md | 111 ++++++- RESEARCH.md | 50 ++- docker-compose.yml | 61 ++++ docs/OPERATIONS.md | 180 +++++++++++ panel/app.py | 97 +++++- panel/bot.py | 95 +++++- panel/static/app.js | 91 +++++- panel/static/index.html | 16 + panel/static/style.css | 10 + pyproject.toml | 24 ++ requirements-dev.txt | 2 + ridgeline/__init__.py | 6 + ridgeline/audit.py | 122 ++++++++ ridgeline/freqtrade_risk.py | 136 ++++++++ ridgeline/notify.py | 38 +++ ridgeline/report.py | 362 ++++++++++++++++++++++ ridgeline/risk.py | 158 ++++++++++ scripts/init_private_config.py | 46 +++ scripts/report.py | 56 ++++ scripts/research.py | 4 +- scripts/watchdog.py | 113 +++++++ tests/conftest.py | 60 ++++ tests/test_audit.py | 47 +++ tests/test_panel_api.py | 68 ++++ tests/test_repo_hygiene.py | 35 +++ tests/test_report.py | 44 +++ tests/test_risk.py | 113 +++++++ tests/test_strategies.py | 92 ++++++ tests/test_universe.py | 50 +++ user_data/research/latest.json | 360 ++++++++++----------- user_data/research/report-2026-10-11.json | 360 ++++++++++----------- user_data/strategies/EmaRsiMomentum.py | 42 +-- user_data/strategies/TrendDipBuyer1h.py | 19 +- user_data/strategies/TrendRider4h.py | 19 +- 38 files changed, 2706 insertions(+), 437 deletions(-) create mode 100644 .dockerignore create mode 100644 .github/workflows/ci.yml create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 docs/OPERATIONS.md create mode 100644 pyproject.toml create mode 100644 requirements-dev.txt create mode 100644 ridgeline/__init__.py create mode 100644 ridgeline/audit.py create mode 100644 ridgeline/freqtrade_risk.py create mode 100644 ridgeline/notify.py create mode 100644 ridgeline/report.py create mode 100644 ridgeline/risk.py create mode 100644 scripts/init_private_config.py create mode 100644 scripts/report.py create mode 100644 scripts/watchdog.py create mode 100644 tests/conftest.py create mode 100644 tests/test_audit.py create mode 100644 tests/test_panel_api.py create mode 100644 tests/test_repo_hygiene.py create mode 100644 tests/test_report.py create mode 100644 tests/test_risk.py create mode 100644 tests/test_strategies.py create mode 100644 tests/test_universe.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..42920cb --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +# The image only needs the Dockerfile; the repo is mounted at runtime. +* +!Dockerfile diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..544202d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,38 @@ +name: CI + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: requirements-dev.txt + + - name: Install + run: | + python -m pip install --upgrade pip + pip install "freqtrade==2026.8" "joblib<1.6" -r requirements-dev.txt + + - name: Lint + run: ruff check . + + - name: Throwaway private config + run: python scripts/init_private_config.py + + - name: Strategies load + run: freqtrade list-strategies --userdir user_data -c user_data/config.json + + - name: Tests + run: pytest diff --git a/.gitignore b/.gitignore index 4f09816..b4493fc 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,8 @@ user_data/plot/ *.docx ~$*.docx all_usdt.json +reports/ +user_data/audit/ +user_data/risk_state.json +.pytest_cache/ +.ruff_cache/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1ba6b81 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +# 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. +FROM freqtradeorg/freqtrade:2026.8 + +# joblib 1.6 dropped a module Freqtrade's hyperopt imports +RUN pip install --no-cache-dir "joblib<1.6" httpx psutil + +WORKDIR /ridgeline +ENTRYPOINT [] diff --git a/README.md b/README.md index 920953f..30ab7ff 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,15 @@ A Freqtrade bot for Binance **spot**, with: - three candidate strategies and an honest research pipeline to pick between them, - a **paper-money control panel**: live paper trading with a fake wallet you choose and can reset, plus fast historical replay, -- the official **FreqUI** dashboard for detailed live monitoring. +- the official **FreqUI** dashboard for detailed live monitoring, +- **operations tooling**: + - portfolio risk limits and a one-click kill switch + - a tamper-evident audit trail + - investor-style tear sheets + - Docker deployment with a watchdog and Telegram alerts + - tests and CI on every push + +[![CI](https://github.com/devam2001/ridgeline/actions/workflows/ci.yml/badge.svg)](https://github.com/devam2001/ridgeline/actions/workflows/ci.yml) Everything ships in **dry-run** mode. Nothing here touches real money; the panel cannot start a live bot at all. @@ -21,6 +29,11 @@ panel cannot start a live bot at all. | `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 | +| `scripts/report.py`, `scripts/watchdog.py` | tear-sheet generator; health/risk watchdog with Telegram alerts | +| `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 | | `RESEARCH.md` | latest research results and what they mean | ## 1. Install @@ -36,18 +49,10 @@ freqtrade list-strategies --userdir user_data # all three should show OK `joblib<1.6` is needed because joblib 1.6 removed a module Freqtrade's hyperopt still imports. -If `user_data/config.private.json` doesn't exist, create it: - -```json -{ - "exchange": {"key": "", "secret": ""}, - "api_server": { - "username": "freqtrader", - "password": "", - "jwt_secret_key": "<64 random hex chars>", - "ws_token": "" - } -} +Create the private config (API secrets, gitignored). This never overwrites an existing file: + +```bash +python scripts/init_private_config.py ``` ## 2. Control panel (paper money + replay) @@ -165,7 +170,85 @@ cooldown / StoplossGuard / MaxDrawdown protections.) real fills and slippage match the paper results. 4. Turn on `telegram` in the config for entry/exit notifications. -## Risk controls (EmaRsiMomentum; the other strategies follow the same pattern) +## 6. Operations + +### Risk limits and kill switch +Every strategy runs under portfolio-level limits, enforced in backtests too: +- **Max single position:** 35% of equity. +- **Max open risk:** 4.5% of equity, i.e. the total loss if every open stop hit at once. +- **Daily loss:** 3%. Hitting it pauses new entries until the next UTC day. +- **Drawdown halt:** 15%. Hitting it stops new entries until a human resumes. + +The panel's **Risk controls** card shows live usage of each limit, with three controls: +- **Pause entries** +- **Resume** +- **Flatten all**: the kill switch. It market-closes everything and stops the bot. + +### Audit trail +Every control action, risk event and watchdog alert is appended to a +hash-chained log in `user_data/audit/`. The panel shows whether the chain +still verifies. + +### Reports +Investor-style tear sheets come as one self-contained HTML file. Use +Print → Save as PDF in your browser to get a PDF. Each covers: +- returns: monthly and year-to-date +- equity vs BTC, and drawdown +- Sharpe, Sortino and Calmar +- trade statistics and the full trade log + +```bash +python scripts/report.py --db user_data/paper/.sqlite --month 2026-10 +python scripts/report.py --backtest user_data/replays/ +``` + +The panel has the same: **Performance tear sheet** on Paper trading, and **Open tear sheet** after a replay. + +### Deployment and monitoring +```bash +python scripts/init_private_config.py +docker compose up -d --build # bot + panel (monitor mode) + watchdog +``` + +The watchdog sends Telegram alerts when: +- the bot is down or stalled +- the bot is paused or halted +- any risk limit is at 80% of its threshold or more + +Setup, VPS hardening and the incident runbook are in +[docs/OPERATIONS.md](docs/OPERATIONS.md). + +### Tests +```bash +pip install -r requirements-dev.txt +ruff check . && pytest +``` + +The tests cover: +- risk-limit boundaries +- audit tamper detection +- a lookahead check: signals must not change when future candles are removed +- point-in-time universe selection +- the panel's dry-run guard and kill-switch sequence +- repo hygiene: no secrets, and no live config in git + +GitHub Actions runs them on every push. + +## Managing other people's money + +The code above is the operational plumbing a fund would expect. **It does not +make running other people's money legal.** In Australia, dealing in or +managing crypto-asset portfolios for others, copy trading, or pooling client +funds generally requires an Australian Financial Services Licence (AFSL). +Pooled funds are usually also a registered managed investment scheme (MIS), +with compliance, custody and audit obligations. A wholesale-only structure +reduces some obligations but doesn't remove the licence question. Other +countries have their own equivalents. + +Talk to a financial-services lawyer before taking anyone else's capital. +This section is a pointer, not legal advice. + +## Strategy risk settings (EmaRsiMomentum; the other strategies follow the same pattern) - **1.5% risk per trade, ATR-adaptive** — the stoploss distance is no longer a flat 5%; it's `min(8%, max(2%, ATR% × 2.5))`, computed per pair diff --git a/RESEARCH.md b/RESEARCH.md index 7e7411c..dff261a 100644 --- a/RESEARCH.md +++ b/RESEARCH.md @@ -6,6 +6,9 @@ **Setup** - Long-only Binance spot, starting with $1,000, at most 3 trades open at once. - 0.1% fee per trade, and protections switched on. +- **The portfolio risk limits are applied**, exactly as in paper and live + trading: 35% max position, 4.5% max open risk, 3% daily loss limit, 15% + drawdown halt. See `docs/OPERATIONS.md`. - **In-sample (IS):** 2023-06-01 → 2025-06-01. Used for design and hyperopt. - **Out-of-sample (OOS):** 2025-06-01 → 2026-10-10. Never used for tuning. - **Pairs are chosen point-in-time.** Each window trades the top 25 USDT pairs @@ -24,9 +27,24 @@ | Strategy | IS return | OOS return | OOS max DD | OOS profit factor | OOS trades | Walk-forward | Gates | |---|---|---|---|---|---|---|---| -| **TrendRider4h** | +10.8% | **+19.3%** | **9.8%** | **1.47** | 99 | 71% | **8 / 9** | -| EmaRsiMomentum | +13.6% | +8.0% | 16.5% | 1.18 | 77 | 43% | 5 / 9 | -| TrendDipBuyer1h | −20.7% | −17.7% | 19.5% | 0.75 | 309 | 36% | 3 / 9 | +| **TrendRider4h** | +5.6% | **+17.3%** | **9.8%** | **1.43** | 99 | 64% | **8 / 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 | + +### What the risk limits changed + +| | Without limits | With limits | +|---|---|---| +| TrendRider4h, OOS | +19.3% | **+17.3%** | +| TrendRider4h, IS | +10.8% | +5.6% | +| TrendRider4h, walk-forward | 71% | 64% | +| TrendDipBuyer1h, IS | −20.7% | **−8.0%** | + +The limits **cost return on the good strategy**. The 35% position cap blocks +the large positions that risk-based sizing wants in low-volatility coins +(BTC, BNB, TRX). The limits **cut losses on the bad strategy**: the drawdown +halt more than halved TrendDipBuyer1h's in-sample loss. That trade-off is +the point of a risk layer. **No strategy is eligible for real money yet.** @@ -42,14 +60,15 @@ the next monthly re-run will settle it either way. - Ranking by today's volume favours coins that pumped during the test. - QNT (+133% in OOS) alone added +$210 from 5 trades. - ZEC (+2,171%) pushed the equal-weight benchmark to +68%. -3. **Point-in-time top 25 (this run).** OOS +19.3%, IS +10.8%. +3. **Point-in-time top 25 (this run).** OOS +19.3%, IS +10.8% before the risk + limits; +17.3% and +5.6% with them. - Pairs are chosen only from information available at the time. The gap between the IS results is the most important finding: -- **+82.5% on the hand-picked pairs vs +10.8% point-in-time.** TrendRider4h - was hyperopted on the 15 hand-picked pairs, and a large part of that IS - result came from fitting those particular coins. -- **The OOS result barely moved (+21% → +19%).** It's the more trustworthy +- **+82.5% on the hand-picked pairs vs +10.8% point-in-time** (both without + risk limits). TrendRider4h was hyperopted on the 15 hand-picked pairs, + and a large part of that IS result came from fitting those particular coins. +- **The OOS result barely moved (+21% → +19% before risk limits).** It's the more trustworthy number, but expect live results below even that. ## TrendRider4h in detail @@ -61,15 +80,16 @@ The gap between the IS results is the most important finding: |---|---|---| | 2024-06 → 09 | −5.5% | −38% | | 2024-12 → 2025-03 | +0.6% | −41% | - | 2025-09 → 12 | +0.7% | −43% | + | 2025-09 → 12 | −0.9% | −43% | | 2025-12 → 2026-03 | 0.0%, no trades (BTC filter) | −31% | - | 2024-09 → 12 | +30.3% | +98% | + | 2024-09 → 12 | +30.7% | +98% | In bull runs it catches only part of the move, as the last row shows. -- **Walk-forward:** profitable in 10 of 14 windows (71%), or 4 of 6 OOS-only. - Zero-trade windows don't count as wins. The worst window was −7.0%. +- **Walk-forward (with risk limits):** profitable in 9 of 14 windows (64%), + or 3 of 6 OOS-only. Zero-trade windows don't count as wins. The worst + window was −7.0%. - **Robustness:** - - Still +17.3% OOS with fees raised by half. + - 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%). - 4h vs 1h intra-candle detail gives near-identical results (+35.7% vs @@ -77,8 +97,8 @@ The gap between the IS results is the most important finding: - **It trades rarely:** 5–7 trades a month. The stats are noisy at this sample size. ### The other two -- **EmaRsiMomentum** improves with point-in-time pairs (+8% OOS) but is - inconsistent: profitable in only 43% of windows, with a profit factor of 1.18. +- **EmaRsiMomentum** is positive OOS (+5.1%) but inconsistent: profitable + in only 43% of windows, with a profit factor of 1.13. - **TrendDipBuyer1h** loses money in every configuration tested. ## Known limitations diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..775e8be --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,61 @@ +# 24/7 deployment: trading bot + monitor-mode panel + watchdog. +# +# python scripts/init_private_config.py # once: API secrets (gitignored) +# docker compose up -d --build +# +# 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; +# reach them from your laptop with an SSH tunnel (see docs/OPERATIONS.md). + +x-ridgeline: &ridgeline + build: . + image: ridgeline:latest + restart: unless-stopped + volumes: + - .:/ridgeline + working_dir: /ridgeline + logging: + driver: json-file + options: { max-size: "10m", max-file: "5" } + +services: + bot: + <<: *ridgeline + command: + - freqtrade + - trade + - --userdir=user_data + - --config=user_data/config.json + - --strategy=${RIDGELINE_STRATEGY:-TrendRider4h} + - --db-url=sqlite:////ridgeline/user_data/tradesv3.docker.sqlite + - --logfile=user_data/logs/bot.log + environment: + # listen inside the container network; publishing below stays on localhost + FREQTRADE__API_SERVER__LISTEN_IP_ADDRESS: "0.0.0.0" + ports: + - "127.0.0.1:8080:8080" + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/api/v1/ping', timeout=5)"] + interval: 60s + timeout: 10s + retries: 3 + start_period: 180s + + panel: + <<: *ridgeline + command: ["python", "-m", "uvicorn", "panel.app:app", "--host", "0.0.0.0", "--port", "8090"] + environment: + PANEL_MODE: monitor # Docker owns the bot process; kill switch still works + RIDGELINE_BOT_URL: http://bot:8080 + ports: + - "127.0.0.1:8090:8090" + depends_on: + - bot + + watchdog: + <<: *ridgeline + command: ["python", "scripts/watchdog.py"] + environment: + RIDGELINE_BOT_URL: http://bot:8080 + depends_on: + - bot diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md new file mode 100644 index 0000000..9301d58 --- /dev/null +++ b/docs/OPERATIONS.md @@ -0,0 +1,180 @@ +# Operations runbook + +How to run ridgeline 24/7, watch it, and act when something goes wrong. + +## Contents +- [Risk limits](#risk-limits) +- [Kill switch: pause vs flatten](#kill-switch-pause-vs-flatten) +- [Audit trail](#audit-trail) +- [Alerts (Telegram)](#alerts-telegram) +- [Deploying on a VPS (Docker)](#deploying-on-a-vps-docker) +- [Incident checklist](#incident-checklist) +- [Monthly routine](#monthly-routine) +- [Reports](#reports) + +--- + +## Risk limits + +Every strategy inherits `RiskMixin` (`ridgeline/freqtrade_risk.py`), which +enforces these limits on top of each strategy's own stops and sizing: + +| Limit | Default | When it's hit | +|---|---|---| +| Max single position | 35% of equity | The stake is reduced to the cap | +| Max open risk | 4.5% of equity | New stakes shrink to fit; at the limit, no new entries. This is the loss if every open trade hit its stop now. | +| Daily loss | 3% (from UTC midnight) | No new entries until the next UTC day | +| Drawdown halt | 15% from peak | New entries **halted until you press Resume**. Drawdown is then measured from equity at resumption. | + +To override the defaults, add this to `user_data/config.json`: + +```json +"ridgeline_risk": { "daily_loss_limit_pct": 2, "max_drawdown_halt_pct": 10 } +``` + +Blocked entries, capped stakes and halts are logged, written to the audit +trail and sent to Telegram (live and paper only). Backtests apply the same +limits, so research results already include them. A backtest models the +halt-then-review step as an automatic resume after `halt_review_days` +(7 by default). + +## Kill switch: pause vs flatten + +The controls are in the panel's **Paper trading → Risk controls** card. They +work for any bot the panel can log in to, including the Docker `bot` service. + +| Button | What happens | Use when | +|---|---|---| +| **Pause entries** | The bot stops opening trades. Open trades keep their stops and exits. | Something looks odd and you want time to investigate: a news event, an exchange incident, unexplained behaviour. | +| **Resume** | Trading restarts and a drawdown halt is cleared. | You've reviewed why it paused or halted. | +| **Flatten all** | Stop entries, then market-sell **every** open position, then stop the bot. Needs `FLATTEN` typed. | Something is clearly wrong and you want zero exposure *now*: a bug, a compromised key, a market crash you won't sit through. | + +Flatten sells at market, so in a fast or thin market fills can be poor. +Always check the result in FreqUI or on the exchange. If a position couldn't +be closed, the panel says so, and you must close it manually. + +## Audit trail + +`user_data/audit/audit-YYYY-MM.jsonl` is an append-only log of every control +action and risk event: +- session start, stop and reset +- pause, resume and flatten +- blocked entries, capped stakes and halts +- watchdog alerts + +Each record contains the hash of the record before it, so any edit, deletion +or reordering is detectable. The panel shows "chain verified" when the chain +is intact. To check it from the command line: + +```bash +python -c "from ridgeline import audit; print(audit.verify())" +``` + +`(True, None)` means intact. Otherwise you get the file and line where the +chain breaks. Keep these files: back them up with the trade databases in +`user_data/paper/` and `user_data/tradesv3*.sqlite`. + +## Alerts (Telegram) + +1. In Telegram, message **@BotFather**, send `/newbot`, and copy the token. +2. Send any message to your new bot. Then open + `https://api.telegram.org/bot/getUpdates` and copy `chat.id`. +3. Put both in `user_data/config.private.json`: + ```json + "telegram": { "enabled": true, "token": "", "chat_id": "" } + ``` +4. Run `python scripts/watchdog.py --test-alert`. You should get a message. + +Freqtrade then sends every entry and exit natively. The **watchdog** adds +out-of-band alerts for: +- the bot unreachable +- the bot stalled (no cycle for 10 minutes) +- the bot not running +- a halt +- any risk limit at 80% or more of its threshold + +Each alert fires once, then again as "resolved" when it clears. + +## Deploying on a VPS (Docker) + +Any small Linux VPS (1 vCPU, 2 GB RAM) is enough. + +```bash +# on the server (Ubuntu) +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 ps # bot should become "healthy" within ~3 min +``` + +Data and the strategy's pair list (see the README's Data section) can be +downloaded inside the container: + +```bash +docker compose run --rm bot freqtrade download-data --userdir user_data --timeframes 1h 4h 1d --timerange 20220601- +``` + +**Access.** All ports are published on `127.0.0.1` only, so nothing is +exposed to the internet. From your own computer, open a tunnel: + +```bash +ssh -N -L 8090:127.0.0.1:8090 -L 8080:127.0.0.1:8080 you@your-server +``` + +Then the panel is at http://127.0.0.1:8090 and FreqUI at http://127.0.0.1:8080. + +**Firewall.** Allow SSH only (`sudo ufw allow OpenSSH && sudo ufw enable`). + +**Services** (`docker-compose.yml`): +- `bot`: trades `TrendRider4h`. Override with `RIDGELINE_STRATEGY=... docker compose up -d`. It runs in whatever mode `config.json` says: **dry-run unless you change it**. +- `panel`: runs in monitor mode. Status, risk controls, reports and replay all work. Start, Stop and Reset are disabled because Docker owns the process. +- `watchdog`: the alerts described above. + +**Updating:** +```bash +git pull && docker compose up -d --build +``` + +## Incident checklist + +1. **Contain.** + - Pause entries if you're unsure. + - Flatten if you're sure exposure itself is the problem. +2. **Look.** + - The panel's audit log and risk meters + - `docker compose logs --tail 200 bot`, or `user_data/logs/` + - FreqUI's trade list + - Your exchange account directly +3. **Exchange-side problems** (suspected key leak, unexpected orders): revoke + the API key on Binance first, then investigate. The key should already be + spot-only with withdrawals disabled. +4. **Record what happened and what you changed.** Control actions are audited + automatically; add context in your own notes. +5. **Resume** only once the cause is understood. After a drawdown halt, write + down why you're resuming. + +## Monthly routine + +```bash +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 +``` + +If you change the pair list or the strategy, restart the bot: `docker compose +restart bot`, or Reset in the panel. + +## Reports + +Two ways to get a tear sheet: +- **From the panel:** + - Paper trading → **Performance tear sheet** + - Replay → **Open tear sheet** after a replay +- **From the command line:** + - `scripts/report.py --db ...` for paper or live sessions + - `scripts/report.py --backtest ...` for backtest results + +Each report is a single HTML file with no external assets. Print → Save as PDF +gives a PDF. Returns are on a realised basis, and the report says so. diff --git a/panel/app.py b/panel/app.py index fd54b7a..89d202f 100644 --- a/panel/app.py +++ b/panel/app.py @@ -1,17 +1,20 @@ # Control panel: paper trading (dummy money), fast historical replay, and # research results. Run with start-panel.ps1, then open http://127.0.0.1:8090 +import re from pathlib import Path from typing import Optional from fastapi import FastAPI, HTTPException -from fastapi.responses import FileResponse +from fastapi.responses import FileResponse, HTMLResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel, Field -from panel.bot import PaperBot, PaperBotError +from panel.bot import MONITOR_MODE, PaperBot, PaperBotError from panel.common import USERDIR, list_strategies, load_json -from panel.replay import ReplayJobs +from panel.replay import REPLAY_DIR, ReplayJobs +from ridgeline import audit +from ridgeline.report import build_tearsheet, load_trades_backtest, load_trades_db, session_balance STATIC = Path(__file__).parent / "static" @@ -32,6 +35,10 @@ class PaperReset(BaseModel): strategy: Optional[str] = None +class FlattenRequest(BaseModel): + confirm: str + + class ReplayRequest(BaseModel): strategy: str balance: float = Field(gt=0, le=10_000_000) @@ -91,6 +98,90 @@ def paper_reset(req: PaperReset): raise HTTPException(409, str(e)) +@app.get("/api/mode") +def mode(): + return {"monitor": MONITOR_MODE} + + +@app.get("/api/risk") +def risk_status(): + try: + return bot.risk_status() + except PaperBotError as e: + raise HTTPException(503, str(e)) + + +@app.post("/api/bot/pause") +def bot_pause(): + try: + return bot.pause() + except PaperBotError as e: + raise HTTPException(409, str(e)) + + +@app.post("/api/bot/resume") +def bot_resume(): + try: + return bot.resume() + except PaperBotError as e: + raise HTTPException(409, str(e)) + + +@app.post("/api/bot/flatten") +def bot_flatten(req: FlattenRequest): + if req.confirm != "FLATTEN": + raise HTTPException(400, 'Type FLATTEN to confirm closing every position.') + try: + return bot.flatten() + except PaperBotError as e: + raise HTTPException(409, str(e)) + + +def _valid_id(value: str) -> str: + if not re.fullmatch(r"[\w-]+", value): + raise HTTPException(400, "Invalid id") + return value + + +def _valid_month(month: Optional[str]) -> Optional[str]: + if month and not re.fullmatch(r"\d{4}-\d{2}", month): + raise HTTPException(400, "month must be YYYY-MM") + 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" + if not db.exists(): + raise HTTPException(404, "No such paper session") + try: + trades = load_trades_db(db) + return build_tearsheet(trades, session_balance(db), title="Ridgeline - paper trading report", + source_label=f"session {session}", month=_valid_month(month), mode="paper") + except ValueError as e: + raise HTTPException(409, str(e)) + + +@app.get("/api/replay/{job_id}/report", response_class=HTMLResponse) +def replay_report(job_id: str): + directory = REPLAY_DIR / _valid_id(job_id) + if not (directory / "job.json").exists(): + raise HTTPException(404, "No such replay") + try: + trades, balance = load_trades_backtest(directory) + job = replays.get(job_id) or {} + return build_tearsheet(trades, balance, title=f"Ridgeline - {job.get('strategy', 'replay')} replay", + source_label=f"replay {job_id}", mode="backtest") + except (ValueError, KeyError, StopIteration) as e: + raise HTTPException(409, f"No report for this replay: {e}") + + +@app.get("/api/audit") +def audit_log(limit: int = 100): + ok, problem = audit.verify() + return {"chain_ok": ok, "problem": problem, "events": audit.tail(min(max(limit, 1), 1000))} + + @app.post("/api/replay") def replay_start(req: ReplayRequest): _check_strategy(req.strategy) diff --git a/panel/bot.py b/panel/bot.py index 14a0104..dbcf5e3 100644 --- a/panel/bot.py +++ b/panel/bot.py @@ -6,6 +6,14 @@ # Safety: every bot started here gets a session config, loaded LAST so it # wins, with dry_run forced on and exchange keys blanked, and the resolved # config is checked before launch. This module cannot start a live bot. +# +# Risk controls (pause / resume / flatten) talk to whichever bot answers on +# the API port - panel-managed, started by hand, or the Docker `bot` service - +# and every control action is written to the audit log. +# +# PANEL_MODE=monitor (used in Docker) disables process start/stop/reset: the +# container runtime owns the bot process there. RIDGELINE_BOT_URL overrides +# the bot's API address. import json import os @@ -20,6 +28,10 @@ import psutil from panel.common import FREQTRADE, ROOT, USERDIR, config_args, load_json, merged_config +from ridgeline import audit +from ridgeline.risk import RiskLimits, build_state, limits_summary, load_halt, set_halt + +MONITOR_MODE = os.environ.get("PANEL_MODE", "").lower() == "monitor" PAPER_DIR = USERDIR / "paper" STATE_FILE = PAPER_DIR / "state.json" @@ -33,11 +45,14 @@ def _api_settings() -> tuple[str, tuple[str, str]]: api = merged_config().get("api_server", {}) host = api.get("listen_ip_address", "127.0.0.1") port = api.get("listen_port", 8080) - return f"http://{host}:{port}", (api.get("username", ""), api.get("password", "")) + if host in ("0.0.0.0", "::"): + host = "127.0.0.1" + url = os.environ.get("RIDGELINE_BOT_URL") or f"http://{host}:{port}" + return url.rstrip("/"), (api.get("username", ""), api.get("password", "")) def _port_open(url: str) -> bool: - host, port = url.split("//")[1].split(":") + host, port = url.split("//")[1].split("/")[0].split(":") with socket.socket() as s: s.settimeout(0.5) return s.connect_ex((host, int(port))) == 0 @@ -72,6 +87,8 @@ def _our_process(self) -> Optional[psutil.Process]: # --- actions --- def start(self, balance: float, strategy: str) -> dict: + if MONITOR_MODE: + raise PaperBotError("Process control is disabled in monitor mode (Docker manages the bot).") if balance <= 0: raise PaperBotError("Starting balance must be positive.") if self._our_process(): @@ -116,6 +133,8 @@ def start(self, balance: float, strategy: str) -> dict: "started": datetime.now(timezone.utc).isoformat(timespec="seconds"), } self._save_state(state) + audit.append("paper_session_started", "panel", + {"session_id": session_id, "strategy": strategy, "balance": balance}) return state def _verify_dry_run(self, args: list[str]) -> None: @@ -126,6 +145,8 @@ def _verify_dry_run(self, args: list[str]) -> None: + out.stderr[-1500:]) def stop(self) -> dict: + if MONITOR_MODE: + raise PaperBotError("Process control is disabled in monitor mode (Docker manages the bot).") proc = self._our_process() if not proc: raise PaperBotError("No panel-managed paper session is running.") @@ -153,6 +174,7 @@ def stop(self) -> dict: state = self._state() state.update(pid=None, stopped=datetime.now(timezone.utc).isoformat(timespec="seconds")) self._save_state(state) + audit.append("paper_session_stopped", "panel", {"session_id": state.get("session_id")}) return state def reset(self, balance: float, strategy: Optional[str] = None) -> dict: @@ -168,6 +190,75 @@ def reset(self, balance: float, strategy: Optional[str] = None) -> dict: time.sleep(0.5) return self.start(balance, strategy) + # --- risk controls (any bot reachable on the API port) --- + + def _call(self, method: str, path: str, **kw): + try: + r = httpx.request(method, f"{self.base_url}/api/v1/{path}", auth=self.auth, timeout=20, **kw) + r.raise_for_status() + return r.json() + except httpx.HTTPStatusError as e: + raise PaperBotError(f"Bot API refused {path}: HTTP {e.response.status_code}") from e + except httpx.HTTPError as e: + raise PaperBotError(f"Bot API not reachable ({type(e).__name__})") from e + + def pause(self) -> dict: + """Stop opening new trades; open trades keep their stops and exits.""" + result = self._call("POST", "stopentry") + audit.append("entries_paused", "panel", {"bot_reply": result}) + return result + + def resume(self) -> dict: + """Resume trading and clear a drawdown halt (after human review).""" + was = load_halt() + result = self._call("POST", "start") + set_halt(False, reason="resumed from panel") + audit.append("trading_resumed", "panel", {"bot_reply": result, "cleared_halt": bool(was.get("halted"))}) + return result + + def flatten(self, wait_s: float = 60) -> dict: + """Kill switch: stop entries, market-close every position, stop the bot.""" + steps = {"stopentry": self._call("POST", "stopentry")} + open_before = len(self._call("GET", "status")) + steps["forceexit"] = self._call("POST", "forceexit", json={"tradeid": "all"}) + deadline = time.monotonic() + wait_s + remaining = open_before + while time.monotonic() < deadline: + remaining = len(self._call("GET", "status")) + if remaining == 0: + break + time.sleep(1) + steps["stop"] = self._call("POST", "stop") + result = {"closed": open_before - remaining, "still_open": remaining, "steps": steps} + audit.append("flatten", "panel", result) + if remaining: + raise PaperBotError(f"Flatten: {remaining} position(s) still open after {wait_s:.0f}s - " + "check the exchange/FreqUI and close them manually.") + return result + + def risk_status(self) -> dict: + """Same portfolio numbers the strategy enforces, rebuilt from the API.""" + cfg = self._call("GET", "show_config") + # Ask the bot itself (a bot started elsewhere may not match local + # state.json). starting_capital is what the strategy's RiskMixin uses + # too (Wallets.get_starting_balance, net of tradable_balance_ratio). + start = float(self._call("GET", "balance").get("starting_capital") or 0) + closed = [ + (datetime.fromtimestamp(t["close_timestamp"] / 1000, timezone.utc), t.get("profit_abs") or 0.0) + for t in self._call("GET", "trades?limit=500").get("trades", []) + if not t.get("is_open") and t.get("close_timestamp") + ] + open_positions = [ + (t["open_rate"], t.get("stop_loss_abs") or 0.0, t["amount"], t.get("profit_abs") or 0.0) + for t in self._call("GET", "status") + ] + halt = load_halt() + reset = datetime.fromisoformat(halt["resumed_at"]) if halt.get("resumed_at") else None + state = build_state(start, closed, open_positions, datetime.now(timezone.utc), reset) + limits = RiskLimits.from_config(merged_config()) + return {**limits_summary(state, limits), "equity": round(state.equity, 2), + "halt": halt, "bot_state": cfg.get("state")} + # --- status --- def _api(self, client: httpx.Client, path: str): diff --git a/panel/static/app.js b/panel/static/app.js index 704cd94..c2a0330 100644 --- a/panel/static/app.js +++ b/panel/static/app.js @@ -108,7 +108,10 @@ async function refreshPaper() { } else if (session) { rows.push(["Strategy", esc(session.strategy)], ["Starting balance", fmtMoney(session.balance)]); } - if (session) rows.push([st.managed ? "Session" : "Last session", `${esc(session.session_id)}`]); + if (session) { + rows.push([st.managed ? "Session" : "Last session", `${esc(session.session_id)}`]); + rows.push(["Report", `Performance tear sheet ↗`]); + } $("#paper-session").innerHTML = rows.map(([k, v]) => `
${k}
${v}
`).join(""); if (st.external && !st.api_up) { @@ -121,9 +124,12 @@ async function refreshPaper() { showPaperAlert(""); } - $("#paper-start").disabled = paperBusy || st.managed || st.external; - $("#paper-stop").disabled = paperBusy || !st.managed; - $("#paper-reset").disabled = paperBusy || st.external; + $("#paper-start").disabled = paperBusy || st.managed || st.external || monitorMode; + $("#paper-stop").disabled = paperBusy || !st.managed || monitorMode; + $("#paper-reset").disabled = paperBusy || st.external || monitorMode; + ["#risk-pause", "#risk-resume", "#risk-flatten"].forEach((id) => ($(id).disabled = paperBusy || !st.api_up)); + if (st.api_up) refreshRisk(); else $("#risk-meters").innerHTML = `

Available while a bot is running.

`; + refreshAudit(); // KPIs const tiles = $("#paper-tiles"); @@ -205,6 +211,8 @@ function renderReplay(job) { const r = job.result; const m = r.metrics; $("#replay-result").hidden = false; + $("#replay-status").innerHTML = `${esc(job.strategy)} · ${esc(job.timerange)} · ` + + `Open tear sheet ↗`; $("#replay-tiles").innerHTML = [ tile("Final balance (USDT)", fmtMoney(m.final_balance, ""), signClass(m.final_balance - m.starting_balance), `from ${fmtMoney(m.starting_balance, "")}`), tile("Strategy return", fmtPct(m.profit_pct), signClass(m.profit_pct), `CAGR ${fmtPct(m.cagr_pct)}`), @@ -378,8 +386,83 @@ async function loadResearch() { body.innerHTML = html; } +// ===================================================================== +// Risk controls & audit log +// ===================================================================== +let monitorMode = false; + +function meter(label, value, limit) { + const frac = limit > 0 ? Math.min(1, value / limit) : 0; + const cls = frac >= 1 ? "bad" : frac >= 0.8 ? "warn" : ""; + return `
${esc(label)}${value.toFixed(2)}% / ${limit}%
+
`; +} + +async function refreshRisk() { + let r; + try { + r = await api("/api/risk"); + } catch (e) { + $("#risk-meters").innerHTML = `

${esc(e.message)}

`; + return; + } + const u = r.usage, l = r.limits; + $("#risk-meters").innerHTML = [ + meter("Drawdown from peak (halt)", u.drawdown_pct, l.max_drawdown_halt_pct), + meter("Today's loss (UTC)", u.day_loss_pct, l.daily_loss_limit_pct), + meter("Open risk if all stops hit", u.open_risk_pct, l.max_open_risk_pct), + ].join("") + `

Max single position: ${l.max_position_pct}% of equity. Bot state: ${esc(r.bot_state)}

`; + const halt = $("#risk-halt"); + halt.hidden = !r.halt?.halted; + if (r.halt?.halted) { + halt.className = "pill ext"; + halt.textContent = "HALTED - review, then Resume"; + halt.title = r.halt.reason || ""; + } +} + +async function refreshAudit() { + const a = await api("/api/audit?limit=30").catch(() => null); + if (!a) return; + const chain = $("#audit-chain"); + chain.className = `pill ${a.chain_ok ? "on" : "ext"}`; + chain.textContent = a.chain_ok ? "chain verified" : "CHAIN BROKEN"; + chain.title = a.problem || "Every record's hash matches its contents and the record before it."; + table($("#audit-table"), [ + { label: "Time (UTC)", render: (e) => esc(e.ts.replace("T", " ").slice(0, 19)) }, + { label: "Actor", key: "actor" }, + { label: "Event", key: "event" }, + { label: "Details", render: (e) => `${esc(e.details.message || JSON.stringify(e.details)).slice(0, 140)}` }, + ], a.events, { empty: "No events yet." }); +} + +async function riskAction(path, body, okMsg) { + const note = $("#risk-note"); + note.textContent = "Working…"; + try { + await api(path, { method: "POST", body }); + note.textContent = okMsg; + } catch (e) { + note.innerHTML = `${esc(e.message)}`; + } + refreshPaper(); +} + +$("#risk-pause").addEventListener("click", () => riskAction("/api/bot/pause", undefined, "New entries paused. Open trades are still managed.")); +$("#risk-resume").addEventListener("click", () => riskAction("/api/bot/resume", undefined, "Trading resumed.")); +$("#risk-flatten").addEventListener("click", () => { + const typed = prompt("KILL SWITCH\n\nThis stops new entries, market-closes EVERY open position, and stops the bot.\n\nType FLATTEN to confirm:"); + if (typed === null) return; + riskAction("/api/bot/flatten", { confirm: typed.trim() }, "All positions closed and the bot stopped."); +}); + // ---------- boot ---------- (async function init() { + monitorMode = (await api("/api/mode").catch(() => ({}))).monitor === true; + 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(() => {}); let tab = "paper"; try { tab = localStorage.getItem("tab") || "paper"; } catch {} diff --git a/panel/static/index.html b/panel/static/index.html index d05d50f..243d908 100644 --- a/panel/static/index.html +++ b/panel/static/index.html @@ -59,6 +59,17 @@

Fake wallet

+
+

Risk controls

+

Available while a bot is running.

+
+ + + + +
+
+
@@ -69,6 +80,11 @@

Open trades

Closed trades (latest 50)

+
+

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 829692e..869fcce 100644 --- a/panel/static/style.css +++ b/panel/static/style.css @@ -113,6 +113,16 @@ input:focus, select:focus { outline: 2px solid var(--accent); outline-offset: -1 .btn-primary { background: var(--accent); border-color: var(--accent); color: var(--accent-text); } .btn-primary:hover { background: var(--accent); filter: brightness(1.08); } .btn-warn { color: var(--warn); } +.btn-danger { color: var(--neg); border-color: color-mix(in srgb, var(--neg) 45%, var(--border)); } +.btn-danger:hover { background: var(--err-bg); } + +.meters { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-bottom: 14px; } +.meter .row { display: flex; justify-content: space-between; font-size: 12px; color: var(--muted); } +.meter .row b { color: var(--text); font-weight: 600; font-variant-numeric: tabular-nums; } +.meter .track { height: 6px; border-radius: 3px; background: var(--surface-2); margin-top: 5px; overflow: hidden; } +.meter .fill { height: 100%; border-radius: 3px; background: var(--pos); } +.meter .fill.warn { background: var(--warn); } +.meter .fill.bad { background: var(--neg); } .btn-ghost { background: transparent; } .pill { display: inline-block; font-size: 12px; font-weight: 600; padding: 1px 10px; border-radius: 999px; background: var(--surface-2); } diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..d5029bb --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,24 @@ +[project] +name = "ridgeline" +version = "0.1.0" +description = "Freqtrade crypto bot with paper trading, point-in-time research gates, risk controls and reporting" +requires-python = ">=3.11" + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-q" + +[tool.ruff] +line-length = 130 +target-version = "py311" +extend-exclude = [".venv", "user_data/hyperopts", "user_data/notebooks"] + +[tool.ruff.lint] +select = ["E", "F", "W", "I"] +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 + +[tool.ruff.lint.isort] +known-first-party = ["ridgeline", "panel", "research", "conftest"] diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..9d7103c --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,2 @@ +pytest>=8 +ruff>=0.6 diff --git a/ridgeline/__init__.py b/ridgeline/__init__.py new file mode 100644 index 0000000..8c9f4ec --- /dev/null +++ b/ridgeline/__init__.py @@ -0,0 +1,6 @@ +"""Shared operational code for ridgeline: risk limits, audit trail, reporting, alerts.""" + +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +USERDIR = ROOT / "user_data" diff --git a/ridgeline/audit.py b/ridgeline/audit.py new file mode 100644 index 0000000..734d8c8 --- /dev/null +++ b/ridgeline/audit.py @@ -0,0 +1,122 @@ +"""Append-only, hash-chained audit log. + +Every record stores the hash of the record before it, so editing, deleting or +reordering any line breaks the chain and `verify()` reports where. The chain +continues across the monthly files (audit-YYYY-MM.jsonl). + +Several processes write here (panel, bot strategy, watchdog), so appends are +serialised with a lock file. +""" + +import hashlib +import json +import os +import time +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Optional + +from ridgeline import USERDIR + +AUDIT_DIR = USERDIR / "audit" +GENESIS = "0" * 64 +_LOCK_STALE_S = 10 + + +def _files(audit_dir: Path) -> list[Path]: + return sorted(audit_dir.glob("audit-*.jsonl")) + + +def _hash(prev_hash: str, record: dict) -> str: + canonical = json.dumps(record, sort_keys=True, separators=(",", ":"), default=str) + return hashlib.sha256((prev_hash + canonical).encode()).hexdigest() + + +def _last_hash(audit_dir: Path) -> str: + for path in reversed(_files(audit_dir)): + lines = [line for line in path.read_text(encoding="utf-8").splitlines() if line.strip()] + if lines: + return json.loads(lines[-1])["hash"] + return GENESIS + + +class _Lock: + def __init__(self, audit_dir: Path): + self.path = audit_dir / ".lock" + + def __enter__(self): + deadline = time.monotonic() + 5 + while True: + try: + fd = os.open(self.path, os.O_CREAT | os.O_EXCL | os.O_WRONLY) + os.close(fd) + return self + except FileExistsError: + try: # a crashed writer can leave the lock behind + if time.time() - self.path.stat().st_mtime > _LOCK_STALE_S: + self.path.unlink(missing_ok=True) + continue + except FileNotFoundError: + continue + if time.monotonic() > deadline: + raise TimeoutError(f"audit log lock busy: {self.path}") + time.sleep(0.02) + + def __exit__(self, *exc): + self.path.unlink(missing_ok=True) + + +def append(event: str, actor: str, details: Optional[dict[str, Any]] = None, + audit_dir: Optional[Path] = None) -> dict: + """Append one event and return the stored record (including its hash).""" + audit_dir = Path(audit_dir or AUDIT_DIR) + audit_dir.mkdir(parents=True, exist_ok=True) + now = datetime.now(timezone.utc) + with _Lock(audit_dir): + prev = _last_hash(audit_dir) + record = { + "ts": now.isoformat(timespec="milliseconds"), + "actor": actor, + "event": event, + "details": details or {}, + "prev_hash": prev, + } + record["hash"] = _hash(prev, record) + with open(audit_dir / f"audit-{now:%Y-%m}.jsonl", "a", encoding="utf-8") as f: + f.write(json.dumps(record, sort_keys=True, default=str) + "\n") + return record + + +def verify(audit_dir: Optional[Path] = None) -> tuple[bool, Optional[str]]: + """Check the whole chain. Returns (ok, description of the first problem).""" + audit_dir = Path(audit_dir or AUDIT_DIR) + prev = GENESIS + for path in _files(audit_dir): + for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): + if not line.strip(): + continue + where = f"{path.name}:{lineno}" + try: + record = json.loads(line) + stored = record.pop("hash") + except (ValueError, KeyError): + return False, f"{where}: unreadable record" + if record.get("prev_hash") != prev: + return False, f"{where}: chain broken (record removed or reordered before this line)" + if _hash(prev, record) != stored: + return False, f"{where}: contents changed after it was written" + prev = stored + return True, None + + +def tail(n: int = 100, audit_dir: Optional[Path] = None) -> list[dict]: + """The most recent `n` records, newest first.""" + audit_dir = Path(audit_dir or AUDIT_DIR) + out: list[dict] = [] + for path in reversed(_files(audit_dir)): + lines = [line for line in path.read_text(encoding="utf-8").splitlines() if line.strip()] + for line in reversed(lines): + out.append(json.loads(line)) + if len(out) >= n: + return out + return out diff --git a/ridgeline/freqtrade_risk.py b/ridgeline/freqtrade_risk.py new file mode 100644 index 0000000..57b3cf2 --- /dev/null +++ b/ridgeline/freqtrade_risk.py @@ -0,0 +1,136 @@ +"""Freqtrade glue for ridgeline.risk: a mixin every ridgeline strategy inherits. + + class TrendRider4h(RiskMixin): + ... + def custom_stake_amount(...): + stake = ... # strategy's own risk-based sizing + return self.apply_risk_caps(pair, stake, stop_distance, current_time, min_stake) + +Entries are vetoed in confirm_trade_entry() when a portfolio limit is hit. +In live / dry-run, every risk event goes to the audit log and to Telegram; +backtests and hyperopt only log, so research runs leave no audit noise. +""" + +import logging +from datetime import datetime, timedelta +from typing import Optional + +from freqtrade.enums import RunMode +from freqtrade.persistence import Trade +from freqtrade.strategy import IStrategy + +from ridgeline import audit +from ridgeline.risk import ( + PortfolioState, + RiskLimits, + build_state, + cap_stake, + check_entry, + is_drawdown_breach, + load_halt, + set_halt, +) + +logger = logging.getLogger(__name__) + +_REPORT_REPEAT = timedelta(hours=1) # don't repeat the same alert more often than this + + +class RiskMixin(IStrategy): + + _risk_limits: Optional[RiskLimits] = None + _bt_halt_until: Optional[datetime] = None + _bt_peak_reset: Optional[datetime] = None + + # --- helpers --- + + @property + def risk_limits(self) -> RiskLimits: + if self._risk_limits is None: + self._risk_limits = RiskLimits.from_config(self.config) + return self._risk_limits + + def _is_live(self) -> bool: + dp = getattr(self, "dp", None) # only attached once Freqtrade wires the strategy up + return dp is not None and dp.runmode in (RunMode.LIVE, RunMode.DRY_RUN) + + def _last_close(self, pair: str) -> Optional[float]: + try: + df, _ = self.dp.get_analyzed_dataframe(pair=pair, timeframe=self.timeframe) + return float(df["close"].iloc[-1]) if len(df) else None + except Exception: + return None + + def _peak_reset_time(self) -> Optional[datetime]: + """After a reviewed halt, drawdown is measured from equity at resumption.""" + if self._is_live(): + ts = load_halt().get("resumed_at") + return datetime.fromisoformat(ts) if ts else None + return self._bt_peak_reset + + def portfolio_state(self, now: datetime) -> PortfolioState: + wallets = getattr(self, "wallets", None) + start = wallets.get_starting_balance() if wallets else float(self.config.get("dry_run_wallet", 0)) + closed = [(t.close_date_utc, t.close_profit_abs or 0.0) for t in Trade.get_trades_proxy(is_open=False)] + open_positions = [] + for t in Trade.get_trades_proxy(is_open=True): + rate = self._last_close(t.pair) or t.open_rate + open_positions.append((t.open_rate, t.stop_loss, t.amount, t.calc_profit(rate))) + return build_state(start, closed, open_positions, now, self._peak_reset_time()) + + def _halted(self, now: datetime) -> bool: + if self._is_live(): + return bool(load_halt().get("halted")) + if self._bt_halt_until is not None and now >= self._bt_halt_until: + # backtest model of "a human reviewed it and resumed" + self._bt_halt_until = None + self._bt_peak_reset = now + return self._bt_halt_until is not None + + def _trigger_halt(self, reason: str, now: datetime) -> None: + if self._is_live(): + set_halt(True, reason) + else: + self._bt_halt_until = now + timedelta(days=self.risk_limits.halt_review_days) + + def _report(self, event: str, message: str, now: datetime, **details) -> None: + logger.info("risk %s: %s %s", event, message, details or "") + if not self._is_live(): + return + sent = self.__dict__.setdefault("_risk_reports_sent", {}) + key = (event, message.split(" ")[0]) + if key in sent and now - sent[key] < _REPORT_REPEAT: + return + sent[key] = now + audit.append(event, actor="strategy", details={"message": message, "strategy": self.__class__.__name__, **details}) + try: + self.dp.send_msg(f"Ridgeline risk ({event}): {message}") + except Exception: + logger.exception("could not send risk alert") + + # --- Freqtrade hooks --- + + def confirm_trade_entry(self, pair: str, order_type: str, amount: float, rate: float, + time_in_force: str, current_time: datetime, entry_tag: Optional[str], + side: str, **kwargs) -> bool: + state = self.portfolio_state(current_time) + allowed, reason = check_entry(state, self.risk_limits, self._halted(current_time)) + if not allowed: + if is_drawdown_breach(reason): + self._trigger_halt(reason, current_time) + self._report("risk_halt", reason, current_time) + else: + self._report("entry_blocked", reason, current_time, pair=pair) + return allowed + + def apply_risk_caps(self, pair: str, stake: float, stop_distance: float, + current_time: datetime, min_stake: Optional[float] = None) -> float: + """Call at the end of custom_stake_amount. Returns 0 (= skip the trade) + if the capped stake falls below the exchange minimum.""" + state = self.portfolio_state(current_time) + capped, reason = cap_stake(stake, stop_distance, state, self.risk_limits) + if reason: + self._report("stake_capped", reason, current_time, pair=pair) + if min_stake is not None and capped < min_stake: + return 0.0 + return capped diff --git a/ridgeline/notify.py b/ridgeline/notify.py new file mode 100644 index 0000000..7a7bc84 --- /dev/null +++ b/ridgeline/notify.py @@ -0,0 +1,38 @@ +"""Out-of-band alerts via the Telegram Bot API. + +Uses the same telegram.token / telegram.chat_id that Freqtrade's built-in +Telegram integration reads from config.private.json. Without them, alerts +are printed to the console instead, so nothing is silently lost. +""" + +import logging +from typing import Optional + +import httpx + +logger = logging.getLogger(__name__) + + +def telegram_credentials(config: dict) -> Optional[tuple[str, str]]: + tg = config.get("telegram") or {} + token, chat_id = str(tg.get("token") or "").strip(), str(tg.get("chat_id") or "").strip() + return (token, chat_id) if token and chat_id else None + + +def send(text: str, config: dict) -> bool: + """Send one alert. Returns True if Telegram accepted it.""" + creds = telegram_credentials(config) + if not creds: + print(f"[ALERT] {text}", flush=True) + return False + token, chat_id = creds + try: + r = httpx.post(f"https://api.telegram.org/bot{token}/sendMessage", + json={"chat_id": chat_id, "text": text, "disable_web_page_preview": True}, timeout=10) + r.raise_for_status() + return True + except httpx.HTTPError as e: + # never log the URL: it contains the bot token + logger.error("Telegram alert failed (%s); alert was: %s", type(e).__name__, text) + print(f"[ALERT - Telegram failed] {text}", flush=True) + return False diff --git a/ridgeline/report.py b/ridgeline/report.py new file mode 100644 index 0000000..2c59876 --- /dev/null +++ b/ridgeline/report.py @@ -0,0 +1,362 @@ +"""Investor-style tear sheet as one self-contained HTML file. + +Inline SVG charts and no external assets, so it can be emailed or archived as +is; the browser's Print -> Save as PDF gives a clean PDF. + +Data sources (normalised to one trades DataFrame): + - a paper/live Freqtrade SQLite database + - a backtest result directory (also what the panel's Replay produces) + +Performance is on a realised basis: equity moves when trades close. Open +positions at the report date are listed but not marked to market. +""" + +import html +import json +import math +import sqlite3 +from datetime import datetime, timezone +from pathlib import Path +from typing import Optional + +import pandas as pd + +from ridgeline import USERDIR + +DATADIR = USERDIR / "data" / "binance" +COLUMNS = ["pair", "open_date", "close_date", "stake", "profit_abs", "exit_reason", "is_open"] + + +# ---------------------------------------------------------------- loading + +def load_trades_db(path: Path) -> pd.DataFrame: + with sqlite3.connect(f"file:{Path(path).as_posix()}?mode=ro", uri=True) as con: + df = pd.read_sql_query( + "SELECT pair, open_date, close_date, stake_amount AS stake, close_profit_abs AS profit_abs, " + "exit_reason, is_open FROM trades", con) + return _normalise(df) + + +def load_trades_backtest(directory: Path) -> tuple[pd.DataFrame, float]: + from freqtrade.data.btanalysis import load_backtest_stats + + stats = load_backtest_stats(directory)["strategy"] + strat = next(iter(stats.values())) + df = pd.DataFrame(strat["trades"]).rename(columns={"stake_amount": "stake"}) + df["is_open"] = False + return _normalise(df), 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") + df["is_open"] = df["is_open"].astype(bool) + df["profit_abs"] = pd.to_numeric(df["profit_abs"], errors="coerce").fillna(0.0) + return df[COLUMNS].sort_values("open_date").reset_index(drop=True) + + +# ---------------------------------------------------------------- metrics + +def daily_equity(trades: pd.DataFrame, start_balance: float, + start: pd.Timestamp, end: pd.Timestamp) -> pd.Series: + closed = trades[~trades["is_open"] & trades["close_date"].notna()] + pnl = closed.groupby(closed["close_date"].dt.floor("D"))["profit_abs"].sum() + days = pd.date_range(start.floor("D"), end.floor("D"), freq="D", tz="UTC") + return start_balance + pnl.reindex(days, fill_value=0.0).cumsum() + + +def risk_stats(equity: pd.Series) -> dict: + rets = equity.pct_change().dropna() + years = max((equity.index[-1] - equity.index[0]).days / 365.25, 1 / 365.25) + total = equity.iloc[-1] / equity.iloc[0] - 1 + cagr = (1 + total) ** (1 / years) - 1 if total > -1 else -1.0 + vol = rets.std() * math.sqrt(365) if len(rets) > 1 else 0.0 + downside = rets[rets < 0].std() * math.sqrt(365) if (rets < 0).sum() > 1 else 0.0 + mean = rets.mean() * 365 if len(rets) else 0.0 + dd = equity / equity.cummax() - 1 + max_dd = -dd.min() if len(dd) else 0.0 + return { + "total_return": total, "cagr": cagr, "volatility": vol, + "sharpe": mean / vol if vol > 0 else 0.0, + "sortino": mean / downside if downside > 0 else 0.0, + "max_drawdown": max_dd, + "calmar": cagr / max_dd if max_dd > 0 else 0.0, + } + + +def trade_stats(trades: pd.DataFrame, start: pd.Timestamp, end: pd.Timestamp) -> dict: + closed = trades[~trades["is_open"]] + wins = closed[closed["profit_abs"] > 0] + losses = closed[closed["profit_abs"] <= 0] + pct = closed["profit_abs"] / closed["stake"].where(closed["stake"] > 0) + gross_loss = -losses["profit_abs"].sum() + # fraction of the period with at least one position open + intervals = [(max(o, start), min(c if pd.notna(c) else end, end)) + for o, c in zip(trades["open_date"], trades["close_date"])] + covered = _union_seconds([iv for iv in intervals if iv[1] > iv[0]]) + span = max((end - start).total_seconds(), 1) + hold = (closed["close_date"] - closed["open_date"]).dt.total_seconds() / 3600 + return { + "trades": len(closed), + "win_rate": len(wins) / len(closed) if len(closed) else 0.0, + "profit_factor": wins["profit_abs"].sum() / gross_loss if gross_loss > 0 else float("inf") if len(wins) else 0.0, + "avg_win_pct": pct[closed["profit_abs"] > 0].mean() if len(wins) else 0.0, + "avg_loss_pct": pct[closed["profit_abs"] <= 0].mean() if len(losses) else 0.0, + "avg_hold_hours": hold.mean() if len(hold) else 0.0, + "time_in_market": covered / span, + "open_positions": int(trades["is_open"].sum()), + } + + +def _union_seconds(intervals: list[tuple]) -> float: + total, cur_s, cur_e = 0.0, None, None + for s, e in sorted(intervals): + if cur_e is None or s > cur_e: + if cur_e is not None: + total += (cur_e - cur_s).total_seconds() + cur_s, cur_e = s, e + else: + cur_e = max(cur_e, e) + if cur_e is not None: + total += (cur_e - cur_s).total_seconds() + return total + + +def monthly_returns(equity: pd.Series) -> pd.DataFrame: + month_end = equity.resample("ME").last() + first = pd.Series([equity.iloc[0]], index=[equity.index[0] - pd.Timedelta(days=1)]) + rets = pd.concat([first, month_end]).pct_change().dropna() + grid = pd.DataFrame({"year": rets.index.year, "month": rets.index.month, "ret": rets.values}) + return grid.pivot(index="year", columns="month", values="ret") + + +def benchmark(pair: str, index: pd.DatetimeIndex) -> Optional[pd.Series]: + path = DATADIR / f"{pair.replace('/', '_')}-1d.feather" + if not path.exists(): + return None + df = pd.read_feather(path, columns=["date", "close"]).set_index("date")["close"] + series = df.reindex(index, method="ffill") + if series.isna().all(): + return None + series = series.ffill().bfill() + return series / series.iloc[0] + + +# ---------------------------------------------------------------- SVG + +def _svg_lines(series: list[tuple[str, pd.Series, str]], height: int = 220, pct_axis: bool = False, + fill_below_zero: bool = False, rebased: bool = False) -> str: + width, pad_l, pad_b, pad_t = 760, 56, 22, 10 + values = pd.concat([s for _, s, _ in series]) + lo, hi = float(values.min()), float(values.max()) + if fill_below_zero: + hi = max(hi, 0.0) + if hi == lo: + hi, lo = hi + 1, lo - 1 + idx = series[0][1].index + n = max(len(idx) - 1, 1) + + def x(i): + return pad_l + (width - pad_l - 8) * i / n + + def y(v): + return pad_t + (height - pad_t - pad_b) * (1 - (v - lo) / (hi - lo)) + + if rebased: # growth factors, 1.0 = start + fmt = (lambda v: f"{(v - 1) * 100:+.0f}%") + elif pct_axis: + fmt = (lambda v: f"{v * 100:.0f}%") + else: + fmt = (lambda v: f"{v:,.0f}") + parts = [f''] + for k in range(5): + v = lo + (hi - lo) * k / 4 + parts.append(f'' + f'{fmt(v)}') + for k in (0, n // 2, n): + parts.append(f'' + f'{idx[k]:%Y-%m-%d}') + for label, s, color in series: + pts = " ".join(f"{x(i):.1f},{y(v):.1f}" for i, v in enumerate(s.values)) + if fill_below_zero: + parts.append(f'') + parts.append(f'') + parts.append("") + legend = "".join(f'{html.escape(lbl)}' + for lbl, _, c in series) + return f'
{legend}
' + "".join(parts) + + +# ---------------------------------------------------------------- HTML + +def _pct(v: float, digits: int = 1) -> str: + if v is None or (isinstance(v, float) and (math.isnan(v) or math.isinf(v))): + return "–" + return f"{v * 100:+.{digits}f}%" + + +def _num(v: float, digits: int = 2) -> str: + if v is None or (isinstance(v, float) and math.isnan(v)): + return "–" + return "∞" if math.isinf(v) else f"{v:.{digits}f}" + + +def build_tearsheet(trades: pd.DataFrame, start_balance: float, *, title: str, source_label: str, + month: Optional[str] = None, mode: str = "paper", + benchmark_pair: str = "BTC/USDT") -> str: + """mode: "paper", "live" or "backtest" - sets the badge and disclosures.""" + if trades.empty: + raise ValueError("No trades in this source yet - nothing to report.") + start = trades["open_date"].min() + end = trades[["open_date", "close_date"]].max().max() + if month: + # report "as of" the end of that month: later trades don't exist yet, + # and trades closed after it were still open then + m_end = pd.Timestamp(month + "-01", tz="UTC") + pd.offsets.MonthEnd(1) + pd.Timedelta(hours=23, minutes=59) + end = min(m_end, pd.Timestamp.now(tz="UTC")) + trades = trades[trades["open_date"] <= end].copy() + if trades.empty: + raise ValueError(f"No trades by the end of {month}.") + later = trades["close_date"] > end + trades.loc[later, "close_date"] = pd.NaT + trades.loc[later, "is_open"] = True + end = max(end, start + pd.Timedelta(days=1)) + + eq = daily_equity(trades, start_balance, start, end) + stats = risk_stats(eq) + tstats = trade_stats(trades, start, end) + grid = monthly_returns(eq) + bench = benchmark(benchmark_pair, eq.index) + dd = eq / eq.cummax() - 1 + + last_month = grid.iloc[-1].dropna().iloc[-1] if not grid.empty else 0.0 + year = eq.index[-1].year + ytd_base = eq[eq.index.year < year] + ytd = eq.iloc[-1] / (ytd_base.iloc[-1] if len(ytd_base) else eq.iloc[0]) - 1 + + closed = trades[~trades["is_open"]] + by_pair = closed.groupby("pair")["profit_abs"].agg(["count", "sum"]).sort_values("sum", ascending=False) + + lines = [("Strategy equity", eq / eq.iloc[0], "#2f6fed")] + if bench is not None: + lines.append((f"{benchmark_pair} buy & hold", bench, "#98a2b3")) + + def card(label, value, sub=""): + return f'
{label}
{value}
{sub}
' + + month_names = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] + grid_rows = "".join( + f"{y}" + "".join( + f' 0 else "neg" if v < 0 else ""}">{_pct(v) if pd.notna(v) else ""}' + for v in (grid.loc[y].get(mn) if mn in grid.columns else float("nan") for mn in range(1, 13))) + + f'{_pct((1 + grid.loc[y].dropna()).prod() - 1)}' + for y in grid.index) + pair_rows = "".join(f"{html.escape(p)}{int(r['count'])}" + f' 0 else "neg"}">{r["sum"]:+,.2f}' + for p, r in pd.concat([by_pair.head(5), by_pair.tail(5)]).drop_duplicates().iterrows()) + trade_rows = "".join( + f"{html.escape(t.pair)}{t.open_date:%Y-%m-%d %H:%M}" + f"{'' if pd.isna(t.close_date) else f'{t.close_date:%Y-%m-%d %H:%M}'}" + f"{t.stake:,.2f} 0 else 'neg' if t.profit_abs < 0 else ''}\">" + f"{'' if t.is_open else f'{t.profit_abs:+,.2f}'}{'open' if t.is_open else html.escape(str(t.exit_reason))}" + for t in trades.itertuples()) + + badge = {"paper": "PAPER TRADING (simulated fills, no real money)", + "backtest": "BACKTEST (historical simulation)", + "live": "LIVE TRADING"}[mode] + basis = {"paper": "Simulated (paper) trading against live Binance prices; fills assume the quoted price and exchange fees, without slippage.", + "backtest": "Historical backtest: simulated fills on past candle prices with exchange fees, without slippage. Backtests are hypothetical and benefit from hindsight in strategy design.", + "live": "Live trading results from exchange fills, net of exchange fees."}[mode] + generated = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") + bench_ret = f"{benchmark_pair}: {_pct(bench.iloc[-1] - 1)}" if bench is not None else "" + + return f""" + +{html.escape(title)} +
+

{html.escape(title)}

+
{start:%d %b %Y} – {end:%d %b %Y} · {html.escape(source_label)} · {badge}
+ +
+ {card("Latest month", _pct(last_month), f"{eq.index[-1]:%B %Y}")} + {card("Year to date", _pct(ytd), str(year))} + {card("Since inception", _pct(stats["total_return"]), bench_ret)} + {card("Equity", f"{eq.iloc[-1]:,.0f}", f"from {start_balance:,.0f} USDT")} + {card("Max drawdown", _pct(-stats["max_drawdown"]), "peak to trough")} + {card("Sharpe", _num(stats["sharpe"]), "annualised, rf = 0")} + {card("Sortino", _num(stats["sortino"]), "annualised")} + {card("Calmar", _num(stats["calmar"]), "CAGR / max DD")} +
+ +

Equity vs benchmark

+{_svg_lines(lines, rebased=True)} +

Drawdown

+{_svg_lines([("Drawdown", dd, "#c4320a")], height=140, pct_axis=True, fill_below_zero=True)} + +

Monthly returns

+
{"".join(f"" for m in month_names)}{grid_rows}
{m}Year
+ +
+

Risk

+ + + + + +
CAGR{_pct(stats["cagr"])}
Volatility (ann.){stats["volatility"] * 100:.1f}%
Max drawdown{stats["max_drawdown"] * 100:.1f}%
Time in market{tstats["time_in_market"] * 100:.0f}%
Open positions at report date{tstats["open_positions"]}
+

Trades

+ + + + + +
Closed trades{tstats["trades"]}
Win rate{tstats["win_rate"] * 100:.1f}%
Profit factor{_num(tstats["profit_factor"])}
Average win / loss{_pct(tstats["avg_win_pct"], 2)} / {_pct(tstats["avg_loss_pct"], 2)}
Average holding time{tstats["avg_hold_hours"]:.0f} h
+
+ +

Best and worst pairs

+
{pair_rows}
PairTradesP&L (USDT)
+ +

Trade log

+
+{trade_rows}
PairOpened (UTC)Closed (UTC)StakeP&LExit
+ +

Disclosures. {basis} +Returns are calculated on a realised basis (equity changes when a trade closes); open positions are not marked to market. +Ratios use daily returns, 365-day annualisation and a zero risk-free rate. Past performance, simulated or live, does not indicate future results. +This document is for information only and is not an offer, solicitation or financial advice. Generated {generated} by ridgeline.

+
""" + + +def session_balance(db_path: Path) -> float: + """Starting balance of a panel paper session (its .config.json).""" + cfg_path = Path(db_path).with_suffix(".config.json") + try: + return float(json.loads(cfg_path.read_text())["dry_run_wallet"]) + except (OSError, ValueError, KeyError): + return 1000.0 diff --git a/ridgeline/risk.py b/ridgeline/risk.py new file mode 100644 index 0000000..48e662b --- /dev/null +++ b/ridgeline/risk.py @@ -0,0 +1,158 @@ +"""Portfolio-level risk limits. + +Pure logic with no Freqtrade dependency, so every limit can be unit-tested. +`ridgeline.freqtrade_risk.RiskMixin` wires these checks into strategies. + +Limits (defaults, overridable via a "ridgeline_risk" section in config.json): + max_position_pct largest single position, % of equity + max_open_risk_pct sum over open trades of (stake x distance to stop), % of equity + daily_loss_limit_pct loss since UTC midnight, % of equity at midnight -> no new + entries until the next UTC day + max_drawdown_halt_pct drawdown from peak equity -> new entries halted until a human + resumes (live/paper). Backtests auto-resume after + halt_review_days to model that review. +""" + +import json +from dataclasses import asdict, dataclass, fields +from datetime import datetime, timezone +from pathlib import Path +from typing import Optional + +from ridgeline import USERDIR + +HALT_STATE_FILE = USERDIR / "risk_state.json" + + +@dataclass(frozen=True) +class RiskLimits: + max_position_pct: float = 35.0 + max_open_risk_pct: float = 4.5 + daily_loss_limit_pct: float = 3.0 + max_drawdown_halt_pct: float = 15.0 + halt_review_days: float = 7.0 # backtests only + + @classmethod + def from_config(cls, config: dict) -> "RiskLimits": + overrides = config.get("ridgeline_risk") or {} + known = {f.name for f in fields(cls)} + return cls(**{k: float(v) for k, v in overrides.items() if k in known}) + + +@dataclass(frozen=True) +class PortfolioState: + equity: float # starting balance + realised + unrealised P&L + peak_equity: float # highest equity seen (realised basis) + day_start_equity: float # equity at the most recent UTC midnight (realised basis) + open_risk: float # currency lost if every open trade hit its stop now + + def drawdown_pct(self) -> float: + return max(0.0, (self.peak_equity - self.equity) / self.peak_equity * 100) if self.peak_equity > 0 else 0.0 + + def day_loss_pct(self) -> float: + if self.day_start_equity <= 0: + return 0.0 + return max(0.0, (self.day_start_equity - self.equity) / self.day_start_equity * 100) + + def open_risk_pct(self) -> float: + return self.open_risk / self.equity * 100 if self.equity > 0 else 100.0 + + +def build_state(start_balance: float, + closed: list[tuple[datetime, float]], + open_positions: list[tuple[float, float, float, float]], + now: datetime, + peak_reset: Optional[datetime] = None) -> PortfolioState: + """Portfolio state from plain data, shared by the strategy (Trade objects), + the panel and the watchdog (REST API), so they can never disagree. + + closed: (close_time, profit_abs) per closed trade + open_positions: (open_rate, stop_price, amount, unrealised_profit_abs) + peak_reset: after a reviewed halt, drawdown restarts from equity then + """ + midnight = now.astimezone(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0) + realised = 0.0 + day_start = peak = start_balance + for close_time, profit in sorted(closed, key=lambda c: c[0]): + realised += profit or 0.0 + if close_time < midnight: + day_start = start_balance + realised + if peak_reset is not None and close_time < peak_reset: + peak = start_balance + realised + else: + peak = max(peak, start_balance + realised) + unrealised = sum(p[3] for p in open_positions) + # loss vs entry if every stop hit now; a stop above entry risks nothing + open_risk = sum(max(0.0, rate - stop) * amount for rate, stop, amount, _ in open_positions if stop) + equity = start_balance + realised + unrealised + return PortfolioState(equity=equity, peak_equity=max(peak, equity), + day_start_equity=day_start, open_risk=open_risk) + + +def check_entry(state: PortfolioState, limits: RiskLimits, halted: bool = False) -> tuple[bool, Optional[str]]: + """May a new position be opened? Returns (allowed, reason if not).""" + if halted: + return False, "trading halted after the drawdown limit was hit - resume manually after review" + if state.equity <= 0: + return False, "no equity" + dd = state.drawdown_pct() + if dd >= limits.max_drawdown_halt_pct: + return False, f"drawdown {dd:.2f}% reached the {limits.max_drawdown_halt_pct:g}% halt limit" + day = state.day_loss_pct() + if day >= limits.daily_loss_limit_pct: + return False, f"today's loss {day:.2f}% reached the {limits.daily_loss_limit_pct:g}% daily limit" + risk = state.open_risk_pct() + if risk >= limits.max_open_risk_pct: + return False, f"open risk {risk:.2f}% already at the {limits.max_open_risk_pct:g}% limit" + return True, None + + +def is_drawdown_breach(reason: Optional[str]) -> bool: + return bool(reason) and "halt limit" in reason + + +def cap_stake(stake: float, stop_distance: float, state: PortfolioState, + limits: RiskLimits) -> tuple[float, Optional[str]]: + """Shrink a proposed stake so neither the position cap nor the open-risk + budget is exceeded. Returns (stake, reason if it was reduced).""" + caps = {"position cap": state.equity * limits.max_position_pct / 100} + if stop_distance > 0: + budget = state.equity * limits.max_open_risk_pct / 100 - state.open_risk + caps["open-risk budget"] = max(0.0, budget) / stop_distance + name, cap = min(caps.items(), key=lambda kv: kv[1]) + if stake <= cap: + return stake, None + return max(0.0, cap), f"stake {stake:.2f} reduced to {cap:.2f} by the {name}" + + +# --- manual halt flag (live / paper) --- + +def load_halt(path: Path = HALT_STATE_FILE) -> dict: + try: + return json.loads(Path(path).read_text()) + except (OSError, ValueError): + return {"halted": False} + + +def set_halt(halted: bool, reason: str = "", path: Path = HALT_STATE_FILE) -> dict: + """Halting records why; resuming records when, so drawdown is measured + from equity at resumption rather than re-triggering on the old peak.""" + now = datetime.now(timezone.utc).isoformat(timespec="seconds") + state = {"halted": halted, "reason": reason, "since": now} + if not halted: + state["resumed_at"] = now + Path(path).parent.mkdir(parents=True, exist_ok=True) + Path(path).write_text(json.dumps(state, indent=2)) + return state + + +def limits_summary(state: PortfolioState, limits: RiskLimits) -> dict: + """Current usage of each limit, for the panel and the watchdog.""" + return { + "limits": asdict(limits), + "usage": { + "drawdown_pct": round(state.drawdown_pct(), 3), + "day_loss_pct": round(state.day_loss_pct(), 3), + "open_risk_pct": round(state.open_risk_pct(), 3), + }, + } diff --git a/scripts/init_private_config.py b/scripts/init_private_config.py new file mode 100644 index 0000000..45fd4b5 --- /dev/null +++ b/scripts/init_private_config.py @@ -0,0 +1,46 @@ +"""Create user_data/config.private.json with freshly generated API secrets. + +Run once on a new machine/server (or in CI). Never overwrites an existing +file. Afterwards, add Telegram and (only when going live) exchange keys by +editing the file - it is gitignored and must never be committed. + + python scripts/init_private_config.py [--bind-all] + +--bind-all makes the bot's API listen on 0.0.0.0, which Docker needs so the +panel/watchdog containers can reach it (docker-compose still only publishes +ports on 127.0.0.1). +""" + +import argparse +import json +import secrets +from pathlib import Path + +PATH = Path(__file__).resolve().parent.parent / "user_data" / "config.private.json" + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--bind-all", action="store_true") + args = ap.parse_args() + if PATH.exists(): + print(f"{PATH} already exists - left unchanged.") + return + config = { + "exchange": {"key": "", "secret": ""}, + "telegram": {"enabled": False, "token": "", "chat_id": ""}, + "api_server": { + "username": "freqtrader", + "password": secrets.token_urlsafe(18), + "jwt_secret_key": secrets.token_hex(32), + "ws_token": secrets.token_urlsafe(24), + }, + } + if args.bind_all: + config["api_server"]["listen_ip_address"] = "0.0.0.0" + PATH.write_text(json.dumps(config, indent=4)) + print(f"Created {PATH} (API username: freqtrader; password is in the file).") + + +if __name__ == "__main__": + main() diff --git a/scripts/report.py b/scripts/report.py new file mode 100644 index 0000000..d3be5df --- /dev/null +++ b/scripts/report.py @@ -0,0 +1,56 @@ +"""Generate an investor-style tear sheet (self-contained HTML). + +Usage (from the project root, with the venv Python): + python scripts/report.py --db user_data/paper/.sqlite [--month 2026-10] + python scripts/report.py --backtest user_data/replays/ [--month 2026-03] + python scripts/report.py --db tradesv3.sqlite --live --balance 5000 + +Writes reports/.html. Open it in a browser; Print -> Save as PDF for a PDF. +""" + +import argparse +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) + +from ridgeline.report import ( # noqa: E402 + build_tearsheet, + load_trades_backtest, + load_trades_db, + session_balance, +) + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + src = ap.add_mutually_exclusive_group(required=True) + src.add_argument("--db", type=Path, help="paper or live Freqtrade SQLite database") + src.add_argument("--backtest", type=Path, help="backtest/replay result directory") + ap.add_argument("--month", help="report as of the end of this month (YYYY-MM)") + ap.add_argument("--balance", type=float, help="starting balance (default: from the session/backtest)") + ap.add_argument("--live", action="store_true", help="label as live trading instead of paper") + ap.add_argument("--title", default="Ridgeline - performance report") + ap.add_argument("-o", "--out", type=Path, default=ROOT / "reports") + args = ap.parse_args() + + if args.db: + trades = load_trades_db(args.db) + balance = args.balance or session_balance(args.db) + label, name = f"session {args.db.stem}", args.db.stem + else: + trades, balance = load_trades_backtest(args.backtest) + balance = args.balance or balance + label, name = f"backtest {args.backtest.name}", args.backtest.name + + page = build_tearsheet(trades, balance, title=args.title, source_label=label, + month=args.month, mode="live" if args.live else "paper" if args.db else "backtest") + args.out.mkdir(parents=True, exist_ok=True) + path = args.out / f"{name}{'-' + args.month if args.month else ''}.html" + path.write_text(page, encoding="utf-8") + print(path) + + +if __name__ == "__main__": + main() diff --git a/scripts/research.py b/scripts/research.py index 6f6a0b3..60938b6 100644 --- a/scripts/research.py +++ b/scripts/research.py @@ -83,7 +83,7 @@ def config_pairs() -> list[str]: @functools.lru_cache(maxsize=None) -def universe_at(when: str, n: int = UNIVERSE_SIZE) -> tuple[str, ...]: +def universe_at(when: str, n: int = UNIVERSE_SIZE, datadir: Path = DATADIR) -> tuple[str, ...]: """Top-n USDT pairs by quote volume over the lookback window before `when`, using only data that existed at that time.""" if STATIC_UNIVERSE: @@ -91,7 +91,7 @@ def universe_at(when: str, n: int = UNIVERSE_SIZE) -> tuple[str, ...]: t = pd.Timestamp(when, tz="UTC") lookback_start = t - pd.Timedelta(days=UNIVERSE_LOOKBACK_DAYS) ranked = [] - for path in DATADIR.glob("*_USDT-1d.feather"): + for path in Path(datadir).glob("*_USDT-1d.feather"): pair = path.stem.rsplit("-", 1)[0].replace("_", "/") if UNIVERSE_BLACKLIST.match(pair): continue diff --git a/scripts/watchdog.py b/scripts/watchdog.py new file mode 100644 index 0000000..8c44527 --- /dev/null +++ b/scripts/watchdog.py @@ -0,0 +1,113 @@ +"""Watchdog: checks the trading bot every minute and alerts when something is wrong. + +Alerts (Telegram if configured, otherwise console) when: + - the bot's API is unreachable + - the bot hasn't processed a cycle for --stale-minutes + - the bot isn't in the "running" state (stopped / entries paused) + - drawdown, daily loss or open risk is near (>= 80%) or over its limit +Each problem alerts once, then again with "resolved" when it clears; every +alert is also written to the audit log. + +Usage (project root, venv Python): + python scripts/watchdog.py # loop forever + python scripts/watchdog.py --once # single check, exit 1 if any problem + python scripts/watchdog.py --test-alert # send one test message and exit +Set RIDGELINE_BOT_URL to watch a bot that isn't on the configured API address. +""" + +import argparse +import sys +import time +from datetime import datetime, timezone +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) + +import httpx # noqa: E402 + +from panel.bot import PaperBot, PaperBotError # noqa: E402 +from panel.common import merged_config # noqa: E402 +from ridgeline import audit, notify # noqa: E402 + +NEAR = 0.8 # fraction of a limit that counts as "near" +LIMIT_KEYS = { + "drawdown_pct": ("max_drawdown_halt_pct", "drawdown from peak"), + "day_loss_pct": ("daily_loss_limit_pct", "today's loss"), + "open_risk_pct": ("max_open_risk_pct", "open risk"), +} + + +def check(bot: PaperBot, stale_minutes: float) -> dict[str, str]: + """Return {problem_key: message} for everything wrong right now.""" + problems: dict[str, str] = {} + try: + httpx.get(f"{bot.base_url}/api/v1/ping", timeout=5).raise_for_status() + except httpx.HTTPError: + return {"down": f"Bot API unreachable at {bot.base_url}"} + try: + health = bot._call("GET", "health") + last = health.get("last_process_ts") + if last: + age_min = (time.time() - last) / 60 + if age_min > stale_minutes: + problems["stale"] = f"Bot hasn't completed a cycle for {age_min:.0f} min" + risk = bot.risk_status() + except PaperBotError as e: + return {"api": str(e)} + + if risk.get("bot_state") != "running": + problems["state"] = f"Bot state is '{risk.get('bot_state')}' (not opening new trades)" + if risk.get("halt", {}).get("halted"): + problems["halt"] = f"Trading HALTED: {risk['halt'].get('reason')}" + for key, (limit_key, label) in LIMIT_KEYS.items(): + value, limit = risk["usage"][key], risk["limits"][limit_key] + if limit and value >= limit: + problems[f"{key}:breach"] = f"{label} {value:.2f}% is at/over the {limit:g}% limit" + elif limit and value >= NEAR * limit: + problems[f"{key}:near"] = f"{label} {value:.2f}% is close to the {limit:g}% limit" + return problems + + +def alert(text: str, config: dict, event: str) -> None: + notify.send(f"ridgeline watchdog: {text}", config) + try: + audit.append(event, actor="watchdog", details={"message": text}) + except Exception as e: # alerting must not die on an audit problem + print(f"[watchdog] audit write failed: {e}", flush=True) + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--interval", type=float, default=60, help="seconds between checks") + ap.add_argument("--stale-minutes", type=float, default=10) + ap.add_argument("--once", action="store_true") + ap.add_argument("--test-alert", action="store_true") + args = ap.parse_args() + + config = merged_config() + if args.test_alert: + sent = notify.send("ridgeline watchdog: test alert - notifications are working.", config) + print("Telegram: sent" if sent else "Telegram not configured or failed - printed to console instead.") + return + + bot = PaperBot() + active: dict[str, str] = {} + print(f"[watchdog] watching {bot.base_url} every {args.interval:.0f}s", flush=True) + while True: + problems = check(bot, args.stale_minutes) + for key, msg in problems.items(): + if key not in active: + alert(msg, config, "watchdog_alert") + for key in set(active) - set(problems): + alert(f"resolved - {active[key]}", config, "watchdog_resolved") + active = problems + stamp = datetime.now(timezone.utc).strftime("%H:%M:%S") + print(f"[watchdog {stamp}] {'OK' if not problems else '; '.join(problems.values())}", flush=True) + if args.once: + sys.exit(1 if problems else 0) + time.sleep(args.interval) + + +if __name__ == "__main__": + main() diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..3964f8d --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,60 @@ +import importlib.util +import sys +from pathlib import Path + +import numpy as np +import pandas as pd +import pytest + +ROOT = Path(__file__).resolve().parent.parent +sys.path[:0] = [str(ROOT), str(ROOT / "scripts")] + + +def make_ohlcv(n: int = 2000, freq: str = "4h", start: str = "2023-01-01", seed: int = 0, + regimes=((0.0008, 0.015), (-0.0006, 0.02), (0.0004, 0.01)), + price: float = 100.0, volume: float = 1000.0) -> pd.DataFrame: + """Random-walk candles moving through trend / crash / chop regimes.""" + rng = np.random.default_rng(seed) + sizes = [n // len(regimes)] * len(regimes) + sizes[-1] += n - sum(sizes) + rets = np.concatenate([rng.normal(mu, sd, k) for (mu, sd), k in zip(regimes, sizes)]) + close = price * np.exp(np.cumsum(rets)) + open_ = np.r_[price, close[:-1]] + high = np.maximum(open_, close) * (1 + rng.uniform(0, 0.01, n)) + low = np.minimum(open_, close) * (1 - rng.uniform(0, 0.01, n)) + return pd.DataFrame({ + "date": pd.date_range(start, periods=n, freq=freq, tz="UTC"), + "open": open_, "high": high, "low": low, "close": close, + "volume": volume * rng.uniform(0.5, 1.5, n), + }) + + +@pytest.fixture +def ohlcv(): + return make_ohlcv + + +def load_strategy(name: str): + """Import a strategy file and instantiate it outside Freqtrade's bot loop.""" + from freqtrade.enums import CandleType, RunMode + + path = ROOT / "user_data" / "strategies" / f"{name}.py" + spec = importlib.util.spec_from_file_location(f"test_strategy_{name}", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + config = {"stake_currency": "USDT", "dry_run_wallet": 1000, "runmode": RunMode.BACKTEST, + "trading_mode": "spot", "candle_type_def": CandleType.SPOT} + return getattr(module, name)(config) + + +@pytest.fixture +def strategy(): + return load_strategy + + +@pytest.fixture +def audit_dir(tmp_path, monkeypatch): + from ridgeline import audit + + monkeypatch.setattr(audit, "AUDIT_DIR", tmp_path / "audit") + return tmp_path / "audit" diff --git a/tests/test_audit.py b/tests/test_audit.py new file mode 100644 index 0000000..b6a8d08 --- /dev/null +++ b/tests/test_audit.py @@ -0,0 +1,47 @@ +import json + +from ridgeline import audit + + +def test_append_and_verify(audit_dir): + for i in range(3): + audit.append("event", "test", {"i": i}) + assert audit.verify() == (True, None) + events = audit.tail(10) + assert [e["details"]["i"] for e in events] == [2, 1, 0] + assert events[-1]["prev_hash"] == audit.GENESIS + + +def _lines(audit_dir): + path = next(audit_dir.glob("audit-*.jsonl")) + return path, path.read_text().splitlines() + + +def test_edited_record_is_detected(audit_dir): + for i in range(3): + audit.append("event", "test", {"amount": i}) + path, lines = _lines(audit_dir) + record = json.loads(lines[1]) + record["details"]["amount"] = 999 + lines[1] = json.dumps(record, sort_keys=True) + path.write_text("\n".join(lines) + "\n") + ok, problem = audit.verify() + assert not ok and ":2:" in problem and "changed" in problem + + +def test_deleted_record_is_detected(audit_dir): + for i in range(3): + audit.append("event", "test", {"i": i}) + path, lines = _lines(audit_dir) + path.write_text("\n".join([lines[0], lines[2]]) + "\n") + ok, problem = audit.verify() + assert not ok and "chain broken" in problem + + +def test_chain_continues_across_monthly_files(audit_dir): + audit.append("first", "test") + path, _ = _lines(audit_dir) + path.rename(audit_dir / "audit-2000-01.jsonl") # pretend it was last month + audit.append("second", "test") + assert len(list(audit_dir.glob("audit-*.jsonl"))) == 2 + assert audit.verify() == (True, None) diff --git a/tests/test_panel_api.py b/tests/test_panel_api.py new file mode 100644 index 0000000..056d097 --- /dev/null +++ b/tests/test_panel_api.py @@ -0,0 +1,68 @@ +"""Panel API: input validation, the dry-run safety guard, and the kill switch.""" + +from types import SimpleNamespace + +import pytest +from fastapi.testclient import TestClient + +import panel.app as app_mod +import panel.bot as bot_mod + + +@pytest.fixture +def client(tmp_path, monkeypatch, audit_dir): + monkeypatch.setattr(bot_mod, "PAPER_DIR", tmp_path / "paper") + monkeypatch.setattr(bot_mod, "STATE_FILE", tmp_path / "paper" / "state.json") + (tmp_path / "paper").mkdir() + return TestClient(app_mod.app) + + +def test_lists_strategies(client): + assert {"TrendRider4h", "EmaRsiMomentum", "TrendDipBuyer1h"} <= set(client.get("/api/strategies").json()) + + +def test_replay_rejects_bad_dates(client): + r = client.post("/api/replay", json={"strategy": "TrendRider4h", "balance": 1000, "start": "2025/06/01", "end": ""}) + assert r.status_code == 422 + + +def test_unknown_strategy_rejected(client): + r = client.post("/api/paper/start", json={"balance": 1000, "strategy": "../../evil"}) + assert r.status_code == 400 + + +def test_refuses_to_start_a_live_bot(client, monkeypatch): + """If the resolved config isn't dry-run, nothing is launched.""" + monkeypatch.setattr(bot_mod, "_port_open", lambda url: False) + monkeypatch.setattr(bot_mod.subprocess, "run", + lambda *a, **k: SimpleNamespace(stdout='{"dry_run": false}', stderr="")) + + def no_launch(*a, **k): + raise AssertionError("a bot process was launched") + + monkeypatch.setattr(bot_mod.subprocess, "Popen", no_launch) + r = client.post("/api/paper/start", json={"balance": 1000, "strategy": "TrendRider4h"}) + assert r.status_code == 409 and "Refusing" in r.json()["detail"] + + +def test_flatten_needs_typed_confirmation(client): + assert client.post("/api/bot/flatten", json={"confirm": "yes"}).status_code == 400 + + +def test_flatten_sequence_and_audit(client, monkeypatch): + calls = [] + + def fake_call(method, path, **kw): + calls.append(path) + return [] if path == "status" else {"status": "ok"} + + monkeypatch.setattr(app_mod.bot, "_call", fake_call) + r = client.post("/api/bot/flatten", json={"confirm": "FLATTEN"}) + assert r.status_code == 200 + assert calls == ["stopentry", "status", "forceexit", "status", "stop"] + audit = client.get("/api/audit").json() + assert audit["chain_ok"] and audit["events"][0]["event"] == "flatten" + + +def test_report_rejects_path_tricks(client): + assert client.get("/api/report", params={"session": "../config"}).status_code == 400 diff --git a/tests/test_repo_hygiene.py b/tests/test_repo_hygiene.py new file mode 100644 index 0000000..101f13d --- /dev/null +++ b/tests/test_repo_hygiene.py @@ -0,0 +1,35 @@ +"""Guards against publishing secrets or shipping a live-trading config.""" + +import json +import shutil +import subprocess + +import pytest + +from conftest import ROOT + +CONFIG = json.loads((ROOT / "user_data" / "config.json").read_text()) + + +def test_config_is_dry_run(): + assert CONFIG["dry_run"] is True + + +def test_no_secrets_in_public_config(): + assert not CONFIG["exchange"].get("key") and not CONFIG["exchange"].get("secret") + assert not CONFIG.get("telegram", {}).get("token") + for field in ("password", "jwt_secret_key", "ws_token", "username"): + assert field not in CONFIG.get("api_server", {}), f"api_server.{field} belongs in config.private.json" + + +def test_private_files_are_ignored(): + ignored = (ROOT / ".gitignore").read_text() + for pattern in ("user_data/config.private.json", "*.docx", "*.sqlite", "user_data/audit/"): + assert pattern in ignored, f"{pattern} missing from .gitignore" + + +@pytest.mark.skipif(not shutil.which("git") or not (ROOT / ".git").exists(), reason="not a git checkout") +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}" diff --git a/tests/test_report.py b/tests/test_report.py new file mode 100644 index 0000000..4533dc5 --- /dev/null +++ b/tests/test_report.py @@ -0,0 +1,44 @@ +import pandas as pd +import pytest + +from ridgeline.report import build_tearsheet, daily_equity, risk_stats, trade_stats + + +def trades(): + t = pd.Timestamp + return pd.DataFrame([ + {"pair": "BTC/USDT", "open_date": t("2026-01-02", tz="UTC"), "close_date": t("2026-01-05", tz="UTC"), + "stake": 300.0, "profit_abs": 30.0, "exit_reason": "trailing_stop_loss", "is_open": False}, + {"pair": "ETH/USDT", "open_date": t("2026-01-10", tz="UTC"), "close_date": t("2026-01-12", tz="UTC"), + "stake": 200.0, "profit_abs": -10.0, "exit_reason": "stop_loss", "is_open": False}, + {"pair": "SOL/USDT", "open_date": t("2026-01-28", tz="UTC"), "close_date": t("2026-02-03", tz="UTC"), + "stake": 250.0, "profit_abs": 25.0, "exit_reason": "exit_signal", "is_open": False}, + ]) + + +def test_equity_and_stats(): + eq = daily_equity(trades(), 1000.0, pd.Timestamp("2026-01-02", tz="UTC"), pd.Timestamp("2026-02-10", tz="UTC")) + assert eq.iloc[0] == 1000.0 and eq.iloc[-1] == pytest.approx(1045.0) + stats = risk_stats(eq) + assert stats["total_return"] == pytest.approx(0.045) + assert stats["max_drawdown"] == pytest.approx(10 / 1030) + ts = trade_stats(trades(), pd.Timestamp("2026-01-02", tz="UTC"), pd.Timestamp("2026-02-10", tz="UTC")) + assert ts["trades"] == 3 and ts["win_rate"] == pytest.approx(2 / 3) and ts["profit_factor"] == pytest.approx(5.5) + + +def test_tearsheet_contents(): + page = build_tearsheet(trades(), 1000.0, title="Test", source_label="unit test", mode="backtest") + assert "+4.5%" in page and "BACKTEST" in page and "solicitation or financial advice" in page + assert "1" in page + assert "+2.0%" in page # only BTC (+30) and ETH (-10) realised by month end + + +def test_empty_source_is_an_error(): + with pytest.raises(ValueError): + build_tearsheet(trades().iloc[0:0], 1000.0, title="t", source_label="s") diff --git a/tests/test_risk.py b/tests/test_risk.py new file mode 100644 index 0000000..c396d9c --- /dev/null +++ b/tests/test_risk.py @@ -0,0 +1,113 @@ +from datetime import datetime, timedelta, timezone + +import pytest + +from ridgeline.risk import ( + PortfolioState, + RiskLimits, + build_state, + cap_stake, + check_entry, + is_drawdown_breach, + load_halt, + set_halt, +) + +LIMITS = RiskLimits() # 35% position, 4.5% open risk, 3% daily loss, 15% drawdown + + +def state(equity=1000.0, peak=1000.0, day_start=1000.0, open_risk=0.0): + return PortfolioState(equity=equity, peak_equity=peak, day_start_equity=day_start, open_risk=open_risk) + + +def test_healthy_portfolio_may_enter(): + assert check_entry(state(), LIMITS) == (True, None) + + +@pytest.mark.parametrize("equity,allowed", [(851.0, True), (850.0, False), (800.0, False)]) +def test_drawdown_halt_boundary(equity, allowed): + ok, reason = check_entry(state(equity=equity, day_start=equity), LIMITS) + assert ok is allowed + if not allowed: + assert is_drawdown_breach(reason) + + +@pytest.mark.parametrize("equity,allowed", [(971.0, True), (970.0, False)]) +def test_daily_loss_boundary(equity, allowed): + ok, reason = check_entry(state(equity=equity), LIMITS) + assert ok is allowed + if not allowed: + assert "daily limit" in reason and not is_drawdown_breach(reason) + + +@pytest.mark.parametrize("open_risk,allowed", [(44.9, True), (45.0, False)]) +def test_open_risk_boundary(open_risk, allowed): + assert check_entry(state(open_risk=open_risk), LIMITS)[0] is allowed + + +def test_manual_halt_blocks_everything(): + ok, reason = check_entry(state(), LIMITS, halted=True) + assert not ok and "resume manually" in reason + + +def test_cap_stake_position_cap(): + stake, reason = cap_stake(600.0, 0.02, state(), LIMITS) + assert stake == pytest.approx(350.0) and "position cap" in reason + + +def test_cap_stake_open_risk_budget(): + # 40 of the 45 risk budget is used; 5 left at a 5% stop -> at most 100 stake + stake, reason = cap_stake(300.0, 0.05, state(open_risk=40.0), LIMITS) + assert stake == pytest.approx(100.0) and "open-risk budget" in reason + + +def test_cap_stake_leaves_small_stakes_alone(): + assert cap_stake(100.0, 0.05, state(), LIMITS) == (100.0, None) + + +def test_limits_from_config_override_and_ignore_unknown(): + limits = RiskLimits.from_config({"ridgeline_risk": {"daily_loss_limit_pct": "2", "nonsense": 1}}) + assert limits.daily_loss_limit_pct == 2.0 and limits.max_position_pct == 35.0 + + +def test_build_state_midnight_peak_and_open_risk(): + now = datetime(2026, 10, 11, 12, tzinfo=timezone.utc) + closed = [ + (now - timedelta(days=2), 100.0), # peak 1100 + (now - timedelta(days=1), -50.0), # 1050 at midnight + (now - timedelta(hours=3), -20.0), # today: 1030 + ] + open_positions = [ + (100.0, 95.0, 2.0, -4.0), # risk 10 + (50.0, 55.0, 1.0, 6.0), # stop above entry: risk 0 + ] + st = build_state(1000.0, closed, open_positions, now) + assert st.equity == pytest.approx(1032.0) + assert st.peak_equity == pytest.approx(1100.0) + assert st.day_start_equity == pytest.approx(1050.0) + assert st.open_risk == pytest.approx(10.0) + + +def test_build_state_daily_loss_resets_at_utc_midnight(): + loss_time = datetime(2026, 10, 10, 23, 30, tzinfo=timezone.utc) + before_midnight = build_state(1000.0, [(loss_time, -40.0)], [], loss_time + timedelta(minutes=10)) + after_midnight = build_state(1000.0, [(loss_time, -40.0)], [], loss_time + timedelta(hours=1)) + assert before_midnight.day_loss_pct() == pytest.approx(4.0) + assert after_midnight.day_loss_pct() == 0.0 + + +def test_peak_reset_after_reviewed_halt(): + t0 = datetime(2026, 1, 1, tzinfo=timezone.utc) + closed = [(t0, 200.0), (t0 + timedelta(days=1), -400.0)] # peak 1200, now 800 + assert build_state(1000.0, closed, [], t0 + timedelta(days=5)).drawdown_pct() == pytest.approx(33.33, 0.01) + reset = build_state(1000.0, closed, [], t0 + timedelta(days=5), peak_reset=t0 + timedelta(days=2)) + assert reset.drawdown_pct() == 0.0 + + +def test_halt_flag_roundtrip(tmp_path): + path = tmp_path / "risk_state.json" + assert load_halt(path) == {"halted": False} + set_halt(True, "drawdown", path) + assert load_halt(path)["halted"] is True + resumed = set_halt(False, "reviewed", path) + assert resumed["halted"] is False and "resumed_at" in load_halt(path) diff --git a/tests/test_strategies.py b/tests/test_strategies.py new file mode 100644 index 0000000..53bcfb2 --- /dev/null +++ b/tests/test_strategies.py @@ -0,0 +1,92 @@ +"""Strategies load, compute signals, never peek ahead, and respect risk limits.""" + +from datetime import datetime, timedelta, timezone + +import numpy as np +import pytest + +from ridgeline.risk import PortfolioState + +STRATEGIES = { + # name: (timeframe of the synthetic candles, informative columns to fake) + "TrendRider4h": ("4h", ["ema200_1d", "btc_close_1d", "btc_ema200_1d"]), + "EmaRsiMomentum": ("1h", ["ema_macro_trend_4h"]), + "TrendDipBuyer1h": ("1h", ["ema200_4h", "btc_close_1d", "btc_ema200_1d"]), +} + + +def with_informatives(df, columns): + """Fake the merged higher-timeframe columns causally (shifted, past data only).""" + df = df.copy() + slow = df["close"].rolling(300, min_periods=1).mean().shift(6) + for col in columns: + df[col] = df["close"].shift(6) if col == "btc_close_1d" else slow + return df + + +def signals(strat, df): + meta = {"pair": "TEST/USDT"} + out = strat.populate_indicators(df.copy(), meta) + out = strat.populate_entry_trend(out, meta) + out = strat.populate_exit_trend(out, meta) + return out[["enter_long", "exit_long"]].fillna(0).astype(int).to_numpy() + + +@pytest.mark.parametrize("name", STRATEGIES) +def test_strategy_produces_signals(strategy, ohlcv, name): + timeframe, informatives = STRATEGIES[name] + strat = strategy(name) + assert strat.timeframe == timeframe + df = with_informatives(ohlcv(n=3000, freq=timeframe), informatives) + sig = signals(strat, df) + assert sig.shape == (3000, 2) + assert set(np.unique(sig)) <= {0, 1} + + +@pytest.mark.parametrize("name", STRATEGIES) +def test_no_lookahead_prefix_invariance(strategy, ohlcv, name): + """Signals at bar t must not change when later bars are removed.""" + timeframe, informatives = STRATEGIES[name] + strat = strategy(name) + df = with_informatives(ohlcv(n=2500, freq=timeframe, seed=7), informatives) + full = signals(strat, df) + for cut in (1500, 2000, 2400): + assert np.array_equal(signals(strat, df.iloc[:cut].reset_index(drop=True)), full[:cut]), \ + f"{name}: signals before bar {cut} changed when later data was removed" + + +NOW = datetime(2026, 10, 11, 12, tzinfo=timezone.utc) + + +def fixed_state(monkeypatch, strat, **kw): + st = PortfolioState(**{"equity": 1000.0, "peak_equity": 1000.0, "day_start_equity": 1000.0, "open_risk": 0.0, **kw}) + monkeypatch.setattr(type(strat), "portfolio_state", lambda self, now: st) + + +def confirm(strat, now=NOW): + return strat.confirm_trade_entry("BTC/USDT", "limit", 1.0, 100.0, "GTC", now, None, "long") + + +def test_entry_blocked_by_daily_loss(strategy, monkeypatch): + strat = strategy("TrendRider4h") + fixed_state(monkeypatch, strat, equity=960.0) # -4% today + assert confirm(strat) is False + + +def test_drawdown_halt_and_backtest_review(strategy, monkeypatch): + strat = strategy("TrendRider4h") + fixed_state(monkeypatch, strat, equity=840.0, day_start_equity=840.0) # -16% from peak + assert confirm(strat) is False + # portfolio recovers, but the halt stands until the review period passes + fixed_state(monkeypatch, strat) + assert confirm(strat, NOW + timedelta(days=1)) is False + assert confirm(strat, NOW + timedelta(days=8)) is True + + +def test_stake_capped_and_skipped_below_minimum(strategy, monkeypatch): + strat = strategy("TrendRider4h") + fixed_state(monkeypatch, strat, open_risk=40.0) # 5 of the 45 risk budget left + assert strat.apply_risk_caps("BTC/USDT", 300.0, 0.05, NOW) == pytest.approx(100.0) + 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 diff --git a/tests/test_universe.py b/tests/test_universe.py new file mode 100644 index 0000000..4dadb19 --- /dev/null +++ b/tests/test_universe.py @@ -0,0 +1,50 @@ +"""Point-in-time pair selection must only use data that existed at the time.""" + +import numpy as np +import pandas as pd +import pytest + +import research + + +def write_daily(datadir, pair, start, end, price, volume): + dates = pd.date_range(start, end, freq="D", tz="UTC") + price = np.broadcast_to(np.asarray(price, dtype=float), len(dates)) if np.ndim(price) == 0 else np.asarray(price) + volume = np.broadcast_to(np.asarray(volume, dtype=float), len(dates)) if np.ndim(volume) == 0 else np.asarray(volume) + df = pd.DataFrame({"date": dates, "open": price, "high": price, "low": price, "close": price, "volume": volume}) + df.to_feather(datadir / f"{pair}_USDT-1d.feather") + + +@pytest.fixture +def datadir(tmp_path): + research.universe_at.cache_clear() + d = tmp_path / "data" + d.mkdir() + write_daily(d, "BIG", "2022-01-01", "2024-12-31", 10.0, 1e6) + # tiny volume before 2024-06-01, enormous after: a pump in the "future" + dates = pd.date_range("2022-01-01", "2024-12-31", freq="D", tz="UTC") + write_daily(d, "PUMP", "2022-01-01", "2024-12-31", 5.0, np.where(dates < "2024-06-01", 10.0, 1e12)) + write_daily(d, "STABLEX", "2022-01-01", "2024-12-31", 1.0005, 1e9) # stablecoin by behaviour + write_daily(d, "USDC", "2022-01-01", "2024-12-31", 1.0, 1e9) # stablecoin by name + write_daily(d, "NEW", "2024-05-01", "2024-12-31", 3.0, 1e9) # listed too recently + write_daily(d, "DEAD", "2022-01-01", "2024-03-01", 2.0, 1e9) # stopped trading + return d + + +def test_ranks_on_past_volume_only(datadir): + assert research.universe_at("2024-06-01", n=1, datadir=datadir) == ("BIG/USDT",) + + +def test_future_pump_does_not_leak(datadir): + universe = research.universe_at("2024-06-01", n=10, datadir=datadir) + assert universe == ("BIG/USDT", "PUMP/USDT") # PUMP is eligible but ranked on its small past volume + later = research.universe_at("2024-09-01", n=1, datadir=datadir) + assert later == ("PUMP/USDT",) # once the volume is in the past, it counts + + +def test_filters(datadir): + universe = set(research.universe_at("2024-06-01", n=10, datadir=datadir)) + assert "STABLEX/USDT" not in universe # price pinned at $1 + 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 diff --git a/user_data/research/latest.json b/user_data/research/latest.json index 25e52ce..bd69fa7 100644 --- a/user_data/research/latest.json +++ b/user_data/research/latest.json @@ -1,5 +1,5 @@ { - "generated": "2026-10-10T13:44:53+00:00", + "generated": "2026-10-10T14:06:21+00:00", "periods": { "is": [ "2023-06-01", @@ -85,24 +85,24 @@ "start": "2023-06-01 00:00:00", "end": "2025-06-01 00:00:00", "starting_balance": 1000.0, - "final_balance": 1135.72, - "profit_pct": 13.57, + "final_balance": 1077.2, + "profit_pct": 7.72, "market_change_pct": 85.52, - "max_drawdown_pct": 19.78, - "profit_factor": 1.11, + "max_drawdown_pct": 21.55, + "profit_factor": 1.07, "winrate_pct": 52.9, - "trades": 191, - "calmar": 1.79, - "sharpe": 0.21, - "cagr_pct": 6.56, + "trades": 189, + "calmar": 0.94, + "sharpe": 0.14, + "cagr_pct": 3.78, "exit_reasons": { "roi": { - "count": 111, - "profit_abs": 1353.33 + "count": 110, + "profit_abs": 1132.39 }, "trailing_stop_loss": { - "count": 80, - "profit_abs": -1217.6 + "count": 79, + "profit_abs": -1055.18 } } }, @@ -112,24 +112,24 @@ "start": "2025-06-01 00:00:00", "end": "2026-10-10 00:00:00", "starting_balance": 1000.0, - "final_balance": 1080.26, - "profit_pct": 8.03, + "final_balance": 1051.15, + "profit_pct": 5.12, "market_change_pct": -31.67, - "max_drawdown_pct": 16.47, - "profit_factor": 1.18, - "winrate_pct": 58.4, - "trades": 77, - "calmar": 1.88, - "sharpe": 0.22, - "cagr_pct": 5.85, + "max_drawdown_pct": 18.13, + "profit_factor": 1.13, + "winrate_pct": 58.1, + "trades": 74, + "calmar": 1.09, + "sharpe": 0.15, + "cagr_pct": 3.74, "exit_reasons": { "roi": { - "count": 47, - "profit_abs": 516.4 + "count": 43, + "profit_abs": 444.39 }, "trailing_stop_loss": { - "count": 30, - "profit_abs": -436.14 + "count": 31, + "profit_abs": -393.24 } } }, @@ -139,24 +139,24 @@ "start": "2025-06-01 00:00:00", "end": "2026-10-10 00:00:00", "starting_balance": 1000.0, - "final_balance": 1057.51, - "profit_pct": 5.75, + "final_balance": 1031.29, + "profit_pct": 3.13, "market_change_pct": -31.67, - "max_drawdown_pct": 16.89, - "profit_factor": 1.13, - "winrate_pct": 54.5, - "trades": 77, - "calmar": 1.31, - "sharpe": 0.16, - "cagr_pct": 4.2, + "max_drawdown_pct": 18.38, + "profit_factor": 1.08, + "winrate_pct": 52.7, + "trades": 74, + "calmar": 0.66, + "sharpe": 0.09, + "cagr_pct": 2.29, "exit_reasons": { "roi": { - "count": 47, - "profit_abs": 492.5 + "count": 43, + "profit_abs": 423.81 }, "trailing_stop_loss": { - "count": 30, - "profit_abs": -434.99 + "count": 31, + "profit_abs": -392.52 } } }, @@ -164,91 +164,91 @@ "windows": [ { "window": "2023-06-01 -> 2023-09-01", - "profit_pct": -4.65, - "trades": 17, + "profit_pct": -5.74, + "trades": 18, "market_change_pct": -20.06 }, { "window": "2023-09-01 -> 2023-12-01", - "profit_pct": 8.18, + "profit_pct": 6.3, "trades": 23, "market_change_pct": 51.5 }, { "window": "2023-12-01 -> 2024-03-01", - "profit_pct": 4.88, + "profit_pct": 9.01, "trades": 47, "market_change_pct": 57.02 }, { "window": "2024-03-01 -> 2024-06-01", - "profit_pct": -1.42, - "trades": 31, + "profit_pct": -7.08, + "trades": 23, "market_change_pct": 20.51 }, { "window": "2024-06-01 -> 2024-09-01", - "profit_pct": -9.83, + "profit_pct": -8.48, "trades": 17, "market_change_pct": -37.21 }, { "window": "2024-09-01 -> 2024-12-01", - "profit_pct": 2.32, + "profit_pct": 1.33, "trades": 22, "market_change_pct": 97.44 }, { "window": "2024-12-01 -> 2025-03-01", - "profit_pct": -2.14, + "profit_pct": -2.33, "trades": 14, "market_change_pct": -42.39 }, { "window": "2025-03-01 -> 2025-06-01", - "profit_pct": -3.45, - "trades": 17, + "profit_pct": -1.88, + "trades": 18, "market_change_pct": 5.71 }, { "window": "2025-06-01 -> 2025-09-01", - "profit_pct": 4.63, + "profit_pct": 4.77, "trades": 20, "market_change_pct": 19.44 }, { "window": "2025-09-01 -> 2025-12-01", - "profit_pct": -3.5, + "profit_pct": -2.48, "trades": 13, "market_change_pct": -42.63 }, { "window": "2025-12-01 -> 2026-03-01", - "profit_pct": -8.06, + "profit_pct": -7.78, "trades": 7, "market_change_pct": -33.34 }, { "window": "2026-03-01 -> 2026-06-01", - "profit_pct": -4.88, - "trades": 14, + "profit_pct": -6.09, + "trades": 16, "market_change_pct": 7.44 }, { "window": "2026-06-01 -> 2026-09-01", - "profit_pct": 1.66, + "profit_pct": 1.73, "trades": 10, "market_change_pct": -0.87 }, { "window": "2026-09-01 -> 2026-10-10", - "profit_pct": 14.87, + "profit_pct": 13.76, "trades": 15, "market_change_pct": 19.98 } ], "pct_profitable": 42.9, - "worst_pct": -9.83 + "worst_pct": -8.48 }, "bias": { "lookahead_clean": true, @@ -259,27 +259,27 @@ "gates": [ { "label": "OOS profit > 0%", - "value": 8.03, + "value": 5.12, "pass": true }, { "label": "OOS profit factor >= 1.3", - "value": 1.18, + "value": 1.13, "pass": false }, { "label": "OOS max drawdown <= 20%", - "value": 16.47, + "value": 18.13, "pass": true }, { "label": "OOS Calmar >= 1.0", - "value": 1.88, + "value": 1.09, "pass": true }, { "label": "OOS trades >= 100", - "value": 77, + "value": 74, "pass": false }, { @@ -289,7 +289,7 @@ }, { "label": "OOS still profitable with +50% fees", - "value": 5.75, + "value": 3.13, "pass": true }, { @@ -312,28 +312,28 @@ "start": "2023-06-01 00:00:00", "end": "2025-06-01 00:00:00", "starting_balance": 1000.0, - "final_balance": 793.35, - "profit_pct": -20.67, + "final_balance": 920.32, + "profit_pct": -7.97, "market_change_pct": 85.52, - "max_drawdown_pct": 33.17, - "profit_factor": 0.91, - "winrate_pct": 64.4, - "trades": 982, - "calmar": -1.63, - "sharpe": -0.91, - "cagr_pct": -10.92, + "max_drawdown_pct": 26.4, + "profit_factor": 0.96, + "winrate_pct": 64.3, + "trades": 932, + "calmar": -0.79, + "sharpe": -0.34, + "cagr_pct": -4.06, "exit_reasons": { "exit_signal": { - "count": 774, - "profit_abs": 845.62 + "count": 732, + "profit_abs": 867.7 }, "roi": { - "count": 95, - "profit_abs": 520.3 + "count": 92, + "profit_abs": 550.15 }, "trailing_stop_loss": { - "count": 113, - "profit_abs": -1572.57 + "count": 108, + "profit_abs": -1497.52 } } }, @@ -343,28 +343,28 @@ "start": "2025-06-01 00:00:00", "end": "2026-10-10 00:00:00", "starting_balance": 1000.0, - "final_balance": 823.2, - "profit_pct": -17.68, + "final_balance": 821.78, + "profit_pct": -17.82, "market_change_pct": -31.67, - "max_drawdown_pct": 19.46, - "profit_factor": 0.75, - "winrate_pct": 62.5, - "trades": 309, - "calmar": -3.5, - "sharpe": -1.25, - "cagr_pct": -13.34, + "max_drawdown_pct": 19.23, + "profit_factor": 0.73, + "winrate_pct": 62.7, + "trades": 300, + "calmar": -3.57, + "sharpe": -1.29, + "cagr_pct": -13.45, "exit_reasons": { "roi": { - "count": 28, - "profit_abs": 142.89 + "count": 25, + "profit_abs": 125.05 }, "exit_signal": { - "count": 248, - "profit_abs": 111.8 + "count": 242, + "profit_abs": 121.05 }, "trailing_stop_loss": { "count": 33, - "profit_abs": -431.49 + "profit_abs": -424.31 } } }, @@ -374,28 +374,28 @@ "start": "2025-06-01 00:00:00", "end": "2026-10-10 00:00:00", "starting_balance": 1000.0, - "final_balance": 755.93, - "profit_pct": -24.41, + "final_balance": 752.6, + "profit_pct": -24.74, "market_change_pct": -31.67, - "max_drawdown_pct": 25.75, - "profit_factor": 0.65, - "winrate_pct": 58.6, - "trades": 309, - "calmar": -3.65, - "sharpe": -1.8, - "cagr_pct": -18.61, + "max_drawdown_pct": 25.85, + "profit_factor": 0.62, + "winrate_pct": 59.2, + "trades": 284, + "calmar": -3.69, + "sharpe": -1.83, + "cagr_pct": -18.87, "exit_reasons": { "roi": { - "count": 25, - "profit_abs": 126.1 + "count": 22, + "profit_abs": 108.34 }, "exit_signal": { - "count": 250, - "profit_abs": 58.09 + "count": 228, + "profit_abs": 65.49 }, "trailing_stop_loss": { "count": 34, - "profit_abs": -428.25 + "profit_abs": -421.23 } } }, @@ -403,62 +403,62 @@ "windows": [ { "window": "2023-06-01 -> 2023-09-01", - "profit_pct": -9.01, - "trades": 115, + "profit_pct": -7.65, + "trades": 116, "market_change_pct": -20.06 }, { "window": "2023-09-01 -> 2023-12-01", - "profit_pct": 2.5, - "trades": 118, + "profit_pct": 3.35, + "trades": 119, "market_change_pct": 51.5 }, { "window": "2023-12-01 -> 2024-03-01", - "profit_pct": 5.99, - "trades": 172, + "profit_pct": 7.23, + "trades": 171, "market_change_pct": 57.02 }, { "window": "2024-03-01 -> 2024-06-01", - "profit_pct": -2.65, - "trades": 136, + "profit_pct": -0.33, + "trades": 135, "market_change_pct": 20.51 }, { "window": "2024-06-01 -> 2024-09-01", - "profit_pct": -5.46, - "trades": 78, + "profit_pct": -5.66, + "trades": 79, "market_change_pct": -37.21 }, { "window": "2024-09-01 -> 2024-12-01", - "profit_pct": 6.54, + "profit_pct": 7.37, "trades": 140, "market_change_pct": 97.44 }, { "window": "2024-12-01 -> 2025-03-01", - "profit_pct": 5.84, + "profit_pct": 6.81, "trades": 116, "market_change_pct": -42.39 }, { "window": "2025-03-01 -> 2025-06-01", - "profit_pct": -3.26, - "trades": 94, + "profit_pct": -2.36, + "trades": 93, "market_change_pct": 5.71 }, { "window": "2025-06-01 -> 2025-09-01", - "profit_pct": -8.02, - "trades": 139, + "profit_pct": -8.82, + "trades": 135, "market_change_pct": 19.44 }, { "window": "2025-09-01 -> 2025-12-01", - "profit_pct": -4.9, - "trades": 72, + "profit_pct": -4.94, + "trades": 74, "market_change_pct": -42.63 }, { @@ -475,19 +475,19 @@ }, { "window": "2026-06-01 -> 2026-09-01", - "profit_pct": -1.31, + "profit_pct": -0.78, "trades": 18, "market_change_pct": -0.87 }, { "window": "2026-09-01 -> 2026-10-10", - "profit_pct": -0.93, - "trades": 87, + "profit_pct": -0.13, + "trades": 86, "market_change_pct": 19.98 } ], "pct_profitable": 35.7, - "worst_pct": -9.01 + "worst_pct": -8.82 }, "bias": { "lookahead_clean": true, @@ -498,27 +498,27 @@ "gates": [ { "label": "OOS profit > 0%", - "value": -17.68, + "value": -17.82, "pass": false }, { "label": "OOS profit factor >= 1.3", - "value": 0.75, + "value": 0.73, "pass": false }, { "label": "OOS max drawdown <= 20%", - "value": 19.46, + "value": 19.23, "pass": true }, { "label": "OOS Calmar >= 1.0", - "value": -3.5, + "value": -3.57, "pass": false }, { "label": "OOS trades >= 100", - "value": 309, + "value": 300, "pass": true }, { @@ -528,7 +528,7 @@ }, { "label": "OOS still profitable with +50% fees", - "value": -24.41, + "value": -24.74, "pass": false }, { @@ -551,28 +551,28 @@ "start": "2023-06-01 00:00:00", "end": "2025-06-01 00:00:00", "starting_balance": 1000.0, - "final_balance": 1107.95, - "profit_pct": 10.79, + "final_balance": 1056.11, + "profit_pct": 5.61, "market_change_pct": 85.76, - "max_drawdown_pct": 16.76, - "profit_factor": 1.1, - "winrate_pct": 34.3, - "trades": 280, - "calmar": 1.68, - "sharpe": 0.19, - "cagr_pct": 5.25, + "max_drawdown_pct": 15.21, + "profit_factor": 1.05, + "winrate_pct": 35.8, + "trades": 274, + "calmar": 0.96, + "sharpe": 0.11, + "cagr_pct": 2.76, "exit_reasons": { "exit_signal": { - "count": 248, - "profit_abs": 189.65 + "count": 242, + "profit_abs": 189.2 }, "trailing_stop_loss": { "count": 26, - "profit_abs": 13.87 + "profit_abs": -39.65 }, "stop_loss": { "count": 6, - "profit_abs": -95.57 + "profit_abs": -93.45 } } }, @@ -582,24 +582,24 @@ "start": "2025-06-01 00:00:00", "end": "2026-10-10 00:00:00", "starting_balance": 1000.0, - "final_balance": 1193.0, - "profit_pct": 19.3, + "final_balance": 1173.08, + "profit_pct": 17.31, "market_change_pct": -31.1, - "max_drawdown_pct": 9.81, - "profit_factor": 1.47, + "max_drawdown_pct": 9.79, + "profit_factor": 1.43, "winrate_pct": 34.3, "trades": 99, - "calmar": 7.58, - "sharpe": 0.51, - "cagr_pct": 13.87, + "calmar": 6.81, + "sharpe": 0.48, + "cagr_pct": 12.47, "exit_reasons": { "exit_signal": { "count": 93, - "profit_abs": 239.21 + "profit_abs": 219.29 }, "trailing_stop_loss": { "count": 6, - "profit_abs": -46.21 + "profit_abs": -46.2 } } }, @@ -609,24 +609,24 @@ "start": "2025-06-01 00:00:00", "end": "2026-10-10 00:00:00", "starting_balance": 1000.0, - "final_balance": 1172.52, - "profit_pct": 17.25, + "final_balance": 1153.88, + "profit_pct": 15.39, "market_change_pct": -31.1, - "max_drawdown_pct": 10.02, - "profit_factor": 1.41, + "max_drawdown_pct": 10.0, + "profit_factor": 1.37, "winrate_pct": 34.3, "trades": 99, - "calmar": 6.63, - "sharpe": 0.46, - "cagr_pct": 12.43, + "calmar": 5.93, + "sharpe": 0.43, + "cagr_pct": 11.11, "exit_reasons": { "exit_signal": { "count": 93, - "profit_abs": 219.66 + "profit_abs": 200.98 }, "trailing_stop_loss": { "count": 6, - "profit_abs": -47.14 + "profit_abs": -47.1 } } }, @@ -640,7 +640,7 @@ }, { "window": "2023-09-01 -> 2023-12-01", - "profit_pct": 1.29, + "profit_pct": 0.67, "trades": 34, "market_change_pct": 54.45 }, @@ -652,7 +652,7 @@ }, { "window": "2024-03-01 -> 2024-06-01", - "profit_pct": 2.16, + "profit_pct": 2.15, "trades": 47, "market_change_pct": 21.27 }, @@ -664,7 +664,7 @@ }, { "window": "2024-09-01 -> 2024-12-01", - "profit_pct": 30.29, + "profit_pct": 30.74, "trades": 43, "market_change_pct": 97.89 }, @@ -682,13 +682,13 @@ }, { "window": "2025-06-01 -> 2025-09-01", - "profit_pct": 5.73, + "profit_pct": 5.37, "trades": 46, "market_change_pct": 18.99 }, { "window": "2025-09-01 -> 2025-12-01", - "profit_pct": 0.7, + "profit_pct": -0.92, "trades": 27, "market_change_pct": -42.56 }, @@ -700,7 +700,7 @@ }, { "window": "2026-03-01 -> 2026-06-01", - "profit_pct": -0.88, + "profit_pct": -0.86, "trades": 1, "market_change_pct": 5.11 }, @@ -712,12 +712,12 @@ }, { "window": "2026-09-01 -> 2026-10-10", - "profit_pct": 0.02, + "profit_pct": 0.2, "trades": 21, "market_change_pct": 20.38 } ], - "pct_profitable": 71.4, + "pct_profitable": 64.3, "worst_pct": -6.98 }, "bias": { @@ -729,22 +729,22 @@ "gates": [ { "label": "OOS profit > 0%", - "value": 19.3, + "value": 17.31, "pass": true }, { "label": "OOS profit factor >= 1.3", - "value": 1.47, + "value": 1.43, "pass": true }, { "label": "OOS max drawdown <= 20%", - "value": 9.81, + "value": 9.79, "pass": true }, { "label": "OOS Calmar >= 1.0", - "value": 7.58, + "value": 6.81, "pass": true }, { @@ -754,12 +754,12 @@ }, { "label": "Profitable in >= 60% of walk-forward windows", - "value": 71.4, + "value": 64.3, "pass": true }, { "label": "OOS still profitable with +50% fees", - "value": 17.25, + "value": 15.39, "pass": true }, { diff --git a/user_data/research/report-2026-10-11.json b/user_data/research/report-2026-10-11.json index 25e52ce..bd69fa7 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-10T13:44:53+00:00", + "generated": "2026-10-10T14:06:21+00:00", "periods": { "is": [ "2023-06-01", @@ -85,24 +85,24 @@ "start": "2023-06-01 00:00:00", "end": "2025-06-01 00:00:00", "starting_balance": 1000.0, - "final_balance": 1135.72, - "profit_pct": 13.57, + "final_balance": 1077.2, + "profit_pct": 7.72, "market_change_pct": 85.52, - "max_drawdown_pct": 19.78, - "profit_factor": 1.11, + "max_drawdown_pct": 21.55, + "profit_factor": 1.07, "winrate_pct": 52.9, - "trades": 191, - "calmar": 1.79, - "sharpe": 0.21, - "cagr_pct": 6.56, + "trades": 189, + "calmar": 0.94, + "sharpe": 0.14, + "cagr_pct": 3.78, "exit_reasons": { "roi": { - "count": 111, - "profit_abs": 1353.33 + "count": 110, + "profit_abs": 1132.39 }, "trailing_stop_loss": { - "count": 80, - "profit_abs": -1217.6 + "count": 79, + "profit_abs": -1055.18 } } }, @@ -112,24 +112,24 @@ "start": "2025-06-01 00:00:00", "end": "2026-10-10 00:00:00", "starting_balance": 1000.0, - "final_balance": 1080.26, - "profit_pct": 8.03, + "final_balance": 1051.15, + "profit_pct": 5.12, "market_change_pct": -31.67, - "max_drawdown_pct": 16.47, - "profit_factor": 1.18, - "winrate_pct": 58.4, - "trades": 77, - "calmar": 1.88, - "sharpe": 0.22, - "cagr_pct": 5.85, + "max_drawdown_pct": 18.13, + "profit_factor": 1.13, + "winrate_pct": 58.1, + "trades": 74, + "calmar": 1.09, + "sharpe": 0.15, + "cagr_pct": 3.74, "exit_reasons": { "roi": { - "count": 47, - "profit_abs": 516.4 + "count": 43, + "profit_abs": 444.39 }, "trailing_stop_loss": { - "count": 30, - "profit_abs": -436.14 + "count": 31, + "profit_abs": -393.24 } } }, @@ -139,24 +139,24 @@ "start": "2025-06-01 00:00:00", "end": "2026-10-10 00:00:00", "starting_balance": 1000.0, - "final_balance": 1057.51, - "profit_pct": 5.75, + "final_balance": 1031.29, + "profit_pct": 3.13, "market_change_pct": -31.67, - "max_drawdown_pct": 16.89, - "profit_factor": 1.13, - "winrate_pct": 54.5, - "trades": 77, - "calmar": 1.31, - "sharpe": 0.16, - "cagr_pct": 4.2, + "max_drawdown_pct": 18.38, + "profit_factor": 1.08, + "winrate_pct": 52.7, + "trades": 74, + "calmar": 0.66, + "sharpe": 0.09, + "cagr_pct": 2.29, "exit_reasons": { "roi": { - "count": 47, - "profit_abs": 492.5 + "count": 43, + "profit_abs": 423.81 }, "trailing_stop_loss": { - "count": 30, - "profit_abs": -434.99 + "count": 31, + "profit_abs": -392.52 } } }, @@ -164,91 +164,91 @@ "windows": [ { "window": "2023-06-01 -> 2023-09-01", - "profit_pct": -4.65, - "trades": 17, + "profit_pct": -5.74, + "trades": 18, "market_change_pct": -20.06 }, { "window": "2023-09-01 -> 2023-12-01", - "profit_pct": 8.18, + "profit_pct": 6.3, "trades": 23, "market_change_pct": 51.5 }, { "window": "2023-12-01 -> 2024-03-01", - "profit_pct": 4.88, + "profit_pct": 9.01, "trades": 47, "market_change_pct": 57.02 }, { "window": "2024-03-01 -> 2024-06-01", - "profit_pct": -1.42, - "trades": 31, + "profit_pct": -7.08, + "trades": 23, "market_change_pct": 20.51 }, { "window": "2024-06-01 -> 2024-09-01", - "profit_pct": -9.83, + "profit_pct": -8.48, "trades": 17, "market_change_pct": -37.21 }, { "window": "2024-09-01 -> 2024-12-01", - "profit_pct": 2.32, + "profit_pct": 1.33, "trades": 22, "market_change_pct": 97.44 }, { "window": "2024-12-01 -> 2025-03-01", - "profit_pct": -2.14, + "profit_pct": -2.33, "trades": 14, "market_change_pct": -42.39 }, { "window": "2025-03-01 -> 2025-06-01", - "profit_pct": -3.45, - "trades": 17, + "profit_pct": -1.88, + "trades": 18, "market_change_pct": 5.71 }, { "window": "2025-06-01 -> 2025-09-01", - "profit_pct": 4.63, + "profit_pct": 4.77, "trades": 20, "market_change_pct": 19.44 }, { "window": "2025-09-01 -> 2025-12-01", - "profit_pct": -3.5, + "profit_pct": -2.48, "trades": 13, "market_change_pct": -42.63 }, { "window": "2025-12-01 -> 2026-03-01", - "profit_pct": -8.06, + "profit_pct": -7.78, "trades": 7, "market_change_pct": -33.34 }, { "window": "2026-03-01 -> 2026-06-01", - "profit_pct": -4.88, - "trades": 14, + "profit_pct": -6.09, + "trades": 16, "market_change_pct": 7.44 }, { "window": "2026-06-01 -> 2026-09-01", - "profit_pct": 1.66, + "profit_pct": 1.73, "trades": 10, "market_change_pct": -0.87 }, { "window": "2026-09-01 -> 2026-10-10", - "profit_pct": 14.87, + "profit_pct": 13.76, "trades": 15, "market_change_pct": 19.98 } ], "pct_profitable": 42.9, - "worst_pct": -9.83 + "worst_pct": -8.48 }, "bias": { "lookahead_clean": true, @@ -259,27 +259,27 @@ "gates": [ { "label": "OOS profit > 0%", - "value": 8.03, + "value": 5.12, "pass": true }, { "label": "OOS profit factor >= 1.3", - "value": 1.18, + "value": 1.13, "pass": false }, { "label": "OOS max drawdown <= 20%", - "value": 16.47, + "value": 18.13, "pass": true }, { "label": "OOS Calmar >= 1.0", - "value": 1.88, + "value": 1.09, "pass": true }, { "label": "OOS trades >= 100", - "value": 77, + "value": 74, "pass": false }, { @@ -289,7 +289,7 @@ }, { "label": "OOS still profitable with +50% fees", - "value": 5.75, + "value": 3.13, "pass": true }, { @@ -312,28 +312,28 @@ "start": "2023-06-01 00:00:00", "end": "2025-06-01 00:00:00", "starting_balance": 1000.0, - "final_balance": 793.35, - "profit_pct": -20.67, + "final_balance": 920.32, + "profit_pct": -7.97, "market_change_pct": 85.52, - "max_drawdown_pct": 33.17, - "profit_factor": 0.91, - "winrate_pct": 64.4, - "trades": 982, - "calmar": -1.63, - "sharpe": -0.91, - "cagr_pct": -10.92, + "max_drawdown_pct": 26.4, + "profit_factor": 0.96, + "winrate_pct": 64.3, + "trades": 932, + "calmar": -0.79, + "sharpe": -0.34, + "cagr_pct": -4.06, "exit_reasons": { "exit_signal": { - "count": 774, - "profit_abs": 845.62 + "count": 732, + "profit_abs": 867.7 }, "roi": { - "count": 95, - "profit_abs": 520.3 + "count": 92, + "profit_abs": 550.15 }, "trailing_stop_loss": { - "count": 113, - "profit_abs": -1572.57 + "count": 108, + "profit_abs": -1497.52 } } }, @@ -343,28 +343,28 @@ "start": "2025-06-01 00:00:00", "end": "2026-10-10 00:00:00", "starting_balance": 1000.0, - "final_balance": 823.2, - "profit_pct": -17.68, + "final_balance": 821.78, + "profit_pct": -17.82, "market_change_pct": -31.67, - "max_drawdown_pct": 19.46, - "profit_factor": 0.75, - "winrate_pct": 62.5, - "trades": 309, - "calmar": -3.5, - "sharpe": -1.25, - "cagr_pct": -13.34, + "max_drawdown_pct": 19.23, + "profit_factor": 0.73, + "winrate_pct": 62.7, + "trades": 300, + "calmar": -3.57, + "sharpe": -1.29, + "cagr_pct": -13.45, "exit_reasons": { "roi": { - "count": 28, - "profit_abs": 142.89 + "count": 25, + "profit_abs": 125.05 }, "exit_signal": { - "count": 248, - "profit_abs": 111.8 + "count": 242, + "profit_abs": 121.05 }, "trailing_stop_loss": { "count": 33, - "profit_abs": -431.49 + "profit_abs": -424.31 } } }, @@ -374,28 +374,28 @@ "start": "2025-06-01 00:00:00", "end": "2026-10-10 00:00:00", "starting_balance": 1000.0, - "final_balance": 755.93, - "profit_pct": -24.41, + "final_balance": 752.6, + "profit_pct": -24.74, "market_change_pct": -31.67, - "max_drawdown_pct": 25.75, - "profit_factor": 0.65, - "winrate_pct": 58.6, - "trades": 309, - "calmar": -3.65, - "sharpe": -1.8, - "cagr_pct": -18.61, + "max_drawdown_pct": 25.85, + "profit_factor": 0.62, + "winrate_pct": 59.2, + "trades": 284, + "calmar": -3.69, + "sharpe": -1.83, + "cagr_pct": -18.87, "exit_reasons": { "roi": { - "count": 25, - "profit_abs": 126.1 + "count": 22, + "profit_abs": 108.34 }, "exit_signal": { - "count": 250, - "profit_abs": 58.09 + "count": 228, + "profit_abs": 65.49 }, "trailing_stop_loss": { "count": 34, - "profit_abs": -428.25 + "profit_abs": -421.23 } } }, @@ -403,62 +403,62 @@ "windows": [ { "window": "2023-06-01 -> 2023-09-01", - "profit_pct": -9.01, - "trades": 115, + "profit_pct": -7.65, + "trades": 116, "market_change_pct": -20.06 }, { "window": "2023-09-01 -> 2023-12-01", - "profit_pct": 2.5, - "trades": 118, + "profit_pct": 3.35, + "trades": 119, "market_change_pct": 51.5 }, { "window": "2023-12-01 -> 2024-03-01", - "profit_pct": 5.99, - "trades": 172, + "profit_pct": 7.23, + "trades": 171, "market_change_pct": 57.02 }, { "window": "2024-03-01 -> 2024-06-01", - "profit_pct": -2.65, - "trades": 136, + "profit_pct": -0.33, + "trades": 135, "market_change_pct": 20.51 }, { "window": "2024-06-01 -> 2024-09-01", - "profit_pct": -5.46, - "trades": 78, + "profit_pct": -5.66, + "trades": 79, "market_change_pct": -37.21 }, { "window": "2024-09-01 -> 2024-12-01", - "profit_pct": 6.54, + "profit_pct": 7.37, "trades": 140, "market_change_pct": 97.44 }, { "window": "2024-12-01 -> 2025-03-01", - "profit_pct": 5.84, + "profit_pct": 6.81, "trades": 116, "market_change_pct": -42.39 }, { "window": "2025-03-01 -> 2025-06-01", - "profit_pct": -3.26, - "trades": 94, + "profit_pct": -2.36, + "trades": 93, "market_change_pct": 5.71 }, { "window": "2025-06-01 -> 2025-09-01", - "profit_pct": -8.02, - "trades": 139, + "profit_pct": -8.82, + "trades": 135, "market_change_pct": 19.44 }, { "window": "2025-09-01 -> 2025-12-01", - "profit_pct": -4.9, - "trades": 72, + "profit_pct": -4.94, + "trades": 74, "market_change_pct": -42.63 }, { @@ -475,19 +475,19 @@ }, { "window": "2026-06-01 -> 2026-09-01", - "profit_pct": -1.31, + "profit_pct": -0.78, "trades": 18, "market_change_pct": -0.87 }, { "window": "2026-09-01 -> 2026-10-10", - "profit_pct": -0.93, - "trades": 87, + "profit_pct": -0.13, + "trades": 86, "market_change_pct": 19.98 } ], "pct_profitable": 35.7, - "worst_pct": -9.01 + "worst_pct": -8.82 }, "bias": { "lookahead_clean": true, @@ -498,27 +498,27 @@ "gates": [ { "label": "OOS profit > 0%", - "value": -17.68, + "value": -17.82, "pass": false }, { "label": "OOS profit factor >= 1.3", - "value": 0.75, + "value": 0.73, "pass": false }, { "label": "OOS max drawdown <= 20%", - "value": 19.46, + "value": 19.23, "pass": true }, { "label": "OOS Calmar >= 1.0", - "value": -3.5, + "value": -3.57, "pass": false }, { "label": "OOS trades >= 100", - "value": 309, + "value": 300, "pass": true }, { @@ -528,7 +528,7 @@ }, { "label": "OOS still profitable with +50% fees", - "value": -24.41, + "value": -24.74, "pass": false }, { @@ -551,28 +551,28 @@ "start": "2023-06-01 00:00:00", "end": "2025-06-01 00:00:00", "starting_balance": 1000.0, - "final_balance": 1107.95, - "profit_pct": 10.79, + "final_balance": 1056.11, + "profit_pct": 5.61, "market_change_pct": 85.76, - "max_drawdown_pct": 16.76, - "profit_factor": 1.1, - "winrate_pct": 34.3, - "trades": 280, - "calmar": 1.68, - "sharpe": 0.19, - "cagr_pct": 5.25, + "max_drawdown_pct": 15.21, + "profit_factor": 1.05, + "winrate_pct": 35.8, + "trades": 274, + "calmar": 0.96, + "sharpe": 0.11, + "cagr_pct": 2.76, "exit_reasons": { "exit_signal": { - "count": 248, - "profit_abs": 189.65 + "count": 242, + "profit_abs": 189.2 }, "trailing_stop_loss": { "count": 26, - "profit_abs": 13.87 + "profit_abs": -39.65 }, "stop_loss": { "count": 6, - "profit_abs": -95.57 + "profit_abs": -93.45 } } }, @@ -582,24 +582,24 @@ "start": "2025-06-01 00:00:00", "end": "2026-10-10 00:00:00", "starting_balance": 1000.0, - "final_balance": 1193.0, - "profit_pct": 19.3, + "final_balance": 1173.08, + "profit_pct": 17.31, "market_change_pct": -31.1, - "max_drawdown_pct": 9.81, - "profit_factor": 1.47, + "max_drawdown_pct": 9.79, + "profit_factor": 1.43, "winrate_pct": 34.3, "trades": 99, - "calmar": 7.58, - "sharpe": 0.51, - "cagr_pct": 13.87, + "calmar": 6.81, + "sharpe": 0.48, + "cagr_pct": 12.47, "exit_reasons": { "exit_signal": { "count": 93, - "profit_abs": 239.21 + "profit_abs": 219.29 }, "trailing_stop_loss": { "count": 6, - "profit_abs": -46.21 + "profit_abs": -46.2 } } }, @@ -609,24 +609,24 @@ "start": "2025-06-01 00:00:00", "end": "2026-10-10 00:00:00", "starting_balance": 1000.0, - "final_balance": 1172.52, - "profit_pct": 17.25, + "final_balance": 1153.88, + "profit_pct": 15.39, "market_change_pct": -31.1, - "max_drawdown_pct": 10.02, - "profit_factor": 1.41, + "max_drawdown_pct": 10.0, + "profit_factor": 1.37, "winrate_pct": 34.3, "trades": 99, - "calmar": 6.63, - "sharpe": 0.46, - "cagr_pct": 12.43, + "calmar": 5.93, + "sharpe": 0.43, + "cagr_pct": 11.11, "exit_reasons": { "exit_signal": { "count": 93, - "profit_abs": 219.66 + "profit_abs": 200.98 }, "trailing_stop_loss": { "count": 6, - "profit_abs": -47.14 + "profit_abs": -47.1 } } }, @@ -640,7 +640,7 @@ }, { "window": "2023-09-01 -> 2023-12-01", - "profit_pct": 1.29, + "profit_pct": 0.67, "trades": 34, "market_change_pct": 54.45 }, @@ -652,7 +652,7 @@ }, { "window": "2024-03-01 -> 2024-06-01", - "profit_pct": 2.16, + "profit_pct": 2.15, "trades": 47, "market_change_pct": 21.27 }, @@ -664,7 +664,7 @@ }, { "window": "2024-09-01 -> 2024-12-01", - "profit_pct": 30.29, + "profit_pct": 30.74, "trades": 43, "market_change_pct": 97.89 }, @@ -682,13 +682,13 @@ }, { "window": "2025-06-01 -> 2025-09-01", - "profit_pct": 5.73, + "profit_pct": 5.37, "trades": 46, "market_change_pct": 18.99 }, { "window": "2025-09-01 -> 2025-12-01", - "profit_pct": 0.7, + "profit_pct": -0.92, "trades": 27, "market_change_pct": -42.56 }, @@ -700,7 +700,7 @@ }, { "window": "2026-03-01 -> 2026-06-01", - "profit_pct": -0.88, + "profit_pct": -0.86, "trades": 1, "market_change_pct": 5.11 }, @@ -712,12 +712,12 @@ }, { "window": "2026-09-01 -> 2026-10-10", - "profit_pct": 0.02, + "profit_pct": 0.2, "trades": 21, "market_change_pct": 20.38 } ], - "pct_profitable": 71.4, + "pct_profitable": 64.3, "worst_pct": -6.98 }, "bias": { @@ -729,22 +729,22 @@ "gates": [ { "label": "OOS profit > 0%", - "value": 19.3, + "value": 17.31, "pass": true }, { "label": "OOS profit factor >= 1.3", - "value": 1.47, + "value": 1.43, "pass": true }, { "label": "OOS max drawdown <= 20%", - "value": 9.81, + "value": 9.79, "pass": true }, { "label": "OOS Calmar >= 1.0", - "value": 7.58, + "value": 6.81, "pass": true }, { @@ -754,12 +754,12 @@ }, { "label": "Profitable in >= 60% of walk-forward windows", - "value": 71.4, + "value": 64.3, "pass": true }, { "label": "OOS still profitable with +50% fees", - "value": 17.25, + "value": 15.39, "pass": true }, { diff --git a/user_data/strategies/EmaRsiMomentum.py b/user_data/strategies/EmaRsiMomentum.py index cf5051d..9d67f6e 100644 --- a/user_data/strategies/EmaRsiMomentum.py +++ b/user_data/strategies/EmaRsiMomentum.py @@ -17,24 +17,32 @@ # This is a STARTING POINT strategy meant to be backtested and tuned before # any live use. Run in dry-run first. Nothing here is financial advice. +import sys from datetime import datetime +from pathlib import Path from typing import Optional import talib.abstract as ta -from pandas import DataFrame - from freqtrade.persistence import Trade from freqtrade.strategy import ( BooleanParameter, DecimalParameter, IntParameter, - IStrategy, informative, stoploss_from_open, ) +from pandas import DataFrame + +# Shared ridgeline package (portfolio risk limits, audit log) lives at the +# project root; Freqtrade only puts this strategies folder on the import path. +_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 EmaRsiMomentum(IStrategy): + +class EmaRsiMomentum(RiskMixin): INTERFACE_VERSION = 3 # --- Timeframe --- @@ -267,33 +275,13 @@ def custom_stake_amount( risk_amount = total_equity * self.risk_per_trade stake = risk_amount / stoploss_distance - # Respect exchange/strategy min & max stake bounds. + # Respect exchange/strategy min & max stake bounds, then the + # portfolio-level caps (position size, total open risk). if min_stake is not None: stake = max(stake, min_stake) stake = min(stake, max_stake) - return stake - - def confirm_trade_entry( - self, - pair: str, - order_type: str, - amount: float, - rate: float, - time_in_force: str, - current_time: datetime, - entry_tag: Optional[str], - side: str, - **kwargs, - ) -> bool: - """ - Final safety gate before an order is actually sent. Extend this if - you want extra checks (e.g. blocking entries around scheduled news, - or during unusually wide spreads). The ATR stop distance itself is - pinned in custom_stoploss() below, the first time it's called for - this trade — see there for why. - """ - return True + return self.apply_risk_caps(pair, stake, stoploss_distance, current_time, min_stake) def custom_stoploss( self, diff --git a/user_data/strategies/TrendDipBuyer1h.py b/user_data/strategies/TrendDipBuyer1h.py index 7fea90f..ff5f1c6 100644 --- a/user_data/strategies/TrendDipBuyer1h.py +++ b/user_data/strategies/TrendDipBuyer1h.py @@ -13,23 +13,31 @@ # Mean reversion has a high win rate and small average wins, so it is very # fee-sensitive - always check the fee stress test before trusting it. +import sys from datetime import datetime +from pathlib import Path from typing import Optional import talib.abstract as ta -from pandas import DataFrame - from freqtrade.persistence import Trade from freqtrade.strategy import ( DecimalParameter, IntParameter, - IStrategy, informative, stoploss_from_open, ) +from pandas import DataFrame + +# Shared ridgeline package (portfolio risk limits, audit log) lives at the +# project root; Freqtrade only puts this strategies folder on the import path. +_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 TrendDipBuyer1h(IStrategy): +class TrendDipBuyer1h(RiskMixin): INTERFACE_VERSION = 3 timeframe = "1h" @@ -154,7 +162,8 @@ def custom_stake_amount( stake = total_equity * self.risk_per_trade / distance if min_stake is not None: stake = max(stake, min_stake) - return min(stake, max_stake) + stake = min(stake, max_stake) + return self.apply_risk_caps(pair, stake, distance, current_time, min_stake) def custom_stoploss( self, diff --git a/user_data/strategies/TrendRider4h.py b/user_data/strategies/TrendRider4h.py index 996e713..7cad43f 100644 --- a/user_data/strategies/TrendRider4h.py +++ b/user_data/strategies/TrendRider4h.py @@ -16,24 +16,32 @@ # # Starting point for backtesting / hyperopt, not a profit guarantee. +import sys from datetime import datetime +from pathlib import Path from typing import Optional import talib.abstract as ta -from pandas import DataFrame - from freqtrade.persistence import Trade from freqtrade.strategy import ( BooleanParameter, DecimalParameter, IntParameter, - IStrategy, informative, stoploss_from_absolute, ) +from pandas import DataFrame + +# Shared ridgeline package (portfolio risk limits, audit log) lives at the +# project root; Freqtrade only puts this strategies folder on the import path. +_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 TrendRider4h(IStrategy): +class TrendRider4h(RiskMixin): INTERFACE_VERSION = 3 timeframe = "4h" @@ -167,7 +175,8 @@ def custom_stake_amount( stake = total_equity * self.risk_per_trade / distance if min_stake is not None: stake = max(stake, min_stake) - return min(stake, max_stake) + stake = min(stake, max_stake) + return self.apply_risk_caps(pair, stake, distance, current_time, min_stake) def custom_stoploss( self,