Compare commits
122 Commits
main
...
3c9bb2f017
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c9bb2f017 | |||
| 90801d665d | |||
| cf84f7b1a7 | |||
| 21c16977e8 | |||
| 7a281125e9 | |||
| 4efe30508b | |||
| 356a09e5ab | |||
| 8521ab909a | |||
| f0d76021d1 | |||
| b936d443b8 | |||
| 0396970d29 | |||
| a46aa9ccd9 | |||
| d2d4fb4ac6 | |||
| c8beb7e3ae | |||
| 95f8c47d4f | |||
| e254f068fd | |||
| 5259e6e039 | |||
| 0fc9000934 | |||
| 08eae15e11 | |||
| 89734c3a87 | |||
| 91b4a6bb5e | |||
| 0823c05868 | |||
| 3ee79b5202 | |||
| bf8e5fbf39 | |||
| 11ff49befb | |||
| 847fa18053 | |||
| f24009a784 | |||
| 00380448c3 | |||
| 798f4dec99 | |||
| 3f46505af4 | |||
| a283806a5e | |||
| 44cadf7a9f | |||
| 3669f5e33e | |||
| 7455fcaa6c | |||
| 2c070fb9b3 | |||
| 8de77deadd | |||
| bd506752ec | |||
| bd59b5cd5b | |||
| a3305c81b9 | |||
| 85b7567677 | |||
| de6424ec3f | |||
| ca37a7ab5a | |||
| a70fc91f96 | |||
| 68a62b0a53 | |||
| 7d3fd9502e | |||
| f118ade3cf | |||
| bdcd886722 | |||
| 2a85f1e23c | |||
| 79ea70c8d7 | |||
| a6c9493406 | |||
| 53d8bc4390 | |||
| f3ba4d56a3 | |||
| 43d9064d1e | |||
| ec13cd4c07 | |||
| f88e39f5be | |||
| 02bd61f378 | |||
| 2842351f9b | |||
| 58597de6d7 | |||
| 27bccf3bf5 | |||
| 2d704753d6 | |||
| 538977115b | |||
| e052c7aa3e | |||
| e0789457dc | |||
| 3b5c0b27ed | |||
| 77604960f5 | |||
| 668b7dad51 | |||
| 88970e750b | |||
| 7470e093e1 | |||
| ec33f8b718 | |||
| dad2432d4d | |||
| 3ac36525b0 | |||
| 987ec5bbb0 | |||
| db16f209bb | |||
| d95d31a852 | |||
| 5a0af663c1 | |||
| b191cee27e | |||
| 6a96ed6006 | |||
| 998f5e8616 | |||
| a731b7c8df | |||
| 643952a8e2 | |||
| b61818330b | |||
| 0e6f5d74a7 | |||
| c505829dfb | |||
| 08742c5063 | |||
| 3f7d24734d | |||
| 5047601d46 | |||
| 8ed6454ea5 | |||
| 98daf71a52 | |||
| 57769967d1 | |||
| bf9609708c | |||
| 235241f95a | |||
| 1f3f69c21d | |||
| 439e179128 | |||
| 6b1ffff66a | |||
| d63544b75d | |||
| 1e5047e20f | |||
| 6fe1a3e0f0 | |||
| 4db6121ca7 | |||
| 5e12307b1f | |||
| 4be7077c0a | |||
| 736e33302b | |||
| a7bed99caa | |||
| dc9c76a716 | |||
| a15dc40050 | |||
| b3e0da7455 | |||
| 037dec643e | |||
| 3a09dfd62f | |||
| 353ff05267 | |||
| a596e7cab2 | |||
| c72e8e669b | |||
| 3d9f4dc201 | |||
| e30535f5e9 | |||
| a687db9413 | |||
| b7d03a9b61 | |||
| b38ed17c96 | |||
| dcd98fffa2 | |||
| 70990532a3 | |||
| 53e6e07829 | |||
| 7699e6dd1d | |||
| 636f0304ae | |||
| 6ebdc34cd3 | |||
| bc1844b233 |
+38
@@ -0,0 +1,38 @@
|
|||||||
|
# Secrets
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
config.json
|
||||||
|
|
||||||
|
# Oanda daemon state
|
||||||
|
oanda_state.json
|
||||||
|
|
||||||
|
# Editor / tooling
|
||||||
|
.claude/
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.egg-info/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Backtesting data (keep scripts, ignore generated files)
|
||||||
|
backtest/cache/
|
||||||
|
backtest/*.csv
|
||||||
|
backtest/research/*.parquet
|
||||||
|
backtest/research/duka_m5_boundary_spreads.csv
|
||||||
|
backtest/trailgrid/
|
||||||
|
backtest/trailgrid5m/
|
||||||
|
backtest/__pycache__/
|
||||||
|
.env.*
|
||||||
|
logs/
|
||||||
|
backtest/research/commit_ladder_2026-08/
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## What this is
|
||||||
|
|
||||||
|
Signal-intelligence platform for **XAU/USD (gold — the only supported pair)**. It fetches OHLCV from the active data provider (**Oanda v20 since 2026-07-28**; Twelve Data and Tiingo remain registered fallbacks), scores 9 technical indicators per timeframe with regime-adaptive weights, checks multi-timeframe agreement and news risk, grades the signal (A+…F), and generates a full trade setup (entry, SL, TP1/TP2, trailing plan). Delivery: Telegram bot, CLI, and an Oanda practice-account auto-execution daemon. A realism-focused backtester replays history through the *exact same* signal code.
|
||||||
|
|
||||||
|
Every numeric threshold in `config.py` and `pair_signals/xauusd.py` was backtested or is load-bearing for a backtested result. Validation evidence lives in the factory/module docstrings and in `~/.claude/projects/.../memory/`.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python test_all.py # QA suite: 302 tests, 20 modules, no network (mocked)
|
||||||
|
python main.py --pair XAUUSD --timeframe H1 --detail # CLI signal report (--json for full dump)
|
||||||
|
python telegram_bot.py # the bot (refuses to boot without credentials)
|
||||||
|
python oanda_daemon.py # auto-execution daemon on Oanda practice account
|
||||||
|
python dashboard_publisher.py --once # web dashboard: one scan+publish cycle to $DASHBOARD_OUT_DIR
|
||||||
|
# (omit --once for the candle-close daemon; deploy/README-dashboard.md)
|
||||||
|
|
||||||
|
# Backtests
|
||||||
|
python backtest/gpu_backtest.py --pair XAUUSD --days 365
|
||||||
|
python backtest/gpu_backtest.py --pair XAUUSD --start 2025-01-01 --end 2025-06-01 \
|
||||||
|
--primary-tf 15min --confirm-tfs 1h,4h
|
||||||
|
# key flags: --spread --quality A+,A,B,C --strategies --workers --max-hold-bars
|
||||||
|
# --warmup-bars --cooldown-bars --no-cache --export-csv out.csv --detail
|
||||||
|
```
|
||||||
|
|
||||||
|
**`test_all.py` is NOT pytest** — it uses a custom `@test("name")` decorator that runs each test immediately at import; don't grep for `def test_` or try `pytest test_all.py`. Run the whole file; it prints per-module results and exits non-zero on failure. There is no way to run a single test short of commenting modules out. Tests must stay green; every new gate/knob needs tests. When patching weekend logic in tests, patch `providers.base._is_forex_weekend`.
|
||||||
|
|
||||||
|
Python 3.12; a `venv/` exists in-repo. Dependencies in `requirements.txt` (numpy, pandas, requests, twelvedata, python-telegram-bot ≥20, websockets).
|
||||||
|
|
||||||
|
## Configuration & credentials
|
||||||
|
|
||||||
|
Precedence for every credential/deployment setting: **env var → config.json → refuse to run** (no hardcoded fallbacks). A gitignored repo-root `.env` is loaded at `app_config` import via `os.environ.setdefault`. `config.json` (gitignored; schema in `config.json.example`) holds secrets plus deployment settings: `data_providers.active` (the live-feed switch), `data_providers.streaming` (optional tick-stream provider for the level-breach monitor), `scanning.pairs`/`timeframes`, per-pair `blocked_hours_utc`, `log_level`. Key env vars: `TELEGRAM_TOKEN`, `TELEGRAM_CHAT_ID`, `TELEGRAM_CHANNEL_ID`, `TWELVE_DATA_API_KEY`, `TIINGO_API_KEY`, `OANDA_ACCOUNT_ID`/`OANDA_API_TOKEN`/`OANDA_BASE_URL`, `DEEPSEEK_API_KEY` (news LLM; the `deepseek` config.json block holds model/enabled/timeout/daily cap).
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### End-to-end pipeline (every scan)
|
||||||
|
|
||||||
|
```
|
||||||
|
providers/<active>.fetch_ohlcv() per TF: throttle, fetch, drop forming candle, validate/clean, cache
|
||||||
|
→ engine.TechnicalEngine.analyze() per TF: 9 scored indicators + ATR context → regime → composite score
|
||||||
|
→ signal_aggregator.analyze_pair() live entry point (weekend/day filters, fetch, staleness)
|
||||||
|
└─ analyze_from_dataframes() replayable core: MTF agreement → news → bonuses/penalties →
|
||||||
|
final signal + quality grade → gates → trade setup → reasons
|
||||||
|
→ SignalReport consumed by telegram_bot.py, main.py, oanda_daemon.py, backtester
|
||||||
|
```
|
||||||
|
|
||||||
|
Signal currency everywhere is `SignalStrength` (−2..+2). DataFrames are UTC tz-aware, oldest-first, capitalized OHLCV columns. Everything runs on **closed bars only** — the forming candle is dropped, and session/hour filters evaluate **bar-CLOSE time** (bar label + candle duration) identically in live and backtest.
|
||||||
|
|
||||||
|
### Layers
|
||||||
|
|
||||||
|
- **`providers/`** — one class per provider subclassing `BaseDataProvider` (`providers/base.py`), which owns everything the pipeline depends on: TTL+boundary cache, forming-candle drop, freshness check, validate/clean, paginated `fetch_range` (feeds the backtester from any provider), optional tick streaming. Subclasses implement only `_fetch_raw` + symbol/interval maps. Registry + `make_data_provider(config, name=None)` in `providers/__init__.py`; config.json `data_providers.active` selects the live candle feed and the optional `data_providers.streaming` scalar selects a different provider for the tick stream. A test enforces that subclasses don't override shared base methods (`supports_streaming`/`iter_ticks` are the sanctioned streaming override points). Tiingo streams mid-price ticks (`wss://api.tiingo.com/fx`) — do not re-add spread display from its free FX feed (two-tier indicative quotes, removed 2026-07-23); the no-spread rule extends to the WS stream.
|
||||||
|
- **`engine.py`** — `TechnicalEngine`: 9 scored indicators (ema_trend, rsi, macd, bollinger, adx, roc, keltner, divergence, candlestick) + ATR as context. Regime classification (trending_up/down, `trending` = mixed/direction-UNKNOWN, transitioning, ranging) drives per-indicator weight multipliers and regime-aware RSI reinterpretation. Compute order matters (ATR first; MACD always computed even when not a pillar — divergence needs the histogram). Private series ride on the result as `_underscore` attrs; scorers/formatters skip anything `_`-prefixed or weight-0.
|
||||||
|
- **`signal_aggregator.py`** — MTF agreement, diminishing-decay penalty system, exhaustion hard block, quality grading, then an ordered chain of post-grade gates (session, XAU SELL quality/vol/hour, regime blocks, M5 noise gates), then three setup generators: XAU ATR (A+/A), generic swing-based, plus the M5 pullback (`XAU_PB`) and chart-pattern (`XAU_CP`, `chart_patterns.py`) entry paths. `TradeSetup.strategy` selects trailing params; trail params are stamped on the setup from the *producing* config so per-TF overrides reach formatters.
|
||||||
|
- **`pair_signals/`** — per-pair config factories. **Always use the factory (`produce_signal` / `make_config`), never bare `PlatformConfig()`** — the factory applies the config.json overlay first, then the validated per-TF deltas (M5 hour-9 block, M15 trail arm 0.70, M15 SELL-hour block, etc.; evidence in the docstring). A test enforces factory ≡ inline-config equivalence. Confirmation map: M5→[M15,H1], M15→[M5,H1], H1→[M15,H4], H4→[H1,D1].
|
||||||
|
- **`news_engine.py`** — Forex Factory calendar (MyFXBook RSS fallback) + RSS keyword sentiment with gold-inverse keywords. Class-level caches (fresh engine per scan). Optional DeepSeek enrichment via `llm_client.py` (requests-only, no SDK): batched headline sentiment overriding keyword scores, gold-direction on release surprises, and a market-summary blurb (`SignalReport.news_summary`) — **display/warning-only, keyword fallback on any failure, inert without a key**; prompts are static module constants (DeepSeek prefix-cache pricing) and requests send `thinking: disabled` (V4-Flash reasoning otherwise blows the timeout). By XAU-tuned default, event risk and sentiment **warn but do not gate signals** (`event_risk_caps_signals=False`, `sentiment_affects_signals=False`) — the LLM does not change that.
|
||||||
|
- **`telegram_bot.py`** — owner-locked bot + subscriber channel; candle-boundary auto-scan loops (M5/M15/H1/H4, boundary+10 s), alert quality threshold + fingerprint dedup (committed only after a successful send), 📰 LLM market-context line on alerts when available, WebSocket level-breach monitor fed by the streaming provider (`data_providers.streaming`, falling back to the active provider; auto-disabled when the resolved provider can't stream or has no API key — Oanda does not stream, Tiingo does). Rotating log: `logs/telegram.log`, 3-h rotation × 240 backups.
|
||||||
|
- **`oanda_daemon.py` / `oanda_executor.py`** — practice-account auto-execution. Book-parity rules (see daemon docstring): no broker-side TP (trailing model — TP1/TP2 advisory), 5-bar per-TF cooldown, trail ratchets at bar close exactly once per bar. Accepted divergence: one position total.
|
||||||
|
- **`dashboard_publisher.py` / `static/dashboard.html`** — web dashboard as a **static publisher** (live LAMP server): standalone daemon scans at candle closes via `produce_signal` and atomically writes `snapshot.json`/`news.json`/`dashboard.html` to `$DASHBOARD_OUT_DIR` (Apache serves them; page is public by owner decision). **Hard independence rule: never imports, and is never imported by, `telegram_bot`/`oanda_daemon`** — confirmation map and boundary math are deliberate local copies, so a web-side failure can only mean a stale page. Due-TF classification uses the RAW wake time (flooring wake−buffer skips every H1/H4 scan — 2026-08-02 bug). News panel reads `NewsEngine.get_fundamental_context()` plus the additive `get_week_events()` accessor (display-only; no signal path calls it); as a separate process it has its own news caches and DeepSeek daily cap. Deployment checklist: `deploy/README-dashboard.md`.
|
||||||
|
- **`backtest/gpu_backtest.py`** — bar-by-bar replay through the real `analyze_from_dataframes` (news off). Realism model (do not regress): next-bar-open entries re-anchored to the raw price path, round-trip `--spread` charged per trade, trade resolution on RAW bars (cleaning applies only to signal-side data), same-bar SL+TP tie → SL first, gap fills at bar open, trail ratchet at bar close only. Indicators are precomputed once per TF (CuPy GPU or NumPy fallback — check the `Backend:` log line); replay fans out over worker processes and the 5-bar cooldown is applied in an ordered pass afterwards, so parallel and sequential books are identical. Parquet cache in `backtest/cache/` keyed by provider/symbol/TF/window. `backtest/*.csv` are gitignored pinned trade books from past campaigns — used for manual before/after comparison. `backtest/instructions_for_backtest.txt` lists what every backtest report must record.
|
||||||
|
|
||||||
|
## Rules for making changes (read before editing signal logic)
|
||||||
|
|
||||||
|
1. **Never change a tuned value without re-validation.** Acceptance rule: positive total R in BOTH train and validation halves of a ≥12-month window, PF ≥ 1.3, at realistic cost (XAU $0.30 round trip), trailing exits, next-open fills. Marginal edges must additionally pass the 2024-06→2025-03 out-of-sample window — within-year splits alone have approved configs that later failed OOS.
|
||||||
|
2. **Per-timeframe overrides live in the pair factory**, never in global defaults — the same knob has opposite effects on different TFs (proven repeatedly: `tp_floor_rr`, hour-9 block, trail params). Document validation evidence in the factory docstring.
|
||||||
|
3. **Pair isolation is a hard invariant.** XAU gates in the aggregator are display-name-gated so a future pair port cannot inherit gold-calibrated logic by accident.
|
||||||
|
4. **Live == backtest parity.** If you add a live gate, add it to the replay path in the same commit; all time filters evaluate bar-close time in both paths.
|
||||||
|
5. **ROC stays weighted** even though it's correlated with MACD/Keltner/EMA — zeroing it backtested 13R worse; composite thresholds are calibrated with it.
|
||||||
|
6. **Backtester realism knobs are results-critical** (raw-path resolution, spread charging, next-open entry, SL-first ties, close-only trail). Touching them invalidates comparability with all pinned books.
|
||||||
|
7. **The forming candle must stay dropped** — live signals repaint otherwise.
|
||||||
|
8. Don't add config fields nothing reads; don't disable indicators in production paths (`disabled_indicators` is for experiments only).
|
||||||
|
9. EUR/USD was removed 2026-07-16 after a no-edge verdict (cost-to-volatility; last commit with EUR support: `7d3fd95`). Do not re-add without new data. BTC/ETH work lives on unmerged branches only.
|
||||||
|
|
||||||
|
## Current state notes (2026-07-30)
|
||||||
|
|
||||||
|
- Oanda v20 is the live feed (`data_providers.active: "oanda"`), but the pinned backtest books are still Twelve-Data-pinned and the backtest cost assumption is $0.30 while measured real spreads are ~$0.65–0.72 — re-pin books on Oanda bars before treating feed-switch comparisons as valid (see Oanda/TD divergence study in memory).
|
||||||
|
- Human-facing docs in `docs/` (`user_guide.html`, `beginner_guide.html` + PDF builds) must be kept in sync when signal behavior changes.
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# Full Migration to Oanda v20 as Market-Data Source — Roadmap
|
||||||
|
|
||||||
|
**Status: partially executed (updated 2026-08-03).** Phases 1–2 shipped 2026-07-28 via the `providers/` package refactor and the live feed switched to Oanda (`data_providers.active: "oanda"`). Phases 3–6 remain unbuilt: the level-breach stream monitor is simply disabled for Oanda, and the pinned backtest books are still Twelve-Data-pinned at the $0.30 cost assumption. Owner decision 2026-07-24 stands: Twelve Data is a **permanent fallback** (no removal phase).
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The daemon already executes trades on an Oanda practice account, and since 2026-07-28 the signal feed (Telegram bot, CLI, backtester) also runs on Oanda. Oanda's API is free with no metering (120 req/s vs TD's 55 credits/min), serves real bid/ask spreads, and matches the actual trading calendar. The 2026-07-24 divergence study (`backtest/research/oanda_td_divergence.py`) proved the switch is **not plumbing-only**: steady-state bars differ by median $0.69 (11% of M15 ATR), bar direction agrees only 89.9%, TD prints ~4,000 phantom Saturday bars/yr + 21:00 UTC rollover bars that contaminate Monday indicator windows, and Oanda's real spread (~$0.72 liquid / $0.84 rollover) is ~2.4× the $0.30 the validated books assume. So the migration = plumbing (Phases 1–3, inert) + shadow evidence (4) + book re-validation with owner gates (5) + reversible cutover (6). CLAUDE.md rules 1 (no tuned-value change without re-validation) and 4 (live == backtest parity) govern throughout.
|
||||||
|
|
||||||
|
**Master sequencing rule:** Phases 1–3 land inert behind `data_providers.active == "twelve_data"`. Nothing changes live behavior until the Phase 6 config flip, which stays reversible forever (TD is a permanent fallback).
|
||||||
|
|
||||||
|
## Phase 1 — Provider plumbing (inert)
|
||||||
|
|
||||||
|
Files: `oanda_provider.py`, `data_provider.py`, `config.py`, `config.json.example`.
|
||||||
|
|
||||||
|
1. **Constructor self-resolution**: `OandaDataProvider.__init__(config, api_token=None, base_url=None)` — defaults resolve via existing `app_config.oanda_api_token()` / `oanda_base_url()`; explicit args keep `oanda_daemon.py:180` working. Fail lazily at first fetch (mirror ForexDataProvider's `_td is None` pattern) so credential-less construction doesn't break the hermetic test header.
|
||||||
|
2. **Registry**: add `"oanda": OandaDataProvider` to `PROVIDER_REGISTRY` (`data_provider.py:392`); import at bottom of `data_provider.py` (one-direction edge, no cycle — `oanda_provider` must never import from `data_provider`). Add `PROVIDER_NAME` class attr to both providers. Cutover config needs an `"oanda": {}` block under `data_providers` (creds stay in the existing top-level `oanda` section / env).
|
||||||
|
3. **Dedupe shared constants**: move `CANDLE_SECONDS` / `BARS_PER_DAY` to `config.py`, alias as class attrs in both providers — all existing call sites (`signal_aggregator.py:18,217,640,942`, `telegram_bot.py:1062`, `gpu_backtest.py:975,1706`, tests) keep working unchanged. Also move `_is_forex_weekend` to `config.py` eventually (currently duplicated + sync-tested).
|
||||||
|
4. **`_fetch_window(symbol, tf, start, end)`** on both providers — the backtester's common interface. Contract: raw closed bars labeled in [start, end], oldest-first, capitalized OHLCV, UTC index; empty DataFrame (not exception) for empty windows; caller keeps windows ≤ ~4500 bars. TD: thin wrapper over existing `_fetch_twelve_data(start_date=, end_date=)` (keep its exception behavior byte-identical). Oanda: new `from`/`to` RFC3339 request (`price=MBA, dailyAlignment=0, alignmentTimezone=Etc/UTC`); refactor `_fetch_oanda_candles`'s parsing body (complete-filter, mid extraction, spread cache) into a shared `_parse_candles()`. The from+count quirk (Oanda's `count` includes the boundary bar; see `backtest/research/fetch_oanda_m15.py`) does not apply to from/to windows. Add a no-op-by-construction `_drop_forming_candle` to OandaDataProvider (`gpu_backtest.py:1025` calls it unconditionally). No throttle needed for Oanda.
|
||||||
|
5. **Tests (extend Module 15)**: registry selection; constructor self-resolution + explicit-args-win; `_fetch_window` both providers (mock `_session.get` / `_fetch_twelve_data`); CANDLE_SECONDS/BARS_PER_DAY cross-provider sync; provider-contract hasattr test covering everything callers reach for (`fetch_ohlcv, get_pair_info, clear_cache, _is_data_fresh, _has_latest_closed_bar, _validate_and_clean, _drop_forming_candle, _fetch_window, CANDLE_SECONDS, BARS_PER_DAY, _cache, _cache_timestamps, PROVIDER_NAME`); port Module 11's two cache/boundary tests (`test_all.py:2278,2313`) to Oanda variants patching `_fetch_oanda_candles`. Tests that swap `set_app_config` must restore `_TEST_APP` in `finally`.
|
||||||
|
|
||||||
|
## Phase 2 — Backtester provider-agnosticism
|
||||||
|
|
||||||
|
File: `backtest/gpu_backtest.py` (`fetch_history`, lines 943–1036).
|
||||||
|
|
||||||
|
1. Line 984: `provider._fetch_twelve_data(...)` → `provider._fetch_window(...)`. Keep the backward-stepping chunk loop, `empty_streak` bail, cursor stepping byte-identical (rule 6 — this loop produced every pinned cache).
|
||||||
|
2. Lines 975/1706: `ForexDataProvider.BARS_PER_DAY` → `provider.BARS_PER_DAY`.
|
||||||
|
3. **Provider-namespaced cache keys** in `_cache_path` (line 938): TD keeps legacy names (pinned parquets stay loadable, needed for A/B), Oanda gets `oanda_` prefix (`oanda_XAU_USD_15min_....parquet`) — no collisions. Comment the asymmetry.
|
||||||
|
4. `clean=True/False` raw-path semantics untouched.
|
||||||
|
5. **Verification before merge**: one M15 12-month TD replay pre/post commit — trade books must be identical. **Alignment audit**: fetch one month of Oanda H4 + D1 via `_fetch_window`, assert label sets match TD's cached labels (H4 at 00/04/08 UTC, D1 at 00:00 UTC) — a mismatch would silently shift every MTF confirmation.
|
||||||
|
6. Tests: `fetch_history` dispatch with a stub provider (concat/dedupe/trim, clean=False skips cleaning); `_cache_path` namespacing regression.
|
||||||
|
|
||||||
|
## Phase 3 — Telegram bot: TD WebSocket → Oanda pricing stream
|
||||||
|
|
||||||
|
File: `telegram_bot.py`; new resolver in `app_config.py`.
|
||||||
|
|
||||||
|
1. New `oanda_stream_url()` resolver: env `OANDA_STREAM_URL` → config.json `oanda.stream_url` (add to `_KNOWN_OANDA`) → derived `oanda_base_url().replace("api-", "stream-", 1)`.
|
||||||
|
2. Pure parser `_parse_stream_line(line) -> Optional[(pair_symbol, mid_price)]`: type==PRICE → mid from closeoutBid/Ask (fallback bids[0]/asks[0]); HEARTBEAT/garbage → None. `_STREAM_TO_PAIR` from `OandaDataProvider.OANDA_INSTRUMENT` (replaces TD-display-keyed `_WS_TO_PAIR`).
|
||||||
|
3. Reader thread (no new deps): `requests.get(.../v3/accounts/{id}/pricing/stream?instruments=..., stream=True, timeout=(10,30))`, iterate `iter_lines(chunk_size=1)` — **chunk_size=1 is load-bearing** (urllib3 buffering vs 5s server heartbeats); push lines to asyncio queue via `loop.call_soon_threadsafe`; interrupt a blocked read with `resp.close()` from the async side.
|
||||||
|
4. Async monitor `_oanda_stream_monitor`: dedicated thread (NOT the 4-worker scan executor), `await asyncio.wait_for(queue.get(), timeout=15)` as staleness watchdog (3× server heartbeat) → reconnect with existing exponential-backoff pattern. PRICE lines feed the identical existing body: `_check_level_breach` → `_handle_breach` → M15 rescan, 5-min cooldown. No client heartbeat.
|
||||||
|
5. **Provider-gated selection** in `_start_scheduler` (line 889): `active_provider == "oanda"` → stream monitor, else existing `_ws_monitor`. This is the rollback hinge and stays permanently (TD fallback). `_stop_scheduler` must stop the reader thread. Generalize `/status` labels (`_status_text` iterates `SCAN_PAIRS`, not `_WS_TO_PAIR`).
|
||||||
|
6. Tests: `_parse_stream_line` variants (PRICE/closeout, PRICE/arrays, HEARTBEAT, malformed, unknown instrument); `_STREAM_TO_PAIR` round-trip; monitor selection under both `active` values; watchdog-reconnect (structure one connection lifecycle as a testable inner coroutine).
|
||||||
|
|
||||||
|
## Phase 4 — Shadow / parity evidence (3–4 weeks, no hot-path code)
|
||||||
|
|
||||||
|
The daemon already runs the full signal stack on Oanda bars. Formalize evidence before any cutover:
|
||||||
|
|
||||||
|
1. **Code parity (exact)**: replay the daemon's shadow window through the backtester on Oanda data (Phase 2 enables this); diff against daemon-logged decisions per (tf, bar_close_ts) — signal, quality, direction, entry/SL/TP. Acceptance: 100% modulo documented daemon divergences (single-position rule, quality gate). Any mismatch is a bug, not feed noise.
|
||||||
|
2. **Feed divergence (measured, not zero-gated)**: `backtest/research/shadow_compare.py` parses `logs/telegram.log` + daemon log into per-scan rows: signal agreement rate, direction flips, grade drift, clustering by hour/weekday. Expect high-single-digit % divergence concentrated near grade thresholds and Monday sessions (TD Saturday/rollover bars). Deliverable: written note attributing every divergence class.
|
||||||
|
3. **Operational telemetry**: boundary grace-retry frequency (is Oanda's just-closed bar present at boundary+10s?), `get_spread()` time series (live input to the Phase 5 cost decision), stream disconnect counts, Monday-open indicator snapshots TD-vs-Oanda.
|
||||||
|
4. **Owner note**: at cutover the data feed host family should match the execution host (practice vs live pricing can micro-differ).
|
||||||
|
|
||||||
|
## Phase 5 — Re-validation campaign (rule 1, owner gates)
|
||||||
|
|
||||||
|
Fetch Oanda history (M5/M15/H1/H4/D1, 2020-07 → present; Oanda XAU reaches ~2005). Run matrix per primary TF (M5/M15/H1, current factory configs): 12-mo train/val halves, 5-yr, OOS 2024-06→2025-03 — each at `--spread 0.30` (pure feed-effect A/B vs pinned TD books), `0.70` (honest Oanda cost), `0.85` (rollover stress). Caution: Jul–Nov 2025 is provider-sensitive (weekly offsets −$6..+$18.5) — lean on 5-yr + OOS for decisions. Acceptance unchanged: positive both halves, PF ≥ 1.3, trailing exits, next-open fills, OOS positive.
|
||||||
|
|
||||||
|
**Owner decision gates (never silent):**
|
||||||
|
1. **Canonical cost** — (a) `round_trip_cost=0.70` everywhere (moves the live TP1 spread gate $9→$21 — a strategy re-tune); (b) `default_spread=0.70` for backtest PnL but retune `min_tp_cost_multiple` 30→~13 to preserve the validated $9 gate; (c) keep $0.30 with documented optimism (only if $0.70 books pass anyway). Full acceptance matrix at chosen values.
|
||||||
|
2. **Degradation handling** per failing TF: re-run tuning grids on Oanda data / drop TF from `scanning.timeframes` / keep Oanda execution-only (status quo).
|
||||||
|
3. **Re-pin**: passing books become the new pinned CSVs + factory-docstring evidence; CLAUDE.md tuned-value annotations update in the cutover commit, not before.
|
||||||
|
|
||||||
|
Files (per decisions): `pair_signals/xauusd.py`, `config.py` (`PAIR_INFO.default_spread`, `SignalConfig.round_trip_cost`/`min_tp_cost_multiple`), `test_all.py` tuned-constant assertions, CLAUDE.md.
|
||||||
|
|
||||||
|
## Phase 6 — Cutover (reversible forever; no TD removal)
|
||||||
|
|
||||||
|
1. config.json: `data_providers.active: "oanda"` + `"oanda": {}` block; restart bot. Everything flips through two seams: `make_data_provider` (bot shared dp, CLI, backtester) and the Phase 3 monitor selector. Any Phase 5 tuned-value changes land in this commit with their validation records.
|
||||||
|
2. **Bake ≥1 week** (full weekly cycle incl. Monday open): grace-retry rate, stream stability, alert plausibility vs daemon, `/cache` freshness.
|
||||||
|
3. **Rollback = flip `active` back** — TD code, key, caches, and WS monitor all stay maintained indefinitely (owner decision). Dual-provider posture: cross-provider sync tests (Phase 1) keep both honest; TD subscription/key must stay valid to remain a real fallback.
|
||||||
|
4. Docs: CLAUDE.md (pipeline header, data_provider section, env-var table + `OANDA_STREAM_URL`, backtester cache note, comparability note: "pre-Oanda books are TD-feed books"), `docs/` guides if they name the data source.
|
||||||
|
|
||||||
|
## Key risks
|
||||||
|
|
||||||
|
1. **Cost-gate coupling**: naively setting `round_trip_cost=0.70` silently moves the live $9 TP1 gate to $21 — owner gate 5.1 exists precisely for this.
|
||||||
|
2. **Cutover before re-pinning** — feed divergence is material (11% of ATR, 10% direction flips); sequencing rule prevents it.
|
||||||
|
3. **Hermetic test header** — `_TEST_APP` pins twelve_data; provider-swapping tests must restore it or later modules fail mysteriously.
|
||||||
|
4. **Stream liveness triad** — `chunk_size=1`, `resp.close()` interruption, 15s watchdog.
|
||||||
|
5. **H4/D1 alignment** — audit before trusting any Oanda backtest.
|
||||||
|
6. **Monday/rollover divergence class** — attribute in Phase 4 evidence; do not "fix".
|
||||||
|
|
||||||
|
## Verification (per phase)
|
||||||
|
|
||||||
|
- Phases 1–3: `python test_all.py` green (235 + new); live behavior provably unchanged (TD active).
|
||||||
|
- Phase 2: pre/post TD book identity diff; Oanda H4/D1 alignment audit.
|
||||||
|
- Phase 3: soak a second bot instance with `active=oanda` scratch config; observe stream reconnects in log.
|
||||||
|
- Phase 5: full acceptance matrix, owner sign-offs recorded in factory docstrings.
|
||||||
|
- Phase 6: 1-week bake checklist above; rollback drill (flip back, confirm TD scan cycle) once during bake.
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+398
@@ -0,0 +1,398 @@
|
|||||||
|
"""
|
||||||
|
app_config.py — deployment-settings overlay (config.json)
|
||||||
|
=========================================================
|
||||||
|
|
||||||
|
Loads the gitignored config.json at the repo root ONCE per process.
|
||||||
|
Deployment surface ONLY: Telegram credentials, data-provider selection
|
||||||
|
and API keys, scanned pairs/timeframes, per-pair base blocked hours,
|
||||||
|
alert-quality boot default, log level. Signal logic, weights,
|
||||||
|
thresholds and gates stay in config.py / pair_signals.
|
||||||
|
|
||||||
|
Precedence for every setting: env var → config.json → code default.
|
||||||
|
A repo-root .env file (gitignored) is loaded at import via setdefault,
|
||||||
|
so it behaves exactly like real environment variables except that a
|
||||||
|
real shell export always wins. Secrets have NO code default — with
|
||||||
|
neither env nor config.json the bot refuses to start
|
||||||
|
(telegram_bot.main) and data fetches fail with a named error
|
||||||
|
(data_provider).
|
||||||
|
|
||||||
|
Parity contract (test-enforced): with no config.json, or one holding
|
||||||
|
the config.json.example defaults, every produced PlatformConfig is
|
||||||
|
byte-identical to the pure-code defaults. apply_pair_overrides() runs
|
||||||
|
in the pair factory BEFORE the validated per-timeframe deltas, so the
|
||||||
|
bot, CLI and backtester all see the same overlay (live == backtest).
|
||||||
|
|
||||||
|
Spec: docs/superpowers/specs/2026-07-21-centralized-config-design.md
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from config import PAIR_INFO, PlatformConfig, Timeframe, XAUUSD
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
CONFIG_PATH = os.path.join(
|
||||||
|
os.path.dirname(os.path.abspath(__file__)), "config.json")
|
||||||
|
ENV_PATH = os.path.join(
|
||||||
|
os.path.dirname(os.path.abspath(__file__)), ".env")
|
||||||
|
|
||||||
|
|
||||||
|
def load_dotenv(path: str = ENV_PATH) -> None:
|
||||||
|
"""Minimal .env loader (no python-dotenv dependency).
|
||||||
|
|
||||||
|
KEY=VALUE lines land in os.environ via setdefault — a variable
|
||||||
|
already exported in the shell always wins, keeping the
|
||||||
|
env → config.json precedence intact (.env is just a convenient
|
||||||
|
place to put env vars). Comments, blank lines, ``export `` prefixes
|
||||||
|
and single/double quotes are handled; anything else is skipped.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with open(path, encoding="utf-8") as fh:
|
||||||
|
lines = fh.read().splitlines()
|
||||||
|
except OSError:
|
||||||
|
return
|
||||||
|
for line in lines:
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
if line.startswith("export "):
|
||||||
|
line = line[len("export "):]
|
||||||
|
key, _, value = line.partition("=")
|
||||||
|
key, value = key.strip(), value.strip()
|
||||||
|
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
|
||||||
|
value = value[1:-1]
|
||||||
|
elif value[:1] not in "\"'":
|
||||||
|
# Unquoted value: whitespace-then-# starts an inline comment
|
||||||
|
# (KEY=abc # prod). Quote the value to keep a literal " #".
|
||||||
|
for sep in (" #", "\t#"):
|
||||||
|
cut = value.find(sep)
|
||||||
|
if cut != -1:
|
||||||
|
value = value[:cut].rstrip()
|
||||||
|
if key:
|
||||||
|
os.environ.setdefault(key, value)
|
||||||
|
|
||||||
|
|
||||||
|
# Module import == process boot, before any resolver can run.
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
_VALID_QUALITIES = ("A+", "A", "B", "C")
|
||||||
|
_VALID_LOG_LEVELS = ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL")
|
||||||
|
# Timeframes the bot's candle-boundary scheduler supports (must have
|
||||||
|
# AUTOSCAN_CONFIRMATIONS entries in telegram_bot.py).
|
||||||
|
_SCANNABLE_TFS = ("M5", "M15", "H1", "H4")
|
||||||
|
|
||||||
|
_KNOWN_TOP = {"telegram", "data_providers", "scanning", "pairs", "log_level",
|
||||||
|
"oanda", "deepseek"}
|
||||||
|
_KNOWN_OANDA = {
|
||||||
|
"account_id", "api_token", "base_url", "lot_size",
|
||||||
|
"quality_gate", "max_hold_bars",
|
||||||
|
}
|
||||||
|
_KNOWN_DEEPSEEK = {
|
||||||
|
"api_key", "model", "enabled", "timeout_seconds", "daily_request_cap",
|
||||||
|
}
|
||||||
|
_KNOWN_TELEGRAM = {"token", "chat_id", "channel_id", "alert_min_quality"}
|
||||||
|
_KNOWN_SCANNING = {"pairs", "timeframes"}
|
||||||
|
_KNOWN_PAIR_KEYS = {"blocked_hours_utc"}
|
||||||
|
|
||||||
|
|
||||||
|
class AppConfigError(Exception):
|
||||||
|
"""config.json is present but invalid — fail fast at boot."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AppConfig:
|
||||||
|
telegram_token: str = ""
|
||||||
|
telegram_chat_id: str = ""
|
||||||
|
telegram_channel_id: str = "" # "" = channel feed disabled
|
||||||
|
alert_min_quality: str = "C"
|
||||||
|
active_provider: str = "twelve_data"
|
||||||
|
streaming_provider: Optional[str] = None # None = active provider
|
||||||
|
provider_settings: Dict[str, Dict[str, Any]] = field(default_factory=dict)
|
||||||
|
scan_pairs: List[str] = field(default_factory=lambda: [XAUUSD])
|
||||||
|
scan_timeframes: List[Timeframe] = field(default_factory=lambda: [
|
||||||
|
Timeframe.M5, Timeframe.M15, Timeframe.H1, Timeframe.H4])
|
||||||
|
pair_overrides: Dict[str, Dict[str, Any]] = field(default_factory=dict)
|
||||||
|
oanda_settings: Dict[str, Any] = field(default_factory=dict)
|
||||||
|
deepseek_settings: Dict[str, Any] = field(default_factory=dict)
|
||||||
|
log_level: str = "INFO"
|
||||||
|
|
||||||
|
|
||||||
|
def _check_keys(section: Dict[str, Any], known: set, where: str) -> None:
|
||||||
|
if not isinstance(section, dict):
|
||||||
|
raise AppConfigError(f"config.json: {where} must be a JSON object")
|
||||||
|
unknown = sorted(set(section) - known)
|
||||||
|
if unknown:
|
||||||
|
raise AppConfigError(
|
||||||
|
f"config.json: unknown key(s) {unknown} in {where}; "
|
||||||
|
f"known keys: {sorted(known)}")
|
||||||
|
|
||||||
|
|
||||||
|
def load_app_config(path: str = CONFIG_PATH) -> AppConfig:
|
||||||
|
"""Parse + validate config.json. Missing file → pure defaults."""
|
||||||
|
app = AppConfig()
|
||||||
|
if not os.path.exists(path):
|
||||||
|
logger.warning(
|
||||||
|
"config.json not found at %s — running on code defaults "
|
||||||
|
"(secrets must come from environment variables)", path)
|
||||||
|
return app
|
||||||
|
try:
|
||||||
|
with open(path, encoding="utf-8") as fh:
|
||||||
|
raw = json.load(fh)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
raise AppConfigError(f"config.json is not valid JSON: {e}") from e
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
raise AppConfigError("config.json top level must be a JSON object")
|
||||||
|
_check_keys(raw, _KNOWN_TOP, "top level")
|
||||||
|
|
||||||
|
tg = raw.get("telegram", {})
|
||||||
|
_check_keys(tg, _KNOWN_TELEGRAM, '"telegram"')
|
||||||
|
app.telegram_token = str(tg.get("token", app.telegram_token))
|
||||||
|
app.telegram_chat_id = str(tg.get("chat_id", app.telegram_chat_id))
|
||||||
|
app.telegram_channel_id = str(
|
||||||
|
tg.get("channel_id", app.telegram_channel_id))
|
||||||
|
quality = tg.get("alert_min_quality", app.alert_min_quality)
|
||||||
|
if quality not in _VALID_QUALITIES:
|
||||||
|
raise AppConfigError(
|
||||||
|
f'config.json: telegram.alert_min_quality "{quality}" '
|
||||||
|
f"not in {_VALID_QUALITIES}")
|
||||||
|
app.alert_min_quality = quality
|
||||||
|
|
||||||
|
dp = raw.get("data_providers")
|
||||||
|
if dp is not None:
|
||||||
|
if not isinstance(dp, dict):
|
||||||
|
raise AppConfigError('config.json: "data_providers" must be an object')
|
||||||
|
app.active_provider = str(dp.get("active", app.active_provider))
|
||||||
|
app.provider_settings = {}
|
||||||
|
for name, block in dp.items():
|
||||||
|
# "active" and "streaming" are reserved scalars, not blocks.
|
||||||
|
if name in ("active", "streaming"):
|
||||||
|
continue
|
||||||
|
if not isinstance(block, dict):
|
||||||
|
raise AppConfigError(
|
||||||
|
f'config.json: data_providers.{name} must be an object')
|
||||||
|
app.provider_settings[name] = dict(block)
|
||||||
|
if app.active_provider not in app.provider_settings:
|
||||||
|
raise AppConfigError(
|
||||||
|
f'config.json: data_providers.active "{app.active_provider}" '
|
||||||
|
"has no matching provider block")
|
||||||
|
streaming = dp.get("streaming")
|
||||||
|
if streaming is not None:
|
||||||
|
if not isinstance(streaming, str):
|
||||||
|
raise AppConfigError(
|
||||||
|
'config.json: data_providers.streaming must be a string '
|
||||||
|
"(a provider name)")
|
||||||
|
if streaming not in app.provider_settings:
|
||||||
|
raise AppConfigError(
|
||||||
|
f'config.json: data_providers.streaming "{streaming}" '
|
||||||
|
"has no matching provider block")
|
||||||
|
app.streaming_provider = streaming
|
||||||
|
|
||||||
|
scanning = raw.get("scanning", {})
|
||||||
|
_check_keys(scanning, _KNOWN_SCANNING, '"scanning"')
|
||||||
|
if "pairs" in scanning:
|
||||||
|
pairs = scanning["pairs"]
|
||||||
|
if not isinstance(pairs, list):
|
||||||
|
raise AppConfigError('config.json: scanning.pairs must be a list')
|
||||||
|
bad = [p for p in pairs if p not in PAIR_INFO]
|
||||||
|
if bad:
|
||||||
|
raise AppConfigError(
|
||||||
|
f"config.json: unknown pair(s) {bad} in scanning.pairs; "
|
||||||
|
f"known: {sorted(PAIR_INFO)}")
|
||||||
|
app.scan_pairs = list(pairs)
|
||||||
|
if "timeframes" in scanning:
|
||||||
|
names = scanning["timeframes"]
|
||||||
|
if not isinstance(names, list):
|
||||||
|
raise AppConfigError(
|
||||||
|
'config.json: scanning.timeframes must be a list')
|
||||||
|
bad = [n for n in names if n not in _SCANNABLE_TFS]
|
||||||
|
if bad:
|
||||||
|
raise AppConfigError(
|
||||||
|
f"config.json: unsupported timeframe(s) {bad} in "
|
||||||
|
f"scanning.timeframes; supported: {list(_SCANNABLE_TFS)}")
|
||||||
|
app.scan_timeframes = [Timeframe[n] for n in names]
|
||||||
|
|
||||||
|
pair_section = raw.get("pairs", {})
|
||||||
|
if not isinstance(pair_section, dict):
|
||||||
|
raise AppConfigError('config.json: "pairs" must be a JSON object')
|
||||||
|
for symbol, overrides in pair_section.items():
|
||||||
|
if symbol not in PAIR_INFO:
|
||||||
|
raise AppConfigError(
|
||||||
|
f'config.json: unknown pair "{symbol}" in pairs; '
|
||||||
|
f"known: {sorted(PAIR_INFO)}")
|
||||||
|
_check_keys(overrides, _KNOWN_PAIR_KEYS, f'pairs."{symbol}"')
|
||||||
|
if "blocked_hours_utc" in overrides:
|
||||||
|
hours = overrides["blocked_hours_utc"]
|
||||||
|
valid = isinstance(hours, list) and all(
|
||||||
|
isinstance(h, int) and not isinstance(h, bool)
|
||||||
|
and 0 <= h <= 23 for h in hours)
|
||||||
|
if not valid:
|
||||||
|
raise AppConfigError(
|
||||||
|
f'config.json: pairs."{symbol}".blocked_hours_utc must '
|
||||||
|
"be a list of integers in 0-23")
|
||||||
|
app.pair_overrides = {s: dict(o) for s, o in pair_section.items()}
|
||||||
|
|
||||||
|
oanda = raw.get("oanda", {})
|
||||||
|
_check_keys(oanda, _KNOWN_OANDA, '"oanda"')
|
||||||
|
if "lot_size" in oanda:
|
||||||
|
ls = oanda["lot_size"]
|
||||||
|
if not isinstance(ls, int) or isinstance(ls, bool) or ls < 1:
|
||||||
|
raise AppConfigError(
|
||||||
|
"config.json: oanda.lot_size must be a positive integer")
|
||||||
|
if "quality_gate" in oanda and oanda["quality_gate"] not in _VALID_QUALITIES:
|
||||||
|
raise AppConfigError(
|
||||||
|
f'config.json: oanda.quality_gate "{oanda["quality_gate"]}" '
|
||||||
|
f"not in {_VALID_QUALITIES}")
|
||||||
|
if "max_hold_bars" in oanda:
|
||||||
|
mhb = oanda["max_hold_bars"]
|
||||||
|
if not isinstance(mhb, int) or isinstance(mhb, bool) or mhb < 1:
|
||||||
|
raise AppConfigError(
|
||||||
|
"config.json: oanda.max_hold_bars must be a positive integer")
|
||||||
|
app.oanda_settings = dict(oanda)
|
||||||
|
|
||||||
|
deepseek = raw.get("deepseek", {})
|
||||||
|
_check_keys(deepseek, _KNOWN_DEEPSEEK, '"deepseek"')
|
||||||
|
if "enabled" in deepseek and not isinstance(deepseek["enabled"], bool):
|
||||||
|
raise AppConfigError(
|
||||||
|
"config.json: deepseek.enabled must be true or false")
|
||||||
|
if "timeout_seconds" in deepseek:
|
||||||
|
ts = deepseek["timeout_seconds"]
|
||||||
|
if isinstance(ts, bool) or not isinstance(ts, (int, float)) or ts <= 0:
|
||||||
|
raise AppConfigError(
|
||||||
|
"config.json: deepseek.timeout_seconds must be a positive "
|
||||||
|
"number")
|
||||||
|
if "daily_request_cap" in deepseek:
|
||||||
|
cap = deepseek["daily_request_cap"]
|
||||||
|
if isinstance(cap, bool) or not isinstance(cap, int) or cap < 1:
|
||||||
|
raise AppConfigError(
|
||||||
|
"config.json: deepseek.daily_request_cap must be a positive "
|
||||||
|
"integer")
|
||||||
|
if "model" in deepseek:
|
||||||
|
if not isinstance(deepseek["model"], str) or not deepseek["model"]:
|
||||||
|
raise AppConfigError(
|
||||||
|
"config.json: deepseek.model must be a non-empty string")
|
||||||
|
app.deepseek_settings = dict(deepseek)
|
||||||
|
|
||||||
|
level = raw.get("log_level", app.log_level)
|
||||||
|
if level not in _VALID_LOG_LEVELS:
|
||||||
|
raise AppConfigError(
|
||||||
|
f'config.json: log_level "{level}" not in {_VALID_LOG_LEVELS}')
|
||||||
|
app.log_level = level
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
# ── Process-wide singleton ────────────────────────────────────────────
|
||||||
|
|
||||||
|
_cached: Optional[AppConfig] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_app_config() -> AppConfig:
|
||||||
|
global _cached
|
||||||
|
if _cached is None:
|
||||||
|
_cached = load_app_config()
|
||||||
|
return _cached
|
||||||
|
|
||||||
|
|
||||||
|
def set_app_config(app: Optional[AppConfig]) -> None:
|
||||||
|
"""Override the process-wide AppConfig (tests/experiments).
|
||||||
|
Pass None to re-enable lazy loading from CONFIG_PATH."""
|
||||||
|
global _cached
|
||||||
|
_cached = app
|
||||||
|
|
||||||
|
|
||||||
|
# ── Resolution helpers: env var → config.json → default ───────────────
|
||||||
|
|
||||||
|
def telegram_token(app: Optional[AppConfig] = None) -> str:
|
||||||
|
app = app or get_app_config()
|
||||||
|
return os.environ.get("TELEGRAM_TOKEN") or app.telegram_token
|
||||||
|
|
||||||
|
|
||||||
|
def telegram_chat_id(app: Optional[AppConfig] = None) -> str:
|
||||||
|
app = app or get_app_config()
|
||||||
|
return os.environ.get("TELEGRAM_CHAT_ID") or app.telegram_chat_id
|
||||||
|
|
||||||
|
|
||||||
|
def telegram_channel_id(app: Optional[AppConfig] = None) -> str:
|
||||||
|
"""Env set-but-empty ("") means "disable the feed" and beats JSON,
|
||||||
|
so this checks None-ness, not truthiness."""
|
||||||
|
app = app or get_app_config()
|
||||||
|
env = os.environ.get("TELEGRAM_CHANNEL_ID")
|
||||||
|
return env if env is not None else app.telegram_channel_id
|
||||||
|
|
||||||
|
|
||||||
|
# Per-provider env-var overrides for API keys (env → config.json → "").
|
||||||
|
_PROVIDER_ENV_KEYS = {
|
||||||
|
"twelve_data": "TWELVE_DATA_API_KEY",
|
||||||
|
"tiingo": "TIINGO_API_KEY",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def provider_api_key(
|
||||||
|
app: Optional[AppConfig] = None, provider: Optional[str] = None
|
||||||
|
) -> str:
|
||||||
|
"""API key for `provider` (default: the ACTIVE provider). The env
|
||||||
|
override is per-provider (TWELVE_DATA_API_KEY applies only to
|
||||||
|
twelve_data, TIINGO_API_KEY only to tiingo)."""
|
||||||
|
app = app or get_app_config()
|
||||||
|
name = provider or app.active_provider
|
||||||
|
env_key = _PROVIDER_ENV_KEYS.get(name)
|
||||||
|
if env_key:
|
||||||
|
env = os.environ.get(env_key)
|
||||||
|
if env:
|
||||||
|
return env
|
||||||
|
block = app.provider_settings.get(name, {})
|
||||||
|
return str(block.get("api_key", ""))
|
||||||
|
|
||||||
|
|
||||||
|
# ── Oanda resolution helpers (env → config.json → default) ───────────
|
||||||
|
|
||||||
|
def oanda_account_id(app: Optional[AppConfig] = None) -> str:
|
||||||
|
app = app or get_app_config()
|
||||||
|
return os.environ.get("OANDA_ACCOUNT_ID") or str(
|
||||||
|
app.oanda_settings.get("account_id", ""))
|
||||||
|
|
||||||
|
|
||||||
|
def oanda_api_token(app: Optional[AppConfig] = None) -> str:
|
||||||
|
app = app or get_app_config()
|
||||||
|
return os.environ.get("OANDA_API_TOKEN") or str(
|
||||||
|
app.oanda_settings.get("api_token", ""))
|
||||||
|
|
||||||
|
|
||||||
|
def oanda_base_url(app: Optional[AppConfig] = None) -> str:
|
||||||
|
app = app or get_app_config()
|
||||||
|
return os.environ.get("OANDA_BASE_URL") or str(
|
||||||
|
app.oanda_settings.get("base_url", "https://api-fxpractice.oanda.com"))
|
||||||
|
|
||||||
|
|
||||||
|
# ── DeepSeek resolution helper (env → config.json → "") ──────────────
|
||||||
|
|
||||||
|
def deepseek_api_key(app: Optional[AppConfig] = None) -> str:
|
||||||
|
app = app or get_app_config()
|
||||||
|
return os.environ.get("DEEPSEEK_API_KEY") or str(
|
||||||
|
app.deepseek_settings.get("api_key", ""))
|
||||||
|
|
||||||
|
|
||||||
|
# ── PlatformConfig overlay ────────────────────────────────────────────
|
||||||
|
|
||||||
|
def apply_pair_overrides(
|
||||||
|
cfg: PlatformConfig, app: Optional[AppConfig] = None
|
||||||
|
) -> PlatformConfig:
|
||||||
|
"""Stamp deployment settings from config.json onto a PlatformConfig.
|
||||||
|
|
||||||
|
Called by the pair factories BEFORE their validated per-timeframe
|
||||||
|
deltas, so e.g. the M5 hour-9 append lands on top of the JSON base.
|
||||||
|
The pair's base blocked_hours_utc is set on BOTH mirrored copies
|
||||||
|
(xau_strategy + backtest) so they cannot drift.
|
||||||
|
"""
|
||||||
|
app = app or get_app_config()
|
||||||
|
overrides = app.pair_overrides.get(cfg.symbol, {})
|
||||||
|
if "blocked_hours_utc" in overrides:
|
||||||
|
hours = list(overrides["blocked_hours_utc"])
|
||||||
|
cfg.xau_strategy.blocked_hours_utc = hours
|
||||||
|
cfg.backtest.blocked_hours_utc = list(hours)
|
||||||
|
cfg.scan_pairs = list(app.scan_pairs)
|
||||||
|
cfg.scan_timeframes = list(app.scan_timeframes)
|
||||||
|
return cfg
|
||||||
@@ -0,0 +1,629 @@
|
|||||||
|
"""
|
||||||
|
ForexBot2 — Filter Rejection Diagnostic
|
||||||
|
========================================
|
||||||
|
Replays the same backtest loop as gpu_backtest.py but instead of
|
||||||
|
collecting trades, counts how many M15 bars are rejected by each
|
||||||
|
gate in the signal pipeline.
|
||||||
|
|
||||||
|
Produces a summary table showing where potential trades are being
|
||||||
|
lost, helping tune filter thresholds.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python backtest/diagnose_filters.py --pair XAUUSD --days 365
|
||||||
|
python backtest/diagnose_filters.py --pair XAUUSD --days 365 --sample-every 1
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from collections import Counter, defaultdict
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
# Ensure project root on sys.path.
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
if str(PROJECT_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from config import ( # noqa: E402
|
||||||
|
PAIR_INFO,
|
||||||
|
PlatformConfig,
|
||||||
|
SignalStrength,
|
||||||
|
Timeframe,
|
||||||
|
)
|
||||||
|
from signal_aggregator import SignalAggregator, SignalReport # noqa: E402
|
||||||
|
|
||||||
|
# Reuse GPU backtest infrastructure (data loading, indicator pre-compute, etc.)
|
||||||
|
from gpu_backtest import ( # noqa: E402
|
||||||
|
BAR_SECONDS,
|
||||||
|
GPUTechnicalEngine,
|
||||||
|
PAIR_ALIASES,
|
||||||
|
BacktestConfig,
|
||||||
|
IndicatorCache,
|
||||||
|
fetch_history,
|
||||||
|
precompute_indicators,
|
||||||
|
_is_xau_blocked_by_calendar,
|
||||||
|
)
|
||||||
|
from providers import make_data_provider # noqa: E402
|
||||||
|
|
||||||
|
logger = logging.getLogger("diagnose_filters")
|
||||||
|
|
||||||
|
|
||||||
|
# ═════════════════════════════════════════════════════════════════════
|
||||||
|
# OUTCOME CLASSIFICATION
|
||||||
|
# ═════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def classify_bar(
|
||||||
|
report: SignalReport,
|
||||||
|
is_xau: bool,
|
||||||
|
) -> Tuple[str, List[str]]:
|
||||||
|
"""Classify a bar's outcome and list which filters killed it.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(outcome, [filter_names])
|
||||||
|
|
||||||
|
Outcomes:
|
||||||
|
"TRADE" — quality C+ and trade_setup exists
|
||||||
|
"QUALITY_OK_NO_SETUP" — quality C+ but trade_setup is None
|
||||||
|
"LOW_QUALITY" — signal reached BUY/SELL but quality D or F
|
||||||
|
"NEUTRAL" — signal stayed NEUTRAL (score didn't reach +/-30)
|
||||||
|
|
||||||
|
Filter names are extracted from report.warnings.
|
||||||
|
"""
|
||||||
|
filters_hit: List[str] = []
|
||||||
|
|
||||||
|
# Known filter tags, matched against the exact warning strings the
|
||||||
|
# aggregator emits (refreshed 2026-07-30 for gates 6d-6i). ORDER
|
||||||
|
# MATTERS: most-specific prefix first, and each warning maps to at
|
||||||
|
# most ONE tag (break on first hit) — substring families like
|
||||||
|
# "M5 FILTER: SELL quality" vs "M5 FILTER" and "TREND EXHAUSTION"
|
||||||
|
# vs "EXHAUSTION" would otherwise double-count.
|
||||||
|
filter_tags = [
|
||||||
|
# M5 noise gates (6i, _apply_m5_filters)
|
||||||
|
("M5 FILTER: |score|", "M5_SCORE"),
|
||||||
|
("M5 FILTER: no MTF agreement", "M5_MTF"),
|
||||||
|
("M5 FILTER: no confirmation TF", "M5_CONF"),
|
||||||
|
("M5 FILTER: SELL quality", "M5_SELL_QUALITY"),
|
||||||
|
("M5 FILTER: SELL blocked — RSI", "M5_SELL_RSI"),
|
||||||
|
("M5 FILTER: '", "M5_VOL"),
|
||||||
|
("M5 FILTER", "M5_OTHER"),
|
||||||
|
# XAU SELL gates (6e/6g/6g2/6g3)
|
||||||
|
("SELL FILTER: quality", "SELL_QUALITY"),
|
||||||
|
("SELL FILTER: volatility", "SELL_VOL"),
|
||||||
|
("SELL FILTER: regime", "SELL_REGIME"),
|
||||||
|
("UTC entry blocked for shorts", "SELL_HOUR"),
|
||||||
|
# Hard gates
|
||||||
|
("MTF OPPOSITION", "MTF_OPPOSITION"),
|
||||||
|
("EXHAUSTED TREND FILTER", "EXHAUSTED_TREND"),
|
||||||
|
("BIG CANDLE FILTER", "BIG_CANDLE"),
|
||||||
|
("WEEKEND FILTER", "WEEKEND_FILTER"),
|
||||||
|
("SESSION FILTER", "SESSION_FILTER"),
|
||||||
|
("DAY FILTER", "DAY_FILTER"),
|
||||||
|
("LOW ATR FILTER", "LOW_ATR_FILTER"),
|
||||||
|
("REGIME FILTER", "REGIME_FILTER"),
|
||||||
|
# Soft penalties / context warnings
|
||||||
|
("TREND EXHAUSTION", "TREND_EXHAUSTION"),
|
||||||
|
("EXHAUSTION", "EXHAUSTION_PENALTY"),
|
||||||
|
("NO PULLBACK", "NO_PULLBACK_PENALTY"),
|
||||||
|
("Weak pullback", "WEAK_PULLBACK_PENALTY"),
|
||||||
|
("Extended — RSI", "RSI_EXTENDED_PENALTY"),
|
||||||
|
("Counter-trend", "COUNTER_TREND_PENALTY"),
|
||||||
|
("EXTREME volatility", "EXTREME_VOL_PENALTY"),
|
||||||
|
("NEAR EMA200", "NEAR_EMA200_PENALTY"),
|
||||||
|
("FAR FROM EMA200", "FAR_EMA200_PENALTY"),
|
||||||
|
("EVENT RISK", "EVENT_RISK"),
|
||||||
|
("Timeframes CONFLICT", "MTF_CONFLICT"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for w in report.warnings:
|
||||||
|
for tag, name in filter_tags:
|
||||||
|
if tag in w:
|
||||||
|
filters_hit.append(name)
|
||||||
|
break
|
||||||
|
|
||||||
|
# Determine outcome
|
||||||
|
non_neutral = report.final_signal not in (SignalStrength.NEUTRAL,)
|
||||||
|
quality_ok = report.signal_quality in ("A+", "A", "B", "C")
|
||||||
|
|
||||||
|
if quality_ok and report.trade_setup is not None:
|
||||||
|
return "TRADE", filters_hit
|
||||||
|
elif quality_ok and report.trade_setup is None:
|
||||||
|
return "QUALITY_OK_NO_SETUP", filters_hit
|
||||||
|
elif non_neutral and not quality_ok:
|
||||||
|
return "LOW_QUALITY", filters_hit
|
||||||
|
else:
|
||||||
|
return "NEUTRAL", filters_hit
|
||||||
|
|
||||||
|
|
||||||
|
# ═════════════════════════════════════════════════════════════════════
|
||||||
|
# SCORE DISTRIBUTION TRACKER
|
||||||
|
# ═════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class ScoreTracker:
|
||||||
|
"""Tracks score/confidence distributions for analysis."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.scores: List[float] = []
|
||||||
|
self.confidences: List[float] = []
|
||||||
|
self.abs_scores: List[float] = []
|
||||||
|
# How many bars had score in various ranges
|
||||||
|
self.score_bins = Counter()
|
||||||
|
self.conf_bins = Counter()
|
||||||
|
# Track "near miss" bars — score close to thresholds
|
||||||
|
self.near_miss_score: int = 0 # score 20-29 (almost BUY/SELL)
|
||||||
|
self.near_miss_conf: int = 0 # confidence 45-54 (almost passing)
|
||||||
|
|
||||||
|
def record(self, score: float, confidence: float):
|
||||||
|
self.scores.append(score)
|
||||||
|
self.confidences.append(confidence)
|
||||||
|
self.abs_scores.append(abs(score))
|
||||||
|
|
||||||
|
# Score bins
|
||||||
|
a = abs(score)
|
||||||
|
if a < 10:
|
||||||
|
self.score_bins["0-9"] += 1
|
||||||
|
elif a < 20:
|
||||||
|
self.score_bins["10-19"] += 1
|
||||||
|
elif a < 30:
|
||||||
|
self.score_bins["20-29"] += 1
|
||||||
|
self.near_miss_score += 1
|
||||||
|
elif a < 40:
|
||||||
|
self.score_bins["30-39"] += 1
|
||||||
|
elif a < 50:
|
||||||
|
self.score_bins["40-49"] += 1
|
||||||
|
else:
|
||||||
|
self.score_bins["50+"] += 1
|
||||||
|
|
||||||
|
# Confidence bins
|
||||||
|
if confidence < 30:
|
||||||
|
self.conf_bins["0-29"] += 1
|
||||||
|
elif confidence < 45:
|
||||||
|
self.conf_bins["30-44"] += 1
|
||||||
|
elif confidence < 55:
|
||||||
|
self.conf_bins["45-54"] += 1
|
||||||
|
self.near_miss_conf += 1
|
||||||
|
elif confidence < 65:
|
||||||
|
self.conf_bins["55-64"] += 1
|
||||||
|
elif confidence < 75:
|
||||||
|
self.conf_bins["65-74"] += 1
|
||||||
|
else:
|
||||||
|
self.conf_bins["75+"] += 1
|
||||||
|
|
||||||
|
|
||||||
|
# ═════════════════════════════════════════════════════════════════════
|
||||||
|
# MAIN DIAGNOSTIC LOOP
|
||||||
|
# ═════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def run_diagnosis(
|
||||||
|
pair: str,
|
||||||
|
start: datetime,
|
||||||
|
end: datetime,
|
||||||
|
sample_every: int = 4,
|
||||||
|
use_cache: bool = True,
|
||||||
|
primary_tf: Timeframe = Timeframe.M15,
|
||||||
|
confirm_tfs: Optional[List[Timeframe]] = None,
|
||||||
|
warmup_bars: int = 250,
|
||||||
|
) -> None:
|
||||||
|
if confirm_tfs is None:
|
||||||
|
confirm_tfs = [Timeframe.H1, Timeframe.H4]
|
||||||
|
|
||||||
|
pair_info = PAIR_INFO[pair]
|
||||||
|
display = pair_info["display"]
|
||||||
|
is_xau = display == "XAU/USD"
|
||||||
|
|
||||||
|
platform_cfg = PlatformConfig(
|
||||||
|
symbol=pair,
|
||||||
|
display_name=display,
|
||||||
|
pip_value=pair_info["pip_value"],
|
||||||
|
primary_timeframe=primary_tf,
|
||||||
|
confirmation_timeframes=list(confirm_tfs),
|
||||||
|
)
|
||||||
|
|
||||||
|
provider = make_data_provider(platform_cfg)
|
||||||
|
|
||||||
|
# ── Load data ──
|
||||||
|
timeframes = [primary_tf] + list(confirm_tfs)
|
||||||
|
pad_days = max(60, warmup_bars // 4)
|
||||||
|
fetch_start = start - timedelta(days=pad_days)
|
||||||
|
|
||||||
|
dfs_full: Dict[Timeframe, pd.DataFrame] = {}
|
||||||
|
for tf in timeframes:
|
||||||
|
df = fetch_history(
|
||||||
|
provider, pair, tf,
|
||||||
|
start=fetch_start, end=end,
|
||||||
|
use_cache=use_cache,
|
||||||
|
)
|
||||||
|
if df.empty:
|
||||||
|
raise RuntimeError(f"No data for {pair} {tf.value}")
|
||||||
|
dfs_full[tf] = df
|
||||||
|
|
||||||
|
# ── Pre-compute indicators ──
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
caches: Dict[str, IndicatorCache] = {
|
||||||
|
tf.value: precompute_indicators(dfs_full[tf], platform_cfg.technical, gpu_log=True)
|
||||||
|
for tf in timeframes
|
||||||
|
}
|
||||||
|
logger.info(f"Indicator pre-compute: {time.perf_counter() - t0:.2f}s")
|
||||||
|
|
||||||
|
# ── Build aggregator with GPU engine ──
|
||||||
|
aggregator = SignalAggregator(platform_cfg)
|
||||||
|
aggregator.tech_engine = GPUTechnicalEngine(platform_cfg.technical, caches)
|
||||||
|
|
||||||
|
# ── Walk primary TF ──
|
||||||
|
primary_df = dfs_full[primary_tf]
|
||||||
|
bt_start_ts = pd.Timestamp(start)
|
||||||
|
bt_end_ts = pd.Timestamp(end)
|
||||||
|
|
||||||
|
first_eligible = max(
|
||||||
|
warmup_bars,
|
||||||
|
int(primary_df.index.searchsorted(bt_start_ts)),
|
||||||
|
)
|
||||||
|
last_pos = min(
|
||||||
|
int(primary_df.index.searchsorted(bt_end_ts, side="right")),
|
||||||
|
len(primary_df),
|
||||||
|
)
|
||||||
|
|
||||||
|
primary_dur = pd.Timedelta(seconds=BAR_SECONDS[primary_tf])
|
||||||
|
|
||||||
|
# Counters
|
||||||
|
total_bars_in_window = 0
|
||||||
|
total_bars_sampled = 0
|
||||||
|
outcome_counts = Counter()
|
||||||
|
filter_counts = Counter()
|
||||||
|
# Filters that killed bars that were otherwise directional (non-NEUTRAL score)
|
||||||
|
directional_filter_counts = Counter()
|
||||||
|
# Pre-pipeline rejections (XAU calendar filters applied before analyze_from_dataframes)
|
||||||
|
pre_pipeline = Counter()
|
||||||
|
|
||||||
|
# Track signal directions
|
||||||
|
signal_dir_counts = Counter()
|
||||||
|
quality_counts = Counter()
|
||||||
|
|
||||||
|
# Score tracker
|
||||||
|
tracker = ScoreTracker()
|
||||||
|
|
||||||
|
# Track "would-have-been" trades: bars where the primary score was
|
||||||
|
# directional but got killed by a specific filter
|
||||||
|
filter_kill_breakdown = defaultdict(Counter)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Diagnosing {display} {primary_tf.value}: "
|
||||||
|
f"bars {first_eligible}-{last_pos} (every {sample_every}th bar)"
|
||||||
|
)
|
||||||
|
|
||||||
|
t_loop = time.perf_counter()
|
||||||
|
for pos in range(first_eligible, last_pos, sample_every):
|
||||||
|
total_bars_in_window += 1
|
||||||
|
ts = primary_df.index[pos]
|
||||||
|
decision_time = ts + primary_dur
|
||||||
|
|
||||||
|
# ── XAU calendar pre-filter (same as gpu_backtest.py) ──
|
||||||
|
if is_xau:
|
||||||
|
block = _is_xau_blocked_by_calendar(ts, platform_cfg)
|
||||||
|
if block:
|
||||||
|
pre_pipeline[block] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
total_bars_sampled += 1
|
||||||
|
|
||||||
|
# ── Build sliced dfs (same logic as gpu_backtest.py) ──
|
||||||
|
slice_dfs: Dict[Timeframe, pd.DataFrame] = {}
|
||||||
|
for tf in timeframes:
|
||||||
|
full = dfs_full[tf]
|
||||||
|
bar_dur = pd.Timedelta(seconds=BAR_SECONDS[tf])
|
||||||
|
cutoff = decision_time - bar_dur
|
||||||
|
cut = int(full.index.searchsorted(cutoff, side="right"))
|
||||||
|
if cut == 0:
|
||||||
|
continue
|
||||||
|
slice_dfs[tf] = full.iloc[:cut]
|
||||||
|
|
||||||
|
if primary_tf not in slice_dfs:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# ── Run the full signal pipeline ──
|
||||||
|
report: SignalReport = aggregator.analyze_from_dataframes(
|
||||||
|
symbol=pair,
|
||||||
|
dfs=slice_dfs,
|
||||||
|
context=None,
|
||||||
|
use_news=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Record score/confidence
|
||||||
|
tracker.record(report.final_score, report.final_confidence)
|
||||||
|
|
||||||
|
# Signal direction
|
||||||
|
signal_dir_counts[report.final_signal.name] += 1
|
||||||
|
quality_counts[report.signal_quality] += 1
|
||||||
|
|
||||||
|
# Classify
|
||||||
|
outcome, filters_hit = classify_bar(report, is_xau)
|
||||||
|
outcome_counts[outcome] += 1
|
||||||
|
|
||||||
|
for f in filters_hit:
|
||||||
|
filter_counts[f] += 1
|
||||||
|
|
||||||
|
# If the primary score was directional (>= 15 or <= -15), the bar
|
||||||
|
# had some potential — track what killed it
|
||||||
|
if abs(report.primary_score) >= 15:
|
||||||
|
for f in filters_hit:
|
||||||
|
directional_filter_counts[f] += 1
|
||||||
|
|
||||||
|
# For bars that got quality D/F but had a directional signal,
|
||||||
|
# track which filter warnings appeared
|
||||||
|
if outcome in ("LOW_QUALITY", "QUALITY_OK_NO_SETUP"):
|
||||||
|
for f in filters_hit:
|
||||||
|
filter_kill_breakdown[outcome][f] += 1
|
||||||
|
|
||||||
|
elapsed = time.perf_counter() - t_loop
|
||||||
|
|
||||||
|
# ═════════════════════════════════════════════════════════════════
|
||||||
|
# PRINT RESULTS
|
||||||
|
# ═════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
bar = "=" * 80
|
||||||
|
print(f"\n{bar}")
|
||||||
|
print(f" FILTER REJECTION DIAGNOSTIC — {display}")
|
||||||
|
print(f" {start.date()} to {end.date()} | {primary_tf.value} "
|
||||||
|
f"(sampled every {sample_every}th bar)")
|
||||||
|
print(f"{bar}")
|
||||||
|
|
||||||
|
# Scale counts if sampling
|
||||||
|
scale_note = ""
|
||||||
|
if sample_every > 1:
|
||||||
|
scale_note = f" (sampled {total_bars_sampled} of ~{total_bars_in_window * sample_every} eligible bars)"
|
||||||
|
print(f"\n Total bars in backtest window: {total_bars_in_window}")
|
||||||
|
if pre_pipeline:
|
||||||
|
for reason, count in sorted(pre_pipeline.items()):
|
||||||
|
print(f" Pre-pipeline rejection ({reason}): {count}")
|
||||||
|
print(f" Bars analyzed: {total_bars_sampled}{scale_note}")
|
||||||
|
print(f" Analysis took: {elapsed:.1f}s "
|
||||||
|
f"({total_bars_sampled / max(elapsed, 0.001):.0f} bars/s)")
|
||||||
|
|
||||||
|
# ── Outcome breakdown ──
|
||||||
|
print(f"\n{'-' * 80}")
|
||||||
|
print(" OUTCOME BREAKDOWN")
|
||||||
|
print(f"{'-' * 80}")
|
||||||
|
outcome_order = ["TRADE", "QUALITY_OK_NO_SETUP", "LOW_QUALITY", "NEUTRAL"]
|
||||||
|
outcome_labels = {
|
||||||
|
"TRADE": "Actual trades (quality C+ & setup exists)",
|
||||||
|
"QUALITY_OK_NO_SETUP": "Quality C+ but NO trade setup generated",
|
||||||
|
"LOW_QUALITY": "Directional signal but quality D/F",
|
||||||
|
"NEUTRAL": "NEUTRAL (score didn't reach +/-30 or conf < 55)",
|
||||||
|
}
|
||||||
|
for o in outcome_order:
|
||||||
|
count = outcome_counts.get(o, 0)
|
||||||
|
pct = count / max(total_bars_sampled, 1) * 100
|
||||||
|
label = outcome_labels.get(o, o)
|
||||||
|
print(f" {count:6d} ({pct:5.1f}%) {label}")
|
||||||
|
|
||||||
|
# ── Signal direction breakdown ──
|
||||||
|
print(f"\n{'-' * 80}")
|
||||||
|
print(" SIGNAL DIRECTION BREAKDOWN")
|
||||||
|
print(f"{'-' * 80}")
|
||||||
|
for name in ["STRONG_BUY", "BUY", "NEUTRAL", "SELL", "STRONG_SELL"]:
|
||||||
|
count = signal_dir_counts.get(name, 0)
|
||||||
|
pct = count / max(total_bars_sampled, 1) * 100
|
||||||
|
print(f" {count:6d} ({pct:5.1f}%) {name}")
|
||||||
|
|
||||||
|
# ── Quality grade breakdown ──
|
||||||
|
print(f"\n{'-' * 80}")
|
||||||
|
print(" QUALITY GRADE BREAKDOWN")
|
||||||
|
print(f"{'-' * 80}")
|
||||||
|
for grade in ["A+", "A", "B", "C", "D", "F"]:
|
||||||
|
count = quality_counts.get(grade, 0)
|
||||||
|
pct = count / max(total_bars_sampled, 1) * 100
|
||||||
|
print(f" {count:6d} ({pct:5.1f}%) Grade {grade}")
|
||||||
|
|
||||||
|
# ── Filter hit counts (all bars) ──
|
||||||
|
print(f"\n{'-' * 80}")
|
||||||
|
print(" FILTER HIT COUNTS (all bars, a bar can hit multiple filters)")
|
||||||
|
print(f"{'-' * 80}")
|
||||||
|
if filter_counts:
|
||||||
|
for name, count in filter_counts.most_common():
|
||||||
|
pct = count / max(total_bars_sampled, 1) * 100
|
||||||
|
print(f" {count:6d} ({pct:5.1f}%) {name}")
|
||||||
|
else:
|
||||||
|
print(" (no filters triggered)")
|
||||||
|
|
||||||
|
# ── Filters that killed directional bars (primary_score >= 15) ──
|
||||||
|
print(f"\n{'-' * 80}")
|
||||||
|
print(" FILTERS KILLING DIRECTIONAL BARS (|primary_score| >= 15)")
|
||||||
|
print(f"{'-' * 80}")
|
||||||
|
if directional_filter_counts:
|
||||||
|
for name, count in directional_filter_counts.most_common():
|
||||||
|
print(f" {count:6d} {name}")
|
||||||
|
else:
|
||||||
|
print(" (none)")
|
||||||
|
|
||||||
|
# ── Filter breakdown for LOW_QUALITY bars ──
|
||||||
|
if filter_kill_breakdown.get("LOW_QUALITY"):
|
||||||
|
print(f"\n{'-' * 80}")
|
||||||
|
print(" FILTERS ON LOW_QUALITY BARS (had BUY/SELL but quality D/F)")
|
||||||
|
print(f"{'-' * 80}")
|
||||||
|
for name, count in filter_kill_breakdown["LOW_QUALITY"].most_common():
|
||||||
|
print(f" {count:6d} {name}")
|
||||||
|
|
||||||
|
# ── Filter breakdown for QUALITY_OK_NO_SETUP bars ──
|
||||||
|
if filter_kill_breakdown.get("QUALITY_OK_NO_SETUP"):
|
||||||
|
print(f"\n{'-' * 80}")
|
||||||
|
print(" FILTERS ON QUALITY_OK_NO_SETUP BARS (quality C+ but no trade setup)")
|
||||||
|
print(f"{'-' * 80}")
|
||||||
|
for name, count in filter_kill_breakdown["QUALITY_OK_NO_SETUP"].most_common():
|
||||||
|
print(f" {count:6d} {name}")
|
||||||
|
|
||||||
|
# ── Score distribution ──
|
||||||
|
print(f"\n{'-' * 80}")
|
||||||
|
print(" SCORE DISTRIBUTION (|score|)")
|
||||||
|
print(f"{'-' * 80}")
|
||||||
|
for bin_name in ["0-9", "10-19", "20-29", "30-39", "40-49", "50+"]:
|
||||||
|
count = tracker.score_bins.get(bin_name, 0)
|
||||||
|
pct = count / max(total_bars_sampled, 1) * 100
|
||||||
|
marker = " <-- near miss (almost BUY/SELL)" if bin_name == "20-29" else ""
|
||||||
|
print(f" {count:6d} ({pct:5.1f}%) |score| {bin_name}{marker}")
|
||||||
|
|
||||||
|
# ── Confidence distribution ──
|
||||||
|
print(f"\n{'-' * 80}")
|
||||||
|
print(" CONFIDENCE DISTRIBUTION")
|
||||||
|
print(f"{'-' * 80}")
|
||||||
|
for bin_name in ["0-29", "30-44", "45-54", "55-64", "65-74", "75+"]:
|
||||||
|
count = tracker.conf_bins.get(bin_name, 0)
|
||||||
|
pct = count / max(total_bars_sampled, 1) * 100
|
||||||
|
marker = " <-- near miss (almost passing)" if bin_name == "45-54" else ""
|
||||||
|
print(f" {count:6d} ({pct:5.1f}%) confidence {bin_name}{marker}")
|
||||||
|
|
||||||
|
# ── Near misses ──
|
||||||
|
print(f"\n{'-' * 80}")
|
||||||
|
print(" NEAR MISSES")
|
||||||
|
print(f"{'-' * 80}")
|
||||||
|
print(f" {tracker.near_miss_score:6d} Bars with |score| 20-29 (threshold is 30)")
|
||||||
|
print(f" {tracker.near_miss_conf:6d} Bars with confidence 45-54 (threshold is 55)")
|
||||||
|
|
||||||
|
# ── Summary statistics ──
|
||||||
|
if tracker.scores:
|
||||||
|
scores_arr = np.array(tracker.scores)
|
||||||
|
conf_arr = np.array(tracker.confidences)
|
||||||
|
print(f"\n{'-' * 80}")
|
||||||
|
print(" SCORE/CONFIDENCE STATISTICS")
|
||||||
|
print(f"{'-' * 80}")
|
||||||
|
print(f" Score: mean={np.mean(scores_arr):+.1f} "
|
||||||
|
f"median={np.median(scores_arr):+.1f} "
|
||||||
|
f"std={np.std(scores_arr):.1f} "
|
||||||
|
f"min={np.min(scores_arr):+.1f} "
|
||||||
|
f"max={np.max(scores_arr):+.1f}")
|
||||||
|
print(f" Confidence: mean={np.mean(conf_arr):.1f} "
|
||||||
|
f"median={np.median(conf_arr):.1f} "
|
||||||
|
f"std={np.std(conf_arr):.1f} "
|
||||||
|
f"min={np.min(conf_arr):.1f} "
|
||||||
|
f"max={np.max(conf_arr):.1f}")
|
||||||
|
|
||||||
|
# ── Blocked hours analysis (for XAU) ──
|
||||||
|
if is_xau:
|
||||||
|
blocked = set(platform_cfg.xau_strategy.blocked_hours_utc)
|
||||||
|
all_hours = set(range(24))
|
||||||
|
open_hours = sorted(all_hours - blocked)
|
||||||
|
print(f"\n{'-' * 80}")
|
||||||
|
print(" XAU HOUR/DAY CONFIG")
|
||||||
|
print(f"{'-' * 80}")
|
||||||
|
print(f" Blocked hours: {sorted(blocked)}")
|
||||||
|
print(f" Open hours: {open_hours}")
|
||||||
|
print(f" Allowed days: {platform_cfg.xau_strategy.allowed_weekdays} "
|
||||||
|
f"(0=Mon..4=Fri)")
|
||||||
|
print(f" Open fraction: {len(open_hours)/24:.0%} of hours x "
|
||||||
|
f"{len(platform_cfg.xau_strategy.allowed_weekdays)}/5 weekdays = "
|
||||||
|
f"{len(open_hours)/24 * len(platform_cfg.xau_strategy.allowed_weekdays)/5:.0%} of trading time")
|
||||||
|
|
||||||
|
# ── Interpretation / key takeaways ──
|
||||||
|
print(f"\n{bar}")
|
||||||
|
print(" KEY TAKEAWAYS")
|
||||||
|
print(f"{bar}")
|
||||||
|
|
||||||
|
neutral_pct = outcome_counts.get("NEUTRAL", 0) / max(total_bars_sampled, 1) * 100
|
||||||
|
low_q_pct = outcome_counts.get("LOW_QUALITY", 0) / max(total_bars_sampled, 1) * 100
|
||||||
|
trade_count = outcome_counts.get("TRADE", 0)
|
||||||
|
|
||||||
|
print(f" - {neutral_pct:.1f}% of bars never reach a directional signal (NEUTRAL)")
|
||||||
|
print(f" - {low_q_pct:.1f}% of bars get a BUY/SELL but confidence/quality too low")
|
||||||
|
print(f" - {trade_count} bars produce actual trades")
|
||||||
|
|
||||||
|
if filter_counts:
|
||||||
|
top3 = filter_counts.most_common(3)
|
||||||
|
print(f" - Top rejection filters:")
|
||||||
|
for name, count in top3:
|
||||||
|
print(f" {name}: {count} bars hit")
|
||||||
|
|
||||||
|
if tracker.near_miss_score > 50:
|
||||||
|
print(f" - SIGNIFICANT: {tracker.near_miss_score} bars had |score| 20-29 "
|
||||||
|
f"(lowering threshold from 30 to 25 would capture many)")
|
||||||
|
|
||||||
|
if tracker.near_miss_conf > 50:
|
||||||
|
print(f" - SIGNIFICANT: {tracker.near_miss_conf} bars had confidence 45-54 "
|
||||||
|
f"(lowering threshold from 55 to 50 would capture many)")
|
||||||
|
|
||||||
|
print(f"\n{bar}\n")
|
||||||
|
|
||||||
|
|
||||||
|
# ═════════════════════════════════════════════════════════════════════
|
||||||
|
# CLI
|
||||||
|
# ═════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def _resolve_pair(s: str) -> str:
|
||||||
|
s_up = s.upper().strip()
|
||||||
|
if s_up in PAIR_ALIASES:
|
||||||
|
return PAIR_ALIASES[s_up]
|
||||||
|
if s_up in PAIR_INFO:
|
||||||
|
return s_up
|
||||||
|
raise SystemExit(f"Unknown pair '{s}'. Valid: {', '.join(sorted(PAIR_ALIASES))}")
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_tf(s: str) -> Timeframe:
|
||||||
|
for tf in Timeframe:
|
||||||
|
if tf.value == s:
|
||||||
|
return tf
|
||||||
|
raise SystemExit(f"Unknown timeframe '{s}'")
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: Optional[List[str]] = None) -> int:
|
||||||
|
p = argparse.ArgumentParser(
|
||||||
|
description="Diagnose which filters reject the most bars in the backtest pipeline",
|
||||||
|
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
||||||
|
)
|
||||||
|
p.add_argument("--pair", required=True, help="XAUUSD | GOLD")
|
||||||
|
p.add_argument("--days", type=int, default=365, help="Days of history")
|
||||||
|
p.add_argument("--start", help="ISO date YYYY-MM-DD (overrides --days)")
|
||||||
|
p.add_argument("--end", help="ISO date YYYY-MM-DD (defaults to today)")
|
||||||
|
p.add_argument("--primary-tf", default="15min", help="Primary TF")
|
||||||
|
p.add_argument("--confirm-tfs", default="1h,4h", help="Comma-separated confirmation TFs")
|
||||||
|
p.add_argument("--warmup-bars", type=int, default=250)
|
||||||
|
p.add_argument("--sample-every", type=int, default=4,
|
||||||
|
help="Analyze every Nth bar (1=all, 4=25%% sample for speed)")
|
||||||
|
p.add_argument("--no-cache", action="store_true", help="Skip parquet cache")
|
||||||
|
p.add_argument("--log-level", default="INFO")
|
||||||
|
args = p.parse_args(argv)
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=args.log_level.upper(),
|
||||||
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||||
|
)
|
||||||
|
|
||||||
|
pair = _resolve_pair(args.pair)
|
||||||
|
primary_tf = _resolve_tf(args.primary_tf)
|
||||||
|
confirm_tfs = [_resolve_tf(s.strip()) for s in args.confirm_tfs.split(",") if s.strip()]
|
||||||
|
|
||||||
|
end = (
|
||||||
|
datetime.fromisoformat(args.end).replace(tzinfo=timezone.utc)
|
||||||
|
if args.end else datetime.now(timezone.utc)
|
||||||
|
)
|
||||||
|
if args.start:
|
||||||
|
start = datetime.fromisoformat(args.start).replace(tzinfo=timezone.utc)
|
||||||
|
else:
|
||||||
|
start = end - timedelta(days=args.days)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Diagnosing {PAIR_INFO[pair]['display']} | "
|
||||||
|
f"Window: {start.date()} -> {end.date()} | "
|
||||||
|
f"Sample: every {args.sample_every}th bar"
|
||||||
|
)
|
||||||
|
|
||||||
|
run_diagnosis(
|
||||||
|
pair=pair,
|
||||||
|
start=start,
|
||||||
|
end=end,
|
||||||
|
sample_every=args.sample_every,
|
||||||
|
use_cache=not args.no_cache,
|
||||||
|
primary_tf=primary_tf,
|
||||||
|
confirm_tfs=confirm_tfs,
|
||||||
|
warmup_bars=args.warmup_bars,
|
||||||
|
)
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,59 @@
|
|||||||
|
Note down entery and exit Why we took trade?
|
||||||
|
What were each indicator signal on that specific time when we took trade
|
||||||
|
How many indicators agreed at trade time
|
||||||
|
Always backtest using engine.py and signal_aggregator.py
|
||||||
|
Dont take trades on weekends and Fridays
|
||||||
|
Keep TP small 1.3 Reward ratio
|
||||||
|
At the end of test make table of results and print all values you observed.
|
||||||
|
Trade context
|
||||||
|
|
||||||
|
Time of day and trading session (Asian/London/NY/overlap)
|
||||||
|
Day of week (track Mon/Tue/Wed/Thu performance separately)
|
||||||
|
Market regime at entry: trending, ranging, or choppy (use ATR or ADX)
|
||||||
|
Volatility level (ATR value or ATR percentile vs recent average)
|
||||||
|
Pair/instrument traded
|
||||||
|
News proximity (high-impact news within 30 min before/after entry)
|
||||||
|
|
||||||
|
Trade execution details
|
||||||
|
|
||||||
|
Spread at entry in pips
|
||||||
|
Slippage on entry and exit (planned vs actual fill)
|
||||||
|
Time in trade (minutes/hours from entry to exit)
|
||||||
|
Maximum Adverse Excursion (MAE): how far against you it went
|
||||||
|
Maximum Favorable Excursion (MFE): how far in profit before pullback
|
||||||
|
|
||||||
|
Risk metrics
|
||||||
|
|
||||||
|
Max consecutive losses (streak)
|
||||||
|
Max consecutive wins (streak)
|
||||||
|
Expectancy: (Win% x Avg Win) minus (Loss% x Avg Loss)
|
||||||
|
Expectancy per hour in trade
|
||||||
|
Recovery factor: Total R divided by Max DD
|
||||||
|
|
||||||
|
Indicator/signal analysis
|
||||||
|
|
||||||
|
Each indicator's standalone win rate when it agreed with the trade
|
||||||
|
Confluence level performance (2 vs 3 vs 4+ indicators agreeing)
|
||||||
|
Which indicator disagreed on losing trades (find the "canary")
|
||||||
|
|
||||||
|
Decision quality
|
||||||
|
|
||||||
|
Rate each setup A/B/C at entry, before knowing outcome
|
||||||
|
Verify A-grade setups actually win more often
|
||||||
|
Track system trades vs discretionary deviations separately
|
||||||
|
|
||||||
|
Process notes
|
||||||
|
|
||||||
|
Screenshot or chart snapshot at entry and exit
|
||||||
|
Emotional state (calm, FOMO, revenge, tired)
|
||||||
|
What would have happened if you held longer or exited sooner
|
||||||
|
|
||||||
|
Aggregate analysis at end of backtest
|
||||||
|
|
||||||
|
Equity curve, not just total R
|
||||||
|
Performance breakdown by session, day, volatility regime
|
||||||
|
Win rate over rolling 20-trade windows (check for edge decay)
|
||||||
|
|
||||||
|
Critical extra
|
||||||
|
"Do not trade" log: setups flagged but skipped, and what happened after
|
||||||
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""Research tooling: calendar sourcing/store and edge-screening utilities.
|
||||||
|
|
||||||
|
Nothing here is imported by the live bot; these modules support
|
||||||
|
backtesting and edge validation only.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""Trade-book diff: relaxed/gated run vs its control.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python backtest/research/book_diff.py CONTROL.csv CANDIDATE.csv BAR_MINUTES
|
||||||
|
|
||||||
|
Decision numbers are RUN-LEVEL (ΔtotalR, ΔPF, ΔmaxDD, Δtrades) — never
|
||||||
|
the added-trade bucket alone: the 5-bar entry cooldown means new
|
||||||
|
entries displace existing ones. The added/removed decomposition and the
|
||||||
|
per-decision-hour histogram of added trades are diagnostics.
|
||||||
|
|
||||||
|
Shifted trades = removed+added pairs in the same direction within
|
||||||
|
±5 bars (cooldown displacement, not a genuinely new/lost signal).
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
|
||||||
|
def _metrics(df):
|
||||||
|
r = df["r_multiple"]
|
||||||
|
gains, losses = r[r > 0].sum(), -r[r <= 0].sum()
|
||||||
|
cum = r.cumsum()
|
||||||
|
return dict(trades=len(df), total_r=r.sum(),
|
||||||
|
pf=gains / max(losses, 1e-9),
|
||||||
|
wr=(r > 0).mean() * 100 if len(df) else 0.0,
|
||||||
|
max_dd=(cum.cummax() - cum).max() if len(cum) else 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def diff_books(control_csv, candidate_csv, bar_minutes, split="2026-01-29"):
|
||||||
|
ctrl, cand = pd.read_csv(control_csv), pd.read_csv(candidate_csv)
|
||||||
|
for df in (ctrl, cand):
|
||||||
|
df["_t"] = pd.to_datetime(df["entry_time"], utc=True, format="mixed")
|
||||||
|
df["_key"] = df["entry_time"].astype(str) + "|" + df["direction"]
|
||||||
|
|
||||||
|
added = cand[~cand["_key"].isin(ctrl["_key"])].copy()
|
||||||
|
removed = ctrl[~ctrl["_key"].isin(cand["_key"])].copy()
|
||||||
|
|
||||||
|
# cooldown displacement: same-direction removed/added within ±5 bars
|
||||||
|
tol = pd.Timedelta(minutes=5 * bar_minutes)
|
||||||
|
shifted = 0
|
||||||
|
rem_pool = removed.copy()
|
||||||
|
for _, a in added.iterrows():
|
||||||
|
m = rem_pool[(rem_pool["direction"] == a["direction"])
|
||||||
|
& ((rem_pool["_t"] - a["_t"]).abs() <= tol)]
|
||||||
|
if len(m):
|
||||||
|
shifted += 1
|
||||||
|
rem_pool = rem_pool.drop(m.index[0])
|
||||||
|
|
||||||
|
mc, mk = _metrics(ctrl), _metrics(cand)
|
||||||
|
split_ts = pd.Timestamp(split, tz="UTC")
|
||||||
|
halves = (cand[cand["_t"] < split_ts]["r_multiple"].sum(),
|
||||||
|
cand[cand["_t"] >= split_ts]["r_multiple"].sum())
|
||||||
|
hour_hist = None
|
||||||
|
if len(added):
|
||||||
|
added["dec_hour"] = (added["_t"]
|
||||||
|
- pd.Timedelta(minutes=bar_minutes)).dt.hour
|
||||||
|
hour_hist = added.groupby("dec_hour")["r_multiple"] \
|
||||||
|
.agg(["count", "sum"]).round(2)
|
||||||
|
return dict(
|
||||||
|
control=mc, candidate=mk,
|
||||||
|
delta_r=mk["total_r"] - mc["total_r"],
|
||||||
|
delta_pf=mk["pf"] - mc["pf"],
|
||||||
|
delta_dd=mk["max_dd"] - mc["max_dd"],
|
||||||
|
delta_trades=mk["trades"] - mc["trades"],
|
||||||
|
added_n=len(added), added_r=added["r_multiple"].sum(),
|
||||||
|
removed_n=len(removed), removed_r=removed["r_multiple"].sum(),
|
||||||
|
shifted=shifted, halves=halves, added_by_hour=hour_hist,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
d = diff_books(sys.argv[1], sys.argv[2], int(sys.argv[3]))
|
||||||
|
c, k = d["control"], d["candidate"]
|
||||||
|
print(f"control : {c['trades']:4d} trades {c['total_r']:+7.1f}R "
|
||||||
|
f"PF {c['pf']:.2f} WR {c['wr']:.1f}% DD {c['max_dd']:.1f}")
|
||||||
|
print(f"candidate: {k['trades']:4d} trades {k['total_r']:+7.1f}R "
|
||||||
|
f"PF {k['pf']:.2f} WR {k['wr']:.1f}% DD {k['max_dd']:.1f}")
|
||||||
|
print(f"ΔR {d['delta_r']:+.1f} ΔPF {d['delta_pf']:+.2f} "
|
||||||
|
f"ΔDD {d['delta_dd']:+.1f} Δtrades {d['delta_trades']:+d}")
|
||||||
|
print(f"added {d['added_n']} ({d['added_r']:+.1f}R) "
|
||||||
|
f"removed {d['removed_n']} ({d['removed_r']:+.1f}R) "
|
||||||
|
f"shifted≈{d['shifted']}")
|
||||||
|
print(f"candidate halves: H1 {d['halves'][0]:+.1f}R H2 {d['halves'][1]:+.1f}R")
|
||||||
|
if d["added_by_hour"] is not None:
|
||||||
|
print("\nadded trades by decision hour:")
|
||||||
|
print(d["added_by_hour"].to_string())
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""Burst-candle frequency by UTC hour — the empirical "hedge fund hours".
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python backtest/research/burst_profile.py PARQUET_GLOB
|
||||||
|
|
||||||
|
Per UTC bar-close hour of the given bars: % of bars whose range is
|
||||||
|
>= K x the PRIOR bar's Wilder ATR(14), for K in {3,4,5}; mean bar
|
||||||
|
range; and mean signed drift over the 6 bars following a >=4x burst
|
||||||
|
(does price continue or snap back?). Analysis tooling only.
|
||||||
|
"""
|
||||||
|
import glob
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
|
||||||
|
def burst_table(parquet_glob: str, bar_minutes: int = 5):
|
||||||
|
files = sorted(glob.glob(parquet_glob))
|
||||||
|
if not files:
|
||||||
|
raise SystemExit(f"no parquet matches {parquet_glob}")
|
||||||
|
df = pd.concat([pd.read_parquet(f) for f in files]).sort_index()
|
||||||
|
df = df[~df.index.duplicated(keep="last")]
|
||||||
|
h, l, c = df["High"], df["Low"], df["Close"]
|
||||||
|
tr = pd.concat([h - l, (h - c.shift()).abs(), (l - c.shift()).abs()],
|
||||||
|
axis=1).max(axis=1)
|
||||||
|
atr = tr.ewm(alpha=1 / 14, min_periods=14, adjust=False).mean()
|
||||||
|
ratio = (h - l) / atr.shift(1)
|
||||||
|
close_hour = (df.index + pd.Timedelta(minutes=bar_minutes)).hour
|
||||||
|
fwd6 = c.shift(-6) - c # signed move over the 6 bars after this one
|
||||||
|
out = pd.DataFrame({
|
||||||
|
"bars": ratio.groupby(close_hour).size(),
|
||||||
|
**{f"pct_ge_{k}x": (ratio >= k).groupby(close_hour).mean() * 100
|
||||||
|
for k in (3, 4, 5)},
|
||||||
|
"mean_range": (h - l).groupby(close_hour).mean(),
|
||||||
|
"post4x_drift6": fwd6[ratio >= 4].groupby(close_hour[ratio >= 4]).mean(),
|
||||||
|
})
|
||||||
|
return out.round(3)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print(burst_table(sys.argv[1],
|
||||||
|
int(sys.argv[2]) if len(sys.argv) > 2 else 5).to_string())
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
"""Commit-ladder campaign driver (2026-08).
|
||||||
|
|
||||||
|
Question: of all signal-code states shipped in the last two months, which
|
||||||
|
configuration is the most accurate? Historical in-era backtest numbers are
|
||||||
|
not comparable (the backtester's realism model changed on 07-08/07-16 and
|
||||||
|
the data feed switched to Oanda on 07-28), so every historical
|
||||||
|
configuration is recreated on TODAY's harness via --set overrides and
|
||||||
|
replayed on the same pinned Oanda bars. Bug-fix commits stay on; the
|
||||||
|
ladder compares shipped tuning decisions only.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python backtest/research/commit_ladder.py # full matrix
|
||||||
|
python backtest/research/commit_ladder.py --tf 5min # one timeframe
|
||||||
|
python backtest/research/commit_ladder.py --windows 12mo,oos
|
||||||
|
|
||||||
|
Resumable: a run whose CSV already exists in the output dir is skipped.
|
||||||
|
Outputs: backtest/research/commit_ladder_2026-08/<variant>_<window>.csv
|
||||||
|
+ .log per run, summary.csv, and a printed ranking table.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
BT = ROOT / "backtest" / "gpu_backtest.py"
|
||||||
|
OUT = ROOT / "backtest" / "research" / "commit_ladder_2026-08"
|
||||||
|
|
||||||
|
WINDOWS = {
|
||||||
|
"12mo": ("2025-07-30", "2026-07-30"),
|
||||||
|
"oos": ("2024-06-01", "2025-03-30"),
|
||||||
|
"5yr": ("2021-07-30", "2026-07-30"),
|
||||||
|
}
|
||||||
|
SPLIT_12MO = pd.Timestamp("2026-01-29", tz="UTC") # book_diff.py's split
|
||||||
|
|
||||||
|
# (variant, "commit date what-it-represents", [--set overrides])
|
||||||
|
VARIANTS: dict[str, list[tuple[str, str, list[str]]]] = {
|
||||||
|
"5min": [
|
||||||
|
("M5-A", "dad2432 07-09 gates+trailing+pullback",
|
||||||
|
["xau_strategy.blocked_hours_utc=[0,1,2,10,11,13,16,21]",
|
||||||
|
"xau_strategy.cp_enabled=false",
|
||||||
|
"signals.trail_dist_r=0.75",
|
||||||
|
"xau_strategy.m5_min_abs_score=30",
|
||||||
|
"xau_strategy.m5_min_confirmation_score=10"]),
|
||||||
|
("M5-B", "f3ba4d5 07-11 +hour-9 block",
|
||||||
|
["xau_strategy.blocked_hours_utc=[0,1,2,10,11,13,16,21,9]",
|
||||||
|
"xau_strategy.cp_enabled=false",
|
||||||
|
"signals.trail_dist_r=0.75",
|
||||||
|
"xau_strategy.m5_min_abs_score=30",
|
||||||
|
"xau_strategy.m5_min_confirmation_score=10"]),
|
||||||
|
("M5-C", "de6424e 07-16 +chart patterns",
|
||||||
|
["xau_strategy.blocked_hours_utc=[0,1,2,10,11,13,16,21,9]",
|
||||||
|
"signals.trail_dist_r=0.75",
|
||||||
|
"xau_strategy.m5_min_abs_score=30",
|
||||||
|
"xau_strategy.m5_min_confirmation_score=10"]),
|
||||||
|
("M5-D", "3ee79b5 07-21 +trail dist 0.85",
|
||||||
|
["xau_strategy.blocked_hours_utc=[0,1,2,10,11,13,16,21,9]",
|
||||||
|
"xau_strategy.m5_min_abs_score=30",
|
||||||
|
"xau_strategy.m5_min_confirmation_score=10"]),
|
||||||
|
("M5-E", "0396970 07-30 relaxation (HEAD)", []),
|
||||||
|
],
|
||||||
|
"15min": [
|
||||||
|
("M15-A", "987ec5b 07-09 pre-campaigns",
|
||||||
|
["signals.tp_floor_rr=0.5",
|
||||||
|
"xau_strategy.blocked_regimes=[]",
|
||||||
|
"xau_strategy.sell_blocked_hours_utc=[]",
|
||||||
|
"signals.trail_arm_r=0.60"]),
|
||||||
|
("M15-B", "f3ba4d5/f88e39f 07-11 1R floor + regime block",
|
||||||
|
["xau_strategy.sell_blocked_hours_utc=[]",
|
||||||
|
"signals.trail_arm_r=0.60"]),
|
||||||
|
("M15-C", "7d3fd95 07-16 +SELL hour 15/17 block",
|
||||||
|
["signals.trail_arm_r=0.60"]),
|
||||||
|
("M15-D", "bf8e5fb 07-21 trail arm 0.70 (HEAD)", []),
|
||||||
|
],
|
||||||
|
"1h": [
|
||||||
|
("H1-A", "pre-d95d31a 07-08 TP-tightening model",
|
||||||
|
["signals.min_rr_ratio=0.4",
|
||||||
|
"signals.tp_tightening_factor=0.8"]),
|
||||||
|
("H1-B", "HEAD", []),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def run_one(tf: str, name: str, overrides: list[str], wkey: str,
|
||||||
|
spread: str | None = None) -> Path:
|
||||||
|
suffix = f"_s{spread}" if spread else ""
|
||||||
|
csv_path = OUT / f"{name}_{wkey}{suffix}.csv"
|
||||||
|
log_path = OUT / f"{name}_{wkey}{suffix}.log"
|
||||||
|
if csv_path.exists():
|
||||||
|
print(f" [skip] {csv_path.name} exists")
|
||||||
|
return csv_path
|
||||||
|
start, end = WINDOWS[wkey]
|
||||||
|
cmd = [sys.executable, str(BT), "--pair", "XAUUSD",
|
||||||
|
"--primary-tf", tf, "--start", start, "--end", end,
|
||||||
|
"--export-csv", str(csv_path)]
|
||||||
|
if spread:
|
||||||
|
cmd += ["--spread", spread]
|
||||||
|
for ov in overrides:
|
||||||
|
cmd += ["--set", ov]
|
||||||
|
print(f" [run ] {csv_path.name} ...", flush=True)
|
||||||
|
with open(log_path, "w") as lf:
|
||||||
|
rc = subprocess.run(cmd, stdout=lf, stderr=subprocess.STDOUT,
|
||||||
|
cwd=ROOT).returncode
|
||||||
|
if rc != 0:
|
||||||
|
raise SystemExit(f"backtest failed (rc={rc}) — see {log_path}")
|
||||||
|
log = log_path.read_text()
|
||||||
|
n_ov = log.count("--set override")
|
||||||
|
if n_ov != len(overrides):
|
||||||
|
raise SystemExit(
|
||||||
|
f"{name}_{wkey}: expected {len(overrides)} --set overrides, "
|
||||||
|
f"log shows {n_ov} — see {log_path}")
|
||||||
|
if "Loading cached" not in log and wkey != "sanity":
|
||||||
|
print(f" [warn] {log_path.name}: no 'Loading cached' line — "
|
||||||
|
f"data was fetched live for at least one timeframe")
|
||||||
|
return csv_path
|
||||||
|
|
||||||
|
|
||||||
|
def metrics(csv_path: Path, wkey: str) -> dict:
|
||||||
|
df = pd.read_csv(csv_path, parse_dates=["entry_time"])
|
||||||
|
if df.empty:
|
||||||
|
return {"trades": 0, "total_r": 0.0, "wr": 0.0, "pf": 0.0,
|
||||||
|
"max_dd": 0.0, "h1_r": 0.0, "h2_r": 0.0, "years_pos": ""}
|
||||||
|
if df["entry_time"].dt.tz is None:
|
||||||
|
df["entry_time"] = df["entry_time"].dt.tz_localize("UTC")
|
||||||
|
r = df["r_multiple"]
|
||||||
|
pos, neg = r[r > 0].sum(), r[r < 0].sum()
|
||||||
|
cum = r.cumsum()
|
||||||
|
dd = (pd.concat([pd.Series([0.0]), cum]).cummax()[1:] - cum).max()
|
||||||
|
out = {
|
||||||
|
"trades": len(df),
|
||||||
|
"total_r": round(r.sum(), 1),
|
||||||
|
"wr": round((r > 0).mean() * 100, 1),
|
||||||
|
"pf": round(pos / abs(neg), 2) if neg else float("inf"),
|
||||||
|
"max_dd": round(dd, 1),
|
||||||
|
"h1_r": "", "h2_r": "", "years_pos": "",
|
||||||
|
}
|
||||||
|
if wkey == "12mo":
|
||||||
|
h1 = r[df["entry_time"] < SPLIT_12MO].sum()
|
||||||
|
out["h1_r"] = round(h1, 1)
|
||||||
|
out["h2_r"] = round(r.sum() - h1, 1)
|
||||||
|
if wkey == "5yr":
|
||||||
|
by_year = r.groupby(df["entry_time"].dt.year).sum()
|
||||||
|
out["years_pos"] = f"{(by_year > 0).sum()}/{len(by_year)}"
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--tf", choices=list(VARIANTS), default=None)
|
||||||
|
ap.add_argument("--windows", default="12mo,oos,5yr")
|
||||||
|
args = ap.parse_args()
|
||||||
|
OUT.mkdir(parents=True, exist_ok=True)
|
||||||
|
wkeys = [w.strip() for w in args.windows.split(",")]
|
||||||
|
tfs = [args.tf] if args.tf else list(VARIANTS)
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for tf in tfs:
|
||||||
|
for name, label, overrides in VARIANTS[tf]:
|
||||||
|
for wkey in wkeys:
|
||||||
|
csv_path = run_one(tf, name, overrides, wkey)
|
||||||
|
m = metrics(csv_path, wkey)
|
||||||
|
rows.append({"tf": tf, "variant": name, "represents": label,
|
||||||
|
"window": wkey, **m})
|
||||||
|
print(f" {name} {wkey}: {m['trades']} trades, "
|
||||||
|
f"{m['total_r']}R, PF {m['pf']}, WR {m['wr']}%")
|
||||||
|
|
||||||
|
summary = pd.DataFrame(rows)
|
||||||
|
summary.to_csv(OUT / "summary.csv", index=False)
|
||||||
|
print("\n=== SUMMARY (also in summary.csv) ===")
|
||||||
|
print(summary.to_string(index=False))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
"""Dukascopy XAU/USD tick download + bid/ask spread study.
|
||||||
|
|
||||||
|
Validates ForexBot2's $0.30 round-trip cost assumption (config:
|
||||||
|
SignalConfig.round_trip_cost / PAIR_INFO default_spread). Round-trip
|
||||||
|
cost of a market entry+exit = one ask-bid spread width (buy at ask,
|
||||||
|
sell at bid), so the assumption holds iff typical spread ~ $0.30.
|
||||||
|
|
||||||
|
bi5 format: LZMA 'alone' stream of 20-byte big-endian records
|
||||||
|
(ms_in_hour uint32, ask uint32, bid uint32, ask_vol f32, bid_vol f32);
|
||||||
|
XAUUSD prices scaled x1000. URL months are 0-indexed.
|
||||||
|
"""
|
||||||
|
import lzma
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from datetime import date, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
import requests
|
||||||
|
|
||||||
|
SCALE = 1000.0
|
||||||
|
CACHE = Path(__file__).parent / "tick_cache"
|
||||||
|
CACHE.mkdir(exist_ok=True)
|
||||||
|
URL = "https://datafeed.dukascopy.com/datafeed/XAUUSD/{y}/{m:02d}/{d:02d}/{h:02d}h_ticks.bi5"
|
||||||
|
|
||||||
|
# Bot's tradable bar-close hours: session 03:00-23:00 minus XAU blocked
|
||||||
|
# hours [0,1,2,10,11,13,16,21] (M5 additionally blocks 9 — reported
|
||||||
|
# separately below).
|
||||||
|
BLOCKED = {0, 1, 2, 10, 11, 13, 16, 21}
|
||||||
|
TRADABLE = sorted(set(range(3, 23)) - BLOCKED)
|
||||||
|
|
||||||
|
# ── Day sample ────────────────────────────────────────────────────────
|
||||||
|
recent = [date(2026, 7, 6) + timedelta(days=i) for i in range(17)]
|
||||||
|
recent = [d for d in recent if d.weekday() < 5] # Mon-Fri Jul 6-22
|
||||||
|
monthly = [date(2025, 8, 13), date(2025, 9, 10), date(2025, 10, 15),
|
||||||
|
date(2025, 11, 12), date(2025, 12, 10), date(2026, 1, 14),
|
||||||
|
date(2026, 2, 11), date(2026, 3, 11), date(2026, 4, 15),
|
||||||
|
date(2026, 5, 13), date(2026, 6, 10)] # mid-month Wednesdays
|
||||||
|
nfp = [date(2026, 6, 5), date(2026, 7, 3)] # NFP Fridays
|
||||||
|
sundays = [date(2026, 7, 12), date(2026, 7, 19)] # Sun 22-24 open
|
||||||
|
DAYS = sorted(set(recent + monthly + nfp + sundays))
|
||||||
|
assert all(d.weekday() < 5 for d in recent + monthly + nfp)
|
||||||
|
|
||||||
|
|
||||||
|
_tls = __import__("threading").local()
|
||||||
|
|
||||||
|
|
||||||
|
def _session() -> requests.Session:
|
||||||
|
if not hasattr(_tls, "s"):
|
||||||
|
_tls.s = requests.Session()
|
||||||
|
_tls.s.headers["User-Agent"] = "Mozilla/5.0 (research; spread-study)"
|
||||||
|
return _tls.s
|
||||||
|
|
||||||
|
|
||||||
|
def fetch(day: date, hour: int) -> Path:
|
||||||
|
p = CACHE / f"{day.isoformat()}_{hour:02d}.bi5"
|
||||||
|
if p.exists():
|
||||||
|
return p
|
||||||
|
u = URL.format(y=day.year, m=day.month - 1, d=day.day, h=hour)
|
||||||
|
last = None
|
||||||
|
for attempt in range(4):
|
||||||
|
try:
|
||||||
|
r = _session().get(u, timeout=30)
|
||||||
|
if r.status_code != 200:
|
||||||
|
raise RuntimeError(f"{u} -> HTTP {r.status_code}")
|
||||||
|
p.write_bytes(r.content)
|
||||||
|
return p
|
||||||
|
except (requests.ConnectionError, requests.Timeout, RuntimeError) as exc:
|
||||||
|
last = exc
|
||||||
|
__import__("time").sleep(1.5 * (attempt + 1))
|
||||||
|
if hasattr(_tls, "s"):
|
||||||
|
_tls.s.close()
|
||||||
|
del _tls.s
|
||||||
|
raise RuntimeError(f"giving up on {u}: {last}")
|
||||||
|
|
||||||
|
|
||||||
|
def decode(p: Path, day: date, hour: int) -> pd.DataFrame:
|
||||||
|
blob = p.read_bytes()
|
||||||
|
if not blob:
|
||||||
|
return pd.DataFrame()
|
||||||
|
raw = lzma.decompress(blob)
|
||||||
|
n = len(raw) // 20
|
||||||
|
a = np.frombuffer(raw[: n * 20], dtype=">u4").reshape(n, 5)
|
||||||
|
f = np.frombuffer(raw[: n * 20], dtype=">f4").reshape(n, 5)
|
||||||
|
df = pd.DataFrame({
|
||||||
|
"ms": a[:, 0].astype(np.int64),
|
||||||
|
"ask": a[:, 1] / SCALE,
|
||||||
|
"bid": a[:, 2] / SCALE,
|
||||||
|
})
|
||||||
|
df["spread"] = df["ask"] - df["bid"]
|
||||||
|
df["date"], df["hour"] = day.isoformat(), hour
|
||||||
|
df["weekday"] = day.weekday()
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
def load_all() -> pd.DataFrame:
|
||||||
|
jobs = [(d, h) for d in DAYS for h in range(24)]
|
||||||
|
with ThreadPoolExecutor(max_workers=3) as ex:
|
||||||
|
paths = list(ex.map(lambda j: fetch(*j), jobs))
|
||||||
|
frames = [decode(p, d, h) for p, (d, h) in zip(paths, jobs)]
|
||||||
|
df = pd.concat([f for f in frames if not f.empty], ignore_index=True)
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
def wstats(g: pd.DataFrame) -> pd.Series:
|
||||||
|
"""Tick stats + time-weighted mean (each tick's spread held until the
|
||||||
|
next tick; last tick held to hour end)."""
|
||||||
|
tw_num = tw_den = 0.0
|
||||||
|
keys = [k for k in ("date", "hour") if k in g.columns]
|
||||||
|
for _, grp in g.groupby(keys, sort=False):
|
||||||
|
ms = grp["ms"].to_numpy()
|
||||||
|
sp = grp["spread"].to_numpy()
|
||||||
|
w = np.diff(np.append(ms, 3_600_000)).clip(min=0)
|
||||||
|
tw_num += float((sp * w).sum())
|
||||||
|
tw_den += float(w.sum())
|
||||||
|
s = g["spread"]
|
||||||
|
return pd.Series({
|
||||||
|
"ticks": len(s),
|
||||||
|
"tw_mean": tw_num / tw_den if tw_den else np.nan,
|
||||||
|
"mean": s.mean(), "median": s.median(),
|
||||||
|
"p75": s.quantile(0.75), "p90": s.quantile(0.90),
|
||||||
|
"p99": s.quantile(0.99), "max": s.max(),
|
||||||
|
"pct_le_030": (s <= 0.30).mean() * 100,
|
||||||
|
"pct_ge_060": (s >= 0.60).mean() * 100,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def m5_boundary_spreads(df: pd.DataFrame) -> pd.DataFrame:
|
||||||
|
"""Spread of the FIRST tick at/after each M5 boundary — what a
|
||||||
|
next-open fill actually pays."""
|
||||||
|
rows = []
|
||||||
|
for (day, hour), grp in df.groupby(["date", "hour"], sort=False):
|
||||||
|
ms = grp["ms"].to_numpy()
|
||||||
|
sp = grp["spread"].to_numpy()
|
||||||
|
wd = grp["weekday"].iat[0]
|
||||||
|
for b in range(0, 3_600_000, 300_000):
|
||||||
|
i = np.searchsorted(ms, b)
|
||||||
|
if i < len(ms) and ms[i] - b <= 30_000: # tick within 30 s
|
||||||
|
rows.append((day, wd, hour, b // 60_000, sp[i]))
|
||||||
|
return pd.DataFrame(rows, columns=["date", "weekday", "hour", "minute", "spread"])
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
df = load_all()
|
||||||
|
print(f"days={df['date'].nunique()} ticks={len(df):,} "
|
||||||
|
f"price range {df['bid'].min():.0f}-{df['ask'].max():.0f}")
|
||||||
|
|
||||||
|
print("\n── Overall (all sampled hours) ──")
|
||||||
|
print(wstats(df).round(3).to_string())
|
||||||
|
|
||||||
|
trad = df[df["hour"].isin(TRADABLE) & (df["weekday"] < 5)]
|
||||||
|
print("\n── Bot-tradable hours (session 03-23 minus blocked, Mon-Fri) ──")
|
||||||
|
print(wstats(trad).round(3).to_string())
|
||||||
|
|
||||||
|
roll = df[df["hour"].isin([21, 22, 23])]
|
||||||
|
print("\n── Rollover window 21-24 UTC ──")
|
||||||
|
print(wstats(roll).round(3).to_string())
|
||||||
|
|
||||||
|
print("\n── Per UTC hour ──")
|
||||||
|
per_hour = df[df["weekday"] < 5].groupby("hour").apply(wstats, include_groups=False).round(3)
|
||||||
|
per_hour["tradable"] = ["Y" if h in TRADABLE else "-" for h in per_hour.index]
|
||||||
|
print(per_hour.to_string())
|
||||||
|
per_hour.to_csv(Path(__file__).parent / "duka_spread_by_hour.csv")
|
||||||
|
|
||||||
|
print("\n── M5 next-open boundary fills ──")
|
||||||
|
mb = m5_boundary_spreads(df)
|
||||||
|
mb_trad = mb[mb["hour"].isin(TRADABLE) & (mb["weekday"] < 5)]
|
||||||
|
for name, d in [("all hours", mb), ("tradable hours", mb_trad)]:
|
||||||
|
s = d["spread"]
|
||||||
|
print(f"{name}: n={len(s):,} mean={s.mean():.3f} median={s.median():.3f} "
|
||||||
|
f"p90={s.quantile(0.9):.3f} p99={s.quantile(0.99):.3f} "
|
||||||
|
f"%<=0.30={(s <= 0.30).mean()*100:.1f} %>=0.60={(s >= 0.60).mean()*100:.1f}")
|
||||||
|
mb.to_csv(Path(__file__).parent / "duka_m5_boundary_spreads.csv", index=False)
|
||||||
|
|
||||||
|
print("\n── By month (tradable hours, tick median / tw_mean) ──")
|
||||||
|
trad2 = trad.copy()
|
||||||
|
trad2["month"] = trad2["date"].str[:7]
|
||||||
|
bym = trad2.groupby("month").apply(wstats, include_groups=False).round(3)
|
||||||
|
print(bym[["ticks", "tw_mean", "median", "p90", "pct_le_030", "pct_ge_060"]].to_string())
|
||||||
|
|
||||||
|
print("\n── NFP Fridays 12:00-15:00 UTC vs same hours other days ──")
|
||||||
|
nfp_days = {"2026-06-05", "2026-07-03"}
|
||||||
|
win = df[df["hour"].isin([12, 13, 14])]
|
||||||
|
for name, d in [("NFP", win[win["date"].isin(nfp_days)]),
|
||||||
|
("non-NFP", win[~win["date"].isin(nfp_days) & (win["weekday"] < 5)])]:
|
||||||
|
s = d["spread"]
|
||||||
|
print(f"{name}: n={len(s):,} tick-median={s.median():.3f} p90={s.quantile(0.9):.3f} "
|
||||||
|
f"p99={s.quantile(0.99):.3f} max={s.max():.2f}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
hour,ticks,tw_mean,mean,median,p75,p90,p99,max,pct_le_030,pct_ge_060,tradable
|
||||||
|
0,362893.0,0.749,0.768,0.79,0.82,0.86,1.1,7.69,0.053,94.561,-
|
||||||
|
1,540182.0,0.735,0.742,0.74,0.81,0.87,1.0,5.59,0.073,90.449,-
|
||||||
|
2,431589.0,0.716,0.722,0.73,0.8,0.86,0.95,2.96,0.1,85.27,-
|
||||||
|
3,343952.0,0.708,0.711,0.71,0.79,0.84,0.95,1.67,0.081,82.939,Y
|
||||||
|
4,246262.0,0.704,0.702,0.71,0.78,0.82,0.94,1.86,0.098,82.627,Y
|
||||||
|
5,388365.0,0.712,0.716,0.72,0.79,0.82,0.95,2.17,0.076,86.745,Y
|
||||||
|
6,368999.0,0.65,0.651,0.65,0.69,0.74,0.94,2.32,0.079,71.668,Y
|
||||||
|
7,313149.0,0.601,0.598,0.59,0.65,0.69,0.994,3.08,0.239,42.904,Y
|
||||||
|
8,347276.0,0.612,0.616,0.6,0.66,0.74,1.0,3.04,0.308,48.349,Y
|
||||||
|
9,321619.0,0.612,0.615,0.6,0.66,0.72,1.0,5.95,0.288,50.644,Y
|
||||||
|
10,295271.0,0.611,0.618,0.6,0.66,0.71,1.0,9.36,0.164,49.253,-
|
||||||
|
11,336963.0,0.619,0.624,0.6,0.67,0.79,1.0,4.97,0.203,50.178,-
|
||||||
|
12,476773.0,0.66,0.697,0.63,0.72,0.877,1.367,14.98,0.257,59.633,Y
|
||||||
|
13,651425.0,0.641,0.648,0.63,0.69,0.787,1.487,11.407,0.296,60.297,-
|
||||||
|
14,673391.0,0.625,0.625,0.63,0.68,0.77,0.9,11.98,0.458,59.085,Y
|
||||||
|
15,598398.0,0.636,0.638,0.64,0.7,0.78,0.91,4.49,0.433,66.296,Y
|
||||||
|
16,485023.0,0.648,0.644,0.64,0.7,0.757,0.917,4.09,0.118,68.496,-
|
||||||
|
17,450119.0,0.626,0.628,0.627,0.7,0.726,0.84,4.08,0.154,63.337,Y
|
||||||
|
18,411538.0,0.626,0.63,0.637,0.7,0.71,0.79,4.16,0.107,67.734,Y
|
||||||
|
19,435396.0,0.638,0.643,0.65,0.7,0.72,0.896,5.804,0.11,72.007,Y
|
||||||
|
20,212654.0,0.684,0.665,0.66,0.7,0.76,1.217,7.15,0.226,70.215,Y
|
||||||
|
21,31319.0,0.627,0.622,0.61,0.68,0.75,1.02,2.08,0.089,53.153,-
|
||||||
|
22,108879.0,0.829,0.834,0.797,0.84,0.98,2.05,10.11,0.056,95.434,Y
|
||||||
|
23,161079.0,1.032,0.913,0.8,0.89,1.307,2.77,13.174,0.092,95.41,-
|
||||||
|
@@ -0,0 +1,158 @@
|
|||||||
|
"""Fetch XAU/USD M15 candles from the Oanda v20 REST API.
|
||||||
|
|
||||||
|
Mechanical data-fetching infrastructure only — no analysis here.
|
||||||
|
Paginates GET /v3/instruments/XAU_USD/candles from a start cursor
|
||||||
|
through "now" and writes the result to a parquet file.
|
||||||
|
|
||||||
|
Run from the repo root:
|
||||||
|
venv/bin/python backtest/research/fetch_oanda_m15.py
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
import requests
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||||
|
|
||||||
|
from app_config import oanda_api_token, oanda_base_url # noqa: E402
|
||||||
|
|
||||||
|
INSTRUMENT = "XAU_USD"
|
||||||
|
GRANULARITY = "M15"
|
||||||
|
START = datetime(2025, 7, 1, 0, 0, 0, tzinfo=timezone.utc)
|
||||||
|
OUT_PATH = Path(__file__).parent / "oanda_xau_m15_20250701_now.parquet"
|
||||||
|
COUNT = 5000
|
||||||
|
REQUEST_SLEEP_S = 0.5
|
||||||
|
FRESHNESS_WINDOW = timedelta(minutes=30)
|
||||||
|
|
||||||
|
|
||||||
|
def _rfc3339(dt: datetime) -> str:
|
||||||
|
return dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_all_candles():
|
||||||
|
token = oanda_api_token()
|
||||||
|
base_url = oanda_base_url()
|
||||||
|
if not token:
|
||||||
|
raise RuntimeError(
|
||||||
|
"No Oanda API token resolved (env OANDA_API_TOKEN / "
|
||||||
|
"config.json oanda.api_token). Aborting.")
|
||||||
|
|
||||||
|
url = f"{base_url}/v3/instruments/{INSTRUMENT}/candles"
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"Accept-Datetime-Format": "RFC3339",
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor = START
|
||||||
|
include_first = True
|
||||||
|
all_rows = []
|
||||||
|
n_requests = 0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
params = {
|
||||||
|
"granularity": GRANULARITY,
|
||||||
|
"price": "MBA",
|
||||||
|
"count": COUNT,
|
||||||
|
"dailyAlignment": 0,
|
||||||
|
"alignmentTimezone": "Etc/UTC",
|
||||||
|
"from": _rfc3339(cursor),
|
||||||
|
"includeFirst": "true" if include_first else "false",
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = requests.get(url, headers=headers, params=params, timeout=30)
|
||||||
|
n_requests += 1
|
||||||
|
if resp.status_code != 200:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Oanda API request failed: status={resp.status_code}\n"
|
||||||
|
f"URL: {resp.url}\n"
|
||||||
|
f"Response body: {resp.text}")
|
||||||
|
|
||||||
|
payload = resp.json()
|
||||||
|
candles = payload.get("candles", [])
|
||||||
|
|
||||||
|
for c in candles:
|
||||||
|
if not c.get("complete", False):
|
||||||
|
continue
|
||||||
|
mid = c["mid"]
|
||||||
|
bid = c["bid"]
|
||||||
|
ask = c["ask"]
|
||||||
|
all_rows.append({
|
||||||
|
"Datetime": pd.Timestamp(c["time"]).tz_convert("UTC")
|
||||||
|
if pd.Timestamp(c["time"]).tzinfo is not None
|
||||||
|
else pd.Timestamp(c["time"]).tz_localize("UTC"),
|
||||||
|
"Open": float(mid["o"]),
|
||||||
|
"High": float(mid["h"]),
|
||||||
|
"Low": float(mid["l"]),
|
||||||
|
"Close": float(mid["c"]),
|
||||||
|
"Volume": int(c["volume"]),
|
||||||
|
"bid_o": float(bid["o"]),
|
||||||
|
"bid_h": float(bid["h"]),
|
||||||
|
"bid_l": float(bid["l"]),
|
||||||
|
"bid_c": float(bid["c"]),
|
||||||
|
"ask_o": float(ask["o"]),
|
||||||
|
"ask_h": float(ask["h"]),
|
||||||
|
"ask_l": float(ask["l"]),
|
||||||
|
"ask_c": float(ask["c"]),
|
||||||
|
})
|
||||||
|
|
||||||
|
print(f" request {n_requests}: from={params['from']} "
|
||||||
|
f"got {len(candles)} candles (complete kept so far: "
|
||||||
|
f"{len(all_rows)})", file=sys.stderr)
|
||||||
|
|
||||||
|
if not candles:
|
||||||
|
break
|
||||||
|
|
||||||
|
last_time = pd.Timestamp(candles[-1]["time"])
|
||||||
|
if last_time.tzinfo is None:
|
||||||
|
last_time = last_time.tz_localize("UTC")
|
||||||
|
else:
|
||||||
|
last_time = last_time.tz_convert("UTC")
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
# Oanda's "count" includes the "from" boundary bar itself, so with
|
||||||
|
# includeFirst=false every full page comes back as count-1, not
|
||||||
|
# count (verified empirically: count=N, includeFirst=false always
|
||||||
|
# yields N-1 candles when a full page of data exists ahead of the
|
||||||
|
# cursor). Compare against the count actually expected for this
|
||||||
|
# request's includeFirst setting, not the raw requested count.
|
||||||
|
expected_full_page = COUNT if include_first else COUNT - 1
|
||||||
|
got_fewer_than_requested = len(candles) < expected_full_page
|
||||||
|
near_now = (now - last_time.to_pydatetime()) < FRESHNESS_WINDOW
|
||||||
|
|
||||||
|
if got_fewer_than_requested or near_now:
|
||||||
|
break
|
||||||
|
|
||||||
|
cursor = last_time.to_pydatetime()
|
||||||
|
include_first = False
|
||||||
|
time.sleep(REQUEST_SLEEP_S)
|
||||||
|
|
||||||
|
return all_rows, n_requests
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print(f"Fetching {INSTRUMENT} {GRANULARITY} candles from "
|
||||||
|
f"{_rfc3339(START)} through now...", file=sys.stderr)
|
||||||
|
|
||||||
|
rows, n_requests = fetch_all_candles()
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
raise RuntimeError("No complete candles were fetched.")
|
||||||
|
|
||||||
|
df = pd.DataFrame(rows)
|
||||||
|
df = df.set_index("Datetime")
|
||||||
|
df = df.sort_index()
|
||||||
|
df = df[~df.index.duplicated(keep="last")]
|
||||||
|
|
||||||
|
df.to_parquet(OUT_PATH)
|
||||||
|
|
||||||
|
print(f"\nSaved {len(df)} rows to {OUT_PATH}", file=sys.stderr)
|
||||||
|
print(f"Requests made: {n_requests}", file=sys.stderr)
|
||||||
|
print(f"First timestamp: {df.index[0]}", file=sys.stderr)
|
||||||
|
print(f"Last timestamp: {df.index[-1]}", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
"""Forex Factory calendar scraper -> backtest/cache/calendar.parquet.
|
||||||
|
|
||||||
|
Fetches weekly calendar pages and parses the event JSON embedded in
|
||||||
|
`window.calendarComponentStates` (the `days:` array). Event `dateline`
|
||||||
|
is a unix epoch, so timestamps carry no timezone ambiguity — verified
|
||||||
|
2026-07-11 against known release times (NFP 12:30/13:30 UTC across DST,
|
||||||
|
ECB 12:15/13:15) and against EUR/USD M15 volatility: the bar containing
|
||||||
|
a high-impact event averages 3.5x normal range, and shifting event
|
||||||
|
times by ±1h destroys that alignment.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python backtest/research/ff_calendar_scraper.py --start 2024-01-01 --end 2026-07-13
|
||||||
|
python backtest/research/ff_calendar_scraper.py # refresh: last stored week -> today
|
||||||
|
|
||||||
|
Merges into the existing parquet on event (id, dateline), so refreshing
|
||||||
|
is idempotent and also picks up revised `actual` values for recent weeks.
|
||||||
|
Be polite: ~1.6s between requests (a full year is ~52 requests).
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from datetime import date, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
import requests
|
||||||
|
|
||||||
|
CACHE_DIR = Path(__file__).resolve().parent.parent / "cache"
|
||||||
|
PARQUET = CACHE_DIR / "calendar.parquet"
|
||||||
|
|
||||||
|
UA = ("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
|
||||||
|
"(KHTML, like Gecko) Chrome/126.0 Safari/537.36")
|
||||||
|
|
||||||
|
FIELDS = ["id", "dateline", "name", "country", "currency", "impactName",
|
||||||
|
"timeLabel", "timeMasked", "actual", "forecast", "previous",
|
||||||
|
"revision", "actualBetterWorse"]
|
||||||
|
|
||||||
|
|
||||||
|
def week_url(d: date) -> str:
|
||||||
|
return (f"https://www.forexfactory.com/calendar?week="
|
||||||
|
f"{d.strftime('%b').lower()}{d.day}.{d.year}")
|
||||||
|
|
||||||
|
|
||||||
|
def extract_days(html: str) -> Optional[list]:
|
||||||
|
"""Balanced-bracket extraction of the `days:` JSON array."""
|
||||||
|
i = html.find("days:")
|
||||||
|
if i < 0:
|
||||||
|
return None
|
||||||
|
j = html.find("[", i)
|
||||||
|
depth, in_str, esc = 0, False, False
|
||||||
|
for k in range(j, len(html)):
|
||||||
|
ch = html[k]
|
||||||
|
if in_str:
|
||||||
|
if esc:
|
||||||
|
esc = False
|
||||||
|
elif ch == "\\":
|
||||||
|
esc = True
|
||||||
|
elif ch == '"':
|
||||||
|
in_str = False
|
||||||
|
else:
|
||||||
|
if ch == '"':
|
||||||
|
in_str = True
|
||||||
|
elif ch == "[":
|
||||||
|
depth += 1
|
||||||
|
elif ch == "]":
|
||||||
|
depth -= 1
|
||||||
|
if depth == 0:
|
||||||
|
return json.loads(html[j:k + 1])
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_week(sess: requests.Session, d: date, retries: int = 4) -> Optional[List[dict]]:
|
||||||
|
for attempt in range(retries):
|
||||||
|
try:
|
||||||
|
r = sess.get(week_url(d), timeout=30)
|
||||||
|
if r.status_code == 200:
|
||||||
|
days = extract_days(r.text)
|
||||||
|
if days is not None:
|
||||||
|
return [{k: e.get(k) for k in FIELDS}
|
||||||
|
for day in days for e in day.get("events", [])]
|
||||||
|
except requests.RequestException:
|
||||||
|
pass
|
||||||
|
time.sleep(5 * (attempt + 1))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def scrape(start: date, end: date) -> pd.DataFrame:
|
||||||
|
sess = requests.Session()
|
||||||
|
sess.headers["User-Agent"] = UA
|
||||||
|
rows: List[dict] = []
|
||||||
|
failed: List[date] = []
|
||||||
|
d = start
|
||||||
|
while d <= end:
|
||||||
|
week_rows = fetch_week(sess, d)
|
||||||
|
if week_rows is None:
|
||||||
|
failed.append(d)
|
||||||
|
print(f"FAILED week {d}")
|
||||||
|
else:
|
||||||
|
rows.extend(week_rows)
|
||||||
|
d += timedelta(days=7)
|
||||||
|
time.sleep(1.6)
|
||||||
|
if failed:
|
||||||
|
print(f"WARNING: {len(failed)} weeks failed: {failed}")
|
||||||
|
return pd.DataFrame(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def merge_into_store(new: pd.DataFrame, path: Path = PARQUET) -> pd.DataFrame:
|
||||||
|
if path.exists():
|
||||||
|
old = pd.read_parquet(path)
|
||||||
|
combined = pd.concat([old, new])
|
||||||
|
else:
|
||||||
|
combined = new
|
||||||
|
# keep="last": a refresh overwrites stale rows (actuals fill in
|
||||||
|
# after release, revisions land weeks later).
|
||||||
|
combined = (combined.drop_duplicates(subset=["id", "dateline"], keep="last")
|
||||||
|
.sort_values("dateline").reset_index(drop=True))
|
||||||
|
combined.to_parquet(path, index=False)
|
||||||
|
return combined
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||||
|
ap.add_argument("--start", type=date.fromisoformat, default=None)
|
||||||
|
ap.add_argument("--end", type=date.fromisoformat, default=date.today())
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
start = args.start
|
||||||
|
if start is None:
|
||||||
|
if PARQUET.exists():
|
||||||
|
last = pd.read_parquet(PARQUET).dateline.max()
|
||||||
|
start = pd.Timestamp(last, unit="s").date() - timedelta(days=7)
|
||||||
|
else:
|
||||||
|
start = date(2024, 1, 1)
|
||||||
|
print(f"Scraping {start} -> {args.end}")
|
||||||
|
new = scrape(start, args.end)
|
||||||
|
combined = merge_into_store(new)
|
||||||
|
print(f"Store: {len(combined)} events "
|
||||||
|
f"({pd.Timestamp(combined.dateline.min(), unit='s')} -> "
|
||||||
|
f"{pd.Timestamp(combined.dateline.max(), unit='s')}) -> {PARQUET}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""Per-hour P&L breakdown of a backtest trade book.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python backtest/research/hour_analysis.py BOOK.csv BAR_MINUTES
|
||||||
|
|
||||||
|
Reports, per UTC hour (both entry hour and DECISION hour = entry_time
|
||||||
|
minus one bar, the clock blocked_hours_utc evaluates): trade count,
|
||||||
|
total R, mean R, win rate, profit factor. Also a per-(hour, direction)
|
||||||
|
table. Analysis tooling only — never imported by the live code.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
|
||||||
|
def hour_tables(book_csv: str, bar_minutes: int):
|
||||||
|
df = pd.read_csv(book_csv)
|
||||||
|
et = pd.to_datetime(df["entry_time"], utc=True, format="mixed")
|
||||||
|
df["entry_hour"] = et.dt.hour
|
||||||
|
df["decision_hour"] = (et - pd.Timedelta(minutes=bar_minutes)).dt.hour
|
||||||
|
|
||||||
|
def table(group_cols):
|
||||||
|
g = df.groupby(group_cols)["r_multiple"]
|
||||||
|
out = g.agg(count="count", total_r="sum", mean_r="mean",
|
||||||
|
wr=lambda r: (r > 0).mean() * 100)
|
||||||
|
out["pf"] = g.apply(
|
||||||
|
lambda r: r[r > 0].sum() / max(-r[r <= 0].sum(), 1e-9))
|
||||||
|
return out.round(2)
|
||||||
|
|
||||||
|
return (table("decision_hour"), table(["decision_hour", "direction"]),
|
||||||
|
table("entry_hour"))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
book, mins = sys.argv[1], int(sys.argv[2])
|
||||||
|
by_dec, by_dec_dir, by_ent = hour_tables(book, mins)
|
||||||
|
print(f"\n── {book} · by DECISION hour (bar-close clock) ──")
|
||||||
|
print(by_dec.to_string())
|
||||||
|
print(f"\n── by (decision hour, direction) ──")
|
||||||
|
print(by_dec_dir.to_string())
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
"""
|
||||||
|
Oanda vs Twelve Data bar-divergence study — XAU/USD M15, 12 months.
|
||||||
|
====================================================================
|
||||||
|
Compares the mid-price OHLC bars the signal engine would see from each
|
||||||
|
provider over the same window. Purpose: quantify whether migrating the
|
||||||
|
signal feed from Twelve Data to Oanda is a formality (bars ~identical)
|
||||||
|
or a recalibration (bars differ at a scale that moves indicators).
|
||||||
|
|
||||||
|
Inputs:
|
||||||
|
--td Twelve Data parquet (raw bars from backtest/cache/)
|
||||||
|
--oanda Oanda parquet (from fetch_oanda_m5.py / fetch_oanda_m15.py)
|
||||||
|
--start / --end analysis window (UTC dates)
|
||||||
|
|
||||||
|
Outputs:
|
||||||
|
printed report
|
||||||
|
backtest/research/oanda_td_divergence_by_hour.csv
|
||||||
|
backtest/research/oanda_td_bar_diffs.parquet (per-bar diffs, common index)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
RESEARCH_DIR = Path(__file__).parent
|
||||||
|
PIP = 0.10 # XAU pip in $
|
||||||
|
|
||||||
|
|
||||||
|
def wilder_atr(df: pd.DataFrame, period: int = 14) -> pd.Series:
|
||||||
|
hl = df["High"] - df["Low"]
|
||||||
|
hc = (df["High"] - df["Close"].shift(1)).abs()
|
||||||
|
lc = (df["Low"] - df["Close"].shift(1)).abs()
|
||||||
|
tr = pd.concat([hl, hc, lc], axis=1).max(axis=1)
|
||||||
|
return tr.ewm(alpha=1.0 / period, adjust=False).mean()
|
||||||
|
|
||||||
|
|
||||||
|
def pct_stats(s: pd.Series) -> dict:
|
||||||
|
a = s.abs()
|
||||||
|
return {
|
||||||
|
"mean": a.mean(), "median": a.median(),
|
||||||
|
"p90": a.quantile(0.90), "p95": a.quantile(0.95),
|
||||||
|
"p99": a.quantile(0.99), "max": a.max(),
|
||||||
|
"bias(mean signed)": s.mean(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def fmt_stats(name: str, s: pd.Series) -> str:
|
||||||
|
st = pct_stats(s)
|
||||||
|
return (f" {name:<6} mean ${st['mean']:.3f} med ${st['median']:.3f} "
|
||||||
|
f"p95 ${st['p95']:.3f} p99 ${st['p99']:.3f} max ${st['max']:.2f} "
|
||||||
|
f"bias {st['bias(mean signed)']:+.4f}")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--td", default=str(RESEARCH_DIR.parent / "cache" /
|
||||||
|
"XAU_USD_15min_20240522_20260723.parquet"))
|
||||||
|
ap.add_argument("--oanda", default=str(RESEARCH_DIR /
|
||||||
|
"oanda_xau_m15_20250701_now.parquet"))
|
||||||
|
ap.add_argument("--start", default="2025-07-24")
|
||||||
|
ap.add_argument("--end", default="2026-07-23")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
td = pd.read_parquet(args.td)
|
||||||
|
oa = pd.read_parquet(args.oanda)
|
||||||
|
|
||||||
|
start = pd.Timestamp(args.start, tz="UTC")
|
||||||
|
end = pd.Timestamp(args.end, tz="UTC") + pd.Timedelta(days=1)
|
||||||
|
start = max(start, td.index[0], oa.index[0])
|
||||||
|
end = min(end, td.index[-1], oa.index[-1])
|
||||||
|
td = td.loc[(td.index >= start) & (td.index <= end)]
|
||||||
|
oa = oa.loc[(oa.index >= start) & (oa.index <= end)]
|
||||||
|
|
||||||
|
print("=" * 78)
|
||||||
|
print("OANDA vs TWELVE DATA — XAU/USD M15 bar divergence")
|
||||||
|
print(f"Window: {start} -> {end} ({(end - start).days} days)")
|
||||||
|
print("=" * 78)
|
||||||
|
|
||||||
|
# ── Coverage ─────────────────────────────────────────────────────
|
||||||
|
common = td.index.intersection(oa.index)
|
||||||
|
td_only = td.index.difference(oa.index)
|
||||||
|
oa_only = oa.index.difference(td.index)
|
||||||
|
print(f"\nCOVERAGE")
|
||||||
|
print(f" Twelve Data bars : {len(td):>7,}")
|
||||||
|
print(f" Oanda bars : {len(oa):>7,}")
|
||||||
|
print(f" common : {len(common):>7,}")
|
||||||
|
print(f" TD-only : {len(td_only):>7,} "
|
||||||
|
f"({100 * len(td_only) / max(len(td), 1):.2f}% of TD)")
|
||||||
|
print(f" Oanda-only : {len(oa_only):>7,} "
|
||||||
|
f"({100 * len(oa_only) / max(len(oa), 1):.2f}% of Oanda)")
|
||||||
|
for label, idx in (("TD-only", td_only), ("Oanda-only", oa_only)):
|
||||||
|
if len(idx):
|
||||||
|
by_date = pd.Series(1, index=idx).groupby(idx.date).sum()
|
||||||
|
top = by_date.sort_values(ascending=False).head(8)
|
||||||
|
print(f" {label} bars cluster on: "
|
||||||
|
+ ", ".join(f"{d} ({n})" for d, n in top.items()))
|
||||||
|
by_hour = pd.Series(1, index=idx).groupby(idx.hour).sum()
|
||||||
|
top_h = by_hour.sort_values(ascending=False).head(6)
|
||||||
|
print(f" {label} by UTC hour: "
|
||||||
|
+ ", ".join(f"{h:02d}h ({n})" for h, n in top_h.items()))
|
||||||
|
|
||||||
|
t = td.loc[common]
|
||||||
|
o = oa.loc[common]
|
||||||
|
|
||||||
|
# ── Scale references ─────────────────────────────────────────────
|
||||||
|
atr = wilder_atr(t).dropna()
|
||||||
|
mean_price = t["Close"].mean()
|
||||||
|
print(f"\nSCALE REFERENCES (Twelve Data feed)")
|
||||||
|
print(f" mean close : ${mean_price:,.2f}")
|
||||||
|
print(f" mean bar range : ${(t['High'] - t['Low']).mean():.3f}")
|
||||||
|
print(f" mean ATR14 : ${atr.mean():.3f} median ${atr.median():.3f}")
|
||||||
|
|
||||||
|
# ── Per-field diffs (TD − Oanda mid) ─────────────────────────────
|
||||||
|
print(f"\nPER-BAR DIFFS (TD − Oanda, $; pip = $0.10)")
|
||||||
|
diffs = {}
|
||||||
|
for col in ("Open", "High", "Low", "Close"):
|
||||||
|
d = t[col] - o[col]
|
||||||
|
diffs[col] = d
|
||||||
|
print(fmt_stats(col, d))
|
||||||
|
|
||||||
|
dclose = diffs["Close"].abs()
|
||||||
|
print(f"\n |ΔClose| as % of mean ATR14 : "
|
||||||
|
f"mean {100 * dclose.mean() / atr.mean():.1f}% "
|
||||||
|
f"p95 {100 * dclose.quantile(0.95) / atr.mean():.1f}%")
|
||||||
|
|
||||||
|
print(f"\nTHRESHOLD EXCEEDANCE (|ΔClose|)")
|
||||||
|
for thr in (0.10, 0.30, 0.65, 1.00, 2.00, 5.00):
|
||||||
|
pct = 100 * (dclose > thr).mean()
|
||||||
|
print(f" > ${thr:<5.2f}: {pct:6.2f}% of bars ({(dclose > thr).sum():,})")
|
||||||
|
|
||||||
|
# ── Direction / return agreement ─────────────────────────────────
|
||||||
|
t_ret = t["Close"].pct_change()
|
||||||
|
o_ret = o["Close"].pct_change()
|
||||||
|
# only consecutive common bars (gap = one M15 step)
|
||||||
|
step_ok = pd.Series(common, index=common).diff().eq(pd.Timedelta(minutes=15))
|
||||||
|
tr, orr = t_ret[step_ok], o_ret[step_ok]
|
||||||
|
corr = tr.corr(orr)
|
||||||
|
t_dir = np.sign(t["Close"] - t["Open"])
|
||||||
|
o_dir = np.sign(o["Close"] - o["Open"])
|
||||||
|
both_nonflat = (t_dir != 0) & (o_dir != 0)
|
||||||
|
dir_agree = 100 * (t_dir[both_nonflat] == o_dir[both_nonflat]).mean()
|
||||||
|
print(f"\nAGREEMENT")
|
||||||
|
print(f" close-return correlation (consecutive bars): {corr:.5f} "
|
||||||
|
f"(n={step_ok.sum():,})")
|
||||||
|
print(f" bar direction (sign of close−open) agree : {dir_agree:.2f}%")
|
||||||
|
|
||||||
|
# ── By-hour breakdown ────────────────────────────────────────────
|
||||||
|
hours = common.hour
|
||||||
|
rows = []
|
||||||
|
for h in range(24):
|
||||||
|
m = hours == h
|
||||||
|
if not m.any():
|
||||||
|
continue
|
||||||
|
row = {"hour_utc": h, "bars": int(m.sum()),
|
||||||
|
"mean_abs_dclose": dclose[m].mean(),
|
||||||
|
"p95_abs_dclose": dclose[m].quantile(0.95),
|
||||||
|
"mean_abs_dhigh": diffs["High"][m].abs().mean(),
|
||||||
|
"mean_abs_dlow": diffs["Low"][m].abs().mean()}
|
||||||
|
if "bid_c" in o.columns:
|
||||||
|
row["oanda_median_spread"] = (
|
||||||
|
(o["ask_c"] - o["bid_c"])[m].median())
|
||||||
|
rows.append(row)
|
||||||
|
by_hour = pd.DataFrame(rows)
|
||||||
|
out_hour = RESEARCH_DIR / "oanda_td_divergence_by_hour.csv"
|
||||||
|
by_hour.to_csv(out_hour, index=False, float_format="%.4f")
|
||||||
|
print(f"\nBY UTC HOUR (written to {out_hour.name})")
|
||||||
|
print(by_hour.to_string(index=False,
|
||||||
|
float_format=lambda x: f"{x:.3f}"))
|
||||||
|
|
||||||
|
# ── By-month drift ───────────────────────────────────────────────
|
||||||
|
print(f"\nBY MONTH (mean |ΔClose|, $)")
|
||||||
|
by_month = dclose.groupby(common.to_period("M")).mean()
|
||||||
|
for p, v in by_month.items():
|
||||||
|
print(f" {p}: {v:.3f}")
|
||||||
|
|
||||||
|
# ── Oanda real spread (bonus, ties to Dukascopy study) ───────────
|
||||||
|
if "bid_c" in o.columns:
|
||||||
|
spread = o["ask_c"] - o["bid_c"]
|
||||||
|
liquid = ~o.index.hour.isin([21, 22, 23, 0])
|
||||||
|
print(f"\nOANDA REAL SPREAD (close bid/ask, $)")
|
||||||
|
print(f" all bars : median {spread.median():.3f} "
|
||||||
|
f"p25 {spread.quantile(0.25):.3f} p75 {spread.quantile(0.75):.3f}")
|
||||||
|
print(f" liquid hours : median {spread[liquid].median():.3f}")
|
||||||
|
print(f" 21-00 UTC : median {spread[~liquid].median():.3f}")
|
||||||
|
|
||||||
|
# ── Persist per-bar diffs ────────────────────────────────────────
|
||||||
|
out = pd.DataFrame({
|
||||||
|
"td_close": t["Close"], "oa_close": o["Close"],
|
||||||
|
"d_open": diffs["Open"], "d_high": diffs["High"],
|
||||||
|
"d_low": diffs["Low"], "d_close": diffs["Close"],
|
||||||
|
})
|
||||||
|
out_path = RESEARCH_DIR / "oanda_td_bar_diffs.parquet"
|
||||||
|
out.to_parquet(out_path)
|
||||||
|
print(f"\nPer-bar diffs written to {out_path}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
hour_utc,bars,mean_abs_dclose,p95_abs_dclose,mean_abs_dhigh,mean_abs_dlow,oanda_median_spread
|
||||||
|
0,1029,3.3483,13.9460,3.2906,3.4310,0.7900
|
||||||
|
1,1028,3.3483,12.8292,3.3477,3.6398,0.7700
|
||||||
|
2,1028,3.0288,10.8960,2.9469,3.1317,0.7700
|
||||||
|
3,1028,2.8783,11.1300,2.8419,2.9992,0.7600
|
||||||
|
4,1028,2.8232,10.4710,2.8165,2.8455,0.7400
|
||||||
|
5,1028,2.9268,10.6027,2.9234,3.0038,0.7400
|
||||||
|
6,1028,3.0466,10.9265,2.9487,3.0796,0.7200
|
||||||
|
7,1028,3.0286,11.3512,3.0301,3.0797,0.6700
|
||||||
|
8,1028,3.0223,11.1483,2.8989,3.0801,0.6600
|
||||||
|
9,1028,3.0460,11.6345,2.9307,3.0609,0.6600
|
||||||
|
10,1028,2.9280,10.9015,2.8647,3.0029,0.6600
|
||||||
|
11,1028,3.0340,11.4712,2.9591,2.9472,0.6600
|
||||||
|
12,1028,3.0986,11.3282,2.9412,3.1407,0.7000
|
||||||
|
13,1028,3.4014,12.3343,3.1077,3.4228,0.7300
|
||||||
|
14,1028,3.5074,11.3628,3.3960,3.6569,0.6900
|
||||||
|
15,1028,3.4514,11.8982,3.4124,3.6733,0.6700
|
||||||
|
16,1028,3.1275,12.5155,3.2038,3.3061,0.6700
|
||||||
|
17,1020,2.9826,12.6435,3.0064,3.1008,0.6900
|
||||||
|
18,1015,2.8721,12.5480,2.9101,2.9996,0.6900
|
||||||
|
19,1004,2.8637,12.1335,2.8824,2.9454,0.6900
|
||||||
|
20,996,2.8006,11.9325,2.8486,2.8577,0.7600
|
||||||
|
21,335,1.1085,4.8600,1.1212,1.2034,0.9400
|
||||||
|
22,676,4.0827,13.6787,4.8422,4.6761,0.8300
|
||||||
|
23,1027,3.2722,12.5725,3.5617,3.7747,0.8700
|
||||||
|
@@ -0,0 +1,290 @@
|
|||||||
|
"""
|
||||||
|
Classical chart-pattern detection for the XAU_CP entry archetype.
|
||||||
|
================================================================
|
||||||
|
|
||||||
|
Third entry archetype (after the composite-breakout generators and the
|
||||||
|
XAU_PB pullback): trade the completion of a proven multi-bar price
|
||||||
|
pattern. Pattern set is deliberately tight — the classical patterns
|
||||||
|
with the strongest documented statistics (Bulkowski, *Encyclopedia of
|
||||||
|
Chart Patterns*) that are unambiguous from OHLC at bar close:
|
||||||
|
|
||||||
|
double_bottom bullish reversal two matching swing lows, break of
|
||||||
|
the interim peak (neckline)
|
||||||
|
double_top bearish reversal mirrored
|
||||||
|
inv_hs bullish reversal inverse head & shoulders, break of
|
||||||
|
the neckline (the LOWER of the two
|
||||||
|
peaks flanking the head — the earlier
|
||||||
|
horizontal trigger, no sloped fits;
|
||||||
|
validated as-is, see detector note)
|
||||||
|
hs_top bearish reversal head & shoulders top, mirrored
|
||||||
|
bull_flag bullish continuation strong pole, shallow ordered
|
||||||
|
retrace, close at a new high
|
||||||
|
bear_flag bearish continuation mirrored
|
||||||
|
|
||||||
|
Triangles / wedges / cup-and-handle are excluded on purpose: weaker
|
||||||
|
historical stats and apex-timing ambiguity — more parameters for less
|
||||||
|
edge.
|
||||||
|
|
||||||
|
Design contract (mirrors the rest of the signal stack):
|
||||||
|
- Detectors see ONLY the visible closed-bar slice the aggregator was
|
||||||
|
given; the last row is the just-closed signal bar. No forming candle,
|
||||||
|
no future bars → identical behavior live and in the bar-replay
|
||||||
|
backtester, and signals never repaint.
|
||||||
|
- All geometric thresholds are in ATR units so one parameter set is
|
||||||
|
scale-free across timeframes. ATR comes from the engine (Wilder 14).
|
||||||
|
- Trigger discipline: a pattern fires only on the bar that COMPLETES it
|
||||||
|
— the first close beyond the neckline/boundary since the pattern
|
||||||
|
formed. One breakout → at most one signal.
|
||||||
|
- Swing points use the same local-extremum definition as the engine's
|
||||||
|
structure S/R (window w bars each side), so a "swing" here is the
|
||||||
|
same object a chartist sees on the engine's key levels.
|
||||||
|
|
||||||
|
Every detector returns None or a dict:
|
||||||
|
{"pattern": str, "direction": "BUY"|"SELL",
|
||||||
|
"anchor": float, # SL reference (pattern invalidation price)
|
||||||
|
"trigger": float, # neckline / boundary that was broken
|
||||||
|
"height": float} # measured-move distance in price units
|
||||||
|
|
||||||
|
Defaults below are the textbook definitions, not tuned values. The
|
||||||
|
validation record for which patterns/timeframes are enabled lives in
|
||||||
|
pair_signals/xauusd.py (per repo rules, per-TF adoption happens in the
|
||||||
|
factory, never in global defaults).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
__all__ = ["detect_chart_pattern", "PATTERN_DETECTORS"]
|
||||||
|
|
||||||
|
|
||||||
|
def _swing_indices(values: np.ndarray, w: int, find_high: bool) -> List[int]:
|
||||||
|
"""Indices of local extrema with `w` bars on each side.
|
||||||
|
|
||||||
|
A bar i is a swing high if values[i] is the strict maximum of
|
||||||
|
values[i-w : i+w+1] (ties reject — a flat top is not a clean swing).
|
||||||
|
The last `w` bars can never qualify, which is exactly right: a swing
|
||||||
|
isn't confirmed until `w` bars close after it.
|
||||||
|
"""
|
||||||
|
n = len(values)
|
||||||
|
out: List[int] = []
|
||||||
|
for i in range(w, n - w):
|
||||||
|
window = values[i - w : i + w + 1]
|
||||||
|
if np.isnan(window).any():
|
||||||
|
continue
|
||||||
|
center = values[i]
|
||||||
|
rest = np.delete(window, w)
|
||||||
|
if find_high:
|
||||||
|
if center > rest.max():
|
||||||
|
out.append(i)
|
||||||
|
else:
|
||||||
|
if center < rest.min():
|
||||||
|
out.append(i)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _first_close_beyond(closes: np.ndarray, start: int, level: float,
|
||||||
|
above: bool) -> bool:
|
||||||
|
"""True iff the LAST close is the first one beyond `level` since
|
||||||
|
`start` (exclusive of the last bar)."""
|
||||||
|
last = closes[-1]
|
||||||
|
prior = closes[start:-1]
|
||||||
|
if above:
|
||||||
|
return last > level and (len(prior) == 0 or prior.max() <= level)
|
||||||
|
return last < level and (len(prior) == 0 or prior.min() >= level)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Double bottom / top ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _detect_double(o, h, l, c, atr, cfg, bullish: bool) -> Optional[Dict]:
|
||||||
|
w = cfg.cp_swing_window
|
||||||
|
ext = l if bullish else h
|
||||||
|
idx = _swing_indices(ext, w, find_high=not bullish)
|
||||||
|
if len(idx) < 2:
|
||||||
|
return None
|
||||||
|
i1, i2 = idx[-2], idx[-1]
|
||||||
|
n = len(c)
|
||||||
|
if i2 - i1 < cfg.cp_min_sep_bars:
|
||||||
|
return None
|
||||||
|
if (n - 1) - i2 > cfg.cp_recency_bars:
|
||||||
|
return None
|
||||||
|
e1, e2 = ext[i1], ext[i2]
|
||||||
|
if abs(e1 - e2) > cfg.cp_tolerance_atr * atr:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if bullish:
|
||||||
|
neckline = float(h[i1 : i2 + 1].max())
|
||||||
|
floor = min(e1, e2)
|
||||||
|
height = neckline - floor
|
||||||
|
else:
|
||||||
|
neckline = float(l[i1 : i2 + 1].min())
|
||||||
|
ceil_ = max(e1, e2)
|
||||||
|
height = ceil_ - neckline
|
||||||
|
|
||||||
|
if not (cfg.cp_min_height_atr * atr <= height <= cfg.cp_max_height_atr * atr):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Pattern must not have been invalidated after the second extreme:
|
||||||
|
# a lower low (bullish) / higher high (bearish) breaks the pattern.
|
||||||
|
if bullish:
|
||||||
|
if i2 + 1 < n and l[i2 + 1 :].min() < min(e1, e2) - 0.1 * atr:
|
||||||
|
return None
|
||||||
|
if not _first_close_beyond(c, i2, neckline, above=True):
|
||||||
|
return None
|
||||||
|
return {"pattern": "double_bottom", "direction": "BUY",
|
||||||
|
"anchor": float(min(e1, e2)), "trigger": neckline,
|
||||||
|
"height": float(height)}
|
||||||
|
else:
|
||||||
|
if i2 + 1 < n and h[i2 + 1 :].max() > max(e1, e2) + 0.1 * atr:
|
||||||
|
return None
|
||||||
|
if not _first_close_beyond(c, i2, neckline, above=False):
|
||||||
|
return None
|
||||||
|
return {"pattern": "double_top", "direction": "SELL",
|
||||||
|
"anchor": float(max(e1, e2)), "trigger": neckline,
|
||||||
|
"height": float(height)}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Head & shoulders (top and inverse) ───────────────────────────────
|
||||||
|
|
||||||
|
def _detect_hs(o, h, l, c, atr, cfg, bullish: bool) -> Optional[Dict]:
|
||||||
|
"""bullish=True → inverse H&S (three swing LOWS, head deepest)."""
|
||||||
|
w = cfg.cp_swing_window
|
||||||
|
ext = l if bullish else h
|
||||||
|
idx = _swing_indices(ext, w, find_high=not bullish)
|
||||||
|
if len(idx) < 3:
|
||||||
|
return None
|
||||||
|
i1, i2, i3 = idx[-3], idx[-2], idx[-1]
|
||||||
|
n = len(c)
|
||||||
|
if (n - 1) - i3 > cfg.cp_recency_bars:
|
||||||
|
return None
|
||||||
|
s1, head, s2 = ext[i1], ext[i2], ext[i3]
|
||||||
|
|
||||||
|
if bullish:
|
||||||
|
# Head strictly the deepest low; shoulders roughly level.
|
||||||
|
if not (head < s1 - cfg.cp_hs_head_min_atr * atr
|
||||||
|
and head < s2 - cfg.cp_hs_head_min_atr * atr):
|
||||||
|
return None
|
||||||
|
if abs(s1 - s2) > cfg.cp_hs_shoulder_tol_atr * atr:
|
||||||
|
return None
|
||||||
|
# Neckline: the LOWER of the two interim peaks — the EARLIER of
|
||||||
|
# the two horizontal triggers (max() would demand clearing both
|
||||||
|
# peaks). The whole validated CP book (M5 BUY-only) ran on this
|
||||||
|
# trigger — do not flip to max() without full re-validation.
|
||||||
|
p1 = float(h[i1 : i2 + 1].max())
|
||||||
|
p2 = float(h[i2 : i3 + 1].max())
|
||||||
|
neckline = min(p1, p2)
|
||||||
|
height = neckline - head
|
||||||
|
else:
|
||||||
|
if not (head > s1 + cfg.cp_hs_head_min_atr * atr
|
||||||
|
and head > s2 + cfg.cp_hs_head_min_atr * atr):
|
||||||
|
return None
|
||||||
|
if abs(s1 - s2) > cfg.cp_hs_shoulder_tol_atr * atr:
|
||||||
|
return None
|
||||||
|
t1 = float(l[i1 : i2 + 1].min())
|
||||||
|
t2 = float(l[i2 : i3 + 1].min())
|
||||||
|
neckline = max(t1, t2)
|
||||||
|
height = head - neckline
|
||||||
|
|
||||||
|
if not (cfg.cp_min_height_atr * atr <= height <= cfg.cp_max_height_atr * atr):
|
||||||
|
return None
|
||||||
|
|
||||||
|
if bullish:
|
||||||
|
# Invalidated if price undercut the head after the right shoulder.
|
||||||
|
if i3 + 1 < n and l[i3 + 1 :].min() < head - 0.1 * atr:
|
||||||
|
return None
|
||||||
|
if not _first_close_beyond(c, i3, neckline, above=True):
|
||||||
|
return None
|
||||||
|
return {"pattern": "inv_hs", "direction": "BUY",
|
||||||
|
"anchor": float(s2), "trigger": neckline,
|
||||||
|
"height": float(height)}
|
||||||
|
else:
|
||||||
|
if i3 + 1 < n and h[i3 + 1 :].max() > head + 0.1 * atr:
|
||||||
|
return None
|
||||||
|
if not _first_close_beyond(c, i3, neckline, above=False):
|
||||||
|
return None
|
||||||
|
return {"pattern": "hs_top", "direction": "SELL",
|
||||||
|
"anchor": float(s2), "trigger": neckline,
|
||||||
|
"height": float(height)}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Flags (bull and bear) ────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _detect_flag(o, h, l, c, atr, cfg, bullish: bool) -> Optional[Dict]:
|
||||||
|
n = len(c)
|
||||||
|
lo_r, hi_r = cfg.cp_flag_retrace
|
||||||
|
if bullish:
|
||||||
|
# Pole top = highest high BEFORE the trigger bar.
|
||||||
|
ip = int(np.argmax(h[:-1]))
|
||||||
|
flag_len = (n - 1) - ip
|
||||||
|
if not (cfg.cp_flag_min_bars <= flag_len <= cfg.cp_flag_max_bars):
|
||||||
|
return None
|
||||||
|
pole_start = max(0, ip - cfg.cp_flag_pole_bars + 1)
|
||||||
|
pole_low = float(l[pole_start : ip + 1].min())
|
||||||
|
pole = float(h[ip]) - pole_low
|
||||||
|
if pole < cfg.cp_flag_pole_atr * atr:
|
||||||
|
return None
|
||||||
|
flag_low = float(l[ip + 1 : n].min())
|
||||||
|
retrace = (float(h[ip]) - flag_low) / pole
|
||||||
|
if not (lo_r <= retrace <= hi_r):
|
||||||
|
return None
|
||||||
|
if not _first_close_beyond(c, ip, float(h[ip]), above=True):
|
||||||
|
return None
|
||||||
|
return {"pattern": "bull_flag", "direction": "BUY",
|
||||||
|
"anchor": flag_low, "trigger": float(h[ip]),
|
||||||
|
"height": float(pole)}
|
||||||
|
else:
|
||||||
|
ip = int(np.argmin(l[:-1]))
|
||||||
|
flag_len = (n - 1) - ip
|
||||||
|
if not (cfg.cp_flag_min_bars <= flag_len <= cfg.cp_flag_max_bars):
|
||||||
|
return None
|
||||||
|
pole_start = max(0, ip - cfg.cp_flag_pole_bars + 1)
|
||||||
|
pole_high = float(h[pole_start : ip + 1].max())
|
||||||
|
pole = pole_high - float(l[ip])
|
||||||
|
if pole < cfg.cp_flag_pole_atr * atr:
|
||||||
|
return None
|
||||||
|
flag_high = float(h[ip + 1 : n].max())
|
||||||
|
retrace = (flag_high - float(l[ip])) / pole
|
||||||
|
if not (lo_r <= retrace <= hi_r):
|
||||||
|
return None
|
||||||
|
if not _first_close_beyond(c, ip, float(l[ip]), above=False):
|
||||||
|
return None
|
||||||
|
return {"pattern": "bear_flag", "direction": "SELL",
|
||||||
|
"anchor": flag_high, "trigger": float(l[ip]),
|
||||||
|
"height": float(pole)}
|
||||||
|
|
||||||
|
|
||||||
|
# Priority order: most structured / rarest first — if two patterns
|
||||||
|
# complete on the same bar, the more specific one names the trade.
|
||||||
|
PATTERN_DETECTORS = {
|
||||||
|
"inv_hs": lambda o, h, l, c, atr, cfg: _detect_hs(o, h, l, c, atr, cfg, True),
|
||||||
|
"hs_top": lambda o, h, l, c, atr, cfg: _detect_hs(o, h, l, c, atr, cfg, False),
|
||||||
|
"double_bottom": lambda o, h, l, c, atr, cfg: _detect_double(o, h, l, c, atr, cfg, True),
|
||||||
|
"double_top": lambda o, h, l, c, atr, cfg: _detect_double(o, h, l, c, atr, cfg, False),
|
||||||
|
"bull_flag": lambda o, h, l, c, atr, cfg: _detect_flag(o, h, l, c, atr, cfg, True),
|
||||||
|
"bear_flag": lambda o, h, l, c, atr, cfg: _detect_flag(o, h, l, c, atr, cfg, False),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def detect_chart_pattern(df: pd.DataFrame, atr: float, cfg) -> Optional[Dict]:
|
||||||
|
"""Run enabled detectors over the last cp_lookback(+1) bars.
|
||||||
|
|
||||||
|
`df` is the visible history at decision time (last row = signal
|
||||||
|
bar). Returns the first match in PATTERN_DETECTORS priority order,
|
||||||
|
or None.
|
||||||
|
"""
|
||||||
|
if atr <= 0 or len(df) < cfg.cp_lookback + 1:
|
||||||
|
return None
|
||||||
|
tail = df.iloc[-(cfg.cp_lookback + 1):]
|
||||||
|
o = tail["Open"].to_numpy(dtype=np.float64)
|
||||||
|
h = tail["High"].to_numpy(dtype=np.float64)
|
||||||
|
l = tail["Low"].to_numpy(dtype=np.float64)
|
||||||
|
c = tail["Close"].to_numpy(dtype=np.float64)
|
||||||
|
enabled = set(cfg.cp_patterns)
|
||||||
|
for name, det in PATTERN_DETECTORS.items():
|
||||||
|
if name not in enabled:
|
||||||
|
continue
|
||||||
|
hit = det(o, h, l, c, atr, cfg)
|
||||||
|
if hit is not None:
|
||||||
|
return hit
|
||||||
|
return None
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"telegram": {
|
||||||
|
"token": "123456789:REPLACE_WITH_YOUR_BOT_TOKEN",
|
||||||
|
"chat_id": "REPLACE_WITH_OWNER_CHAT_ID",
|
||||||
|
"channel_id": "-100REPLACE_WITH_CHANNEL_ID_OR_EMPTY_STRING",
|
||||||
|
"alert_min_quality": "C"
|
||||||
|
},
|
||||||
|
"data_providers": {
|
||||||
|
"active": "oanda",
|
||||||
|
"streaming": "tiingo",
|
||||||
|
"twelve_data": {
|
||||||
|
"api_key": "REPLACE_WITH_YOUR_TWELVE_DATA_API_KEY"
|
||||||
|
},
|
||||||
|
"oanda": {},
|
||||||
|
"tiingo": {
|
||||||
|
"api_key": "REPLACE_WITH_YOUR_TIINGO_API_KEY_OR_EMPTY"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scanning": {
|
||||||
|
"pairs": ["XAU/USD"],
|
||||||
|
"timeframes": ["M5", "M15", "H1", "H4"]
|
||||||
|
},
|
||||||
|
"pairs": {
|
||||||
|
"XAU/USD": {
|
||||||
|
"blocked_hours_utc": [0, 1, 2, 10, 11, 13, 16, 21]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"oanda": {
|
||||||
|
"account_id": "REPLACE_WITH_ACCOUNT_ID",
|
||||||
|
"api_token": "REPLACE_WITH_API_TOKEN",
|
||||||
|
"base_url": "https://api-fxpractice.oanda.com",
|
||||||
|
"lot_size": 1,
|
||||||
|
"quality_gate": "C",
|
||||||
|
"max_hold_bars": 300
|
||||||
|
},
|
||||||
|
"deepseek": {
|
||||||
|
"api_key": "REPLACE_WITH_YOUR_DEEPSEEK_API_KEY_OR_EMPTY",
|
||||||
|
"model": "deepseek-v4-flash",
|
||||||
|
"enabled": true,
|
||||||
|
"timeout_seconds": 12,
|
||||||
|
"daily_request_cap": 500
|
||||||
|
},
|
||||||
|
"log_level": "INFO"
|
||||||
|
}
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
"""
|
"""
|
||||||
Forex Signal Intelligence Platform - Configuration
|
ForexBot2 — Configuration (XAU/USD focused)
|
||||||
===================================================
|
==========================================
|
||||||
Central configuration for all signal analysis parameters.
|
|
||||||
Tuned by market regime and validated against historical performance.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
@@ -29,168 +27,316 @@ class SignalStrength(Enum):
|
|||||||
STRONG_SELL = -2
|
STRONG_SELL = -2
|
||||||
|
|
||||||
|
|
||||||
class MarketSession(Enum):
|
# ── Supported pairs ──────────────────────────────────────────────
|
||||||
SYDNEY = "sydney"
|
XAUUSD = "XAU/USD"
|
||||||
TOKYO = "tokyo"
|
|
||||||
LONDON = "london"
|
|
||||||
NEW_YORK = "new_york"
|
|
||||||
OVERLAP_LONDON_NY = "london_ny_overlap"
|
|
||||||
|
|
||||||
|
# price_precision: decimals for SL/TP rounding and display.
|
||||||
# ── Major Forex Pairs ──────────────────────────────────────────────
|
# default_spread: round-trip cost per trade in price units, charged by
|
||||||
MAJOR_PAIRS = [
|
# the backtester when --spread is not given.
|
||||||
"EURUSD=X", "GBPUSD=X", "USDJPY=X", "USDCHF=X",
|
PAIR_INFO = {
|
||||||
"AUDUSD=X", "USDCAD=X", "NZDUSD=X",
|
XAUUSD: {"display": "XAU/USD", "pip_value": 0.10,
|
||||||
]
|
"price_precision": 2, "default_spread": 0.30},
|
||||||
|
|
||||||
CROSS_PAIRS = [
|
|
||||||
"EURGBP=X", "EURJPY=X", "GBPJPY=X", "AUDJPY=X",
|
|
||||||
"EURCHF=X", "CADJPY=X",
|
|
||||||
]
|
|
||||||
|
|
||||||
# Yahoo Finance symbol mapping for display
|
|
||||||
SYMBOL_DISPLAY = {
|
|
||||||
"EURUSD=X": "EUR/USD", "GBPUSD=X": "GBP/USD",
|
|
||||||
"USDJPY=X": "USD/JPY", "USDCHF=X": "USD/CHF",
|
|
||||||
"AUDUSD=X": "AUD/USD", "USDCAD=X": "USD/CAD",
|
|
||||||
"NZDUSD=X": "NZD/USD", "EURGBP=X": "EUR/GBP",
|
|
||||||
"EURJPY=X": "EUR/JPY", "GBPJPY=X": "GBP/JPY",
|
|
||||||
"AUDJPY=X": "AUD/JPY", "EURCHF=X": "EUR/CHF",
|
|
||||||
"CADJPY=X": "CAD/JPY",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class TechnicalConfig:
|
class TechnicalConfig:
|
||||||
"""Technical indicator parameters - carefully tuned for Forex."""
|
"""Technical indicator parameters."""
|
||||||
# EMA periods (fast/medium/slow for trend detection)
|
|
||||||
ema_fast: int = 9
|
ema_fast: int = 9
|
||||||
ema_medium: int = 21
|
ema_medium: int = 21
|
||||||
ema_slow: int = 50
|
ema_slow: int = 50
|
||||||
ema_trend: int = 200
|
ema_trend: int = 200
|
||||||
|
# 4-EMA mode only: score partial ribbon alignment (2-3 of 4 checks)
|
||||||
|
# as NEUTRAL instead of a weak BUY/SELL. Research knob (2026-08-04
|
||||||
|
# indicator audit: partial-alignment trades averaged -0.008R vs
|
||||||
|
# +0.230R fully aligned on the M5 12mo Oanda book); default False =
|
||||||
|
# legacy behavior until a replay campaign validates flipping it.
|
||||||
|
ema_partial_neutral: bool = False
|
||||||
|
|
||||||
# RSI
|
|
||||||
rsi_period: int = 14
|
rsi_period: int = 14
|
||||||
rsi_overbought: float = 70.0
|
rsi_overbought: float = 70.0
|
||||||
rsi_oversold: float = 30.0
|
rsi_oversold: float = 30.0
|
||||||
rsi_neutral_upper: float = 60.0
|
rsi_neutral_upper: float = 60.0
|
||||||
rsi_neutral_lower: float = 40.0
|
rsi_neutral_lower: float = 40.0
|
||||||
|
|
||||||
# MACD (12, 26, 9 is standard)
|
|
||||||
macd_fast: int = 12
|
macd_fast: int = 12
|
||||||
macd_slow: int = 26
|
macd_slow: int = 26
|
||||||
macd_signal: int = 9
|
macd_signal: int = 9
|
||||||
|
|
||||||
# Bollinger Bands
|
|
||||||
bb_period: int = 20
|
bb_period: int = 20
|
||||||
bb_std: float = 2.0
|
bb_std: float = 2.0
|
||||||
|
|
||||||
# ATR for volatility measurement
|
|
||||||
atr_period: int = 14
|
atr_period: int = 14
|
||||||
|
|
||||||
# Stochastic
|
|
||||||
stoch_k: int = 14
|
|
||||||
stoch_d: int = 3
|
|
||||||
stoch_overbought: float = 80.0
|
|
||||||
stoch_oversold: float = 20.0
|
|
||||||
|
|
||||||
# ADX for trend strength
|
|
||||||
adx_period: int = 14
|
adx_period: int = 14
|
||||||
adx_strong_trend: float = 25.0
|
adx_strong_trend: float = 25.0
|
||||||
adx_very_strong: float = 50.0
|
adx_very_strong: float = 50.0
|
||||||
|
|
||||||
# Ichimoku
|
divergence_lookback: int = 40
|
||||||
ichimoku_tenkan: int = 9
|
swing_sr_lookback: int = 50
|
||||||
ichimoku_kijun: int = 26
|
|
||||||
ichimoku_senkou_b: int = 52
|
|
||||||
|
|
||||||
# Volume profile
|
disabled_indicators: List[str] = field(default_factory=list)
|
||||||
volume_ma_period: int = 20
|
|
||||||
|
|
||||||
# Pivot points lookback
|
ema_mode: str = "4ema" # "4ema" (9/21/50/200) or "2ema" (50/200)
|
||||||
pivot_lookback: int = 1
|
rsi_mode: str = "regime_aware" # "classic" or "regime_aware"
|
||||||
|
macd_as_pillar: bool = True # MACD histogram is a scored signal
|
||||||
|
|
||||||
# Divergence detection — how many bars back to scan for swing points
|
# ROC (Rate of Change)
|
||||||
divergence_lookback: int = 14
|
roc_period: int = 10
|
||||||
|
roc_threshold: float = 1.0 # % threshold for strong signal
|
||||||
|
|
||||||
|
# Keltner Channels
|
||||||
|
kc_period: int = 20
|
||||||
|
kc_atr_period: int = 10
|
||||||
|
kc_multiplier: float = 1.5
|
||||||
|
|
||||||
|
# ADX slope (trend acceleration detection)
|
||||||
|
adx_slope_lookback: int = 5
|
||||||
|
adx_slope_emerging_threshold: float = 3.0
|
||||||
|
|
||||||
|
regime_weights_override: Dict[str, Dict[str, float]] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class SignalConfig:
|
class SignalConfig:
|
||||||
"""Signal scoring and filtering parameters."""
|
"""Signal scoring & filtering."""
|
||||||
# Minimum confidence to emit a signal (0-100)
|
mtf_agreement_threshold: int = 2 # of 3 timeframes
|
||||||
min_confidence: float = 55.0
|
|
||||||
|
|
||||||
# Weight allocation for composite score
|
min_rr_ratio: float = 0.5
|
||||||
weights: Dict[str, float] = field(default_factory=lambda: {
|
# TP tightening (May 2026) was tuned on the pre-spread backtester.
|
||||||
"trend": 0.25, # EMA alignment, ADX
|
# Re-tested 2026-07-08 with $0.30 spread + gap fills: 0.8 nearly
|
||||||
"momentum": 0.20, # RSI, Stochastic, MACD
|
# halves total R (+36.1R vs +66.8R, worse in both train/val halves)
|
||||||
"volatility": 0.15, # BB position, ATR regime
|
# — smaller wins make the fixed spread a larger share of every TP.
|
||||||
"price_action": 0.15, # Candlestick patterns, S/R
|
tp_tightening_factor: float = 1.0
|
||||||
"multi_timeframe": 0.15,# Higher TF confirmation
|
|
||||||
"sentiment": 0.10, # News/calendar events
|
|
||||||
})
|
|
||||||
|
|
||||||
# Multi-timeframe confirmation requirements
|
# ── Trade geometry ────────────────────────────────────────────
|
||||||
mtf_agreement_threshold: int = 2 # out of 3 timeframes must agree
|
# Round-trip transaction cost estimate in price units (XAU ~$0.30).
|
||||||
|
round_trip_cost: float = 0.30
|
||||||
|
# Spread gate: reject setups whose TP1 distance is below
|
||||||
|
# round_trip_cost × this multiple (caps cost at ~3% of target).
|
||||||
|
# Validated 2026-07: small-target trades lose in both data halves
|
||||||
|
# on M5 while TP≥$10 trades earn ~+0.08R/trade.
|
||||||
|
min_tp_cost_multiple: float = 30.0
|
||||||
|
# Fixed SL floor in pips (80 pips = $8 on XAU). 0 disables — SL then
|
||||||
|
# floors at ATR × v2_sl_atr_multiplier only.
|
||||||
|
min_sl_pips: float = 80.0
|
||||||
|
# TP1 floor as a fraction of SL distance (0.5 = historical RR 0.5).
|
||||||
|
tp_floor_rr: float = 0.5
|
||||||
|
|
||||||
# Signal cooldown (prevent over-signalling same direction)
|
# ── Trailing-stop exit (2026-07) ──────────────────────────────
|
||||||
cooldown_bars: int = 5
|
# Once favorable excursion reaches trail_arm_r × risk, the stop
|
||||||
|
# trails trail_dist_r × risk behind the best price (updated at bar
|
||||||
# Risk management defaults
|
# close). TP1 becomes advisory — the trail is the exit. Validated
|
||||||
default_risk_pct: float = 1.0 # risk 1% per trade
|
# on 12mo M5: +105R vs +48R with the fixed RR-0.5 target, balanced
|
||||||
default_rr_ratio: float = 2.0 # 1:2 risk-reward minimum
|
# train/val, positive every quarter, PF 2.27. The RR-0.5 target was
|
||||||
|
# the binding constraint — winners kept running after TP.
|
||||||
|
# Set trail_arm_r = 0 to disable and exit at TP1 as before.
|
||||||
|
trail_arm_r: float = 0.6
|
||||||
|
trail_dist_r: float = 0.75
|
||||||
|
# Pullback (XAU_PB) trades run tighter stops (~$8 vs ~$25), so the
|
||||||
|
# global 0.6R arm fires on noise. Arm later; whole 0.8-arm row of
|
||||||
|
# the grid is +35-38R vs +32R at global params (balanced halves).
|
||||||
|
pb_trail_arm_r: float = 0.8
|
||||||
|
pb_trail_dist_r: float = 0.75
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class NewsConfig:
|
class NewsConfig:
|
||||||
"""Economic calendar and sentiment configuration."""
|
"""Economic calendar & sentiment."""
|
||||||
# High-impact event currencies
|
tracked_currencies: List[str] = field(default_factory=lambda: ["USD", "XAU"])
|
||||||
tracked_currencies: List[str] = field(default_factory=lambda: [
|
|
||||||
"USD", "EUR", "GBP", "JPY", "CHF", "AUD", "CAD", "NZD"
|
|
||||||
])
|
|
||||||
|
|
||||||
# Event impact levels to monitor
|
|
||||||
min_impact: str = "medium" # "low", "medium", "high"
|
|
||||||
|
|
||||||
# Pre-event buffer (minutes before high-impact event = no new signals)
|
|
||||||
pre_event_buffer_minutes: int = 30
|
pre_event_buffer_minutes: int = 30
|
||||||
|
|
||||||
# Post-event volatility window (minutes after event = cautious signals)
|
|
||||||
post_event_buffer_minutes: int = 15
|
post_event_buffer_minutes: int = 15
|
||||||
|
# Whether event risk gates the SIGNAL (danger: confidence hard-capped
|
||||||
|
# at 30; caution: 15-point penalty). Warnings are always emitted
|
||||||
|
# regardless. Default False = XAU-tuned: replaying the live gates
|
||||||
|
# against the validated 12mo XAU M5 book (547 trades, +139.9R)
|
||||||
|
# showed they suppress 25-35 trades worth +4.6..+7.2R/yr — trades
|
||||||
|
# entered near USD releases OUTPERFORM (+0.475R vs +0.256R avg).
|
||||||
|
# Audit 2026-07-11 (calendar-replay study; tooling since removed).
|
||||||
|
event_risk_caps_signals: bool = False
|
||||||
|
# Whether headline sentiment moves the SIGNAL (aligned: +5 confidence;
|
||||||
|
# conflicting: 10-point penalty). Warnings are always emitted
|
||||||
|
# regardless. Default False = news is informational only; decisions
|
||||||
|
# are pure technical, matching the backtester (use_news=False).
|
||||||
|
sentiment_affects_signals: bool = False
|
||||||
|
# Wider stay-out window after a high-impact release that surprised
|
||||||
|
# vs forecast (inline releases use post_event_buffer_minutes).
|
||||||
|
post_event_surprise_buffer_minutes: int = 45
|
||||||
|
# How long fetched calendar/headline data is reused across scans.
|
||||||
|
# Must stay below pre_event_buffer_minutes so an event can't cross
|
||||||
|
# into the danger window while a stale cache says it's clear.
|
||||||
|
context_cache_ttl_seconds: int = 600
|
||||||
|
|
||||||
# News sentiment sources
|
|
||||||
rss_feeds: List[str] = field(default_factory=lambda: [
|
rss_feeds: List[str] = field(default_factory=lambda: [
|
||||||
"https://www.forexlive.com/feed/",
|
"https://investinglive.com/feed/",
|
||||||
"https://www.fxstreet.com/rss",
|
"https://www.fxstreet.com/rss",
|
||||||
|
# Google News query targeted at gold drivers — also carries Kitco,
|
||||||
|
# Reuters etc. whose direct feeds are unavailable.
|
||||||
|
"https://news.google.com/rss/search?q=gold+price+OR+fed+OR+inflation&hl=en-US&gl=US&ceid=US:en",
|
||||||
|
# CNBC "Economy" section
|
||||||
|
"https://search.cnbc.com/rs/search/combinedcms/view.xml?partnerId=wrss01&id=20910258",
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class XauStrategyConfig:
|
||||||
|
"""XAU/USD strategy config."""
|
||||||
|
enabled: bool = True
|
||||||
|
tp_atr_multiplier: float = 1.0
|
||||||
|
sl_atr_multiplier: float = 2.0
|
||||||
|
blocked_hours_utc: List[int] = field(default_factory=lambda: [0, 1, 2, 10, 11, 13, 16, 21])
|
||||||
|
weekend_filter: bool = True
|
||||||
|
min_atr_threshold: float = 1.0
|
||||||
|
v2_sl_atr_multiplier: float = 2.0
|
||||||
|
allowed_weekdays: List[int] = field(default_factory=lambda: [0, 1, 2, 3, 4, 6]) # Mon-Fri + Sun (Sydney open)
|
||||||
|
sell_min_quality: List[str] = field(default_factory=lambda: ["A+", "A", "B"])
|
||||||
|
# Volatility regimes in which SELL setups are allowed (low/normal-vol
|
||||||
|
# shorts lose consistently against gold's macro uptrend).
|
||||||
|
sell_vol_regimes: List[str] = field(default_factory=lambda: ["high", "extreme"])
|
||||||
|
# Market regimes in which no XAU setup may fire (any direction).
|
||||||
|
# Empty by default; per-timeframe factories opt in — e.g. the mixed
|
||||||
|
# "trending" regime (strong ADX, EMAs disagree) on M15.
|
||||||
|
blocked_regimes: List[str] = field(default_factory=list)
|
||||||
|
# Market regimes in which XAU SELL setups may not fire. Empty by
|
||||||
|
# default; per-timeframe factories opt in.
|
||||||
|
sell_blocked_regimes: List[str] = field(default_factory=list)
|
||||||
|
# Bar-CLOSE (decision-time) UTC hours in which XAU SELL setups may
|
||||||
|
# not fire. Empty by default; per-timeframe factories opt in.
|
||||||
|
# BUYs are unaffected (unlike blocked_hours_utc, which is both-way).
|
||||||
|
sell_blocked_hours_utc: List[int] = field(default_factory=list)
|
||||||
|
|
||||||
|
# ── M5-specific filters ──────────────────────────────────────
|
||||||
|
# M5 signals are noisy; require stronger confirmation before
|
||||||
|
# generating trade setups. Without these, M5 is breakeven
|
||||||
|
# (+0.01R/trade) with 21% drawdown.
|
||||||
|
# Relaxed 2026-07-30 (30→25, 10→5) in the relaxation campaign:
|
||||||
|
# the combined config (with the M5 hour-21 unblock) beat control
|
||||||
|
# in ALL 6 calendar years of the 5yr Oanda replay (+230.9R vs
|
||||||
|
# +168.0R, PF 1.26 vs 1.21, max DD 21.3 vs 27.0), 12mo +107.4R
|
||||||
|
# vs +84.6R PF 1.36 both halves positive, OOS 2024-06→2025-03
|
||||||
|
# +26.1R vs +17.2R, and at $0.70 spread +86.9R vs +66.4R.
|
||||||
|
# Score floors below 25 are vacuous (grade C requires |score|≥25).
|
||||||
|
m5_require_mtf: bool = True
|
||||||
|
m5_min_abs_score: float = 25.0
|
||||||
|
m5_min_confirmation_score: float = 5.0
|
||||||
|
# A-grade M5 SELLs enter at the extreme of temporary dips and
|
||||||
|
# lose (-0.017R/trade on 163 trades) — maximum conviction at
|
||||||
|
# the worst entry. Cap SELL quality so A-grade can't trigger
|
||||||
|
# the ATR-based setup generator; B-grade swing setups survive
|
||||||
|
# because they catch moderate drops, not extremes.
|
||||||
|
m5_sell_max_quality: List[str] = field(default_factory=lambda: ["B", "C"])
|
||||||
|
# 'high' vol regime on M5 is chop transition — elevated noise
|
||||||
|
# without directional expansion. 61% WR / negative total across
|
||||||
|
# 12mo while low/normal/extreme are all positive (validated 2026-07).
|
||||||
|
m5_blocked_vol_regimes: List[str] = field(default_factory=lambda: ["high"])
|
||||||
|
# Block M5 SELLs when RSI is already < this floor: the dip is
|
||||||
|
# extended and mean-reverts before the target (negative in both
|
||||||
|
# halves of 6mo and 12mo backtests).
|
||||||
|
m5_sell_min_rsi: float = 25.0
|
||||||
|
|
||||||
|
# ── M5 pullback entry (XAU_PB) ──
|
||||||
|
# Second entry archetype: enter on the retrace to EMA21/KC-mid in
|
||||||
|
# an established trend instead of at Keltner-breakout extension.
|
||||||
|
# Validated 2026-07: +38.3R/254 trades on 12mo, positive both
|
||||||
|
# halves and all four full quarters, core trades undisturbed.
|
||||||
|
m5_pullback_enabled: bool = True
|
||||||
|
# Composite score is weaker mid-pullback BY DESIGN (price sits
|
||||||
|
# below the momentum bands) — separate, lower floor than Gate 1.
|
||||||
|
m5_pullback_min_score: float = 15.0
|
||||||
|
m5_pullback_adx_min: float = 20.0
|
||||||
|
# RSI must be in the pullback zone, NOT the extended zone the
|
||||||
|
# breakout entries fire in (those run 65-80).
|
||||||
|
m5_pullback_rsi_buy: tuple = (35.0, 62.0)
|
||||||
|
m5_pullback_rsi_sell: tuple = (38.0, 65.0)
|
||||||
|
# Retrace depth from the pre-pullback swing extreme, in ATRs.
|
||||||
|
# Shallower = still extended; deeper = trend likely broken.
|
||||||
|
# M5 gold trend legs measure 3-4 ATR swing-to-trough (median 3.9),
|
||||||
|
# so the ceiling sits well above that of higher TFs.
|
||||||
|
m5_pullback_retrace_atr: tuple = (0.8, 6.0)
|
||||||
|
# Price must have touched EMA21/KC-mid within this many bars.
|
||||||
|
m5_pullback_touch_bars: int = 3
|
||||||
|
|
||||||
|
# ── Chart-pattern entry (XAU_CP) ──────────────────────────────
|
||||||
|
# Third entry archetype: completion of a classical chart pattern
|
||||||
|
# (see chart_patterns.py for the pattern set and design contract).
|
||||||
|
# Defaults are textbook geometry, NOT tuned values. Disabled by
|
||||||
|
# default — per-TF adoption happens in pair_signals/xauusd.py only
|
||||||
|
# after the pattern book passes the standard validation rule
|
||||||
|
# (positive R both halves of ≥12mo, PF ≥ 1.3, $0.30 spread,
|
||||||
|
# trailing exits, next-open fills).
|
||||||
|
cp_enabled: bool = False
|
||||||
|
cp_patterns: List[str] = field(default_factory=lambda: [
|
||||||
|
"inv_hs", "hs_top", "double_bottom", "double_top",
|
||||||
|
"bull_flag", "bear_flag",
|
||||||
|
])
|
||||||
|
cp_lookback: int = 72 # bars scanned for pattern structure
|
||||||
|
cp_swing_window: int = 3 # bars each side to confirm a swing
|
||||||
|
cp_recency_bars: int = 20 # last extreme must be this fresh
|
||||||
|
cp_min_sep_bars: int = 8 # min bars between double extremes
|
||||||
|
cp_tolerance_atr: float = 0.5 # double top/bottom level match tol
|
||||||
|
cp_min_height_atr: float = 1.5 # pattern height floor (noise gate)
|
||||||
|
cp_max_height_atr: float = 8.0 # pattern height ceiling
|
||||||
|
cp_hs_head_min_atr: float = 0.5 # head must exceed shoulders by this
|
||||||
|
cp_hs_shoulder_tol_atr: float = 1.0 # shoulder level match tol
|
||||||
|
cp_flag_pole_atr: float = 3.0 # min pole size
|
||||||
|
cp_flag_pole_bars: int = 12 # pole measured over ≤ this many bars
|
||||||
|
cp_flag_min_bars: int = 3 # flag consolidation length bounds
|
||||||
|
cp_flag_max_bars: int = 12
|
||||||
|
cp_flag_retrace: tuple = (0.1, 0.5) # flag depth as fraction of pole
|
||||||
|
|
||||||
|
# ── Big-candle entry gate ──────────────────────────────────────
|
||||||
|
# When the recent window on the scan's own primary TF contains an
|
||||||
|
# abnormally large candle (bar range >= K x the PRIOR bar's ATR —
|
||||||
|
# institutional burst; price may reverse violently), no XAU entry
|
||||||
|
# of ANY kind may fire (core, XAU_PB, XAU_CP). 0.0 = disabled.
|
||||||
|
# Per-TF factories opt in only after the standard validation rule.
|
||||||
|
big_candle_atr_multiple: float = 0.0
|
||||||
|
big_candle_lookback_hours: float = 24.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FilterConfig:
|
||||||
|
"""Regime and session filters for live signals."""
|
||||||
|
regime_filter: bool = False
|
||||||
|
regime_filter_timeframe: Timeframe = Timeframe.H4
|
||||||
|
regime_filter_min_adx: float = 25.0
|
||||||
|
|
||||||
|
session_filter: bool = True
|
||||||
|
session_start_hour: int = 3
|
||||||
|
session_end_hour: int = 23
|
||||||
|
blocked_hours_utc: List[int] = field(default_factory=lambda: [0, 1, 2, 10, 11, 13, 16, 21])
|
||||||
|
|
||||||
|
allowed_weekdays: List[int] = field(default_factory=lambda: [0, 1, 2, 3, 4, 6]) # Mon-Fri + Sun (Sydney open)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class PlatformConfig:
|
class PlatformConfig:
|
||||||
"""Overall platform configuration."""
|
"""Top-level configuration."""
|
||||||
technical: TechnicalConfig = field(default_factory=TechnicalConfig)
|
technical: TechnicalConfig = field(default_factory=TechnicalConfig)
|
||||||
signals: SignalConfig = field(default_factory=SignalConfig)
|
signals: SignalConfig = field(default_factory=SignalConfig)
|
||||||
news: NewsConfig = field(default_factory=NewsConfig)
|
news: NewsConfig = field(default_factory=NewsConfig)
|
||||||
|
backtest: FilterConfig = field(default_factory=FilterConfig)
|
||||||
|
xau_strategy: XauStrategyConfig = field(default_factory=XauStrategyConfig)
|
||||||
|
|
||||||
# Default pairs to monitor
|
symbol: str = XAUUSD
|
||||||
watch_pairs: List[str] = field(default_factory=lambda: MAJOR_PAIRS)
|
display_name: str = "XAU/USD"
|
||||||
|
pip_value: float = 0.10
|
||||||
|
|
||||||
# Analysis timeframes (primary + confirmation)
|
|
||||||
primary_timeframe: Timeframe = Timeframe.H1
|
primary_timeframe: Timeframe = Timeframe.H1
|
||||||
confirmation_timeframes: List[Timeframe] = field(
|
confirmation_timeframes: List[Timeframe] = field(
|
||||||
default_factory=lambda: [Timeframe.M15, Timeframe.H4]
|
default_factory=lambda: [Timeframe.M15, Timeframe.H4]
|
||||||
)
|
)
|
||||||
|
|
||||||
# Data source
|
data_lookback_days: int = 60
|
||||||
data_lookback_days: int = 60 # enough for 200 EMA on H1
|
|
||||||
|
|
||||||
# Twelve Data API key (overridden by TWELVE_DATA_API_KEY env var)
|
refresh_interval: int = 300
|
||||||
twelve_data_api_key: str = "f563b22ffdfd4924955decbe1a8c8701"
|
|
||||||
|
|
||||||
# Dashboard refresh interval (seconds)
|
scan_pairs: List[str] = field(
|
||||||
refresh_interval: int = 300 # 5 minutes
|
default_factory=lambda: [XAUUSD]
|
||||||
|
)
|
||||||
|
scan_timeframes: List[Timeframe] = field(
|
||||||
|
default_factory=lambda: [Timeframe.M5, Timeframe.M15, Timeframe.H1, Timeframe.H4]
|
||||||
|
)
|
||||||
|
ws_cooldown_seconds: int = 300
|
||||||
|
|
||||||
# Logging
|
|
||||||
log_level: str = "INFO"
|
log_level: str = "INFO"
|
||||||
log_signals_to_file: bool = True
|
|
||||||
signal_log_path: str = "signals.log"
|
|
||||||
|
|||||||
@@ -0,0 +1,420 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
XAU/USD static dashboard publisher.
|
||||||
|
|
||||||
|
Standalone daemon that scans XAU/USD at candle closes and atomically
|
||||||
|
writes `snapshot.json` + `news.json` (plus a copy of
|
||||||
|
`static/dashboard.html`) into an Apache-served directory. Apache does
|
||||||
|
all web serving — this process never opens a port, so a failure here
|
||||||
|
can only ever mean a stale page.
|
||||||
|
|
||||||
|
Independence rules (owner requirement — the telegram bot must be
|
||||||
|
immune to anything on the web side):
|
||||||
|
* Runs as its own process; never imported by, and never imports,
|
||||||
|
`telegram_bot` / `oanda_daemon` (telegram_bot resolves credentials
|
||||||
|
at import). The confirmation map and boundary math are therefore
|
||||||
|
duplicated here as local constants.
|
||||||
|
* The only shared-code dependency beyond the standard signal path is
|
||||||
|
the additive `NewsEngine.get_week_events` accessor, which no
|
||||||
|
production signal path calls.
|
||||||
|
|
||||||
|
Scheduling: every M15/H1/H4 boundary is also an M5 boundary (all
|
||||||
|
intervals are multiples of 5, anchored to UTC midnight), so one
|
||||||
|
sequential loop sleeps to the next M5 boundary (+10 s buffer) and scans
|
||||||
|
whichever timeframes just closed. Due-classification uses the RAW wake
|
||||||
|
time — flooring 12:00:10 to the 5-minute grid yields the 12:00 boundary
|
||||||
|
that just closed. Do NOT subtract the buffer first: 12:00:10 − 15 s
|
||||||
|
floors to 11:55 and silently skips every H1/H4 scan (v1 bug, 2026-08-02).
|
||||||
|
|
||||||
|
Scans run sequentially M5→M15→H1→H4 through one shared data provider so
|
||||||
|
later timeframes hit the warm boundary cache — that ordering IS the
|
||||||
|
duplicate-fetch dedup (providers have no in-flight dedup; Oanda has no
|
||||||
|
throttle). Keep it sequential.
|
||||||
|
|
||||||
|
Run: python dashboard_publisher.py [--once] [--out DIR]
|
||||||
|
Out dir precedence: --out → DASHBOARD_OUT_DIR env → /var/www/trade
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import math
|
||||||
|
import numbers
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from logging.handlers import TimedRotatingFileHandler
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
from config import PlatformConfig, Timeframe, XAUUSD
|
||||||
|
from main import report_to_dict
|
||||||
|
from news_engine import NewsEngine
|
||||||
|
from pair_signals import produce_signal
|
||||||
|
from providers import make_data_provider
|
||||||
|
import providers.base as _providers_base
|
||||||
|
|
||||||
|
logger = logging.getLogger("dashboard")
|
||||||
|
|
||||||
|
DASHBOARD_TFS = [Timeframe.M5, Timeframe.M15, Timeframe.H1, Timeframe.H4]
|
||||||
|
|
||||||
|
# Local copy of the bot's confirmation map (see independence rules above).
|
||||||
|
CONFIRMATIONS = {
|
||||||
|
Timeframe.M5: [Timeframe.M15, Timeframe.H1],
|
||||||
|
Timeframe.M15: [Timeframe.M5, Timeframe.H1],
|
||||||
|
Timeframe.H1: [Timeframe.M15, Timeframe.H4],
|
||||||
|
Timeframe.H4: [Timeframe.H1, Timeframe.D1],
|
||||||
|
}
|
||||||
|
|
||||||
|
TF_INTERVAL_MINUTES = {
|
||||||
|
Timeframe.M5: 5, Timeframe.M15: 15, Timeframe.H1: 60, Timeframe.H4: 240,
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULT_OUT_DIR = "/var/www/trade"
|
||||||
|
SCAN_BUFFER_SECONDS = 10
|
||||||
|
SNAPSHOT_FILE = "snapshot.json"
|
||||||
|
NEWS_FILE = "news.json"
|
||||||
|
PAGE_FILE = "dashboard.html"
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════
|
||||||
|
# Pure layer (no network, fully covered by test_all.py)
|
||||||
|
# ══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def _safe_num(v):
|
||||||
|
"""Coerce to a JSON-safe float; NaN/±inf become None."""
|
||||||
|
try:
|
||||||
|
f = float(v)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return v
|
||||||
|
if math.isnan(f) or math.isinf(f):
|
||||||
|
return None
|
||||||
|
return f
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize(obj):
|
||||||
|
"""Recursively make a payload safe for json.dumps(allow_nan=False):
|
||||||
|
NaN/inf → None, numpy scalars → native ints/floats."""
|
||||||
|
if isinstance(obj, bool):
|
||||||
|
return obj
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
return {k: _sanitize(v) for k, v in obj.items()}
|
||||||
|
if isinstance(obj, (list, tuple)):
|
||||||
|
return [_sanitize(v) for v in obj]
|
||||||
|
if isinstance(obj, numbers.Integral):
|
||||||
|
return int(obj)
|
||||||
|
if isinstance(obj, numbers.Real):
|
||||||
|
return _safe_num(obj)
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
def analysis_to_dict(analysis) -> Optional[dict]:
|
||||||
|
"""Serialize a TechnicalAnalysis. Iterates analysis.indicators only —
|
||||||
|
never vars()/asdict(), which would drag dynamic pandas attrs
|
||||||
|
(_rsi_series, _macd_histogram, …) into the payload. ATR (weight 0)
|
||||||
|
is surfaced separately as context, not in the directional grid."""
|
||||||
|
if analysis is None:
|
||||||
|
return None
|
||||||
|
indicators = {}
|
||||||
|
atr_context = None
|
||||||
|
for name, ind in analysis.indicators.items():
|
||||||
|
if name.startswith("_"):
|
||||||
|
continue
|
||||||
|
entry = {
|
||||||
|
"signal": ind.signal.name,
|
||||||
|
"value": _safe_num(ind.value),
|
||||||
|
"confidence": _safe_num(ind.confidence),
|
||||||
|
"weight": _safe_num(ind.weight),
|
||||||
|
"description": ind.description,
|
||||||
|
}
|
||||||
|
if name == "atr":
|
||||||
|
atr_context = entry
|
||||||
|
elif ind.weight != 0:
|
||||||
|
indicators[name] = entry
|
||||||
|
return {
|
||||||
|
"timeframe": analysis.timeframe,
|
||||||
|
"timestamp": str(analysis.timestamp),
|
||||||
|
"composite_signal": analysis.composite_signal.name,
|
||||||
|
"composite_score": _safe_num(analysis.composite_score),
|
||||||
|
"confidence": _safe_num(analysis.confidence),
|
||||||
|
"market_regime": analysis.market_regime,
|
||||||
|
"volatility_regime": analysis.volatility_regime,
|
||||||
|
"pattern_signals": list(analysis.pattern_signals),
|
||||||
|
"key_levels": {k: _safe_num(v) for k, v in analysis.key_levels.items()},
|
||||||
|
"indicators": indicators,
|
||||||
|
"atr_context": atr_context,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def report_to_webapp_dict(report) -> dict:
|
||||||
|
"""main.report_to_dict + the per-TF detail the dashboard needs."""
|
||||||
|
d = report_to_dict(report)
|
||||||
|
d["primary_signal"] = report.primary_signal.name
|
||||||
|
d["primary_score"] = _safe_num(report.primary_score)
|
||||||
|
d["mtf_scores"] = {k: _safe_num(v) for k, v in report.mtf_scores.items()}
|
||||||
|
d["exhaustion_count"] = report.exhaustion_count
|
||||||
|
d["primary_analysis"] = analysis_to_dict(report.primary_analysis)
|
||||||
|
d["confirmation_analyses"] = [
|
||||||
|
analysis_to_dict(a) for a in report.confirmation_analyses
|
||||||
|
]
|
||||||
|
return _sanitize(d)
|
||||||
|
|
||||||
|
|
||||||
|
def event_to_dict(ev) -> dict:
|
||||||
|
"""EconomicEvent → dict. surprise/minutes_until/is_high_impact are
|
||||||
|
properties, so asdict() would skip them — added explicitly.
|
||||||
|
actual/forecast/previous stay strings as published ('228K', '0.3%')."""
|
||||||
|
return {
|
||||||
|
"time": ev.time.astimezone(timezone.utc).isoformat(),
|
||||||
|
"currency": ev.currency,
|
||||||
|
"impact": ev.impact,
|
||||||
|
"event": ev.event,
|
||||||
|
"forecast": ev.forecast,
|
||||||
|
"previous": ev.previous,
|
||||||
|
"actual": ev.actual,
|
||||||
|
"surprise": ev.surprise,
|
||||||
|
"minutes_until": _safe_num(ev.minutes_until),
|
||||||
|
"is_high_impact": ev.is_high_impact,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def headline_to_dict(h) -> dict:
|
||||||
|
return {
|
||||||
|
"headline": h.headline,
|
||||||
|
"source": h.source,
|
||||||
|
"timestamp": h.timestamp.astimezone(timezone.utc).isoformat()
|
||||||
|
if h.timestamp else None,
|
||||||
|
"currencies": list(h.currencies_mentioned),
|
||||||
|
"score": _safe_num(h.sentiment_score),
|
||||||
|
"keywords": list(h.keywords),
|
||||||
|
"score_by_currency": {
|
||||||
|
k: _safe_num(v) for k, v in h.sentiment_by_currency.items()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_news_payload(context, week_events, now: datetime) -> dict:
|
||||||
|
"""news.json: full calendar week + scored headlines + LLM summary."""
|
||||||
|
headlines = sorted(
|
||||||
|
context.news_headlines,
|
||||||
|
key=lambda h: h.timestamp or now, reverse=True,
|
||||||
|
)
|
||||||
|
return _sanitize({
|
||||||
|
"generated_at": now.isoformat(),
|
||||||
|
"llm_summary": context.llm_summary,
|
||||||
|
"sentiment": dict(context.news_sentiment),
|
||||||
|
"high_impact_warning": context.high_impact_warning,
|
||||||
|
"warning_message": context.warning_message,
|
||||||
|
"calendar_available": context.calendar_available,
|
||||||
|
"events": [event_to_dict(e) for e in week_events],
|
||||||
|
"headlines": [headline_to_dict(h) for h in headlines],
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def due_timeframes(wake_dt: datetime) -> List[Timeframe]:
|
||||||
|
"""Timeframes whose candle just closed at the boundary this wake
|
||||||
|
belongs to. Takes the RAW wake time (boundary + buffer): flooring to
|
||||||
|
the 5-minute grid yields the boundary that just passed. Never
|
||||||
|
subtract the buffer first — see module docstring (v1 bug)."""
|
||||||
|
minutes = wake_dt.hour * 60 + wake_dt.minute
|
||||||
|
floored = minutes - (minutes % 5)
|
||||||
|
return [tf for tf in DASHBOARD_TFS if floored % TF_INTERVAL_MINUTES[tf] == 0]
|
||||||
|
|
||||||
|
|
||||||
|
def seconds_until_boundary(interval_minutes: int, buffer: int = 10) -> float:
|
||||||
|
# Same math as telegram_bot.py / oanda_daemon.py — kept local so the
|
||||||
|
# dashboard never imports bot modules (independence rules above).
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
total_minutes = now.hour * 60 + now.minute
|
||||||
|
elapsed_minutes = total_minutes % interval_minutes
|
||||||
|
elapsed_seconds = elapsed_minutes * 60 + now.second + now.microsecond / 1e6
|
||||||
|
interval_seconds = interval_minutes * 60
|
||||||
|
remaining = interval_seconds - elapsed_seconds + buffer
|
||||||
|
if remaining <= buffer:
|
||||||
|
remaining += interval_seconds
|
||||||
|
return remaining
|
||||||
|
|
||||||
|
|
||||||
|
def _empty_tf_entry() -> dict:
|
||||||
|
return {"status": "warming", "scanned_at": None, "scan_seconds": None,
|
||||||
|
"error": None, "error_at": None, "report": None}
|
||||||
|
|
||||||
|
|
||||||
|
def build_snapshot_payload(snapshots: Dict[str, dict], now: datetime) -> dict:
|
||||||
|
"""snapshot.json: one entry per dashboard TF. Timeframes not yet
|
||||||
|
scanned appear as status="warming" so the page can render a
|
||||||
|
placeholder instead of a hole. server_time_epoch lets the client
|
||||||
|
compute its clock offset once so countdowns don't drift."""
|
||||||
|
timeframes = {}
|
||||||
|
for tf in DASHBOARD_TFS:
|
||||||
|
interval = TF_INTERVAL_MINUTES[tf]
|
||||||
|
minutes = now.hour * 60 + now.minute
|
||||||
|
secs_into = (minutes % interval) * 60 + now.second + now.microsecond / 1e6
|
||||||
|
next_close = now.timestamp() + (interval * 60 - secs_into)
|
||||||
|
entry = _empty_tf_entry()
|
||||||
|
entry.update(snapshots.get(tf.value) or {})
|
||||||
|
entry["next_close_epoch"] = next_close
|
||||||
|
entry["next_scan_epoch"] = next_close + SCAN_BUFFER_SECONDS
|
||||||
|
timeframes[tf.value] = entry
|
||||||
|
return _sanitize({
|
||||||
|
"symbol": XAUUSD,
|
||||||
|
"server_time_epoch": now.timestamp(),
|
||||||
|
"market_closed": _providers_base._is_forex_weekend(now),
|
||||||
|
"timeframes": timeframes,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def publish(obj: dict, path: str) -> None:
|
||||||
|
"""Atomic JSON write: temp file + os.replace, so Apache never serves
|
||||||
|
a torn file. allow_nan=False — a NaN slipping through would render
|
||||||
|
the page blank (JSON.parse rejects bare NaN)."""
|
||||||
|
data = json.dumps(obj, allow_nan=False, separators=(",", ":"))
|
||||||
|
tmp = path + ".tmp"
|
||||||
|
with open(tmp, "w", encoding="utf-8") as fh:
|
||||||
|
fh.write(data)
|
||||||
|
os.replace(tmp, path)
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════
|
||||||
|
# Runtime layer (constructed only from main(); never at import)
|
||||||
|
# ══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def _setup_logging(level: str = "INFO") -> None:
|
||||||
|
log_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "logs")
|
||||||
|
os.makedirs(log_dir, exist_ok=True)
|
||||||
|
log_file = os.path.join(log_dir, "dashboard.log")
|
||||||
|
|
||||||
|
fmt = logging.Formatter(
|
||||||
|
"%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||||
|
datefmt="%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
file_handler = TimedRotatingFileHandler(
|
||||||
|
log_file, when="h", interval=3, backupCount=240, utc=True)
|
||||||
|
file_handler.setFormatter(fmt)
|
||||||
|
|
||||||
|
console_handler = logging.StreamHandler(sys.stdout)
|
||||||
|
console_handler.setFormatter(fmt)
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=getattr(logging, level.upper(), logging.INFO),
|
||||||
|
handlers=[file_handler, console_handler],
|
||||||
|
force=True)
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardPublisher:
|
||||||
|
def __init__(self, out_dir: str):
|
||||||
|
self.out_dir = out_dir
|
||||||
|
self.snapshots: Dict[str, dict] = {}
|
||||||
|
self.news_payload: Optional[dict] = None
|
||||||
|
self._dp = None
|
||||||
|
self._news_engine: Optional[NewsEngine] = None
|
||||||
|
|
||||||
|
def _provider(self):
|
||||||
|
if self._dp is None:
|
||||||
|
self._dp = make_data_provider(PlatformConfig())
|
||||||
|
return self._dp
|
||||||
|
|
||||||
|
def _news(self) -> NewsEngine:
|
||||||
|
if self._news_engine is None:
|
||||||
|
self._news_engine = NewsEngine(PlatformConfig().news)
|
||||||
|
return self._news_engine
|
||||||
|
|
||||||
|
def install_static(self) -> None:
|
||||||
|
"""Copy the repo's dashboard.html into the out dir (atomic), so
|
||||||
|
git pull + restart also updates the page."""
|
||||||
|
src = os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
||||||
|
"static", PAGE_FILE)
|
||||||
|
if not os.path.exists(src):
|
||||||
|
logger.warning(f"{src} missing — page not installed")
|
||||||
|
return
|
||||||
|
dst = os.path.join(self.out_dir, PAGE_FILE)
|
||||||
|
shutil.copyfile(src, dst + ".tmp")
|
||||||
|
os.replace(dst + ".tmp", dst)
|
||||||
|
|
||||||
|
def scan_tf(self, tf: Timeframe) -> None:
|
||||||
|
started = time.time()
|
||||||
|
try:
|
||||||
|
report = produce_signal(XAUUSD, tf, self._provider(),
|
||||||
|
CONFIRMATIONS[tf])
|
||||||
|
self.snapshots[tf.value] = {
|
||||||
|
"status": "ok",
|
||||||
|
"scanned_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"scan_seconds": round(time.time() - started, 2),
|
||||||
|
"error": None,
|
||||||
|
"error_at": None,
|
||||||
|
"report": report_to_webapp_dict(report),
|
||||||
|
}
|
||||||
|
logger.info(f"{tf.value}: {report.final_signal.name} "
|
||||||
|
f"q={report.signal_quality or '-'} "
|
||||||
|
f"({time.time() - started:.1f}s)")
|
||||||
|
except Exception as e:
|
||||||
|
# Keep the last good report; the page shows it with an
|
||||||
|
# error badge instead of going blank.
|
||||||
|
logger.exception(f"{tf.value} scan failed")
|
||||||
|
entry = self.snapshots.get(tf.value) or _empty_tf_entry()
|
||||||
|
entry["status"] = "error"
|
||||||
|
entry["error"] = f"{type(e).__name__}: {e}"
|
||||||
|
entry["error_at"] = datetime.now(timezone.utc).isoformat()
|
||||||
|
self.snapshots[tf.value] = entry
|
||||||
|
|
||||||
|
def refresh_news(self) -> None:
|
||||||
|
try:
|
||||||
|
ne = self._news()
|
||||||
|
context = ne.get_fundamental_context()
|
||||||
|
week = ne.get_week_events()
|
||||||
|
self.news_payload = build_news_payload(
|
||||||
|
context, week, datetime.now(timezone.utc))
|
||||||
|
except Exception:
|
||||||
|
logger.exception("news refresh failed — keeping last payload")
|
||||||
|
|
||||||
|
def publish_all(self) -> None:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
publish(build_snapshot_payload(self.snapshots, now),
|
||||||
|
os.path.join(self.out_dir, SNAPSHOT_FILE))
|
||||||
|
if self.news_payload is not None:
|
||||||
|
publish(self.news_payload, os.path.join(self.out_dir, NEWS_FILE))
|
||||||
|
|
||||||
|
def run_cycle(self, tfs: List[Timeframe]) -> None:
|
||||||
|
for tf in tfs: # sequential — see module docstring (cache dedup)
|
||||||
|
self.scan_tf(tf)
|
||||||
|
self.refresh_news()
|
||||||
|
self.publish_all()
|
||||||
|
|
||||||
|
def run_forever(self) -> None:
|
||||||
|
self.run_cycle(DASHBOARD_TFS) # populate the page at boot
|
||||||
|
while True:
|
||||||
|
delay = seconds_until_boundary(5, buffer=SCAN_BUFFER_SECONDS)
|
||||||
|
logger.info(f"next M5 boundary in {delay:.0f}s")
|
||||||
|
time.sleep(delay)
|
||||||
|
try:
|
||||||
|
due = due_timeframes(datetime.now(timezone.utc))
|
||||||
|
logger.info(f"boundary wake — scanning {[t.value for t in due]}")
|
||||||
|
self.run_cycle(due)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("cycle failed")
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None) -> None:
|
||||||
|
ap = argparse.ArgumentParser(description="XAU/USD static dashboard publisher")
|
||||||
|
ap.add_argument("--once", action="store_true",
|
||||||
|
help="one scan+publish cycle, then exit")
|
||||||
|
ap.add_argument("--out",
|
||||||
|
default=os.environ.get("DASHBOARD_OUT_DIR", DEFAULT_OUT_DIR),
|
||||||
|
help="output directory served by Apache "
|
||||||
|
f"(default: $DASHBOARD_OUT_DIR or {DEFAULT_OUT_DIR})")
|
||||||
|
args = ap.parse_args(argv)
|
||||||
|
|
||||||
|
_setup_logging()
|
||||||
|
os.makedirs(args.out, exist_ok=True)
|
||||||
|
pub = DashboardPublisher(args.out)
|
||||||
|
pub.install_static()
|
||||||
|
if args.once:
|
||||||
|
pub.run_cycle(DASHBOARD_TFS)
|
||||||
|
print(f"Published to {args.out}")
|
||||||
|
return
|
||||||
|
pub.run_forever()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -1,547 +0,0 @@
|
|||||||
"""
|
|
||||||
Forex Signal Intelligence Platform - Data Provider
|
|
||||||
====================================================
|
|
||||||
Retrieves and validates market data from Twelve Data API.
|
|
||||||
Falls back to synthetic GBM data if the API is unavailable.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import time
|
|
||||||
from collections import deque
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from typing import Dict, List, Optional
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
import pandas as pd
|
|
||||||
import requests
|
|
||||||
|
|
||||||
from config import PlatformConfig, Timeframe, SYMBOL_DISPLAY
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class DataIntegrityError(Exception):
|
|
||||||
"""Raised when data fails validation checks."""
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class ForexDataProvider:
|
|
||||||
"""
|
|
||||||
Forex data provider backed by Twelve Data API.
|
|
||||||
|
|
||||||
Twelve Data has native 4h candles, eliminating the H1→H4 resampling
|
|
||||||
hack needed for Yahoo Finance. Free tier: 800 credits/day, 8 req/min.
|
|
||||||
"""
|
|
||||||
|
|
||||||
TD_BASE_URL = "https://api.twelvedata.com"
|
|
||||||
|
|
||||||
TD_INTERVALS = {
|
|
||||||
Timeframe.M1: "1min",
|
|
||||||
Timeframe.M5: "5min",
|
|
||||||
Timeframe.M15: "15min",
|
|
||||||
Timeframe.M30: "30min",
|
|
||||||
Timeframe.H1: "1h",
|
|
||||||
Timeframe.H4: "4h",
|
|
||||||
Timeframe.D1: "1day",
|
|
||||||
Timeframe.W1: "1week",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Approximate bars per day for each timeframe (used to size outputsize)
|
|
||||||
BARS_PER_DAY = {
|
|
||||||
Timeframe.M1: 1440,
|
|
||||||
Timeframe.M5: 288,
|
|
||||||
Timeframe.M15: 96,
|
|
||||||
Timeframe.M30: 48,
|
|
||||||
Timeframe.H1: 24,
|
|
||||||
Timeframe.H4: 6,
|
|
||||||
Timeframe.D1: 1,
|
|
||||||
Timeframe.W1: 0.143,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Free plan: 8 API credits per minute (each symbol = 1 credit)
|
|
||||||
RATE_LIMIT = 8
|
|
||||||
RATE_WINDOW = 60.0
|
|
||||||
|
|
||||||
def __init__(self, config: PlatformConfig):
|
|
||||||
self.config = config
|
|
||||||
self._api_key = (
|
|
||||||
os.environ.get("TWELVE_DATA_API_KEY")
|
|
||||||
or config.twelve_data_api_key
|
|
||||||
)
|
|
||||||
self._cache: Dict[str, pd.DataFrame] = {}
|
|
||||||
self._cache_timestamps: Dict[str, float] = {}
|
|
||||||
self._request_times: deque = deque() # timestamps of recent API credits used
|
|
||||||
self._session = requests.Session()
|
|
||||||
self._session.headers.update({
|
|
||||||
"User-Agent": "Mozilla/5.0 (compatible; ForexIntel/1.0)"
|
|
||||||
})
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _to_td_symbol(symbol: str) -> str:
|
|
||||||
"""Convert internal symbol (EURUSD=X) to Twelve Data format (EUR/USD)."""
|
|
||||||
clean = symbol.replace("=X", "")
|
|
||||||
return f"{clean[:3]}/{clean[3:]}"
|
|
||||||
|
|
||||||
def fetch_ohlcv(
|
|
||||||
self,
|
|
||||||
symbol: str,
|
|
||||||
timeframe: Timeframe,
|
|
||||||
lookback_days: Optional[int] = None,
|
|
||||||
) -> pd.DataFrame:
|
|
||||||
"""
|
|
||||||
Fetch OHLCV data for a Forex pair with full validation.
|
|
||||||
|
|
||||||
Returns a DataFrame with columns: Open, High, Low, Close, Volume
|
|
||||||
Index is a timezone-aware DatetimeIndex in UTC.
|
|
||||||
"""
|
|
||||||
if lookback_days is None:
|
|
||||||
lookback_days = self.config.data_lookback_days
|
|
||||||
|
|
||||||
cache_key = f"{symbol}_{timeframe.value}"
|
|
||||||
now = time.time()
|
|
||||||
|
|
||||||
if (
|
|
||||||
cache_key in self._cache_timestamps
|
|
||||||
and (now - self._cache_timestamps[cache_key]) < self.config.refresh_interval
|
|
||||||
and cache_key in self._cache
|
|
||||||
):
|
|
||||||
cached = self._cache.get(cache_key)
|
|
||||||
if cached is not None and not cached.empty:
|
|
||||||
return cached
|
|
||||||
|
|
||||||
df = self._fetch_twelve_data(symbol, timeframe, lookback_days)
|
|
||||||
df = self._validate_and_clean(df, symbol, timeframe)
|
|
||||||
|
|
||||||
self._cache[cache_key] = df
|
|
||||||
self._cache_timestamps[cache_key] = now
|
|
||||||
return df
|
|
||||||
|
|
||||||
def prefetch_batch(
|
|
||||||
self,
|
|
||||||
symbols: List[str],
|
|
||||||
timeframes: List[Timeframe],
|
|
||||||
lookback_days: Optional[int] = None,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Batch-fetch all symbols for each timeframe in a single API call per TF.
|
|
||||||
|
|
||||||
Twelve Data accepts comma-separated symbols, so 7 pairs × 3 timeframes
|
|
||||||
= 3 requests instead of 21 — well within the free-tier 8 req/min limit.
|
|
||||||
Results are stored in the local cache; subsequent fetch_ohlcv calls
|
|
||||||
for the same symbol/TF will be served from cache.
|
|
||||||
"""
|
|
||||||
if lookback_days is None:
|
|
||||||
lookback_days = self.config.data_lookback_days
|
|
||||||
|
|
||||||
for tf in timeframes:
|
|
||||||
try:
|
|
||||||
self._batch_fetch_timeframe(symbols, tf, lookback_days)
|
|
||||||
time.sleep(1.0) # brief pause between TF requests
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Batch prefetch failed for {tf.value}: {e}")
|
|
||||||
|
|
||||||
def fetch_multiple(
|
|
||||||
self,
|
|
||||||
symbols: List[str],
|
|
||||||
timeframe: Timeframe,
|
|
||||||
lookback_days: Optional[int] = None,
|
|
||||||
) -> Dict[str, pd.DataFrame]:
|
|
||||||
"""Fetch data for multiple symbols, returning dict of DataFrames."""
|
|
||||||
results = {}
|
|
||||||
for symbol in symbols:
|
|
||||||
try:
|
|
||||||
df = self.fetch_ohlcv(symbol, timeframe, lookback_days)
|
|
||||||
results[symbol] = df
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Failed to fetch {symbol}: {e}")
|
|
||||||
return results
|
|
||||||
|
|
||||||
def get_latest_price(self, symbol: str) -> Optional[float]:
|
|
||||||
"""Return the most recent close price for a symbol."""
|
|
||||||
try:
|
|
||||||
df = self.fetch_ohlcv(symbol, Timeframe.M5, lookback_days=1)
|
|
||||||
if df is not None and not df.empty:
|
|
||||||
return float(df["Close"].iloc[-1])
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_spread_estimate(self, symbol: str) -> float:
|
|
||||||
"""Estimate typical spread from recent M5 high-low ranges."""
|
|
||||||
try:
|
|
||||||
df = self.fetch_ohlcv(symbol, Timeframe.M5, lookback_days=1)
|
|
||||||
if df is not None and not df.empty:
|
|
||||||
return float((df["High"] - df["Low"]).median())
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
fallback_spreads = {
|
|
||||||
"EURUSD=X": 0.00010, "GBPUSD=X": 0.00015,
|
|
||||||
"USDJPY=X": 0.010, "USDCHF=X": 0.00015,
|
|
||||||
"AUDUSD=X": 0.00015, "USDCAD=X": 0.00018,
|
|
||||||
"NZDUSD=X": 0.00020,
|
|
||||||
}
|
|
||||||
return fallback_spreads.get(symbol, 0.00020)
|
|
||||||
|
|
||||||
def get_pair_info(self, symbol: str) -> Dict:
|
|
||||||
"""Return display name and pip value for a pair."""
|
|
||||||
display = SYMBOL_DISPLAY.get(symbol, symbol)
|
|
||||||
is_jpy = "JPY" in symbol
|
|
||||||
return {
|
|
||||||
"symbol": symbol,
|
|
||||||
"display": display,
|
|
||||||
"pip_value": 0.01 if is_jpy else 0.0001,
|
|
||||||
"is_jpy_pair": is_jpy,
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── Private Methods ────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _throttle(self, credits: int = 1) -> None:
|
|
||||||
"""
|
|
||||||
Block until `credits` API credits are available.
|
|
||||||
|
|
||||||
Twelve Data free tier: 8 credits/minute. Each symbol in any request
|
|
||||||
(single or batch) consumes 1 credit. This method enforces the limit
|
|
||||||
regardless of whether requests are batched or individual.
|
|
||||||
"""
|
|
||||||
while True:
|
|
||||||
now = time.time()
|
|
||||||
# Evict credits older than the rate window
|
|
||||||
while self._request_times and now - self._request_times[0] >= self.RATE_WINDOW:
|
|
||||||
self._request_times.popleft()
|
|
||||||
|
|
||||||
available = self.RATE_LIMIT - len(self._request_times)
|
|
||||||
if available >= credits:
|
|
||||||
for _ in range(credits):
|
|
||||||
self._request_times.append(time.time())
|
|
||||||
return
|
|
||||||
|
|
||||||
# Wait until the oldest credit expires
|
|
||||||
wait = (self._request_times[0] + self.RATE_WINDOW) - now + 0.2
|
|
||||||
logger.info(
|
|
||||||
f"Rate limit: {len(self._request_times)}/{self.RATE_LIMIT} credits used "
|
|
||||||
f"— waiting {wait:.1f}s for next window..."
|
|
||||||
)
|
|
||||||
time.sleep(wait)
|
|
||||||
|
|
||||||
def _batch_fetch_timeframe(
|
|
||||||
self, symbols: List[str], timeframe: Timeframe, lookback_days: int
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Single API call fetching all symbols for one timeframe.
|
|
||||||
Twelve Data returns a dict keyed by symbol when multiple are requested.
|
|
||||||
Parsed DataFrames are validated and stored in the cache.
|
|
||||||
"""
|
|
||||||
if not self._api_key:
|
|
||||||
return
|
|
||||||
|
|
||||||
td_symbols = [self._to_td_symbol(s) for s in symbols]
|
|
||||||
interval = self.TD_INTERVALS[timeframe]
|
|
||||||
outputsize = min(
|
|
||||||
int(lookback_days * self.BARS_PER_DAY[timeframe]) + 20, 5000
|
|
||||||
)
|
|
||||||
outputsize = max(outputsize, 300)
|
|
||||||
|
|
||||||
# Each symbol in a batch request costs 1 credit
|
|
||||||
self._throttle(credits=len(symbols))
|
|
||||||
|
|
||||||
resp = self._session.get(
|
|
||||||
f"{self.TD_BASE_URL}/time_series",
|
|
||||||
params={
|
|
||||||
"symbol": ",".join(td_symbols),
|
|
||||||
"interval": interval,
|
|
||||||
"outputsize": outputsize,
|
|
||||||
"apikey": self._api_key,
|
|
||||||
"timezone": "UTC",
|
|
||||||
"format": "json",
|
|
||||||
},
|
|
||||||
timeout=30,
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
data = resp.json()
|
|
||||||
|
|
||||||
# If the whole batch request returned a top-level error, raise it
|
|
||||||
if isinstance(data, dict) and data.get("status") == "error":
|
|
||||||
raise DataIntegrityError(
|
|
||||||
f"Batch API error ({timeframe.value}): {data.get('message', 'unknown')}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Batch response: {"EUR/USD": {...}, "GBP/USD": {...}, ...}
|
|
||||||
now = time.time()
|
|
||||||
for symbol, td_sym in zip(symbols, td_symbols):
|
|
||||||
try:
|
|
||||||
sym_data = data.get(td_sym, {})
|
|
||||||
if sym_data.get("status") == "error":
|
|
||||||
logger.warning(
|
|
||||||
f"Batch fetch error for {td_sym}: {sym_data.get('message')}"
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
values = sym_data.get("values")
|
|
||||||
if not values:
|
|
||||||
logger.warning(f"No values in batch response for {td_sym}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
records = [
|
|
||||||
{
|
|
||||||
"Datetime": pd.Timestamp(v["datetime"], tz="UTC"),
|
|
||||||
"Open": float(v["open"]),
|
|
||||||
"High": float(v["high"]),
|
|
||||||
"Low": float(v["low"]),
|
|
||||||
"Close": float(v["close"]),
|
|
||||||
"Volume": float(v.get("volume") or 0),
|
|
||||||
}
|
|
||||||
for v in reversed(values)
|
|
||||||
]
|
|
||||||
df = pd.DataFrame(records).set_index("Datetime")
|
|
||||||
df.index.name = "Datetime"
|
|
||||||
df = self._validate_and_clean(df, symbol, timeframe)
|
|
||||||
|
|
||||||
cache_key = f"{symbol}_{timeframe.value}"
|
|
||||||
self._cache[cache_key] = df
|
|
||||||
self._cache_timestamps[cache_key] = now
|
|
||||||
logger.debug(f"Batch cached {len(df)} bars for {td_sym} @ {interval}")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Failed to parse batch data for {td_sym}: {e}")
|
|
||||||
|
|
||||||
def _fetch_twelve_data(
|
|
||||||
self, symbol: str, timeframe: Timeframe, lookback_days: int
|
|
||||||
) -> pd.DataFrame:
|
|
||||||
"""
|
|
||||||
Fetch OHLCV from Twelve Data REST API.
|
|
||||||
Falls back to synthetic GBM data if API is unavailable or key is missing.
|
|
||||||
"""
|
|
||||||
if not self._api_key:
|
|
||||||
logger.warning("No Twelve Data API key — using synthetic data. "
|
|
||||||
"Set TWELVE_DATA_API_KEY env var or config.twelve_data_api_key.")
|
|
||||||
return self._generate_synthetic_data(symbol, timeframe, lookback_days)
|
|
||||||
|
|
||||||
td_symbol = self._to_td_symbol(symbol)
|
|
||||||
interval = self.TD_INTERVALS[timeframe]
|
|
||||||
outputsize = min(
|
|
||||||
int(lookback_days * self.BARS_PER_DAY[timeframe]) + 20,
|
|
||||||
5000,
|
|
||||||
)
|
|
||||||
outputsize = max(outputsize, 300)
|
|
||||||
|
|
||||||
self._throttle(credits=1)
|
|
||||||
|
|
||||||
try:
|
|
||||||
resp = self._session.get(
|
|
||||||
f"{self.TD_BASE_URL}/time_series",
|
|
||||||
params={
|
|
||||||
"symbol": td_symbol,
|
|
||||||
"interval": interval,
|
|
||||||
"outputsize": outputsize,
|
|
||||||
"apikey": self._api_key,
|
|
||||||
"timezone": "UTC",
|
|
||||||
"format": "json",
|
|
||||||
},
|
|
||||||
timeout=15,
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
data = resp.json()
|
|
||||||
|
|
||||||
if data.get("status") == "error":
|
|
||||||
raise DataIntegrityError(
|
|
||||||
f"Twelve Data API error for {td_symbol}: {data.get('message', 'unknown')}"
|
|
||||||
)
|
|
||||||
|
|
||||||
values = data.get("values")
|
|
||||||
if not values:
|
|
||||||
raise DataIntegrityError(f"Empty response from Twelve Data for {td_symbol}")
|
|
||||||
|
|
||||||
# Values arrive newest-first; reverse to chronological order
|
|
||||||
records = [
|
|
||||||
{
|
|
||||||
"Datetime": pd.Timestamp(v["datetime"], tz="UTC"),
|
|
||||||
"Open": float(v["open"]),
|
|
||||||
"High": float(v["high"]),
|
|
||||||
"Low": float(v["low"]),
|
|
||||||
"Close": float(v["close"]),
|
|
||||||
"Volume": float(v.get("volume") or 0),
|
|
||||||
}
|
|
||||||
for v in reversed(values)
|
|
||||||
]
|
|
||||||
|
|
||||||
df = pd.DataFrame(records).set_index("Datetime")
|
|
||||||
df.index.name = "Datetime"
|
|
||||||
logger.debug(f"Fetched {len(df)} bars for {td_symbol} @ {interval}")
|
|
||||||
return df
|
|
||||||
|
|
||||||
except DataIntegrityError:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.info(f"Twelve Data fetch failed for {symbol} ({e}), using synthetic data.")
|
|
||||||
return self._generate_synthetic_data(symbol, timeframe, lookback_days)
|
|
||||||
|
|
||||||
def _generate_synthetic_data(
|
|
||||||
self, symbol: str, timeframe: Timeframe, lookback_days: int
|
|
||||||
) -> pd.DataFrame:
|
|
||||||
"""
|
|
||||||
Generate realistic synthetic Forex OHLCV using GBM with mean-reversion.
|
|
||||||
Used as a fallback when live data is unavailable.
|
|
||||||
"""
|
|
||||||
pair_params = {
|
|
||||||
"EURUSD=X": {"price": 1.1350, "vol": 0.07, "drift": -0.001},
|
|
||||||
"GBPUSD=X": {"price": 1.3200, "vol": 0.08, "drift": 0.002},
|
|
||||||
"USDJPY=X": {"price": 144.50, "vol": 0.09, "drift": 0.003},
|
|
||||||
"USDCHF=X": {"price": 0.8850, "vol": 0.07, "drift": -0.001},
|
|
||||||
"AUDUSD=X": {"price": 0.6550, "vol": 0.10, "drift": -0.002},
|
|
||||||
"USDCAD=X": {"price": 1.3650, "vol": 0.07, "drift": 0.001},
|
|
||||||
"NZDUSD=X": {"price": 0.6100, "vol": 0.10, "drift": -0.001},
|
|
||||||
"EURGBP=X": {"price": 0.8590, "vol": 0.06, "drift": 0.000},
|
|
||||||
"EURJPY=X": {"price": 163.90, "vol": 0.09, "drift": 0.002},
|
|
||||||
"GBPJPY=X": {"price": 190.70, "vol": 0.11, "drift": 0.003},
|
|
||||||
"AUDJPY=X": {"price": 94.60, "vol": 0.11, "drift": 0.001},
|
|
||||||
"EURCHF=X": {"price": 0.9380, "vol": 0.05, "drift": 0.000},
|
|
||||||
"CADJPY=X": {"price": 105.80, "vol": 0.09, "drift": 0.001},
|
|
||||||
}
|
|
||||||
|
|
||||||
params = pair_params.get(symbol, {"price": 1.0000, "vol": 0.08, "drift": 0.000})
|
|
||||||
base_price = params["price"]
|
|
||||||
annual_vol = params["vol"]
|
|
||||||
annual_drift = params["drift"]
|
|
||||||
|
|
||||||
tf_minutes = {
|
|
||||||
Timeframe.M1: 1, Timeframe.M5: 5, Timeframe.M15: 15,
|
|
||||||
Timeframe.M30: 30, Timeframe.H1: 60, Timeframe.H4: 240,
|
|
||||||
Timeframe.D1: 1440, Timeframe.W1: 10080,
|
|
||||||
}
|
|
||||||
bar_minutes = tf_minutes.get(timeframe, 60)
|
|
||||||
bars_per_year = (365.25 * 24 * 60) / bar_minutes
|
|
||||||
dt = 1.0 / bars_per_year
|
|
||||||
|
|
||||||
n_bars = min(int(lookback_days * 24 * 60 / bar_minutes), 2000)
|
|
||||||
n_bars = max(n_bars, 300)
|
|
||||||
|
|
||||||
seed_base = int(hashlib.md5(symbol.encode()).hexdigest()[:8], 16)
|
|
||||||
time_component = int(time.time()) // 3600
|
|
||||||
np.random.seed((seed_base + time_component) % (2**31))
|
|
||||||
|
|
||||||
bar_vol = annual_vol * np.sqrt(dt)
|
|
||||||
bar_drift = annual_drift * dt
|
|
||||||
|
|
||||||
regime_len = np.random.randint(20, 80)
|
|
||||||
trend_strength = np.random.normal(0, 0.3)
|
|
||||||
|
|
||||||
price = base_price
|
|
||||||
prices = [price]
|
|
||||||
|
|
||||||
for i in range(1, n_bars):
|
|
||||||
if i % regime_len == 0:
|
|
||||||
regime_len = np.random.randint(20, 80)
|
|
||||||
trend_strength = np.random.normal(0, 0.3)
|
|
||||||
|
|
||||||
mr_force = -0.001 * (price - base_price) / base_price
|
|
||||||
ret = bar_drift + trend_strength * dt + mr_force + bar_vol * np.random.randn()
|
|
||||||
|
|
||||||
if np.random.random() < 0.02:
|
|
||||||
ret *= np.random.uniform(2.0, 4.0)
|
|
||||||
|
|
||||||
price *= (1 + ret)
|
|
||||||
prices.append(price)
|
|
||||||
|
|
||||||
prices = np.array(prices)
|
|
||||||
opens = np.roll(prices, 1)
|
|
||||||
opens[0] = prices[0]
|
|
||||||
|
|
||||||
typical_range = bar_vol * base_price * 1.5
|
|
||||||
highs = np.maximum(prices, opens) + np.abs(np.random.normal(0, typical_range * 0.6, n_bars))
|
|
||||||
lows = np.minimum(prices, opens) - np.abs(np.random.normal(0, typical_range * 0.6, n_bars))
|
|
||||||
highs = np.maximum(highs, np.maximum(opens, prices))
|
|
||||||
lows = np.minimum(lows, np.minimum(opens, prices))
|
|
||||||
|
|
||||||
volume = np.random.lognormal(mean=10, sigma=0.5, size=n_bars).astype(int)
|
|
||||||
|
|
||||||
freq_map = {
|
|
||||||
Timeframe.M1: "1min", Timeframe.M5: "5min",
|
|
||||||
Timeframe.M15: "15min", Timeframe.M30: "30min",
|
|
||||||
Timeframe.H1: "1h", Timeframe.H4: "4h",
|
|
||||||
Timeframe.D1: "D", Timeframe.W1: "W",
|
|
||||||
}
|
|
||||||
freq = freq_map.get(timeframe, "1h")
|
|
||||||
end_time = pd.Timestamp.now(tz="UTC").floor(f"{bar_minutes}min")
|
|
||||||
index = pd.date_range(end=end_time, periods=n_bars, freq=freq, tz="UTC")
|
|
||||||
|
|
||||||
df = pd.DataFrame({
|
|
||||||
"Open": opens, "High": highs, "Low": lows,
|
|
||||||
"Close": prices, "Volume": volume,
|
|
||||||
}, index=index)
|
|
||||||
df.index.name = "Datetime"
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"Generated {n_bars} synthetic {timeframe.value} bars for "
|
|
||||||
f"{SYMBOL_DISPLAY.get(symbol, symbol)} @ {prices[-1]:.5f}"
|
|
||||||
)
|
|
||||||
return df
|
|
||||||
|
|
||||||
def _validate_and_clean(
|
|
||||||
self, df: pd.DataFrame, symbol: str, timeframe: Timeframe
|
|
||||||
) -> pd.DataFrame:
|
|
||||||
"""
|
|
||||||
Validate and clean OHLCV data.
|
|
||||||
|
|
||||||
Steps: drop all-NaN rows → ffill small gaps → remove future timestamps
|
|
||||||
→ fix OHLC inconsistencies → remove >10-sigma outliers → sort & deduplicate.
|
|
||||||
"""
|
|
||||||
if df is None or df.empty:
|
|
||||||
raise DataIntegrityError(f"Empty dataset for {symbol}")
|
|
||||||
|
|
||||||
original_len = len(df)
|
|
||||||
|
|
||||||
df = df.dropna(subset=["Open", "High", "Low", "Close"], how="all")
|
|
||||||
df = df.ffill(limit=3)
|
|
||||||
df = df.dropna(subset=["Close"])
|
|
||||||
|
|
||||||
now = pd.Timestamp.utcnow()
|
|
||||||
future_mask = df.index > now + pd.Timedelta(minutes=5)
|
|
||||||
if future_mask.any():
|
|
||||||
logger.warning(f"{symbol}: Dropping {future_mask.sum()} future timestamps")
|
|
||||||
df = df[~future_mask]
|
|
||||||
|
|
||||||
invalid_ohlc = (
|
|
||||||
(df["High"] < df["Low"]) |
|
|
||||||
(df["High"] < df["Open"]) |
|
|
||||||
(df["High"] < df["Close"]) |
|
|
||||||
(df["Low"] > df["Open"]) |
|
|
||||||
(df["Low"] > df["Close"])
|
|
||||||
)
|
|
||||||
if invalid_ohlc.any():
|
|
||||||
logger.warning(f"{symbol}: Fixing {invalid_ohlc.sum()} OHLC-inconsistent bars")
|
|
||||||
for idx in df[invalid_ohlc].index:
|
|
||||||
vals = [df.loc[idx, "Open"], df.loc[idx, "Close"]]
|
|
||||||
df.loc[idx, "High"] = max(vals + [df.loc[idx, "High"]])
|
|
||||||
df.loc[idx, "Low"] = min(vals + [df.loc[idx, "Low"]])
|
|
||||||
|
|
||||||
returns = df["Close"].pct_change()
|
|
||||||
std_ret = returns.std()
|
|
||||||
if std_ret > 0:
|
|
||||||
outlier_mask = (returns - returns.mean()).abs() > 10 * std_ret
|
|
||||||
if outlier_mask.any():
|
|
||||||
logger.warning(f"{symbol}: Removing {outlier_mask.sum()} extreme outlier bars")
|
|
||||||
df = df[~outlier_mask]
|
|
||||||
|
|
||||||
min_required = max(
|
|
||||||
self.config.technical.ema_trend,
|
|
||||||
self.config.technical.ichimoku_senkou_b,
|
|
||||||
200,
|
|
||||||
) + 10
|
|
||||||
|
|
||||||
if len(df) < min_required:
|
|
||||||
logger.warning(
|
|
||||||
f"{symbol} {timeframe.value}: Only {len(df)} bars "
|
|
||||||
f"(need {min_required}). Some indicators will be partial."
|
|
||||||
)
|
|
||||||
|
|
||||||
df = df.sort_index()
|
|
||||||
df = df[~df.index.duplicated(keep="last")]
|
|
||||||
|
|
||||||
cleaned = original_len - len(df)
|
|
||||||
if cleaned > 0:
|
|
||||||
logger.info(f"{symbol}: Cleaned {cleaned}/{original_len} bars")
|
|
||||||
|
|
||||||
return df
|
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# Deploying the web dashboard (live LAMP server)
|
||||||
|
|
||||||
|
The dashboard is a **static publisher**: `dashboard_publisher.py` runs as its
|
||||||
|
own daemon, scans XAU/USD at candle closes, and atomically writes
|
||||||
|
`dashboard.html` + `snapshot.json` + `news.json` into a folder Apache already
|
||||||
|
serves. Apache does all web serving — no new port, no Python web server, and
|
||||||
|
the telegram bot process is completely unaffected by anything that happens
|
||||||
|
here (see the module docstring for the independence rules).
|
||||||
|
|
||||||
|
All steps below run **on the live server** (`/home/Forex/ForexBot2`).
|
||||||
|
|
||||||
|
## 1. Get the code
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/Forex/ForexBot2
|
||||||
|
git pull
|
||||||
|
venv/bin/python test_all.py # optional sanity: suite must be green
|
||||||
|
```
|
||||||
|
|
||||||
|
No new Python dependencies are required.
|
||||||
|
|
||||||
|
## 2. Make the web folder writable
|
||||||
|
|
||||||
|
The dashboard publishes straight into the portal docroot `/var/www/trade/`
|
||||||
|
(owner decision 2026-08-03) — it adds exactly three files there:
|
||||||
|
`dashboard.html`, `snapshot.json`, `news.json`. The daemon user must be able
|
||||||
|
to write them:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# if the portal files can be owned by the daemon user:
|
||||||
|
sudo chown Forex: /var/www/trade
|
||||||
|
# otherwise grant write without changing ownership:
|
||||||
|
sudo setfacl -m u:Forex:rwx /var/www/trade
|
||||||
|
```
|
||||||
|
|
||||||
|
(A different path is one edit away: the unit's `Environment=DASHBOARD_OUT_DIR=`
|
||||||
|
line, or `--out` for one-off runs.)
|
||||||
|
|
||||||
|
SELinux (RHEL/Alma/CentOS enforcing): directories under `/var/www` normally
|
||||||
|
already carry `httpd_sys_content_t`, and new files inherit the parent label, so
|
||||||
|
no action is usually needed. If Apache 403s despite correct permissions:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo restorecon -R /var/www/trade
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Smoke-test once
|
||||||
|
|
||||||
|
```bash
|
||||||
|
venv/bin/python dashboard_publisher.py --once
|
||||||
|
ls /var/www/trade # dashboard.html snapshot.json news.json added next to the portal
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open `http://<server>/dashboard.html` (path relative to however the
|
||||||
|
vhost maps `/var/www/trade`) — all four timeframe cards, the calendar, and
|
||||||
|
the newswire should populate.
|
||||||
|
|
||||||
|
## 4. Install the daemon
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp deploy/gold-dashboard.service /etc/systemd/system/
|
||||||
|
# edit User= if the repo owner is not "Forex"
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now gold-dashboard
|
||||||
|
systemctl status gold-dashboard
|
||||||
|
tail -f logs/dashboard.log
|
||||||
|
```
|
||||||
|
|
||||||
|
The daemon scans all four timeframes at boot, then wakes at every M5
|
||||||
|
boundary (+10 s) and rescans whichever timeframes just closed. Logs rotate
|
||||||
|
every 3 h (240 backups), same scheme as the bot.
|
||||||
|
|
||||||
|
## 5. Optional Apache polish
|
||||||
|
|
||||||
|
The page cache-busts its JSON fetches (`?t=<epoch>`), so no Apache config is
|
||||||
|
strictly needed. To be explicit anyway:
|
||||||
|
|
||||||
|
```apache
|
||||||
|
<Directory "/var/www/trade">
|
||||||
|
<FilesMatch "\.json$">
|
||||||
|
Header set Cache-Control "no-cache"
|
||||||
|
</FilesMatch>
|
||||||
|
</Directory>
|
||||||
|
```
|
||||||
|
|
||||||
|
The page is deliberately public (owner decision 2026-08-03). To restrict it
|
||||||
|
later, add standard Apache basic auth (`htpasswd` + `Require valid-user`) on
|
||||||
|
that directory — no code changes needed.
|
||||||
|
|
||||||
|
## Operational notes
|
||||||
|
|
||||||
|
- **Updating**: `git pull` + `systemctl restart gold-dashboard`. The daemon
|
||||||
|
re-copies `static/dashboard.html` into the web folder at startup.
|
||||||
|
- **If the daemon dies**, systemd restarts it in 30 s; worst case the page
|
||||||
|
shows stale data with its own "updated N min ago" evidence. Apache and the
|
||||||
|
telegram bot are unaffected.
|
||||||
|
- **DeepSeek**: the publisher process has its own daily request cap
|
||||||
|
(`deepseek.daily_request_cap`, default 500) separate from the bot's —
|
||||||
|
expect roughly up to ~430 extra calls/day worst case, V4-Flash pricing.
|
||||||
|
- **Credentials**: uses the same env/.env/config.json resolution as the rest
|
||||||
|
of the platform (Oanda candles feed; news needs no key; DeepSeek optional).
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# systemd unit for the XAU/USD static dashboard publisher.
|
||||||
|
# Install (as root, on the live server):
|
||||||
|
# cp deploy/gold-dashboard.service /etc/systemd/system/
|
||||||
|
# systemctl daemon-reload && systemctl enable --now gold-dashboard
|
||||||
|
# See deploy/README-dashboard.md for the full checklist.
|
||||||
|
|
||||||
|
[Unit]
|
||||||
|
Description=XAU/USD dashboard publisher (writes static JSON/HTML for Apache)
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
# Adjust to the account that owns /home/Forex/ForexBot2:
|
||||||
|
User=Forex
|
||||||
|
WorkingDirectory=/home/Forex/ForexBot2
|
||||||
|
Environment=DASHBOARD_OUT_DIR=/var/www/trade
|
||||||
|
ExecStart=/home/Forex/ForexBot2/venv/bin/python dashboard_publisher.py
|
||||||
|
Restart=always
|
||||||
|
RestartSec=30
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>ForexBot2 — Beginner's Guide</title>
|
||||||
|
<style>
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 22mm 18mm;
|
||||||
|
@bottom-center { content: "ForexBot2 Beginner's Guide — page " counter(page) " of " counter(pages); font-size: 8pt; color: #888; }
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { font-family: "DejaVu Sans", Helvetica, Arial, sans-serif; font-size: 10.5pt; line-height: 1.65; color: #1c2733; margin: 0; }
|
||||||
|
h1 { font-size: 21pt; margin: 0 0 2mm; color: #0d1b2a; }
|
||||||
|
.subtitle { color: #5a6b7c; font-size: 10.5pt; margin-bottom: 8mm; }
|
||||||
|
h2 { font-size: 13.5pt; color: #0d1b2a; border-bottom: 2px solid #d4a017; padding-bottom: 1.2mm; margin: 8mm 0 3mm; page-break-after: avoid; }
|
||||||
|
h3 { font-size: 11pt; margin: 5mm 0 1.5mm; color: #23374d; page-break-after: avoid; }
|
||||||
|
p { margin: 0 0 2.8mm; }
|
||||||
|
table { border-collapse: collapse; width: 100%; margin: 3mm 0 4mm; font-size: 9.3pt; page-break-inside: avoid; }
|
||||||
|
th { background: #0d1b2a; color: #fff; text-align: left; padding: 1.8mm 2.5mm; font-weight: 600; }
|
||||||
|
td { border-bottom: 1px solid #dde4ea; padding: 1.8mm 2.5mm; vertical-align: top; }
|
||||||
|
tr:nth-child(even) td { background: #f4f7fa; }
|
||||||
|
table.breakable { page-break-inside: auto; }
|
||||||
|
table.breakable tr { page-break-inside: avoid; }
|
||||||
|
.signal-box { background: #f4f7fa; border: 1px solid #c9d4de; border-left: 4px solid #d4a017; border-radius: 2mm; padding: 3.5mm 4.5mm; font-family: "DejaVu Sans Mono", monospace; font-size: 8.6pt; line-height: 1.55; white-space: pre-wrap; margin: 3mm 0 4mm; page-break-inside: avoid; }
|
||||||
|
.callout { background: #fdf6e3; border: 1px solid #e8d49a; border-left: 4px solid #d4a017; border-radius: 2mm; padding: 3mm 4.5mm; margin: 3mm 0 4mm; page-break-inside: avoid; }
|
||||||
|
.callout.red { background: #fdf0ee; border-color: #eab4ac; border-left-color: #c0392b; }
|
||||||
|
.callout.green { background: #eef7ee; border-color: #b3d9b3; border-left-color: #2e7d32; }
|
||||||
|
.callout b:first-child { display: block; margin-bottom: 1mm; }
|
||||||
|
.example { background: #eef2f6; border-radius: 2mm; padding: 3mm 4.5mm; margin: 2mm 0 4mm; page-break-inside: avoid; }
|
||||||
|
.example b:first-child { color: #23374d; }
|
||||||
|
ul, ol { margin: 0 0 3mm; padding-left: 6mm; }
|
||||||
|
li { margin-bottom: 1.6mm; }
|
||||||
|
code { font-family: "DejaVu Sans Mono", monospace; font-size: 8.8pt; background: #eef2f6; padding: 0.3mm 1.2mm; border-radius: 1mm; }
|
||||||
|
.small { font-size: 8.5pt; color: #5a6b7c; }
|
||||||
|
.disclaimer { border: 1.5px solid #c0392b; border-radius: 2mm; padding: 3.5mm 4.5mm; margin-top: 6mm; font-size: 9pt; page-break-inside: avoid; }
|
||||||
|
.disclaimer b { color: #c0392b; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<h1>ForexBot2 — Beginner's Guide</h1>
|
||||||
|
<div class="subtitle">Everything explained in plain words. No experience needed. · July 2026</div>
|
||||||
|
|
||||||
|
<h2>1 · What is this bot?</h2>
|
||||||
|
<p>ForexBot2 is a computer program that watches the <b>price of gold</b> all day and all night. It studies the price charts using nine different mathematical checks. When the checks agree that the price is likely to move in one direction, it sends you a message on <b>Telegram</b> telling you:</p>
|
||||||
|
<ul>
|
||||||
|
<li>whether to <b>buy</b> or <b>sell</b>,</li>
|
||||||
|
<li>at what price to get in,</li>
|
||||||
|
<li>at what price to get out if the trade goes wrong (so your loss stays small),</li>
|
||||||
|
<li>and how to lock in profit if the trade goes well.</li>
|
||||||
|
</ul>
|
||||||
|
<p>Most days the bot stays quiet for hours. <b>That is normal.</b> Quiet means "nothing worth trading right now." Never trade just because you are bored.</p>
|
||||||
|
|
||||||
|
<h2>2 · Ten words you need to know</h2>
|
||||||
|
<p>Every trading word used in this guide (and in the bot's messages) is explained here. Come back to this table whenever you see a word you don't remember.</p>
|
||||||
|
<table class="breakable">
|
||||||
|
<tr><th style="width:26%">Word</th><th>Plain meaning</th></tr>
|
||||||
|
<tr><td><b>BUY (going "long")</b></td><td>You make money if the gold price goes <b>up</b>.</td></tr>
|
||||||
|
<tr><td><b>SELL (going "short")</b></td><td>You make money if the gold price goes <b>down</b>. Yes, that's possible — your broker lets you sell gold you don't own and buy it back cheaper later.</td></tr>
|
||||||
|
<tr><td><b>Entry</b></td><td>The price at which you open the trade.</td></tr>
|
||||||
|
<tr><td><b>Stop loss (SL)</b></td><td>A safety brake. You tell your broker: "if the price reaches this level, close my trade automatically." It puts a hard limit on how much one trade can lose. <b>Every trade must have one.</b></td></tr>
|
||||||
|
<tr><td><b>Take profit (TP)</b></td><td>The opposite of a stop loss: a level where the trade closes automatically <b>with a profit</b>. In this system it is only a rough guide — see section 6.</td></tr>
|
||||||
|
<tr><td><b>Risk, or "R"</b></td><td>The amount of money you have chosen to lose if the trade fails. If you decide to risk $20, then <b>1R = $20</b>. A trade that wins "+2R" made you $40. This is how traders measure results.</td></tr>
|
||||||
|
<tr><td><b>Trailing stop ("trail")</b></td><td>A stop loss that <b>follows the price</b> as it moves in your favor, but never moves backwards. Explained with numbers in section 6.</td></tr>
|
||||||
|
<tr><td><b>Timeframe (TF)</b></td><td>The size of each candle on the chart. <b>M5</b> = each candle is 5 minutes, <b>M15</b> = 15 minutes, <b>H1</b> = 1 hour, <b>H4</b> = 4 hours. Bigger timeframe = slower, calmer trading.</td></tr>
|
||||||
|
<tr><td><b>XAU/USD</b></td><td>Just the official symbol for "gold priced in US dollars."</td></tr>
|
||||||
|
<tr><td><b>Spread</b></td><td>The tiny difference between the buying price and the selling price. It's your broker's fee, paid on every trade.</td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>3 · Reading a bot message</h2>
|
||||||
|
<p>Here is a typical signal, with plain-English notes below it:</p>
|
||||||
|
<div class="signal-box">🔍 XAU/USD Signal Analysis (H1) ← gold, on the 1-hour chart
|
||||||
|
🟢 BUY Grade: A | 64% confidence ← what to do + how good the setup looks
|
||||||
|
✅ MTF aligned ← bigger and smaller charts agree
|
||||||
|
📋 Indicators … ← the bot's 9 checks (you can skip this part)
|
||||||
|
━━━━━━━━━━━━━━━━━━━━
|
||||||
|
🚀 TRADE SETUP ← ↓ THIS is the part you act on ↓
|
||||||
|
📈 Direction: BUY
|
||||||
|
▶ Entry: 4,132.50 ← open the trade near this price
|
||||||
|
🛑 SL: 4,088.20 ← your safety brake (stop loss)
|
||||||
|
🎯 TP1: 4,176.80 ← rough profit target (a guide, not a rule)
|
||||||
|
🔄 Trail: once price hits 4,159.08,
|
||||||
|
trail SL $33.23 behind best price ← how to lock in profit (see section 6)</div>
|
||||||
|
<ul>
|
||||||
|
<li><b>Grade (A+, A, B, C):</b> the bot's own mark for how clean the setup is. A+ is best. It never sends anything worse than C.</li>
|
||||||
|
<li><b>Confidence and the indicator list:</b> extra detail for curious people. You don't need to understand it to trade the signal.</li>
|
||||||
|
<li><b>⚠️ Warning lines:</b> things the bot noticed, like high volatility. They are "be aware," not "don't trade."</li>
|
||||||
|
<li><b>The trade setup at the bottom is the only part you must act on.</b></li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>4 · How much money to put in (the most important section)</h2>
|
||||||
|
<p>You never put your whole account into one trade. You decide how much you are willing to <b>lose</b>, and work backwards from there. The rule:</p>
|
||||||
|
<div class="callout"><b>Risk 1% of your account per trade. Never more.</b> If you have $2,000, that means one trade may lose at most $20. Losing trades are a normal part of the system — 1% means a bad week stings but never hurts you badly.</div>
|
||||||
|
<div class="example"><b>Worked example.</b><br>
|
||||||
|
Your account: <b>$2,000</b>. Your risk: 1% = <b>$20</b>. So for this trade, 1R = $20.<br>
|
||||||
|
The signal says: Entry 4,132.50, Stop loss 4,088.20. The gap between them is <b>$44.30 per ounce</b> of gold.<br>
|
||||||
|
How much gold to buy: $20 ÷ $44.30 = <b>0.45 ounces</b>.<br>
|
||||||
|
Now, whatever happens: if the price falls to the stop loss, you lose about $20 — and no more. If the trade wins 2R, you make about $40.</div>
|
||||||
|
<p class="small">Most broker apps have a built-in calculator: enter the stop-loss price and the amount you want to risk ($20), and it works out the position size for you. Use it — it avoids mistakes. (On many platforms gold is traded in "lots," where 0.01 lot = 1 ounce.)</p>
|
||||||
|
|
||||||
|
<h2>5 · What to do when a signal arrives — step by step</h2>
|
||||||
|
<ol>
|
||||||
|
<li><b>Check the timeframe</b> in the first line. Only act on the one timeframe you have chosen to follow (see section 7). Ignore the rest.</li>
|
||||||
|
<li><b>Work out your position size</b> using the 1% rule above. Do this before anything else.</li>
|
||||||
|
<li><b>Open the trade</b> in your broker app, in the direction shown (BUY or SELL), at the current market price. Try to act soon after the message; if the price has already run far past the entry price, skip the trade — another signal will come.</li>
|
||||||
|
<li><b>Set the stop loss immediately</b>, at the exact SL price in the message. Not "in your head" — in the app. Gold can move fast, and the stop loss is what protects you while you sleep.</li>
|
||||||
|
<li><b>Then manage the trade with the trailing stop</b>, explained next.</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h2>6 · The trailing stop — how winners get paid</h2>
|
||||||
|
<p>The take-profit price (TP1) in the message is only a guide. The system makes its money by <b>letting winning trades run</b> and following them with a moving safety brake. Every signal message tells you exactly when to start and how far behind to follow. Here is the idea with numbers:</p>
|
||||||
|
<div class="example"><b>Example.</b> You bought gold at <b>4,132</b>, stop loss at <b>4,088</b> (risking $44 per ounce).<br>
|
||||||
|
The message says: <i>"once price hits 4,159, trail SL $33 behind best price."</i><br><br>
|
||||||
|
<b>Step 1 — wait.</b> Until gold reaches 4,159, do nothing. If it falls to 4,088 first, the stop loss closes the trade. That loss is normal and already budgeted.<br>
|
||||||
|
<b>Step 2 — the price reaches 4,159.</b> Now move your stop loss up to 4,159 − 33 = <b>4,126</b>. From here on, the worst case is roughly break-even.<br>
|
||||||
|
<b>Step 3 — keep following.</b> Each time the price makes a new high, move the stop up so it stays $33 below that high. Price climbs to 4,180? Stop goes to 4,147 — profit is now locked in. Price climbs to 4,210? Stop goes to 4,177.<br>
|
||||||
|
<b>Step 4 — the price turns.</b> Eventually gold falls back $33 from its best point and hits your stop. The trade closes by itself, and you keep everything above your entry. <b>You never decide when to take profit — the trail decides.</b></div>
|
||||||
|
<ul>
|
||||||
|
<li>Only move the stop <b>up</b> (for a BUY). Never move it back down. For a SELL, everything is mirrored: the stop only moves down.</li>
|
||||||
|
<li>Move it when a candle finishes, not every second. Checking once per candle (every hour, on the 1-hour timeframe) is enough.</li>
|
||||||
|
<li>Many broker apps can do this automatically — look for a "trailing stop" order and set the distance from the message.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>7 · Which timeframe should you follow?</h2>
|
||||||
|
<p>The bot sends signals from several chart speeds. As a beginner, <b>follow only the 1-hour (H1) signals</b> and ignore the others. Here is why:</p>
|
||||||
|
<table>
|
||||||
|
<tr><th></th><th>M5 (5-minute)</th><th>M15 (15-minute)</th><th>H1 (1-hour) ★ start here</th></tr>
|
||||||
|
<tr><td>Signals per week</td><td>about 10</td><td>about 10</td><td>about 3</td></tr>
|
||||||
|
<tr><td>A trade usually lasts</td><td>a few hours</td><td>about half a day</td><td>half a day to a few days</td></tr>
|
||||||
|
<tr><td>Time you have to react</td><td>1–2 minutes</td><td>up to 15 minutes</td><td>up to an hour</td></tr>
|
||||||
|
<tr><td>Suits</td><td>full-time / automated</td><td>experienced, quick</td><td>anyone with a job and a life</td></tr>
|
||||||
|
</table>
|
||||||
|
<ul>
|
||||||
|
<li><b>H1 is forgiving.</b> You have up to an hour to act on a message, and small delays barely matter. On the 5-minute signals, being two minutes late already costs real money — they are not for manual beginners.</li>
|
||||||
|
<li><b>H1 trades are held longer</b> — often overnight, sometimes over days. That is normal. Your stop loss is working for you while you sleep.</li>
|
||||||
|
<li><b>Never take the same signal on two timeframes.</b> They are usually the same gold move seen at different zoom levels. Taking both doubles your risk, not your profit.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>8 · What to expect — read this before your first trade</h2>
|
||||||
|
<ul>
|
||||||
|
<li><b>You will lose about 4 trades out of every 10.</b> That is how the system is designed. Losses are small (1R each) and wins are bigger on average. The profit comes from many trades added together, not from any single one.</li>
|
||||||
|
<li><b>Losing streaks happen.</b> Five or six losses in a row will happen at some point every year. It does not mean the system is broken. This is exactly why each loss is capped at 1% of your account.</li>
|
||||||
|
<li><b>Expect a rough patch.</b> At some point in a normal year, your account will likely be around 10% below its best point before recovering. If a 10% dip would make you panic or bet bigger to "win it back," trade smaller (0.5% risk) or don't trade.</li>
|
||||||
|
<li><b>Judge the system over months, not days.</b> On the 1-hour timeframe there are only ~3 trades a week. One bad week means nothing; look at results per quarter.</li>
|
||||||
|
<li><b>Real results will be a little worse than tests.</b> The bot's numbers come from careful historical testing, but tests can't include your reaction speed or your broker's exact prices. Expect a bit less, not a bit more.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>9 · The five rules</h2>
|
||||||
|
<div class="callout red">
|
||||||
|
<b>Break these and no signal system can save you.</b>
|
||||||
|
<ol style="margin-bottom:0">
|
||||||
|
<li><b>Risk 1% per trade, always.</b> Never increase the size to win back a loss.</li>
|
||||||
|
<li><b>Every trade gets a stop loss in the app, at entry time.</b> No exceptions, no "mental" stops.</li>
|
||||||
|
<li><b>One timeframe, one open trade at a time.</b></li>
|
||||||
|
<li><b>Missed the entry? Let it go.</b> Chasing a price that already moved turns a good trade into a gamble.</li>
|
||||||
|
<li><b>Only trade money you can afford to lose completely.</b> Never rent, savings you need, or borrowed money.</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
<div class="callout green"><b>A good first month:</b> follow the H1 signals with the smallest position size your broker allows (or a demo account), write down every trade, and practice the trailing stop until it feels routine. Only then trade at 1% risk.</div>
|
||||||
|
|
||||||
|
<div class="disclaimer"><b>⚠️ Risk warning.</b> This bot and guide are for education and research only. They are <b>not financial advice</b>. Trading gold and currencies involves a high risk of losing money, and most beginners lose. Past results — including tested results — do not guarantee future profits. Never trade money you cannot afford to lose. If you are unsure, speak to a licensed financial adviser.</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
# Centralized Configuration (config.json) — Design
|
||||||
|
|
||||||
|
**Date:** 2026-07-21
|
||||||
|
**Status:** Approved (user confirmed all four scoping decisions)
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Move deployment settings — Telegram credentials, data-provider API keys and
|
||||||
|
selection, scanned pairs, scan timeframes, per-pair base blocked hours, alert
|
||||||
|
quality boot default, log level — into a single gitignored `config.json` at the
|
||||||
|
repo root, loaded once at startup.
|
||||||
|
|
||||||
|
**Hard constraint:** zero change to signal logic, indicator weights, thresholds,
|
||||||
|
or gates. With an unedited `config.json` (or none at all), every produced
|
||||||
|
`PlatformConfig` must be byte-identical to today's factory output. This is
|
||||||
|
test-enforced (see Testing).
|
||||||
|
|
||||||
|
## Scoping decisions (user-confirmed 2026-07-21)
|
||||||
|
|
||||||
|
1. **Secrets live in config.json** (gitignored). A committed
|
||||||
|
`config.json.example` carries placeholders. Env vars still override.
|
||||||
|
The hardcoded credential fallbacks in `config.py` and `telegram_bot.py`
|
||||||
|
are **deleted** — on a machine with neither config.json nor env vars, the
|
||||||
|
bot refuses to start with an explicit error instead of silently using
|
||||||
|
committed credentials. (Recommend rotating the Telegram token and Twelve
|
||||||
|
Data key after merge; both are exposed in git history.)
|
||||||
|
2. **Multi-provider is structure only**: a `data_providers` section with named
|
||||||
|
provider blocks and an `active` selector. Only Twelve Data is implemented.
|
||||||
|
No failover logic (nothing to fail over to yet).
|
||||||
|
3. **Blocked hours: base list per pair only.** The validated per-timeframe
|
||||||
|
deltas (M5 appends hour 9; M15 SELL-block 15/17 UTC) stay in
|
||||||
|
`pair_signals/xauusd.py` next to their validation evidence. The overlay is
|
||||||
|
applied **before** those deltas and updates **both** mirrored copies
|
||||||
|
(`xau_strategy.blocked_hours_utc` and `backtest.blocked_hours_utc`) so they
|
||||||
|
cannot drift.
|
||||||
|
4. **Extra keys included:** scan timeframes, alert min quality (boot default),
|
||||||
|
log level. Everything else (indicator params, RR geometry, M5 gates,
|
||||||
|
CP knobs, news config) deliberately stays code-only.
|
||||||
|
|
||||||
|
## File schema
|
||||||
|
|
||||||
|
`config.json` (gitignored; `config.json.example` committed with placeholders
|
||||||
|
for secrets and today's tuned values for everything else):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"telegram": {
|
||||||
|
"token": "<bot token>",
|
||||||
|
"chat_id": "<owner chat id>",
|
||||||
|
"channel_id": "<-100… or empty string to disable the channel feed>",
|
||||||
|
"alert_min_quality": "C"
|
||||||
|
},
|
||||||
|
"data_providers": {
|
||||||
|
"active": "twelve_data",
|
||||||
|
"twelve_data": { "api_key": "<key>" }
|
||||||
|
},
|
||||||
|
"scanning": {
|
||||||
|
"pairs": ["XAU/USD"],
|
||||||
|
"timeframes": ["M5", "M15", "H1", "H4"]
|
||||||
|
},
|
||||||
|
"pairs": {
|
||||||
|
"XAU/USD": { "blocked_hours_utc": [0, 1, 2, 10, 11, 13, 16, 21] }
|
||||||
|
},
|
||||||
|
"log_level": "INFO"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Timeframe strings use the `Timeframe` enum names (`M5`, `M15`, `H1`, `H4`, …).
|
||||||
|
Pair keys use canonical symbols (`XAU/USD`).
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### New module: `app_config.py`
|
||||||
|
|
||||||
|
Single responsibility: read + validate `config.json`, expose typed accessors,
|
||||||
|
apply the pair-level overlay. No signal knowledge.
|
||||||
|
|
||||||
|
- `AppConfig` dataclass: `telegram` (token, chat_id, channel_id,
|
||||||
|
alert_min_quality), `data_providers` (active name + per-provider dicts),
|
||||||
|
`scan_pairs`, `scan_timeframes`, `pair_overrides`
|
||||||
|
(symbol → {blocked_hours_utc}), `log_level`.
|
||||||
|
- `load_app_config(path=REPO_ROOT/"config.json") -> AppConfig` — module-level
|
||||||
|
cached (loaded once per process). Missing file → `AppConfig` of pure
|
||||||
|
defaults + one warning log. Malformed JSON, unknown top-level/section keys,
|
||||||
|
unknown pair symbols, hours outside 0–23, unknown timeframe names, unknown
|
||||||
|
`active` provider → raise `AppConfigError` with a named message (fail fast
|
||||||
|
at boot).
|
||||||
|
- Precedence, applied inside the accessors:
|
||||||
|
**env var → config.json → code default**. Secrets (`TELEGRAM_TOKEN`,
|
||||||
|
`TELEGRAM_CHAT_ID`, `TELEGRAM_CHANNEL_ID`, `TWELVE_DATA_API_KEY`) have **no
|
||||||
|
code default** after this change.
|
||||||
|
- `apply_pair_overrides(cfg: PlatformConfig, app: AppConfig) -> PlatformConfig`
|
||||||
|
— sets the pair's base `blocked_hours_utc` on **both**
|
||||||
|
`cfg.xau_strategy.blocked_hours_utc` and `cfg.backtest.blocked_hours_utc`,
|
||||||
|
plus `cfg.scan_pairs` / `cfg.scan_timeframes`. Called by the pair factory
|
||||||
|
(see below). A no-op when the pair has no override block.
|
||||||
|
|
||||||
|
### Wiring points (all consumers)
|
||||||
|
|
||||||
|
| Consumer | Change |
|
||||||
|
|---|---|
|
||||||
|
| `pair_signals/xauusd.py` | `make_config` calls `apply_pair_overrides` **before** the per-TF deltas (so M5 still appends hour 9 to whatever base the JSON sets, and mirrors stay in sync). |
|
||||||
|
| `data_provider.py` | API key resolution becomes `app_config` accessor for the **active** provider (env `TWELVE_DATA_API_KEY` still wins). `config.twelve_data_api_key` field and its hardcoded default are deleted. Missing key keeps today's behavior: provider constructed with `_td=None`, errors on first fetch. |
|
||||||
|
| `telegram_bot.py` | `TOKEN`, `CHAT_ID`, `CHANNEL_ID` come from `app_config` (hardcoded fallbacks deleted; missing token/chat_id → explicit startup error). `_alert_min_quality` boot default and `logging` level come from `app_config`. `SCAN_PAIRS` (today built from a bare `PlatformConfig()`) reads `app_config.scan_pairs` directly; `apply_pair_overrides` also stamps `cfg.scan_pairs`/`cfg.scan_timeframes` so the dataclass field stays truthful for any other reader. |
|
||||||
|
| `main.py` | No structural change — it uses the factory, which now applies the overlay. |
|
||||||
|
| `backtest/gpu_backtest.py` | No structural change — it uses the factory too, so an edited base blocked-hours list flows into replays identically to live (parity by construction). Data fetches pick up the provider key via `data_provider.py`. |
|
||||||
|
| `config.py` | `twelve_data_api_key` field removed. Everything else untouched. |
|
||||||
|
|
||||||
|
### Multi-provider structure
|
||||||
|
|
||||||
|
`data_provider.py` gains a minimal registry:
|
||||||
|
`PROVIDER_REGISTRY = {"twelve_data": ForexDataProvider}`. `active` selects
|
||||||
|
from it; an unknown name fails at load. Adding a provider later = one config
|
||||||
|
block + one adapter class registered here. No shared base class extracted now
|
||||||
|
(YAGNI — with one provider there is nothing to abstract over yet).
|
||||||
|
|
||||||
|
## Error handling summary
|
||||||
|
|
||||||
|
| Condition | Behavior |
|
||||||
|
|---|---|
|
||||||
|
| No config.json | Defaults + warning. Secrets must come from env or startup fails (bot) / fetch fails (provider). |
|
||||||
|
| Malformed JSON / bad values / unknown keys | `AppConfigError` at load, process exits with named message. |
|
||||||
|
| Missing Telegram token or chat_id at bot boot | Explicit startup error naming the missing key and both places it can be set. |
|
||||||
|
| Missing data-provider key | Same as today: provider builds, first fetch errors. |
|
||||||
|
|
||||||
|
## Migration
|
||||||
|
|
||||||
|
- Create the local `config.json` populated with the **current live values**
|
||||||
|
(the ones being deleted from source) so the running bot keeps working
|
||||||
|
unchanged. `.gitignore` gains `config.json`.
|
||||||
|
- Commit `config.json.example` with placeholders.
|
||||||
|
- CLAUDE.md: update the env-var table, repository map, and config section.
|
||||||
|
- Post-merge recommendation (user action): rotate the Telegram token and
|
||||||
|
Twelve Data key, since both are in git history.
|
||||||
|
|
||||||
|
## Testing (`test_all.py`, new module)
|
||||||
|
|
||||||
|
1. **Parity pin (the critical one):** a config.json containing the example
|
||||||
|
defaults, and no config.json at all, both produce factory output identical
|
||||||
|
to today's (full dataclass equality across all four TFs).
|
||||||
|
2. Overlay mapping: JSON blocked hours land on **both** mirrored fields; M5
|
||||||
|
factory still appends hour 9 on top of a custom base.
|
||||||
|
3. Precedence: env var beats config.json beats default, per key.
|
||||||
|
4. Validation: malformed JSON, bad hour, unknown pair/timeframe/provider each
|
||||||
|
raise `AppConfigError`.
|
||||||
|
5. Missing-file behavior: defaults + no crash.
|
||||||
|
6. Existing factory-equivalence test stays green (equivalence is now defined
|
||||||
|
over factory-with-overlay vs inline-config-with-overlay).
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
Hot reload (config is read once at boot), a second concrete provider,
|
||||||
|
failover, moving any tuned strategy value beyond the base blocked-hours list,
|
||||||
|
news/indicator/geometry settings in JSON.
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>ForexBot2 — User Guide</title>
|
||||||
|
<style>
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 22mm 18mm;
|
||||||
|
@bottom-center { content: "ForexBot2 User Guide — page " counter(page) " of " counter(pages); font-size: 8pt; color: #888; }
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { font-family: "DejaVu Sans", Helvetica, Arial, sans-serif; font-size: 10pt; line-height: 1.55; color: #1c2733; margin: 0; }
|
||||||
|
h1 { font-size: 21pt; margin: 0 0 2mm; color: #0d1b2a; }
|
||||||
|
.subtitle { color: #5a6b7c; font-size: 10.5pt; margin-bottom: 8mm; }
|
||||||
|
h2 { font-size: 13.5pt; color: #0d1b2a; border-bottom: 2px solid #d4a017; padding-bottom: 1.2mm; margin: 8mm 0 3mm; page-break-after: avoid; }
|
||||||
|
h3 { font-size: 11pt; margin: 5mm 0 1.5mm; color: #23374d; page-break-after: avoid; }
|
||||||
|
p { margin: 0 0 2.5mm; }
|
||||||
|
table { border-collapse: collapse; width: 100%; margin: 3mm 0 4mm; font-size: 9pt; page-break-inside: avoid; }
|
||||||
|
th { background: #0d1b2a; color: #fff; text-align: left; padding: 1.8mm 2.5mm; font-weight: 600; }
|
||||||
|
td { border-bottom: 1px solid #dde4ea; padding: 1.6mm 2.5mm; }
|
||||||
|
tr:nth-child(even) td { background: #f4f7fa; }
|
||||||
|
.signal-box { background: #f4f7fa; border: 1px solid #c9d4de; border-left: 4px solid #d4a017; border-radius: 2mm; padding: 3.5mm 4.5mm; font-family: "DejaVu Sans Mono", monospace; font-size: 8.4pt; line-height: 1.5; white-space: pre-wrap; margin: 3mm 0 4mm; page-break-inside: avoid; }
|
||||||
|
.callout { background: #fdf6e3; border: 1px solid #e8d49a; border-left: 4px solid #d4a017; border-radius: 2mm; padding: 3mm 4.5mm; margin: 3mm 0 4mm; page-break-inside: avoid; }
|
||||||
|
.callout.red { background: #fdf0ee; border-color: #eab4ac; border-left-color: #c0392b; }
|
||||||
|
.callout b:first-child { display: block; margin-bottom: 1mm; }
|
||||||
|
ul, ol { margin: 0 0 3mm; padding-left: 6mm; }
|
||||||
|
li { margin-bottom: 1.4mm; }
|
||||||
|
code { font-family: "DejaVu Sans Mono", monospace; font-size: 8.6pt; background: #eef2f6; padding: 0.3mm 1.2mm; border-radius: 1mm; }
|
||||||
|
.small { font-size: 8.5pt; color: #5a6b7c; }
|
||||||
|
.disclaimer { border: 1.5px solid #c0392b; border-radius: 2mm; padding: 3.5mm 4.5mm; margin-top: 6mm; font-size: 9pt; page-break-inside: avoid; }
|
||||||
|
.disclaimer b { color: #c0392b; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<h1>ForexBot2 — User Guide</h1>
|
||||||
|
<div class="subtitle">How to read, act on, and survive the signals · July 2026</div>
|
||||||
|
|
||||||
|
<h2>1 · What the bot does</h2>
|
||||||
|
<p>ForexBot2 analyzes <b>gold (XAU/USD)</b> around the clock on several chart timeframes at once. On every candle close it scores nine technical indicators (trend, momentum, volatility, divergences, candlestick patterns), adapts the weighting to the current market regime (trending / ranging / transitioning), checks that higher timeframes agree, adjusts for news and economic-calendar events, and — only when everything lines up — sends a <b>signal with a complete trade plan</b> to Telegram: direction, entry, stop loss, target, and a trailing-stop management plan.</p>
|
||||||
|
<p>Most of the time it sends nothing. Silence means no edge was found; that is the bot working correctly, not a malfunction.</p>
|
||||||
|
|
||||||
|
<h2>2 · Anatomy of a signal</h2>
|
||||||
|
<div class="signal-box">🔍 XAU/USD Signal Analysis (M15)
|
||||||
|
🕐 2026-07-10 14:00 UTC
|
||||||
|
💰 Close: 4,132.50
|
||||||
|
📊 Regime: Trending Up | Vol: normal
|
||||||
|
━━━━━━━━━━━━━━━━━━━━
|
||||||
|
🟢 BUY 🅰 A | Score: +38.2 | 64% conf
|
||||||
|
✅ MTF aligned
|
||||||
|
📈 MTF: M5 ✅ H1 ✅
|
||||||
|
📋 Indicators … (9 rows)
|
||||||
|
⚠️ EXTREME volatility — wider stops needed
|
||||||
|
━━━━━━━━━━━━━━━━━━━━
|
||||||
|
🚀 TRADE SETUP
|
||||||
|
📈 Direction: BUY
|
||||||
|
▶ Entry: 4,132.50
|
||||||
|
🛑 SL: 4,103.10 ($29.40 / 294 pips)
|
||||||
|
🎯 TP1: 4,161.90 ($29.40 / 294 pips)
|
||||||
|
⚖️ RR: 1:1.0
|
||||||
|
🔄 Trail: once price hits 4,150.14 (+0.6R),
|
||||||
|
trail SL $22.05 behind best price —
|
||||||
|
TP1 is a guide, let it run</div>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr><th style="width:28%">Field</th><th>What it means</th></tr>
|
||||||
|
<tr><td><b>Signal + grade</b></td><td>Direction (BUY / SELL / STRONG_*) plus a quality grade <b>A+ → F</b>. A trade setup is only attached at grade C or better. Higher grades = more indicator agreement and cleaner regime.</td></tr>
|
||||||
|
<tr><td><b>Score / confidence</b></td><td>The regime-weighted composite of all nine indicators. Bigger absolute score = stronger consensus.</td></tr>
|
||||||
|
<tr><td><b>MTF aligned</b></td><td>The two confirmation timeframes agree with the trade direction. Signals without alignment are filtered out on M5.</td></tr>
|
||||||
|
<tr><td><b>Warnings ⚠️</b></td><td>Context, not veto. Backtesting shows warned trades perform about the same — strong momentum often triggers warnings.</td></tr>
|
||||||
|
<tr><td><b>Market context 📰</b></td><td>An AI-written one-liner summarizing today's macro backdrop (headlines + high-impact releases) and its likely pull on gold. Informational only — it never blocks or grades a signal.</td></tr>
|
||||||
|
<tr><td><b>SL / TP1 / RR</b></td><td>Stop loss sits behind the nearest swing level with an ATR buffer. TP1 is the nearest opposing level. <b>1R</b> = the entry-to-SL distance = the amount you risk.</td></tr>
|
||||||
|
<tr><td><b>Trail plan 🔄</b></td><td>The real exit. Once price moves +0.6R in your favor, move the stop to trail 0.75R behind the best price and update it at each candle close. <b>TP1 is advisory — let winners run.</b></td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>3 · Acting on a signal — step by step</h2>
|
||||||
|
<ol>
|
||||||
|
<li><b>Size the position first.</b> Decide your risk per trade — <b>0.5–1% of account, never more</b>. Position size = (account × risk%) ÷ stop distance in $. The stop distance varies per signal, so the size changes every trade; the dollar risk must not.</li>
|
||||||
|
<li><b>Enter promptly at market.</b> All backtested results assume entry at the open of the candle after the signal. On M5, minutes matter (see §4). If you are very late — price already moved a large part of the way to the arm level — skip the trade. There is always another signal.</li>
|
||||||
|
<li><b>Place the stop loss immediately</b> at the given SL. No mental stops — gold gaps and spikes.</li>
|
||||||
|
<li><b>Manage with the trail, not TP1.</b> When price reaches the arm level printed in the message, start trailing the stop the stated $ distance behind the best price, tightening only at candle close. This is what produced the backtested results; taking fixed TP1 profits gives most of the edge away.</li>
|
||||||
|
<li><b>One signal, one trade.</b> The M5, M15 and H1 books confirm off each other and are usually long or short gold at the same time. Taking the same move on multiple timeframes multiplies risk, not diversification. Pick your timeframe (§4) and ignore the others.</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h2>4 · Choosing your timeframe</h2>
|
||||||
|
<p>Twelve-month backtest (Jul 2025 – Jul 2026, real spreads, entry at next candle open):</p>
|
||||||
|
<table>
|
||||||
|
<tr><th></th><th>M5</th><th>M15</th><th>H1</th></tr>
|
||||||
|
<tr><td>Trades / week</td><td>~10</td><td>~9.5</td><td>~3</td></tr>
|
||||||
|
<tr><td>Total result (12 mo)</td><td>+135.6R</td><td>+67.2R</td><td>+49.9R</td></tr>
|
||||||
|
<tr><td>Win rate / profit factor</td><td>59.7% / 1.81</td><td>55.8% / 1.40</td><td>59.4% / 1.94</td></tr>
|
||||||
|
<tr><td>Typical (median) hold</td><td>2.4 h</td><td>6 h</td><td>12.5 h</td></tr>
|
||||||
|
<tr><td>90% of trades close within</td><td>16 h</td><td>40 h</td><td>3.7 days</td></tr>
|
||||||
|
<tr><td>Typical stop distance</td><td>~$19</td><td>~$29</td><td>~$44</td></tr>
|
||||||
|
<tr><td>Worst drawdown</td><td>11.2R</td><td>9.4R</td><td>9.5R</td></tr>
|
||||||
|
</table>
|
||||||
|
<ul>
|
||||||
|
<li><b>M5 — intraday, execution-critical.</b> Highest total edge, but stops are tight: a couple of dollars of slippage or a late entry eats a meaningful share of every trade. Suitable only if you can act within a minute or two of the alert (or automate). Trades open and close within a session; you sleep flat.</li>
|
||||||
|
<li><b>M15 — the middle path.</b> Same frequency as M5, wider stops, up to 15 minutes to act. About half your trades will still be open overnight.</li>
|
||||||
|
<li><b>H1 — swing trading around a normal life.</b> Best quality per trade and very forgiving of slow manual execution (you have up to an hour). The costs: positions held for days, through news and overnight sessions, and a <b>lumpy</b> equity curve — one quarter of the test year was negative and most of the profit came in a single quarter. Judge it on quarters, not weeks.</li>
|
||||||
|
</ul>
|
||||||
|
<div class="callout"><b>If you trade manually from the Telegram alerts, H1 (or M15) is the honest choice.</b> M5's backtested edge assumes near-instant fills that a human copying signals rarely achieves.</div>
|
||||||
|
|
||||||
|
<h2>5 · What to expect (and what not to)</h2>
|
||||||
|
<ul>
|
||||||
|
<li><b>You will lose ~40% of trades.</b> A 56–60% win rate means losing streaks of 5+ are routine over a year. The system's edge is the average across hundreds of trades, not any single one.</li>
|
||||||
|
<li><b>Stops hit fast, winners take time.</b> Losing trades die in roughly half the time winners take. An early stop-out is the system doing its job.</li>
|
||||||
|
<li><b>Drawdowns of ~10R are normal.</b> At 1% risk per trade, expect to be ~10% down from a peak at some point in a normal year. This is why risking more than 1% per trade is how accounts die.</li>
|
||||||
|
<li><b>Backtest ≠ live.</b> Results above charge real spreads and realistic fills, but cannot model your personal latency, broker slippage, or requotes. Assume live results will be somewhat worse — especially on M5.</li>
|
||||||
|
<li><b>Quiet periods are normal.</b> Filters block bad hours, bad regimes and misaligned timeframes. Days can pass without a signal.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>6 · Rules that keep you alive</h2>
|
||||||
|
<div class="callout red">
|
||||||
|
<b>The five rules</b>
|
||||||
|
<ol style="margin-bottom:0">
|
||||||
|
<li>Risk a fixed 0.5–1% per trade. Never "make it back" by sizing up after losses.</li>
|
||||||
|
<li>Hard stop loss in the market on every trade, placed at entry time.</li>
|
||||||
|
<li>One timeframe, one trade at a time. No stacking, no pyramiding onto bot signals.</li>
|
||||||
|
<li>Missed the entry? Skip it. Chasing converts a positive-edge trade into a coin flip.</li>
|
||||||
|
<li>Evaluate performance monthly at the earliest, quarterly for H1. Never after a bad day.</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>7 · Bot commands (owner)</h2>
|
||||||
|
<table>
|
||||||
|
<tr><th style="width:30%">Command</th><th>What it does</th></tr>
|
||||||
|
<tr><td><code>/start</code></td><td>Show the button menu (manual scans per timeframe, Scan All TFs).</td></tr>
|
||||||
|
<tr><td><code>/scan</code></td><td>Run an immediate analysis of the primary timeframe.</td></tr>
|
||||||
|
<tr><td><code>/autoscan</code></td><td>Toggle automatic scanning (M5 / M15 / H1 / H4 at each candle close).</td></tr>
|
||||||
|
<tr><td><code>/status</code></td><td>Uptime, auto-scan state, WebSocket price-monitor state.</td></tr>
|
||||||
|
<tr><td><code>/news</code></td><td>Net XAU/USD news sentiment score (informational only — never gates signals).</td></tr>
|
||||||
|
<tr><td><code>/cache</code> · <code>/clearcache</code></td><td>Inspect / reset the market-data cache.</td></tr>
|
||||||
|
<tr><td><code>/help</code></td><td>Command reference.</td></tr>
|
||||||
|
</table>
|
||||||
|
<p class="small">The bot also streams live prices over WebSocket and fires an immediate scan when price breaches a key level (swing S/R, Bollinger, Keltner, EMA-200). Commands respond only to the configured owner chat; subscribers receive the signal feed via the channel.</p>
|
||||||
|
|
||||||
|
<div class="disclaimer"><b>⚠️ Risk disclaimer.</b> This bot and guide are for educational and research purposes only and are <b>not financial advice</b>. Trading forex, gold and CFDs on margin carries a high risk of loss; past (and backtested) performance does not guarantee future results. Do your own analysis and never risk money you cannot afford to lose.</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
File diff suppressed because it is too large
Load Diff
+185
@@ -0,0 +1,185 @@
|
|||||||
|
"""
|
||||||
|
ForexBot2 — DeepSeek LLM client
|
||||||
|
================================
|
||||||
|
Thin requests-based client for DeepSeek's OpenAI-compatible chat API,
|
||||||
|
used by `news_engine` for display/warning-only enrichment (headline
|
||||||
|
sentiment, event surprise direction, market summary). Signals are NEVER
|
||||||
|
gated on LLM output — every caller has a non-LLM fallback and treats a
|
||||||
|
`None` return as "use the fallback".
|
||||||
|
|
||||||
|
Design constraints:
|
||||||
|
- One attempt per call, no retries: news runs inside scan cycles; the
|
||||||
|
news engine's 600 s context cache is the natural retry cadence.
|
||||||
|
- A UTC-day request cap bounds spend even if a bug causes call storms.
|
||||||
|
- Cost: DeepSeek caches identical prompt PREFIXES server-side (cache-hit
|
||||||
|
input is ~50x cheaper), so callers pass a STATIC system prompt (no
|
||||||
|
timestamps or interpolation) and put all variable content in the user
|
||||||
|
payload.
|
||||||
|
|
||||||
|
This is a module-level singleton (`get_llm_client`) because the daily
|
||||||
|
cap counter must survive the per-scan reconstruction of `NewsEngine`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import datetime as _dt
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from app_config import deepseek_api_key, get_app_config
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DEEPSEEK_URL = "https://api.deepseek.com/chat/completions"
|
||||||
|
DEFAULT_MODEL = "deepseek-v4-flash"
|
||||||
|
# 8s timed out on a measured cold-prefix-cache headline batch (2026-08-03);
|
||||||
|
# warm-cache calls come back well under that. 12s buys cold-start headroom
|
||||||
|
# while still bounding worst-case scan latency (≤3 calls per cache window).
|
||||||
|
DEFAULT_TIMEOUT = 12.0
|
||||||
|
DEFAULT_DAILY_CAP = 500
|
||||||
|
|
||||||
|
|
||||||
|
class DeepSeekClient:
|
||||||
|
"""JSON-in/JSON-out chat wrapper. All failures return None."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
api_key: str,
|
||||||
|
model: str = DEFAULT_MODEL,
|
||||||
|
timeout: float = DEFAULT_TIMEOUT,
|
||||||
|
daily_cap: int = DEFAULT_DAILY_CAP,
|
||||||
|
enabled: bool = True,
|
||||||
|
):
|
||||||
|
self._api_key = api_key or ""
|
||||||
|
self._model = model
|
||||||
|
self._timeout = timeout
|
||||||
|
self._daily_cap = daily_cap
|
||||||
|
self._enabled = enabled
|
||||||
|
self._session = requests.Session()
|
||||||
|
self._session.headers.update({
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": f"Bearer {self._api_key}",
|
||||||
|
})
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._count_day: Optional[_dt.date] = None
|
||||||
|
self._count = 0
|
||||||
|
self._warned: set = set()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def available(self) -> bool:
|
||||||
|
return self._enabled and bool(self._api_key)
|
||||||
|
|
||||||
|
def _warn_once(self, kind: str, msg: str, *args: Any) -> None:
|
||||||
|
if kind in self._warned:
|
||||||
|
logger.debug(msg, *args)
|
||||||
|
else:
|
||||||
|
self._warned.add(kind)
|
||||||
|
logger.warning(msg, *args)
|
||||||
|
|
||||||
|
def _under_cap(self) -> bool:
|
||||||
|
"""Count a request against the UTC-day cap; False = don't call."""
|
||||||
|
today = _dt.datetime.now(_dt.timezone.utc).date()
|
||||||
|
with self._lock:
|
||||||
|
if self._count_day != today:
|
||||||
|
self._count_day = today
|
||||||
|
self._count = 0
|
||||||
|
self._warned.discard("cap")
|
||||||
|
if self._count >= self._daily_cap:
|
||||||
|
self._warn_once(
|
||||||
|
"cap", "DeepSeek daily request cap (%d) exhausted — "
|
||||||
|
"falling back to keyword analysis until UTC midnight",
|
||||||
|
self._daily_cap)
|
||||||
|
return False
|
||||||
|
self._count += 1
|
||||||
|
return True
|
||||||
|
|
||||||
|
def chat_json(self, system_prompt: str,
|
||||||
|
user_payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||||
|
"""One chat call; returns the model's JSON object or None."""
|
||||||
|
if not self.available or not self._under_cap():
|
||||||
|
return None
|
||||||
|
body = {
|
||||||
|
"model": self._model,
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": system_prompt},
|
||||||
|
{"role": "user",
|
||||||
|
"content": json.dumps(user_payload, sort_keys=True)},
|
||||||
|
],
|
||||||
|
"response_format": {"type": "json_object"},
|
||||||
|
"temperature": 0,
|
||||||
|
# V4-Flash is a hybrid thinking model; without this flag it
|
||||||
|
# burns hundreds of reasoning tokens per call — measured
|
||||||
|
# 2026-08-03: a 25-headline batch blew straight through the
|
||||||
|
# timeout AND could exhaust max_tokens before the answer,
|
||||||
|
# returning empty content. The doctrine + few-shot examples
|
||||||
|
# in the system prompts carry the quality instead.
|
||||||
|
"thinking": {"type": "disabled"},
|
||||||
|
# Must fit the largest reply: a full sentiment batch is
|
||||||
|
# ~10 tokens per headline; 1000 truncated mid-JSON on a real
|
||||||
|
# RSS cycle (2026-08-03).
|
||||||
|
"max_tokens": 2000,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
resp = self._session.post(
|
||||||
|
DEEPSEEK_URL, json=body, timeout=self._timeout)
|
||||||
|
except requests.RequestException as e:
|
||||||
|
self._warn_once("network", "DeepSeek call failed: %s", e)
|
||||||
|
return None
|
||||||
|
if resp.status_code != 200:
|
||||||
|
self._warn_once(
|
||||||
|
f"http_{resp.status_code}",
|
||||||
|
"DeepSeek HTTP %s: %s", resp.status_code, resp.text[:200])
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
parsed = self._parse_response(resp.json())
|
||||||
|
except ValueError:
|
||||||
|
parsed = None
|
||||||
|
if parsed is None:
|
||||||
|
self._warn_once("parse", "DeepSeek response was not the "
|
||||||
|
"expected JSON object — ignoring")
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_response(body: Any) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Extract the assistant's JSON object from a chat completion."""
|
||||||
|
try:
|
||||||
|
content = body["choices"][0]["message"]["content"]
|
||||||
|
except (KeyError, IndexError, TypeError):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
obj = json.loads(content)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
return obj if isinstance(obj, dict) else None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Process-wide singleton ────────────────────────────────────────────
|
||||||
|
|
||||||
|
_client: Optional[DeepSeekClient] = None
|
||||||
|
_client_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def get_llm_client() -> DeepSeekClient:
|
||||||
|
global _client
|
||||||
|
with _client_lock:
|
||||||
|
if _client is None:
|
||||||
|
settings = get_app_config().deepseek_settings
|
||||||
|
_client = DeepSeekClient(
|
||||||
|
api_key=deepseek_api_key(),
|
||||||
|
model=str(settings.get("model", DEFAULT_MODEL)),
|
||||||
|
timeout=float(settings.get("timeout_seconds",
|
||||||
|
DEFAULT_TIMEOUT)),
|
||||||
|
daily_cap=int(settings.get("daily_request_cap",
|
||||||
|
DEFAULT_DAILY_CAP)),
|
||||||
|
enabled=settings.get("enabled", True) is not False,
|
||||||
|
)
|
||||||
|
return _client
|
||||||
|
|
||||||
|
|
||||||
|
def reset_llm_client() -> None:
|
||||||
|
"""Drop the singleton (tests / config reloads)."""
|
||||||
|
global _client
|
||||||
|
with _client_lock:
|
||||||
|
_client = None
|
||||||
@@ -1,35 +1,27 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
Forex Signal Intelligence Platform
|
ForexBot2 — XAU/USD Signal Intelligence
|
||||||
====================================
|
========================================
|
||||||
Main entry point. Runs full analysis and outputs formatted signal reports.
|
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python main.py # Analyze all major pairs
|
python main.py # Live signal on H1
|
||||||
python main.py --pairs EURUSD GBPUSD # Specific pairs
|
python main.py --timeframe H4 --detail # Detailed H4 signal
|
||||||
python main.py --timeframe H4 # Different primary TF
|
python main.py --json # JSON output
|
||||||
python main.py --json # JSON output
|
|
||||||
python main.py --watch # Continuous monitoring mode
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
import time
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import List
|
|
||||||
|
|
||||||
# Force UTF-8 output on Windows so box-drawing / Unicode chars render correctly
|
# Force UTF-8 output on Windows so box-drawing chars render
|
||||||
if hasattr(sys.stdout, "reconfigure"):
|
if hasattr(sys.stdout, "reconfigure"):
|
||||||
sys.stdout.reconfigure(encoding="utf-8")
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
if hasattr(sys.stderr, "reconfigure"):
|
if hasattr(sys.stderr, "reconfigure"):
|
||||||
sys.stderr.reconfigure(encoding="utf-8")
|
sys.stderr.reconfigure(encoding="utf-8")
|
||||||
|
|
||||||
from config import (
|
from config import PlatformConfig, Timeframe, SignalStrength, XAUUSD
|
||||||
PlatformConfig, Timeframe, SignalStrength,
|
from pair_signals import PAIR_PRODUCERS
|
||||||
MAJOR_PAIRS, CROSS_PAIRS,
|
|
||||||
)
|
|
||||||
from signal_aggregator import SignalAggregator, SignalReport
|
from signal_aggregator import SignalAggregator, SignalReport
|
||||||
|
|
||||||
|
|
||||||
@@ -38,12 +30,10 @@ from signal_aggregator import SignalAggregator, SignalReport
|
|||||||
# ══════════════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
class Colors:
|
class Colors:
|
||||||
"""ANSI color codes for terminal output."""
|
|
||||||
RESET = "\033[0m"
|
RESET = "\033[0m"
|
||||||
BOLD = "\033[1m"
|
BOLD = "\033[1m"
|
||||||
DIM = "\033[2m"
|
DIM = "\033[2m"
|
||||||
UNDERLINE = "\033[4m"
|
UNDERLINE = "\033[4m"
|
||||||
|
|
||||||
RED = "\033[91m"
|
RED = "\033[91m"
|
||||||
GREEN = "\033[92m"
|
GREEN = "\033[92m"
|
||||||
YELLOW = "\033[93m"
|
YELLOW = "\033[93m"
|
||||||
@@ -53,224 +43,153 @@ class Colors:
|
|||||||
WHITE = "\033[97m"
|
WHITE = "\033[97m"
|
||||||
GRAY = "\033[90m"
|
GRAY = "\033[90m"
|
||||||
|
|
||||||
BG_RED = "\033[41m"
|
|
||||||
BG_GREEN = "\033[42m"
|
|
||||||
BG_YELLOW = "\033[43m"
|
|
||||||
BG_BLUE = "\033[44m"
|
|
||||||
BG_MAGENTA = "\033[45m"
|
|
||||||
BG_CYAN = "\033[46m"
|
|
||||||
|
|
||||||
|
|
||||||
def signal_color(signal: SignalStrength) -> str:
|
def signal_color(signal: SignalStrength) -> str:
|
||||||
"""Get color for a signal strength."""
|
|
||||||
return {
|
return {
|
||||||
SignalStrength.STRONG_BUY: Colors.GREEN + Colors.BOLD,
|
SignalStrength.STRONG_BUY: Colors.GREEN + Colors.BOLD,
|
||||||
SignalStrength.BUY: Colors.GREEN,
|
SignalStrength.BUY: Colors.GREEN,
|
||||||
SignalStrength.NEUTRAL: Colors.GRAY,
|
SignalStrength.NEUTRAL: Colors.GRAY,
|
||||||
SignalStrength.SELL: Colors.RED,
|
SignalStrength.SELL: Colors.RED,
|
||||||
SignalStrength.STRONG_SELL: Colors.RED + Colors.BOLD,
|
SignalStrength.STRONG_SELL: Colors.RED + Colors.BOLD,
|
||||||
}.get(signal, Colors.RESET)
|
}.get(signal, Colors.RESET)
|
||||||
|
|
||||||
|
|
||||||
def quality_color(quality: str) -> str:
|
def quality_color(q: str) -> str:
|
||||||
"""Get color for signal quality grade."""
|
|
||||||
return {
|
return {
|
||||||
"A+": Colors.GREEN + Colors.BOLD,
|
"A+": Colors.GREEN + Colors.BOLD,
|
||||||
"A": Colors.GREEN,
|
"A": Colors.GREEN,
|
||||||
"B": Colors.CYAN,
|
"B": Colors.CYAN,
|
||||||
"C": Colors.YELLOW,
|
"C": Colors.YELLOW,
|
||||||
"D": Colors.GRAY,
|
"D": Colors.GRAY,
|
||||||
"F": Colors.DIM,
|
"F": Colors.DIM,
|
||||||
}.get(quality, Colors.RESET)
|
}.get(q, Colors.RESET)
|
||||||
|
|
||||||
|
|
||||||
def score_bar(score: float, width: int = 30) -> str:
|
def print_header(title: str = "XAU/USD SIGNAL INTELLIGENCE"):
|
||||||
"""Create a visual score bar from -100 to +100."""
|
|
||||||
normalized = (score + 100) / 200 # 0 to 1
|
|
||||||
filled = int(normalized * width)
|
|
||||||
center = width // 2
|
|
||||||
|
|
||||||
bar = ""
|
|
||||||
for i in range(width):
|
|
||||||
if i == center:
|
|
||||||
bar += "│"
|
|
||||||
elif (score >= 0 and center < i <= filled) or \
|
|
||||||
(score < 0 and filled <= i < center):
|
|
||||||
bar += "█"
|
|
||||||
else:
|
|
||||||
bar += "░"
|
|
||||||
|
|
||||||
if score >= 0:
|
|
||||||
color = Colors.GREEN if score > 20 else Colors.GRAY
|
|
||||||
else:
|
|
||||||
color = Colors.RED if score < -20 else Colors.GRAY
|
|
||||||
|
|
||||||
return f"{color}{bar}{Colors.RESET}"
|
|
||||||
|
|
||||||
|
|
||||||
def print_header():
|
|
||||||
"""Print the platform header."""
|
|
||||||
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
|
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
|
||||||
width = 80
|
print(f"\n{Colors.CYAN}{'═' * 80}{Colors.RESET}")
|
||||||
|
print(f"{Colors.BOLD}{Colors.CYAN} {title}{Colors.RESET}")
|
||||||
print(f"\n{Colors.CYAN}{'═' * width}{Colors.RESET}")
|
print(f"{Colors.GRAY} Time: {now}{Colors.RESET}")
|
||||||
print(f"{Colors.BOLD}{Colors.CYAN} ╔═══════════════════════════════════════════════════════╗{Colors.RESET}")
|
print(f"{Colors.CYAN}{'═' * 80}{Colors.RESET}\n")
|
||||||
print(f"{Colors.BOLD}{Colors.CYAN} ║ FOREX SIGNAL INTELLIGENCE PLATFORM ║{Colors.RESET}")
|
|
||||||
print(f"{Colors.BOLD}{Colors.CYAN} ║ Multi-Layer Signal Analysis Engine ║{Colors.RESET}")
|
|
||||||
print(f"{Colors.BOLD}{Colors.CYAN} ╚═══════════════════════════════════════════════════════╝{Colors.RESET}")
|
|
||||||
print(f"{Colors.GRAY} Scan Time: {now}{Colors.RESET}")
|
|
||||||
print(f"{Colors.CYAN}{'═' * width}{Colors.RESET}\n")
|
|
||||||
|
|
||||||
|
|
||||||
def print_summary_table(reports: List[SignalReport]):
|
# ── Live signal renderer ───────────────────────────────────────────
|
||||||
"""Print a summary table of all pairs."""
|
|
||||||
print(f"{Colors.BOLD}{Colors.WHITE} ┌─────────────┬───────────┬──────────────────────────────────┬────────┬────────────┐{Colors.RESET}")
|
|
||||||
print(f"{Colors.BOLD}{Colors.WHITE} │ Pair │ Signal │ Score │ Grade │ Confidence │{Colors.RESET}")
|
|
||||||
print(f"{Colors.BOLD}{Colors.WHITE} ├─────────────┼───────────┼──────────────────────────────────┼────────┼────────────┤{Colors.RESET}")
|
|
||||||
|
|
||||||
for r in reports:
|
_INDICATOR_PREFIXES = (
|
||||||
sig = r.final_signal.name.replace("_", " ")
|
"EMA Trend", "RSI", "Bollinger Bands", "Divergence",
|
||||||
sc = signal_color(r.final_signal)
|
"Candlestick", "ADX", "ATR", "News", "Regime",
|
||||||
qc = quality_color(r.signal_quality)
|
)
|
||||||
bar = score_bar(r.final_score, 30)
|
|
||||||
|
|
||||||
pair = f"{r.display_name:<11}"
|
|
||||||
signal_str = f"{sc}{sig:<9}{Colors.RESET}"
|
|
||||||
grade_str = f"{qc}{r.signal_quality:<6}{Colors.RESET}"
|
|
||||||
conf_str = f"{r.final_confidence:5.1f}%"
|
|
||||||
|
|
||||||
# Add warning indicator
|
|
||||||
warn = ""
|
|
||||||
if r.event_risk == "danger":
|
|
||||||
warn = f" {Colors.RED}⚠{Colors.RESET}"
|
|
||||||
elif r.event_risk == "caution":
|
|
||||||
warn = f" {Colors.YELLOW}△{Colors.RESET}"
|
|
||||||
|
|
||||||
print(f" │ {pair} │ {signal_str} │ {bar} │ {grade_str} │ {conf_str:>8}{warn} │")
|
|
||||||
|
|
||||||
print(f"{Colors.BOLD}{Colors.WHITE} └─────────────┴───────────┴──────────────────────────────────┴────────┴────────────┘{Colors.RESET}")
|
|
||||||
|
|
||||||
# Legend
|
|
||||||
print(f"\n{Colors.GRAY} Legend: "
|
|
||||||
f"{Colors.GREEN}██ Bullish{Colors.GRAY} | "
|
|
||||||
f"{Colors.RED}██ Bearish{Colors.GRAY} | "
|
|
||||||
f"│ Center (0) | "
|
|
||||||
f"{Colors.RED}⚠ Event Risk{Colors.GRAY} | "
|
|
||||||
f"{Colors.YELLOW}△ Caution{Colors.RESET}")
|
|
||||||
|
|
||||||
|
|
||||||
def print_detailed_report(report: SignalReport):
|
def _compact_reason(reason: str) -> str:
|
||||||
"""Print detailed analysis for a single pair."""
|
"""Shorten a verbose reason string for the compact view."""
|
||||||
|
s = reason
|
||||||
|
if s.startswith("STRONG: "):
|
||||||
|
s = s[8:]
|
||||||
|
# Strip the leading "<Indicator>: " prefix — it's always an indicator name
|
||||||
|
for prefix in _INDICATOR_PREFIXES:
|
||||||
|
if s.startswith(prefix + ": "):
|
||||||
|
s = s[len(prefix) + 2:]
|
||||||
|
break
|
||||||
|
# Drop trailing detail after first ' | '
|
||||||
|
if " | " in s:
|
||||||
|
s = s.split(" | ", 1)[0]
|
||||||
|
return s.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_price(price: float) -> str:
|
||||||
|
"""2dp for gold-scale prices, 5dp for sub-10 FX-scale prices."""
|
||||||
|
return f"{price:.2f}" if price >= 10 else f"{price:.5f}"
|
||||||
|
|
||||||
|
|
||||||
|
def print_signal_report(report: SignalReport, detail: bool = False):
|
||||||
sc = signal_color(report.final_signal)
|
sc = signal_color(report.final_signal)
|
||||||
qc = quality_color(report.signal_quality)
|
qc = quality_color(report.signal_quality)
|
||||||
sig_name = report.final_signal.name.replace("_", " ")
|
sig_name = report.final_signal.name.replace("_", " ")
|
||||||
|
|
||||||
print(f"\n{Colors.CYAN}{'─' * 80}{Colors.RESET}")
|
# ── 1. DECISION ────────────────────────────────────────────────
|
||||||
print(f" {Colors.BOLD}{Colors.WHITE}{report.display_name}{Colors.RESET}"
|
mtf_tag = (f"{Colors.GREEN}MTF✓{Colors.RESET}" if report.mtf_agreement
|
||||||
f" │ Price: {Colors.BOLD}{report.current_price:.5f}{Colors.RESET}"
|
else f"{Colors.YELLOW}MTF✗{Colors.RESET}")
|
||||||
f" │ Regime: {Colors.CYAN}{report.market_regime}{Colors.RESET}"
|
print(f" {sc}▶ {sig_name}{Colors.RESET} "
|
||||||
f" │ Vol: {report.volatility_regime}")
|
f"{qc}{report.signal_quality}{Colors.RESET} · "
|
||||||
|
f"{sc}{report.final_score:+.1f}{Colors.RESET} · "
|
||||||
|
f"{report.final_confidence:.0f}% · {mtf_tag}")
|
||||||
|
print(f" {Colors.GRAY}{report.action_recommendation}{Colors.RESET}")
|
||||||
|
|
||||||
print(f"\n {Colors.BOLD}SIGNAL:{Colors.RESET} {sc}{sig_name}{Colors.RESET}"
|
# ── 2. TRADE SETUP (if signal is actionable) ───────────────────
|
||||||
f" │ Score: {sc}{report.final_score:+.1f}{Colors.RESET}"
|
if report.trade_setup:
|
||||||
f" │ Grade: {qc}{report.signal_quality}{Colors.RESET}"
|
ts = report.trade_setup
|
||||||
f" │ Confidence: {report.final_confidence:.0f}%")
|
dc = Colors.GREEN if ts.direction == "BUY" else Colors.RED
|
||||||
|
print(f"\n {Colors.BOLD}Setup{Colors.RESET} "
|
||||||
|
f"{dc}{ts.direction}{Colors.RESET} @ {Colors.BOLD}{_fmt_price(ts.entry_price)}{Colors.RESET} "
|
||||||
|
f"→ SL {Colors.RED}{_fmt_price(ts.stop_loss)}{Colors.RESET} "
|
||||||
|
f"({ts.risk_pips:.1f}p) "
|
||||||
|
f"→ TP {Colors.GREEN}{_fmt_price(ts.take_profit_1)}{Colors.RESET} "
|
||||||
|
f"({ts.reward_pips:.1f}p) "
|
||||||
|
f"RR 1:{ts.risk_reward:.1f}")
|
||||||
|
|
||||||
print(f" {Colors.BOLD}Action:{Colors.RESET} {report.action_recommendation}")
|
# ── 3. CONTEXT (price · regime · vol · MTF breakdown) ──────────
|
||||||
|
print(f"\n {Colors.WHITE}{report.display_name}{Colors.RESET} "
|
||||||
|
f"{Colors.BOLD}{_fmt_price(report.current_price)}{Colors.RESET} · "
|
||||||
|
f"{Colors.CYAN}{report.market_regime}{Colors.RESET} · "
|
||||||
|
f"vol {report.volatility_regime}")
|
||||||
|
|
||||||
# Multi-timeframe breakdown
|
|
||||||
if report.mtf_signals:
|
if report.mtf_signals:
|
||||||
print(f"\n {Colors.BOLD}Multi-Timeframe:{Colors.RESET}", end="")
|
parts = []
|
||||||
mtf_agree = f" {Colors.GREEN}✓ ALIGNED{Colors.RESET}" if report.mtf_agreement else f" {Colors.YELLOW}✗ DIVERGENT{Colors.RESET}"
|
|
||||||
print(mtf_agree)
|
|
||||||
for tf, sig in report.mtf_signals.items():
|
for tf, sig in report.mtf_signals.items():
|
||||||
tf_sc = signal_color(sig)
|
tf_sc = signal_color(sig)
|
||||||
tf_score = report.mtf_scores.get(tf, 0)
|
sc_val = report.mtf_scores.get(tf, 0)
|
||||||
print(f" {tf:>5}: {tf_sc}{sig.name.replace('_', ' '):<12}{Colors.RESET} ({tf_score:+.1f})")
|
parts.append(f"{tf} {tf_sc}{sig.name.replace('_',' ')}{Colors.RESET} {sc_val:+.1f}")
|
||||||
|
print(f" {Colors.GRAY}MTF{Colors.RESET} " + " · ".join(parts))
|
||||||
|
|
||||||
# Indicators detail
|
# ── 4. WHY — top 3 each side, compact ──────────────────────────
|
||||||
if report.primary_analysis:
|
bulls = [_compact_reason(r) for r in report.bullish_reasons[:3]]
|
||||||
print(f"\n {Colors.BOLD}Indicators ({report.primary_analysis.timeframe}):{Colors.RESET}")
|
bears = [_compact_reason(r) for r in report.bearish_reasons[:3]]
|
||||||
|
if bulls or bears:
|
||||||
|
print()
|
||||||
|
rows = max(len(bulls), len(bears))
|
||||||
|
for i in range(rows):
|
||||||
|
left = f"{Colors.GREEN}+{Colors.RESET} {bulls[i]}" if i < len(bulls) else ""
|
||||||
|
right = f"{Colors.RED}-{Colors.RESET} {bears[i]}" if i < len(bears) else ""
|
||||||
|
print(f" {left:<60} {right}")
|
||||||
|
|
||||||
|
# ── 5. WARNINGS / NEWS / EVENTS ────────────────────────────────
|
||||||
|
if report.warnings:
|
||||||
|
for w in report.warnings:
|
||||||
|
print(f" {Colors.YELLOW}⚠{Colors.RESET} {w}")
|
||||||
|
if report.event_description and report.event_risk != "clear":
|
||||||
|
print(f" {Colors.YELLOW}📅{Colors.RESET} {report.event_description}")
|
||||||
|
if report.news_description and report.news_description != "Neutral news sentiment":
|
||||||
|
print(f" {Colors.MAGENTA}📰{Colors.RESET} {report.news_description}")
|
||||||
|
if report.news_summary:
|
||||||
|
print(f" {Colors.MAGENTA}🧠{Colors.RESET} {report.news_summary}")
|
||||||
|
|
||||||
|
# ── 6. DETAIL (only with --detail) ─────────────────────────────
|
||||||
|
if detail and report.primary_analysis:
|
||||||
|
print(f"\n {Colors.BOLD}Indicators ({report.primary_analysis.timeframe}){Colors.RESET}")
|
||||||
for name, ind in report.primary_analysis.indicators.items():
|
for name, ind in report.primary_analysis.indicators.items():
|
||||||
if name.startswith("_") or ind.weight == 0:
|
if name.startswith("_") or ind.weight == 0:
|
||||||
continue
|
continue
|
||||||
isc = signal_color(ind.signal)
|
isc = signal_color(ind.signal)
|
||||||
ind_name = f"{ind.name:<18}"
|
print(f" {ind.name:<16} {isc}{ind.signal.name.replace('_',' '):<11}{Colors.RESET} "
|
||||||
print(f" {ind_name} {isc}{ind.signal.name.replace('_', ' '):<12}{Colors.RESET}"
|
f"{ind.confidence:>3.0f}% {Colors.GRAY}│ {ind.description}{Colors.RESET}")
|
||||||
f" ({ind.confidence:.0f}%) │ {Colors.GRAY}{ind.description}{Colors.RESET}")
|
|
||||||
|
|
||||||
# Bullish reasons
|
|
||||||
if report.bullish_reasons:
|
|
||||||
print(f"\n {Colors.GREEN}▲ Bullish Factors:{Colors.RESET}")
|
|
||||||
for reason in report.bullish_reasons[:5]:
|
|
||||||
print(f" {Colors.GREEN}+{Colors.RESET} {reason}")
|
|
||||||
|
|
||||||
# Bearish reasons
|
|
||||||
if report.bearish_reasons:
|
|
||||||
print(f"\n {Colors.RED}▼ Bearish Factors:{Colors.RESET}")
|
|
||||||
for reason in report.bearish_reasons[:5]:
|
|
||||||
print(f" {Colors.RED}-{Colors.RESET} {reason}")
|
|
||||||
|
|
||||||
# Warnings
|
|
||||||
if report.warnings:
|
|
||||||
print(f"\n {Colors.YELLOW}⚠ Warnings:{Colors.RESET}")
|
|
||||||
for warning in report.warnings:
|
|
||||||
print(f" {Colors.YELLOW}!{Colors.RESET} {warning}")
|
|
||||||
|
|
||||||
# News context
|
|
||||||
if report.news_description and report.news_description != "Neutral news sentiment":
|
|
||||||
print(f"\n {Colors.MAGENTA}📰 News:{Colors.RESET} {report.news_description}")
|
|
||||||
if report.event_description and report.event_risk != "clear":
|
|
||||||
print(f" {Colors.YELLOW}📅 Event:{Colors.RESET} {report.event_description}")
|
|
||||||
|
|
||||||
# Trade setup
|
|
||||||
if report.trade_setup:
|
|
||||||
ts = report.trade_setup
|
|
||||||
dir_color = Colors.GREEN if ts.direction == "BUY" else Colors.RED
|
|
||||||
print(f"\n {Colors.BOLD}Trade Setup:{Colors.RESET}")
|
|
||||||
print(f" Direction: {dir_color}{ts.direction}{Colors.RESET}")
|
|
||||||
print(f" Entry: {ts.entry_price:.5f}")
|
|
||||||
print(f" Stop Loss: {Colors.RED}{ts.stop_loss:.5f}{Colors.RESET} ({ts.risk_pips:.1f} pips)")
|
|
||||||
print(f" Take Profit 1: {Colors.GREEN}{ts.take_profit_1:.5f}{Colors.RESET} ({ts.reward_pips:.1f} pips)")
|
|
||||||
print(f" Take Profit 2: {Colors.GREEN}{ts.take_profit_2:.5f}{Colors.RESET}")
|
|
||||||
print(f" Risk:Reward: 1:{ts.risk_reward:.1f}")
|
|
||||||
|
|
||||||
# Key levels
|
|
||||||
if report.primary_analysis and report.primary_analysis.key_levels:
|
|
||||||
levels = report.primary_analysis.key_levels
|
levels = report.primary_analysis.key_levels
|
||||||
print(f"\n {Colors.BOLD}Key Levels:{Colors.RESET}")
|
if levels:
|
||||||
level_names = ["r3", "r2", "r1", "pivot", "s1", "s2", "s3"]
|
swing_keys = sorted(
|
||||||
for lvl in level_names:
|
[k for k in levels if k.startswith("swing_")],
|
||||||
if lvl in levels:
|
key=lambda k: levels[k], reverse=True,
|
||||||
val = levels[lvl]
|
)
|
||||||
if val > report.current_price:
|
if swing_keys:
|
||||||
color = Colors.RED # resistance
|
print(f"\n {Colors.BOLD}Structure Levels{Colors.RESET}")
|
||||||
else:
|
for k in swing_keys:
|
||||||
color = Colors.GREEN # support
|
val = levels[k]
|
||||||
label = lvl.upper()
|
color = Colors.RED if val > report.current_price else Colors.GREEN
|
||||||
print(f" {label:>6}: {color}{val:.5f}{Colors.RESET}")
|
label = k.replace("_", " ").title()
|
||||||
|
print(f" {label:>20} {color}{val:.3f}{Colors.RESET}")
|
||||||
|
|
||||||
def print_event_calendar(reports: List[SignalReport]):
|
|
||||||
"""Print upcoming economic events affecting watched pairs."""
|
|
||||||
events_shown = set()
|
|
||||||
has_events = False
|
|
||||||
|
|
||||||
for report in reports:
|
|
||||||
if report.event_risk != "clear" and report.event_description not in events_shown:
|
|
||||||
if not has_events:
|
|
||||||
print(f"\n{Colors.YELLOW}{Colors.BOLD} 📅 ECONOMIC CALENDAR ALERTS{Colors.RESET}")
|
|
||||||
has_events = True
|
|
||||||
events_shown.add(report.event_description)
|
|
||||||
risk_color = Colors.RED if report.event_risk == "danger" else Colors.YELLOW
|
|
||||||
print(f" {risk_color}● {report.event_description}{Colors.RESET} → affects {report.display_name}")
|
|
||||||
|
|
||||||
if not has_events:
|
|
||||||
print(f"\n{Colors.GRAY} 📅 No high-impact events in the near term{Colors.RESET}")
|
|
||||||
|
|
||||||
|
|
||||||
def report_to_dict(report: SignalReport) -> dict:
|
def report_to_dict(report: SignalReport) -> dict:
|
||||||
"""Convert a SignalReport to a JSON-serializable dict."""
|
|
||||||
d = {
|
d = {
|
||||||
"symbol": report.symbol,
|
"symbol": report.symbol,
|
||||||
"display_name": report.display_name,
|
"display_name": report.display_name,
|
||||||
@@ -288,6 +207,8 @@ def report_to_dict(report: SignalReport) -> dict:
|
|||||||
"event_risk": report.event_risk,
|
"event_risk": report.event_risk,
|
||||||
"event_description": report.event_description,
|
"event_description": report.event_description,
|
||||||
"news_sentiment": round(report.news_sentiment, 3),
|
"news_sentiment": round(report.news_sentiment, 3),
|
||||||
|
"news_description": report.news_description,
|
||||||
|
"news_summary": report.news_summary,
|
||||||
"bullish_reasons": report.bullish_reasons,
|
"bullish_reasons": report.bullish_reasons,
|
||||||
"bearish_reasons": report.bearish_reasons,
|
"bearish_reasons": report.bearish_reasons,
|
||||||
"warnings": report.warnings,
|
"warnings": report.warnings,
|
||||||
@@ -303,142 +224,119 @@ def report_to_dict(report: SignalReport) -> dict:
|
|||||||
"risk_reward": ts.risk_reward,
|
"risk_reward": ts.risk_reward,
|
||||||
"risk_pips": round(ts.risk_pips, 1),
|
"risk_pips": round(ts.risk_pips, 1),
|
||||||
"reward_pips": round(ts.reward_pips, 1),
|
"reward_pips": round(ts.reward_pips, 1),
|
||||||
|
"strategy": ts.strategy,
|
||||||
|
"trail_arm_r": ts.trail_arm_r,
|
||||||
|
"trail_dist_r": ts.trail_dist_r,
|
||||||
}
|
}
|
||||||
if report.primary_analysis:
|
if report.primary_analysis:
|
||||||
d["indicators"] = {}
|
d["indicators"] = {
|
||||||
for name, ind in report.primary_analysis.indicators.items():
|
name: {
|
||||||
if not name.startswith("_"):
|
"signal": ind.signal.name,
|
||||||
d["indicators"][name] = {
|
"confidence": round(ind.confidence, 1),
|
||||||
"signal": ind.signal.name,
|
"description": ind.description,
|
||||||
"confidence": round(ind.confidence, 1),
|
}
|
||||||
"description": ind.description,
|
for name, ind in report.primary_analysis.indicators.items()
|
||||||
}
|
if not name.startswith("_") and ind.weight != 0
|
||||||
|
}
|
||||||
d["key_levels"] = {
|
d["key_levels"] = {
|
||||||
k: round(v, 5) for k, v in report.primary_analysis.key_levels.items()
|
k: round(v, 5) for k, v in report.primary_analysis.key_levels.items()
|
||||||
}
|
}
|
||||||
return d
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════════════
|
||||||
# MAIN
|
# CLI
|
||||||
# ══════════════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
def parse_args():
|
def parse_args():
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Forex Signal Intelligence Platform",
|
description="ForexBot2 — XAU/USD Signal Intelligence",
|
||||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
epilog="""
|
epilog="""
|
||||||
Examples:
|
Examples:
|
||||||
python main.py Analyze all major pairs (H1)
|
python main.py Live signal (H1, summary)
|
||||||
python main.py --pairs EURUSD GBPUSD Analyze specific pairs
|
python main.py --detail Live signal with full breakdown
|
||||||
python main.py --timeframe H4 Use 4-hour as primary TF
|
python main.py --timeframe H4 --detail H4 timeframe
|
||||||
python main.py --all Include cross pairs
|
python main.py --json JSON output
|
||||||
python main.py --json Output as JSON
|
|
||||||
python main.py --detail Show detailed analysis per pair
|
|
||||||
python main.py --watch Continuous monitoring (5min refresh)
|
|
||||||
""",
|
""",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument("--pair", "-p", default="XAUUSD",
|
||||||
"--pairs", nargs="+", default=None,
|
help="Pair to analyze: XAUUSD (default: XAUUSD)")
|
||||||
help="Specific pairs to analyze (e.g., EURUSD GBPUSD)",
|
parser.add_argument("--timeframe", "-tf", default="H1",
|
||||||
)
|
choices=["M5", "M15", "M30", "H1", "H4", "D1"],
|
||||||
parser.add_argument(
|
help="Primary analysis timeframe (default: H1)")
|
||||||
"--timeframe", "-tf", default="H1",
|
parser.add_argument("--detail", action="store_true",
|
||||||
choices=["M5", "M15", "M30", "H1", "H4", "D1"],
|
help="Show full indicator breakdown")
|
||||||
help="Primary analysis timeframe (default: H1)",
|
parser.add_argument("--json", action="store_true",
|
||||||
)
|
help="Output as JSON")
|
||||||
parser.add_argument(
|
parser.add_argument("--verbose", "-v", action="store_true",
|
||||||
"--all", action="store_true",
|
help="Debug logging")
|
||||||
help="Include cross pairs in analysis",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--json", action="store_true",
|
|
||||||
help="Output results as JSON",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--detail", action="store_true",
|
|
||||||
help="Show detailed analysis for each pair",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--top", type=int, default=None,
|
|
||||||
help="Show only top N signals by strength",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--watch", action="store_true",
|
|
||||||
help="Continuous monitoring mode (refreshes every 5 min)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--min-grade", default="F",
|
|
||||||
choices=["A+", "A", "B", "C", "D", "F"],
|
|
||||||
help="Minimum signal quality to display",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--verbose", "-v", action="store_true",
|
|
||||||
help="Enable debug logging",
|
|
||||||
)
|
|
||||||
return parser.parse_args()
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
def symbol_from_name(name: str) -> str:
|
def _resolve_pair(s: str) -> str:
|
||||||
"""Convert pair name like 'EURUSD' to Yahoo symbol 'EURUSD=X'."""
|
aliases = {
|
||||||
name = name.upper().replace("/", "").replace("_", "")
|
"XAUUSD": XAUUSD, "XAU/USD": XAUUSD, "GOLD": XAUUSD,
|
||||||
if not name.endswith("=X"):
|
}
|
||||||
name += "=X"
|
key = s.upper().strip()
|
||||||
return name
|
if key in aliases:
|
||||||
|
return aliases[key]
|
||||||
|
raise SystemExit(f"Unknown pair '{s}'. Try one of: {', '.join(sorted(aliases))}")
|
||||||
|
|
||||||
|
|
||||||
def run_analysis(args) -> List[SignalReport]:
|
def build_config(args) -> PlatformConfig:
|
||||||
"""Run a single analysis cycle."""
|
pair = _resolve_pair(args.pair)
|
||||||
config = PlatformConfig()
|
|
||||||
|
|
||||||
# Configure timeframe
|
|
||||||
tf_map = {
|
tf_map = {
|
||||||
"M5": Timeframe.M5, "M15": Timeframe.M15, "M30": Timeframe.M30,
|
"M5": Timeframe.M5, "M15": Timeframe.M15, "M30": Timeframe.M30,
|
||||||
"H1": Timeframe.H1, "H4": Timeframe.H4, "D1": Timeframe.D1,
|
"H1": Timeframe.H1, "H4": Timeframe.H4, "D1": Timeframe.D1,
|
||||||
}
|
}
|
||||||
config.primary_timeframe = tf_map[args.timeframe]
|
primary_tf = tf_map[args.timeframe]
|
||||||
|
|
||||||
# Configure confirmation timeframes based on primary
|
|
||||||
tf_confirmations = {
|
tf_confirmations = {
|
||||||
"M5": [Timeframe.M1, Timeframe.M15],
|
"M5": [Timeframe.M1, Timeframe.M15],
|
||||||
"M15": [Timeframe.M5, Timeframe.H1],
|
"M15": [Timeframe.M5, Timeframe.H1],
|
||||||
"M30": [Timeframe.M15, Timeframe.H1],
|
"M30": [Timeframe.M15, Timeframe.H1],
|
||||||
"H1": [Timeframe.M15, Timeframe.H4],
|
"H1": [Timeframe.M15, Timeframe.H4],
|
||||||
"H4": [Timeframe.H1, Timeframe.D1],
|
"H4": [Timeframe.H1, Timeframe.D1],
|
||||||
"D1": [Timeframe.H4, Timeframe.W1],
|
"D1": [Timeframe.H4, Timeframe.W1],
|
||||||
}
|
}
|
||||||
config.confirmation_timeframes = tf_confirmations.get(
|
confirmation_tfs = tf_confirmations.get(
|
||||||
args.timeframe, [Timeframe.M15, Timeframe.H4]
|
args.timeframe, [Timeframe.M15, Timeframe.H4]
|
||||||
)
|
)
|
||||||
|
|
||||||
# Configure pairs
|
# Per-pair tuned configs from the pair_signals factories.
|
||||||
if args.pairs:
|
factory = PAIR_PRODUCERS.get(pair)
|
||||||
config.watch_pairs = [symbol_from_name(p) for p in args.pairs]
|
if factory is not None:
|
||||||
elif args.all:
|
return factory(primary_tf, confirmation_tfs)
|
||||||
config.watch_pairs = MAJOR_PAIRS + CROSS_PAIRS
|
|
||||||
else:
|
|
||||||
config.watch_pairs = MAJOR_PAIRS
|
|
||||||
|
|
||||||
# Run analysis
|
config = PlatformConfig()
|
||||||
|
config.primary_timeframe = primary_tf
|
||||||
|
config.confirmation_timeframes = confirmation_tfs
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def run_live(args, config: PlatformConfig):
|
||||||
|
print(f"{Colors.GRAY} Fetching market data and running analysis...{Colors.RESET}\n")
|
||||||
aggregator = SignalAggregator(config)
|
aggregator = SignalAggregator(config)
|
||||||
reports = aggregator.analyze_all_pairs()
|
report = aggregator.analyze_pair(symbol=config.symbol)
|
||||||
|
|
||||||
# Filter by grade
|
if args.json:
|
||||||
grade_order = ["A+", "A", "B", "C", "D", "F"]
|
print(json.dumps(report_to_dict(report), indent=2))
|
||||||
min_idx = grade_order.index(args.min_grade) if args.min_grade in grade_order else len(grade_order) - 1
|
return
|
||||||
reports = [r for r in reports if grade_order.index(r.signal_quality) <= min_idx]
|
|
||||||
|
|
||||||
# Top N
|
print_header(f"{config.display_name} SIGNAL INTELLIGENCE")
|
||||||
if args.top:
|
print_signal_report(report, detail=args.detail)
|
||||||
reports = reports[:args.top]
|
print(f"\n{Colors.CYAN}{'═' * 80}{Colors.RESET}")
|
||||||
|
print(f"{Colors.GRAY} Disclaimer: For educational/research purposes only.{Colors.RESET}")
|
||||||
return reports
|
print(f"{Colors.CYAN}{'═' * 80}{Colors.RESET}\n")
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
args = parse_args()
|
args = parse_args()
|
||||||
|
|
||||||
# Setup logging
|
|
||||||
log_level = logging.DEBUG if args.verbose else logging.WARNING
|
log_level = logging.DEBUG if args.verbose else logging.WARNING
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=log_level,
|
level=log_level,
|
||||||
@@ -446,63 +344,8 @@ def main():
|
|||||||
datefmt="%H:%M:%S",
|
datefmt="%H:%M:%S",
|
||||||
)
|
)
|
||||||
|
|
||||||
if args.watch:
|
config = build_config(args)
|
||||||
# Continuous monitoring mode
|
run_live(args, config)
|
||||||
print(f"{Colors.BOLD}Starting continuous monitoring mode (Ctrl+C to stop)...{Colors.RESET}")
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
reports = run_analysis(args)
|
|
||||||
if args.json:
|
|
||||||
output = [report_to_dict(r) for r in reports]
|
|
||||||
print(json.dumps(output, indent=2))
|
|
||||||
else:
|
|
||||||
print("\033[2J\033[H") # clear screen
|
|
||||||
print_header()
|
|
||||||
print_summary_table(reports)
|
|
||||||
print_event_calendar(reports)
|
|
||||||
if args.detail:
|
|
||||||
for r in reports:
|
|
||||||
print_detailed_report(r)
|
|
||||||
print(f"\n{Colors.GRAY} Next refresh in 5 minutes...{Colors.RESET}")
|
|
||||||
|
|
||||||
time.sleep(300)
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
print(f"\n{Colors.YELLOW}Monitoring stopped.{Colors.RESET}")
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
# Single run
|
|
||||||
print(f"{Colors.GRAY} Fetching market data and running analysis...{Colors.RESET}\n")
|
|
||||||
reports = run_analysis(args)
|
|
||||||
|
|
||||||
if args.json:
|
|
||||||
output = [report_to_dict(r) for r in reports]
|
|
||||||
print(json.dumps(output, indent=2))
|
|
||||||
else:
|
|
||||||
print_header()
|
|
||||||
if reports:
|
|
||||||
print_summary_table(reports)
|
|
||||||
print_event_calendar(reports)
|
|
||||||
|
|
||||||
if args.detail:
|
|
||||||
for r in reports:
|
|
||||||
print_detailed_report(r)
|
|
||||||
else:
|
|
||||||
# Show detail for top signals only
|
|
||||||
actionable = [r for r in reports if r.signal_quality in ("A+", "A", "B")]
|
|
||||||
if actionable:
|
|
||||||
print(f"\n{Colors.BOLD}{Colors.WHITE} ═══ ACTIONABLE SIGNALS (Grade B or above) ═══{Colors.RESET}")
|
|
||||||
for r in actionable:
|
|
||||||
print_detailed_report(r)
|
|
||||||
else:
|
|
||||||
print(f"\n{Colors.GRAY} No high-grade signals detected. "
|
|
||||||
f"Use --detail for full breakdown or --min-grade C to see more.{Colors.RESET}")
|
|
||||||
else:
|
|
||||||
print(f"{Colors.YELLOW} No data available. Check your internet connection.{Colors.RESET}")
|
|
||||||
|
|
||||||
print(f"\n{Colors.CYAN}{'═' * 80}{Colors.RESET}")
|
|
||||||
print(f"{Colors.GRAY} Disclaimer: This tool is for educational/research purposes only.{Colors.RESET}")
|
|
||||||
print(f"{Colors.GRAY} Past performance ≠ future results. Always use proper risk management.{Colors.RESET}")
|
|
||||||
print(f"{Colors.CYAN}{'═' * 80}{Colors.RESET}\n")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Regular → Executable
+574
-81
@@ -8,19 +8,91 @@ Key principle: High-impact news events override technical signals.
|
|||||||
A perfect technical setup can be destroyed by an unexpected NFP print.
|
A perfect technical setup can be destroyed by an unexpected NFP print.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timezone
|
||||||
|
from email.utils import parsedate_to_datetime
|
||||||
from typing import Dict, List, Optional, Tuple
|
from typing import Dict, List, Optional, Tuple
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from config import NewsConfig
|
from config import NewsConfig
|
||||||
|
from llm_client import get_llm_client
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ── DeepSeek prompts ─────────────────────────────────────────────────
|
||||||
|
# STATIC constants on purpose: DeepSeek prices a repeated byte-identical
|
||||||
|
# prompt prefix at the cache-hit rate (~50x cheaper), so nothing here may
|
||||||
|
# be interpolated or timestamped. All variable content travels in the
|
||||||
|
# user payload. LLM output is display/warning-only — it never gates
|
||||||
|
# signals (see NewsConfig.sentiment_affects_signals).
|
||||||
|
|
||||||
|
_LLM_SENTIMENT_SYSTEM = """\
|
||||||
|
You are a forex/gold news analyst. You receive JSON:
|
||||||
|
{"headlines": ["...", ...]}
|
||||||
|
For EACH headline, in the same order, score:
|
||||||
|
- "s": overall market sentiment of the headline, -1.0 (very bearish)
|
||||||
|
to 1.0 (very bullish), 0.0 if irrelevant or neutral.
|
||||||
|
- "xau": the effect on the GOLD (XAU/USD) price, -1.0 (gold down)
|
||||||
|
to 1.0 (gold up), 0.0 if irrelevant.
|
||||||
|
Gold doctrine: hawkish Fed / rate hikes / rising real yields / strong
|
||||||
|
dollar are gold-bearish; easing / rate cuts / safe-haven flows /
|
||||||
|
geopolitical stress / weak dollar are gold-bullish.
|
||||||
|
Handle negation correctly ("no rate hike" is not hawkish).
|
||||||
|
Reply with ONLY JSON: {"scores": [[s, xau], ...]} — exactly one
|
||||||
|
[s, xau] pair per input headline, in the same order, nothing else.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
"Fed signals faster rate hikes as inflation stays hot" -> [0.4, -0.7]
|
||||||
|
"Gold surges to record high on safe-haven demand" -> [0.8, 0.9]
|
||||||
|
"Dollar slips after soft jobs report; Treasury yields fall" -> [-0.4, 0.6]
|
||||||
|
"""
|
||||||
|
|
||||||
|
_LLM_EVENT_SYSTEM = """\
|
||||||
|
You are a forex/gold analyst. You receive one released economic event as
|
||||||
|
JSON: {"event", "currency", "actual", "forecast", "previous"}.
|
||||||
|
Decide the likely effect of this RELEASE (actual vs forecast) on the
|
||||||
|
GOLD (XAU/USD) price. A stronger-than-forecast USD economy or a hawkish
|
||||||
|
implication is usually gold-bearish; weaker data or a dovish implication
|
||||||
|
is usually gold-bullish. Mind inverted metrics: higher unemployment or
|
||||||
|
jobless claims = weaker economy.
|
||||||
|
Reply with ONLY JSON:
|
||||||
|
{"gold_direction": "bullish"|"bearish"|"neutral", "reason": "<max 10 words>"}
|
||||||
|
"""
|
||||||
|
|
||||||
|
_LLM_SUMMARY_SYSTEM = """\
|
||||||
|
You are a forex/gold market-context writer. You receive JSON with recent
|
||||||
|
"headlines" and today's high-impact "events" (released: true means the
|
||||||
|
number is out). Write a 1-2 sentence plain-English summary of the
|
||||||
|
current macro backdrop for a GOLD (XAU/USD) trader: the dominant theme
|
||||||
|
and its likely pull on gold. No advice, no hedging boilerplate, max 45
|
||||||
|
words.
|
||||||
|
Reply with ONLY JSON: {"summary": "..."}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
_RELEASE_VALUE_RE = re.compile(r"(-?\d+(?:\.\d+)?)\s*([kmbt])?", re.IGNORECASE)
|
||||||
|
_RELEASE_MULTIPLIERS = {"k": 1e3, "m": 1e6, "b": 1e9, "t": 1e12}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_release_value(raw: Optional[str]) -> Optional[float]:
|
||||||
|
"""Parse a calendar release value like '0.3%', '-1.7%', '228K', '1.1M'."""
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
match = _RELEASE_VALUE_RE.search(raw.replace(",", ""))
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
multiplier = _RELEASE_MULTIPLIERS.get((match.group(2) or "").lower(), 1.0)
|
||||||
|
return float(match.group(1)) * multiplier
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class EconomicEvent:
|
class EconomicEvent:
|
||||||
"""Single economic calendar event."""
|
"""Single economic calendar event."""
|
||||||
@@ -31,7 +103,21 @@ class EconomicEvent:
|
|||||||
forecast: Optional[str] = None
|
forecast: Optional[str] = None
|
||||||
previous: Optional[str] = None
|
previous: Optional[str] = None
|
||||||
actual: Optional[str] = None
|
actual: Optional[str] = None
|
||||||
surprise: Optional[str] = None # "positive", "negative", "inline", None
|
|
||||||
|
@property
|
||||||
|
def surprise(self) -> Optional[str]:
|
||||||
|
"""Actual vs forecast: "positive"/"negative"/"inline", or None when
|
||||||
|
either value is missing or non-numeric. Direction is numeric only —
|
||||||
|
it does not know whether higher is market-bullish for this event."""
|
||||||
|
actual = _parse_release_value(self.actual)
|
||||||
|
forecast = _parse_release_value(self.forecast)
|
||||||
|
if actual is None or forecast is None:
|
||||||
|
return None
|
||||||
|
if actual > forecast:
|
||||||
|
return "positive"
|
||||||
|
if actual < forecast:
|
||||||
|
return "negative"
|
||||||
|
return "inline"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_upcoming(self) -> bool:
|
def is_upcoming(self) -> bool:
|
||||||
@@ -56,6 +142,9 @@ class NewsSentiment:
|
|||||||
currencies_mentioned: List[str]
|
currencies_mentioned: List[str]
|
||||||
sentiment_score: float # -1.0 (bearish) to +1.0 (bullish)
|
sentiment_score: float # -1.0 (bearish) to +1.0 (bullish)
|
||||||
keywords: List[str]
|
keywords: List[str]
|
||||||
|
# Per-currency score: monetary-policy keywords flip sign for XAU
|
||||||
|
# (hawkish is dollar-bullish but gold-bearish).
|
||||||
|
sentiment_by_currency: Dict[str, float] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -69,6 +158,7 @@ class FundamentalContext:
|
|||||||
news_headlines: List[NewsSentiment] = field(default_factory=list)
|
news_headlines: List[NewsSentiment] = field(default_factory=list)
|
||||||
event_impact_on_pairs: Dict[str, str] = field(default_factory=dict)
|
event_impact_on_pairs: Dict[str, str] = field(default_factory=dict)
|
||||||
calendar_available: bool = False # True if live calendar data was fetched
|
calendar_available: bool = False # True if live calendar data was fetched
|
||||||
|
llm_summary: str = "" # DeepSeek market-context blurb (display-only)
|
||||||
|
|
||||||
|
|
||||||
class NewsEngine:
|
class NewsEngine:
|
||||||
@@ -81,39 +171,85 @@ class NewsEngine:
|
|||||||
# Keywords that indicate directional sentiment
|
# Keywords that indicate directional sentiment
|
||||||
BULLISH_KEYWORDS = {
|
BULLISH_KEYWORDS = {
|
||||||
"rate hike", "hawkish", "strong growth", "beat expectations",
|
"rate hike", "hawkish", "strong growth", "beat expectations",
|
||||||
"above forecast", "surplus", "expansion", "employment gains",
|
"above forecast", "above estimate", "above expectation",
|
||||||
|
"surplus", "expansion", "employment gains",
|
||||||
"strong jobs", "gdp growth", "inflation rising", "tightening",
|
"strong jobs", "gdp growth", "inflation rising", "tightening",
|
||||||
"positive surprise", "record high", "bullish", "rally",
|
"positive surprise", "record high", "bullish", "rally",
|
||||||
"stronger than expected", "upbeat",
|
"stronger than expected", "upbeat",
|
||||||
|
"beats", "surges", "soars", "jumps", "climbs", "rises",
|
||||||
|
"higher than expected", "better than expected",
|
||||||
|
"strong data", "robust", "resilient", "accelerates",
|
||||||
}
|
}
|
||||||
|
|
||||||
BEARISH_KEYWORDS = {
|
BEARISH_KEYWORDS = {
|
||||||
"rate cut", "dovish", "recession", "slowdown", "contraction",
|
"rate cut", "dovish", "recession", "slowdown", "contraction",
|
||||||
"below forecast", "deficit", "miss expectations", "weak data",
|
"below forecast", "below estimate", "below expectation",
|
||||||
|
"deficit", "miss expectations", "weak data",
|
||||||
"unemployment rise", "negative surprise", "bearish", "selloff",
|
"unemployment rise", "negative surprise", "bearish", "selloff",
|
||||||
"weaker than expected", "downbeat", "easing", "stimulus",
|
"weaker than expected", "downbeat", "easing", "stimulus",
|
||||||
|
"misses", "slides", "drops", "falls", "plunges", "tumbles",
|
||||||
|
"weakens", "slumps", "declines", "retreats",
|
||||||
|
"lower than expected", "worse than expected",
|
||||||
|
"disappointing", "soft data",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Currency-specific keywords for headline parsing
|
# Monetary-policy keywords whose direction inverts for gold: tighter
|
||||||
|
# policy lifts the dollar and yields, weighing on non-yielding gold;
|
||||||
|
# easing does the opposite. Only unambiguous policy words — economic
|
||||||
|
# data words (beats/misses) keep their normal direction.
|
||||||
|
GOLD_INVERSE_KEYWORDS = {
|
||||||
|
"rate hike", "hawkish", "tightening",
|
||||||
|
"rate cut", "dovish", "easing", "stimulus",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Currency-specific keywords for headline parsing. Matched with word
|
||||||
|
# boundaries — bare "us"/"eu"/"uk" are deliberately excluded because as
|
||||||
|
# substrings they false-positive constantly ("versus" → USD).
|
||||||
CURRENCY_KEYWORDS = {
|
CURRENCY_KEYWORDS = {
|
||||||
"USD": ["dollar", "fed", "fomc", "treasury", "us ", "american", "nonfarm", "nfp"],
|
"USD": ["dollar", "fed", "federal reserve", "fomc", "treasury", "american", "nonfarm", "nfp"],
|
||||||
"EUR": ["euro", "ecb", "eurozone", "eu ", "german", "french"],
|
"EUR": ["euro", "ecb", "eurozone", "german", "french"],
|
||||||
"GBP": ["pound", "sterling", "boe", "bank of england", "uk ", "british"],
|
"GBP": ["pound", "sterling", "boe", "bank of england", "british"],
|
||||||
"JPY": ["yen", "boj", "bank of japan", "japanese"],
|
"JPY": ["yen", "boj", "bank of japan", "japanese"],
|
||||||
"CHF": ["franc", "snb", "swiss"],
|
"CHF": ["franc", "snb", "swiss"],
|
||||||
"AUD": ["aussie", "rba", "australian", "australia"],
|
"AUD": ["aussie", "rba", "australian", "australia"],
|
||||||
"CAD": ["loonie", "boc", "canadian", "canada"],
|
"CAD": ["loonie", "boc", "canadian", "canada"],
|
||||||
"NZD": ["kiwi", "rbnz", "new zealand"],
|
"NZD": ["kiwi", "rbnz", "new zealand"],
|
||||||
|
"XAU": ["gold", "precious metal", "safe haven", "bullion"],
|
||||||
}
|
}
|
||||||
|
|
||||||
# High-impact event types (these move markets significantly)
|
# One compiled alternation per currency (code + keywords). (?<![a-z]) /
|
||||||
HIGH_IMPACT_EVENTS = {
|
# (?![a-z]) instead of \b so "0.3%fed" still matches but "versus",
|
||||||
"interest rate decision", "nonfarm payrolls", "non-farm",
|
# "audit", "cascade" don't.
|
||||||
"gdp", "cpi", "inflation", "employment change",
|
_CURRENCY_PATTERNS = {
|
||||||
"retail sales", "pmi", "fomc", "ecb press conference",
|
cur: re.compile(
|
||||||
"boe rate", "rba rate", "snb rate", "boc rate",
|
r"(?<![a-z])(?:"
|
||||||
|
+ "|".join(re.escape(kw) for kw in [cur.lower()] + kws)
|
||||||
|
+ r")(?![a-z])"
|
||||||
|
)
|
||||||
|
for cur, kws in CURRENCY_KEYWORDS.items()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_calendar_backoff_until: float = 0.0
|
||||||
|
|
||||||
|
# Fetched-data cache, class-level: a fresh NewsEngine is built per scan,
|
||||||
|
# so instance state would never be reused. Guarded by _cache_lock since
|
||||||
|
# scans run concurrently in a thread pool.
|
||||||
|
_cache_lock = threading.Lock()
|
||||||
|
_calendar_cache: Tuple[float, List[EconomicEvent]] = (0.0, [])
|
||||||
|
_headlines_cache: Tuple[float, List[NewsSentiment]] = (0.0, [])
|
||||||
|
_EMPTY_RESULT_TTL = 60.0 # retry sooner after a failed/empty fetch
|
||||||
|
|
||||||
|
# DeepSeek result caches. Headlines and releases are immutable, so a
|
||||||
|
# scored one never needs re-scoring; bounded by dropping the oldest
|
||||||
|
# insertion. _llm_sent_cache is only touched under _cache_lock (its
|
||||||
|
# writer runs inside _cached_fetch); the other two take the lock
|
||||||
|
# around cache access but keep HTTP calls outside it.
|
||||||
|
_llm_sent_cache: Dict[str, Tuple[float, float]] = {} # sha1 → (s, xau)
|
||||||
|
_llm_event_cache: Dict[Tuple[str, str, str], str] = {}
|
||||||
|
_llm_summary_cache: Tuple[float, str] = (0.0, "")
|
||||||
|
_LLM_SENT_CACHE_MAX = 400
|
||||||
|
_LLM_EVENT_CACHE_MAX = 100
|
||||||
|
|
||||||
def __init__(self, config: NewsConfig):
|
def __init__(self, config: NewsConfig):
|
||||||
self.config = config
|
self.config = config
|
||||||
self._session = requests.Session()
|
self._session = requests.Session()
|
||||||
@@ -139,7 +275,13 @@ class NewsEngine:
|
|||||||
events = self._fetch_economic_calendar(currencies)
|
events = self._fetch_economic_calendar(currencies)
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
context.events_today = [e for e in events if e.time.date() == now.date()]
|
# Compare dates in UTC — feed timestamps carry their own offset
|
||||||
|
# (FF sends US-Eastern), so a late-evening ET event would
|
||||||
|
# otherwise land on the wrong day.
|
||||||
|
context.events_today = [
|
||||||
|
e for e in events
|
||||||
|
if e.time.astimezone(timezone.utc).date() == now.date()
|
||||||
|
]
|
||||||
context.events_upcoming_1h = [
|
context.events_upcoming_1h = [
|
||||||
e for e in events
|
e for e in events
|
||||||
if 0 < e.minutes_until <= 60
|
if 0 < e.minutes_until <= 60
|
||||||
@@ -173,10 +315,12 @@ class NewsEngine:
|
|||||||
headlines = self._fetch_news_sentiment()
|
headlines = self._fetch_news_sentiment()
|
||||||
context.news_headlines = headlines
|
context.news_headlines = headlines
|
||||||
|
|
||||||
# Aggregate sentiment per currency
|
# Aggregate sentiment per currency (per-currency score where
|
||||||
|
# available, so gold-inverted policy keywords are respected)
|
||||||
for currency in currencies:
|
for currency in currencies:
|
||||||
scores = [
|
scores = [
|
||||||
h.sentiment_score for h in headlines
|
h.sentiment_by_currency.get(currency, h.sentiment_score)
|
||||||
|
for h in headlines
|
||||||
if currency in h.currencies_mentioned
|
if currency in h.currencies_mentioned
|
||||||
]
|
]
|
||||||
if scores:
|
if scores:
|
||||||
@@ -185,31 +329,70 @@ class NewsEngine:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"News sentiment fetch failed: {e}")
|
logger.warning(f"News sentiment fetch failed: {e}")
|
||||||
|
|
||||||
|
# 3. LLM market-context summary (display-only)
|
||||||
|
try:
|
||||||
|
context.llm_summary = self._llm_market_summary(context)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"LLM market summary failed: {e}")
|
||||||
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_pair(pair_symbol: str) -> Tuple[str, str]:
|
||||||
|
if "/" in pair_symbol:
|
||||||
|
parts = pair_symbol.split("/")
|
||||||
|
return parts[0], parts[1]
|
||||||
|
clean = pair_symbol.replace("=X", "")
|
||||||
|
return clean[:3], clean[3:]
|
||||||
|
|
||||||
def get_event_risk_level(self, pair_symbol: str, context: FundamentalContext) -> Tuple[str, str]:
|
def get_event_risk_level(self, pair_symbol: str, context: FundamentalContext) -> Tuple[str, str]:
|
||||||
"""
|
"""
|
||||||
Get event risk level for a specific pair.
|
Get event risk level for a specific pair.
|
||||||
Returns (risk_level, description).
|
Returns (risk_level, description).
|
||||||
risk_level: "clear", "caution", "danger"
|
risk_level: "clear", "caution", "danger"
|
||||||
"""
|
"""
|
||||||
clean = pair_symbol.replace("=X", "")
|
base, quote = self._parse_pair(pair_symbol)
|
||||||
base = clean[:3]
|
|
||||||
quote = clean[3:]
|
|
||||||
|
|
||||||
# Check upcoming high-impact events
|
# Check upcoming high-impact events. Grade from the SOONEST
|
||||||
|
# matching event, not the first listed — an unsorted feed could
|
||||||
|
# otherwise report "caution" for a far event while a nearer one
|
||||||
|
# warrants "danger".
|
||||||
|
soonest = None
|
||||||
for event in context.events_upcoming_1h:
|
for event in context.events_upcoming_1h:
|
||||||
if event.is_high_impact and event.currency in (base, quote):
|
if event.is_high_impact and event.currency in (base, quote):
|
||||||
mins = event.minutes_until
|
if soonest is None or event.minutes_until < soonest.minutes_until:
|
||||||
if mins <= self.config.pre_event_buffer_minutes:
|
soonest = event
|
||||||
return "danger", f"{event.currency} {event.event} in {mins:.0f}min"
|
if soonest is not None:
|
||||||
else:
|
mins = soonest.minutes_until
|
||||||
return "caution", f"{event.currency} {event.event} in {mins:.0f}min"
|
if mins <= self.config.pre_event_buffer_minutes:
|
||||||
|
return "danger", f"{soonest.currency} {soonest.event} in {mins:.0f}min"
|
||||||
|
return "caution", f"{soonest.currency} {soonest.event} in {mins:.0f}min"
|
||||||
|
|
||||||
# Check for recent high-impact releases (within post-event buffer)
|
# Check for recent high-impact releases. A surprise print (actual
|
||||||
|
# away from forecast) keeps the market unstable longer than an
|
||||||
|
# inline release, so it gets a wider post-event window.
|
||||||
for event in context.events_today:
|
for event in context.events_today:
|
||||||
if event.is_high_impact and event.currency in (base, quote):
|
if event.is_high_impact and event.currency in (base, quote) and not event.is_upcoming:
|
||||||
if not event.is_upcoming and event.minutes_until > -self.config.post_event_buffer_minutes:
|
surprise = event.surprise
|
||||||
|
surprised = surprise in ("positive", "negative")
|
||||||
|
window = (
|
||||||
|
self.config.post_event_surprise_buffer_minutes
|
||||||
|
if surprised
|
||||||
|
else self.config.post_event_buffer_minutes
|
||||||
|
)
|
||||||
|
if event.minutes_until > -window:
|
||||||
|
if surprised:
|
||||||
|
desc = (
|
||||||
|
f"{event.currency} {event.event} released — {surprise} surprise "
|
||||||
|
f"(actual {event.actual} vs forecast {event.forecast})"
|
||||||
|
)
|
||||||
|
# LLM enrichment touches the DESCRIPTION only —
|
||||||
|
# `surprise` is numeric-direction and does not know
|
||||||
|
# whether a beat is gold-bullish; the LLM does.
|
||||||
|
gold_dir = self._llm_event_direction(event)
|
||||||
|
if gold_dir:
|
||||||
|
desc += f" — gold {gold_dir}"
|
||||||
|
return "caution", desc
|
||||||
return "caution", f"{event.currency} {event.event} just released"
|
return "caution", f"{event.currency} {event.event} just released"
|
||||||
|
|
||||||
return "clear", "No significant events"
|
return "clear", "No significant events"
|
||||||
@@ -222,9 +405,7 @@ class NewsEngine:
|
|||||||
Returns (score, description) where score is -1 to +1.
|
Returns (score, description) where score is -1 to +1.
|
||||||
Positive = bullish for the pair, negative = bearish.
|
Positive = bullish for the pair, negative = bearish.
|
||||||
"""
|
"""
|
||||||
clean = pair_symbol.replace("=X", "")
|
base, quote = self._parse_pair(pair_symbol)
|
||||||
base = clean[:3]
|
|
||||||
quote = clean[3:]
|
|
||||||
|
|
||||||
base_sent = context.news_sentiment.get(base, 0.0)
|
base_sent = context.news_sentiment.get(base, 0.0)
|
||||||
quote_sent = context.news_sentiment.get(quote, 0.0)
|
quote_sent = context.news_sentiment.get(quote, 0.0)
|
||||||
@@ -245,20 +426,72 @@ class NewsEngine:
|
|||||||
|
|
||||||
return net, desc
|
return net, desc
|
||||||
|
|
||||||
|
def get_week_events(
|
||||||
|
self, currencies: Optional[List[str]] = None
|
||||||
|
) -> List[EconomicEvent]:
|
||||||
|
"""Full cached calendar week (past and upcoming), filtered to
|
||||||
|
`currencies` (default: tracked_currencies), sorted by time.
|
||||||
|
|
||||||
|
Read-only accessor for display surfaces (the web dashboard) —
|
||||||
|
rides the same class-level cache as signal scans, so it adds no
|
||||||
|
network I/O. No production signal path calls this.
|
||||||
|
"""
|
||||||
|
if currencies is None:
|
||||||
|
currencies = self.config.tracked_currencies
|
||||||
|
events = self._cached_fetch("_calendar_cache", self._fetch_calendar_raw)
|
||||||
|
return sorted(
|
||||||
|
(e for e in events if e.currency in currencies),
|
||||||
|
key=lambda e: e.time,
|
||||||
|
)
|
||||||
|
|
||||||
# ── Private Methods ────────────────────────────────────────────
|
# ── Private Methods ────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Maps country names in MyFXBook titles to currency codes
|
||||||
|
_COUNTRY_TO_CURRENCY = {
|
||||||
|
"united states": "USD", "euro area": "EUR", "united kingdom": "GBP",
|
||||||
|
"japan": "JPY", "switzerland": "CHF", "australia": "AUD",
|
||||||
|
"canada": "CAD", "new zealand": "NZD",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _cached_fetch(self, cache_attr: str, fetch_fn) -> list:
|
||||||
|
"""Return cached data for `cache_attr` or refetch when the TTL
|
||||||
|
expired. Successful results live for context_cache_ttl_seconds;
|
||||||
|
empty results only _EMPTY_RESULT_TTL so outages retry quickly.
|
||||||
|
The lock is held across the fetch so concurrent scans share one
|
||||||
|
request instead of stampeding the source."""
|
||||||
|
ts, data = getattr(NewsEngine, cache_attr)
|
||||||
|
ttl = self.config.context_cache_ttl_seconds if data else self._EMPTY_RESULT_TTL
|
||||||
|
if time.time() - ts < ttl:
|
||||||
|
return data
|
||||||
|
|
||||||
|
with NewsEngine._cache_lock:
|
||||||
|
ts, data = getattr(NewsEngine, cache_attr) # re-check under lock
|
||||||
|
ttl = self.config.context_cache_ttl_seconds if data else self._EMPTY_RESULT_TTL
|
||||||
|
if time.time() - ts < ttl:
|
||||||
|
return data
|
||||||
|
data = fetch_fn()
|
||||||
|
setattr(NewsEngine, cache_attr, (time.time(), data))
|
||||||
|
return data
|
||||||
|
|
||||||
def _fetch_economic_calendar(self, currencies: List[str]) -> List[EconomicEvent]:
|
def _fetch_economic_calendar(self, currencies: List[str]) -> List[EconomicEvent]:
|
||||||
|
"""Cached calendar fetch, filtered to the tracked currencies."""
|
||||||
|
events = self._cached_fetch("_calendar_cache", self._fetch_calendar_raw)
|
||||||
|
return [e for e in events if e.currency in currencies]
|
||||||
|
|
||||||
|
def _fetch_calendar_raw(self) -> List[EconomicEvent]:
|
||||||
"""
|
"""
|
||||||
Fetch economic calendar events from a free API.
|
Fetch economic calendar events from a free API.
|
||||||
|
Tries Forex Factory first, falls back to MyFXBook RSS.
|
||||||
Returns an empty list (with a log warning) if unavailable —
|
Returns an empty list (with a log warning) if unavailable —
|
||||||
we never generate fake events since that causes constant false alarms.
|
we never generate fake events since that causes constant false alarms.
|
||||||
"""
|
"""
|
||||||
try:
|
for fetcher in (self._try_fetch_calendar_api, self._try_fetch_myfxbook_rss):
|
||||||
events = self._try_fetch_calendar_api()
|
try:
|
||||||
if events:
|
events = fetcher()
|
||||||
return [e for e in events if e.currency in currencies]
|
if events:
|
||||||
except Exception as e:
|
return events
|
||||||
logger.debug(f"Calendar API fetch exception: {e}")
|
except Exception as e:
|
||||||
|
logger.debug(f"Calendar fetch exception ({fetcher.__name__}): {e}")
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Economic calendar data unavailable. "
|
"Economic calendar data unavailable. "
|
||||||
@@ -268,41 +501,144 @@ class NewsEngine:
|
|||||||
|
|
||||||
def _try_fetch_calendar_api(self) -> List[EconomicEvent]:
|
def _try_fetch_calendar_api(self) -> List[EconomicEvent]:
|
||||||
"""Attempt to fetch from the Forex Factory free JSON calendar."""
|
"""Attempt to fetch from the Forex Factory free JSON calendar."""
|
||||||
events = []
|
now = time.time()
|
||||||
|
if now < NewsEngine._calendar_backoff_until:
|
||||||
|
wait = NewsEngine._calendar_backoff_until - now
|
||||||
|
logger.debug(f"Calendar API in backoff for {wait:.0f}s more")
|
||||||
|
return []
|
||||||
|
|
||||||
url = "https://nfs.faireconomy.media/ff_calendar_thisweek.json"
|
url = "https://nfs.faireconomy.media/ff_calendar_thisweek.json"
|
||||||
resp = self._session.get(url, timeout=10)
|
|
||||||
|
for attempt in range(3):
|
||||||
|
try:
|
||||||
|
resp = self._session.get(url, timeout=10)
|
||||||
|
except requests.RequestException as e:
|
||||||
|
logger.debug(f"Calendar request failed (attempt {attempt + 1}): {e}")
|
||||||
|
if attempt < 2:
|
||||||
|
time.sleep(2 ** attempt)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if resp.status_code == 429:
|
||||||
|
retry_after = int(resp.headers.get("Retry-After", 300))
|
||||||
|
retry_after = min(retry_after, 600)
|
||||||
|
NewsEngine._calendar_backoff_until = time.time() + retry_after
|
||||||
|
logger.warning(
|
||||||
|
f"Calendar API rate-limited (429), backing off {retry_after}s"
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
|
if resp.status_code != 200:
|
||||||
|
logger.debug(f"Calendar API returned {resp.status_code}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
data = resp.json()
|
||||||
|
events = []
|
||||||
|
impact_map = {"High": "high", "Medium": "medium", "Low": "low"}
|
||||||
|
|
||||||
|
for item in data:
|
||||||
|
try:
|
||||||
|
impact = impact_map.get(item.get("impact", ""), "low")
|
||||||
|
|
||||||
|
date_str = item.get("date", "")
|
||||||
|
if not date_str:
|
||||||
|
continue
|
||||||
|
event_time = datetime.fromisoformat(date_str.replace("Z", "+00:00"))
|
||||||
|
|
||||||
|
events.append(EconomicEvent(
|
||||||
|
time=event_time,
|
||||||
|
currency=item.get("country", ""),
|
||||||
|
impact=impact,
|
||||||
|
event=item.get("title", ""),
|
||||||
|
forecast=item.get("forecast") or None,
|
||||||
|
previous=item.get("previous") or None,
|
||||||
|
actual=item.get("actual") or None,
|
||||||
|
))
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
return events
|
||||||
|
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _try_fetch_myfxbook_rss(self) -> List[EconomicEvent]:
|
||||||
|
"""Fallback calendar source: MyFXBook economic calendar RSS."""
|
||||||
|
url = "https://www.myfxbook.com/rss/forex-economic-calendar-events"
|
||||||
|
try:
|
||||||
|
resp = self._session.get(url, timeout=10)
|
||||||
|
except requests.RequestException as e:
|
||||||
|
logger.debug(f"MyFXBook calendar request failed: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
if resp.status_code != 200:
|
if resp.status_code != 200:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
data = resp.json()
|
try:
|
||||||
impact_map = {"High": "high", "Medium": "medium", "Low": "low"}
|
root = ET.fromstring(resp.content)
|
||||||
|
except ET.ParseError:
|
||||||
|
return []
|
||||||
|
|
||||||
for item in data:
|
events = []
|
||||||
|
for item in root.iter("item"):
|
||||||
try:
|
try:
|
||||||
impact = impact_map.get(item.get("impact", ""), "low")
|
title_el = item.find("title")
|
||||||
|
pub_el = item.find("pubDate")
|
||||||
date_str = item.get("date", "")
|
desc_el = item.find("description")
|
||||||
if not date_str:
|
if title_el is None or title_el.text is None:
|
||||||
continue
|
continue
|
||||||
event_time = datetime.fromisoformat(date_str.replace("Z", "+00:00"))
|
|
||||||
|
title = title_el.text.strip()
|
||||||
|
|
||||||
|
currency = ""
|
||||||
|
title_lower = title.lower()
|
||||||
|
for country, cur in self._COUNTRY_TO_CURRENCY.items():
|
||||||
|
if title_lower.startswith(country):
|
||||||
|
currency = cur
|
||||||
|
title = title[len(country):].strip()
|
||||||
|
break
|
||||||
|
if not currency:
|
||||||
|
continue
|
||||||
|
|
||||||
|
event_time = self._parse_rss_date(
|
||||||
|
pub_el.text if pub_el is not None else None
|
||||||
|
)
|
||||||
|
if event_time is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
impact = "low"
|
||||||
|
desc_text = desc_el.text if desc_el is not None and desc_el.text else ""
|
||||||
|
impact_match = re.search(r"sprite-(high|medium|low)-impact", desc_text)
|
||||||
|
if impact_match:
|
||||||
|
impact = impact_match.group(1)
|
||||||
|
|
||||||
|
cells = re.findall(r"<td[^>]*>(.*?)</td>", desc_text, re.DOTALL)
|
||||||
|
cells = [re.sub(r"<[^>]+>", "", c).strip() for c in cells]
|
||||||
|
previous = cells[2] if len(cells) > 2 and cells[2] else None
|
||||||
|
forecast = cells[3] if len(cells) > 3 and cells[3] else None
|
||||||
|
actual = cells[4] if len(cells) > 4 and cells[4] else None
|
||||||
|
|
||||||
events.append(EconomicEvent(
|
events.append(EconomicEvent(
|
||||||
time=event_time,
|
time=event_time,
|
||||||
currency=item.get("country", ""),
|
currency=currency,
|
||||||
impact=impact,
|
impact=impact,
|
||||||
event=item.get("title", ""),
|
event=title,
|
||||||
forecast=item.get("forecast") or None,
|
forecast=forecast,
|
||||||
previous=item.get("previous") or None,
|
previous=previous,
|
||||||
actual=item.get("actual") or None,
|
actual=actual,
|
||||||
))
|
))
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
logger.debug(f"MyFXBook calendar: parsed {len(events)} events")
|
||||||
return events
|
return events
|
||||||
|
|
||||||
def _fetch_news_sentiment(self) -> List[NewsSentiment]:
|
def _fetch_news_sentiment(self) -> List[NewsSentiment]:
|
||||||
|
"""Cached headline fetch + sentiment analysis."""
|
||||||
|
return self._cached_fetch("_headlines_cache", self._fetch_news_sentiment_raw)
|
||||||
|
|
||||||
|
def _fetch_news_sentiment_raw(self) -> List[NewsSentiment]:
|
||||||
"""Fetch and analyze news headlines for sentiment."""
|
"""Fetch and analyze news headlines for sentiment."""
|
||||||
headlines = []
|
headlines = []
|
||||||
|
seen_titles = set()
|
||||||
|
|
||||||
for feed_url in self.config.rss_feeds:
|
for feed_url in self.config.rss_feeds:
|
||||||
try:
|
try:
|
||||||
@@ -328,6 +664,14 @@ class NewsEngine:
|
|||||||
if pub_date and (datetime.now(timezone.utc) - pub_date).total_seconds() > 86400:
|
if pub_date and (datetime.now(timezone.utc) - pub_date).total_seconds() > 86400:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Dedup within and across feeds so a syndicated story
|
||||||
|
# doesn't count twice in the sentiment average. Google
|
||||||
|
# News appends " - Publisher", so compare without it.
|
||||||
|
title_key = title.lower().rsplit(" - ", 1)[0].strip()
|
||||||
|
if title_key in seen_titles:
|
||||||
|
continue
|
||||||
|
seen_titles.add(title_key)
|
||||||
|
|
||||||
sentiment = self._analyze_headline_sentiment(title)
|
sentiment = self._analyze_headline_sentiment(title)
|
||||||
currencies = self._extract_currencies(title)
|
currencies = self._extract_currencies(title)
|
||||||
|
|
||||||
@@ -339,26 +683,54 @@ class NewsEngine:
|
|||||||
currencies_mentioned=currencies,
|
currencies_mentioned=currencies,
|
||||||
sentiment_score=sentiment,
|
sentiment_score=sentiment,
|
||||||
keywords=self._extract_keywords(title),
|
keywords=self._extract_keywords(title),
|
||||||
|
sentiment_by_currency={
|
||||||
|
cur: self._analyze_headline_sentiment(title, cur)
|
||||||
|
for cur in currencies
|
||||||
|
},
|
||||||
))
|
))
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Failed to fetch RSS from {feed_url}: {e}")
|
logger.debug(f"Failed to fetch RSS from {feed_url}: {e}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._apply_llm_sentiment(headlines)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"LLM sentiment failed, keyword scores stand: {e}")
|
||||||
|
|
||||||
return headlines
|
return headlines
|
||||||
|
|
||||||
def _analyze_headline_sentiment(self, headline: str) -> float:
|
def _analyze_headline_sentiment(
|
||||||
|
self, headline: str, for_currency: Optional[str] = None
|
||||||
|
) -> float:
|
||||||
"""
|
"""
|
||||||
Simple keyword-based sentiment analysis.
|
Simple keyword-based sentiment analysis.
|
||||||
|
|
||||||
Returns -1.0 (very bearish) to +1.0 (very bullish).
|
Returns -1.0 (very bearish) to +1.0 (very bullish).
|
||||||
|
|
||||||
|
When for_currency is "XAU", monetary-policy keywords contribute
|
||||||
|
with inverted sign (see GOLD_INVERSE_KEYWORDS).
|
||||||
|
|
||||||
Note: This is intentionally simple. NLP models like FinBERT
|
Note: This is intentionally simple. NLP models like FinBERT
|
||||||
would be more accurate but require large dependencies. This catches
|
would be more accurate but require large dependencies. This catches
|
||||||
the most impactful sentiment signals from headlines.
|
the most impactful sentiment signals from headlines.
|
||||||
"""
|
"""
|
||||||
headline_lower = headline.lower()
|
headline_lower = headline.lower()
|
||||||
bullish_score = sum(1 for kw in self.BULLISH_KEYWORDS if kw in headline_lower)
|
invert_policy = for_currency == "XAU"
|
||||||
bearish_score = sum(1 for kw in self.BEARISH_KEYWORDS if kw in headline_lower)
|
|
||||||
|
bullish_score = 0
|
||||||
|
bearish_score = 0
|
||||||
|
for kw in self.BULLISH_KEYWORDS:
|
||||||
|
if kw in headline_lower:
|
||||||
|
if invert_policy and kw in self.GOLD_INVERSE_KEYWORDS:
|
||||||
|
bearish_score += 1
|
||||||
|
else:
|
||||||
|
bullish_score += 1
|
||||||
|
for kw in self.BEARISH_KEYWORDS:
|
||||||
|
if kw in headline_lower:
|
||||||
|
if invert_policy and kw in self.GOLD_INVERSE_KEYWORDS:
|
||||||
|
bullish_score += 1
|
||||||
|
else:
|
||||||
|
bearish_score += 1
|
||||||
|
|
||||||
total = bullish_score + bearish_score
|
total = bullish_score + bearish_score
|
||||||
if total == 0:
|
if total == 0:
|
||||||
@@ -366,21 +738,138 @@ class NewsEngine:
|
|||||||
|
|
||||||
return (bullish_score - bearish_score) / total
|
return (bullish_score - bearish_score) / total
|
||||||
|
|
||||||
|
# ── DeepSeek enrichment (display/warning-only, keyword fallback) ──
|
||||||
|
|
||||||
|
def _llm_headline_scores(
|
||||||
|
self, texts: List[str]
|
||||||
|
) -> Optional[List[Tuple[float, float]]]:
|
||||||
|
"""One batched DeepSeek call → [(overall, xau), ...] aligned with
|
||||||
|
`texts`, or None on any failure/mismatch (caller keeps keyword
|
||||||
|
scores). Tests stub this method directly."""
|
||||||
|
result = get_llm_client().chat_json(
|
||||||
|
_LLM_SENTIMENT_SYSTEM, {"headlines": texts})
|
||||||
|
if not result:
|
||||||
|
return None
|
||||||
|
scores = result.get("scores")
|
||||||
|
if not isinstance(scores, list) or len(scores) != len(texts):
|
||||||
|
return None
|
||||||
|
out = []
|
||||||
|
for item in scores:
|
||||||
|
try:
|
||||||
|
s = max(-1.0, min(1.0, float(item[0])))
|
||||||
|
x = max(-1.0, min(1.0, float(item[1])))
|
||||||
|
except (TypeError, KeyError, ValueError, IndexError):
|
||||||
|
return None
|
||||||
|
out.append((s, x))
|
||||||
|
return out
|
||||||
|
|
||||||
|
def _apply_llm_sentiment(self, headlines: List[NewsSentiment]) -> None:
|
||||||
|
"""Overwrite keyword sentiment on `headlines` with LLM scores.
|
||||||
|
|
||||||
|
Runs inside _cached_fetch, i.e. UNDER _cache_lock — cache access
|
||||||
|
is direct and the (rare) HTTP call blocks sibling scans at most
|
||||||
|
once per cache window, matching the existing shared-fetch design.
|
||||||
|
Only headlines missing from the cache are sent; a failed batch
|
||||||
|
changes nothing.
|
||||||
|
"""
|
||||||
|
if not headlines or not get_llm_client().available:
|
||||||
|
return
|
||||||
|
cache = NewsEngine._llm_sent_cache
|
||||||
|
keyed = [(hashlib.sha1(h.headline.encode()).hexdigest(), h)
|
||||||
|
for h in headlines]
|
||||||
|
# Cap the batch so the reply fits the client's max_tokens — the
|
||||||
|
# overflow stays keyword-scored this cycle and, still uncached,
|
||||||
|
# goes out with the next window's batch.
|
||||||
|
new = [(k, h) for k, h in keyed if k not in cache][:25]
|
||||||
|
if new:
|
||||||
|
scores = self._llm_headline_scores([h.headline for _, h in new])
|
||||||
|
if scores is not None:
|
||||||
|
for (k, _), sc in zip(new, scores):
|
||||||
|
cache[k] = sc
|
||||||
|
while len(cache) > self._LLM_SENT_CACHE_MAX:
|
||||||
|
cache.pop(next(iter(cache)))
|
||||||
|
for k, h in keyed:
|
||||||
|
sc = cache.get(k)
|
||||||
|
if sc is None:
|
||||||
|
continue
|
||||||
|
overall, xau = sc
|
||||||
|
h.sentiment_score = overall
|
||||||
|
h.sentiment_by_currency = {
|
||||||
|
cur: (xau if cur == "XAU" else overall)
|
||||||
|
for cur in h.currencies_mentioned
|
||||||
|
}
|
||||||
|
|
||||||
|
def _llm_event_direction(self, event: EconomicEvent) -> Optional[str]:
|
||||||
|
"""Gold direction for a released surprise, e.g. "bearish: strong
|
||||||
|
jobs lift yields". None → caller keeps the numeric description.
|
||||||
|
Cached per release; failures are NOT cached (retry next scan)."""
|
||||||
|
key = (event.event, event.currency, str(event.actual or ""))
|
||||||
|
with NewsEngine._cache_lock:
|
||||||
|
if key in NewsEngine._llm_event_cache:
|
||||||
|
return NewsEngine._llm_event_cache[key] or None
|
||||||
|
client = get_llm_client()
|
||||||
|
if not client.available:
|
||||||
|
return None
|
||||||
|
result = client.chat_json(_LLM_EVENT_SYSTEM, {
|
||||||
|
"event": event.event,
|
||||||
|
"currency": event.currency,
|
||||||
|
"actual": event.actual,
|
||||||
|
"forecast": event.forecast,
|
||||||
|
"previous": event.previous,
|
||||||
|
})
|
||||||
|
if not result:
|
||||||
|
return None
|
||||||
|
direction = result.get("gold_direction")
|
||||||
|
if direction not in ("bullish", "bearish", "neutral"):
|
||||||
|
return None
|
||||||
|
reason = str(result.get("reason", "")).strip()[:80]
|
||||||
|
text = "" if direction == "neutral" else (
|
||||||
|
f"{direction}: {reason}" if reason else direction)
|
||||||
|
with NewsEngine._cache_lock:
|
||||||
|
NewsEngine._llm_event_cache[key] = text
|
||||||
|
while len(NewsEngine._llm_event_cache) > self._LLM_EVENT_CACHE_MAX:
|
||||||
|
NewsEngine._llm_event_cache.pop(
|
||||||
|
next(iter(NewsEngine._llm_event_cache)))
|
||||||
|
return text or None
|
||||||
|
|
||||||
|
def _llm_market_summary(self, context: FundamentalContext) -> str:
|
||||||
|
"""1-2 sentence macro blurb for alerts; "" when unavailable.
|
||||||
|
Cached (success OR failure) for the context-cache TTL so at most
|
||||||
|
one call is made per window."""
|
||||||
|
with NewsEngine._cache_lock:
|
||||||
|
ts, cached = NewsEngine._llm_summary_cache
|
||||||
|
if time.time() - ts < self.config.context_cache_ttl_seconds:
|
||||||
|
return cached
|
||||||
|
high_impact = [e for e in context.events_today if e.is_high_impact]
|
||||||
|
if not context.news_headlines and not high_impact:
|
||||||
|
return ""
|
||||||
|
client = get_llm_client()
|
||||||
|
if not client.available:
|
||||||
|
return ""
|
||||||
|
result = client.chat_json(_LLM_SUMMARY_SYSTEM, {
|
||||||
|
"headlines": [h.headline for h in context.news_headlines[:15]],
|
||||||
|
"events": [
|
||||||
|
{"event": e.event, "currency": e.currency,
|
||||||
|
"actual": e.actual, "forecast": e.forecast,
|
||||||
|
"released": not e.is_upcoming}
|
||||||
|
for e in high_impact[:10]
|
||||||
|
],
|
||||||
|
})
|
||||||
|
summary = ""
|
||||||
|
if result:
|
||||||
|
summary = str(result.get("summary", "")).strip()[:300]
|
||||||
|
with NewsEngine._cache_lock:
|
||||||
|
NewsEngine._llm_summary_cache = (time.time(), summary)
|
||||||
|
return summary
|
||||||
|
|
||||||
def _extract_currencies(self, headline: str) -> List[str]:
|
def _extract_currencies(self, headline: str) -> List[str]:
|
||||||
"""Extract mentioned currencies from a headline."""
|
"""Extract mentioned currencies from a headline."""
|
||||||
headline_lower = headline.lower()
|
headline_lower = headline.lower()
|
||||||
found = []
|
return [
|
||||||
|
currency
|
||||||
for currency, keywords in self.CURRENCY_KEYWORDS.items():
|
for currency, pattern in self._CURRENCY_PATTERNS.items()
|
||||||
if currency.lower() in headline_lower:
|
if pattern.search(headline_lower)
|
||||||
found.append(currency)
|
]
|
||||||
continue
|
|
||||||
for kw in keywords:
|
|
||||||
if kw in headline_lower:
|
|
||||||
found.append(currency)
|
|
||||||
break
|
|
||||||
|
|
||||||
return list(set(found))
|
|
||||||
|
|
||||||
def _extract_keywords(self, headline: str) -> List[str]:
|
def _extract_keywords(self, headline: str) -> List[str]:
|
||||||
"""Extract relevant trading keywords from headline."""
|
"""Extract relevant trading keywords from headline."""
|
||||||
@@ -393,9 +882,20 @@ class NewsEngine:
|
|||||||
if not date_str:
|
if not date_str:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# RFC 822/2822 forms, with or without seconds, numeric offsets AND
|
||||||
|
# named zones (GMT, EST/EDT, PST…). The old strptime "%Z" path
|
||||||
|
# accepted ANY abbreviation but attached no offset, so e.g. an EST
|
||||||
|
# pubDate was silently stamped UTC — a 5-hour error.
|
||||||
|
try:
|
||||||
|
dt = parsedate_to_datetime(date_str.strip())
|
||||||
|
if dt is not None:
|
||||||
|
if dt.tzinfo is None: # unknown zone name — assume UTC
|
||||||
|
return dt.replace(tzinfo=timezone.utc)
|
||||||
|
return dt.astimezone(timezone.utc)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
formats = [
|
formats = [
|
||||||
"%a, %d %b %Y %H:%M:%S %z", # RFC 822 with numeric tz offset
|
|
||||||
"%a, %d %b %Y %H:%M:%S %Z", # RFC 822 with tz abbreviation (GMT/UTC)
|
|
||||||
"%Y-%m-%dT%H:%M:%S%z", # ISO 8601 with offset
|
"%Y-%m-%dT%H:%M:%S%z", # ISO 8601 with offset
|
||||||
"%Y-%m-%d %H:%M:%S", # ISO 8601 naive (assume UTC)
|
"%Y-%m-%d %H:%M:%S", # ISO 8601 naive (assume UTC)
|
||||||
]
|
]
|
||||||
@@ -413,15 +913,8 @@ class NewsEngine:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_affected_pairs(currency: str) -> List[str]:
|
def _get_affected_pairs(currency: str) -> List[str]:
|
||||||
"""Get all major pairs affected by a currency event."""
|
_MAP = {
|
||||||
pairs = {
|
"USD": ["XAU/USD"],
|
||||||
"USD": ["EURUSD=X", "GBPUSD=X", "USDJPY=X", "USDCHF=X", "AUDUSD=X", "USDCAD=X", "NZDUSD=X"],
|
"XAU": ["XAU/USD"],
|
||||||
"EUR": ["EURUSD=X", "EURGBP=X", "EURJPY=X", "EURCHF=X"],
|
|
||||||
"GBP": ["GBPUSD=X", "EURGBP=X", "GBPJPY=X"],
|
|
||||||
"JPY": ["USDJPY=X", "EURJPY=X", "GBPJPY=X", "AUDJPY=X", "CADJPY=X"],
|
|
||||||
"CHF": ["USDCHF=X", "EURCHF=X"],
|
|
||||||
"AUD": ["AUDUSD=X", "AUDJPY=X"],
|
|
||||||
"CAD": ["USDCAD=X", "CADJPY=X"],
|
|
||||||
"NZD": ["NZDUSD=X"],
|
|
||||||
}
|
}
|
||||||
return pairs.get(currency, [])
|
return _MAP.get(currency, [])
|
||||||
|
|||||||
+775
@@ -0,0 +1,775 @@
|
|||||||
|
"""
|
||||||
|
ForexBot2 — Oanda Automated Trading Daemon
|
||||||
|
============================================
|
||||||
|
Autonomous trading process for XAU/USD on an Oanda practice account.
|
||||||
|
Runs candle-boundary scan loops (M5/M15/H1/H4), executes trades via
|
||||||
|
the Oanda v20 API, and manages trailing stops at bar close — matching
|
||||||
|
the backtested bar-close ratchet model.
|
||||||
|
|
||||||
|
Book-parity notes (matching backtest/gpu_backtest.py):
|
||||||
|
- No broker-side take-profit: in the trailing model TP1/TP2 are
|
||||||
|
advisory; the only exits are SL (broker), TRAIL (daemon ratchet),
|
||||||
|
and TIMEOUT (max_hold_bars). A hard TP would cap winners the trail
|
||||||
|
lets run and diverge the live book from the backtest.
|
||||||
|
- Entry cooldown is 5 bars per timeframe (the backtester's only
|
||||||
|
sequential rule) — not a fingerprint block.
|
||||||
|
- The trail ratchets at bar CLOSE only, and each closed bar is
|
||||||
|
processed exactly once (last_bar_ts guard), so weekends and feed
|
||||||
|
lag can't double-count bars or inflate the hold counter.
|
||||||
|
|
||||||
|
Divergence from the backtest book (accepted for demo safety): one
|
||||||
|
position total. While a trade is open, other signals are logged and
|
||||||
|
skipped, so skipped entries can be compared against the book later.
|
||||||
|
|
||||||
|
Usage: python oanda_daemon.py
|
||||||
|
Config: env vars OANDA_ACCOUNT_ID, OANDA_API_TOKEN, OANDA_BASE_URL
|
||||||
|
or config.json "oanda" section (env wins).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from logging.handlers import TimedRotatingFileHandler
|
||||||
|
from typing import Dict, Optional
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from app_config import (
|
||||||
|
get_app_config, oanda_account_id, oanda_api_token, oanda_base_url,
|
||||||
|
)
|
||||||
|
from config import PAIR_INFO, PlatformConfig, Timeframe, XAUUSD
|
||||||
|
from signal_aggregator import SignalAggregator, SignalReport, SignalStrength
|
||||||
|
from pair_signals import PAIR_PRODUCERS
|
||||||
|
from providers.oanda import OandaDataProvider
|
||||||
|
from oanda_executor import OandaExecutor, OandaAPIError
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ── Constants ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
SYMBOL = XAUUSD
|
||||||
|
INSTRUMENT = "XAU_USD"
|
||||||
|
|
||||||
|
SCAN_CONFIRMATIONS = {
|
||||||
|
Timeframe.M5: [Timeframe.M15, Timeframe.H1],
|
||||||
|
Timeframe.M15: [Timeframe.M5, Timeframe.H1],
|
||||||
|
Timeframe.H1: [Timeframe.M15, Timeframe.H4],
|
||||||
|
Timeframe.H4: [Timeframe.H1, Timeframe.D1],
|
||||||
|
}
|
||||||
|
|
||||||
|
TF_INTERVAL_MINUTES = {
|
||||||
|
Timeframe.M5: 5, Timeframe.M15: 15, Timeframe.M30: 30,
|
||||||
|
Timeframe.H1: 60, Timeframe.H4: 240, Timeframe.D1: 1440,
|
||||||
|
}
|
||||||
|
|
||||||
|
SCAN_TIMEFRAMES = [Timeframe.M5, Timeframe.M15, Timeframe.H1, Timeframe.H4]
|
||||||
|
|
||||||
|
QUALITY_RANK = {"A+": 4, "A": 3, "B": 2, "C": 1, "D": 0, "F": -1}
|
||||||
|
|
||||||
|
# Matches the backtester's --cooldown-bars default (the only sequential
|
||||||
|
# rule in the replay): no new entry within 5 bars of the previous entry
|
||||||
|
# on the same timeframe.
|
||||||
|
COOLDOWN_BARS = 5
|
||||||
|
|
||||||
|
# How often the heartbeat confirms the process is alive and the account
|
||||||
|
# is still reachable — independent of the candle-boundary loops, so a
|
||||||
|
# wedged scan or mid-run credential expiry still surfaces in the log.
|
||||||
|
HEARTBEAT_SECONDS = 3600
|
||||||
|
|
||||||
|
STATE_FILE = os.path.join(
|
||||||
|
os.path.dirname(os.path.abspath(__file__)), "oanda_state.json")
|
||||||
|
|
||||||
|
# ── Config resolution (env → config.json → default) ──────────────────
|
||||||
|
|
||||||
|
def _oanda_setting_int(env_key: str, json_key: str, default: int) -> int:
|
||||||
|
env = os.environ.get(env_key)
|
||||||
|
if env:
|
||||||
|
return int(env)
|
||||||
|
return int(get_app_config().oanda_settings.get(json_key, default))
|
||||||
|
|
||||||
|
|
||||||
|
def _oanda_setting_str(env_key: str, json_key: str, default: str) -> str:
|
||||||
|
return (os.environ.get(env_key)
|
||||||
|
or str(get_app_config().oanda_settings.get(json_key, default)))
|
||||||
|
|
||||||
|
# ── Logging setup ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _setup_logging(level: str = "INFO") -> None:
|
||||||
|
log_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "logs")
|
||||||
|
os.makedirs(log_dir, exist_ok=True)
|
||||||
|
log_file = os.path.join(log_dir, "daemon.log")
|
||||||
|
|
||||||
|
fmt = logging.Formatter(
|
||||||
|
"%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||||
|
datefmt="%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
file_handler = TimedRotatingFileHandler(
|
||||||
|
log_file, when="h", interval=3, backupCount=240, utc=True)
|
||||||
|
file_handler.setFormatter(fmt)
|
||||||
|
|
||||||
|
console_handler = logging.StreamHandler(sys.stdout)
|
||||||
|
console_handler.setFormatter(fmt)
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=getattr(logging, level.upper(), logging.INFO),
|
||||||
|
handlers=[file_handler, console_handler],
|
||||||
|
force=True)
|
||||||
|
|
||||||
|
# ── Boundary timing (same as telegram_bot.py) ─────────────────────────
|
||||||
|
|
||||||
|
def _seconds_until_boundary(interval_minutes: int, buffer: int = 10) -> float:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
total_minutes = now.hour * 60 + now.minute
|
||||||
|
elapsed_minutes = total_minutes % interval_minutes
|
||||||
|
elapsed_seconds = elapsed_minutes * 60 + now.second + now.microsecond / 1e6
|
||||||
|
interval_seconds = interval_minutes * 60
|
||||||
|
remaining = interval_seconds - elapsed_seconds + buffer
|
||||||
|
if remaining <= buffer:
|
||||||
|
remaining += interval_seconds
|
||||||
|
return remaining
|
||||||
|
|
||||||
|
# ── Trade state persistence ───────────────────────────────────────────
|
||||||
|
|
||||||
|
def _load_state() -> Dict:
|
||||||
|
if os.path.exists(STATE_FILE):
|
||||||
|
try:
|
||||||
|
with open(STATE_FILE, encoding="utf-8") as fh:
|
||||||
|
return json.load(fh)
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
logger.warning("Could not load state file, starting fresh")
|
||||||
|
return {"trades": {}, "last_entries": {}}
|
||||||
|
|
||||||
|
|
||||||
|
def _save_state(state: Dict) -> None:
|
||||||
|
try:
|
||||||
|
tmp = STATE_FILE + ".tmp"
|
||||||
|
with open(tmp, "w", encoding="utf-8") as fh:
|
||||||
|
json.dump(state, fh, indent=2)
|
||||||
|
os.replace(tmp, STATE_FILE)
|
||||||
|
except OSError:
|
||||||
|
logger.exception("Failed to save state file")
|
||||||
|
|
||||||
|
# ── Trading Daemon ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TradingDaemon:
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
account_id = oanda_account_id()
|
||||||
|
api_token = oanda_api_token()
|
||||||
|
base_url = oanda_base_url()
|
||||||
|
lot_size = _oanda_setting_int("OANDA_LOT_SIZE", "lot_size", 1)
|
||||||
|
self._quality_gate = _oanda_setting_str(
|
||||||
|
"OANDA_QUALITY_GATE", "quality_gate", "C")
|
||||||
|
self._max_hold_bars = _oanda_setting_int(
|
||||||
|
"OANDA_MAX_HOLD_BARS", "max_hold_bars", 300)
|
||||||
|
self._price_precision = PAIR_INFO[SYMBOL]["price_precision"]
|
||||||
|
|
||||||
|
if not account_id or not api_token:
|
||||||
|
msg = (
|
||||||
|
"Missing Oanda credentials. Set OANDA_ACCOUNT_ID and "
|
||||||
|
"OANDA_API_TOKEN env vars or config.json oanda section.")
|
||||||
|
logger.critical(msg)
|
||||||
|
raise SystemExit(msg)
|
||||||
|
|
||||||
|
self._provider = OandaDataProvider(
|
||||||
|
PlatformConfig(), api_token, base_url)
|
||||||
|
self._executor = OandaExecutor(
|
||||||
|
account_id, api_token, base_url, lot_size,
|
||||||
|
price_precision=self._price_precision)
|
||||||
|
self._thread_pool = ThreadPoolExecutor(max_workers=4)
|
||||||
|
|
||||||
|
self._state = _load_state()
|
||||||
|
self._state.setdefault("trades", {})
|
||||||
|
self._state.setdefault("last_entries", {})
|
||||||
|
self._state_lock = threading.Lock()
|
||||||
|
# Serializes the position-check + order-placement critical section
|
||||||
|
# across concurrent TF loops (all four fire at aligned boundaries).
|
||||||
|
self._entry_lock = threading.Lock()
|
||||||
|
self._running = True
|
||||||
|
|
||||||
|
# ── Main entry point ───────────────────────────────────────────
|
||||||
|
|
||||||
|
async def run(self) -> None:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
loop.set_exception_handler(self._asyncio_exception_handler)
|
||||||
|
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||||
|
loop.add_signal_handler(sig, self._request_shutdown)
|
||||||
|
|
||||||
|
logger.info("=== Oanda Trading Daemon starting ===")
|
||||||
|
logger.info(
|
||||||
|
"Quality gate: %s | Max hold: %d bars | Cooldown: %d bars | "
|
||||||
|
"Timeframes: %s",
|
||||||
|
self._quality_gate, self._max_hold_bars, COOLDOWN_BARS,
|
||||||
|
"/".join(tf.name for tf in SCAN_TIMEFRAMES))
|
||||||
|
|
||||||
|
# Credentials first: with a bad token every later call fails, so
|
||||||
|
# refuse to start before touching positions or spawning loops.
|
||||||
|
self._verify_account_access()
|
||||||
|
|
||||||
|
self._discover_existing_positions()
|
||||||
|
|
||||||
|
tasks = []
|
||||||
|
for tf in SCAN_TIMEFRAMES:
|
||||||
|
interval = TF_INTERVAL_MINUTES[tf]
|
||||||
|
tasks.append(asyncio.create_task(
|
||||||
|
self._candle_loop(tf, interval)))
|
||||||
|
tasks.append(asyncio.create_task(self._heartbeat_loop()))
|
||||||
|
|
||||||
|
logger.info("Candle-boundary loops started")
|
||||||
|
try:
|
||||||
|
await asyncio.gather(*tasks)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
self._shutdown()
|
||||||
|
|
||||||
|
def _verify_account_access(self) -> None:
|
||||||
|
"""Boot-time credential check. A 4xx here means the token/account
|
||||||
|
id is wrong — that never fixes itself, so refuse to start rather
|
||||||
|
than run a daemon that can't place or manage trades. A connection
|
||||||
|
failure or 5xx is a transient outage: log it and continue, since
|
||||||
|
broker-side stops already protect any open position and the
|
||||||
|
hourly heartbeat will keep reporting account health."""
|
||||||
|
try:
|
||||||
|
self._executor.get_account_summary()
|
||||||
|
except OandaAPIError as e:
|
||||||
|
if 400 <= e.status_code < 500:
|
||||||
|
logger.critical(
|
||||||
|
"Oanda rejected the account credentials (HTTP %d): "
|
||||||
|
"%s — refusing to start", e.status_code, e)
|
||||||
|
raise SystemExit(
|
||||||
|
f"Oanda rejected the account credentials "
|
||||||
|
f"(HTTP {e.status_code}): {e}")
|
||||||
|
logger.error(
|
||||||
|
"Failed to fetch account summary at boot (HTTP %d): %s",
|
||||||
|
e.status_code, e)
|
||||||
|
|
||||||
|
def _asyncio_exception_handler(self, loop, context) -> None:
|
||||||
|
exc = context.get("exception")
|
||||||
|
if exc is not None:
|
||||||
|
logger.critical(
|
||||||
|
"Uncaught asyncio exception: %s",
|
||||||
|
context.get("message", ""), exc_info=exc)
|
||||||
|
else:
|
||||||
|
logger.critical(
|
||||||
|
"Uncaught asyncio error: %s", context.get("message", ""))
|
||||||
|
|
||||||
|
def _request_shutdown(self) -> None:
|
||||||
|
logger.info("Shutdown requested")
|
||||||
|
self._running = False
|
||||||
|
for task in asyncio.all_tasks():
|
||||||
|
task.cancel()
|
||||||
|
|
||||||
|
def _shutdown(self) -> None:
|
||||||
|
with self._state_lock:
|
||||||
|
_save_state(self._state)
|
||||||
|
self._thread_pool.shutdown(wait=False)
|
||||||
|
logger.info("=== Oanda Trading Daemon stopped ===")
|
||||||
|
|
||||||
|
# ── Position discovery (restart recovery) ──────────────────────
|
||||||
|
|
||||||
|
def _discover_existing_positions(self) -> None:
|
||||||
|
try:
|
||||||
|
open_trades = self._executor.get_open_trades(INSTRUMENT)
|
||||||
|
except OandaAPIError:
|
||||||
|
logger.exception("Failed to query open trades at boot")
|
||||||
|
return
|
||||||
|
|
||||||
|
saved = self._state.get("trades", {})
|
||||||
|
live_ids = {t["id"] for t in open_trades}
|
||||||
|
|
||||||
|
stale = [tid for tid in saved if tid not in live_ids]
|
||||||
|
for tid in stale:
|
||||||
|
logger.info(
|
||||||
|
"Stale trade %s in state file (closed on Oanda), removing", tid)
|
||||||
|
del saved[tid]
|
||||||
|
|
||||||
|
for trade in open_trades:
|
||||||
|
tid = trade["id"]
|
||||||
|
if tid in saved:
|
||||||
|
logger.info(
|
||||||
|
"Recovered trade %s from state: %s %s @ %.2f, SL %.2f",
|
||||||
|
tid, saved[tid].get("direction"), INSTRUMENT,
|
||||||
|
saved[tid].get("entry"), saved[tid].get("current_sl"))
|
||||||
|
continue
|
||||||
|
|
||||||
|
direction = "BUY" if float(trade.get("currentUnits", 0)) > 0 else "SELL"
|
||||||
|
entry = float(trade.get("price", 0))
|
||||||
|
sl_order = trade.get("stopLossOrder", {})
|
||||||
|
sl = float(sl_order.get("price", entry))
|
||||||
|
risk = abs(entry - sl) if sl else 1.0
|
||||||
|
|
||||||
|
saved[tid] = {
|
||||||
|
"tf": "H1",
|
||||||
|
"direction": direction,
|
||||||
|
"entry": entry,
|
||||||
|
"sl_initial": sl,
|
||||||
|
"current_sl": sl,
|
||||||
|
"risk": risk,
|
||||||
|
"peak": entry,
|
||||||
|
"trail_armed": False,
|
||||||
|
"trail_arm_r": 0.6,
|
||||||
|
"trail_dist_r": 0.75,
|
||||||
|
"bars_held": 0,
|
||||||
|
"last_bar_ts": None,
|
||||||
|
"signal_bar_ts": None,
|
||||||
|
}
|
||||||
|
logger.info(
|
||||||
|
"Adopted unknown trade %s: %s @ %.2f, SL %.2f, risk %.2f "
|
||||||
|
"(conservative defaults)",
|
||||||
|
tid, direction, entry, sl, risk)
|
||||||
|
|
||||||
|
self._state["trades"] = saved
|
||||||
|
_save_state(self._state)
|
||||||
|
logger.info(
|
||||||
|
"Position discovery: %d active trade(s)", len(saved))
|
||||||
|
|
||||||
|
# ── Candle-boundary loop ───────────────────────────────────────
|
||||||
|
|
||||||
|
async def _candle_loop(self, tf: Timeframe, interval_minutes: int) -> None:
|
||||||
|
label = tf.value
|
||||||
|
try:
|
||||||
|
while self._running:
|
||||||
|
wait = _seconds_until_boundary(interval_minutes, buffer=10)
|
||||||
|
logger.info("%s loop: next scan in %.0fs", label, wait)
|
||||||
|
await asyncio.sleep(wait)
|
||||||
|
if not self._running:
|
||||||
|
break
|
||||||
|
logger.info("%s loop: firing scan", label)
|
||||||
|
try:
|
||||||
|
await self._scan_and_manage(tf)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("%s loop: cycle failed", label)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
logger.info("%s loop stopped", label)
|
||||||
|
|
||||||
|
async def _scan_and_manage(self, tf: Timeframe) -> None:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
|
||||||
|
# Trade management FIRST: the SL ratchet must not wait on (or be
|
||||||
|
# starved by) the signal scan — news fetches can take seconds and
|
||||||
|
# a failed scan must never cost an open trade its trail update.
|
||||||
|
try:
|
||||||
|
await loop.run_in_executor(
|
||||||
|
self._thread_pool, self._manage_open_trades, tf)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("%s: trade management failed", tf.value)
|
||||||
|
|
||||||
|
report = await loop.run_in_executor(
|
||||||
|
self._thread_pool, self._run_signal, tf)
|
||||||
|
|
||||||
|
if not self._should_execute(report, tf):
|
||||||
|
return
|
||||||
|
|
||||||
|
await loop.run_in_executor(
|
||||||
|
self._thread_pool, self._execute_trade, report, tf)
|
||||||
|
|
||||||
|
def _manage_open_trades(self, tf: Timeframe) -> None:
|
||||||
|
self._check_closed_trades()
|
||||||
|
self._manage_trailing(tf)
|
||||||
|
|
||||||
|
# ── Heartbeat ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _heartbeat_once(self) -> None:
|
||||||
|
with self._state_lock:
|
||||||
|
n = len(self._state.get("trades", {}))
|
||||||
|
try:
|
||||||
|
self._executor.get_account_summary()
|
||||||
|
except OandaAPIError as e:
|
||||||
|
logger.error("HEARTBEAT: account summary failed: %s", e)
|
||||||
|
# Logged unconditionally — even on a failed summary call the
|
||||||
|
# heartbeat line itself must reach the log, so a wedged process
|
||||||
|
# doesn't look identical to a healthy but silent one.
|
||||||
|
logger.info("HEARTBEAT: alive | tracked trades: %d", n)
|
||||||
|
|
||||||
|
async def _heartbeat_loop(self) -> None:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
try:
|
||||||
|
while self._running:
|
||||||
|
await asyncio.sleep(HEARTBEAT_SECONDS)
|
||||||
|
if not self._running:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
await loop.run_in_executor(
|
||||||
|
self._thread_pool, self._heartbeat_once)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("heartbeat loop: cycle failed")
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
logger.info("heartbeat loop stopped")
|
||||||
|
|
||||||
|
# ── Signal pipeline ────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _run_signal(self, tf: Timeframe) -> SignalReport:
|
||||||
|
conf_tfs = SCAN_CONFIRMATIONS.get(
|
||||||
|
tf, [Timeframe.M15, Timeframe.H4])
|
||||||
|
factory = PAIR_PRODUCERS.get(SYMBOL)
|
||||||
|
if factory is not None:
|
||||||
|
config = factory(tf, conf_tfs)
|
||||||
|
else:
|
||||||
|
config = PlatformConfig()
|
||||||
|
config.primary_timeframe = tf
|
||||||
|
config.confirmation_timeframes = conf_tfs
|
||||||
|
|
||||||
|
aggregator = SignalAggregator(config)
|
||||||
|
aggregator.data_provider = self._provider
|
||||||
|
report = aggregator.analyze_pair(symbol=SYMBOL)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"%s scan: signal=%s score=%.1f conf=%.0f quality=%s "
|
||||||
|
"regime=%s vol=%s price=%.2f spread=%.4f",
|
||||||
|
tf.value,
|
||||||
|
report.final_signal.name, report.final_score,
|
||||||
|
report.final_confidence, report.signal_quality,
|
||||||
|
report.market_regime, report.volatility_regime,
|
||||||
|
report.current_price,
|
||||||
|
self._provider.get_spread(SYMBOL))
|
||||||
|
|
||||||
|
# Stale-data/suppression warnings must be visible at WARNING;
|
||||||
|
# routine session-filter notices go to DEBUG — the M5 loop fires
|
||||||
|
# 288x/day and would drown the log at WARNING otherwise.
|
||||||
|
for w in report.warnings:
|
||||||
|
if "STALE" in w.upper() or "FAIL" in w.upper():
|
||||||
|
logger.warning("%s scan: %s", tf.value, w)
|
||||||
|
else:
|
||||||
|
logger.debug("%s scan: %s", tf.value, w)
|
||||||
|
|
||||||
|
return report
|
||||||
|
|
||||||
|
# ── Execution decision ─────────────────────────────────────────
|
||||||
|
|
||||||
|
def _should_execute(self, report: SignalReport, tf: Timeframe) -> bool:
|
||||||
|
if report.final_signal == SignalStrength.NEUTRAL:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if report.trade_setup is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
quality_rank = QUALITY_RANK.get(report.signal_quality, -1)
|
||||||
|
gate_rank = QUALITY_RANK.get(self._quality_gate, 1)
|
||||||
|
if quality_rank < gate_rank:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if self._in_cooldown(tf):
|
||||||
|
logger.info(
|
||||||
|
"%s: actionable signal (%s %s) but inside %d-bar entry "
|
||||||
|
"cooldown, skipping",
|
||||||
|
tf.value, report.trade_setup.direction,
|
||||||
|
report.signal_quality, COOLDOWN_BARS)
|
||||||
|
return False
|
||||||
|
|
||||||
|
with self._state_lock:
|
||||||
|
if self._state.get("trades"):
|
||||||
|
logger.info(
|
||||||
|
"%s: actionable signal (%s %s) but position already "
|
||||||
|
"open, skipping",
|
||||||
|
tf.value, report.trade_setup.direction,
|
||||||
|
report.signal_quality)
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _in_cooldown(self, tf: Timeframe) -> bool:
|
||||||
|
"""Backtest parity: no entry within COOLDOWN_BARS bars of the
|
||||||
|
previous entry on the same timeframe (wall-clock approximation)."""
|
||||||
|
with self._state_lock:
|
||||||
|
last = self._state.get("last_entries", {}).get(tf.value)
|
||||||
|
if not last:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
last_ts = datetime.fromisoformat(last)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
interval_s = TF_INTERVAL_MINUTES[tf] * 60
|
||||||
|
elapsed = (datetime.now(timezone.utc) - last_ts).total_seconds()
|
||||||
|
return elapsed < COOLDOWN_BARS * interval_s
|
||||||
|
|
||||||
|
# ── Trade execution ────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _execute_trade(self, report: SignalReport, tf: Timeframe) -> None:
|
||||||
|
ts = report.trade_setup
|
||||||
|
if ts is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
# All four TF loops can fire at an aligned boundary (e.g. :00) and
|
||||||
|
# pass _should_execute before any of them has recorded a fill —
|
||||||
|
# re-check under the entry lock so only one order goes out.
|
||||||
|
with self._entry_lock:
|
||||||
|
with self._state_lock:
|
||||||
|
if self._state.get("trades"):
|
||||||
|
logger.info(
|
||||||
|
"%s: position opened by a concurrent scan, skipping",
|
||||||
|
tf.value)
|
||||||
|
return
|
||||||
|
|
||||||
|
# NO take-profit on the order: the backtested trailing model
|
||||||
|
# exits via SL/TRAIL/TIMEOUT only — a hard TP would cap
|
||||||
|
# winners and diverge the live book from the backtest.
|
||||||
|
try:
|
||||||
|
result = self._executor.place_market_order(
|
||||||
|
instrument=INSTRUMENT,
|
||||||
|
direction=ts.direction,
|
||||||
|
stop_loss=ts.stop_loss,
|
||||||
|
)
|
||||||
|
except OandaAPIError:
|
||||||
|
logger.exception(
|
||||||
|
"Failed to place %s order for %s %s",
|
||||||
|
ts.direction, SYMBOL, tf.value)
|
||||||
|
return
|
||||||
|
|
||||||
|
fill_tx = result.get("orderFillTransaction")
|
||||||
|
if not fill_tx:
|
||||||
|
logger.warning(
|
||||||
|
"Order not filled for %s %s", SYMBOL, tf.value)
|
||||||
|
return
|
||||||
|
|
||||||
|
trade_opened = fill_tx.get("tradeOpened", {})
|
||||||
|
trade_id = trade_opened.get("tradeID")
|
||||||
|
if not trade_id:
|
||||||
|
logger.warning("No trade ID in fill response")
|
||||||
|
return
|
||||||
|
|
||||||
|
fill_price = float(fill_tx.get("price", ts.entry_price))
|
||||||
|
risk = abs(fill_price - ts.stop_loss)
|
||||||
|
|
||||||
|
trail_arm = ts.trail_arm_r if ts.trail_arm_r is not None else 0.6
|
||||||
|
trail_dist = ts.trail_dist_r if ts.trail_dist_r is not None else 0.75
|
||||||
|
|
||||||
|
now_iso = datetime.now(timezone.utc).isoformat()
|
||||||
|
trade_state = {
|
||||||
|
"tf": tf.value,
|
||||||
|
"direction": ts.direction,
|
||||||
|
"entry": fill_price,
|
||||||
|
"sl_initial": ts.stop_loss,
|
||||||
|
"current_sl": ts.stop_loss,
|
||||||
|
"tp1": ts.take_profit_1,
|
||||||
|
"tp2": ts.take_profit_2,
|
||||||
|
"risk": risk,
|
||||||
|
"peak": fill_price,
|
||||||
|
"trail_armed": False,
|
||||||
|
"trail_arm_r": trail_arm,
|
||||||
|
"trail_dist_r": trail_dist,
|
||||||
|
"bars_held": 0,
|
||||||
|
# The signal bar is already closed; the first bar this
|
||||||
|
# trade lives through is the one that just started. Guard
|
||||||
|
# so a lagging feed can't trail on the signal bar itself.
|
||||||
|
"signal_bar_ts": report.timestamp,
|
||||||
|
"last_bar_ts": None,
|
||||||
|
"strategy": ts.strategy,
|
||||||
|
"quality": report.signal_quality,
|
||||||
|
"rr": ts.risk_reward,
|
||||||
|
"opened_at": now_iso,
|
||||||
|
}
|
||||||
|
|
||||||
|
with self._state_lock:
|
||||||
|
self._state["trades"][trade_id] = trade_state
|
||||||
|
self._state["last_entries"][tf.value] = now_iso
|
||||||
|
_save_state(self._state)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"TRADE OPENED: %s %s @ %.2f | SL %.2f | TP1 %.2f (advisory) | "
|
||||||
|
"risk $%.2f | RR %.2f | quality %s | strategy %s | TF %s | "
|
||||||
|
"trail arm %.2fR dist %.2fR",
|
||||||
|
ts.direction, SYMBOL, fill_price, ts.stop_loss,
|
||||||
|
ts.take_profit_1,
|
||||||
|
risk, ts.risk_reward, report.signal_quality,
|
||||||
|
ts.strategy, tf.value, trail_arm, trail_dist)
|
||||||
|
|
||||||
|
# ── Trailing stop management ───────────────────────────────────
|
||||||
|
|
||||||
|
def _manage_trailing(self, tf: Timeframe) -> None:
|
||||||
|
with self._state_lock:
|
||||||
|
trades = dict(self._state.get("trades", {}))
|
||||||
|
|
||||||
|
for trade_id, info in trades.items():
|
||||||
|
if info.get("tf") != tf.value:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
df = self._provider.fetch_ohlcv(SYMBOL, tf)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"Failed to fetch candle data for trail update")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if df.empty:
|
||||||
|
logger.warning(
|
||||||
|
"%s: no candle data for trail update of trade %s",
|
||||||
|
tf.value, trade_id)
|
||||||
|
continue
|
||||||
|
|
||||||
|
bar_ts = df.index[-1].isoformat()
|
||||||
|
|
||||||
|
# Process each closed bar exactly once. Weekends and feed lag
|
||||||
|
# re-serve the same last bar; without this guard bars_held
|
||||||
|
# inflates (an M5 trade would "time out" over a weekend) and
|
||||||
|
# the peak gets double-counted.
|
||||||
|
if bar_ts == info.get("last_bar_ts"):
|
||||||
|
continue
|
||||||
|
sig_ts = info.get("signal_bar_ts")
|
||||||
|
if sig_ts:
|
||||||
|
try:
|
||||||
|
if df.index[-1] <= pd.Timestamp(sig_ts):
|
||||||
|
continue
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
info["last_bar_ts"] = bar_ts
|
||||||
|
|
||||||
|
info["bars_held"] = info.get("bars_held", 0) + 1
|
||||||
|
|
||||||
|
if info["bars_held"] >= self._max_hold_bars:
|
||||||
|
logger.info(
|
||||||
|
"TIMEOUT: trade %s held %d bars, closing",
|
||||||
|
trade_id, info["bars_held"])
|
||||||
|
try:
|
||||||
|
self._executor.close_trade(trade_id)
|
||||||
|
except OandaAPIError:
|
||||||
|
# Keep tracking — the trade is still live on the
|
||||||
|
# broker; we retry the close at the next bar.
|
||||||
|
logger.exception(
|
||||||
|
"Failed to close timed-out trade %s — will retry",
|
||||||
|
trade_id)
|
||||||
|
with self._state_lock:
|
||||||
|
self._state["trades"][trade_id] = info
|
||||||
|
_save_state(self._state)
|
||||||
|
continue
|
||||||
|
with self._state_lock:
|
||||||
|
self._state["trades"].pop(trade_id, None)
|
||||||
|
_save_state(self._state)
|
||||||
|
continue
|
||||||
|
|
||||||
|
last_bar = df.iloc[-1]
|
||||||
|
bar_high = float(last_bar["High"])
|
||||||
|
bar_low = float(last_bar["Low"])
|
||||||
|
|
||||||
|
new_sl = self._compute_trail(info, bar_high, bar_low)
|
||||||
|
|
||||||
|
if new_sl is not None:
|
||||||
|
# Round to instrument precision and track exactly what the
|
||||||
|
# broker holds, so tracked vs broker SL can never drift.
|
||||||
|
new_sl = round(new_sl, self._price_precision)
|
||||||
|
if new_sl != info["current_sl"]:
|
||||||
|
try:
|
||||||
|
self._executor.modify_trade_sl(trade_id, new_sl)
|
||||||
|
info["current_sl"] = new_sl
|
||||||
|
except OandaAPIError:
|
||||||
|
logger.exception(
|
||||||
|
"Failed to update trailing SL for trade %s",
|
||||||
|
trade_id)
|
||||||
|
|
||||||
|
with self._state_lock:
|
||||||
|
self._state["trades"][trade_id] = info
|
||||||
|
_save_state(self._state)
|
||||||
|
|
||||||
|
def _compute_trail(
|
||||||
|
self, info: Dict, bar_high: float, bar_low: float
|
||||||
|
) -> Optional[float]:
|
||||||
|
buy = info["direction"] == "BUY"
|
||||||
|
risk = info["risk"]
|
||||||
|
if risk <= 0:
|
||||||
|
return None
|
||||||
|
arm = info.get("trail_arm_r", 0.6)
|
||||||
|
dist = info.get("trail_dist_r", 0.75)
|
||||||
|
if arm <= 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
fav = bar_high if buy else bar_low
|
||||||
|
if buy:
|
||||||
|
info["peak"] = max(info.get("peak", info["entry"]), fav)
|
||||||
|
else:
|
||||||
|
info["peak"] = min(info.get("peak", info["entry"]), fav)
|
||||||
|
|
||||||
|
exc = ((info["peak"] - info["entry"]) if buy
|
||||||
|
else (info["entry"] - info["peak"])) / risk
|
||||||
|
|
||||||
|
if exc >= arm:
|
||||||
|
if buy:
|
||||||
|
lvl = info["peak"] - dist * risk
|
||||||
|
cur_sl = info.get("current_sl", info["sl_initial"])
|
||||||
|
if lvl > cur_sl:
|
||||||
|
info["trail_armed"] = True
|
||||||
|
return lvl
|
||||||
|
else:
|
||||||
|
lvl = info["peak"] + dist * risk
|
||||||
|
cur_sl = info.get("current_sl", info["sl_initial"])
|
||||||
|
if lvl < cur_sl:
|
||||||
|
info["trail_armed"] = True
|
||||||
|
return lvl
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ── Closed-trade detection ─────────────────────────────────────
|
||||||
|
|
||||||
|
def _check_closed_trades(self) -> None:
|
||||||
|
with self._state_lock:
|
||||||
|
tracked = set(self._state.get("trades", {}).keys())
|
||||||
|
|
||||||
|
if not tracked:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
open_trades = self._executor.get_open_trades(INSTRUMENT)
|
||||||
|
except OandaAPIError:
|
||||||
|
logger.exception("Failed to query open trades")
|
||||||
|
return
|
||||||
|
|
||||||
|
live_ids = {t["id"] for t in open_trades}
|
||||||
|
closed = tracked - live_ids
|
||||||
|
|
||||||
|
for tid in closed:
|
||||||
|
with self._state_lock:
|
||||||
|
info = self._state["trades"].pop(tid, {})
|
||||||
|
entry = info.get("entry", 0)
|
||||||
|
direction = info.get("direction", "?")
|
||||||
|
risk = info.get("risk", 1)
|
||||||
|
sl = info.get("current_sl", 0)
|
||||||
|
was_trailed = info.get("trail_armed", False)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"TRADE CLOSED BY BROKER: %s %s %s | entry %.2f | "
|
||||||
|
"SL was %.2f | risk $%.2f | trailed=%s | bars_held=%d",
|
||||||
|
tid, direction, SYMBOL, entry, sl, risk,
|
||||||
|
was_trailed, info.get("bars_held", 0))
|
||||||
|
|
||||||
|
if closed:
|
||||||
|
with self._state_lock:
|
||||||
|
_save_state(self._state)
|
||||||
|
|
||||||
|
# ── Uncaught exception handlers ───────────────────────────────────────
|
||||||
|
|
||||||
|
def _excepthook(exc_type, exc_value, exc_tb):
|
||||||
|
if issubclass(exc_type, KeyboardInterrupt):
|
||||||
|
sys.__excepthook__(exc_type, exc_value, exc_tb)
|
||||||
|
return
|
||||||
|
logger.critical(
|
||||||
|
"Uncaught exception", exc_info=(exc_type, exc_value, exc_tb))
|
||||||
|
|
||||||
|
def _threading_excepthook(args):
|
||||||
|
logger.critical(
|
||||||
|
"Uncaught exception in thread %s",
|
||||||
|
args.thread.name if args.thread else "?",
|
||||||
|
exc_info=(args.exc_type, args.exc_value, args.exc_traceback))
|
||||||
|
|
||||||
|
# ── Entry point ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
_setup_logging(
|
||||||
|
os.environ.get("OANDA_LOG_LEVEL") or get_app_config().log_level)
|
||||||
|
sys.excepthook = _excepthook
|
||||||
|
threading.excepthook = _threading_excepthook
|
||||||
|
|
||||||
|
daemon = TradingDaemon()
|
||||||
|
asyncio.run(daemon.run())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
"""
|
||||||
|
ForexBot2 — Oanda v20 Order Executor
|
||||||
|
======================================
|
||||||
|
Handles all Oanda account operations: market orders, trade queries,
|
||||||
|
SL/TP modification, and trade closure. Uses raw ``requests`` against
|
||||||
|
the Oanda v20 REST API — no extra dependencies.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class OandaAPIError(Exception):
|
||||||
|
"""Raised when an Oanda API call fails."""
|
||||||
|
|
||||||
|
def __init__(self, message: str, status_code: int = 0, body: str = ""):
|
||||||
|
super().__init__(message)
|
||||||
|
self.status_code = status_code
|
||||||
|
self.body = body
|
||||||
|
|
||||||
|
|
||||||
|
class OandaExecutor:
|
||||||
|
"""Oanda v20 order execution and trade management."""
|
||||||
|
|
||||||
|
MAX_RETRIES = 1
|
||||||
|
RETRY_DELAY = 2.0
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
account_id: str,
|
||||||
|
api_token: str,
|
||||||
|
base_url: str = "https://api-fxpractice.oanda.com",
|
||||||
|
lot_size: int = 1,
|
||||||
|
price_precision: int = 2,
|
||||||
|
):
|
||||||
|
self._account_id = account_id
|
||||||
|
self._base_url = base_url.rstrip("/")
|
||||||
|
self._lot_size = lot_size
|
||||||
|
self._price_precision = price_precision
|
||||||
|
self._session = requests.Session()
|
||||||
|
self._session.headers.update({
|
||||||
|
"Authorization": f"Bearer {api_token}",
|
||||||
|
"Accept-Datetime-Format": "RFC3339",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
})
|
||||||
|
|
||||||
|
# ── Order Placement ────────────────────────────────────────────
|
||||||
|
|
||||||
|
def place_market_order(
|
||||||
|
self,
|
||||||
|
instrument: str,
|
||||||
|
direction: str,
|
||||||
|
stop_loss: float,
|
||||||
|
take_profit: Optional[float] = None,
|
||||||
|
units: Optional[int] = None,
|
||||||
|
) -> Dict:
|
||||||
|
if units is None:
|
||||||
|
units = self._lot_size
|
||||||
|
signed_units = str(units) if direction.upper() == "BUY" else str(-units)
|
||||||
|
|
||||||
|
order_body = {
|
||||||
|
"order": {
|
||||||
|
"type": "MARKET",
|
||||||
|
"instrument": instrument,
|
||||||
|
"units": signed_units,
|
||||||
|
"timeInForce": "FOK",
|
||||||
|
"stopLossOnFill": {
|
||||||
|
"price": self._fmt_price(stop_loss),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if take_profit is not None:
|
||||||
|
order_body["order"]["takeProfitOnFill"] = {
|
||||||
|
"price": self._fmt_price(take_profit),
|
||||||
|
}
|
||||||
|
|
||||||
|
url = f"{self._base_url}/v3/accounts/{self._account_id}/orders"
|
||||||
|
# Never retry order placement: a timeout after the server already
|
||||||
|
# filled would open a SECOND position on retry. Fail loudly instead;
|
||||||
|
# the next candle boundary re-evaluates.
|
||||||
|
resp = self._request_with_retry("POST", url, json=order_body, retries=0)
|
||||||
|
data = self._parse_json(resp)
|
||||||
|
|
||||||
|
fill_tx = data.get("orderFillTransaction")
|
||||||
|
if fill_tx:
|
||||||
|
trade_id = fill_tx.get("tradeOpened", {}).get("tradeID", "unknown")
|
||||||
|
fill_price = float(fill_tx.get("price", 0))
|
||||||
|
logger.info(
|
||||||
|
"ORDER FILLED: %s %s %s units @ %.2f — trade %s, SL %.2f%s",
|
||||||
|
direction.upper(), instrument, signed_units, fill_price,
|
||||||
|
trade_id, stop_loss,
|
||||||
|
f", TP {take_profit:.2f}" if take_profit else "")
|
||||||
|
else:
|
||||||
|
cancel_tx = data.get("orderCancelTransaction", {})
|
||||||
|
reason = cancel_tx.get("reason", "unknown")
|
||||||
|
logger.warning(
|
||||||
|
"ORDER NOT FILLED: %s %s — reason: %s",
|
||||||
|
direction.upper(), instrument, reason)
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
# ── Trade Queries ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_open_trades(self, instrument: Optional[str] = None) -> List[Dict]:
|
||||||
|
url = f"{self._base_url}/v3/accounts/{self._account_id}/openTrades"
|
||||||
|
resp = self._request_with_retry("GET", url)
|
||||||
|
trades = self._parse_json(resp).get("trades", [])
|
||||||
|
if instrument:
|
||||||
|
trades = [t for t in trades if t.get("instrument") == instrument]
|
||||||
|
return trades
|
||||||
|
|
||||||
|
def get_trade(self, trade_id: str) -> Dict:
|
||||||
|
url = (f"{self._base_url}/v3/accounts/{self._account_id}"
|
||||||
|
f"/trades/{trade_id}")
|
||||||
|
resp = self._request_with_retry("GET", url)
|
||||||
|
return self._parse_json(resp).get("trade", {})
|
||||||
|
|
||||||
|
def get_account_summary(self) -> Dict:
|
||||||
|
url = f"{self._base_url}/v3/accounts/{self._account_id}/summary"
|
||||||
|
resp = self._request_with_retry("GET", url)
|
||||||
|
account = self._parse_json(resp).get("account", {})
|
||||||
|
logger.info(
|
||||||
|
"Account: balance=%.2f, NAV=%.2f, unrealizedPL=%.2f, openTrades=%s",
|
||||||
|
float(account.get("balance", 0)),
|
||||||
|
float(account.get("NAV", 0)),
|
||||||
|
float(account.get("unrealizedPL", 0)),
|
||||||
|
account.get("openTradeCount", 0))
|
||||||
|
return account
|
||||||
|
|
||||||
|
# ── Trade Modification ─────────────────────────────────────────
|
||||||
|
|
||||||
|
def modify_trade_sl(self, trade_id: str, new_sl: float) -> Dict:
|
||||||
|
url = (f"{self._base_url}/v3/accounts/{self._account_id}"
|
||||||
|
f"/trades/{trade_id}/orders")
|
||||||
|
body = {"stopLoss": {"price": self._fmt_price(new_sl)}}
|
||||||
|
resp = self._request_with_retry("PUT", url, json=body)
|
||||||
|
logger.info(
|
||||||
|
"TRAIL UPDATE: trade %s — new SL %.2f", trade_id, new_sl)
|
||||||
|
return self._parse_json(resp)
|
||||||
|
|
||||||
|
def modify_trade_tp(self, trade_id: str, new_tp: float) -> Dict:
|
||||||
|
url = (f"{self._base_url}/v3/accounts/{self._account_id}"
|
||||||
|
f"/trades/{trade_id}/orders")
|
||||||
|
body = {"takeProfit": {"price": self._fmt_price(new_tp)}}
|
||||||
|
resp = self._request_with_retry("PUT", url, json=body)
|
||||||
|
logger.info("TP UPDATE: trade %s — new TP %.2f", trade_id, new_tp)
|
||||||
|
return self._parse_json(resp)
|
||||||
|
|
||||||
|
def close_trade(self, trade_id: str) -> Dict:
|
||||||
|
url = (f"{self._base_url}/v3/accounts/{self._account_id}"
|
||||||
|
f"/trades/{trade_id}/close")
|
||||||
|
resp = self._request_with_retry("PUT", url, json={"units": "ALL"})
|
||||||
|
data = self._parse_json(resp)
|
||||||
|
close_tx = data.get("orderFillTransaction", {})
|
||||||
|
close_price = float(close_tx.get("price", 0))
|
||||||
|
pl = float(close_tx.get("pl", 0))
|
||||||
|
logger.info(
|
||||||
|
"TRADE CLOSED: trade %s @ %.2f — PL %.2f",
|
||||||
|
trade_id, close_price, pl)
|
||||||
|
return data
|
||||||
|
|
||||||
|
# ── Internals ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_json(resp: requests.Response) -> Dict:
|
||||||
|
try:
|
||||||
|
return resp.json()
|
||||||
|
except ValueError as e:
|
||||||
|
raise OandaAPIError(
|
||||||
|
f"Oanda returned a non-JSON response (HTTP {resp.status_code})",
|
||||||
|
resp.status_code, resp.text[:300]) from e
|
||||||
|
|
||||||
|
def _fmt_price(self, price: float) -> str:
|
||||||
|
# Oanda rejects prices with more decimals than the instrument's
|
||||||
|
# displayPrecision (XAU_USD: sub-cent precision is invalid).
|
||||||
|
return f"{price:.{self._price_precision}f}"
|
||||||
|
|
||||||
|
def _request_with_retry(
|
||||||
|
self, method: str, url: str, retries: Optional[int] = None, **kwargs
|
||||||
|
) -> requests.Response:
|
||||||
|
if retries is None:
|
||||||
|
retries = self.MAX_RETRIES
|
||||||
|
last_err = None
|
||||||
|
for attempt in range(1 + retries):
|
||||||
|
try:
|
||||||
|
resp = self._session.request(method, url, timeout=15, **kwargs)
|
||||||
|
if resp.status_code >= 500:
|
||||||
|
raise OandaAPIError(
|
||||||
|
f"Oanda server error {resp.status_code}",
|
||||||
|
resp.status_code, resp.text)
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
raise OandaAPIError(
|
||||||
|
f"Oanda API error {resp.status_code}: {resp.text}",
|
||||||
|
resp.status_code, resp.text)
|
||||||
|
return resp
|
||||||
|
except (requests.ConnectionError, requests.Timeout,
|
||||||
|
OandaAPIError) as e:
|
||||||
|
last_err = e
|
||||||
|
if isinstance(e, OandaAPIError) and 400 <= e.status_code < 500:
|
||||||
|
raise
|
||||||
|
if attempt < retries:
|
||||||
|
logger.warning(
|
||||||
|
"Oanda request failed (attempt %d/%d): %s — retrying in %.0fs",
|
||||||
|
attempt + 1, 1 + retries, e,
|
||||||
|
self.RETRY_DELAY)
|
||||||
|
time.sleep(self.RETRY_DELAY)
|
||||||
|
raise OandaAPIError(f"Oanda request failed after retries: {last_err}")
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""
|
||||||
|
Per-pair signal producers.
|
||||||
|
|
||||||
|
Each pair module exposes:
|
||||||
|
make_config(primary_tf, confirmation_tfs) -> PlatformConfig
|
||||||
|
|
||||||
|
To add a new pair: create pair_signals/<pair>.py with make_config(),
|
||||||
|
then register it in this file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Optional, List, Dict, Callable
|
||||||
|
|
||||||
|
from config import PlatformConfig, Timeframe
|
||||||
|
from signal_aggregator import SignalAggregator, SignalReport
|
||||||
|
from providers import BaseDataProvider
|
||||||
|
|
||||||
|
PAIR_PRODUCERS: Dict[str, Callable] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def register_pair(symbol: str, config_factory: Callable):
|
||||||
|
PAIR_PRODUCERS[symbol] = config_factory
|
||||||
|
|
||||||
|
|
||||||
|
def get_supported_pairs() -> List[str]:
|
||||||
|
return list(PAIR_PRODUCERS.keys())
|
||||||
|
|
||||||
|
|
||||||
|
def produce_signal(
|
||||||
|
symbol: str,
|
||||||
|
primary_tf: Timeframe,
|
||||||
|
data_provider: BaseDataProvider,
|
||||||
|
confirmation_tfs: Optional[List[Timeframe]] = None,
|
||||||
|
) -> SignalReport:
|
||||||
|
config_factory = PAIR_PRODUCERS.get(symbol)
|
||||||
|
if config_factory is None:
|
||||||
|
raise ValueError(
|
||||||
|
f"No signal producer registered for {symbol}. "
|
||||||
|
f"Available: {list(PAIR_PRODUCERS.keys())}"
|
||||||
|
)
|
||||||
|
|
||||||
|
config = config_factory(primary_tf, confirmation_tfs)
|
||||||
|
aggregator = SignalAggregator(config)
|
||||||
|
aggregator.data_provider = data_provider
|
||||||
|
return aggregator.analyze_pair(symbol=symbol)
|
||||||
|
|
||||||
|
|
||||||
|
from pair_signals import xauusd as _xau_mod
|
||||||
|
from config import XAUUSD
|
||||||
|
|
||||||
|
register_pair(XAUUSD, _xau_mod.make_config)
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
"""XAU/USD signal producer config.
|
||||||
|
|
||||||
|
PlatformConfig defaults ARE the tuned XAU/USD configuration, so this
|
||||||
|
factory sets the timeframes plus documented per-timeframe overrides.
|
||||||
|
It must stay equivalent to the inline PlatformConfig(symbol=XAUUSD,
|
||||||
|
...) construction (plus those overrides) used by the backtester and
|
||||||
|
Telegram bot (enforced by a test in test_all.py).
|
||||||
|
|
||||||
|
M15 override (validated 2026-07-11, window 2025-03-30 -> 2026-05-31,
|
||||||
|
$0.30 spread, trailing exits): tp_floor_rr 1.0 scores +116.6R/460
|
||||||
|
trades PF 1.83 vs +105.4R/424 PF 1.82 at the 0.5 default — better in
|
||||||
|
both train/val halves and on the 2024-06 -> 2025-03 out-of-sample
|
||||||
|
window (+30.3R vs +10.6R; the 1R floor lifts targets past the min-TP
|
||||||
|
spread gate, roughly doubling trades there). The same knob HURTS M5
|
||||||
|
(pinned 12mo book: +131.3R/699 vs +139.9R/547, PF 1.55 vs 1.78), so it
|
||||||
|
is per-timeframe, exactly as PENDING_STEPS' decision rule anticipated.
|
||||||
|
|
||||||
|
M15 override 2 (validated 2026-07-11, 12mo window 2025-07-11 ->
|
||||||
|
2026-07-11 + OOS 2024-06-01 -> 2025-03-30, $0.30 spread): block the
|
||||||
|
mixed "trending" regime (ADX strong but EMAs unaligned — momentum
|
||||||
|
without directional structure). Those trades lost -6.8R/24 on 12mo,
|
||||||
|
negative in BOTH halves; blocking scores +65.7R/499 PF 1.38 DD 10.4R
|
||||||
|
vs +59.0R/521 PF 1.32 DD 12.6R baseline, better in both halves. OOS
|
||||||
|
is neutral (-1.4R = the 4 such trades that existed there; no power to
|
||||||
|
confirm or refute). M5 never trades this regime (its gates already
|
||||||
|
exclude it), so the block is M15-only. A second candidate — blocking
|
||||||
|
transitioning-regime SELLs (-4.0R/47 on 12mo) — was REJECTED: those
|
||||||
|
sells made +5.7R/32 in the 2024 OOS window (bull-market artifact).
|
||||||
|
|
||||||
|
M15 override 3 (validated 2026-07-16, 5-year replay 2021-07-17 ->
|
||||||
|
2026-07-16 under the audit-fixed backtester, $0.30 spread, trailing
|
||||||
|
exits): block SELL entries whose bar CLOSES at 15:00 or 17:00 UTC
|
||||||
|
(sell_blocked_hours_utc=[15, 17], gate 6g3 — BUYs unaffected; those
|
||||||
|
hours flank the already-blocked hour 16). The pocket lost -33.5R/165
|
||||||
|
trades, negative in 6/6 calendar years and in BOTH halves of the last
|
||||||
|
12 months (H1 -6.8R, H2 -4.2R); each hour independently is 5/6 years
|
||||||
|
negative with both halves negative (hours 14/18 fail that screen and
|
||||||
|
stay open). Cross-TF the M5 book CONFIRMS (same pocket -14.1R/59,
|
||||||
|
5/6 years negative) rather than contradicts — unlike the rejected
|
||||||
|
trending_down block. Full replay with the gate: +176.7R/1627 PF 1.31
|
||||||
|
DD 29.1R vs +144.9R/1781 PF 1.22 DD 34.4R control; better in ALL SIX
|
||||||
|
years; last-12mo halves both improve (H1 +36.3 vs +29.5, H2 +46.9 vs
|
||||||
|
+43.2; 12mo PF 1.55 vs 1.43); OOS 2024-06-01 -> 2025-03-30 improves
|
||||||
|
(+45.4R PF 1.47 DD 17.8 vs +41.9R PF 1.39 DD 21.7). SELL side goes
|
||||||
|
-1.2R -> +30.6R over 5 years. BUYs at those hours made +34.3R, hence
|
||||||
|
SELL-only rather than a blocked_hours_utc entry. Mechanism: NY-session
|
||||||
|
shorts against gold's macro uptrend (15:00 = post-US-data reversal
|
||||||
|
window, 17:00 = London close unwind).
|
||||||
|
|
||||||
|
M5 override 2 (2026-07-16, chart patterns): enable BUY-only CP on M5
|
||||||
|
(inv_hs, double_bottom, bull_flag — drop all bearish patterns). SELL CP
|
||||||
|
is consistently negative (OOS -3.21R, 5yr +3.76R PF 1.07) while BUY CP
|
||||||
|
is positive across windows: 12mo +41.8R PF 1.60 (train +9.66 PF 1.30,
|
||||||
|
val +32.17 PF 1.86), 5yr +65.2R PF 1.48, OOS +1.20R (aggregate
|
||||||
|
positive, val half -1.89R — marginal but sign-correct in aggregate).
|
||||||
|
Gold's structural uptrend makes bullish patterns fundamentally more
|
||||||
|
reliable. M15/H1 fail on all windows; M5-only enablement.
|
||||||
|
|
||||||
|
M5 override 3 (validated 2026-07-21, 5-year replay 2021-07-21 ->
|
||||||
|
2026-07-21, $0.30 spread, next-open fills): trailing distance widened
|
||||||
|
to 0.85R (trail_dist_r 0.85, arm unchanged 0.60). Same 30-cell grid
|
||||||
|
methodology as the M15 campaign, opposite finding — on M5 the arm
|
||||||
|
point barely matters; the winners need a looser leash. Plateau arm
|
||||||
|
0.5-0.7 x dist 0.85-1.0 (+227..+234R vs +218.0R baseline). Chosen
|
||||||
|
0.60/0.85 (OOS-preferred end of the plateau): +231.2R/2028 PF 1.30 WR
|
||||||
|
57.1% vs +218.0R PF 1.29 WR 58.3%; both last-12mo halves better
|
||||||
|
(+56.8/+115.1 vs +54.0/+104.6); 5yr max DD 19.2R vs 19.1R (noise);
|
||||||
|
OOS 2024-06-01 -> 2025-03-30 +14.5R DD 15.1 vs +11.1R DD 15.4.
|
||||||
|
dist 1.0 was REJECTED at the OOS gate (+10.9R < baseline +11.1R)
|
||||||
|
despite the best in-sample total (+233.3R) — the OOS ordering picks
|
||||||
|
the conservative plateau edge. XAU_PB trades are unaffected (they
|
||||||
|
carry their own validated pb_trail 0.8/0.75 per-trade overrides);
|
||||||
|
XAU_CP trades share the global params and were validated inside this
|
||||||
|
book. M15 arms at 0.70 (its own campaign, same day); the divergence
|
||||||
|
is the per-TF pattern yet again.
|
||||||
|
|
||||||
|
M15 override 4 (validated 2026-07-21, 5-year replay 2021-07-21 ->
|
||||||
|
2026-07-21, $0.30 spread, next-open fills): trailing-stop arm raised to
|
||||||
|
0.70R (trail_arm_r 0.70, dist unchanged 0.75). Full grid arm 0.3-0.9 x
|
||||||
|
dist 0.5-1.0 (30 cells): every earlier-arm cell (0.3/0.45) is WORSE —
|
||||||
|
the "arm earlier to save fading trades" hypothesis is refuted; the
|
||||||
|
plateau is arm 0.70-0.80 x dist 0.70-0.85 (all >= +190R). Chosen
|
||||||
|
plateau center 0.70/0.75: +210.7R/1629 PF 1.35 WR 57.8% vs baseline
|
||||||
|
0.60/0.75 +175.7R PF 1.30 WR 53.9%; both last-12mo halves improve
|
||||||
|
(+49.7/+50.0 vs +36.4/+46.0); per-year better 4/6 (2022 -0.9R, 2023
|
||||||
|
-1.4R, both within tolerance); OOS 2024-06-01 -> 2025-03-30 +57.4R
|
||||||
|
PF 1.57 DD 17.0R vs +45.4R PF 1.47 DD 17.8R — better on every OOS
|
||||||
|
metric. One clause waived BY OWNER DECISION 2026-07-21: 5yr max DD is
|
||||||
|
33.9R vs 29.1R baseline (+4.8R deeper worst episode, 2022-23 chop),
|
||||||
|
accepted in exchange for +20% total R. M5 keeps the global 0.6 arm
|
||||||
|
(same per-TF divergence pattern as tp_floor_rr and the hour-9 block).
|
||||||
|
|
||||||
|
M5 override (validated 2026-07-11, full-history replay 2020-03-17 ->
|
||||||
|
2026-07-11 + 12mo book, $0.30 spread): block hour 9 UTC (post-London-
|
||||||
|
open whipsaw). Hour-9 entries lost -28.9R/143 over 6 years, negative
|
||||||
|
in 5/7 calendar years including winning years (2022 -9.2R). Blocking:
|
||||||
|
+207.1R vs +178.3R baseline on the 6-year replay, max DD 28.7R vs
|
||||||
|
35.5R, improves 5/7 years (2023 goes -13.0R -> breakeven); the tuned
|
||||||
|
12mo book is unchanged (+139.0R vs +139.3R). M15-and-up keep hour 9:
|
||||||
|
it is POSITIVE there (M15 6yr +1.7R, M15 OOS +8.6R), so the block is
|
||||||
|
M5-only, same per-timeframe pattern as the M15 overrides.
|
||||||
|
|
||||||
|
M5 override 2 — relaxation campaign (validated 2026-07-30, Oanda
|
||||||
|
bars, $0.30 spread, trailing exits): UNBLOCK hour 21 UTC on M5 only,
|
||||||
|
combined with the config.py M5 gate relaxations (m5_min_abs_score
|
||||||
|
30->25, m5_min_confirmation_score 10->5). The combined config beat
|
||||||
|
the pre-relaxation control in ALL 6 calendar years of the 5yr replay
|
||||||
|
2021-07-30 -> 2026-07-30 (+230.9R/2319 PF 1.26 DD 21.3 vs
|
||||||
|
+168.0R/2048 PF 1.21 DD 27.0), on 12mo (+107.4R PF 1.36, halves
|
||||||
|
+68.6/+38.8 vs +84.6R PF 1.33), on OOS 2024-06-01 -> 2025-03-30
|
||||||
|
(+26.1R vs +17.2R), and at $0.70 spread (+86.9R vs +66.4R). Hour-21
|
||||||
|
trades alone: +28.0R/22, positive every year — overturning the old
|
||||||
|
block per the full-evidence rule. Hour 21 stays BLOCKED on M15+
|
||||||
|
(12mo M15 unblock tested -0.3R). Declined-but-open from the same
|
||||||
|
campaign: an M5 hour-22 block (+4.8R on 12mo — the burst-heavy
|
||||||
|
"institutional" hour — but -3.2R on 5yr and -1.1R on OOS: regime-
|
||||||
|
specific); M15 hour-16 unblock (+0.8R/+57 trades 12mo but -18.3R
|
||||||
|
DD +34% on 5yr); every SELL-gate/vol/TP-size relaxation retested
|
||||||
|
negative (see memory notes for the full grid).
|
||||||
|
|
||||||
|
M5 override 4 — XAU_PB disabled (validated 2026-08-04, Oanda bars,
|
||||||
|
$0.65 REAL spread — first campaign validated at measured cost on the
|
||||||
|
live feed, per the 2026-08-04 re-pin baseline): m5_pullback_enabled
|
||||||
|
False on M5. Loss forensics on the Oanda books identified XAU_PB as
|
||||||
|
the largest wrong-direction loss source (12mo: -43.5R of the -156.6R
|
||||||
|
wrong-direction bucket; 5yr: -274.5R). Disabling: 12mo +96.6R/588
|
||||||
|
PF 1.44 max DD 13.9R halves +51.2/+45.4 vs +82.3R/788 PF 1.26 DD
|
||||||
|
23.9R shipped (which FAILS the PF>=1.3 clause at real cost); OOS
|
||||||
|
2024-06-01 -> 2025-03-30 +9.5R PF 1.34; 5yr +123.9R/956 PF 1.35 vs
|
||||||
|
PF 1.16 with PB. PB's 5yr net contribution (+23.6R) was almost
|
||||||
|
entirely the 2022 chop year (+36.1R vs -2.2R without) — a regime
|
||||||
|
earner that has been a drag on trend-regime bars since; its tight
|
||||||
|
stops are also the most cost-punished at $0.65. PB code and the
|
||||||
|
pb_trail_* params STAY for future regime-gated experiments (same
|
||||||
|
pattern as cp_enabled). Rejected in the same campaign (4 candidates
|
||||||
|
total, N logged): hour-19 block (+3.7R 12mo but -3.3R OOS, -1.5R
|
||||||
|
5yr — OOS gate, the classic trap); hours-14/15 block (-6.9R 12mo
|
||||||
|
once PB is removed — their raw negativity WAS the PB losses);
|
||||||
|
trail_dist_r 0.75 (-2.6R 12mo; the 0.88R average give-back is the
|
||||||
|
validated price of riding trends, exits confirmed NOT the loss
|
||||||
|
driver: exit-management accounts for 1-2% of lost R vs 50%
|
||||||
|
wrong-direction / 49% entry-timing).
|
||||||
|
|
||||||
|
M5 override 5 — EMA partial-alignment squash (validated 2026-08-04,
|
||||||
|
Oanda bars, $0.65 real spread): ema_partial_neutral True on M5 —
|
||||||
|
partial 4-EMA ribbon alignment (2-3 of 4 checks) scores NEUTRAL
|
||||||
|
instead of a weak BUY/SELL. Motivated by the indicator audit: partial-
|
||||||
|
alignment trades averaged -0.008R/84 vs +0.230R/341 fully aligned.
|
||||||
|
Replay (knob mirrored in the backtester's precomputed EMA scorer in
|
||||||
|
the same commit — live==backtest parity): 12mo +97.6R/556 PF 1.48
|
||||||
|
DD 10.7R halves +47.8/+49.8 vs control +96.6R/588 PF 1.44 DD 13.9R;
|
||||||
|
5yr +127.4R/912 PF 1.38 DD 12.8R vs +123.9R/956 PF 1.35 DD 13.2R,
|
||||||
|
2022 improves -2.2 -> -1.1; OOS 2024-06-01 -> 2025-03-30 +9.4R
|
||||||
|
PF 1.33 vs +9.5R PF 1.34 — a one-trade tie, documented as such (the
|
||||||
|
OOS window holds up; it neither prefers nor punishes). Net effect:
|
||||||
|
32 fewer marginal trades, every risk-adjusted measure better.
|
||||||
|
Rejected in the same round (trial-logged): confirmation-TF swap
|
||||||
|
M5->[M15,H4] (12mo +104.0R PF 1.51 DD 10.3 but OOS +7.8R PF 1.27
|
||||||
|
vs control +9.5R PF 1.34 and 2022 3x worse — the H4 confirm keeps
|
||||||
|
endorsing stale direction in chop; OOS-ordering rejection, same
|
||||||
|
precedent as trail dist 1.0); confirm [H1,H4] (-31.8R 12mo);
|
||||||
|
triple-confirm [M15,H1,H4] (-4.9R); divergence weight -1/3
|
||||||
|
(-3.8R 12mo); candlestick weight +50% (-3.0R 12mo). The weight
|
||||||
|
vector keeps proving calibrated as a system: divergence/candlestick/
|
||||||
|
bollinger all look mis-weighted in-book and all replay worse when
|
||||||
|
"fixed" (bollinger weight-0: +80.9R PF 1.29 DD 23.8 — its constant
|
||||||
|
oppose vote is a load-bearing quality bar, like ROC).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Optional, List
|
||||||
|
from config import PlatformConfig, Timeframe
|
||||||
|
from app_config import apply_pair_overrides
|
||||||
|
|
||||||
|
_CONFIRMATIONS = {
|
||||||
|
Timeframe.M5: [Timeframe.M15, Timeframe.H1],
|
||||||
|
Timeframe.M15: [Timeframe.M5, Timeframe.H1],
|
||||||
|
Timeframe.H1: [Timeframe.M15, Timeframe.H4],
|
||||||
|
Timeframe.H4: [Timeframe.H1, Timeframe.D1],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def make_config(
|
||||||
|
primary_tf: Timeframe,
|
||||||
|
confirmation_tfs: Optional[List[Timeframe]] = None,
|
||||||
|
) -> PlatformConfig:
|
||||||
|
config = PlatformConfig()
|
||||||
|
# Deployment overlay (config.json) BEFORE the validated per-TF
|
||||||
|
# deltas below, so e.g. the M5 hour-9 append lands on the JSON base.
|
||||||
|
apply_pair_overrides(config)
|
||||||
|
config.primary_timeframe = primary_tf
|
||||||
|
config.confirmation_timeframes = (
|
||||||
|
confirmation_tfs or _CONFIRMATIONS.get(primary_tf, [Timeframe.M15, Timeframe.H4])
|
||||||
|
)
|
||||||
|
if primary_tf == Timeframe.M5:
|
||||||
|
# Block hour 9 UTC on M5 only; UNBLOCK hour 21 on M5 only
|
||||||
|
# (positive every year of the 5yr replay) — see docstring.
|
||||||
|
config.xau_strategy.blocked_hours_utc = [
|
||||||
|
h for h in config.xau_strategy.blocked_hours_utc if h != 21
|
||||||
|
] + [9]
|
||||||
|
# BUY-only chart patterns on M5 — see module docstring.
|
||||||
|
config.xau_strategy.cp_enabled = True
|
||||||
|
config.xau_strategy.cp_patterns = ["inv_hs", "double_bottom", "bull_flag"]
|
||||||
|
# XAU_PB pullback entries OFF — largest wrong-direction loss
|
||||||
|
# source at real cost on Oanda bars — see module docstring.
|
||||||
|
config.xau_strategy.m5_pullback_enabled = False
|
||||||
|
# Partial EMA-ribbon alignment scores NEUTRAL on M5 — its
|
||||||
|
# trades carried no edge — see module docstring.
|
||||||
|
config.technical.ema_partial_neutral = True
|
||||||
|
# Wider trailing distance on M5 only — see module docstring.
|
||||||
|
config.signals.trail_arm_r = 0.60
|
||||||
|
config.signals.trail_dist_r = 0.85
|
||||||
|
if primary_tf == Timeframe.M15:
|
||||||
|
# 1R minimum target on M15 only — see module docstring.
|
||||||
|
config.signals.tp_floor_rr = 1.0
|
||||||
|
# Block the mixed "trending" regime on M15 only — see docstring.
|
||||||
|
config.xau_strategy.blocked_regimes = ["trending"]
|
||||||
|
# Block SELL entries at 15:00/17:00 UTC on M15 only — see docstring.
|
||||||
|
config.xau_strategy.sell_blocked_hours_utc = [15, 17]
|
||||||
|
# Later trailing-stop arm on M15 only — see module docstring.
|
||||||
|
config.signals.trail_arm_r = 0.70
|
||||||
|
config.signals.trail_dist_r = 0.75
|
||||||
|
return config
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""
|
||||||
|
ForexBot2 — Data-provider package
|
||||||
|
==================================
|
||||||
|
One class per provider, all subclassing `BaseDataProvider` (which owns
|
||||||
|
caching, validation, boundary guarantees, and range pagination — the
|
||||||
|
behavior the signal pipeline depends on).
|
||||||
|
|
||||||
|
Adding a provider = one module implementing `_fetch_raw` + one registry
|
||||||
|
entry + one config.json `data_providers` block. The live candle feed is
|
||||||
|
selected by config.json `data_providers.active`; the optional
|
||||||
|
`data_providers.streaming` scalar selects a different provider for the
|
||||||
|
tick stream (level-breach monitor). Registration alone changes nothing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Dict, Optional
|
||||||
|
|
||||||
|
from app_config import AppConfigError, get_app_config
|
||||||
|
from config import PlatformConfig
|
||||||
|
|
||||||
|
from providers.base import BaseDataProvider, DataIntegrityError, _is_forex_weekend
|
||||||
|
from providers.twelve_data import TwelveDataProvider
|
||||||
|
from providers.oanda import OandaDataError, OandaDataProvider
|
||||||
|
from providers.tiingo import TiingoDataError, TiingoDataProvider
|
||||||
|
|
||||||
|
#: config.json data_providers.active selects from here.
|
||||||
|
PROVIDER_REGISTRY: Dict[str, type] = {
|
||||||
|
"twelve_data": TwelveDataProvider,
|
||||||
|
"oanda": OandaDataProvider,
|
||||||
|
"tiingo": TiingoDataProvider,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def make_data_provider(config: PlatformConfig,
|
||||||
|
name: Optional[str] = None) -> BaseDataProvider:
|
||||||
|
"""Build a provider by name (default: config.json data_providers.active)."""
|
||||||
|
if name is None:
|
||||||
|
name = get_app_config().active_provider
|
||||||
|
cls = PROVIDER_REGISTRY.get(name)
|
||||||
|
if cls is None:
|
||||||
|
raise AppConfigError(
|
||||||
|
f'Unknown data provider "{name}"; '
|
||||||
|
f"registered: {sorted(PROVIDER_REGISTRY)}")
|
||||||
|
return cls(config)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"BaseDataProvider",
|
||||||
|
"DataIntegrityError",
|
||||||
|
"OandaDataError",
|
||||||
|
"OandaDataProvider",
|
||||||
|
"TiingoDataError",
|
||||||
|
"TiingoDataProvider",
|
||||||
|
"TwelveDataProvider",
|
||||||
|
"PROVIDER_REGISTRY",
|
||||||
|
"make_data_provider",
|
||||||
|
"_is_forex_weekend",
|
||||||
|
]
|
||||||
@@ -0,0 +1,423 @@
|
|||||||
|
"""
|
||||||
|
ForexBot2 — Provider base class
|
||||||
|
================================
|
||||||
|
`BaseDataProvider` owns everything that must behave identically no matter
|
||||||
|
where the bars come from: in-memory caching with candle-duration TTL, the
|
||||||
|
closed-bar boundary guarantee, freshness checks, validation/cleaning, the
|
||||||
|
forming-candle drop, and paginated date-range fetching for the backtester.
|
||||||
|
|
||||||
|
A concrete provider implements only:
|
||||||
|
_fetch_raw(symbol, timeframe, outputsize, start, end) -> pd.DataFrame
|
||||||
|
returning a normalized frame (capitalized OHLCV columns, UTC tz-aware
|
||||||
|
DatetimeIndex named "Datetime", oldest-first), plus optionally
|
||||||
|
`get_spread` and the streaming-quote capability (`supports_streaming` +
|
||||||
|
`iter_ticks`).
|
||||||
|
|
||||||
|
The signal pipeline (engine + aggregator + backtester) consumes ONLY the
|
||||||
|
public contract defined here, so swapping providers never touches signal
|
||||||
|
code.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import AsyncIterator, Callable, Dict, Optional, Sequence, Tuple
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from config import PAIR_INFO, PlatformConfig, Timeframe, XAUUSD
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_forex_weekend(now=None) -> bool:
|
||||||
|
"""Forex market closed: Friday 22:00 UTC → Sunday 22:00 UTC."""
|
||||||
|
if now is None:
|
||||||
|
now = pd.Timestamp.now(tz="UTC")
|
||||||
|
wd = now.weekday()
|
||||||
|
hour = now.hour
|
||||||
|
if wd == 4 and hour >= 22:
|
||||||
|
return True
|
||||||
|
if wd == 5:
|
||||||
|
return True
|
||||||
|
if wd == 6 and hour < 22:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class DataIntegrityError(Exception):
|
||||||
|
"""Raised when data fails validation checks."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class BaseDataProvider(ABC):
|
||||||
|
"""Shared OHLCV provider behavior; subclasses supply the raw fetch."""
|
||||||
|
|
||||||
|
#: Registry name; overridden per provider.
|
||||||
|
name: str = "base"
|
||||||
|
|
||||||
|
#: Exception type this provider raises for fetch/validation failures.
|
||||||
|
#: Provider-specific errors subclass DataIntegrityError so consumers
|
||||||
|
#: can catch one type regardless of the active provider.
|
||||||
|
DATA_ERROR = DataIntegrityError
|
||||||
|
|
||||||
|
BARS_PER_DAY = {
|
||||||
|
Timeframe.M1: 1440, Timeframe.M5: 288, Timeframe.M15: 96,
|
||||||
|
Timeframe.M30: 48, Timeframe.H1: 24, Timeframe.H4: 6,
|
||||||
|
Timeframe.D1: 1, Timeframe.W1: 0.143,
|
||||||
|
}
|
||||||
|
|
||||||
|
CANDLE_SECONDS = {
|
||||||
|
Timeframe.M1: 60, Timeframe.M5: 300, Timeframe.M15: 900,
|
||||||
|
Timeframe.M30: 1800, Timeframe.H1: 3600, Timeframe.H4: 14400,
|
||||||
|
Timeframe.D1: 86400, Timeframe.W1: 604800,
|
||||||
|
}
|
||||||
|
|
||||||
|
#: Hard per-request bar cap shared by Twelve Data and Oanda.
|
||||||
|
MAX_BARS_PER_REQUEST = 5000
|
||||||
|
|
||||||
|
# Grace wait before the one retry when a fresh response at a candle
|
||||||
|
# boundary is missing the just-closed bar (feed latency).
|
||||||
|
FRESH_FETCH_RETRY_SECONDS = 3.0
|
||||||
|
|
||||||
|
def __init__(self, config: PlatformConfig):
|
||||||
|
self.config = config
|
||||||
|
self._cache: Dict[str, pd.DataFrame] = {}
|
||||||
|
self._cache_timestamps: Dict[str, float] = {}
|
||||||
|
# The bot shares one provider across a thread pool; cache eviction
|
||||||
|
# is check-then-act and needs locking.
|
||||||
|
self._cache_lock = threading.Lock()
|
||||||
|
|
||||||
|
# ── Abstract: the only provider-specific piece ─────────────────
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def _fetch_raw(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
timeframe: Timeframe,
|
||||||
|
outputsize: int,
|
||||||
|
start: Optional[datetime] = None,
|
||||||
|
end: Optional[datetime] = None,
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
"""Fetch up to `outputsize` bars, optionally bounded by start/end
|
||||||
|
(tz-aware UTC datetimes). Must return a normalized frame:
|
||||||
|
capitalized OHLCV columns, UTC DatetimeIndex named "Datetime",
|
||||||
|
oldest-first. Raise self.DATA_ERROR on failure."""
|
||||||
|
|
||||||
|
# ── Public API ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def fetch_ohlcv(
|
||||||
|
self,
|
||||||
|
symbol: str = XAUUSD,
|
||||||
|
timeframe: Timeframe = Timeframe.H1,
|
||||||
|
lookback_days: Optional[int] = None,
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
"""Fetch OHLCV with in-memory caching for live mode."""
|
||||||
|
if lookback_days is None:
|
||||||
|
lookback_days = self.config.data_lookback_days
|
||||||
|
|
||||||
|
cache_key = f"{symbol}_{timeframe.value}"
|
||||||
|
now = time.time()
|
||||||
|
|
||||||
|
cache_ttl = self.CANDLE_SECONDS.get(timeframe, self.config.refresh_interval)
|
||||||
|
with self._cache_lock:
|
||||||
|
cached = self._cache.get(cache_key)
|
||||||
|
cached_at = self._cache_timestamps.get(cache_key)
|
||||||
|
if (
|
||||||
|
cached is not None
|
||||||
|
and cached_at is not None
|
||||||
|
and (now - cached_at) < cache_ttl
|
||||||
|
and not cached.empty
|
||||||
|
):
|
||||||
|
if (self._is_data_fresh(cached, symbol, timeframe)
|
||||||
|
and self._has_latest_closed_bar(cached, timeframe)):
|
||||||
|
return cached
|
||||||
|
logger.warning(f"{symbol} {timeframe.value}: cached data is stale, refetching")
|
||||||
|
with self._cache_lock:
|
||||||
|
self._cache.pop(cache_key, None)
|
||||||
|
self._cache_timestamps.pop(cache_key, None)
|
||||||
|
|
||||||
|
outputsize = max(
|
||||||
|
300,
|
||||||
|
min(int(lookback_days * self.BARS_PER_DAY[timeframe]) + 20,
|
||||||
|
self.MAX_BARS_PER_REQUEST),
|
||||||
|
)
|
||||||
|
df = self._fetch_raw(symbol, timeframe, outputsize)
|
||||||
|
df = self._drop_forming_candle(df, timeframe)
|
||||||
|
df = self._validate_and_clean(df, symbol, timeframe)
|
||||||
|
|
||||||
|
# The closed-bar guarantee must hold for FRESH responses too, not
|
||||||
|
# just cache hits: the scanner fires seconds after the boundary,
|
||||||
|
# and a lagging feed can answer without the just-closed bar — the
|
||||||
|
# scan would silently analyze (and session-check) the previous
|
||||||
|
# bar. Give the feed one short grace retry, then proceed loudly.
|
||||||
|
if not df.empty and not self._has_latest_closed_bar(df, timeframe):
|
||||||
|
time.sleep(self.FRESH_FETCH_RETRY_SECONDS)
|
||||||
|
try:
|
||||||
|
retry = self._fetch_raw(symbol, timeframe, outputsize)
|
||||||
|
retry = self._drop_forming_candle(retry, timeframe)
|
||||||
|
retry = self._validate_and_clean(retry, symbol, timeframe)
|
||||||
|
if retry is not None and not retry.empty:
|
||||||
|
df = retry
|
||||||
|
except DataIntegrityError as e:
|
||||||
|
logger.warning(
|
||||||
|
f"{symbol} {timeframe.value}: boundary retry refetch "
|
||||||
|
f"failed ({e}) — keeping original data")
|
||||||
|
if not self._has_latest_closed_bar(df, timeframe):
|
||||||
|
logger.warning(
|
||||||
|
f"{symbol} {timeframe.value}: feed lagging — just-closed bar "
|
||||||
|
f"still missing after retry; this scan may run one bar behind"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not self._is_data_fresh(df, symbol, timeframe):
|
||||||
|
logger.warning(
|
||||||
|
f"STALE DATA from API: {symbol} {timeframe.value} — "
|
||||||
|
f"last bar {df.index[-1]} is too old. Signal quality may be degraded."
|
||||||
|
)
|
||||||
|
|
||||||
|
with self._cache_lock:
|
||||||
|
self._cache[cache_key] = df
|
||||||
|
self._cache_timestamps[cache_key] = now
|
||||||
|
return df
|
||||||
|
|
||||||
|
def fetch_range(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
timeframe: Timeframe,
|
||||||
|
start: datetime,
|
||||||
|
end: datetime,
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
"""Fetch the RAW closed-bar series between start/end, paginating
|
||||||
|
under the per-request bar cap. Returns uncleaned bars (the
|
||||||
|
backtester resolves trades on the raw price path); callers wanting
|
||||||
|
the signal-side series run `validate_and_clean` on the result."""
|
||||||
|
bars_per_day = self.BARS_PER_DAY[timeframe]
|
||||||
|
days_per_chunk = max(1, int(4500 / bars_per_day))
|
||||||
|
|
||||||
|
chunks = []
|
||||||
|
cursor_end = end
|
||||||
|
empty_streak = 0
|
||||||
|
while cursor_end > start:
|
||||||
|
cursor_start = max(start, cursor_end - timedelta(days=days_per_chunk))
|
||||||
|
try:
|
||||||
|
chunk = self._fetch_raw(
|
||||||
|
symbol, timeframe, self.MAX_BARS_PER_REQUEST,
|
||||||
|
start=cursor_start, end=cursor_end,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
# Providers raise for windows with no data (weekends,
|
||||||
|
# holidays, asset gaps). Skip the window, step back, retry
|
||||||
|
# — but bail after a few consecutive empties.
|
||||||
|
logger.warning(
|
||||||
|
f"Chunk {cursor_start.date()}–{cursor_end.date()} failed: {e}"
|
||||||
|
)
|
||||||
|
empty_streak += 1
|
||||||
|
if empty_streak >= 3:
|
||||||
|
break
|
||||||
|
cursor_end = cursor_start - timedelta(seconds=1)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if chunk is None or chunk.empty:
|
||||||
|
empty_streak += 1
|
||||||
|
if empty_streak >= 3:
|
||||||
|
break
|
||||||
|
cursor_end = cursor_start - timedelta(seconds=1)
|
||||||
|
continue
|
||||||
|
|
||||||
|
empty_streak = 0
|
||||||
|
chunks.append(chunk)
|
||||||
|
# Step back; dedup on concat.
|
||||||
|
cursor_end = chunk.index[0] - timedelta(seconds=1)
|
||||||
|
if len(chunk) < 2:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not chunks:
|
||||||
|
raise RuntimeError(f"No data fetched for {symbol} {timeframe.value}")
|
||||||
|
|
||||||
|
df = pd.concat(chunks).sort_index()
|
||||||
|
df = df[~df.index.duplicated(keep="last")]
|
||||||
|
# Fetches ending "now" include the still-forming candle — drop it
|
||||||
|
# so callers only ever see closed bars.
|
||||||
|
df = self._drop_forming_candle(df, timeframe)
|
||||||
|
start_ts = pd.Timestamp(start)
|
||||||
|
end_ts = pd.Timestamp(end)
|
||||||
|
return df[(df.index >= start_ts) & (df.index <= end_ts)]
|
||||||
|
|
||||||
|
def is_data_fresh(
|
||||||
|
self, df: pd.DataFrame, symbol: str, timeframe: Timeframe
|
||||||
|
) -> bool:
|
||||||
|
"""Public freshness check (last bar within 3 candle durations)."""
|
||||||
|
return self._is_data_fresh(df, symbol, timeframe)
|
||||||
|
|
||||||
|
def validate_and_clean(
|
||||||
|
self, df: pd.DataFrame, symbol: str, timeframe: Timeframe
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
"""Public entry to the shared cleaning pipeline."""
|
||||||
|
return self._validate_and_clean(df, symbol, timeframe)
|
||||||
|
|
||||||
|
def clear_cache(self) -> int:
|
||||||
|
"""Wipe all cached data. Returns number of entries cleared."""
|
||||||
|
with self._cache_lock:
|
||||||
|
count = len(self._cache)
|
||||||
|
self._cache.clear()
|
||||||
|
self._cache_timestamps.clear()
|
||||||
|
logger.info(f"Cache cleared: {count} entries wiped")
|
||||||
|
return count
|
||||||
|
|
||||||
|
def get_pair_info(self, symbol: str = XAUUSD) -> Dict:
|
||||||
|
info = PAIR_INFO.get(symbol, PAIR_INFO[XAUUSD])
|
||||||
|
return {"symbol": symbol, **info}
|
||||||
|
|
||||||
|
def get_spread(self, symbol: str = XAUUSD) -> float:
|
||||||
|
"""Latest observed round-trip spread; providers without live
|
||||||
|
bid/ask fall back to the pair's assumed default."""
|
||||||
|
return PAIR_INFO.get(symbol, PAIR_INFO[XAUUSD])["default_spread"]
|
||||||
|
|
||||||
|
# ── Streaming-quote capability (optional) ──────────────────────
|
||||||
|
|
||||||
|
#: True when the provider can stream live ticks via `iter_ticks`.
|
||||||
|
supports_streaming: bool = False
|
||||||
|
|
||||||
|
def iter_ticks(
|
||||||
|
self,
|
||||||
|
symbols: Sequence[str],
|
||||||
|
on_connect: Optional[Callable[[], None]] = None,
|
||||||
|
) -> AsyncIterator[Tuple[str, float]]:
|
||||||
|
"""Async generator yielding (canonical_symbol, price) ticks for one
|
||||||
|
connection's lifetime; the caller owns reconnect/backoff. Providers
|
||||||
|
that support it set `supports_streaming = True` and override."""
|
||||||
|
raise NotImplementedError(
|
||||||
|
f"{self.name} provider does not support streaming quotes")
|
||||||
|
|
||||||
|
# ── Shared internals ───────────────────────────────────────────
|
||||||
|
|
||||||
|
def _has_latest_closed_bar(self, df: pd.DataFrame, timeframe: Timeframe) -> bool:
|
||||||
|
"""Cached data must include the most recent fully closed bar.
|
||||||
|
|
||||||
|
The TTL alone is wall-clock based: a snapshot fetched mid-candle is
|
||||||
|
still within TTL at the next candle boundary but is missing the bar
|
||||||
|
that just closed, so every boundary scan would analyze — and run the
|
||||||
|
bar-close session check against — the previous bar.
|
||||||
|
"""
|
||||||
|
if df is None or df.empty:
|
||||||
|
return False
|
||||||
|
now = pd.Timestamp.now(tz="UTC")
|
||||||
|
if _is_forex_weekend(now):
|
||||||
|
return True
|
||||||
|
candle_seconds = self.CANDLE_SECONDS.get(timeframe, 3600)
|
||||||
|
dur = pd.Timedelta(seconds=candle_seconds)
|
||||||
|
expected_label = now.floor(dur) - dur
|
||||||
|
return df.index[-1] >= expected_label
|
||||||
|
|
||||||
|
def _drop_forming_candle(self, df: pd.DataFrame, timeframe: Timeframe) -> pd.DataFrame:
|
||||||
|
"""Drop the still-forming last candle so analysis only sees closed bars.
|
||||||
|
|
||||||
|
Some feeds include the current partial candle; the scanner fires
|
||||||
|
seconds after the boundary, so without this guard every indicator
|
||||||
|
reads a seconds-old stub bar and signals repaint. Feeds that only
|
||||||
|
return complete candles pass through unchanged.
|
||||||
|
"""
|
||||||
|
if df is None or df.empty:
|
||||||
|
return df
|
||||||
|
candle_seconds = self.CANDLE_SECONDS.get(timeframe, 3600)
|
||||||
|
now = pd.Timestamp.now(tz="UTC")
|
||||||
|
if df.index[-1] + pd.Timedelta(seconds=candle_seconds) > now:
|
||||||
|
df = df.iloc[:-1]
|
||||||
|
return df
|
||||||
|
|
||||||
|
def _is_data_fresh(self, df: pd.DataFrame, symbol: str, timeframe: Timeframe) -> bool:
|
||||||
|
"""Check if the last candle is within acceptable age for this timeframe."""
|
||||||
|
if df is None or df.empty:
|
||||||
|
return False
|
||||||
|
|
||||||
|
now = pd.Timestamp.now(tz="UTC")
|
||||||
|
|
||||||
|
if _is_forex_weekend(now):
|
||||||
|
return True
|
||||||
|
|
||||||
|
last_bar = df.index[-1]
|
||||||
|
candle_seconds = self.CANDLE_SECONDS.get(timeframe, 3600)
|
||||||
|
max_age_seconds = candle_seconds * 3
|
||||||
|
age_seconds = (now - last_bar).total_seconds()
|
||||||
|
|
||||||
|
if age_seconds > max_age_seconds:
|
||||||
|
logger.warning(
|
||||||
|
f"{symbol} {timeframe.value}: last bar {last_bar.strftime('%Y-%m-%d %H:%M')} "
|
||||||
|
f"is {age_seconds / 60:.0f}m old (limit: {max_age_seconds / 60:.0f}m)"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _validate_and_clean(
|
||||||
|
self, df: pd.DataFrame, symbol: str, timeframe: Timeframe
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
if df is None or df.empty:
|
||||||
|
raise self.DATA_ERROR(f"Empty dataset for {symbol}")
|
||||||
|
|
||||||
|
original_len = len(df)
|
||||||
|
|
||||||
|
df = df.dropna(subset=["Open", "High", "Low", "Close"], how="all")
|
||||||
|
df = df.ffill(limit=3)
|
||||||
|
df = df.dropna(subset=["Close"])
|
||||||
|
|
||||||
|
df = df.sort_index()
|
||||||
|
df = df[~df.index.duplicated(keep="last")]
|
||||||
|
|
||||||
|
now = pd.Timestamp.utcnow()
|
||||||
|
future_mask = df.index > now + pd.Timedelta(minutes=5)
|
||||||
|
if future_mask.any():
|
||||||
|
logger.warning(f"{symbol}: Dropping {future_mask.sum()} future timestamps")
|
||||||
|
df = df[~future_mask]
|
||||||
|
|
||||||
|
invalid_ohlc = (
|
||||||
|
(df["High"] < df["Low"]) |
|
||||||
|
(df["High"] < df["Open"]) |
|
||||||
|
(df["High"] < df["Close"]) |
|
||||||
|
(df["Low"] > df["Open"]) |
|
||||||
|
(df["Low"] > df["Close"])
|
||||||
|
)
|
||||||
|
if invalid_ohlc.any():
|
||||||
|
logger.warning(f"{symbol}: Fixing {invalid_ohlc.sum()} OHLC-inconsistent bars")
|
||||||
|
for idx in df[invalid_ohlc].index:
|
||||||
|
vals = [df.loc[idx, "Open"], df.loc[idx, "Close"]]
|
||||||
|
df.loc[idx, "High"] = max(vals + [df.loc[idx, "High"]])
|
||||||
|
df.loc[idx, "Low"] = min(vals + [df.loc[idx, "Low"]])
|
||||||
|
|
||||||
|
returns = df["Close"].pct_change()
|
||||||
|
std_ret = returns.std()
|
||||||
|
if std_ret > 0:
|
||||||
|
dev = returns - returns.mean()
|
||||||
|
outlier_mask = dev.abs() > 10 * std_ret
|
||||||
|
# A spike bar makes the NEXT bar's return look extreme too (it is
|
||||||
|
# measured against the spike). If the previous bar is flagged and
|
||||||
|
# this bar reverses direction, it is the legitimate snap-back —
|
||||||
|
# keep it, or every spike would punch a two-bar hole in the data.
|
||||||
|
recovery = (
|
||||||
|
outlier_mask
|
||||||
|
& outlier_mask.shift(1, fill_value=False)
|
||||||
|
& (dev * dev.shift(1) < 0)
|
||||||
|
)
|
||||||
|
outlier_mask = outlier_mask & ~recovery
|
||||||
|
if outlier_mask.any():
|
||||||
|
logger.warning(f"{symbol}: Removing {outlier_mask.sum()} extreme outlier bars")
|
||||||
|
df = df[~outlier_mask]
|
||||||
|
|
||||||
|
min_required = max(
|
||||||
|
self.config.technical.ema_trend,
|
||||||
|
200,
|
||||||
|
) + 10
|
||||||
|
|
||||||
|
if len(df) < min_required:
|
||||||
|
logger.warning(
|
||||||
|
f"{symbol} {timeframe.value}: Only {len(df)} bars "
|
||||||
|
f"(need {min_required}). Some indicators will be partial."
|
||||||
|
)
|
||||||
|
|
||||||
|
cleaned = original_len - len(df)
|
||||||
|
if cleaned > 0:
|
||||||
|
logger.info(f"{symbol}: Cleaned {cleaned}/{original_len} bars")
|
||||||
|
|
||||||
|
return df
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
"""
|
||||||
|
ForexBot2 — Oanda v20 data provider
|
||||||
|
====================================
|
||||||
|
OHLCV candles from the Oanda v20 REST API. All shared behavior (cache,
|
||||||
|
validation, boundary guarantees, range pagination) lives in
|
||||||
|
`BaseDataProvider`; this module only maps symbols/granularities, parses
|
||||||
|
Oanda's candle JSON, and exposes the real bid/ask spread from MBA candles.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Dict, Optional
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from app_config import oanda_api_token, oanda_base_url
|
||||||
|
from config import PAIR_INFO, PlatformConfig, Timeframe, XAUUSD
|
||||||
|
|
||||||
|
from providers.base import BaseDataProvider, DataIntegrityError
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class OandaDataError(DataIntegrityError):
|
||||||
|
"""Raised when an Oanda data fetch fails or returns invalid data."""
|
||||||
|
|
||||||
|
|
||||||
|
class OandaDataProvider(BaseDataProvider):
|
||||||
|
"""Oanda v20 REST candle provider."""
|
||||||
|
|
||||||
|
name = "oanda"
|
||||||
|
DATA_ERROR = OandaDataError
|
||||||
|
|
||||||
|
OANDA_GRANULARITY = {
|
||||||
|
Timeframe.M1: "M1",
|
||||||
|
Timeframe.M5: "M5",
|
||||||
|
Timeframe.M15: "M15",
|
||||||
|
Timeframe.M30: "M30",
|
||||||
|
Timeframe.H1: "H1",
|
||||||
|
Timeframe.H4: "H4",
|
||||||
|
Timeframe.D1: "D",
|
||||||
|
Timeframe.W1: "W",
|
||||||
|
}
|
||||||
|
|
||||||
|
OANDA_INSTRUMENT = {
|
||||||
|
"XAU/USD": "XAU_USD",
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
config: PlatformConfig,
|
||||||
|
api_token: Optional[str] = None,
|
||||||
|
base_url: Optional[str] = None,
|
||||||
|
):
|
||||||
|
super().__init__(config)
|
||||||
|
# Credentials default to the config.json `oanda` block / env vars
|
||||||
|
# so the provider registry can build this class with config only.
|
||||||
|
if api_token is None:
|
||||||
|
api_token = oanda_api_token()
|
||||||
|
if base_url is None:
|
||||||
|
base_url = oanda_base_url()
|
||||||
|
self._base_url = base_url.rstrip("/")
|
||||||
|
self._session = requests.Session()
|
||||||
|
self._session.headers.update({
|
||||||
|
"Authorization": f"Bearer {api_token}",
|
||||||
|
"Accept-Datetime-Format": "RFC3339",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
})
|
||||||
|
self._spread_cache: Dict[str, float] = {}
|
||||||
|
|
||||||
|
# ── Public extras ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_spread(self, symbol: str = XAUUSD) -> float:
|
||||||
|
return self._spread_cache.get(
|
||||||
|
symbol, PAIR_INFO.get(symbol, PAIR_INFO[XAUUSD])["default_spread"])
|
||||||
|
|
||||||
|
# ── Raw fetch ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _fetch_raw(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
timeframe: Timeframe,
|
||||||
|
outputsize: int,
|
||||||
|
start: Optional[datetime] = None,
|
||||||
|
end: Optional[datetime] = None,
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
if start is not None or end is not None:
|
||||||
|
return self._fetch_oanda_candles(
|
||||||
|
symbol, timeframe, outputsize, start, end)
|
||||||
|
return self._fetch_oanda_candles(symbol, timeframe, outputsize)
|
||||||
|
|
||||||
|
def _to_oanda_instrument(self, symbol: str) -> str:
|
||||||
|
if symbol in self.OANDA_INSTRUMENT:
|
||||||
|
return self.OANDA_INSTRUMENT[symbol]
|
||||||
|
return symbol.replace("/", "_")
|
||||||
|
|
||||||
|
def _fetch_oanda_candles(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
timeframe: Timeframe,
|
||||||
|
count: int,
|
||||||
|
start: Optional[datetime] = None,
|
||||||
|
end: Optional[datetime] = None,
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
instrument = self._to_oanda_instrument(symbol)
|
||||||
|
granularity = self.OANDA_GRANULARITY.get(timeframe)
|
||||||
|
if granularity is None:
|
||||||
|
raise OandaDataError(
|
||||||
|
f"Unsupported timeframe {timeframe} for Oanda")
|
||||||
|
|
||||||
|
url = f"{self._base_url}/v3/instruments/{instrument}/candles"
|
||||||
|
params = {
|
||||||
|
"granularity": granularity,
|
||||||
|
"price": "MBA",
|
||||||
|
"dailyAlignment": 0,
|
||||||
|
"alignmentTimezone": "Etc/UTC",
|
||||||
|
}
|
||||||
|
# v20 rejects `count` combined with both `from` and `to`; a date
|
||||||
|
# window replaces it (used by fetch_range pagination).
|
||||||
|
if start is not None and end is not None:
|
||||||
|
params["from"] = pd.Timestamp(start).isoformat()
|
||||||
|
params["to"] = pd.Timestamp(end).isoformat()
|
||||||
|
else:
|
||||||
|
params["count"] = min(count, self.MAX_BARS_PER_REQUEST)
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = self._session.get(url, params=params, timeout=30)
|
||||||
|
except requests.RequestException as e:
|
||||||
|
raise OandaDataError(
|
||||||
|
f"Oanda candle fetch failed for {instrument}: {e}") from e
|
||||||
|
|
||||||
|
if resp.status_code != 200:
|
||||||
|
raise OandaDataError(
|
||||||
|
f"Oanda candle fetch failed for {instrument}: "
|
||||||
|
f"HTTP {resp.status_code}: {resp.text[:300]}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = resp.json()
|
||||||
|
except ValueError as e:
|
||||||
|
raise OandaDataError(
|
||||||
|
f"Oanda candle response for {instrument} is not valid "
|
||||||
|
f"JSON: {e}") from e
|
||||||
|
candles = data.get("candles", [])
|
||||||
|
if not candles:
|
||||||
|
raise OandaDataError(
|
||||||
|
f"Empty candle response from Oanda for {instrument}")
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
last_bid_close = None
|
||||||
|
last_ask_close = None
|
||||||
|
for c in candles:
|
||||||
|
if not c.get("complete", False):
|
||||||
|
continue
|
||||||
|
mid = c.get("mid", {})
|
||||||
|
bid = c.get("bid", {})
|
||||||
|
ask = c.get("ask", {})
|
||||||
|
rows.append({
|
||||||
|
"Datetime": pd.Timestamp(c["time"]).tz_convert("UTC"),
|
||||||
|
"Open": float(mid.get("o", 0)),
|
||||||
|
"High": float(mid.get("h", 0)),
|
||||||
|
"Low": float(mid.get("l", 0)),
|
||||||
|
"Close": float(mid.get("c", 0)),
|
||||||
|
"Volume": int(c.get("volume", 0)),
|
||||||
|
})
|
||||||
|
if bid and ask:
|
||||||
|
last_bid_close = float(bid.get("c", 0))
|
||||||
|
last_ask_close = float(ask.get("c", 0))
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
raise OandaDataError(
|
||||||
|
f"No complete candles from Oanda for {instrument}")
|
||||||
|
|
||||||
|
if last_bid_close and last_ask_close:
|
||||||
|
self._spread_cache[symbol] = round(
|
||||||
|
last_ask_close - last_bid_close, 4)
|
||||||
|
|
||||||
|
df = pd.DataFrame(rows)
|
||||||
|
df = df.set_index("Datetime")
|
||||||
|
df = df.sort_index()
|
||||||
|
if df.index.tz is None:
|
||||||
|
df.index = df.index.tz_localize("UTC")
|
||||||
|
df.index.name = "Datetime"
|
||||||
|
|
||||||
|
for col in ("Open", "High", "Low", "Close"):
|
||||||
|
df[col] = pd.to_numeric(df[col], errors="coerce")
|
||||||
|
df["Volume"] = pd.to_numeric(df["Volume"], errors="coerce").fillna(0).astype(float)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"Fetched %d bars for %s @ %s from Oanda",
|
||||||
|
len(df), instrument, granularity)
|
||||||
|
return df
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
"""
|
||||||
|
ForexBot2 — Tiingo data provider
|
||||||
|
=================================
|
||||||
|
OHLCV candles from Tiingo's FX endpoint
|
||||||
|
(`/tiingo/fx/{ticker}/prices`, resampled mid-price bars, no volume).
|
||||||
|
All shared behavior (cache, validation, boundary guarantees, range
|
||||||
|
pagination) lives in `BaseDataProvider`; this module only maps
|
||||||
|
symbols/resample frequencies, sizes the request window, and parses
|
||||||
|
Tiingo's JSON into the normalized frame.
|
||||||
|
|
||||||
|
Every failure mode raises `TiingoDataError` (a `DataIntegrityError`)
|
||||||
|
with a specific message — the scan loops log it and carry on; nothing
|
||||||
|
fails silently. NOTE: candles and mid-price ticks only. Tiingo's free
|
||||||
|
`/tiingo/fx/top` quote endpoint publishes indicative two-tier spreads
|
||||||
|
and must not be used for spread display (removed 2026-07-23) —
|
||||||
|
`get_spread` deliberately stays on the base-class default, and the
|
||||||
|
WebSocket stream (`iter_ticks`) exposes the mid price only, never
|
||||||
|
bid/ask or a derived spread.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import AsyncIterator, Callable, Optional, Sequence, Tuple
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from app_config import provider_api_key
|
||||||
|
from config import PlatformConfig, Timeframe
|
||||||
|
|
||||||
|
from providers.base import BaseDataProvider, DataIntegrityError
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class TiingoDataError(DataIntegrityError):
|
||||||
|
"""Raised when a Tiingo data fetch fails or returns invalid data."""
|
||||||
|
|
||||||
|
|
||||||
|
class TiingoDataProvider(BaseDataProvider):
|
||||||
|
"""Tiingo FX REST candle provider."""
|
||||||
|
|
||||||
|
name = "tiingo"
|
||||||
|
DATA_ERROR = TiingoDataError
|
||||||
|
|
||||||
|
BASE_URL = "https://api.tiingo.com"
|
||||||
|
|
||||||
|
TIINGO_RESAMPLE = {
|
||||||
|
Timeframe.M1: "1min",
|
||||||
|
Timeframe.M5: "5min",
|
||||||
|
Timeframe.M15: "15min",
|
||||||
|
Timeframe.M30: "30min",
|
||||||
|
Timeframe.H1: "1hour",
|
||||||
|
Timeframe.H4: "4hour",
|
||||||
|
Timeframe.D1: "1day",
|
||||||
|
# W1 intentionally unmapped: the FX endpoint has no weekly
|
||||||
|
# resample — requesting it raises loudly instead of guessing.
|
||||||
|
}
|
||||||
|
|
||||||
|
TIINGO_TICKER = {
|
||||||
|
"XAU/USD": "xauusd",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Window padding over the theoretical bar span: FX is closed ~2/7 of
|
||||||
|
# the week (Fri 22:00 → Sun 22:00 UTC), so a calendar window must be
|
||||||
|
# wider than `bars × candle_seconds` to yield the requested count.
|
||||||
|
WINDOW_PAD_FACTOR = 1.6
|
||||||
|
|
||||||
|
def __init__(self, config: PlatformConfig, api_key: Optional[str] = None):
|
||||||
|
super().__init__(config)
|
||||||
|
# Credentials default to env TIINGO_API_KEY → config.json
|
||||||
|
# data_providers.tiingo.api_key, so the provider registry can
|
||||||
|
# build this class with config only.
|
||||||
|
if api_key is None:
|
||||||
|
api_key = provider_api_key(provider="tiingo")
|
||||||
|
self._api_key = api_key or None
|
||||||
|
if self._api_key is None:
|
||||||
|
logger.warning(
|
||||||
|
"Tiingo provider built WITHOUT an API key — every fetch "
|
||||||
|
"will fail with a named error. Set the TIINGO_API_KEY env "
|
||||||
|
"var or data_providers.tiingo.api_key in config.json.")
|
||||||
|
self._session = requests.Session()
|
||||||
|
self._session.headers.update({
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
# Header auth keeps the key out of URLs (and out of logs).
|
||||||
|
"Authorization": f"Token {api_key}" if api_key else "",
|
||||||
|
})
|
||||||
|
|
||||||
|
def _to_tiingo_ticker(self, symbol: str) -> str:
|
||||||
|
if symbol in self.TIINGO_TICKER:
|
||||||
|
return self.TIINGO_TICKER[symbol]
|
||||||
|
return symbol.replace("/", "").replace("=X", "").lower()
|
||||||
|
|
||||||
|
# ── Raw fetch ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _fetch_raw(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
timeframe: Timeframe,
|
||||||
|
outputsize: int,
|
||||||
|
start: Optional[datetime] = None,
|
||||||
|
end: Optional[datetime] = None,
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
if self._api_key is None:
|
||||||
|
raise TiingoDataError(
|
||||||
|
"No Tiingo API key. Set the TIINGO_API_KEY env var or "
|
||||||
|
"data_providers.tiingo.api_key in config.json.")
|
||||||
|
|
||||||
|
resample = self.TIINGO_RESAMPLE.get(timeframe)
|
||||||
|
if resample is None:
|
||||||
|
raise TiingoDataError(
|
||||||
|
f"Unsupported timeframe {timeframe} for Tiingo "
|
||||||
|
f"(no FX resample frequency)")
|
||||||
|
|
||||||
|
ticker = self._to_tiingo_ticker(symbol)
|
||||||
|
|
||||||
|
# Tiingo takes date windows, not bar counts: derive the start
|
||||||
|
# from the requested count for live fetches.
|
||||||
|
if start is None:
|
||||||
|
span = self.CANDLE_SECONDS[timeframe] * (outputsize + 10)
|
||||||
|
start_ts = (pd.Timestamp.now(tz="UTC")
|
||||||
|
- pd.Timedelta(seconds=span * self.WINDOW_PAD_FACTOR))
|
||||||
|
else:
|
||||||
|
start_ts = pd.Timestamp(start)
|
||||||
|
|
||||||
|
params = {
|
||||||
|
"resampleFreq": resample,
|
||||||
|
"startDate": start_ts.strftime("%Y-%m-%dT%H:%M:%S"),
|
||||||
|
}
|
||||||
|
if end is not None:
|
||||||
|
params["endDate"] = pd.Timestamp(end).strftime("%Y-%m-%dT%H:%M:%S")
|
||||||
|
|
||||||
|
url = f"{self.BASE_URL}/tiingo/fx/{ticker}/prices"
|
||||||
|
try:
|
||||||
|
resp = self._session.get(url, params=params, timeout=30)
|
||||||
|
except requests.RequestException as e:
|
||||||
|
raise TiingoDataError(
|
||||||
|
f"Tiingo candle fetch failed for {ticker}: {e}") from e
|
||||||
|
|
||||||
|
if resp.status_code == 429:
|
||||||
|
raise TiingoDataError(
|
||||||
|
f"Tiingo rate limit hit for {ticker} (HTTP 429) — "
|
||||||
|
f"plan quota exhausted; scan will retry next boundary")
|
||||||
|
if resp.status_code in (401, 403):
|
||||||
|
raise TiingoDataError(
|
||||||
|
f"Tiingo auth failed for {ticker} (HTTP {resp.status_code}) "
|
||||||
|
f"— check TIINGO_API_KEY / config.json api_key")
|
||||||
|
if resp.status_code != 200:
|
||||||
|
raise TiingoDataError(
|
||||||
|
f"Tiingo candle fetch failed for {ticker}: "
|
||||||
|
f"HTTP {resp.status_code}: {resp.text[:300]}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = resp.json()
|
||||||
|
except ValueError as e:
|
||||||
|
raise TiingoDataError(
|
||||||
|
f"Tiingo candle response for {ticker} is not valid "
|
||||||
|
f"JSON: {e}") from e
|
||||||
|
|
||||||
|
if not isinstance(data, list) or not data:
|
||||||
|
raise TiingoDataError(
|
||||||
|
f"Empty candle response from Tiingo for {ticker} "
|
||||||
|
f"(window {params['startDate']} → "
|
||||||
|
f"{params.get('endDate', 'now')}; check ticker and plan "
|
||||||
|
f"history limits)")
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for item in data:
|
||||||
|
if "date" not in item:
|
||||||
|
raise TiingoDataError(
|
||||||
|
f"Malformed Tiingo candle for {ticker}: missing "
|
||||||
|
f"'date' in {str(item)[:120]}")
|
||||||
|
rows.append({
|
||||||
|
"Datetime": pd.Timestamp(item["date"]),
|
||||||
|
"Open": item.get("open"),
|
||||||
|
"High": item.get("high"),
|
||||||
|
"Low": item.get("low"),
|
||||||
|
"Close": item.get("close"),
|
||||||
|
# FX endpoint has no volume; 0.0 keeps the engine contract.
|
||||||
|
"Volume": 0.0,
|
||||||
|
})
|
||||||
|
|
||||||
|
df = pd.DataFrame(rows)
|
||||||
|
df = df.set_index("Datetime")
|
||||||
|
df = df.sort_index()
|
||||||
|
if df.index.tz is None:
|
||||||
|
df.index = df.index.tz_localize("UTC")
|
||||||
|
else:
|
||||||
|
df.index = df.index.tz_convert("UTC")
|
||||||
|
df.index.name = "Datetime"
|
||||||
|
|
||||||
|
for col in ("Open", "High", "Low", "Close"):
|
||||||
|
df[col] = pd.to_numeric(df[col], errors="coerce")
|
||||||
|
if df["Close"].isna().all():
|
||||||
|
raise TiingoDataError(
|
||||||
|
f"Tiingo response for {ticker} has no numeric closes — "
|
||||||
|
f"feed format changed?")
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"Fetched %d bars for %s @ %s from Tiingo",
|
||||||
|
len(df), ticker, resample)
|
||||||
|
return df
|
||||||
|
|
||||||
|
# ── Streaming quotes ───────────────────────────────────────────
|
||||||
|
|
||||||
|
supports_streaming = True
|
||||||
|
|
||||||
|
WS_URL = "wss://api.tiingo.com/fx"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_ws_message(raw: str) -> Optional[Tuple[str, float]]:
|
||||||
|
"""Parse one WS message; return (tiingo_ticker, mid_price) for
|
||||||
|
quote updates, None for heartbeats ("H"), subscription acks
|
||||||
|
("I"), malformed JSON, or quotes without a usable price.
|
||||||
|
|
||||||
|
Server error messages ("E", e.g. bad token) RAISE TiingoDataError:
|
||||||
|
Tiingo authenticates inside the socket via the subscribe message,
|
||||||
|
so an auth failure arrives as a data message followed by a clean
|
||||||
|
close — swallowing it would make the caller's reconnect loop
|
||||||
|
hammer the endpoint with no backoff (backoff only grows on
|
||||||
|
exceptions).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
msg = json.loads(raw)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
logger.warning("Tiingo WS: malformed JSON, skipping")
|
||||||
|
return None
|
||||||
|
|
||||||
|
mtype = msg.get("messageType")
|
||||||
|
if mtype == "H":
|
||||||
|
return None
|
||||||
|
if mtype == "I":
|
||||||
|
logger.info("Tiingo WS: subscription acknowledged")
|
||||||
|
return None
|
||||||
|
if mtype == "E":
|
||||||
|
raise TiingoDataError(
|
||||||
|
f"Tiingo WS server error: {str(msg.get('response'))[:200]}")
|
||||||
|
if mtype != "A":
|
||||||
|
return None
|
||||||
|
|
||||||
|
data = msg.get("data")
|
||||||
|
if not isinstance(data, list) or len(data) < 8 or data[0] != "Q":
|
||||||
|
return None
|
||||||
|
# Live-verified field order (2026-08-03): ["Q", ticker, timestamp,
|
||||||
|
# bidSize, bid, mid, askSize, ask] — ask is index 7, NOT 6 as the
|
||||||
|
# docs' table suggests (index 6 is askSize).
|
||||||
|
ticker = data[1]
|
||||||
|
bid, mid, ask = data[4], data[5], data[7]
|
||||||
|
if mid is None and bid is not None and ask is not None:
|
||||||
|
mid = (bid + ask) / 2
|
||||||
|
try:
|
||||||
|
return ticker, float(mid)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
logger.warning("Tiingo WS: quote for %s has no usable price",
|
||||||
|
ticker)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def iter_ticks(
|
||||||
|
self,
|
||||||
|
symbols: Sequence[str],
|
||||||
|
on_connect: Optional[Callable[[], None]] = None,
|
||||||
|
) -> AsyncIterator[Tuple[str, float]]:
|
||||||
|
"""One connection's lifetime of (canonical_symbol, mid) ticks.
|
||||||
|
|
||||||
|
The caller owns the reconnect/backoff loop around this generator.
|
||||||
|
Divergences from the Twelve Data reference implementation, both
|
||||||
|
deliberate: `on_connect` fires on the FIRST RECEIVED message (the
|
||||||
|
"I" ack in practice) rather than after the subscribe send, so a
|
||||||
|
rejected token cannot reset the caller's backoff; and there is no
|
||||||
|
client heartbeat task — Tiingo sends "H" frames itself and the
|
||||||
|
websockets library's built-in ping keepalive detects dead sockets.
|
||||||
|
"""
|
||||||
|
import websockets
|
||||||
|
|
||||||
|
if self._api_key is None:
|
||||||
|
raise TiingoDataError(
|
||||||
|
"No Tiingo API key. Set the TIINGO_API_KEY env var or "
|
||||||
|
"data_providers.tiingo.api_key in config.json.")
|
||||||
|
|
||||||
|
ws_to_pair = {self._to_tiingo_ticker(s): s for s in symbols}
|
||||||
|
async with websockets.connect(self.WS_URL) as ws:
|
||||||
|
await ws.send(json.dumps({
|
||||||
|
"eventName": "subscribe",
|
||||||
|
"authorization": self._api_key,
|
||||||
|
"eventData": {
|
||||||
|
"thresholdLevel": 5,
|
||||||
|
"tickers": sorted(ws_to_pair),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
logger.info(
|
||||||
|
"Tiingo WS connected, subscribing to %d tickers",
|
||||||
|
len(ws_to_pair))
|
||||||
|
connected = False
|
||||||
|
async for raw in ws:
|
||||||
|
parsed = self._parse_ws_message(raw)
|
||||||
|
if not connected:
|
||||||
|
connected = True
|
||||||
|
if on_connect is not None:
|
||||||
|
on_connect()
|
||||||
|
if parsed is None:
|
||||||
|
continue
|
||||||
|
pair_symbol = ws_to_pair.get(parsed[0])
|
||||||
|
if pair_symbol is None:
|
||||||
|
continue
|
||||||
|
yield pair_symbol, parsed[1]
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
"""
|
||||||
|
ForexBot2 — Twelve Data provider
|
||||||
|
=================================
|
||||||
|
OHLCV via the official `twelvedata` SDK plus streaming quotes over the
|
||||||
|
Twelve Data WebSocket. All shared behavior (cache, validation, boundary
|
||||||
|
guarantees, range pagination) lives in `BaseDataProvider`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from collections import deque
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import AsyncIterator, Callable, Optional, Sequence, Tuple
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
from twelvedata import TDClient
|
||||||
|
|
||||||
|
from app_config import provider_api_key
|
||||||
|
from config import PAIR_INFO, PlatformConfig, Timeframe
|
||||||
|
|
||||||
|
from providers.base import BaseDataProvider, DataIntegrityError
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class TwelveDataProvider(BaseDataProvider):
|
||||||
|
"""Twelve Data SDK client with credit throttling and WS quotes."""
|
||||||
|
|
||||||
|
name = "twelve_data"
|
||||||
|
|
||||||
|
TD_INTERVALS = {
|
||||||
|
Timeframe.M1: "1min",
|
||||||
|
Timeframe.M5: "5min",
|
||||||
|
Timeframe.M15: "15min",
|
||||||
|
Timeframe.M30: "30min",
|
||||||
|
Timeframe.H1: "1h",
|
||||||
|
Timeframe.H4: "4h",
|
||||||
|
Timeframe.D1: "1day",
|
||||||
|
Timeframe.W1: "1week",
|
||||||
|
}
|
||||||
|
|
||||||
|
RATE_LIMIT = 55
|
||||||
|
RATE_WINDOW = 60.0
|
||||||
|
|
||||||
|
WS_URL = "wss://ws.twelvedata.com/v1/quotes/price?apikey={api_key}"
|
||||||
|
|
||||||
|
def __init__(self, config: PlatformConfig):
|
||||||
|
super().__init__(config)
|
||||||
|
self._api_key = provider_api_key(provider="twelve_data") or None
|
||||||
|
self._throttle_lock = threading.Lock()
|
||||||
|
self._request_times: deque = deque()
|
||||||
|
self._td: Optional[TDClient] = (
|
||||||
|
TDClient(apikey=self._api_key) if self._api_key else None
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _to_td_symbol(symbol: str) -> str:
|
||||||
|
if "/" in symbol:
|
||||||
|
return symbol
|
||||||
|
clean = symbol.replace("=X", "")
|
||||||
|
return f"{clean[:3]}/{clean[3:]}"
|
||||||
|
|
||||||
|
# ── Raw fetch ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _fetch_raw(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
timeframe: Timeframe,
|
||||||
|
outputsize: int,
|
||||||
|
start: Optional[datetime] = None,
|
||||||
|
end: Optional[datetime] = None,
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
return self._fetch_twelve_data(
|
||||||
|
symbol, timeframe, outputsize,
|
||||||
|
start_date=start.strftime("%Y-%m-%d %H:%M:%S") if start else None,
|
||||||
|
end_date=end.strftime("%Y-%m-%d %H:%M:%S") if end else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _throttle(self, credits: int = 1) -> None:
|
||||||
|
"""Pro tier: 55 credits / minute. Block until budget allows."""
|
||||||
|
while True:
|
||||||
|
with self._throttle_lock:
|
||||||
|
now = time.time()
|
||||||
|
while self._request_times and now - self._request_times[0] >= self.RATE_WINDOW:
|
||||||
|
self._request_times.popleft()
|
||||||
|
|
||||||
|
available = self.RATE_LIMIT - len(self._request_times)
|
||||||
|
if available >= credits:
|
||||||
|
for _ in range(credits):
|
||||||
|
self._request_times.append(time.time())
|
||||||
|
return
|
||||||
|
|
||||||
|
used = len(self._request_times)
|
||||||
|
wait = (self._request_times[0] + self.RATE_WINDOW) - now + 0.2
|
||||||
|
logger.info(
|
||||||
|
f"Rate limit: {used}/{self.RATE_LIMIT} credits used "
|
||||||
|
f"— waiting {wait:.1f}s..."
|
||||||
|
)
|
||||||
|
time.sleep(wait)
|
||||||
|
|
||||||
|
def _fetch_twelve_data(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
timeframe: Timeframe,
|
||||||
|
outputsize: int,
|
||||||
|
start_date: Optional[str] = None,
|
||||||
|
end_date: Optional[str] = None,
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
if self._td is None:
|
||||||
|
raise DataIntegrityError(
|
||||||
|
"No data-provider API key. Set the TWELVE_DATA_API_KEY env "
|
||||||
|
"var or data_providers.twelve_data.api_key in config.json."
|
||||||
|
)
|
||||||
|
|
||||||
|
td_symbol = self._to_td_symbol(symbol)
|
||||||
|
interval = self.TD_INTERVALS[timeframe]
|
||||||
|
|
||||||
|
self._throttle(credits=1)
|
||||||
|
|
||||||
|
kwargs = {
|
||||||
|
"symbol": td_symbol,
|
||||||
|
"interval": interval,
|
||||||
|
"outputsize": min(outputsize, self.MAX_BARS_PER_REQUEST),
|
||||||
|
"timezone": "UTC",
|
||||||
|
}
|
||||||
|
if start_date:
|
||||||
|
kwargs["start_date"] = start_date
|
||||||
|
if end_date:
|
||||||
|
kwargs["end_date"] = end_date
|
||||||
|
|
||||||
|
try:
|
||||||
|
df = self._td.time_series(**kwargs).as_pandas()
|
||||||
|
except Exception as e:
|
||||||
|
raise DataIntegrityError(
|
||||||
|
f"Twelve Data fetch failed for {td_symbol}: {e}"
|
||||||
|
) from e
|
||||||
|
|
||||||
|
if df is None or df.empty:
|
||||||
|
raise DataIntegrityError(f"Empty response from Twelve Data for {td_symbol}")
|
||||||
|
|
||||||
|
# SDK returns lowercase columns; engine expects capitalised.
|
||||||
|
df = df.rename(columns={
|
||||||
|
"open": "Open", "high": "High", "low": "Low",
|
||||||
|
"close": "Close", "volume": "Volume",
|
||||||
|
})
|
||||||
|
for col in ("Open", "High", "Low", "Close", "Volume"):
|
||||||
|
if col in df.columns:
|
||||||
|
df[col] = pd.to_numeric(df[col], errors="coerce")
|
||||||
|
if "Volume" not in df.columns:
|
||||||
|
df["Volume"] = 0.0
|
||||||
|
|
||||||
|
# SDK returns newest-first; engine + indicators want oldest-first.
|
||||||
|
df = df.sort_index()
|
||||||
|
if df.index.tz is None:
|
||||||
|
df.index = df.index.tz_localize("UTC")
|
||||||
|
df.index.name = "Datetime"
|
||||||
|
|
||||||
|
logger.debug(f"Fetched {len(df)} bars for {td_symbol} @ {interval}")
|
||||||
|
return df
|
||||||
|
|
||||||
|
# ── Streaming quotes ───────────────────────────────────────────
|
||||||
|
|
||||||
|
supports_streaming = True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_ws_price(raw: str) -> Optional[Tuple[str, float]]:
|
||||||
|
"""Parse one WS message; return (ws_symbol, price) for price
|
||||||
|
events, None for everything else (logged where noteworthy)."""
|
||||||
|
try:
|
||||||
|
msg = json.loads(raw)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
logger.warning("WebSocket: malformed JSON, skipping")
|
||||||
|
return None
|
||||||
|
|
||||||
|
event = msg.get("event")
|
||||||
|
if event == "subscribe-status":
|
||||||
|
fails = msg.get("fails") or []
|
||||||
|
if fails:
|
||||||
|
logger.warning(f"WebSocket: failed to subscribe: {fails}")
|
||||||
|
return None
|
||||||
|
if event != "price":
|
||||||
|
return None
|
||||||
|
|
||||||
|
ws_symbol = msg.get("symbol")
|
||||||
|
try:
|
||||||
|
price = float(msg["price"])
|
||||||
|
except (KeyError, ValueError, TypeError) as e:
|
||||||
|
logger.warning(f"WebSocket: bad price data for {ws_symbol}: {e}")
|
||||||
|
return None
|
||||||
|
return ws_symbol, price
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _ws_heartbeat(ws) -> None:
|
||||||
|
"""Twelve Data drops idle WS connections; heartbeat every 10s keeps
|
||||||
|
it alive through quiet periods (weekends, low-liquidity hours)."""
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(10)
|
||||||
|
await ws.send(json.dumps({"action": "heartbeat"}))
|
||||||
|
|
||||||
|
async def iter_ticks(
|
||||||
|
self,
|
||||||
|
symbols: Sequence[str],
|
||||||
|
on_connect: Optional[Callable[[], None]] = None,
|
||||||
|
) -> AsyncIterator[Tuple[str, float]]:
|
||||||
|
"""One connection's lifetime of (canonical_symbol, price) ticks.
|
||||||
|
|
||||||
|
The TD WS speaks display symbols ("XAU/USD"); ticks are mapped back
|
||||||
|
to the canonical pair symbols passed in. The caller owns the
|
||||||
|
reconnect/backoff loop around this generator.
|
||||||
|
"""
|
||||||
|
import websockets
|
||||||
|
|
||||||
|
ws_to_pair = {PAIR_INFO[s]["display"]: s for s in symbols}
|
||||||
|
url = self.WS_URL.format(api_key=self._api_key or "")
|
||||||
|
async with websockets.connect(url) as ws:
|
||||||
|
subscribe = {
|
||||||
|
"action": "subscribe",
|
||||||
|
"params": {"symbols": ",".join(ws_to_pair)},
|
||||||
|
}
|
||||||
|
await ws.send(json.dumps(subscribe))
|
||||||
|
logger.info(f"WebSocket connected, subscribed to {len(ws_to_pair)} symbols")
|
||||||
|
if on_connect is not None:
|
||||||
|
on_connect()
|
||||||
|
hb_task = asyncio.create_task(self._ws_heartbeat(ws))
|
||||||
|
try:
|
||||||
|
async for raw in ws:
|
||||||
|
parsed = self._parse_ws_price(raw)
|
||||||
|
if parsed is None:
|
||||||
|
continue
|
||||||
|
pair_symbol = ws_to_pair.get(parsed[0])
|
||||||
|
if pair_symbol is None:
|
||||||
|
continue
|
||||||
|
yield pair_symbol, parsed[1]
|
||||||
|
finally:
|
||||||
|
hb_task.cancel()
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
numpy>=1.24
|
||||||
|
pandas>=2.0
|
||||||
|
requests>=2.28
|
||||||
|
twelvedata>=1.2.0
|
||||||
|
python-telegram-bot>=20.0
|
||||||
|
websockets>=12.0
|
||||||
@@ -1 +0,0 @@
|
|||||||
pip install pandas numpy requests python-dateutil -q
|
|
||||||
Regular → Executable
+1345
-182
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,773 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="color-scheme" content="dark">
|
||||||
|
<title>Gold Desk — XAU/USD</title>
|
||||||
|
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='7' fill='%23d4a72c'/%3E%3C/svg%3E">
|
||||||
|
<style>
|
||||||
|
/* ── Gold Desk tokens ─────────────────────────────────────────── */
|
||||||
|
:root {
|
||||||
|
--bg: #0f0e0b; /* page plane, warm near-black */
|
||||||
|
--card: #17150f; /* card surface */
|
||||||
|
--card2: #1d1b14; /* nested surface (setup, grid cells) */
|
||||||
|
--line: rgba(242,239,230,0.08);
|
||||||
|
--line-strong: rgba(242,239,230,0.16);
|
||||||
|
--ink: #f2efe6;
|
||||||
|
--ink2: #b5ae9c;
|
||||||
|
--muted: #7d7768;
|
||||||
|
--gold: #d4a72c; /* identity accent — never a data encoding */
|
||||||
|
--gold-bright: #e6c35c;
|
||||||
|
--gold-wash: rgba(212,167,44,0.10);
|
||||||
|
--bull: #0ca30c; /* status good — always paired with arrow/text */
|
||||||
|
--bull-wash: rgba(12,163,12,0.12);
|
||||||
|
--bear: #d03b3b; /* status critical */
|
||||||
|
--bear-text: #e66767; /* lighter step for small red text */
|
||||||
|
--bear-wash: rgba(208,59,59,0.12);
|
||||||
|
--amber: #fab219; /* warnings / medium impact */
|
||||||
|
--amber-wash: rgba(250,178,25,0.10);
|
||||||
|
--neutral-mid: #383835; /* diverging midpoint */
|
||||||
|
--mono: ui-monospace, "SF Mono", "Cascadia Code", Consolas, Menlo, monospace;
|
||||||
|
--sans: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
html { background: var(--bg); }
|
||||||
|
body {
|
||||||
|
font-family: var(--sans);
|
||||||
|
color: var(--ink);
|
||||||
|
background: var(--bg);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.45;
|
||||||
|
padding: 14px clamp(10px, 2.5vw, 28px) 40px;
|
||||||
|
max-width: 1500px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.num { font-family: var(--mono); font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
/* ── Eyebrow section labels ───────────────────────────────────── */
|
||||||
|
.eyebrow {
|
||||||
|
font-size: 11px; letter-spacing: 0.14em; text-transform: uppercase;
|
||||||
|
color: var(--muted); font-weight: 600; margin: 26px 0 10px;
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
}
|
||||||
|
.eyebrow::after { content: ""; flex: 1; height: 1px; background: var(--line); }
|
||||||
|
|
||||||
|
/* ── Hero consensus rail ──────────────────────────────────────── */
|
||||||
|
#hero {
|
||||||
|
display: flex; align-items: center; gap: 18px; flex-wrap: wrap;
|
||||||
|
padding: 14px 18px;
|
||||||
|
background: linear-gradient(180deg, var(--card) 0%, var(--bg) 140%);
|
||||||
|
border: 1px solid var(--line); border-radius: 10px;
|
||||||
|
}
|
||||||
|
#hero .symbol {
|
||||||
|
font-family: var(--mono); font-weight: 700; font-size: 15px;
|
||||||
|
letter-spacing: 0.08em; color: var(--gold);
|
||||||
|
}
|
||||||
|
#hero .price {
|
||||||
|
font-family: var(--mono); font-variant-numeric: tabular-nums;
|
||||||
|
font-size: 30px; font-weight: 600; color: var(--gold-bright);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
#hero .rail { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.rail-chip {
|
||||||
|
display: inline-flex; align-items: baseline; gap: 6px;
|
||||||
|
font-family: var(--mono); font-size: 12.5px;
|
||||||
|
padding: 5px 9px; border-radius: 6px;
|
||||||
|
background: var(--card2); border: 1px solid var(--line);
|
||||||
|
color: var(--ink2);
|
||||||
|
}
|
||||||
|
.rail-chip .tfname { color: var(--muted); font-weight: 600; }
|
||||||
|
#hero .meta { margin-left: auto; text-align: right; font-size: 12px; color: var(--ink2); }
|
||||||
|
#hero .meta .num { color: var(--ink); }
|
||||||
|
.live-dot {
|
||||||
|
display: inline-block; width: 8px; height: 8px; border-radius: 50%;
|
||||||
|
background: var(--bull); margin-right: 5px;
|
||||||
|
animation: pulse 2.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.live-dot.closed { background: var(--amber); animation: none; }
|
||||||
|
@keyframes pulse { 50% { opacity: 0.35; } }
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.live-dot { animation: none; }
|
||||||
|
* { transition: none !important; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Banners / toast ──────────────────────────────────────────── */
|
||||||
|
.banner {
|
||||||
|
margin-top: 10px; padding: 9px 14px; border-radius: 8px;
|
||||||
|
font-size: 13px; display: none;
|
||||||
|
}
|
||||||
|
#banner-closed { background: var(--amber-wash); border: 1px solid rgba(250,178,25,0.35); color: var(--amber); }
|
||||||
|
#banner-fail { background: var(--bear-wash); border: 1px solid rgba(208,59,59,0.4); color: var(--bear-text); }
|
||||||
|
|
||||||
|
/* ── Market context strip ─────────────────────────────────────── */
|
||||||
|
#context {
|
||||||
|
margin-top: 14px; padding: 12px 16px;
|
||||||
|
background: var(--card); border: 1px solid var(--line); border-radius: 10px;
|
||||||
|
display: flex; gap: 16px; align-items: flex-start; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
#context .blurb { flex: 1 1 380px; color: var(--ink2); font-size: 13px; }
|
||||||
|
#context .blurb b { color: var(--ink); font-weight: 600; }
|
||||||
|
#context .chips { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||||||
|
.chip {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
font-size: 12px; padding: 4px 9px; border-radius: 999px;
|
||||||
|
border: 1px solid var(--line-strong); color: var(--ink2);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.chip .num { font-size: 12px; }
|
||||||
|
.chip.pos { border-color: rgba(12,163,12,0.45); background: var(--bull-wash); color: var(--ink); }
|
||||||
|
.chip.neg { border-color: rgba(208,59,59,0.45); background: var(--bear-wash); color: var(--ink); }
|
||||||
|
.chip.risk-clear { border-color: rgba(12,163,12,0.45); color: var(--bull); }
|
||||||
|
.chip.risk-caution { border-color: rgba(250,178,25,0.5); background: var(--amber-wash); color: var(--amber); }
|
||||||
|
.chip.risk-danger { border-color: rgba(208,59,59,0.55); background: var(--bear-wash); color: var(--bear-text); }
|
||||||
|
|
||||||
|
/* ── Timeframe cards ──────────────────────────────────────────── */
|
||||||
|
#tf-grid {
|
||||||
|
display: grid; gap: 14px;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(430px, 100%), 1fr));
|
||||||
|
}
|
||||||
|
.tf-card {
|
||||||
|
background: var(--card); border: 1px solid var(--line); border-radius: 10px;
|
||||||
|
padding: 14px 16px 12px; display: flex; flex-direction: column; gap: 11px;
|
||||||
|
}
|
||||||
|
.tf-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.tf-name {
|
||||||
|
font-family: var(--mono); font-weight: 700; font-size: 15px;
|
||||||
|
color: var(--gold); letter-spacing: 0.05em; min-width: 42px;
|
||||||
|
}
|
||||||
|
.sig-badge {
|
||||||
|
font-weight: 700; font-size: 12.5px; letter-spacing: 0.04em;
|
||||||
|
padding: 3px 10px; border-radius: 6px; border: 1px solid var(--line-strong);
|
||||||
|
color: var(--ink2);
|
||||||
|
}
|
||||||
|
.sig-badge.buy { background: var(--bull-wash); border-color: rgba(12,163,12,0.5); color: var(--bull); }
|
||||||
|
.sig-badge.sell { background: var(--bear-wash); border-color: rgba(208,59,59,0.55); color: var(--bear-text); }
|
||||||
|
.q-chip {
|
||||||
|
font-family: var(--mono); font-weight: 700; font-size: 12px;
|
||||||
|
padding: 3px 8px; border-radius: 6px;
|
||||||
|
background: var(--card2); border: 1px solid var(--line-strong); color: var(--ink2);
|
||||||
|
}
|
||||||
|
.q-chip.hi { background: var(--gold-wash); border-color: rgba(212,167,44,0.5); color: var(--gold-bright); }
|
||||||
|
.regime { font-size: 11.5px; color: var(--muted); }
|
||||||
|
.tf-head .spacer { margin-left: auto; }
|
||||||
|
.err-badge {
|
||||||
|
font-size: 11px; font-weight: 700; padding: 2px 8px; border-radius: 6px;
|
||||||
|
background: var(--bear-wash); border: 1px solid rgba(208,59,59,0.5);
|
||||||
|
color: var(--bear-text); cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* score bar: zero-centered diverging, neutral midpoint tick */
|
||||||
|
.scorebar { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.scorebar .track {
|
||||||
|
position: relative; flex: 1; height: 8px; border-radius: 4px;
|
||||||
|
background: var(--card2); overflow: hidden;
|
||||||
|
}
|
||||||
|
.scorebar .track::after { /* midpoint tick */
|
||||||
|
content: ""; position: absolute; left: 50%; top: 0; bottom: 0;
|
||||||
|
width: 2px; background: var(--neutral-mid); transform: translateX(-1px);
|
||||||
|
}
|
||||||
|
.scorebar .fill {
|
||||||
|
position: absolute; top: 0; bottom: 0; border-radius: 4px;
|
||||||
|
transition: width 0.5s ease;
|
||||||
|
}
|
||||||
|
.scorebar .fill.pos { left: 50%; background: var(--bull); }
|
||||||
|
.scorebar .fill.neg { right: 50%; background: var(--bear); }
|
||||||
|
.scorebar .val { width: 52px; text-align: right; font-size: 13px; color: var(--ink); }
|
||||||
|
.scorebar .lbl { font-size: 11px; color: var(--muted); width: 38px; }
|
||||||
|
|
||||||
|
/* 3×3 indicator grid */
|
||||||
|
.ind-grid {
|
||||||
|
display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px;
|
||||||
|
}
|
||||||
|
.ind-cell {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 6px 9px; border-radius: 6px;
|
||||||
|
background: var(--card2); border: 1px solid transparent;
|
||||||
|
font-family: var(--mono); font-size: 12.5px; cursor: help;
|
||||||
|
}
|
||||||
|
.ind-cell:hover, .ind-cell:focus-visible { border-color: var(--line-strong); }
|
||||||
|
.ind-cell .k { color: var(--ink2); }
|
||||||
|
.ind-cell .arrow { font-size: 14px; font-weight: 700; }
|
||||||
|
.ind-cell.buy .arrow { color: var(--bull); }
|
||||||
|
.ind-cell.sell .arrow { color: var(--bear-text); }
|
||||||
|
.ind-cell.neutral .arrow { color: var(--muted); }
|
||||||
|
.ind-cell.absent { opacity: 0.45; }
|
||||||
|
.ind-cell.absent .arrow { color: var(--muted); }
|
||||||
|
.atr-line { font-size: 12px; color: var(--muted); }
|
||||||
|
.atr-line .num { color: var(--ink2); }
|
||||||
|
.patterns { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||||
|
.pat-tag {
|
||||||
|
font-size: 11px; font-family: var(--mono); padding: 2px 7px;
|
||||||
|
border-radius: 5px; background: var(--gold-wash); color: var(--gold);
|
||||||
|
border: 1px solid rgba(212,167,44,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* MTF row */
|
||||||
|
.mtf-row {
|
||||||
|
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
|
||||||
|
font-size: 12px; color: var(--ink2);
|
||||||
|
}
|
||||||
|
.mtf-row .agree { font-weight: 700; }
|
||||||
|
.mtf-row .agree.yes { color: var(--bull); }
|
||||||
|
.mtf-row .agree.no { color: var(--muted); }
|
||||||
|
.mtf-chip {
|
||||||
|
font-family: var(--mono); font-size: 11.5px; padding: 2px 7px;
|
||||||
|
border-radius: 5px; background: var(--card2); border: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* trade setup sub-card */
|
||||||
|
.setup {
|
||||||
|
border: 1px solid var(--line-strong); border-radius: 8px;
|
||||||
|
padding: 10px 12px; background: var(--card2);
|
||||||
|
}
|
||||||
|
.setup.buy { border-left: 3px solid var(--bull); }
|
||||||
|
.setup.sell { border-left: 3px solid var(--bear); }
|
||||||
|
.setup .s-head {
|
||||||
|
display: flex; align-items: baseline; gap: 8px; margin-bottom: 7px;
|
||||||
|
font-weight: 700; font-size: 13px;
|
||||||
|
}
|
||||||
|
.setup .s-head .dir.buy { color: var(--bull); }
|
||||||
|
.setup .s-head .dir.sell { color: var(--bear-text); }
|
||||||
|
.setup .s-head .strat { font-family: var(--mono); font-size: 11px; color: var(--muted); font-weight: 400; }
|
||||||
|
.setup table { border-collapse: collapse; width: 100%; }
|
||||||
|
.setup td { padding: 2px 0; font-size: 12.5px; }
|
||||||
|
.setup td:first-child { color: var(--muted); padding-right: 12px; white-space: nowrap; }
|
||||||
|
.setup td.num { color: var(--ink); }
|
||||||
|
|
||||||
|
/* reasons & warnings */
|
||||||
|
.reasons { font-size: 12.5px; display: flex; flex-direction: column; gap: 3px; }
|
||||||
|
.reasons .r-bull::before { content: "▲ "; color: var(--bull); }
|
||||||
|
.reasons .r-bear::before { content: "▼ "; color: var(--bear-text); }
|
||||||
|
.reasons .r-warn { color: var(--amber); }
|
||||||
|
.reasons .r-warn::before { content: "⚠ "; }
|
||||||
|
.reasons .r-bull, .reasons .r-bear { color: var(--ink2); }
|
||||||
|
details.more { font-size: 12px; }
|
||||||
|
details.more summary { cursor: pointer; color: var(--muted); user-select: none; }
|
||||||
|
details.more summary:hover { color: var(--ink2); }
|
||||||
|
details.more .reasons { margin-top: 4px; }
|
||||||
|
|
||||||
|
.tf-foot {
|
||||||
|
display: flex; align-items: center; gap: 10px; margin-top: auto;
|
||||||
|
padding-top: 8px; border-top: 1px solid var(--line);
|
||||||
|
font-size: 11.5px; color: var(--muted);
|
||||||
|
}
|
||||||
|
.tf-foot .cd { margin-left: auto; }
|
||||||
|
.tf-foot .cd .num { color: var(--ink2); }
|
||||||
|
.warming { color: var(--muted); font-size: 13px; padding: 18px 0; text-align: center; }
|
||||||
|
|
||||||
|
/* confirmations (collapsible extra) */
|
||||||
|
details.confirms summary {
|
||||||
|
cursor: pointer; font-size: 11.5px; color: var(--muted); user-select: none;
|
||||||
|
}
|
||||||
|
details.confirms summary:hover { color: var(--ink2); }
|
||||||
|
.confirm-line {
|
||||||
|
font-family: var(--mono); font-size: 12px; color: var(--ink2);
|
||||||
|
padding: 4px 0 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── News section ─────────────────────────────────────────────── */
|
||||||
|
#news-wrap { display: grid; gap: 14px; grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); }
|
||||||
|
@media (max-width: 1100px) { #news-wrap { grid-template-columns: 1fr; } }
|
||||||
|
.panel {
|
||||||
|
background: var(--card); border: 1px solid var(--line); border-radius: 10px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
.panel-head { display: flex; align-items: center; gap: 12px; margin-bottom: 10px; }
|
||||||
|
.panel-head h2 {
|
||||||
|
font-size: 12px; letter-spacing: 0.12em; text-transform: uppercase;
|
||||||
|
color: var(--ink2); font-weight: 600;
|
||||||
|
}
|
||||||
|
.panel-head .note { font-size: 11px; color: var(--muted); }
|
||||||
|
.panel-head label {
|
||||||
|
margin-left: auto; font-size: 12px; color: var(--ink2);
|
||||||
|
display: inline-flex; gap: 6px; align-items: center; cursor: pointer;
|
||||||
|
}
|
||||||
|
.table-scroll { overflow-x: auto; }
|
||||||
|
#cal-table { border-collapse: collapse; width: 100%; min-width: 560px; }
|
||||||
|
#cal-table th {
|
||||||
|
text-align: left; font-size: 10.5px; letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase; color: var(--muted); font-weight: 600;
|
||||||
|
padding: 4px 10px 6px 0; border-bottom: 1px solid var(--line-strong);
|
||||||
|
}
|
||||||
|
#cal-table td { padding: 5px 10px 5px 0; font-size: 12.5px; border-bottom: 1px solid var(--line); }
|
||||||
|
#cal-table tr.day-row td {
|
||||||
|
padding-top: 10px; font-size: 11px; letter-spacing: 0.1em; text-transform: uppercase;
|
||||||
|
color: var(--gold); font-weight: 600; border-bottom: none;
|
||||||
|
}
|
||||||
|
#cal-table tr.past { opacity: 0.55; }
|
||||||
|
#cal-table tr.next-up td:first-child { box-shadow: inset 3px 0 0 var(--gold); padding-left: 7px; }
|
||||||
|
#cal-table .t { white-space: nowrap; color: var(--ink2); }
|
||||||
|
#cal-table .cur { font-family: var(--mono); color: var(--muted); }
|
||||||
|
#cal-table .ev { color: var(--ink); }
|
||||||
|
.imp {
|
||||||
|
font-size: 10.5px; font-weight: 700; letter-spacing: 0.05em;
|
||||||
|
padding: 1.5px 7px; border-radius: 999px; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.imp.high { background: var(--bear-wash); color: var(--bear-text); border: 1px solid rgba(208,59,59,0.45); }
|
||||||
|
.imp.medium { background: var(--amber-wash); color: var(--amber); border: 1px solid rgba(250,178,25,0.4); }
|
||||||
|
.imp.low { background: var(--card2); color: var(--muted); border: 1px solid var(--line-strong); }
|
||||||
|
.surp { font-weight: 700; cursor: help; }
|
||||||
|
.surp.positive { color: var(--bull); }
|
||||||
|
.surp.negative { color: var(--bear-text); }
|
||||||
|
.surp.inline { color: var(--muted); }
|
||||||
|
|
||||||
|
/* newswire */
|
||||||
|
.wire { display: flex; flex-direction: column; }
|
||||||
|
.wire-item {
|
||||||
|
display: flex; gap: 10px; align-items: baseline;
|
||||||
|
padding: 7px 0; border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.wire-item:last-child { border-bottom: none; }
|
||||||
|
.wire-score {
|
||||||
|
font-family: var(--mono); font-size: 11.5px; font-weight: 700;
|
||||||
|
padding: 1px 6px; border-radius: 5px; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.wire-score.pos { background: var(--bull-wash); color: var(--bull); }
|
||||||
|
.wire-score.neg { background: var(--bear-wash); color: var(--bear-text); }
|
||||||
|
.wire-score.zero { background: var(--card2); color: var(--muted); }
|
||||||
|
.wire-h { font-size: 12.5px; color: var(--ink2); flex: 1; }
|
||||||
|
.wire-meta { font-size: 11px; color: var(--muted); white-space: nowrap; }
|
||||||
|
.empty { color: var(--muted); font-size: 12.5px; padding: 8px 0; }
|
||||||
|
|
||||||
|
footer {
|
||||||
|
margin-top: 30px; font-size: 11px; color: var(--muted);
|
||||||
|
border-top: 1px solid var(--line); padding-top: 12px;
|
||||||
|
}
|
||||||
|
:focus-visible { outline: 2px solid var(--gold); outline-offset: 2px; border-radius: 4px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header id="hero">
|
||||||
|
<div>
|
||||||
|
<div class="symbol">XAU/USD</div>
|
||||||
|
<div class="price num" id="hero-price">—</div>
|
||||||
|
</div>
|
||||||
|
<div class="rail" id="hero-rail" aria-label="Signal by timeframe"></div>
|
||||||
|
<div class="meta">
|
||||||
|
<div id="hero-status"><span class="live-dot"></span>…</div>
|
||||||
|
<div>M5 close in <span class="num" id="hero-cd">--:--</span></div>
|
||||||
|
<div id="hero-updated">updated —</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="banner" id="banner-closed">Market closed (weekend) — showing Friday's last state. Calendar and news stay live.</div>
|
||||||
|
<div class="banner" id="banner-fail"></div>
|
||||||
|
|
||||||
|
<section id="context" hidden>
|
||||||
|
<div class="blurb" id="ctx-blurb"></div>
|
||||||
|
<div class="chips" id="ctx-chips"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="eyebrow">Timeframes</div>
|
||||||
|
<main id="tf-grid"></main>
|
||||||
|
|
||||||
|
<div class="eyebrow">Fundamentals</div>
|
||||||
|
<div id="news-wrap">
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2>Economic calendar</h2>
|
||||||
|
<span class="note">times local · USD events</span>
|
||||||
|
<label><input type="checkbox" id="hi-only"> high impact only</label>
|
||||||
|
</div>
|
||||||
|
<div class="table-scroll">
|
||||||
|
<table id="cal-table">
|
||||||
|
<thead><tr><th>Time</th><th>Cur</th><th>Impact</th><th>Event</th>
|
||||||
|
<th class="num">Fcst</th><th class="num">Prev</th><th class="num">Actual</th></tr></thead>
|
||||||
|
<tbody id="cal-body"><tr><td colspan="7" class="empty">Loading calendar…</td></tr></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head"><h2>Newswire</h2><span class="note" id="wire-note"></span></div>
|
||||||
|
<div class="wire" id="wire"></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
Signals are generated on closed candles only and refresh at candle boundaries
|
||||||
|
(M5/M15/H1/H4). Not financial advice — educational and research use only.
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
"use strict";
|
||||||
|
/* Mirrors telegram_bot._IND_SHORT / _IND_ARROW — kept as local constants
|
||||||
|
by design: this page must stay independent of the bot. */
|
||||||
|
const IND_ORDER = ["ema_trend","rsi","macd","bollinger","adx","roc","keltner","divergence","candlestick"];
|
||||||
|
const IND_SHORT = {ema_trend:"EMA", rsi:"RSI", macd:"MACD", bollinger:"BB",
|
||||||
|
adx:"ADX", roc:"ROC", keltner:"KC", divergence:"Div", candlestick:"Cndl"};
|
||||||
|
const ARROW = {STRONG_BUY:"⇈", BUY:"↑", NEUTRAL:"→", SELL:"↓", STRONG_SELL:"⇊"};
|
||||||
|
const TF_LABEL = {"5min":"M5", "15min":"M15", "1h":"H1", "4h":"H4"};
|
||||||
|
const TF_ORDER = ["5min","15min","1h","4h"];
|
||||||
|
const POLL_MS = 20000;
|
||||||
|
|
||||||
|
let snapshot = null, news = null;
|
||||||
|
let clockOffset = 0; // serverEpoch - localEpoch
|
||||||
|
let lastPollOk = null; // local Date of last successful poll
|
||||||
|
|
||||||
|
const $ = id => document.getElementById(id);
|
||||||
|
const esc = s => String(s ?? "").replace(/[&<>"']/g,
|
||||||
|
c => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]));
|
||||||
|
const fmt$ = v => v == null ? "—" : Number(v).toLocaleString("en-US",
|
||||||
|
{minimumFractionDigits: 2, maximumFractionDigits: 2});
|
||||||
|
const sigClass = s => s?.includes("BUY") ? "buy" : s?.includes("SELL") ? "sell" : "neutral";
|
||||||
|
const sigLabel = s => (s || "NEUTRAL").replace("_", " ");
|
||||||
|
const nowServer = () => Date.now() / 1000 + clockOffset;
|
||||||
|
|
||||||
|
function fmtCountdown(secs) {
|
||||||
|
if (secs == null || !isFinite(secs)) return "--:--";
|
||||||
|
secs = Math.max(0, Math.floor(secs));
|
||||||
|
const h = Math.floor(secs / 3600), m = Math.floor((secs % 3600) / 60), s = secs % 60;
|
||||||
|
const mm = String(m).padStart(2, "0"), ss = String(s).padStart(2, "0");
|
||||||
|
return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`;
|
||||||
|
}
|
||||||
|
function fmtAge(iso) {
|
||||||
|
if (!iso) return "";
|
||||||
|
const secs = nowServer() - Date.parse(iso) / 1000;
|
||||||
|
if (secs < 90) return `${Math.max(0, Math.round(secs))}s ago`;
|
||||||
|
if (secs < 5400) return `${Math.round(secs / 60)}m ago`;
|
||||||
|
if (secs < 172800) return `${Math.round(secs / 3600)}h ago`;
|
||||||
|
return `${Math.round(secs / 86400)}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── hero ─────────────────────────────────────────────────────── */
|
||||||
|
function renderHero() {
|
||||||
|
if (!snapshot) return;
|
||||||
|
let price = null;
|
||||||
|
for (const tf of ["5min","15min","1h","4h"]) {
|
||||||
|
const p = snapshot.timeframes[tf]?.report?.current_price;
|
||||||
|
if (p) { price = p; break; }
|
||||||
|
}
|
||||||
|
$("hero-price").textContent = price ? fmt$(price) : "—";
|
||||||
|
|
||||||
|
$("hero-rail").innerHTML = TF_ORDER.map(tf => {
|
||||||
|
const r = snapshot.timeframes[tf]?.report;
|
||||||
|
const sig = r?.signal || null;
|
||||||
|
const cls = sigClass(sig);
|
||||||
|
const arrow = sig ? (ARROW[sig] || "→") : "·";
|
||||||
|
const color = cls === "buy" ? "var(--bull)" : cls === "sell" ? "var(--bear-text)" : "var(--muted)";
|
||||||
|
const score = r?.score != null ? r.score.toFixed(0) : "–";
|
||||||
|
return `<span class="rail-chip" title="${esc(sigLabel(sig))} (score ${esc(score)})">
|
||||||
|
<span class="tfname">${TF_LABEL[tf]}</span>
|
||||||
|
<span style="color:${color};font-weight:700">${arrow}</span>
|
||||||
|
<span>${esc(score)}</span></span>`;
|
||||||
|
}).join("");
|
||||||
|
|
||||||
|
const closed = snapshot.market_closed;
|
||||||
|
$("hero-status").innerHTML =
|
||||||
|
`<span class="live-dot${closed ? " closed" : ""}"></span>${closed ? "CLOSED" : "OPEN"}`;
|
||||||
|
$("banner-closed").style.display = closed ? "block" : "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── market context strip ─────────────────────────────────────── */
|
||||||
|
function renderContext() {
|
||||||
|
if (!news) return;
|
||||||
|
$("context").hidden = false;
|
||||||
|
$("ctx-blurb").innerHTML = news.llm_summary
|
||||||
|
? `<b>Market context:</b> ${esc(news.llm_summary)}`
|
||||||
|
: `<b>Market context:</b> <span style="color:var(--muted)">no summary available</span>`;
|
||||||
|
|
||||||
|
let chips = "";
|
||||||
|
for (const cur of ["XAU", "USD"]) {
|
||||||
|
const v = news.sentiment?.[cur];
|
||||||
|
if (v == null) continue;
|
||||||
|
const cls = v > 0.05 ? "pos" : v < -0.05 ? "neg" : "";
|
||||||
|
chips += `<span class="chip ${cls}" title="Mean headline sentiment for ${cur} (−1…+1)">
|
||||||
|
${cur} news <span class="num">${v >= 0 ? "+" : ""}${v.toFixed(2)}</span></span>`;
|
||||||
|
}
|
||||||
|
let risk = "clear", riskDesc = "No significant events";
|
||||||
|
if (snapshot) {
|
||||||
|
for (const tf of TF_ORDER) {
|
||||||
|
const r = snapshot.timeframes[tf]?.report;
|
||||||
|
if (r?.event_risk && r.event_risk !== "clear") { risk = r.event_risk; riskDesc = r.event_description; break; }
|
||||||
|
if (r?.event_description) riskDesc = r.event_description;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (news.high_impact_warning) { risk = "danger"; riskDesc = news.warning_message || riskDesc; }
|
||||||
|
chips += `<span class="chip risk-${risk}" title="${esc(riskDesc)}">event risk: ${risk.toUpperCase()}</span>`;
|
||||||
|
$("ctx-chips").innerHTML = chips;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── timeframe cards ──────────────────────────────────────────── */
|
||||||
|
function scoreBar(label, score) {
|
||||||
|
const v = Math.max(-100, Math.min(100, score ?? 0));
|
||||||
|
const pct = Math.abs(v) / 2; // half-track = 100
|
||||||
|
const cls = v >= 0 ? "pos" : "neg";
|
||||||
|
return `<div class="scorebar">
|
||||||
|
<span class="lbl">${label}</span>
|
||||||
|
<div class="track" role="img" aria-label="${label} score ${v.toFixed(1)} of ±100">
|
||||||
|
<div class="fill ${cls}" style="width:${pct}%"></div></div>
|
||||||
|
<span class="val num">${v >= 0 ? "+" : ""}${v.toFixed(1)}</span></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function indGrid(pa) {
|
||||||
|
return `<div class="ind-grid">` + IND_ORDER.map(name => {
|
||||||
|
const ind = pa?.indicators?.[name];
|
||||||
|
const short = IND_SHORT[name];
|
||||||
|
if (!ind) {
|
||||||
|
return `<div class="ind-cell absent" tabindex="0"
|
||||||
|
title="${esc(short)}: no signal this scan"><span class="k">${short}</span><span class="arrow">—</span></div>`;
|
||||||
|
}
|
||||||
|
const cls = sigClass(ind.signal);
|
||||||
|
const tip = `${short} — ${sigLabel(ind.signal)}\n${ind.description || ""}` +
|
||||||
|
(ind.value != null ? `\nvalue ${(+ind.value).toFixed(2)}` : "") +
|
||||||
|
` conf ${Math.round(ind.confidence ?? 0)} w ${ind.weight}`;
|
||||||
|
return `<div class="ind-cell ${cls}" tabindex="0" title="${esc(tip)}">
|
||||||
|
<span class="k">${short}</span><span class="arrow">${ARROW[ind.signal] || "→"}</span></div>`;
|
||||||
|
}).join("") + `</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reasonsBlock(r) {
|
||||||
|
const items = [
|
||||||
|
...(r.warnings || []).map(t => ({cls: "r-warn", t})),
|
||||||
|
...(r.bullish_reasons || []).map(t => ({cls: "r-bull", t})),
|
||||||
|
...(r.bearish_reasons || []).map(t => ({cls: "r-bear", t})),
|
||||||
|
];
|
||||||
|
if (!items.length) return "";
|
||||||
|
const line = i => `<div class="${i.cls}">${esc(i.t)}</div>`;
|
||||||
|
const head = items.slice(0, 3).map(line).join("");
|
||||||
|
if (items.length <= 3) return `<div class="reasons">${head}</div>`;
|
||||||
|
return `<div class="reasons">${head}</div>
|
||||||
|
<details class="more"><summary>${items.length - 3} more…</summary>
|
||||||
|
<div class="reasons">${items.slice(3).map(line).join("")}</div></details>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupCard(ts) {
|
||||||
|
if (!ts) return "";
|
||||||
|
const dir = ts.direction === "BUY" ? "buy" : "sell";
|
||||||
|
const trail = (ts.trail_arm_r != null && ts.trail_dist_r != null)
|
||||||
|
? `arm ${ts.trail_arm_r}R · dist ${ts.trail_dist_r}R` : "platform default";
|
||||||
|
return `<div class="setup ${dir}">
|
||||||
|
<div class="s-head"><span class="dir ${dir}">${ts.direction}</span>
|
||||||
|
<span class="strat">${esc(ts.strategy || "")}</span>
|
||||||
|
<span class="strat" style="margin-left:auto">R:R ${(+ts.risk_reward).toFixed(1)}</span></div>
|
||||||
|
<table>
|
||||||
|
<tr><td>Entry</td><td class="num">${fmt$(ts.entry)}</td>
|
||||||
|
<td style="color:var(--muted);padding-left:16px">TP1</td><td class="num">${fmt$(ts.take_profit_1)}</td></tr>
|
||||||
|
<tr><td>Stop</td><td class="num">${fmt$(ts.stop_loss)}</td>
|
||||||
|
<td style="color:var(--muted);padding-left:16px">TP2</td><td class="num">${fmt$(ts.take_profit_2)}</td></tr>
|
||||||
|
<tr><td>Trail</td><td class="num" colspan="3">${esc(trail)}</td></tr>
|
||||||
|
</table></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mtfRow(r) {
|
||||||
|
const entries = Object.entries(r.mtf_signals || {});
|
||||||
|
if (!entries.length) return "";
|
||||||
|
const chips = entries.map(([tf, sig]) => {
|
||||||
|
const score = r.mtf_scores?.[tf];
|
||||||
|
const cls = sigClass(sig);
|
||||||
|
const color = cls === "buy" ? "var(--bull)" : cls === "sell" ? "var(--bear-text)" : "var(--muted)";
|
||||||
|
return `<span class="mtf-chip">${esc(TF_LABEL[tf] || tf)}
|
||||||
|
<span style="color:${color};font-weight:700">${ARROW[sig] || "→"}</span>
|
||||||
|
<span class="num">${score != null ? (score >= 0 ? "+" : "") + score.toFixed(0) : ""}</span></span>`;
|
||||||
|
}).join("");
|
||||||
|
const agree = r.mtf_agreement;
|
||||||
|
return `<div class="mtf-row"><span>MTF</span>${chips}
|
||||||
|
<span class="agree ${agree ? "yes" : "no"}">${agree ? "✓ aligned" : "✗ mixed"}</span></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmsBlock(r) {
|
||||||
|
const confs = (r.confirmation_analyses || []).filter(Boolean);
|
||||||
|
if (!confs.length) return "";
|
||||||
|
const lines = confs.map(a => {
|
||||||
|
const cls = sigClass(a.composite_signal);
|
||||||
|
const color = cls === "buy" ? "var(--bull)" : cls === "sell" ? "var(--bear-text)" : "var(--muted)";
|
||||||
|
return `<div class="confirm-line">${esc(TF_LABEL[a.timeframe] || a.timeframe)}
|
||||||
|
<span style="color:${color};font-weight:700">${ARROW[a.composite_signal] || "→"}</span>
|
||||||
|
${esc(sigLabel(a.composite_signal))} ${a.composite_score != null ?
|
||||||
|
`<span style="color:var(--muted)">(${a.composite_score >= 0 ? "+" : ""}${a.composite_score.toFixed(1)}, ${esc(a.market_regime || "")})</span>` : ""}</div>`;
|
||||||
|
}).join("");
|
||||||
|
return `<details class="confirms"><summary>confirmation detail</summary>${lines}</details>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCards() {
|
||||||
|
if (!snapshot) return;
|
||||||
|
$("tf-grid").innerHTML = TF_ORDER.map(tf => {
|
||||||
|
const entry = snapshot.timeframes[tf] || {};
|
||||||
|
const r = entry.report;
|
||||||
|
const head = `<div class="tf-head">
|
||||||
|
<span class="tf-name">${TF_LABEL[tf]}</span>`;
|
||||||
|
if (!r) {
|
||||||
|
return `<article class="tf-card">${head}</div>
|
||||||
|
<div class="warming">${entry.status === "error"
|
||||||
|
? `<span class="err-badge" title="${esc(entry.error || "")}">SCAN ERROR</span>`
|
||||||
|
: "warming up — first scan pending…"}</div>
|
||||||
|
<div class="tf-foot"><span class="cd">closes in
|
||||||
|
<span class="num" data-close="${entry.next_close_epoch || ""}">--:--</span></span></div>
|
||||||
|
</article>`;
|
||||||
|
}
|
||||||
|
const pa = r.primary_analysis;
|
||||||
|
const qHi = r.quality === "A+" || r.quality === "A";
|
||||||
|
const regime = [r.market_regime, r.volatility_regime && r.volatility_regime + " vol"]
|
||||||
|
.filter(Boolean).join(" · ");
|
||||||
|
return `<article class="tf-card">
|
||||||
|
${head}
|
||||||
|
<span class="sig-badge ${sigClass(r.signal)}">${sigLabel(r.signal)}</span>
|
||||||
|
${r.quality ? `<span class="q-chip${qHi ? " hi" : ""}" title="Signal quality grade">${r.quality}</span>` : ""}
|
||||||
|
<span class="regime">${esc(regime)}</span>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
${entry.status === "error" ? `<span class="err-badge" title="${esc(entry.error || "")} — showing last good scan">STALE</span>` : ""}
|
||||||
|
</div>
|
||||||
|
${scoreBar("score", pa ? pa.composite_score : r.score)}
|
||||||
|
${pa ? indGrid(pa) : ""}
|
||||||
|
${pa?.atr_context ? `<div class="atr-line">${esc(pa.atr_context.description ||
|
||||||
|
"ATR " + (+pa.atr_context.value).toFixed(2))}</div>` : ""}
|
||||||
|
${pa?.pattern_signals?.length ? `<div class="patterns">${pa.pattern_signals.map(p =>
|
||||||
|
`<span class="pat-tag">${esc(p)}</span>`).join("")}</div>` : ""}
|
||||||
|
${mtfRow(r)}
|
||||||
|
${setupCard(r.trade_setup)}
|
||||||
|
${reasonsBlock(r)}
|
||||||
|
${confirmsBlock(r)}
|
||||||
|
<div class="tf-foot">
|
||||||
|
<span>scanned ${fmtAge(entry.scanned_at)}${entry.scan_seconds ? ` (${entry.scan_seconds}s)` : ""}</span>
|
||||||
|
<span class="cd">closes in <span class="num" data-close="${entry.next_close_epoch || ""}">--:--</span></span>
|
||||||
|
</div>
|
||||||
|
</article>`;
|
||||||
|
}).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── calendar ─────────────────────────────────────────────────── */
|
||||||
|
function renderCalendar() {
|
||||||
|
const body = $("cal-body");
|
||||||
|
if (!news) return;
|
||||||
|
const hiOnly = $("hi-only").checked;
|
||||||
|
let events = (news.events || []);
|
||||||
|
if (hiOnly) events = events.filter(e => e.impact === "high");
|
||||||
|
if (!events.length) {
|
||||||
|
body.innerHTML = `<tr><td colspan="7" class="empty">${
|
||||||
|
news.calendar_available ? "No matching events this week." : "Calendar unavailable — source could not be reached."}</td></tr>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const now = nowServer();
|
||||||
|
let nextUpMarked = false, lastDay = "", rows = "";
|
||||||
|
for (const e of events) {
|
||||||
|
const t = Date.parse(e.time) / 1000;
|
||||||
|
const d = new Date(t * 1000);
|
||||||
|
const day = d.toLocaleDateString(undefined, {weekday: "short", month: "short", day: "numeric"});
|
||||||
|
if (day !== lastDay) {
|
||||||
|
rows += `<tr class="day-row"><td colspan="7">${esc(day)}</td></tr>`;
|
||||||
|
lastDay = day;
|
||||||
|
}
|
||||||
|
const past = t < now;
|
||||||
|
const nextUp = !past && !nextUpMarked && (nextUpMarked = true);
|
||||||
|
const time = d.toLocaleTimeString(undefined, {hour: "2-digit", minute: "2-digit"});
|
||||||
|
const surp = e.surprise
|
||||||
|
? `<span class="surp ${e.surprise}" title="actual ${e.surprise === "positive" ? "above" :
|
||||||
|
e.surprise === "negative" ? "below" : "matches"} forecast (numeric only — not gold direction)">${
|
||||||
|
e.surprise === "positive" ? "▲" : e.surprise === "negative" ? "▼" : "="}</span>` : "";
|
||||||
|
rows += `<tr class="${past ? "past" : ""}${nextUp ? " next-up" : ""}">
|
||||||
|
<td class="t num">${esc(time)}</td>
|
||||||
|
<td class="cur">${esc(e.currency)}</td>
|
||||||
|
<td><span class="imp ${esc(e.impact)}">${e.impact === "medium" ? "Med" :
|
||||||
|
e.impact.charAt(0).toUpperCase() + e.impact.slice(1)}</span></td>
|
||||||
|
<td class="ev">${esc(e.event)}</td>
|
||||||
|
<td class="num">${esc(e.forecast ?? "—")}</td>
|
||||||
|
<td class="num">${esc(e.previous ?? "—")}</td>
|
||||||
|
<td class="num">${esc(e.actual ?? "—")} ${surp}</td></tr>`;
|
||||||
|
}
|
||||||
|
body.innerHTML = rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── newswire ─────────────────────────────────────────────────── */
|
||||||
|
function renderWire() {
|
||||||
|
if (!news) return;
|
||||||
|
const items = (news.headlines || []).slice(0, 40);
|
||||||
|
$("wire-note").textContent = news.generated_at ? `updated ${fmtAge(news.generated_at)}` : "";
|
||||||
|
if (!items.length) {
|
||||||
|
$("wire").innerHTML = `<div class="empty">No recent gold/dollar headlines.</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const item = h => {
|
||||||
|
const s = h.score;
|
||||||
|
const cls = s == null || Math.abs(s) < 0.05 ? "zero" : s > 0 ? "pos" : "neg";
|
||||||
|
const scoreTxt = s == null ? "·" : (s > 0 ? "+" : "") + s.toFixed(1);
|
||||||
|
let host = "";
|
||||||
|
try { host = new URL(h.source).hostname.replace(/^www\./, ""); } catch {}
|
||||||
|
return `<div class="wire-item">
|
||||||
|
<span class="wire-score ${cls}" title="Headline sentiment ${esc(String(s ?? "n/a"))} (−1…+1)${
|
||||||
|
h.currencies?.length ? " · " + esc(h.currencies.join(", ")) : ""}">${scoreTxt}</span>
|
||||||
|
<span class="wire-h">${esc(h.headline)}</span>
|
||||||
|
<span class="wire-meta">${esc(host)}${h.timestamp ? " · " + fmtAge(h.timestamp) : ""}</span></div>`;
|
||||||
|
};
|
||||||
|
const head = items.slice(0, 12).map(item).join("");
|
||||||
|
const rest = items.slice(12);
|
||||||
|
$("wire").innerHTML = head + (rest.length
|
||||||
|
? `<details class="more" style="padding-top:6px"><summary>${rest.length} more headlines…</summary>
|
||||||
|
<div class="wire">${rest.map(item).join("")}</div></details>` : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── polling ──────────────────────────────────────────────────── */
|
||||||
|
async function fetchJSON(name) {
|
||||||
|
const res = await fetch(`${name}?t=${Date.now()}`, {cache: "no-store"});
|
||||||
|
if (!res.ok) throw new Error(`${name}: HTTP ${res.status}`);
|
||||||
|
/* The Date header is stamped at RESPONSE time — snapshot.server_time_epoch
|
||||||
|
is stamped at publish time, minutes stale between candle closes. */
|
||||||
|
const dateHdr = res.headers.get("Date");
|
||||||
|
const data = await res.json();
|
||||||
|
return {data, serverNow: dateHdr ? Date.parse(dateHdr) / 1000 : null};
|
||||||
|
}
|
||||||
|
|
||||||
|
let refetchTimer = null;
|
||||||
|
async function poll() {
|
||||||
|
try {
|
||||||
|
const [snap, nw] = await Promise.all([
|
||||||
|
fetchJSON("snapshot.json"),
|
||||||
|
fetchJSON("news.json").catch(() => null), // news outage ≠ page failure
|
||||||
|
]);
|
||||||
|
snapshot = snap.data;
|
||||||
|
news = (nw && nw.data) || news;
|
||||||
|
clockOffset = (snap.serverNow ?? snap.data.server_time_epoch) - Date.now() / 1000;
|
||||||
|
lastPollOk = new Date();
|
||||||
|
$("banner-fail").style.display = "none";
|
||||||
|
renderHero(); renderContext(); renderCards(); renderCalendar(); renderWire();
|
||||||
|
tick(); // countdowns appear immediately, not after the first 1 s tick
|
||||||
|
|
||||||
|
/* one-shot refetch shortly after the next expected publish, so a
|
||||||
|
fresh scan appears within seconds of the candle close */
|
||||||
|
const nextScan = Math.min(...TF_ORDER.map(tf =>
|
||||||
|
snapshot.timeframes[tf]?.next_scan_epoch || Infinity));
|
||||||
|
if (isFinite(nextScan)) {
|
||||||
|
clearTimeout(refetchTimer);
|
||||||
|
const delay = (nextScan - nowServer() + 6) * 1000;
|
||||||
|
if (delay > 0 && delay < POLL_MS) refetchTimer = setTimeout(poll, delay);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const last = lastPollOk ? ` Last update ${lastPollOk.toLocaleTimeString()}.` : "";
|
||||||
|
const b = $("banner-fail");
|
||||||
|
b.textContent = `Data feed unreachable (${err.message}).${last} Retrying…`;
|
||||||
|
b.style.display = "block";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 1 s countdown ticker (server-clock anchored) */
|
||||||
|
function tick() {
|
||||||
|
if (!snapshot) return;
|
||||||
|
const now = nowServer();
|
||||||
|
document.querySelectorAll("[data-close]").forEach(el => {
|
||||||
|
let t = parseFloat(el.dataset.close);
|
||||||
|
if (!t) return;
|
||||||
|
const tf = TF_ORDER.find(k => snapshot.timeframes[k]?.next_close_epoch === parseFloat(el.dataset.close));
|
||||||
|
let secs = t - now;
|
||||||
|
if (secs < 0) { // boundary passed while waiting for next publish
|
||||||
|
const interval = {"5min": 300, "15min": 900, "1h": 3600, "4h": 14400}[tf] || 300;
|
||||||
|
secs = ((secs % interval) + interval) % interval;
|
||||||
|
}
|
||||||
|
el.textContent = fmtCountdown(secs);
|
||||||
|
});
|
||||||
|
const m5 = snapshot.timeframes["5min"]?.next_close_epoch;
|
||||||
|
if (m5) {
|
||||||
|
let secs = m5 - now;
|
||||||
|
if (secs < 0) secs = ((secs % 300) + 300) % 300;
|
||||||
|
$("hero-cd").textContent = fmtCountdown(secs);
|
||||||
|
}
|
||||||
|
if (lastPollOk) $("hero-updated").textContent = `updated ${fmtAge(lastPollOk.toISOString())}`;
|
||||||
|
}
|
||||||
|
setInterval(tick, 1000);
|
||||||
|
|
||||||
|
$("hi-only").addEventListener("change", renderCalendar);
|
||||||
|
poll();
|
||||||
|
setInterval(poll, POLL_MS);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Executable
+1427
File diff suppressed because it is too large
Load Diff
+5318
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user