25 Commits

Author SHA1 Message Date
harwinder 4db6121ca7 Fix trade setup generation, M5 data-only mode, WS symbol limits, command menu
- Trade setup TP now scales to SL distance ensuring min RR is met when
  no viable swing target exists; skip swing targets with poor RR
- Add float tolerance to RR gate to prevent edge-case rejection
- M5 auto-scan runs for level cache only, no Telegram broadcast
- WebSocket limited to EUR/USD + XAU/USD (Grow plan constraint)
- Register bot command menu via set_my_commands on startup

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-11 18:58:40 -04:00
harwinder 5e12307b1f Suppress Telegram messages when no actionable signals found
Auto-scan still runs and updates the level cache every cycle,
but only sends to Telegram when quality A+/A/B/C signals exist.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-11 18:03:49 -04:00
harwinder 4be7077c0a QA fixes: H4 timing bug, /status + /help commands, WS cold-start
- Fix H4 boundary calculation using total minutes (hour*60+min) not just minute
- Add /help command listing all bot commands
- Add /status command showing WS connection, live prices, level cache, cooldowns
- Run initial M15 scan on boot to populate level cache before WS starts
- Update /start message to mention new commands

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-11 17:16:07 -04:00
harwinder 736e33302b Tune engine from backtesting: wider SL, capped TP, counter-trend filter
Backtest-driven fixes (0% win → 33% win, PF 0.00 → 1.05 over 7 days):
- SL: swing buffer 0.5→1.0 ATR, min distance 1.0→1.5 ATR (more room to breathe)
- TP: cap swing targets at ATR×3.0, fallback ATR×2.0 (was targeting unreachable levels)
- Mature-move TP: when score>30 (A/B grade), tighten TP to ATR×1.5 (move already extended)
- Counter-trend penalty: -15 confidence when signal fights regime direction
- EMA200 penalties halved: -20→-10 (was killing EUR/USD signals)
- Score thresholds lowered: BUY/SELL ±15→±12, STRONG ±40→±35
- Quality grades relaxed, C-grade now gets trade setups and Telegram alerts
- min_rr_ratio: 1.5→1.2
- Gitignore backtest/ directory

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-11 17:00:12 -04:00
harwinder a7bed99caa Buy signal included 2026-05-11 16:37:45 -04:00
harwinder dc9c76a716 Pro tier upgrade: quant rearchitect + WebSocket level-breach monitor
- Restore XAU/USD (Pro plan), drop USD/CAD, raise rate limit to 55/min
- Reduce signal engine to 4 uncorrelated pillars (EMA, RSI, BB, Candlestick)
  with regime-adaptive weights (trending boosts EMA, ranging boosts RSI+BB)
- Add structure-based S/R (swing highs/lows) for trade setup targeting
- Fix confidence death spiral: all adjustments now additive, not multiplicative
- Dynamic R:R trade setup: SL behind swing level, TP at next structure level,
  reject if R:R < 1.5
- Fix news engine for all 4 pairs (was hardcoded to USDJPY)
- Add WebSocket monitor: streams live ticks, triggers M15 scan on level breach
  with 5-min cooldown per pair — catches breakouts between candle boundaries
- Add correlation filter (EUR/USD+GBP/USD, XAU/USD+USD/JPY exposure warnings)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-11 16:33:23 -04:00
harwinder a15dc40050 Replace XAU/USD with USD/CAD — TwelveData has no gold data
XAU/USD is not available in TwelveData's symbol list.
Swapped to USD/CAD across config, scan pairs, Telegram buttons,
and added CAD to tracked news currencies.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-11 15:50:48 -04:00
harwinder b3e0da7455 Add 4-pair candle-boundary auto-scanner (free tier safe)
Expand from 2 to 4 pairs (EUR/USD, USD/JPY, GBP/USD, XAU/USD) with
automatic scanning at M15 and H1 candle close boundaries. Scheduler
staggers H1 by 60s to stay within 8 credits/min; daily budget is
480 of 800 allowed. Cache TTL is now candle-duration-aware so cross-TF
confirmation data is reused without extra API calls. Spread estimate
uses primary-TF data instead of fetching M5 separately.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-11 15:30:11 -04:00
Harwinder 037dec643e Add EUR/USD support and 4-button Telegram scanner
Add EURUSD pair with dynamic pair info lookup. Telegram bot now shows
4 scan buttons: EUR/USD 15M, EUR/USD 1H, USD/JPY 15M, USD/JPY 1H.
Each indicator displays directional arrows in the message output.
Price formatting adapts per pair (5dp for EUR, 3dp for JPY).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 23:49:36 -04:00
Harwinder 3a09dfd62f Strip backtesting, rebalance indicator weights toward leading signals
Remove backtest.py, backtest_gpu.py, metrics.py, local CSV data,
Dukascopy loader, and all backtest CLI modes from main.py. Rebalance
engine weights: leading indicators (divergence, RSI, stochastic, BB)
now carry 66% of signal weight vs 34% for lagging (EMA, MACD). Add
ws_twelvedata.py for live tick streaming. Drop pyarrow and tqdm deps.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 23:40:15 -04:00
Harwinder 353ff05267 Add USAGE_LINUX.md — bot and CLI run instructions for Linux server
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 23:12:55 -04:00
Harwinder a596e7cab2 Add async Telegram bot — live scan button with trade setup
Introduces telegram_bot.py: python-telegram-bot v20+ async bot that
presents a persistent Scan Signal inline button. Each press fetches a
live USD/JPY signal via SignalAggregator and formats it with emoji,
MTF breakdown, top bull/bear reasons, and warnings. STRONG_BUY /
STRONG_SELL signals additionally render the full trade setup (entry,
SL, TP1, TP2, RR). Adds python-telegram-bot>=20.0 to requirements.txt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 23:05:01 -04:00
Harwinder c72e8e669b Round-2 weight optimisation — reduce EMA trend, boost stochastic
GPU sweep (36 combos, RTX 4070 SUPER) on H4 2022-2026 relative to the
round-1 baseline revealed EMA trend was over-weighted at 3.6. Halving
it and nudging stochastic up yields cleaner, higher-quality signals.

Weight changes (backtest_gpu.py _BASE_W + engine.py):
  ema_trend:  3.6 -> 1.8  (over-weighted; halving tightens signal filter)
  stochastic: 1.6 -> 2.4  (x1.5; improved WR to 40.4% in sweep)
  macd:       3.3          (unchanged — dominant signal, kept as anchor)
  adx/ichimoku/rsi/bollinger: unchanged

Combined result vs round-1 (H4 local data 2022-2026):
  Trades:      160 -> 73   (-87 — filters out marginal setups)
  Win Rate:   38.1% -> 43.8%  (+5.7pp)
  Profit Factor: 1.19 -> 1.52
  Return:    +21.30% -> +23.86%  (+2.57pp)
  Max DD:     -8.52% -> -9.32%
  Sharpe:      0.47  ->  0.71   (+51%)
  Sortino:     1.91  ->  3.06   (+60%)

Fewer trades at much higher quality. The lower EMA weight means only
strong, multi-indicator confluences clear the grade threshold — the
73 remaining trades have 43.8% WR vs 38.1% and expectancy nearly 3x.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 22:24:48 -04:00
Harwinder 3d9f4dc201 Enable GPU scoring via CuPy — RTX 4070 SUPER confirmed 6.3x speedup
Install nvidia pip packages for CUDA 12 runtime DLLs (no full toolkit needed):
  nvidia-cuda-nvrtc-cu12, nvidia-cuda-runtime-cu12,
  nvidia-curand-cu12, nvidia-cublas-cu12

Add _register_nvidia_dlls() in backtest_gpu.py that auto-discovers and
registers nvidia package DLL directories (site-packages/nvidia/*/bin/)
via os.add_dll_directory() before CuPy is imported. Suppresses the
spurious "CUDA path could not be detected" warning from cupy._environment.

Backend line now reads: CuPy 14.0.1 (RTX GPU)
Benchmark: scoring pass on 100k bars — NumPy 1.1ms vs CuPy 0.18ms = 6.3x.
Results are identical to CPU path (deterministic).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 22:22:21 -04:00
Harwinder e30535f5e9 GPU backtester, optimised weights, local CSV data pipeline
## What changed

### backtest_gpu.py (new — ~1,300 lines)
Standalone GPU-accelerated backtester completely separate from backtest.py.
Computes all indicator series in one vectorised O(N) pass over the full price
history, then replays positions with a sequential state machine.

Key design:
- VectorIndicators: pandas ewm/rolling (C-optimised) for all 7 indicators
- CuPy GPU backend with automatic NumPy fallback (no hard dependency)
- Two-phase API: prepare_arrays() once, score_and_run() many times
- --optimize flag: 36-combo weight sweep (1 baseline + 7 ablations + 28
  multiplier runs) — calls prepare_arrays() once, replays all combos in seconds
- Full CLI: --tf, --local, --start/--end, --regime-filter, --regime-tf,
  --session-filter, --optimize, --journal, --journal-csv

O(N) vs O(N^2): backtest.py recomputes all indicators per bar (O(N^2));
backtest_gpu.py computes each indicator series once and reads bar T in O(1).
On 100,000 H1 bars: ~4 seconds vs >10 minutes.

### data_provider.py
Added load_local_csv() method — reads MetaTrader tab-delimited CSV exports
from market_data/ directory. Format: Time|Open|High|Low|Close|Volume|Spread
(spread column dropped on load, timestamps localised to UTC). Applies the same
_validate_and_clean() pipeline as the Twelve Data path. LOCAL_CSV_MAP maps
each Timeframe enum to its filename.

market_data/ files added (MetaTrader exports, UTC):
  USDJPY_M15.csv  100,000 bars  2022-05-04 to 2026-05-08
  USDJPY_H1.csv   100,000 bars  2010-04-23 to 2026-05-08
  USDJPY_H4.csv    25,855 bars  2010-04-22 to 2026-05-08
  USDJPY_D1.csv     5,020 bars  2010-04-22 to 2026-05-08

16 years of H1/H4/D1 vs ~6 weeks of H1 on Twelve Data free plan. Use --local
for all backtests; Twelve Data reserved for live signal feed.

### engine.py — optimised indicator weights
36-combo sweep on H4 2022-2026 found MACD and EMA trend are the dominant
signals; RSI and Bollinger are net drag on this trending pair.

Old → new weights:
  ema_trend:  1.2 → 3.6  (×3 — strongest directional signal)
  macd:       1.1 → 3.3  (×3 — ablating it is the single worst move: -42%)
  stochastic: 0.8 → 1.6  (×2 — clean timing, 40% WR at this level)
  adx:        1.0 → 0.5  (÷2 — partially redundant with EMA stack)
  ichimoku:   1.1 → 0.55 (÷2 — lags too much at H4, hurts in ranging)
  rsi:        0.3 → 0.0  (ablated — net drag across all test windows)
  bollinger:  0.9 → 0.0  (ablated — counter-trend noise on trending pair)

RSI and Bollinger are still computed and displayed in live output for
reference; weight=0 means they contribute nothing to the composite score.

Combined result vs baseline (H4 2022-2026, local data):
  Return:   +7.12% → +21.30%  (+14.2pp)
  Max DD:   -9.05% → -8.52%   (tighter — gains did not come at expense of risk)
  Sharpe:    0.23  →  0.47    (2x improvement)
  Sortino:   1.38  →  1.91
  Win Rate: 36.3%  → 38.1%

### backtest.py
Added 16-thread CPU parallelism for signal pre-computation via
ThreadPoolExecutor. _precompute_all_signals() dispatches each bar to a worker;
pandas/NumPy C extensions release the GIL enabling true parallel execution.
Results are collected and replayed in the main loop, replacing the per-bar
recompute. Also added --regime-tf CLI flag so regime filter timeframe can be
specified independently of the primary TF.

### signal_aggregator.py
EMA200 distance gate: confidence *= 0.55 when price is within 0.5% of EMA200
(chop zone) or beyond 1.5% (overextended). Sweet spot is 0.5-1.5% from EMA200
where trending momentum is cleanest. Strongest single filter found: lifted
cumulative return +13.88% to +35.50% in ablation window.

### ENGINE_LOGIC.md (new)
Full reference document covering: architecture, data pipeline, all indicator
logic, composite scoring formula, optimised weights with rationale, regime
classification, EMA200 gate, MTF alignment, grade system, backtesting results,
filter catalogue, and configuration reference.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 22:12:43 -04:00
Harwinder a687db9413 Add EMA200 distance gate — strongest single signal-quality filter
Across 2024 trend / 2023 chop / 2026 hostile windows, distance from the
H1 EMA200 was empirically the most discriminative variable. Sweet spot
0.5-1.5% from EMA200; closer = false-breakout chop, farther = chase.

Outside the band, confidence is multiplied by 0.55 so the trade falls
below the B-grade threshold and is rejected.

Cumulative return across the 3 windows: +13.88% baseline -> +35.50%.
Win rate improved in every regime; 2023 chop turned positive (-2.56% to
+14.94%).

Also extends the trade journal with per-timeframe MTF scores, RSI at
entry, and EMA200 distance for future filter mining; surfaces ema_trend
in key_levels so the gate can read it.
2026-05-07 19:28:19 -04:00
Harwinder b7d03a9b61 Document session filter and auto-toggle flags in USAGE
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 18:44:41 -04:00
Harwinder b38ed17c96 Add session filter + auto-toggle, extend trade journal diagnostics
Session filter restricts trades to a UTC hour window (default 07-16,
London + NY) — opt-in via --session-filter, with --session-start /
--session-end overrides. Wired into both the backtester (cheap hour
check next to the regime gate) and live mode (downgrades signal to
NEUTRAL/F when current UTC hour is outside the window).

Auto-toggle (--auto-session-filter) tracks rolling mean pnl_pct over
the last N closed trades; when it falls below threshold, the session
filter activates, releasing once expectancy recovers. Default lookback
10 trades, threshold -0.3%.

Trade dataclass extended with diagnostic fields captured at entry:
entry_hour, entry_dow, ADX, ATR%, market_regime, volatility_regime,
mtf_agreement. These let --journal-csv exports surface loss-pattern
clusters when a window underperforms.

Validated on 2024 trend / 2023 chop / 2026 recent windows. Auto-toggle
preserves ~50% of trend edge while still cutting ~25% of recent-regime
losses — pure regime filter is still best on cumulative, so both
session flags ship default-off.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 18:38:49 -04:00
Harwinder dcd98fffa2 Swap raw requests for Twelve Data Python SDK
data_provider now uses TDClient.time_series().as_pandas() instead of
hand-rolled REST calls. Throttle, validation, and parquet caching are
unchanged. Adds twelvedata>=1.2.0 to requirements.txt.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 18:38:38 -04:00
Harwinder 70990532a3 Compact hierarchical live signal output
Restructure print_signal_report so decision (signal · grade · score ·
confidence · MTF) sits at the top, followed by trade setup, context line,
MTF breakdown, top supporting/opposing reasons in two columns, warnings,
and an optional detail block under --detail. Adds _compact_reason helper
that strips known indicator name prefixes via _INDICATOR_PREFIXES so
reason lines stay short.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 17:53:50 -04:00
Harwinder 53e6e07829 Wire regime filter into live mode
--regime-filter previously affected only backtests. Live mode now also
checks higher-TF ADX after analysis: signals are downgraded to NEUTRAL
with a warning when ADX < threshold, and confirming when above.

This brings live behavior into parity with what the backtester measures.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 17:40:39 -04:00
Harwinder 7699e6dd1d Add indicator ablation framework + apply ablation-driven cleanup
Framework:
- TechnicalConfig.disabled_indicators skips named indicators in engine.analyze
- Optimizer supports disable_indicator override for parallel ablation sweeps
- main.py --ablate runs leave-one-out across all 10 directional indicators

Cleanup applied (validated across 2024 trend / 2023 chop / 2026 recent):
- Drop pivot by default (cumulative +9.5% to disable)
- Drop candlestick by default (+3.4% cumulative)
- Regime-gate ichimoku: 1.3x in trending, 0.2x in ranging (was a chop villain)
- Trim RSI weight 1.0 -> 0.3 (cumulative noise across regimes)

Result: +10.60% on 2024 trend (+33.24% -> +43.84%, Sharpe 2.17 -> 2.77),
+15.71% on 2023 chop (-18.27% -> -2.56%). Recent regime still adverse.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 17:33:07 -04:00
Harwinder 636f0304ae Refocus on USD/JPY and add walk-forward backtester with regime filter
- Strip multi-pair plumbing; drop synthetic-data fallback
- Add BacktestConfig + walk-forward replay through the live signal pipeline
- Add metrics module (PF, Sharpe, Sortino, max DD, trade journal)
- Add parallel grid optimizer with disk parquet cache
- Add H4 ADX regime gate (--regime-filter, --regime-min-adx) — cuts chop
  losses ~84% in 2023 H1 while preserving trend edge

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 17:15:41 -04:00
Harwinder 6ebdc34cd3 Remove redundant script.txt and tracked __pycache__
script.txt was superseded by requirements.txt. Python bytecode should
not be version-controlled — added .gitignore to keep cache and venv
out of the repo going forward.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 22:25:43 -04:00
Harwinder bc1844b233 Add requirements.txt and usage guide
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 10:51:03 -04:00
17 changed files with 1983 additions and 1213 deletions
+25
View File
@@ -0,0 +1,25 @@
# Secrets
.env
.env.local
# Editor / tooling
.claude/
.vscode/
.idea/
# Python
__pycache__/
*.pyc
*.egg-info/
.venv/
venv/
# OS
.DS_Store
Thumbs.db
# Logs
*.log
# Backtesting (scripts, data, results)
backtest/
+198
View File
@@ -0,0 +1,198 @@
# ForexBot2 — USD/JPY Signal Intelligence
A focused USD/JPY signal generator + walk-forward backtester. Live signals
and backtests run through the **same** technical engine, so what you measure
in a backtest is exactly what you'd execute live.
## Setup
```bash
pip install -r requirements.txt
```
API key (optional — a default key is in `config.py`):
```bash
export TWELVE_DATA_API_KEY=your_key_here # Linux/Mac
$env:TWELVE_DATA_API_KEY="your_key_here" # PowerShell
```
---
## Live Signal
```bash
python main.py # H1, summary view
python main.py --detail # Full indicator breakdown
python main.py --timeframe H4 --detail # H4 instead of H1
python main.py --json # JSON output (for piping)
```
| Option | Default | Description |
|---------------|---------|--------------------------------------------|
| `--timeframe` | `H1` | `M5 M15 M30 H1 H4 D1` |
| `--detail` | off | Indicator breakdown, key levels, reasons |
| `--json` | off | Machine-readable output |
| `--verbose` | off | Debug logging |
### Output
```
USD/JPY │ Price: 149.230 │ Regime: trending_up │ Vol: normal
SIGNAL: BUY │ Score: +28.4 │ Grade: B │ Confidence: 62%
Action: BUY — Decent setup, wait for confirmation
Multi-Timeframe: ✓ ALIGNED
M15: BUY (+18.2)
H1: BUY (+28.4)
H4: STRONG BUY (+45.1)
Trade Setup:
Direction: BUY
Entry: 149.230
Stop Loss: 148.960 (27.0 pips)
Take Profit 1: 149.770 (54.0 pips)
Take Profit 2: 150.040
Risk:Reward: 1:2.0
```
---
## Backtest (Walk-Forward)
The backtester replays USD/JPY history bar-by-bar through the live signal
pipeline. No lookahead, no parallel implementation — what passes in
backtest passes live.
```bash
python main.py --backtest # Full available history
python main.py --backtest --bars 2000 # Last 2000 H1 bars
python main.py --backtest --start 2024-01-01 --end 2024-12-31
python main.py --backtest --timeframe H4 # Backtest on H4
python main.py --backtest --min-grade A # Only A/A+ entries
python main.py --backtest --journal # Last 10 trades printed
python main.py --backtest --journal-csv trades.csv # Full journal to CSV
python main.py --backtest --regime-filter # Skip choppy/ranging bars
python main.py --backtest --regime-filter --regime-min-adx 35
python main.py --backtest --session-filter # Trade only 07-16 UTC
python main.py --backtest --session-filter --session-start 8 --session-end 17
python main.py --backtest --auto-session-filter # Reactive session toggle
python main.py --backtest --auto-session-filter --auto-session-lookback 15 \
--auto-session-threshold-pct -0.5
```
### Regime filter
The engine is a confluence-based trend-follower — it makes money in trending
regimes and loses in chop. `--regime-filter` gates entries on H4 ADX:
signals only fire when ADX ≥ `--regime-min-adx` (default 30). Sweet spot is
ADX 30–35; tighter cuts trades faster than it improves quality.
### Session filter
`--session-filter` restricts entries to a UTC hour window (default 07-16,
covering London + NY). Diagnostic on 2025-11 → 2026-05 showed 47% WR /
+127 pips inside that window vs 0% WR / -571 pips outside it. Override
bounds with `--session-start` (inclusive) and `--session-end` (exclusive).
Tradeoff: it's regime-specific medicine. In trending regimes (e.g. 2024)
it cuts winning off-hours trades and gives back ~33% of the edge. Ships
**default off** because the pure regime filter still wins cumulative
across the 2024 / 2023 / 2026 windows.
### Auto session toggle
`--auto-session-filter` is a reactive variant: it tracks the rolling mean
`pnl_pct` over the last N closed trades (`--auto-session-lookback`,
default 10) and activates the 07-16 UTC window only when that mean drops
below `--auto-session-threshold-pct` (default `-0.3`). Releases
automatically once expectancy recovers.
Less aggressive than the manual flag — preserves more trend edge but
reacts slowly because the deque must fill with losses before kicking in.
Use this when you don't want to manually toggle the regime medicine.
### Execution model
| Aspect | Behavior |
|--------------------|-------------------------------------------------------------------|
| Entry | Next bar's open (no current-bar lookahead) |
| Stop Loss | 1.5 × ATR from entry (configurable) |
| Take Profit | RR × stop distance (default 1:2) |
| Same-bar SL & TP | Conservative: SL fills first |
| Spread cost | Half-spread on entry + half-spread on exit (full round-trip) |
| Slippage | 0.5 pips per fill (entry + exit) |
| Position size | 1% of current equity risked per trade |
| Concurrency | One open position at a time |
| News in backtest | Disabled — RSS/calendar can't be replayed without lookahead |
### Output
```
Backtest Summary — USD/JPY
─────────────────────────────────────────────────
Trades: 87 (52W / 35L)
Win Rate: 59.8%
Profit Factor: 1.84
Expectancy: +12.4 pips/trade (+12.42 per trade)
Initial Balance: $10,000.00
Final Balance: $11,084.00
Total Return: +10.84%
CAGR: +18.92%
Max Drawdown: -4.21%
Sharpe Ratio: 1.42
Sortino Ratio: 2.18
Avg Duration: 14.3 bars
Win Streak: 7 | Loss Streak: 4
─────────────────────────────────────────────────
```
---
## Parameter Optimization (parallel)
```bash
python main.py --backtest --optimize
```
Runs a 3×3×3 grid (27 combos) over `atr_sl_multiplier`, `default_rr_ratio`,
and `min_score` (signal-score threshold). Combos run in parallel across all
CPU cores via `multiprocessing.Pool`. Twelve Data is hit once — workers
share an on-disk parquet cache (`.cache/usdjpy_<tf>.parquet`).
Output ranks combos by total return:
```
Top 10 Parameter Combinations (by total return):
# Return% PF WinR Trades MaxDD% Sharpe Params
──────────────────────────────────────────────────────────────────────
1 +14.21% 2.10 62.5% 64 -3.84 1.78 atr_sl_multiplier=1.5, default_rr_ratio=2.5, min_score=25.0
2 +11.93% 1.92 58.7% 71 -4.11 1.55 atr_sl_multiplier=1.0, default_rr_ratio=2.0, min_score=15.0
...
```
Customize the grid in `config.py → BacktestConfig.param_grid`.
---
## Files
| File | Role |
|-----------------------|---------------------------------------------------|
| `main.py` | CLI entry point (live + backtest + optimize) |
| `config.py` | All parameters in one place |
| `data_provider.py` | Twelve Data fetcher + parquet cache |
| `engine.py` | Technical indicators (8 + divergence + patterns) |
| `signal_aggregator.py`| Multi-TF aggregation, grading, trade setup |
| `news_engine.py` | RSS sentiment + economic calendar (live only) |
| `backtest.py` | Walk-forward replay + parallel optimizer |
| `metrics.py` | Win rate, PF, Sharpe, max DD, trade journal |
---
## Disclaimer
For educational and research use. Past performance does not guarantee
future results. Always use proper risk management.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Regular → Executable
+62 -78
View File
@@ -1,8 +1,6 @@
""" """
Forex Signal Intelligence Platform - Configuration ForexBot2 — Configuration
=================================================== ==========================================
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
@@ -37,160 +35,146 @@ class MarketSession(Enum):
OVERLAP_LONDON_NY = "london_ny_overlap" OVERLAP_LONDON_NY = "london_ny_overlap"
# ── Major Forex Pairs ────────────────────────────────────────────── # ── Supported pairs ──────────────────────────────────────────────
MAJOR_PAIRS = [ USDJPY = "USDJPY=X"
"EURUSD=X", "GBPUSD=X", "USDJPY=X", "USDCHF=X", EURUSD = "EURUSD=X"
"AUDUSD=X", "USDCAD=X", "NZDUSD=X", GBPUSD = "GBPUSD=X"
] XAUUSD = "XAU/USD"
CROSS_PAIRS = [ PAIR_INFO = {
"EURGBP=X", "EURJPY=X", "GBPJPY=X", "AUDJPY=X", USDJPY: {"display": "USD/JPY", "pip_value": 0.01, "is_jpy_pair": True},
"EURCHF=X", "CADJPY=X", EURUSD: {"display": "EUR/USD", "pip_value": 0.0001, "is_jpy_pair": False},
] GBPUSD: {"display": "GBP/USD", "pip_value": 0.0001, "is_jpy_pair": False},
XAUUSD: {"display": "XAU/USD", "pip_value": 0.10, "is_jpy_pair": False},
# 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",
} }
# Defaults (kept for backward compat)
DISPLAY = "USD/JPY"
PIP_VALUE = 0.01
@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
# 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_k: int = 14
stoch_d: int = 3 stoch_d: int = 3
stoch_overbought: float = 80.0 stoch_overbought: float = 80.0
stoch_oversold: float = 20.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
ichimoku_tenkan: int = 9 ichimoku_tenkan: int = 9
ichimoku_kijun: int = 26 ichimoku_kijun: int = 26
ichimoku_senkou_b: int = 52 ichimoku_senkou_b: int = 52
# Volume profile
volume_ma_period: int = 20 volume_ma_period: int = 20
# Pivot points lookback
pivot_lookback: int = 1 pivot_lookback: int = 1
# Divergence detection — how many bars back to scan for swing points
divergence_lookback: int = 14 divergence_lookback: int = 14
swing_sr_lookback: int = 50
disabled_indicators: List[str] = field(default_factory=list)
@dataclass @dataclass
class SignalConfig: class SignalConfig:
"""Signal scoring and filtering parameters.""" """Signal scoring & filtering."""
# Minimum confidence to emit a signal (0-100)
min_confidence: float = 55.0 min_confidence: float = 55.0
# Weight allocation for composite score
weights: Dict[str, float] = field(default_factory=lambda: { weights: Dict[str, float] = field(default_factory=lambda: {
"trend": 0.25, # EMA alignment, ADX "ema_trend": 1.0,
"momentum": 0.20, # RSI, Stochastic, MACD "rsi": 1.5,
"volatility": 0.15, # BB position, ATR regime "bollinger": 1.5,
"price_action": 0.15, # Candlestick patterns, S/R "candlestick": 1.5,
"multi_timeframe": 0.15,# Higher TF confirmation "divergence": 1.0,
"sentiment": 0.10, # News/calendar events
}) })
# Multi-timeframe confirmation requirements mtf_agreement_threshold: int = 2 # of 3 timeframes
mtf_agreement_threshold: int = 2 # out of 3 timeframes must agree
# Signal cooldown (prevent over-signalling same direction)
cooldown_bars: int = 5 cooldown_bars: int = 5
# Risk management defaults default_risk_pct: float = 1.0
default_risk_pct: float = 1.0 # risk 1% per trade default_rr_ratio: float = 2.0
default_rr_ratio: float = 2.0 # 1:2 risk-reward minimum atr_sl_multiplier: float = 1.5
min_rr_ratio: float = 1.2
@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", "JPY", "EUR", "GBP", "XAU"])
tracked_currencies: List[str] = field(default_factory=lambda: [ min_impact: str = "medium"
"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
# 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://www.forexlive.com/feed/",
"https://www.fxstreet.com/rss", "https://www.fxstreet.com/rss",
]) ])
@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 = False
session_start_hour: int = 7
session_end_hour: int = 16
@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)
# Default pairs to monitor symbol: str = USDJPY
watch_pairs: List[str] = field(default_factory=lambda: MAJOR_PAIRS) display_name: str = DISPLAY
pip_value: float = PIP_VALUE
# 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)
twelve_data_api_key: str = "f563b22ffdfd4924955decbe1a8c8701" twelve_data_api_key: str = "f563b22ffdfd4924955decbe1a8c8701"
# Dashboard refresh interval (seconds) refresh_interval: int = 300
refresh_interval: int = 300 # 5 minutes
scan_pairs: List[str] = field(
default_factory=lambda: [EURUSD, USDJPY, GBPUSD, XAUUSD]
)
scan_timeframes: List[Timeframe] = field(
default_factory=lambda: [Timeframe.M5, Timeframe.M15, Timeframe.H1, Timeframe.H4]
)
candle_buffer_seconds: int = 10
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"
Regular → Executable
+84 -356
View File
@@ -1,23 +1,19 @@
""" """
Forex Signal Intelligence Platform - Data Provider ForexBot2 — Data Provider (Twelve Data SDK)
==================================================== ==========================================================
Retrieves and validates market data from Twelve Data API. Fetches and validates OHLCV via the official `twelvedata` Python SDK.
Falls back to synthetic GBM data if the API is unavailable.
""" """
import hashlib
import logging import logging
import os import os
import time import time
from collections import deque from collections import deque
from datetime import datetime, timedelta, timezone from typing import Dict, Optional
from typing import Dict, List, Optional
import numpy as np
import pandas as pd import pandas as pd
import requests from twelvedata import TDClient
from config import PlatformConfig, Timeframe, SYMBOL_DISPLAY from config import PlatformConfig, Timeframe, USDJPY, PAIR_INFO
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -28,14 +24,7 @@ class DataIntegrityError(Exception):
class ForexDataProvider: class ForexDataProvider:
""" """Twelve Data SDK client + on-disk parquet cache."""
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 = { TD_INTERVALS = {
Timeframe.M1: "1min", Timeframe.M1: "1min",
@@ -48,20 +37,19 @@ class ForexDataProvider:
Timeframe.W1: "1week", Timeframe.W1: "1week",
} }
# Approximate bars per day for each timeframe (used to size outputsize)
BARS_PER_DAY = { BARS_PER_DAY = {
Timeframe.M1: 1440, Timeframe.M1: 1440, Timeframe.M5: 288, Timeframe.M15: 96,
Timeframe.M5: 288, Timeframe.M30: 48, Timeframe.H1: 24, Timeframe.H4: 6,
Timeframe.M15: 96, Timeframe.D1: 1, Timeframe.W1: 0.143,
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) CANDLE_SECONDS = {
RATE_LIMIT = 8 Timeframe.M1: 60, Timeframe.M5: 300, Timeframe.M15: 900,
Timeframe.M30: 1800, Timeframe.H1: 3600, Timeframe.H4: 14400,
Timeframe.D1: 86400, Timeframe.W1: 604800,
}
RATE_LIMIT = 55
RATE_WINDOW = 60.0 RATE_WINDOW = 60.0
def __init__(self, config: PlatformConfig): def __init__(self, config: PlatformConfig):
@@ -72,94 +60,55 @@ class ForexDataProvider:
) )
self._cache: Dict[str, pd.DataFrame] = {} self._cache: Dict[str, pd.DataFrame] = {}
self._cache_timestamps: Dict[str, float] = {} self._cache_timestamps: Dict[str, float] = {}
self._request_times: deque = deque() # timestamps of recent API credits used self._request_times: deque = deque()
self._session = requests.Session() self._td: Optional[TDClient] = (
self._session.headers.update({ TDClient(apikey=self._api_key) if self._api_key else None
"User-Agent": "Mozilla/5.0 (compatible; ForexIntel/1.0)" )
})
@staticmethod @staticmethod
def _to_td_symbol(symbol: str) -> str: def _to_td_symbol(symbol: str) -> str:
"""Convert internal symbol (EURUSD=X) to Twelve Data format (EUR/USD).""" if "/" in symbol:
return symbol
clean = symbol.replace("=X", "") clean = symbol.replace("=X", "")
return f"{clean[:3]}/{clean[3:]}" return f"{clean[:3]}/{clean[3:]}"
# ── Public API ─────────────────────────────────────────────────
def fetch_ohlcv( def fetch_ohlcv(
self, self,
symbol: str, symbol: str = USDJPY,
timeframe: Timeframe, timeframe: Timeframe = Timeframe.H1,
lookback_days: Optional[int] = None, lookback_days: Optional[int] = None,
) -> pd.DataFrame: ) -> pd.DataFrame:
""" """Fetch OHLCV with in-memory caching for live mode."""
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: if lookback_days is None:
lookback_days = self.config.data_lookback_days lookback_days = self.config.data_lookback_days
cache_key = f"{symbol}_{timeframe.value}" cache_key = f"{symbol}_{timeframe.value}"
now = time.time() now = time.time()
cache_ttl = self.CANDLE_SECONDS.get(timeframe, self.config.refresh_interval)
if ( if (
cache_key in self._cache_timestamps cache_key in self._cache_timestamps
and (now - self._cache_timestamps[cache_key]) < self.config.refresh_interval and (now - self._cache_timestamps[cache_key]) < cache_ttl
and cache_key in self._cache and cache_key in self._cache
): ):
cached = self._cache.get(cache_key) cached = self._cache.get(cache_key)
if cached is not None and not cached.empty: if cached is not None and not cached.empty:
return cached return cached
df = self._fetch_twelve_data(symbol, timeframe, lookback_days) outputsize = max(
300,
min(int(lookback_days * self.BARS_PER_DAY[timeframe]) + 20, 5000),
)
df = self._fetch_twelve_data(symbol, timeframe, outputsize)
df = self._validate_and_clean(df, symbol, timeframe) df = self._validate_and_clean(df, symbol, timeframe)
self._cache[cache_key] = df self._cache[cache_key] = df
self._cache_timestamps[cache_key] = now self._cache_timestamps[cache_key] = now
return df return df
def prefetch_batch( def get_latest_price(self, symbol: str = USDJPY) -> Optional[float]:
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: try:
df = self.fetch_ohlcv(symbol, Timeframe.M5, lookback_days=1) df = self.fetch_ohlcv(symbol, Timeframe.M5, lookback_days=1)
if df is not None and not df.empty: if df is not None and not df.empty:
@@ -168,47 +117,26 @@ class ForexDataProvider:
pass pass
return None return None
def get_spread_estimate(self, symbol: str) -> float: def get_spread_estimate(self, symbol: str = USDJPY) -> float:
"""Estimate typical spread from recent M5 high-low ranges."""
try: try:
df = self.fetch_ohlcv(symbol, Timeframe.M5, lookback_days=1) df = self.fetch_ohlcv(symbol, Timeframe.M5, lookback_days=1)
if df is not None and not df.empty: if df is not None and not df.empty:
return float((df["High"] - df["Low"]).median()) return float((df["High"] - df["Low"]).median())
except Exception: except Exception:
pass pass
info = PAIR_INFO.get(symbol, PAIR_INFO[USDJPY])
return info["pip_value"]
fallback_spreads = { def get_pair_info(self, symbol: str = USDJPY) -> Dict:
"EURUSD=X": 0.00010, "GBPUSD=X": 0.00015, info = PAIR_INFO.get(symbol, PAIR_INFO[USDJPY])
"USDJPY=X": 0.010, "USDCHF=X": 0.00015, return {"symbol": symbol, **info}
"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: # ── Private ────────────────────────────────────────────────────
"""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: def _throttle(self, credits: int = 1) -> None:
""" """Pro tier: 55 credits / minute. Block until budget allows."""
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: while True:
now = time.time() now = time.time()
# Evict credits older than the rate window
while self._request_times and now - self._request_times[0] >= self.RATE_WINDOW: while self._request_times and now - self._request_times[0] >= self.RATE_WINDOW:
self._request_times.popleft() self._request_times.popleft()
@@ -218,276 +146,76 @@ class ForexDataProvider:
self._request_times.append(time.time()) self._request_times.append(time.time())
return return
# Wait until the oldest credit expires
wait = (self._request_times[0] + self.RATE_WINDOW) - now + 0.2 wait = (self._request_times[0] + self.RATE_WINDOW) - now + 0.2
logger.info( logger.info(
f"Rate limit: {len(self._request_times)}/{self.RATE_LIMIT} credits used " f"Rate limit: {len(self._request_times)}/{self.RATE_LIMIT} credits used "
f"— waiting {wait:.1f}s for next window..." f"— waiting {wait:.1f}s..."
) )
time.sleep(wait) 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( def _fetch_twelve_data(
self, symbol: str, timeframe: Timeframe, lookback_days: int self,
symbol: str,
timeframe: Timeframe,
outputsize: int,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
) -> pd.DataFrame: ) -> pd.DataFrame:
""" if self._td is None:
Fetch OHLCV from Twelve Data REST API. raise DataIntegrityError(
Falls back to synthetic GBM data if API is unavailable or key is missing. "No Twelve Data API key. Set TWELVE_DATA_API_KEY env var "
""" "or config.twelve_data_api_key."
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) td_symbol = self._to_td_symbol(symbol)
interval = self.TD_INTERVALS[timeframe] 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) self._throttle(credits=1)
try: kwargs = {
resp = self._session.get(
f"{self.TD_BASE_URL}/time_series",
params={
"symbol": td_symbol, "symbol": td_symbol,
"interval": interval, "interval": interval,
"outputsize": outputsize, "outputsize": min(outputsize, 5000),
"apikey": self._api_key,
"timezone": "UTC", "timezone": "UTC",
"format": "json", }
}, if start_date:
timeout=15, kwargs["start_date"] = start_date
) if end_date:
resp.raise_for_status() kwargs["end_date"] = end_date
data = resp.json()
if data.get("status") == "error": try:
df = self._td.time_series(**kwargs).as_pandas()
except Exception as e:
raise DataIntegrityError( raise DataIntegrityError(
f"Twelve Data API error for {td_symbol}: {data.get('message', 'unknown')}" f"Twelve Data fetch failed for {td_symbol}: {e}"
) ) from e
values = data.get("values") if df is None or df.empty:
if not values:
raise DataIntegrityError(f"Empty response from Twelve Data for {td_symbol}") raise DataIntegrityError(f"Empty response from Twelve Data for {td_symbol}")
# Values arrive newest-first; reverse to chronological order # SDK returns lowercase columns; engine expects capitalised.
records = [ df = df.rename(columns={
{ "open": "Open", "high": "High", "low": "Low",
"Datetime": pd.Timestamp(v["datetime"], tz="UTC"), "close": "Close", "volume": "Volume",
"Open": float(v["open"]), })
"High": float(v["high"]), for col in ("Open", "High", "Low", "Close", "Volume"):
"Low": float(v["low"]), if col in df.columns:
"Close": float(v["close"]), df[col] = pd.to_numeric(df[col], errors="coerce")
"Volume": float(v.get("volume") or 0), if "Volume" not in df.columns:
} df["Volume"] = 0.0
for v in reversed(values)
]
df = pd.DataFrame(records).set_index("Datetime") # 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" df.index.name = "Datetime"
logger.debug(f"Fetched {len(df)} bars for {td_symbol} @ {interval}") logger.debug(f"Fetched {len(df)} bars for {td_symbol} @ {interval}")
return df 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( def _validate_and_clean(
self, df: pd.DataFrame, symbol: str, timeframe: Timeframe self, df: pd.DataFrame, symbol: str, timeframe: Timeframe
) -> pd.DataFrame: ) -> 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: if df is None or df.empty:
raise DataIntegrityError(f"Empty dataset for {symbol}") raise DataIntegrityError(f"Empty dataset for {symbol}")
Regular → Executable
+91 -83
View File
@@ -83,18 +83,19 @@ class TechnicalEngine:
) )
# ── Compute all indicators ───────────────────────────────── # ── Compute all indicators ─────────────────────────────────
self._compute_ema_signals(df, result) disabled = set(self.config.disabled_indicators or [])
self._compute_rsi(df, result)
self._compute_macd(df, result) if "ema_trend" not in disabled: self._compute_ema_signals(df, result)
self._compute_bollinger(df, result) if "rsi" not in disabled: self._compute_rsi(df, result)
self._compute_stochastic(df, result) if "bollinger" not in disabled: self._compute_bollinger(df, result)
self._compute_adx(df, result) if "adx" not in disabled: self._compute_adx(df, result)
self._compute_atr(df, result) if "atr" not in disabled: self._compute_atr(df, result)
self._compute_ichimoku(df, result)
self._detect_divergences(df, result) self._compute_macd_internal(df, result)
self._detect_candlestick_patterns(df, result)
self._compute_support_resistance(df, result) if "divergence" not in disabled: self._detect_divergences(df, result)
self._compute_volume_analysis(df, result) if "structure" not in disabled: self._compute_structure_sr(df, result)
if "candlestick" not in disabled: self._detect_candlestick_patterns(df, result)
# ── Classify market regime ───────────────────────────────── # ── Classify market regime ─────────────────────────────────
self._classify_regime(df, result) self._classify_regime(df, result)
@@ -169,8 +170,13 @@ class TechnicalEngine:
result.indicators["ema_trend"] = IndicatorSignal( result.indicators["ema_trend"] = IndicatorSignal(
name="EMA Trend", value=normalized, name="EMA Trend", value=normalized,
signal=signal, confidence=conf, signal=signal, confidence=conf,
description=desc, weight=1.2, description=desc, weight=1.0,
) )
if t is not None:
result.key_levels["ema_trend"] = float(t)
result.key_levels["ema_fast"] = float(f)
result.key_levels["ema_medium"] = float(m)
result.key_levels["ema_slow"] = float(s)
def _compute_rsi(self, df: pd.DataFrame, result: TechnicalAnalysis): def _compute_rsi(self, df: pd.DataFrame, result: TechnicalAnalysis):
"""RSI with trend-adjusted interpretation.""" """RSI with trend-adjusted interpretation."""
@@ -215,14 +221,14 @@ class TechnicalEngine:
result.indicators["rsi"] = IndicatorSignal( result.indicators["rsi"] = IndicatorSignal(
name="RSI", value=rsi_val, signal=signal, name="RSI", value=rsi_val, signal=signal,
confidence=conf, description=desc, weight=1.0, confidence=conf, description=desc, weight=1.5,
) )
# Attach RSI series for divergence detection (dynamic attribute, filtered by _prefix) # Attach RSI series for divergence detection (dynamic attribute, filtered by _prefix)
result._rsi_series = rsi # type: ignore[attr-defined] result._rsi_series = rsi # type: ignore[attr-defined]
def _compute_macd(self, df: pd.DataFrame, result: TechnicalAnalysis): def _compute_macd_internal(self, df: pd.DataFrame, result: TechnicalAnalysis):
"""MACD with histogram momentum analysis.""" """Compute MACD histogram internally for divergence detection (not a signal pillar)."""
c = self.config c = self.config
close = df["Close"] close = df["Close"]
@@ -232,62 +238,6 @@ class TechnicalEngine:
signal_line = macd_line.ewm(span=c.macd_signal, adjust=False).mean() signal_line = macd_line.ewm(span=c.macd_signal, adjust=False).mean()
histogram = macd_line - signal_line histogram = macd_line - signal_line
macd_val = macd_line.iloc[-1]
signal_val = signal_line.iloc[-1]
hist_val = histogram.iloc[-1]
# Check for crossover
crossover = False
crossover_type = ""
if len(macd_line) >= 2:
prev_macd = macd_line.iloc[-2]
prev_signal = signal_line.iloc[-2]
if prev_macd <= prev_signal and macd_val > signal_val:
crossover = True
crossover_type = "bullish"
elif prev_macd >= prev_signal and macd_val < signal_val:
crossover = True
crossover_type = "bearish"
# Histogram momentum (is it expanding or contracting?)
hist_momentum = ""
if len(histogram) >= 3:
h1, h2, h3 = histogram.iloc[-3], histogram.iloc[-2], histogram.iloc[-1]
if h3 > h2 > h1 and h3 > 0:
hist_momentum = "accelerating bullish"
elif h3 < h2 < h1 and h3 < 0:
hist_momentum = "accelerating bearish"
elif abs(h3) < abs(h2) and h3 * h2 > 0:
hist_momentum = "decelerating"
# Signal
if crossover and crossover_type == "bullish":
signal = SignalStrength.STRONG_BUY
conf = 75
desc = f"MACD bullish crossover | Hist: {hist_val:.6f}"
elif crossover and crossover_type == "bearish":
signal = SignalStrength.STRONG_SELL
conf = 75
desc = f"MACD bearish crossover | Hist: {hist_val:.6f}"
elif macd_val > signal_val and hist_val > 0:
signal = SignalStrength.BUY
conf = 55
desc = f"MACD above signal, positive histogram | {hist_momentum}"
elif macd_val < signal_val and hist_val < 0:
signal = SignalStrength.SELL
conf = 55
desc = f"MACD below signal, negative histogram | {hist_momentum}"
else:
signal = SignalStrength.NEUTRAL
conf = 35
desc = f"MACD mixed | Hist: {hist_val:.6f}"
result.indicators["macd"] = IndicatorSignal(
name="MACD", value=hist_val, signal=signal,
confidence=conf, description=desc, weight=1.1,
)
# Attach histogram for divergence detection
result._macd_histogram = histogram # type: ignore[attr-defined] result._macd_histogram = histogram # type: ignore[attr-defined]
def _compute_bollinger(self, df: pd.DataFrame, result: TechnicalAnalysis): def _compute_bollinger(self, df: pd.DataFrame, result: TechnicalAnalysis):
@@ -351,7 +301,7 @@ class TechnicalEngine:
result.indicators["bollinger"] = IndicatorSignal( result.indicators["bollinger"] = IndicatorSignal(
name="Bollinger Bands", value=bb_position, signal=signal, name="Bollinger Bands", value=bb_position, signal=signal,
confidence=conf, description=desc, weight=0.9, confidence=conf, description=desc, weight=1.5,
) )
result.key_levels["bb_upper"] = upper_val result.key_levels["bb_upper"] = upper_val
@@ -416,7 +366,7 @@ class TechnicalEngine:
result.indicators["stochastic"] = IndicatorSignal( result.indicators["stochastic"] = IndicatorSignal(
name="Stochastic", value=k_val, signal=signal, name="Stochastic", value=k_val, signal=signal,
confidence=conf, description=desc, weight=0.8, confidence=conf, description=desc, weight=2.5,
) )
def _compute_adx(self, df: pd.DataFrame, result: TechnicalAnalysis): def _compute_adx(self, df: pd.DataFrame, result: TechnicalAnalysis):
@@ -476,7 +426,7 @@ class TechnicalEngine:
result.indicators["adx"] = IndicatorSignal( result.indicators["adx"] = IndicatorSignal(
name="ADX", value=adx_val, signal=signal, name="ADX", value=adx_val, signal=signal,
confidence=conf, description=desc, weight=1.0, confidence=conf, description=desc, weight=0.5,
) )
def _compute_atr(self, df: pd.DataFrame, result: TechnicalAnalysis): def _compute_atr(self, df: pd.DataFrame, result: TechnicalAnalysis):
@@ -602,7 +552,7 @@ class TechnicalEngine:
name="Ichimoku", value=float(score), signal=signal, name="Ichimoku", value=float(score), signal=signal,
confidence=conf, confidence=conf,
description=f"Ichimoku [{score}/4]: {' | '.join(signals)}", description=f"Ichimoku [{score}/4]: {' | '.join(signals)}",
weight=1.1, weight=0.3,
) )
result.key_levels["ichimoku_cloud_top"] = cloud_top result.key_levels["ichimoku_cloud_top"] = cloud_top
@@ -665,11 +615,39 @@ class TechnicalEngine:
name="Divergence", value=len(divergences), name="Divergence", value=len(divergences),
signal=signal, confidence=70, signal=signal, confidence=70,
description=" | ".join(divergences), description=" | ".join(divergences),
weight=1.5, # divergences are strong reversal signals weight=1.0,
) )
# Note: divergences are NOT added to pattern_signals to avoid # Note: divergences are NOT added to pattern_signals to avoid
# duplicate reason collection (the indicator entry covers them). # duplicate reason collection (the indicator entry covers them).
def _compute_structure_sr(self, df: pd.DataFrame, result: TechnicalAnalysis):
"""Detect swing high/low structure levels for S/R and trade setup."""
lookback = min(self.config.swing_sr_lookback, len(df) - 1)
if lookback < 10:
return
price = df["Close"].iloc[-1]
high_window = df["High"].iloc[-lookback:]
low_window = df["Low"].iloc[-lookback:]
swing_highs = self._find_swing_points(high_window, mode="high", window=3)
swing_lows = self._find_swing_points(low_window, mode="low", window=3)
highs_above = sorted([v for _, v in swing_highs if v > price])
highs_below = sorted([v for _, v in swing_highs if v <= price], reverse=True)
lows_below = sorted([v for _, v in swing_lows if v < price], reverse=True)
lows_above = sorted([v for _, v in swing_lows if v >= price])
for i, val in enumerate(highs_above[:3], 1):
result.key_levels[f"swing_high_{i}"] = float(val)
for i, val in enumerate(lows_below[:3], 1):
result.key_levels[f"swing_low_{i}"] = float(val)
if not highs_above and highs_below:
result.key_levels["swing_high_1"] = float(highs_below[0])
if not lows_below and lows_above:
result.key_levels["swing_low_1"] = float(lows_above[0])
def _detect_candlestick_patterns(self, df: pd.DataFrame, result: TechnicalAnalysis): def _detect_candlestick_patterns(self, df: pd.DataFrame, result: TechnicalAnalysis):
"""Detect significant candlestick patterns on recent bars.""" """Detect significant candlestick patterns on recent bars."""
if len(df) < 5: if len(df) < 5:
@@ -731,11 +709,33 @@ class TechnicalEngine:
else: else:
signal = SignalStrength.NEUTRAL signal = SignalStrength.NEUTRAL
conf = 50
atr_val = result.key_levels.get("atr", 0)
latest = c
proximity = atr_val * 0.5 if atr_val > 0 else 0
if proximity > 0:
swing_lows = sorted(
[v for k, v in result.key_levels.items() if k.startswith("swing_low_")]
)
swing_highs = sorted(
[v for k, v in result.key_levels.items() if k.startswith("swing_high_")]
)
near_support = any(abs(latest - sl) <= proximity for sl in swing_lows)
near_resistance = any(abs(latest - sh) <= proximity for sh in swing_highs)
if bullish > bearish and near_support:
conf += 20
patterns.append("(at support)")
elif bearish > bullish and near_resistance:
conf += 20
patterns.append("(at resistance)")
result.indicators["candlestick"] = IndicatorSignal( result.indicators["candlestick"] = IndicatorSignal(
name="Candlestick", value=float(bullish - bearish), name="Candlestick", value=float(bullish - bearish),
signal=signal, confidence=50, signal=signal, confidence=conf,
description=" | ".join(patterns), description=" | ".join(patterns),
weight=0.7, weight=1.5,
) )
result.pattern_signals.extend(patterns) result.pattern_signals.extend(patterns)
@@ -777,7 +777,7 @@ class TechnicalEngine:
result.indicators["pivot"] = IndicatorSignal( result.indicators["pivot"] = IndicatorSignal(
name="Pivot Points", value=pivot, name="Pivot Points", value=pivot,
signal=SignalStrength.BUY if latest > pivot else SignalStrength.SELL, signal=SignalStrength.BUY if latest > pivot else SignalStrength.SELL,
confidence=45, description=desc, weight=0.6, confidence=45, description=desc, weight=0.8,
) )
def _compute_volume_analysis(self, df: pd.DataFrame, result: TechnicalAnalysis): def _compute_volume_analysis(self, df: pd.DataFrame, result: TechnicalAnalysis):
@@ -847,10 +847,18 @@ class TechnicalEngine:
total_weight = 0.0 total_weight = 0.0
total_confidence = 0.0 total_confidence = 0.0
_trending = {
"ema_trend": 2.0, "rsi": 1.5, "bollinger": 0.8,
"candlestick": 1.5, "divergence": 1.0,
}
regime_weights = { regime_weights = {
"trending_up": {"ema_trend": 1.5, "adx": 1.3, "macd": 1.2, "rsi": 0.7, "bollinger": 0.6}, "trending_up": _trending,
"trending_down": {"ema_trend": 1.5, "adx": 1.3, "macd": 1.2, "rsi": 0.7, "bollinger": 0.6}, "trending_down": _trending,
"ranging": {"rsi": 1.4, "stochastic": 1.3, "bollinger": 1.4, "ema_trend": 0.6, "adx": 0.5}, "trending": _trending,
"ranging": {
"ema_trend": 0.5, "rsi": 2.0, "bollinger": 2.0,
"candlestick": 1.5, "divergence": 1.5,
},
} }
active_regime_weights = regime_weights.get(result.market_regime, {}) active_regime_weights = regime_weights.get(result.market_regime, {})
Regular → Executable
+138 -327
View File
@@ -1,35 +1,27 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Forex Signal Intelligence Platform ForexBot2 — USD/JPY Signal Intelligence (single-pair)
==================================== =======================================================
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 from typing import Dict, 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, USDJPY, DISPLAY
PlatformConfig, Timeframe, SignalStrength,
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,16 +43,8 @@ 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,
@@ -72,8 +54,7 @@ def signal_color(signal: SignalStrength) -> str:
}.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,
@@ -81,196 +62,127 @@ def quality_color(quality: str) -> str:
"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 = "USD/JPY 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 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}{ts.entry_price:.3f}{Colors.RESET} "
f"→ SL {Colors.RED}{ts.stop_loss:.3f}{Colors.RESET} "
f"({ts.risk_pips:.1f}p) "
f"→ TP {Colors.GREEN}{ts.take_profit_1:.3f}{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}{report.current_price:.3f}{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}")
# ── 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,
@@ -305,100 +217,60 @@ def report_to_dict(report: SignalReport) -> dict:
"reward_pips": round(ts.reward_pips, 1), "reward_pips": round(ts.reward_pips, 1),
} }
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("_"):
d["indicators"][name] = {
"signal": ind.signal.name, "signal": ind.signal.name,
"confidence": round(ind.confidence, 1), "confidence": round(ind.confidence, 1),
"description": ind.description, "description": ind.description,
} }
for name, ind in report.primary_analysis.indicators.items()
if not name.startswith("_")
}
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 — USD/JPY 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("--timeframe", "-tf", default="H1",
"--pairs", nargs="+", default=None,
help="Specific pairs to analyze (e.g., EURUSD GBPUSD)",
)
parser.add_argument(
"--timeframe", "-tf", default="H1",
choices=["M5", "M15", "M30", "H1", "H4", "D1"], choices=["M5", "M15", "M30", "H1", "H4", "D1"],
help="Primary analysis timeframe (default: H1)", help="Primary analysis timeframe (default: H1)")
) parser.add_argument("--detail", action="store_true",
parser.add_argument( help="Show full indicator breakdown")
"--all", action="store_true", parser.add_argument("--json", action="store_true",
help="Include cross pairs in analysis", help="Output as JSON")
) parser.add_argument("--verbose", "-v", action="store_true",
parser.add_argument( help="Debug logging")
"--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 build_config(args) -> PlatformConfig:
"""Convert pair name like 'EURUSD' to Yahoo symbol 'EURUSD=X'."""
name = name.upper().replace("/", "").replace("_", "")
if not name.endswith("=X"):
name += "=X"
return name
def run_analysis(args) -> List[SignalReport]:
"""Run a single analysis cycle."""
config = PlatformConfig() 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] config.primary_timeframe = 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],
@@ -411,34 +283,28 @@ def run_analysis(args) -> List[SignalReport]:
args.timeframe, [Timeframe.M15, Timeframe.H4] args.timeframe, [Timeframe.M15, Timeframe.H4]
) )
# Configure pairs return config
if args.pairs:
config.watch_pairs = [symbol_from_name(p) for p in args.pairs]
elif args.all:
config.watch_pairs = MAJOR_PAIRS + CROSS_PAIRS
else:
config.watch_pairs = MAJOR_PAIRS
# Run analysis
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()
# 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("USD/JPY 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 +312,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
+18 -17
View File
@@ -104,6 +104,7 @@ class NewsEngine:
"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", "xau", "precious metal", "safe haven", "bullion"],
} }
# High-impact event types (these move markets significantly) # High-impact event types (these move markets significantly)
@@ -187,15 +188,21 @@ class NewsEngine:
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
for event in context.events_upcoming_1h: for event in context.events_upcoming_1h:
@@ -222,9 +229,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)
@@ -413,15 +418,11 @@ 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": ["EURUSD=X", "USDJPY=X", "GBPUSD=X", "XAU/USD"],
"USD": ["EURUSD=X", "GBPUSD=X", "USDJPY=X", "USDCHF=X", "AUDUSD=X", "USDCAD=X", "NZDUSD=X"], "EUR": ["EURUSD=X"],
"EUR": ["EURUSD=X", "EURGBP=X", "EURJPY=X", "EURCHF=X"], "JPY": ["USDJPY=X"],
"GBP": ["GBPUSD=X", "EURGBP=X", "GBPJPY=X"], "GBP": ["GBPUSD=X"],
"JPY": ["USDJPY=X", "EURJPY=X", "GBPJPY=X", "AUDJPY=X", "CADJPY=X"], "XAU": ["XAU/USD"],
"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, [])
+6
View File
@@ -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
View File
@@ -1 +0,0 @@
pip install pandas numpy requests python-dateutil -q
Regular → Executable
+305 -152
View File
@@ -1,29 +1,19 @@
""" """
Forex Signal Intelligence Platform - Signal Aggregator ForexBot2 — Signal Aggregator
======================================================== ==============================
Combines technical analysis across multiple timeframes with Combines multi-timeframe technical analysis with optional news context
fundamental/news context to produce final actionable signals. into a graded actionable signal.
This is the brain — it decides what's actually worth paying attention to.
Key principles:
1. Multi-timeframe agreement = higher confidence
2. High-impact news overrides technicals
3. Divergences between timeframes = caution
4. No signal is better than a bad signal
""" """
import logging import logging
import os
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Dict, List, Optional from typing import Dict, List, Optional
import pandas as pd import pandas as pd
from config import ( from config import PlatformConfig, SignalStrength, Timeframe
PlatformConfig, SignalStrength, Timeframe,
SYMBOL_DISPLAY,
)
from data_provider import ForexDataProvider from data_provider import ForexDataProvider
from engine import TechnicalEngine, TechnicalAnalysis from engine import TechnicalEngine, TechnicalAnalysis
from news_engine import NewsEngine, FundamentalContext from news_engine import NewsEngine, FundamentalContext
@@ -33,10 +23,9 @@ logger = logging.getLogger(__name__)
@dataclass @dataclass
class TradeSetup: class TradeSetup:
"""A complete trade setup recommendation."""
pair: str pair: str
display_name: str display_name: str
direction: str # "BUY" or "SELL" direction: str # "BUY" / "SELL"
entry_price: float entry_price: float
stop_loss: float stop_loss: float
take_profit_1: float take_profit_1: float
@@ -49,47 +38,38 @@ class TradeSetup:
@dataclass @dataclass
class SignalReport: class SignalReport:
"""Complete signal report for a single pair."""
symbol: str symbol: str
display_name: str display_name: str
timestamp: str timestamp: str
# Composite signals
primary_signal: SignalStrength = SignalStrength.NEUTRAL primary_signal: SignalStrength = SignalStrength.NEUTRAL
primary_score: float = 0.0 primary_score: float = 0.0
primary_confidence: float = 0.0 primary_confidence: float = 0.0
# Multi-timeframe agreement
mtf_agreement: bool = False mtf_agreement: bool = False
mtf_signals: Dict[str, SignalStrength] = field(default_factory=dict) mtf_signals: Dict[str, SignalStrength] = field(default_factory=dict)
mtf_scores: Dict[str, float] = field(default_factory=dict) mtf_scores: Dict[str, float] = field(default_factory=dict)
# Technical details
primary_analysis: Optional[TechnicalAnalysis] = None primary_analysis: Optional[TechnicalAnalysis] = None
confirmation_analyses: List[TechnicalAnalysis] = field(default_factory=list) confirmation_analyses: List[TechnicalAnalysis] = field(default_factory=list)
# Fundamental context
event_risk: str = "clear" event_risk: str = "clear"
event_description: str = "" event_description: str = ""
news_sentiment: float = 0.0 news_sentiment: float = 0.0
news_description: str = "" news_description: str = ""
# Final verdict
final_signal: SignalStrength = SignalStrength.NEUTRAL final_signal: SignalStrength = SignalStrength.NEUTRAL
final_score: float = 0.0 final_score: float = 0.0
final_confidence: float = 0.0 final_confidence: float = 0.0
action_recommendation: str = "" action_recommendation: str = ""
signal_quality: str = "" # "A+", "A", "B", "C", "D", "F" signal_quality: str = "" # A+, A, B, C, D, F
# Trade setup (only for actionable signals)
trade_setup: Optional[TradeSetup] = None trade_setup: Optional[TradeSetup] = None
# Key reasons
bullish_reasons: List[str] = field(default_factory=list) bullish_reasons: List[str] = field(default_factory=list)
bearish_reasons: List[str] = field(default_factory=list) bearish_reasons: List[str] = field(default_factory=list)
warnings: List[str] = field(default_factory=list) warnings: List[str] = field(default_factory=list)
# Current price info
current_price: float = 0.0 current_price: float = 0.0
spread_estimate: float = 0.0 spread_estimate: float = 0.0
market_regime: str = "" market_regime: str = ""
@@ -97,54 +77,162 @@ class SignalReport:
class SignalAggregator: class SignalAggregator:
""" """Master aggregator for live signal analysis."""
Master signal aggregator.
Orchestrates data fetching, technical analysis across timeframes,
fundamental analysis, and produces final graded signals.
"""
def __init__(self, config: PlatformConfig): def __init__(self, config: PlatformConfig):
self.config = config self.config = config
self.data_provider = ForexDataProvider(config) self.data_provider = ForexDataProvider(config)
self.tech_engine = TechnicalEngine(config.technical) self.tech_engine = TechnicalEngine(config.technical)
self.news_engine = NewsEngine(config.news) self._news_engine: Optional[NewsEngine] = None # lazy
def analyze_all_pairs(self) -> List[SignalReport]: def _get_news_engine(self) -> NewsEngine:
""" if self._news_engine is None:
Analyze all watched pairs and return sorted signal reports. self._news_engine = NewsEngine(self.config.news)
Returns reports sorted by signal quality (best first). return self._news_engine
"""
reports = []
context = self.news_engine.get_fundamental_context()
# Batch-prefetch all pairs × all needed timeframes (3 API calls instead of 21) # ── Public: live mode entry point ──────────────────────────────
all_timeframes = (
[self.config.primary_timeframe] + self.config.confirmation_timeframes
)
self.data_provider.prefetch_batch(self.config.watch_pairs, all_timeframes)
for symbol in self.config.watch_pairs:
try:
report = self.analyze_pair(symbol, context)
reports.append(report)
except Exception as e:
logger.error(f"Failed to analyze {symbol}: {e}")
continue
# Sort by absolute score (strongest signals first)
reports.sort(key=lambda r: abs(r.final_score), reverse=True)
return reports
def analyze_pair( def analyze_pair(
self, self,
symbol: str, symbol: Optional[str] = None,
context: Optional[FundamentalContext] = None, context: Optional[FundamentalContext] = None,
) -> SignalReport: ) -> SignalReport:
"""Complete analysis of a single pair across all layers.""" """Live-mode entry: fetch all needed timeframes then delegate."""
if context is None: if symbol is None:
context = self.news_engine.get_fundamental_context() symbol = self.config.symbol
timeframes = [self.config.primary_timeframe] + list(
self.config.confirmation_timeframes
)
bt = self.config.backtest
if bt.regime_filter and bt.regime_filter_timeframe not in timeframes:
timeframes.append(bt.regime_filter_timeframe)
dfs: Dict[Timeframe, pd.DataFrame] = {}
for tf in timeframes:
try:
dfs[tf] = self.data_provider.fetch_ohlcv(symbol, tf)
except Exception as e:
logger.warning(f"Fetch failed for {symbol} {tf.value}: {e}")
report = self.analyze_from_dataframes(
symbol=symbol,
dfs=dfs,
context=context,
use_news=True,
)
if bt.regime_filter:
self._apply_regime_filter_live(report, dfs)
if bt.session_filter:
self._apply_session_filter_live(report)
return report
def _apply_session_filter_live(self, report: SignalReport) -> None:
"""Skip signals outside the configured UTC trading window."""
bt = self.config.backtest
now_h = datetime.now(timezone.utc).hour
if not (bt.session_start_hour <= now_h < bt.session_end_hour):
report.warnings.append(
f"SESSION FILTER: {now_h:02}:00 UTC outside "
f"{bt.session_start_hour:02}:00-{bt.session_end_hour:02}:00 — skipping"
)
report.final_signal = SignalStrength.NEUTRAL
report.signal_quality = "F"
report.action_recommendation = (
f"NO TRADE — Outside session "
f"({bt.session_start_hour:02}-{bt.session_end_hour:02} UTC; now {now_h:02})"
)
report.trade_setup = None
else:
report.warnings.append(
f"Session: {now_h:02}:00 UTC inside trading window "
f"{bt.session_start_hour:02}-{bt.session_end_hour:02}"
)
def _apply_regime_filter_live(
self, report: SignalReport, dfs: Dict[Timeframe, pd.DataFrame]
) -> None:
"""If higher-TF ADX is below threshold, downgrade signal to NEUTRAL."""
bt = self.config.backtest
regime_tf = bt.regime_filter_timeframe
df = dfs.get(regime_tf)
if df is None or df.empty:
report.warnings.append(
f"Regime filter requested but {regime_tf.value} data unavailable"
)
return
adx_value = self._latest_adx(df, self.config.technical.adx_period)
if adx_value is None:
report.warnings.append(
f"Regime filter: ADX could not be computed on {regime_tf.value}"
)
return
if adx_value < bt.regime_filter_min_adx:
report.warnings.append(
f"REGIME FILTER: {regime_tf.value} ADX={adx_value:.1f} < "
f"{bt.regime_filter_min_adx:.0f} — market choppy, skipping signal"
)
report.final_signal = SignalStrength.NEUTRAL
report.signal_quality = "F"
report.action_recommendation = (
f"NO TRADE — Regime filter (ADX {adx_value:.1f} on {regime_tf.value})"
)
report.trade_setup = None
else:
report.bullish_reasons.append(
f"Regime: {regime_tf.value} ADX={adx_value:.1f} confirms trending market"
) if report.final_score > 0 else report.bearish_reasons.append(
f"Regime: {regime_tf.value} ADX={adx_value:.1f} confirms trending market"
)
@staticmethod
def _latest_adx(df: pd.DataFrame, period: int = 14) -> Optional[float]:
"""Wilder ADX on a fresh df; returns the last finite value or None."""
if df is None or len(df) < period * 3:
return None
high, low, close = df["High"], df["Low"], df["Close"]
tr = pd.concat([
high - low,
(high - close.shift()).abs(),
(low - close.shift()).abs(),
], axis=1).max(axis=1)
plus_dm = high.diff()
minus_dm = -low.diff()
plus_dm = plus_dm.where((plus_dm > minus_dm) & (plus_dm > 0), 0.0)
minus_dm = minus_dm.where((minus_dm > plus_dm) & (minus_dm > 0), 0.0)
atr_s = tr.ewm(alpha=1/period, min_periods=period).mean()
plus_di = 100 * (plus_dm.ewm(alpha=1/period, min_periods=period).mean() / atr_s)
minus_di = 100 * (minus_dm.ewm(alpha=1/period, min_periods=period).mean() / atr_s)
denom = (plus_di + minus_di).replace(0, float("nan"))
dx = 100 * (plus_di - minus_di).abs() / denom
adx = dx.ewm(alpha=1/period, min_periods=period).mean().dropna()
if adx.empty:
return None
return float(adx.iloc[-1])
# ── Public: bar-replayable entry point ─────────────────────────
def analyze_from_dataframes(
self,
symbol: str,
dfs: Dict[Timeframe, pd.DataFrame],
context: Optional[FundamentalContext] = None,
use_news: bool = True,
) -> SignalReport:
"""
Run the full analysis pipeline on already-fetched dataframes.
`dfs` must include the primary timeframe and ideally the
confirmation timeframes. Each df is treated as the visible history
at decision time — the backtester is responsible for slicing so
no future bars leak in.
"""
pair_info = self.data_provider.get_pair_info(symbol) pair_info = self.data_provider.get_pair_info(symbol)
display = pair_info["display"] display = pair_info["display"]
pip_value = pair_info["pip_value"] pip_value = pair_info["pip_value"]
@@ -155,11 +243,13 @@ class SignalAggregator:
timestamp=str(datetime.now(timezone.utc)), timestamp=str(datetime.now(timezone.utc)),
) )
# ── 1. Primary Timeframe Analysis ────────────────────────── # ── 1. Primary timeframe ───────────────────────────────────
try: primary_df = dfs.get(self.config.primary_timeframe)
primary_df = self.data_provider.fetch_ohlcv( if primary_df is None or primary_df.empty:
symbol, self.config.primary_timeframe report.warnings.append("Primary timeframe data unavailable")
) report.signal_quality = "F"
return report
primary_analysis = self.tech_engine.analyze( primary_analysis = self.tech_engine.analyze(
primary_df, symbol, self.config.primary_timeframe.value primary_df, symbol, self.config.primary_timeframe.value
) )
@@ -170,18 +260,16 @@ class SignalAggregator:
report.market_regime = primary_analysis.market_regime report.market_regime = primary_analysis.market_regime
report.volatility_regime = primary_analysis.volatility_regime report.volatility_regime = primary_analysis.volatility_regime
report.current_price = float(primary_df["Close"].iloc[-1]) report.current_price = float(primary_df["Close"].iloc[-1])
report.timestamp = str(primary_df.index[-1])
report.mtf_signals[self.config.primary_timeframe.value] = primary_analysis.composite_signal report.mtf_signals[self.config.primary_timeframe.value] = primary_analysis.composite_signal
report.mtf_scores[self.config.primary_timeframe.value] = primary_analysis.composite_score report.mtf_scores[self.config.primary_timeframe.value] = primary_analysis.composite_score
except Exception as e:
logger.error(f"Primary analysis failed for {symbol}: {e}")
report.warnings.append(f"Primary timeframe data unavailable: {e}")
report.signal_quality = "F"
return report
# ── 2. Confirmation Timeframe Analyses ───────────────────── # ── 2. Confirmation timeframes ─────────────────────────────
for tf in self.config.confirmation_timeframes: for tf in self.config.confirmation_timeframes:
cf_df = dfs.get(tf)
if cf_df is None or cf_df.empty:
continue
try: try:
cf_df = self.data_provider.fetch_ohlcv(symbol, tf)
cf_analysis = self.tech_engine.analyze(cf_df, symbol, tf.value) cf_analysis = self.tech_engine.analyze(cf_df, symbol, tf.value)
report.confirmation_analyses.append(cf_analysis) report.confirmation_analyses.append(cf_analysis)
report.mtf_signals[tf.value] = cf_analysis.composite_signal report.mtf_signals[tf.value] = cf_analysis.composite_signal
@@ -189,35 +277,49 @@ class SignalAggregator:
except Exception as e: except Exception as e:
logger.warning(f"Confirmation TF {tf.value} failed for {symbol}: {e}") logger.warning(f"Confirmation TF {tf.value} failed for {symbol}: {e}")
# ── 3. Multi-Timeframe Agreement Check ───────────────────── # ── 3. MTF agreement ───────────────────────────────────────
report.mtf_agreement = self._check_mtf_agreement(report) report.mtf_agreement = self._check_mtf_agreement(report)
# ── 4. Fundamental Context ───────────────────────────────── # ── 4. Fundamental context (optional) ──────────────────────
event_risk, event_desc = self.news_engine.get_event_risk_level(symbol, context) if use_news:
news = self._get_news_engine()
if context is None:
context = news.get_fundamental_context()
event_risk, event_desc = news.get_event_risk_level(symbol, context)
report.event_risk = event_risk report.event_risk = event_risk
report.event_description = event_desc report.event_description = event_desc
sentiment, sent_desc = news.get_pair_sentiment(symbol, context)
sentiment, sent_desc = self.news_engine.get_pair_sentiment(symbol, context)
report.news_sentiment = sentiment report.news_sentiment = sentiment
report.news_description = sent_desc report.news_description = sent_desc
else:
report.event_risk = "clear"
report.event_description = ""
report.news_sentiment = 0.0
report.news_description = ""
# ── 5. Spread Estimate ───────────────────────────────────── # ── 5. Spread estimate from primary data (avoids extra API call) ─
report.spread_estimate = self.data_provider.get_spread_estimate(symbol) try:
report.spread_estimate = float(
(primary_df["High"] - primary_df["Low"]).median()
)
except Exception:
report.spread_estimate = 0.0
# ── 6. Compute Final Signal ──────────────────────────────── # ── 6. Final signal ────────────────────────────────────────
self._compute_final_signal(report) self._compute_final_signal(report)
# ── 7. Generate Trade Setup (if signal is actionable) ────── # ── 7. Trade setup for actionable signals ──────────────────
if report.signal_quality in ("A+", "A", "B"): if report.signal_quality in ("A+", "A", "B", "C"):
report.trade_setup = self._generate_trade_setup(report, pip_value) report.trade_setup = self._generate_trade_setup(report, pip_value)
# ── 8. Collect Reasons ───────────────────────────────────── # ── 8. Reasons ─────────────────────────────────────────────
self._collect_reasons(report) self._collect_reasons(report)
return report return report
# ── Internals ──────────────────────────────────────────────────
def _check_mtf_agreement(self, report: SignalReport) -> bool: def _check_mtf_agreement(self, report: SignalReport) -> bool:
"""Check if multiple timeframes agree on direction."""
signals = list(report.mtf_signals.values()) signals = list(report.mtf_signals.values())
if len(signals) < 2: if len(signals) < 2:
return False return False
@@ -229,73 +331,83 @@ class SignalAggregator:
return bullish >= threshold or bearish >= threshold return bullish >= threshold or bearish >= threshold
def _compute_final_signal(self, report: SignalReport): def _compute_final_signal(self, report: SignalReport):
"""
Compute the final signal by combining all layers.
Priority:
1. Event risk can downgrade or block signals
2. MTF agreement boosts confidence
3. News sentiment adds a small bias
4. Quality grade combines everything
"""
score = report.primary_score score = report.primary_score
confidence = report.primary_confidence confidence = report.primary_confidence
# ── MTF adjustment ─────────────────────────────────────────
if report.mtf_agreement: if report.mtf_agreement:
# All timeframes agree — boost confidence and blend scores confidence += 10
confidence = min(confidence + 15, 95)
all_scores = list(report.mtf_scores.values()) all_scores = list(report.mtf_scores.values())
if all_scores: if all_scores:
mtf_avg = sum(all_scores) / len(all_scores) mtf_avg = sum(all_scores) / len(all_scores)
score = score * 0.6 + mtf_avg * 0.4 score = score * 0.6 + mtf_avg * 0.4
else: else:
# Timeframes disagree — reduce confidence confidence -= 15
confidence *= 0.7
if len(report.mtf_scores) >= 2: if len(report.mtf_scores) >= 2:
scores = list(report.mtf_scores.values()) scores = list(report.mtf_scores.values())
has_bull = any(s > 10 for s in scores) has_bull = any(s > 10 for s in scores)
has_bear = any(s < -10 for s in scores) has_bear = any(s < -10 for s in scores)
if has_bull and has_bear: if has_bull and has_bear:
report.warnings.append("Timeframes CONFLICT — mixed signals across timeframes") report.warnings.append("Timeframes CONFLICT — mixed signals across timeframes")
confidence *= 0.6
# ── News sentiment adjustment ──────────────────────────────
if abs(report.news_sentiment) > 0.2: if abs(report.news_sentiment) > 0.2:
# Sentiment aligns with technical
if (report.news_sentiment > 0 and score > 0) or \ if (report.news_sentiment > 0 and score > 0) or \
(report.news_sentiment < 0 and score < 0): (report.news_sentiment < 0 and score < 0):
confidence = min(confidence + 5, 95) confidence += 5
# Sentiment conflicts with technical
elif (report.news_sentiment > 0 and score < -10) or \ elif (report.news_sentiment > 0 and score < -10) or \
(report.news_sentiment < 0 and score > 10): (report.news_sentiment < 0 and score > 10):
report.warnings.append("News sentiment CONFLICTS with technical signal") report.warnings.append("News sentiment CONFLICTS with technical signal")
confidence *= 0.85 confidence -= 10
# ── Event risk adjustment ──────────────────────────────────
if report.event_risk == "danger": if report.event_risk == "danger":
report.warnings.append(f"EVENT RISK: {report.event_description}") report.warnings.append(f"EVENT RISK: {report.event_description}")
confidence *= 0.3 # Heavily penalize — event will disrupt price action confidence = min(confidence, 30)
report.action_recommendation = f"WAIT — {report.event_description}" report.action_recommendation = f"WAIT — {report.event_description}"
elif report.event_risk == "caution": elif report.event_risk == "caution":
report.warnings.append(f"Upcoming event: {report.event_description}") report.warnings.append(f"Upcoming event: {report.event_description}")
confidence *= 0.7 confidence -= 15
is_counter_trend = (
(report.market_regime == "trending_up" and score < 0) or
(report.market_regime == "trending_down" and score > 0)
)
if is_counter_trend:
confidence -= 15
report.warnings.append("Counter-trend signal — reduced confidence")
# ── Volatility regime adjustment ───────────────────────────
if report.volatility_regime == "extreme": if report.volatility_regime == "extreme":
report.warnings.append("EXTREME volatility — wider stops needed, reduced position size") report.warnings.append("EXTREME volatility — wider stops needed, reduced position size")
confidence *= 0.8 confidence -= 10
elif report.volatility_regime == "low": elif report.volatility_regime == "low":
report.warnings.append("Low volatility — potential for breakout, tight ranges") report.warnings.append("Low volatility — potential for breakout, tight ranges")
# ── Final signal determination ───────────────────────────── primary = report.primary_analysis
if score >= 40 and confidence >= 65: if primary is not None:
ema200 = primary.key_levels.get("ema_trend")
if ema200 and ema200 > 0 and report.current_price > 0:
ema_dist_pct = abs(report.current_price - ema200) / ema200 * 100.0
_lo = float(os.environ.get("FB2_EMA_LO", "0.5"))
_hi = float(os.environ.get("FB2_EMA_HI", "1.5"))
if ema_dist_pct < _lo:
report.warnings.append(
f"NEAR EMA200 ({ema_dist_pct:.2f}%) — chop risk"
)
confidence -= 10
elif ema_dist_pct > _hi:
report.warnings.append(
f"FAR FROM EMA200 ({ema_dist_pct:.2f}%) — over-extended"
)
confidence -= 10
confidence = max(0.0, min(95.0, confidence))
# Final signal
if score >= 35 and confidence >= 60:
report.final_signal = SignalStrength.STRONG_BUY report.final_signal = SignalStrength.STRONG_BUY
elif score >= 15 and confidence >= 50: elif score >= 12 and confidence >= 45:
report.final_signal = SignalStrength.BUY report.final_signal = SignalStrength.BUY
elif score <= -40 and confidence >= 65: elif score <= -35 and confidence >= 60:
report.final_signal = SignalStrength.STRONG_SELL report.final_signal = SignalStrength.STRONG_SELL
elif score <= -15 and confidence >= 50: elif score <= -12 and confidence >= 45:
report.final_signal = SignalStrength.SELL report.final_signal = SignalStrength.SELL
else: else:
report.final_signal = SignalStrength.NEUTRAL report.final_signal = SignalStrength.NEUTRAL
@@ -303,23 +415,22 @@ class SignalAggregator:
report.final_score = score report.final_score = score
report.final_confidence = confidence report.final_confidence = confidence
# ── Quality grade ────────────────────────────────────────── # Quality grade
abs_score = abs(score) abs_score = abs(score)
if abs_score >= 50 and confidence >= 75 and report.mtf_agreement: if abs_score >= 45 and confidence >= 70 and report.mtf_agreement:
report.signal_quality = "A+" report.signal_quality = "A+"
elif abs_score >= 35 and confidence >= 65 and report.mtf_agreement: elif abs_score >= 30 and confidence >= 60 and report.mtf_agreement:
report.signal_quality = "A" report.signal_quality = "A"
elif abs_score >= 25 and confidence >= 55: elif abs_score >= 20 and confidence >= 50:
report.signal_quality = "B" report.signal_quality = "B"
elif abs_score >= 15 and confidence >= 45: elif abs_score >= 12 and confidence >= 40:
report.signal_quality = "C" report.signal_quality = "C"
elif abs_score >= 10: elif abs_score >= 8:
report.signal_quality = "D" report.signal_quality = "D"
else: else:
report.signal_quality = "F" report.signal_quality = "F"
# ── Action recommendation ────────────────────────────────── if not report.action_recommendation:
if not report.action_recommendation: # not already set by event risk
sig_name = report.final_signal.name.replace("_", " ") sig_name = report.final_signal.name.replace("_", " ")
if report.signal_quality in ("A+", "A"): if report.signal_quality in ("A+", "A"):
report.action_recommendation = f"{sig_name} — High-quality setup, consider entry" report.action_recommendation = f"{sig_name} — High-quality setup, consider entry"
@@ -331,36 +442,88 @@ class SignalAggregator:
report.action_recommendation = "NO TRADE — No clear signal" report.action_recommendation = "NO TRADE — No clear signal"
def _generate_trade_setup(self, report: SignalReport, pip_value: float) -> Optional[TradeSetup]: def _generate_trade_setup(self, report: SignalReport, pip_value: float) -> Optional[TradeSetup]:
"""Generate entry, SL, TP levels for actionable signals."""
if not report.primary_analysis: if not report.primary_analysis:
return None return None
price = report.current_price price = report.current_price
atr = report.primary_analysis.key_levels.get("atr", 0) atr = report.primary_analysis.key_levels.get("atr", 0)
if atr == 0: if atr == 0:
return None return None
is_buy = report.final_signal in (SignalStrength.BUY, SignalStrength.STRONG_BUY) is_buy = report.final_signal in (SignalStrength.BUY, SignalStrength.STRONG_BUY)
rr = self.config.signals.default_rr_ratio levels = report.primary_analysis.key_levels
min_rr = self.config.signals.min_rr_ratio
# SL = 1.5x ATR from entry (adaptive to volatility) mature_move = abs(report.final_score) > 30
sl_distance = atr * 1.5 tp_mult = 1.5 if mature_move else 2.0
tp1_distance = sl_distance * rr tp_swing_cap = 2.5 if mature_move else 3.0
tp2_distance = sl_distance * rr * 1.5
if is_buy: if is_buy:
entry = price swing_lows = sorted(
sl = price - sl_distance [v for k, v in levels.items() if k.startswith("swing_low_") and v < price],
tp1 = price + tp1_distance reverse=True,
tp2 = price + tp2_distance )
direction = "BUY" if swing_lows:
sl_price = swing_lows[0] - atr * 1.0
else: else:
entry = price sl_price = price - atr * 2.0
sl = price + sl_distance if price - sl_price < atr * 1.5:
tp1 = price - tp1_distance sl_price = price - atr * 1.5
tp2 = price - tp2_distance
sl_distance = price - sl_price
swing_highs = sorted(
v for k, v in levels.items() if k.startswith("swing_high_") and v > price + atr * 0.3
)
if (swing_highs
and swing_highs[0] - price <= atr * tp_swing_cap
and swing_highs[0] - price >= sl_distance * min_rr):
tp1 = swing_highs[0]
else:
tp1 = price + max(atr * tp_mult, sl_distance * min_rr)
if len(swing_highs) >= 2 and swing_highs[1] - price <= atr * 5.0:
tp2 = swing_highs[1]
else:
tp2 = price + max(atr * (tp_mult + 1.5), sl_distance * min_rr * 1.5)
direction = "BUY"
entry, sl = price, sl_price
else:
swing_highs = sorted(
v for k, v in levels.items() if k.startswith("swing_high_") and v > price
)
if swing_highs:
sl_price = swing_highs[0] + atr * 1.0
else:
sl_price = price + atr * 2.0
if sl_price - price < atr * 1.5:
sl_price = price + atr * 1.5
sl_distance = sl_price - price
swing_lows = sorted(
[v for k, v in levels.items() if k.startswith("swing_low_") and v < price - atr * 0.3],
reverse=True,
)
if (swing_lows
and price - swing_lows[0] <= atr * tp_swing_cap
and price - swing_lows[0] >= sl_distance * min_rr):
tp1 = swing_lows[0]
else:
tp1 = price - max(atr * tp_mult, sl_distance * min_rr)
if len(swing_lows) >= 2 and price - swing_lows[1] <= atr * 5.0:
tp2 = swing_lows[1]
else:
tp2 = price - max(atr * (tp_mult + 1.5), sl_distance * min_rr * 1.5)
direction = "SELL" direction = "SELL"
entry, sl = price, sl_price
tp1_distance = abs(tp1 - entry)
rr = tp1_distance / sl_distance if sl_distance > 0 else 0
if rr < min_rr - 1e-9:
return None
risk_pips = sl_distance / pip_value risk_pips = sl_distance / pip_value
reward_pips = tp1_distance / pip_value reward_pips = tp1_distance / pip_value
@@ -373,28 +536,19 @@ class SignalAggregator:
stop_loss=sl, stop_loss=sl,
take_profit_1=tp1, take_profit_1=tp1,
take_profit_2=tp2, take_profit_2=tp2,
risk_reward=rr, risk_reward=round(rr, 2),
risk_pips=risk_pips, risk_pips=risk_pips,
reward_pips=reward_pips, reward_pips=reward_pips,
pip_value=pip_value, pip_value=pip_value,
) )
def _collect_reasons(self, report: SignalReport): def _collect_reasons(self, report: SignalReport):
"""
Collect bullish/bearish reasons from all indicators.
Each indicator's description is captured once via the indicators
dict. Pattern signals (candlestick) are already included in the
'candlestick' indicator description, so we do NOT re-process
pattern_signals to avoid duplicate entries.
"""
if not report.primary_analysis: if not report.primary_analysis:
return return
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
if ind.signal in (SignalStrength.BUY, SignalStrength.STRONG_BUY): if ind.signal in (SignalStrength.BUY, SignalStrength.STRONG_BUY):
prefix = "STRONG: " if ind.signal == SignalStrength.STRONG_BUY else "" prefix = "STRONG: " if ind.signal == SignalStrength.STRONG_BUY else ""
report.bullish_reasons.append(f"{prefix}{ind.name}: {ind.description}") report.bullish_reasons.append(f"{prefix}{ind.name}: {ind.description}")
@@ -402,7 +556,6 @@ class SignalAggregator:
prefix = "STRONG: " if ind.signal == SignalStrength.STRONG_SELL else "" prefix = "STRONG: " if ind.signal == SignalStrength.STRONG_SELL else ""
report.bearish_reasons.append(f"{prefix}{ind.name}: {ind.description}") report.bearish_reasons.append(f"{prefix}{ind.name}: {ind.description}")
# Add news context
if report.news_sentiment > 0.1: if report.news_sentiment > 0.1:
report.bullish_reasons.append(f"News: {report.news_description}") report.bullish_reasons.append(f"News: {report.news_description}")
elif report.news_sentiment < -0.1: elif report.news_sentiment < -0.1:
+802
View File
@@ -0,0 +1,802 @@
#!/usr/bin/env python3
"""
ForexBot2 — Telegram Bot
========================
Async Telegram interface with candle-boundary auto-scanner and
real-time WebSocket level-breach monitor.
Manual scan: 4 pairs × 3 timeframes (M15/H1/H4) via inline buttons.
Auto-scan: Starts automatically on boot if TELEGRAM_CHAT_ID is set.
/autoscan toggles for additional chats or to stop.
M5 fires at :00/:05/:10/… + 10s buffer (data only, no signals).
M15 fires at :00/:15/:30/:45 + 10s buffer.
H1 fires at :00 + 10s buffer.
H4 fires at :00 every 4 hours + 10s buffer.
WebSocket: Streams live ticks from TwelveData for EUR/USD & XAU/USD
(Grow plan supports only these 2 WS symbols).
When price crosses a key structure level (swing S/R, BB,
EMA200) cached from the last boundary scan, triggers an
immediate M15 scan and sends a Level Break Alert.
5-minute cooldown per pair prevents spam.
GBP/USD & USD/JPY are monitored via REST scans only.
Plan: TwelveData Grow (55 credits/min, 8 trial WS credits).
Credit budget (worst case, 4-hour boundary):
Unique fetches: 4 pairs × ~6 TFs = ~24 calls (rest cached) → fits in 55/min.
WebSocket: 2 symbols = 2 credits (one-time on connect).
Level breach scans: ~3 REST calls each, ~10/hour worst case.
Usage:
python telegram_bot.py
TELEGRAM_TOKEN=<token> TELEGRAM_CHAT_ID=<id> python telegram_bot.py
"""
import asyncio
import json
import logging
import os
import sys
import time
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone
from typing import Dict, List, Optional
import websockets
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update
from telegram.constants import ParseMode
from telegram.ext import (
Application,
CallbackQueryHandler,
CommandHandler,
ContextTypes,
)
from config import (
PlatformConfig, SignalStrength, Timeframe,
EURUSD, USDJPY, GBPUSD, XAUUSD, PAIR_INFO,
)
from data_provider import ForexDataProvider
from signal_aggregator import SignalAggregator, SignalReport
# ── Force UTF-8 on Windows ────────────────────────────────────────────
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
logging.basicConfig(
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
level=logging.INFO,
datefmt="%H:%M:%S",
)
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("httpcore").setLevel(logging.WARNING)
logger = logging.getLogger("telegram_bot")
TOKEN = os.environ.get("TELEGRAM_TOKEN", "8228251480:AAFW2OAyQ2idMicql2t_CIXk1r9-qfRTfNE")
CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "6025899022")
# ── Pairs & buttons ───────────────────────────────────────────────────
SCAN_PAIRS = [EURUSD, USDJPY, GBPUSD, XAUUSD]
CORRELATION_GROUPS = {
"usd_negative": {"pairs": {EURUSD, GBPUSD}, "label": "USD-weakness"},
"risk_sentiment": {"pairs": {XAUUSD, USDJPY}, "label": "Risk-sentiment"},
}
_WS_TO_PAIR = {
"EUR/USD": EURUSD,
"XAU/USD": XAUUSD,
}
_WS_SYMBOLS = list(_WS_TO_PAIR.keys())
_LEVEL_KEYS = (
"swing_high_1", "swing_high_2", "swing_high_3",
"swing_low_1", "swing_low_2", "swing_low_3",
"bb_upper", "bb_lower", "ema_trend",
)
SCAN_BUTTONS = [
{"cb": "scan_eurusd_m15", "symbol": EURUSD, "tf": Timeframe.M15, "label": "EUR/USD 15M"},
{"cb": "scan_eurusd_h1", "symbol": EURUSD, "tf": Timeframe.H1, "label": "EUR/USD 1H"},
{"cb": "scan_eurusd_h4", "symbol": EURUSD, "tf": Timeframe.H4, "label": "EUR/USD 4H"},
{"cb": "scan_usdjpy_m15", "symbol": USDJPY, "tf": Timeframe.M15, "label": "USD/JPY 15M"},
{"cb": "scan_usdjpy_h1", "symbol": USDJPY, "tf": Timeframe.H1, "label": "USD/JPY 1H"},
{"cb": "scan_usdjpy_h4", "symbol": USDJPY, "tf": Timeframe.H4, "label": "USD/JPY 4H"},
{"cb": "scan_gbpusd_m15", "symbol": GBPUSD, "tf": Timeframe.M15, "label": "GBP/USD 15M"},
{"cb": "scan_gbpusd_h1", "symbol": GBPUSD, "tf": Timeframe.H1, "label": "GBP/USD 1H"},
{"cb": "scan_gbpusd_h4", "symbol": GBPUSD, "tf": Timeframe.H4, "label": "GBP/USD 4H"},
{"cb": "scan_xauusd_m15", "symbol": XAUUSD, "tf": Timeframe.M15, "label": "XAU/USD 15M"},
{"cb": "scan_xauusd_h1", "symbol": XAUUSD, "tf": Timeframe.H1, "label": "XAU/USD 1H"},
{"cb": "scan_xauusd_h4", "symbol": XAUUSD, "tf": Timeframe.H4, "label": "XAU/USD 4H"},
]
SCAN_LOOKUP = {b["cb"]: b for b in SCAN_BUTTONS}
TF_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],
}
AUTOSCAN_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],
}
_executor = ThreadPoolExecutor(max_workers=2)
# ── Shared data provider (single cache across all scans) ─────────────
_shared_dp: Optional[ForexDataProvider] = None
def _get_shared_dp() -> ForexDataProvider:
global _shared_dp
if _shared_dp is None:
_shared_dp = ForexDataProvider(PlatformConfig())
return _shared_dp
# ── Signal formatting ─────────────────────────────────────────────────
_SIGNAL_EMOJI = {
SignalStrength.STRONG_BUY: "🟢",
SignalStrength.BUY: "🟩",
SignalStrength.NEUTRAL: "⬜",
SignalStrength.SELL: "🟥",
SignalStrength.STRONG_SELL: "🔴",
}
_QUALITY_EMOJI = {
"A+": "🏆", "A": "🥇", "B": "🥈", "C": "🥉", "D": "⚠️", "F": "❌",
}
_ARROW = {
SignalStrength.STRONG_BUY: "⬆️",
SignalStrength.BUY: "↗️",
SignalStrength.NEUTRAL: "➡️",
SignalStrength.SELL: "↘️",
SignalStrength.STRONG_SELL: "⬇️",
}
def _signal_label(sig: SignalStrength) -> str:
return sig.name.replace("_", " ")
def _mtf_line(report: SignalReport) -> str:
if not report.mtf_signals:
return ""
parts = []
for tf, sig in report.mtf_signals.items():
score = report.mtf_scores.get(tf, 0)
parts.append(f"{tf}: {_signal_label(sig)} {score:+.1f}")
return " | ".join(parts)
def _indicator_line(name: str, ind) -> str:
arrow = _ARROW.get(ind.signal, "➡️")
short = ind.description.split(" | ")[0] if " | " in ind.description else ind.description
return f"{arrow} *{ind.name}*: {short}"
def format_signal_message(report: SignalReport) -> str:
sig = report.final_signal
emoji = _SIGNAL_EMOJI.get(sig, "⬜")
qe = _QUALITY_EMOJI.get(report.signal_quality, "")
label = _signal_label(sig)
mtf_tag = "✅ MTF aligned" if report.mtf_agreement else "❌ MTF diverged"
lines: list[str] = []
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
lines += [
f"🔍 *{report.display_name} Signal Analysis* ({report.primary_analysis.timeframe if report.primary_analysis else ''})",
f"🕐 {now}",
"",
f"💰 Price: *{report.current_price:.5f}*" if report.current_price < 10 else f"💰 Price: *{report.current_price:.3f}*",
f"📊 Regime: {report.market_regime.replace('_', ' ').title()} | Vol: {report.volatility_regime}",
"",
"━━━━━━━━━━━━━━━━━━━━",
f"{emoji} *{label}* {qe}{report.signal_quality} | Score: {report.final_score:+.1f} | {report.final_confidence:.0f}% conf",
f"_{report.action_recommendation}_",
f"{mtf_tag}",
]
mtf = _mtf_line(report)
if mtf:
lines += ["", f"📈 *MTF*: {mtf}"]
if report.primary_analysis:
lines += ["", "━━━━━━━━━━━━━━━━━━━━", "📋 *Indicators*"]
for name, ind in report.primary_analysis.indicators.items():
if name.startswith("_") or ind.weight == 0:
continue
lines.append(_indicator_line(name, ind))
if report.warnings:
lines.append("")
for w in report.warnings:
lines.append(f"⚠️ {w}")
if report.trade_setup and sig not in (SignalStrength.NEUTRAL,):
ts = report.trade_setup
dir_emoji = "📈" if ts.direction == "BUY" else "📉"
lines += [
"",
"━━━━━━━━━━━━━━━━━━━━",
f"🚀 *TRADE SETUP*",
f"{dir_emoji} Direction: *{ts.direction}*",
f"▶ Entry: `{ts.entry_price:.3f}`",
f"🛑 SL: `{ts.stop_loss:.3f}` ({ts.risk_pips:.1f} pips)",
f"🎯 TP1: `{ts.take_profit_1:.3f}` ({ts.reward_pips:.1f} pips)",
]
if ts.take_profit_2:
lines.append(f"🎯 TP2: `{ts.take_profit_2:.3f}`")
lines.append(f"⚖️ RR: 1:{ts.risk_reward:.1f}")
lines += ["", "━━━━━━━━━━━━━━━━━━━━",
"_Educational / research use only._"]
return "\n".join(lines)
# ── Correlation filter ───────────────────────────────────────────────
def _detect_correlated_exposure(reports: List[SignalReport]) -> List[str]:
warnings = []
for group_info in CORRELATION_GROUPS.values():
group_pairs = group_info["pairs"]
group_label = group_info["label"]
active = [
r for r in reports
if r.symbol in group_pairs
and r.final_signal not in (SignalStrength.NEUTRAL,)
]
if len(active) >= 2:
directions = set()
for r in active:
if r.final_signal in (SignalStrength.BUY, SignalStrength.STRONG_BUY):
directions.add("BUY")
else:
directions.add("SELL")
if len(directions) == 1:
names = ", ".join(r.display_name for r in active)
warnings.append(
f"⚠️ Same exposure ({group_label}): {names} both {directions.pop()}"
)
return warnings
# ── Auto-scan summary format ─────────────────────────────────────────
def _format_scan_summary(reports: List[SignalReport], tf: Timeframe) -> str:
now = datetime.now(timezone.utc).strftime("%H:%M UTC")
lines = [
f"📊 *{tf.value} Auto-Scan* | {now}",
"━━━━━━━━━━━━━━━━━━━━",
]
for r in reports:
emoji = _SIGNAL_EMOJI.get(r.final_signal, "⬜")
sig = _signal_label(r.final_signal)
mtf = "✅" if r.mtf_agreement else "❌"
lines.append(
f"{emoji} *{r.display_name}* {sig} "
f"{_QUALITY_EMOJI.get(r.signal_quality, '')}{r.signal_quality} "
f"{r.final_score:+.1f} {r.final_confidence:.0f}% {mtf}MTF"
)
lines.append("━━━━━━━━━━━━━━━━━━━━")
actionable = [r for r in reports if r.signal_quality in ("A+", "A", "B", "C")]
if actionable:
names = ", ".join(r.display_name for r in actionable)
lines.append(f"🔔 Actionable: {names}")
else:
lines.append("No actionable signals this cycle.")
corr_warnings = _detect_correlated_exposure(reports)
for w in corr_warnings:
lines.append(w)
return "\n".join(lines)
# ── Keyboard helper ───────────────────────────────────────────────────
def scan_keyboard() -> InlineKeyboardMarkup:
rows = []
for i in range(0, len(SCAN_BUTTONS), 4):
rows.append([
InlineKeyboardButton(b["label"], callback_data=b["cb"])
for b in SCAN_BUTTONS[i:i + 4]
])
return InlineKeyboardMarkup(rows)
# ── Signal runner (shared data provider for cache reuse) ──────────────
def _run_signal_sync(
symbol: str,
timeframe: Timeframe,
confirmation_tfs: Optional[List[Timeframe]] = None,
) -> SignalReport:
if confirmation_tfs is None:
confirmation_tfs = TF_CONFIRMATIONS.get(
timeframe, [Timeframe.M15, Timeframe.H4]
)
config = PlatformConfig()
config.primary_timeframe = timeframe
config.confirmation_timeframes = confirmation_tfs
aggregator = SignalAggregator(config)
aggregator.data_provider = _get_shared_dp()
return aggregator.analyze_pair(symbol=symbol)
async def run_signal(
symbol: str,
timeframe: Timeframe,
confirmation_tfs: Optional[List[Timeframe]] = None,
) -> SignalReport:
loop = asyncio.get_event_loop()
return await loop.run_in_executor(
_executor, _run_signal_sync, symbol, timeframe, confirmation_tfs
)
# ── Candle-boundary scheduler ────────────────────────────────────────
_autoscan_chats: set[int] = set()
_autoscan_tasks: list[asyncio.Task] = []
def _seconds_until_boundary(interval_minutes: int, buffer: int = 10) -> float:
"""Seconds until the next candle close + buffer."""
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
async def _scan_timeframe(
bot,
tf: Timeframe,
confirmation_tfs: List[Timeframe],
) -> None:
"""Scan all pairs for one timeframe and broadcast results."""
reports: List[SignalReport] = []
for symbol in SCAN_PAIRS:
try:
report = await run_signal(symbol, tf, confirmation_tfs)
reports.append(report)
except Exception:
logger.exception(f"Auto-scan failed for {symbol} {tf.value}")
if not reports:
return
_update_level_cache(reports)
if tf == Timeframe.M5:
logger.info("M5 scan: data analysis only — no Telegram broadcast")
return
actionable = [r for r in reports if r.signal_quality in ("A+", "A", "B", "C")]
if not actionable:
logger.info(f"{tf.value} scan: no actionable signals — skipping Telegram message")
return
summary = _format_scan_summary(reports, tf)
for chat_id in list(_autoscan_chats):
try:
await bot.send_message(chat_id, summary, parse_mode=ParseMode.MARKDOWN)
for report in actionable:
detail = format_signal_message(report)
await bot.send_message(chat_id, detail, parse_mode=ParseMode.MARKDOWN)
except Exception:
logger.exception(f"Failed to send to chat {chat_id}")
# ── WebSocket level-breach monitor ──────────────────────────────────
_level_cache: Dict[str, Dict[str, float]] = {}
_last_prices: Dict[str, float] = {}
_scan_cooldowns: Dict[str, float] = {}
_cfg = PlatformConfig()
_WS_API_KEY = os.environ.get("TWELVE_DATA_API_KEY") or _cfg.twelve_data_api_key
_WS_URL = f"wss://ws.twelvedata.com/v1/quotes/price?apikey={_WS_API_KEY}"
_WS_COOLDOWN = _cfg.ws_cooldown_seconds
def _update_level_cache(reports: List[SignalReport]) -> None:
for r in reports:
if r.primary_analysis and r.primary_analysis.key_levels:
levels = {
k: v for k, v in r.primary_analysis.key_levels.items()
if k in _LEVEL_KEYS
}
if levels:
_level_cache[r.symbol] = levels
def _check_level_breach(symbol: str, price: float) -> Optional[str]:
levels = _level_cache.get(symbol)
if not levels:
return None
last = _last_prices.get(symbol)
if last is None:
return None
for name, level in levels.items():
if (last < level <= price) or (last > level >= price):
return name
return None
async def _handle_breach(bot, ws_symbol: str, pair_symbol: str, price: float, level_name: str) -> None:
now = time.time()
if now - _scan_cooldowns.get(pair_symbol, 0) < _WS_COOLDOWN:
return
_scan_cooldowns[pair_symbol] = now
display = PAIR_INFO.get(pair_symbol, {}).get("display", ws_symbol)
label = level_name.replace("_", " ").title()
logger.info(f"Level breach: {display} crossed {label} at {price}")
try:
report = await run_signal(pair_symbol, Timeframe.M15)
_update_level_cache([report])
text = (
f"🔔 *Level Break — {display}*\n"
f"Price crossed *{label}* at `{price}`\n"
f"━━━━━━━━━━━━━━━━━━━━\n"
+ format_signal_message(report)
)
except Exception as exc:
logger.exception(f"Breach scan failed for {display}")
text = f"🔔 *Level Break — {display}*\nPrice crossed {label} at `{price}`\n❌ Scan failed: {exc}"
for chat_id in list(_autoscan_chats):
try:
await bot.send_message(chat_id, text, parse_mode=ParseMode.MARKDOWN)
except Exception:
logger.exception(f"Failed to send breach alert to {chat_id}")
async def _ws_monitor(bot) -> None:
backoff = 1
while _autoscan_chats:
try:
async with websockets.connect(_WS_URL) as ws:
subscribe = {
"action": "subscribe",
"params": {"symbols": ",".join(_WS_SYMBOLS)},
}
await ws.send(json.dumps(subscribe))
logger.info(f"WebSocket connected, subscribed to {len(_WS_SYMBOLS)} symbols")
backoff = 1
async for raw in ws:
if not _autoscan_chats:
break
msg = json.loads(raw)
if msg.get("event") != "price":
continue
ws_symbol = msg.get("symbol")
pair_symbol = _WS_TO_PAIR.get(ws_symbol)
if not pair_symbol:
continue
price = float(msg["price"])
breached = _check_level_breach(pair_symbol, price)
_last_prices[pair_symbol] = price
if breached:
asyncio.create_task(
_handle_breach(bot, ws_symbol, pair_symbol, price, breached)
)
except asyncio.CancelledError:
break
except Exception:
logger.exception(f"WebSocket error, reconnecting in {backoff}s")
await asyncio.sleep(backoff)
backoff = min(backoff * 2, 60)
logger.info("WebSocket monitor stopped")
async def _candle_loop(bot, tf: Timeframe, interval_minutes: int) -> None:
label = tf.value
try:
while _autoscan_chats:
wait = _seconds_until_boundary(interval_minutes, buffer=10)
logger.info(f"{label} auto-scan: next in {wait:.0f}s")
await asyncio.sleep(wait)
if not _autoscan_chats:
break
logger.info(f"{label} auto-scan: firing")
await _scan_timeframe(
bot, tf, AUTOSCAN_CONFIRMATIONS[tf]
)
except asyncio.CancelledError:
pass
finally:
logger.info(f"{label} auto-scan loop stopped")
_AUTOSCAN_LOOPS = [
(Timeframe.M5, 5),
(Timeframe.M15, 15),
(Timeframe.H1, 60),
(Timeframe.H4, 240),
]
_ws_task: Optional[asyncio.Task] = None
def _start_scheduler(bot) -> None:
global _ws_task
alive = [t for t in _autoscan_tasks if not t.done()]
if not alive:
_autoscan_tasks.clear()
for tf, interval in _AUTOSCAN_LOOPS:
_autoscan_tasks.append(
asyncio.create_task(_candle_loop(bot, tf, interval))
)
logger.info("Candle-boundary scheduler started (M5/M15/H1/H4)")
if _ws_task is None or _ws_task.done():
_ws_task = asyncio.create_task(_ws_monitor(bot))
logger.info("WebSocket level-breach monitor started")
def _stop_scheduler() -> None:
global _ws_task
for t in _autoscan_tasks:
t.cancel()
_autoscan_tasks.clear()
if _ws_task and not _ws_task.done():
_ws_task.cancel()
_ws_task = None
logger.info("Candle-boundary scheduler and WebSocket monitor stopped")
# ── Handlers ──────────────────────────────────────────────────────────
async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
scanning = "running" if _autoscan_chats else "off"
text = (
"👋 *ForexBot2 — Signal Intelligence*\n\n"
"Pick a pair and timeframe to scan.\n"
"Signals include a full trade setup with entry, SL and TP.\n\n"
f"Auto-scan: *{scanning}* — /autoscan to toggle\n"
"/status — Bot & WebSocket status\n"
"/help — All commands"
)
await update.message.reply_text(
text,
parse_mode=ParseMode.MARKDOWN,
reply_markup=scan_keyboard(),
)
async def cmd_help(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
text = (
"📖 *ForexBot2 Commands*\n\n"
"/start — Show scan buttons (4 pairs × 4 TFs)\n"
"/autoscan — Toggle auto-scan on/off\n"
"/status — Bot status, WS connection, live prices\n"
"/help — This message\n\n"
"*Auto-scan* fires at candle close: M5/M15/H1/H4.\n"
"*WebSocket* monitors live prices and sends Level Break Alerts "
"when price crosses swing S/R, Bollinger bands, or EMA200."
)
await update.message.reply_text(text, parse_mode=ParseMode.MARKDOWN)
async def cmd_status(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
lines = ["📊 *ForexBot2 Status*\n"]
if _autoscan_chats:
lines.append(f"✅ Auto-scan: *running* ({len(_autoscan_chats)} chat)")
tasks_alive = sum(1 for t in _autoscan_tasks if not t.done())
lines.append(f" Candle loops: {tasks_alive}/{len(_AUTOSCAN_LOOPS)}")
else:
lines.append("⏹ Auto-scan: *stopped*")
if _ws_task and not _ws_task.done():
lines.append("📡 WebSocket: *connected*")
else:
lines.append("❌ WebSocket: *disconnected*")
if _last_prices:
lines.append("\n*Live Prices:*")
for pair in SCAN_PAIRS:
price = _last_prices.get(pair)
display = PAIR_INFO.get(pair, {}).get("display", pair)
if price is None:
lines.append(f" {display}: waiting...")
elif price < 10:
lines.append(f" {display}: `{price:.5f}`")
else:
lines.append(f" {display}: `{price:.3f}`")
else:
lines.append("\n_No live prices yet (WS not streaming)_")
cached_pairs = len(_level_cache)
cached_levels = sum(len(v) for v in _level_cache.values())
if cached_levels:
lines.append(f"\n*Level Cache:* {cached_levels} levels across {cached_pairs} pairs")
else:
lines.append("\n*Level Cache:* empty — breach alerts start after first candle scan")
active_cooldowns = sum(
1 for v in _scan_cooldowns.values()
if time.time() - v < _WS_COOLDOWN
)
if active_cooldowns:
lines.append(f"*Cooldowns:* {active_cooldowns} pair(s) in cooldown")
await update.message.reply_text("\n".join(lines), parse_mode=ParseMode.MARKDOWN)
async def cmd_autoscan(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
chat_id = update.effective_chat.id
if chat_id in _autoscan_chats:
_autoscan_chats.discard(chat_id)
if not _autoscan_chats:
_stop_scheduler()
await update.message.reply_text(
"⏹ Auto-scan stopped.",
parse_mode=ParseMode.MARKDOWN,
)
return
_autoscan_chats.add(chat_id)
_start_scheduler(context.bot)
next_m5 = _seconds_until_boundary(5, buffer=10)
next_m15 = _seconds_until_boundary(15, buffer=10)
next_h1 = _seconds_until_boundary(60, buffer=10)
next_h4 = _seconds_until_boundary(240, buffer=10)
await update.message.reply_text(
"✅ *Auto-scan enabled*\n\n"
f"Scanning {len(SCAN_PAIRS)} pairs on M5/M15/H1/H4 at candle close.\n"
f"📡 Live price monitoring active — level-break alerts enabled.\n"
f"Next: M5 {next_m5 / 60:.0f}m | M15 {next_m15 / 60:.0f}m | "
f"H1 {next_h1 / 60:.0f}m | H4 {next_h4 / 60:.0f}m\n\n"
"Use /autoscan again to stop.",
parse_mode=ParseMode.MARKDOWN,
)
async def cb_scan(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
query = update.callback_query
await query.answer()
btn = SCAN_LOOKUP.get(query.data)
if not btn:
return
pair_display = btn["label"]
await query.edit_message_text(
f"⏳ Scanning {pair_display}…",
parse_mode=ParseMode.MARKDOWN,
)
try:
report = await run_signal(btn["symbol"], btn["tf"])
text = format_signal_message(report)
except Exception as exc:
logger.exception("Signal scan failed")
text = f"❌ Scan failed: {exc}"
await query.edit_message_text(
text,
parse_mode=ParseMode.MARKDOWN,
reply_markup=scan_keyboard(),
)
# ── Boot-time auto-start ──────────────────────────────────────────────
async def _post_init(app: Application) -> None:
"""Register command menu and start scheduler on boot."""
await app.bot.set_my_commands([
("start", "Show scan buttons"),
("autoscan", "Toggle auto-scan on/off"),
("status", "Bot status, WS connection, live prices"),
("help", "All commands"),
])
if not CHAT_ID:
logger.info("TELEGRAM_CHAT_ID not set — auto-scan disabled on boot. "
"Use /autoscan in chat to enable.")
return
chat_id = int(CHAT_ID)
_autoscan_chats.add(chat_id)
logger.info("Running initial scan to populate level cache for WebSocket monitor...")
try:
init_reports = []
for symbol in SCAN_PAIRS:
report = await run_signal(symbol, Timeframe.M15)
init_reports.append(report)
_update_level_cache(init_reports)
logger.info(f"Level cache populated: {sum(len(v) for v in _level_cache.values())} levels")
except Exception:
logger.exception("Initial scan failed — WS will wait for first candle scan")
_start_scheduler(app.bot)
next_m5 = _seconds_until_boundary(5, buffer=10)
next_m15 = _seconds_until_boundary(15, buffer=10)
next_h1 = _seconds_until_boundary(60, buffer=10)
next_h4 = _seconds_until_boundary(240, buffer=10)
logger.info(
f"Auto-scan started for chat {chat_id} — "
f"M5 {next_m5:.0f}s, M15 {next_m15:.0f}s, H1 {next_h1:.0f}s, H4 {next_h4:.0f}s"
)
try:
await app.bot.send_message(
chat_id,
f"🤖 *ForexBot2 started*\n\n"
f"Auto-scanning {len(SCAN_PAIRS)} pairs on M5/M15/H1/H4.\n"
f"📡 Live price monitoring active — level-break alerts enabled.\n"
f"Next: M5 {next_m5 / 60:.0f}m | M15 {next_m15 / 60:.0f}m | "
f"H1 {next_h1 / 60:.0f}m | H4 {next_h4 / 60:.0f}m\n\n"
f"/autoscan to stop | Manual buttons via /start",
parse_mode=ParseMode.MARKDOWN,
)
except Exception:
logger.exception("Failed to send boot message")
# ── Entry point ───────────────────────────────────────────────────────
def main() -> None:
if not TOKEN:
sys.exit("TELEGRAM_TOKEN not set")
logger.info("Starting ForexBot2 Telegram bot…")
app = (
Application.builder()
.token(TOKEN)
.post_init(_post_init)
.build()
)
app.add_handler(CommandHandler("start", cmd_start))
app.add_handler(CommandHandler("help", cmd_help))
app.add_handler(CommandHandler("status", cmd_status))
app.add_handler(CommandHandler("autoscan", cmd_autoscan))
app.add_handler(CallbackQueryHandler(cb_scan, pattern="^scan_"))
logger.info("Bot running — press Ctrl+C to stop")
app.run_polling(allowed_updates=Update.ALL_TYPES)
if __name__ == "__main__":
main()
+55
View File
@@ -0,0 +1,55 @@
"""Live tick stream from Twelve Data WebSocket API."""
import json
import asyncio
import websockets
from datetime import datetime, timezone
API_KEY = "f563b22ffdfd4924955decbe1a8c8701"
WS_URL = f"wss://ws.twelvedata.com/v1/quotes/price?apikey={API_KEY}"
SYMBOLS = ["EUR/USD", "XAU/USD"]
async def stream_ticks():
async with websockets.connect(WS_URL) as ws:
subscribe = {
"action": "subscribe",
"params": {
"symbols": ",".join(SYMBOLS),
},
}
await ws.send(json.dumps(subscribe))
print(f"Subscribed to: {', '.join(SYMBOLS)}")
print("-" * 60)
async for raw in ws:
msg = json.loads(raw)
if msg.get("event") == "subscribe-status":
print(f"Status: {msg.get('status')} — {msg.get('message', '')}")
continue
if msg.get("event") == "heartbeat":
continue
if msg.get("event") == "price":
ts = datetime.fromtimestamp(msg["timestamp"], tz=timezone.utc)
symbol = msg["symbol"]
price = msg["price"]
print(f"{ts:%H:%M:%S} | {symbol:<8} | {price}")
async def main():
print(f"Connecting to Twelve Data WebSocket...")
print(f"API key: ...{API_KEY[-6:]}")
try:
await stream_ticks()
except KeyboardInterrupt:
print("\nStopped.")
except websockets.exceptions.ConnectionClosed as e:
print(f"\nConnection closed: {e}")
if __name__ == "__main__":
asyncio.run(main())