Compare commits

..

6 Commits

Author SHA1 Message Date
Harwinder Sekhon 43d9064d1e Telegram: subscriber channel feed, owner lock, full risk disclaimer
- TELEGRAM_CHANNEL_ID mirrors auto-scan signal + breach alerts to a
  subscriber channel (Stars-subscription monetization; Telegram manages
  membership). Feed keeps running even when the owner's DM auto-scan
  is toggled off.
- _owner_only decorator locks all commands/buttons to TELEGRAM_CHAT_ID;
  others get a refusal pointing at the channel.
- Every signal-bearing message (detailed signal, scan summary, all-TF
  summary, breach alerts) now ends with a not-financial-advice /
  educational-use disclaimer via a single _DISCLAIMER constant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 18:37:50 -04:00
Harwinder Sekhon ec13cd4c07 EUR: document closure of calendar + H1-hybrid avenues
Surprise-conditioned entries lose 2-5p at every horizon both years;
avoidance filter marginal for EUR and profit-removing for XAU; the
H1-direction + M15-pullback prototype failed its pre-registered kill
criteria (2024-25 OOS negative). EUR stays informational-only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 10:26:32 -04:00
Harwinder Sekhon f88e39f5be XAU M15: adopt 1R minimum targets (per-timeframe, validated)
Finishes PENDING_STEPS Step 1 under the current trailing-exit model.
M15 window 2025-03-30->2026-05-31 ($0.30 spread): +116.6R/460 PF 1.83
vs +105.4R/424 PF 1.82 baseline, better in both halves; 2024-06->
2025-03 OOS +30.3R vs +10.6R control. Same knob on the pinned M5 book
is WORSE (+131.3R/699 vs +139.9R/547) so the override is M15-only in
the xauusd factory, per the pre-registered decision rule. M5 path
unchanged (factory override gated on primary_tf == M15).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 10:22:33 -04:00
Harwinder Sekhon 02bd61f378 Live news gate: stop capping signal confidence near events (XAU default)
Replaying the live danger/caution gates against the validated 12mo XAU
M5 book showed they suppress 25-35 trades worth +4.6..+7.2R/yr — trades
entered near USD releases outperform the book average (+0.475R vs
+0.256R). New NewsConfig.event_risk_caps_signals (default False =
XAU-tuned) keeps EVENT RISK warnings in alerts but no longer guts
confidence; EUR factory re-enables the caps (evidence is gold-specific).
Pinned XAU M5 regression: 547 trades, +139.94R, trade-identical.
Tests: 177 passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 10:02:27 -04:00
Harwinder Sekhon 2842351f9b Add calendar research infra: FF scraper + verified event store
Scrapes Forex Factory weekly pages (epoch datelines, actual/forecast/
impact/betterWorse) into backtest/cache/calendar.parquet (gitignored;
rebuild with the scraper). Timestamps verified: NFP/CPI/ECB times exact
across DST, and high-impact event bars average 3.5x ATR with the spike
vanishing at +/-1h shifts. calendar_store provides precise_events and
event_window_mask for screens and counterfactuals.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 09:47:11 -04:00
Harwinder Sekhon 58597de6d7 EUR M15/H1 investigation: document no-validated-edge verdict
Five independent tests at 1-pip cost (M15 wide-open both windows,
transitioning-slice OOS, mean-reversion trade sims, forward-return
screens on M15+M5, H1 both windows) — nothing clears cost in both
2024-25 and 2025-26. Root cause: spread is ~10% of M15 ATR on EUR.
EUR stays out of scan_pairs; open avenues documented in docstring.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 09:02:24 -04:00
10 changed files with 498 additions and 45 deletions
+4 -1
View File
@@ -86,7 +86,8 @@ python backtest/gpu_backtest.py --pair EURUSD --primary-tf 5min --confirm-tfs 15
|----------|---------|---------| |----------|---------|---------|
| `TWELVE_DATA_API_KEY` | Market data API key | Hardcoded fallback in config | | `TWELVE_DATA_API_KEY` | Market data API key | Hardcoded fallback in config |
| `TELEGRAM_TOKEN` | Telegram bot token | Hardcoded fallback | | `TELEGRAM_TOKEN` | Telegram bot token | Hardcoded fallback |
| `TELEGRAM_CHAT_ID` | Auto-scan chat ID (enables boot auto-scan) | Hardcoded fallback | | `TELEGRAM_CHAT_ID` | Owner chat ID (enables boot auto-scan; only this user can drive the bot) | Hardcoded fallback |
| `TELEGRAM_CHANNEL_ID` | Subscriber channel ID (`-100…`); signal + breach alerts mirror there; `""` disables | Hardcoded fallback |
## Dependencies ## Dependencies
@@ -106,6 +107,8 @@ python backtest/gpu_backtest.py --pair EURUSD --primary-tf 5min --confirm-tfs 15
- **Manual scan:** Buttons for M15/H1/H4 individual scans + "Scan All TFs" multi-timeframe summary - **Manual scan:** Buttons for M15/H1/H4 individual scans + "Scan All TFs" multi-timeframe summary
- **WebSocket:** Streams live ticks for XAU/USD; triggers immediate M15 scan on key level breach (swing S/R, BB, KC, EMA200) with 5-min cooldown - **WebSocket:** Streams live ticks for XAU/USD; triggers immediate M15 scan on key level breach (swing S/R, BB, KC, EMA200) with 5-min cooldown
- **Commands:** `/start`, `/scan`, `/autoscan`, `/status`, `/cache`, `/clearcache`, `/help` - **Commands:** `/start`, `/scan`, `/autoscan`, `/status`, `/cache`, `/clearcache`, `/help`
- **Owner lock:** all commands/buttons respond only to `TELEGRAM_CHAT_ID`; others get a refusal message
- **Subscriber channel:** with `TELEGRAM_CHANNEL_ID` set, auto-scan + breach alerts also post to the channel (bot must be channel admin). Monetized via Telegram Stars subscription invite links — Telegram handles membership; the bot has no payment code. Channel feed keeps running even if the owner's DM auto-scan is off
## Backtesting ## Backtesting
+5
View File
@@ -0,0 +1,5 @@
"""Research tooling: calendar sourcing/store and edge-screening utilities.
Nothing here is imported by the live bot; these modules support
backtesting and edge validation only.
"""
+94
View File
@@ -0,0 +1,94 @@
"""Load and query the historical economic calendar (backtest side).
The store is built by `ff_calendar_scraper.py`. Timestamps are unix
epoch (UTC) — verified against known release times and price-volatility
alignment (see scraper docstring).
Key API:
cal = load_calendar() # full DataFrame, `utc` column
ev = precise_events(cal, ["USD", "EUR"]) # high-impact, minute-accurate
mask = event_window_mask(bar_index, ev.utc, before, after)
"""
from pathlib import Path
from typing import Iterable, Optional
import numpy as np
import pandas as pd
PARQUET = Path(__file__).resolve().parent.parent / "cache" / "calendar.parquet"
# timeLabel values that mean "no intraday time" — these events cannot
# anchor an M5/M15 window (German Prelim CPI is the notable High one).
_UNTIMED = {"All Day", "Tentative", ""}
def _parse_release_value(raw) -> float:
"""'0.3%' / '-1.7%' / '228K' / '1.1M' -> float (nan if unparseable)."""
import re
if raw is None or (isinstance(raw, float) and np.isnan(raw)):
return np.nan
m = re.search(r"(-?\d+(?:\.\d+)?)\s*([kmbt])?",
str(raw).replace(",", ""), re.IGNORECASE)
if not m:
return np.nan
mult = {"k": 1e3, "m": 1e6, "b": 1e9, "t": 1e12}.get(
(m.group(2) or "").lower(), 1.0)
return float(m.group(1)) * mult
def load_calendar(path: Path = PARQUET) -> pd.DataFrame:
df = pd.read_parquet(path)
df["utc"] = pd.to_datetime(df.dateline, unit="s", utc=True)
for col in ("actual", "forecast", "previous"):
df[f"{col}_num"] = df[col].map(_parse_release_value)
df["surprise"] = df.actual_num - df.forecast_num
return df
def precise_events(
cal: pd.DataFrame,
currencies: Iterable[str] = ("USD", "EUR"),
impacts: Iterable[str] = ("high",),
require_values: bool = False,
) -> pd.DataFrame:
"""Events with a minute-accurate timestamp, filtered by currency/impact."""
out = cal[cal.currency.isin(list(currencies))
& cal.impactName.isin(list(impacts))
& ~cal.timeMasked.astype(bool)
& ~cal.timeLabel.fillna("").isin(_UNTIMED)]
if require_values:
out = out.dropna(subset=["actual_num", "forecast_num"])
return out.sort_values("utc").reset_index(drop=True)
def event_window_mask(
bar_index: pd.DatetimeIndex,
event_times: pd.Series,
before: pd.Timedelta = pd.Timedelta(minutes=30),
after: pd.Timedelta = pd.Timedelta(minutes=30),
) -> np.ndarray:
"""Boolean mask: bar OPEN time falls inside [event-before, event+after).
Use on a backtest's entry times to identify trades entered around
releases (avoidance counterfactuals, event-window cost models).
"""
starts = pd.DatetimeIndex(event_times) - before
ends = pd.DatetimeIndex(event_times) + after
order = np.argsort(starts.values)
starts, ends = starts.values[order], ends.values[order]
# merge overlapping windows, then interval-stab each bar time
merged_s, merged_e = [], []
for s, e in zip(starts, ends):
if merged_e and s <= merged_e[-1]:
merged_e[-1] = max(merged_e[-1], e)
else:
merged_s.append(s)
merged_e.append(e)
ms = np.array(merged_s)
me = np.array(merged_e)
ts = bar_index.values
pos = np.searchsorted(ms, ts, side="right") - 1
ok = pos >= 0
res = np.zeros(len(ts), dtype=bool)
res[ok] = ts[ok] < me[pos[ok]]
return res
+144
View File
@@ -0,0 +1,144 @@
"""Forex Factory calendar scraper -> backtest/cache/calendar.parquet.
Fetches weekly calendar pages and parses the event JSON embedded in
`window.calendarComponentStates` (the `days:` array). Event `dateline`
is a unix epoch, so timestamps carry no timezone ambiguity — verified
2026-07-11 against known release times (NFP 12:30/13:30 UTC across DST,
ECB 12:15/13:15) and against EUR/USD M15 volatility: the bar containing
a high-impact event averages 3.5x normal range, and shifting event
times by ±1h destroys that alignment.
Usage:
python backtest/research/ff_calendar_scraper.py --start 2024-01-01 --end 2026-07-13
python backtest/research/ff_calendar_scraper.py # refresh: last stored week -> today
Merges into the existing parquet on event (id, dateline), so refreshing
is idempotent and also picks up revised `actual` values for recent weeks.
Be polite: ~1.6s between requests (a full year is ~52 requests).
"""
import argparse
import json
import time
from datetime import date, timedelta
from pathlib import Path
from typing import List, Optional
import pandas as pd
import requests
CACHE_DIR = Path(__file__).resolve().parent.parent / "cache"
PARQUET = CACHE_DIR / "calendar.parquet"
UA = ("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/126.0 Safari/537.36")
FIELDS = ["id", "dateline", "name", "country", "currency", "impactName",
"timeLabel", "timeMasked", "actual", "forecast", "previous",
"revision", "actualBetterWorse"]
def week_url(d: date) -> str:
return (f"https://www.forexfactory.com/calendar?week="
f"{d.strftime('%b').lower()}{d.day}.{d.year}")
def extract_days(html: str) -> Optional[list]:
"""Balanced-bracket extraction of the `days:` JSON array."""
i = html.find("days:")
if i < 0:
return None
j = html.find("[", i)
depth, in_str, esc = 0, False, False
for k in range(j, len(html)):
ch = html[k]
if in_str:
if esc:
esc = False
elif ch == "\\":
esc = True
elif ch == '"':
in_str = False
else:
if ch == '"':
in_str = True
elif ch == "[":
depth += 1
elif ch == "]":
depth -= 1
if depth == 0:
return json.loads(html[j:k + 1])
return None
def fetch_week(sess: requests.Session, d: date, retries: int = 4) -> Optional[List[dict]]:
for attempt in range(retries):
try:
r = sess.get(week_url(d), timeout=30)
if r.status_code == 200:
days = extract_days(r.text)
if days is not None:
return [{k: e.get(k) for k in FIELDS}
for day in days for e in day.get("events", [])]
except requests.RequestException:
pass
time.sleep(5 * (attempt + 1))
return None
def scrape(start: date, end: date) -> pd.DataFrame:
sess = requests.Session()
sess.headers["User-Agent"] = UA
rows: List[dict] = []
failed: List[date] = []
d = start
while d <= end:
week_rows = fetch_week(sess, d)
if week_rows is None:
failed.append(d)
print(f"FAILED week {d}")
else:
rows.extend(week_rows)
d += timedelta(days=7)
time.sleep(1.6)
if failed:
print(f"WARNING: {len(failed)} weeks failed: {failed}")
return pd.DataFrame(rows)
def merge_into_store(new: pd.DataFrame, path: Path = PARQUET) -> pd.DataFrame:
if path.exists():
old = pd.read_parquet(path)
combined = pd.concat([old, new])
else:
combined = new
# keep="last": a refresh overwrites stale rows (actuals fill in
# after release, revisions land weeks later).
combined = (combined.drop_duplicates(subset=["id", "dateline"], keep="last")
.sort_values("dateline").reset_index(drop=True))
combined.to_parquet(path, index=False)
return combined
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--start", type=date.fromisoformat, default=None)
ap.add_argument("--end", type=date.fromisoformat, default=date.today())
args = ap.parse_args()
start = args.start
if start is None:
if PARQUET.exists():
last = pd.read_parquet(PARQUET).dateline.max()
start = pd.Timestamp(last, unit="s").date() - timedelta(days=7)
else:
start = date(2024, 1, 1)
print(f"Scraping {start} -> {args.end}")
new = scrape(start, args.end)
combined = merge_into_store(new)
print(f"Store: {len(combined)} events "
f"({pd.Timestamp(combined.dateline.min(), unit='s')} -> "
f"{pd.Timestamp(combined.dateline.max(), unit='s')}) -> {PARQUET}")
if __name__ == "__main__":
main()
+8
View File
@@ -168,6 +168,14 @@ class NewsConfig:
tracked_currencies: List[str] = field(default_factory=lambda: ["USD", "XAU", "EUR"]) tracked_currencies: List[str] = field(default_factory=lambda: ["USD", "XAU", "EUR"])
pre_event_buffer_minutes: int = 30 pre_event_buffer_minutes: int = 30
post_event_buffer_minutes: int = 15 post_event_buffer_minutes: int = 15
# Whether event risk gates the SIGNAL (danger: confidence hard-capped
# at 30; caution: 15-point penalty). Warnings are always emitted
# regardless. Default False = XAU-tuned: replaying the live gates
# against the validated 12mo XAU M5 book (547 trades, +139.9R)
# showed they suppress 25-35 trades worth +4.6..+7.2R/yr — trades
# entered near USD releases OUTPERFORM (+0.475R vs +0.256R avg).
# Audit 2026-07-11 via backtest/research/calendar_store.
event_risk_caps_signals: bool = False
# Wider stay-out window after a high-impact release that surprised # Wider stay-out window after a high-impact release that surprised
# vs forecast (inline releases use post_event_buffer_minutes). # vs forecast (inline releases use post_event_buffer_minutes).
post_event_surprise_buffer_minutes: int = 45 post_event_surprise_buffer_minutes: int = 45
+39 -2
View File
@@ -15,10 +15,42 @@ branch's -6.7R. Findings that shaped them:
- score 30-35 band is where the train-half losses live → floor 35 - score 30-35 band is where the train-half losses live → floor 35
- trailing exits beat static TP1 (arm 0.45R / dist 0.5R best) - trailing exits beat static TP1 (arm 0.45R / dist 0.5R best)
M15 + H1 investigated 2026-07-11, verdict NO VALIDATED EDGE either
(all at 1-pip round-trip cost, real fills):
- M15 wide-open (all gates off): -2.5R/270 trades 2025-26;
-54.8R/308 trades on 2024-25 out-of-sample. PF 0.61-0.98.
- Best in-sample slice (transitioning-regime-only: +15.4R, PF 1.59,
positive both halves, month-spread) FAILED 2024-25 OOS at PF 0.55.
Within-year train/val splits are not sufficient for EUR — always
re-test on the 2024 window before believing a config.
- Raw-parquet forward-return screens (M15 and M5, ~14 entry
conditions x 4-5 horizons: momentum, mean-reversion, breakout,
streak, session families): nothing clears +1 pip in both years.
- H1 wide-open: +9.8R PF 1.32 in 2025-26 (both halves positive) but
-5.7R PF 0.82 on 2024-25. Positive from Jan 2025 onward; H2-2024
regime negative. Closest to workable, still unvalidated.
Root cause is cost-to-volatility: EUR M15 ATR ~= 10 pips, so the 1-pip
spread taxes every trade ~10% of ATR (M5 worse). The XAU signal engine
survives because gold's ATR/spread ratio is several times larger.
EUR/USD therefore ships FUNCTIONAL BUT OFF by default: it is not in EUR/USD therefore ships FUNCTIONAL BUT OFF by default: it is not in
PlatformConfig.scan_pairs. Scan it on demand (main.py --pair EURUSD, PlatformConfig.scan_pairs. Scan it on demand (main.py --pair EURUSD,
or add to scan_pairs) but do not treat M5 signals as having a or add to scan_pairs) but treat all EUR M5/M15 signals as informational
validated edge. M15 tuning is a future session. only.
Calendar + hybrid avenues CLOSED 2026-07-12 (see backtest/research/):
- Surprise-conditioned entries (real Forex Factory archive, verified
timestamps): follow-surprise loses 2-5 pips at every horizon in
both years under every conditioning. The market prices the
surprise within the release bar.
- Event-window avoidance: EUR effect marginal/inconsistent (and on
XAU it would REMOVE profit — event-window trades outperform).
- H1-anchored direction + M15 pullback execution (pre-registered
kill criteria): 2025-26 +2.8R PF 1.11, 2024-25 OOS -1.5R PF 0.92
-> killed.
Remaining avenues need data we don't have: tick-level fills for
limit-entry cost reduction, or H4/D1 swing horizons.
""" """
from typing import Optional, List from typing import Optional, List
@@ -93,6 +125,11 @@ def make_config(
# belt-and-braces so no gold logic can ever fire on an EUR config. # belt-and-braces so no gold logic can ever fire on an EUR config.
config.xau_strategy.enabled = False config.xau_strategy.enabled = False
# Keep event-risk signal gating ON for EUR (the XAU-tuned default
# turned it off after the 2026-07-11 audit — that evidence is
# gold-specific; EUR event-window trades were flat-to-negative).
config.news.event_risk_caps_signals = True
config.backtest.session_start_hour = 7 config.backtest.session_start_hour = 7
config.backtest.session_end_hour = 20 config.backtest.session_end_hour = 20
config.backtest.blocked_hours_utc = [22, 23, 0, 1, 2, 3] config.backtest.blocked_hours_utc = [22, 23, 0, 1, 2, 3]
+16 -3
View File
@@ -1,9 +1,19 @@
"""XAU/USD signal producer config. """XAU/USD signal producer config.
PlatformConfig defaults ARE the tuned XAU/USD configuration, so this PlatformConfig defaults ARE the tuned XAU/USD configuration, so this
factory only sets the timeframes. It must stay equivalent to the inline factory sets the timeframes plus documented per-timeframe overrides.
PlatformConfig(symbol=XAUUSD, ...) construction used by the backtester It must stay equivalent to the inline PlatformConfig(symbol=XAUUSD,
and Telegram bot (enforced by a test in test_all.py). ...) construction (plus those overrides) used by the backtester and
Telegram bot (enforced by a test in test_all.py).
M15 override (validated 2026-07-11, window 2025-03-30 -> 2026-05-31,
$0.30 spread, trailing exits): tp_floor_rr 1.0 scores +116.6R/460
trades PF 1.83 vs +105.4R/424 PF 1.82 at the 0.5 default — better in
both train/val halves and on the 2024-06 -> 2025-03 out-of-sample
window (+30.3R vs +10.6R; the 1R floor lifts targets past the min-TP
spread gate, roughly doubling trades there). The same knob HURTS M5
(pinned 12mo book: +131.3R/699 vs +139.9R/547, PF 1.55 vs 1.78), so it
is per-timeframe, exactly as PENDING_STEPS' decision rule anticipated.
""" """
from typing import Optional, List from typing import Optional, List
@@ -26,4 +36,7 @@ def make_config(
config.confirmation_timeframes = ( config.confirmation_timeframes = (
confirmation_tfs or _CONFIRMATIONS.get(primary_tf, [Timeframe.M15, Timeframe.H4]) confirmation_tfs or _CONFIRMATIONS.get(primary_tf, [Timeframe.M15, Timeframe.H4])
) )
if primary_tf == Timeframe.M15:
# 1R minimum target on M15 only — see module docstring.
config.signals.tp_floor_rr = 1.0
return config return config
+5
View File
@@ -1138,8 +1138,12 @@ class SignalAggregator:
confidence += 5 confidence += 5
# ── Event risk (hard cap, separate from penalty system) ─── # ── Event risk (hard cap, separate from penalty system) ───
# Warning always shown; the confidence cap is config-gated
# (news.event_risk_caps_signals) — see NewsConfig for the audit
# showing the cap costs R on XAU.
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}")
if self.config.news.event_risk_caps_signals:
confidence = min(confidence, 30) confidence = min(confidence, 30)
report.action_recommendation = f"WAIT — {report.event_description}" report.action_recommendation = f"WAIT — {report.event_description}"
@@ -1163,6 +1167,7 @@ class SignalAggregator:
if report.event_risk == "caution": if report.event_risk == "caution":
report.warnings.append(f"Upcoming event: {report.event_description}") report.warnings.append(f"Upcoming event: {report.event_description}")
if self.config.news.event_risk_caps_signals:
penalties.append(("event_caution", 15)) penalties.append(("event_caution", 15))
is_counter_trend = ( is_counter_trend = (
+126 -23
View File
@@ -18,12 +18,26 @@ WebSocket: Streams live ticks from TwelveData for XAU/USD.
immediate M15 scan and sends a Level Break Alert. immediate M15 scan and sends a Level Break Alert.
5-minute cooldown prevents spam. 5-minute cooldown prevents spam.
Owner lock: All commands and buttons only respond to TELEGRAM_CHAT_ID
(the owner). Everyone else gets a polite refusal — the bot
is a private control panel, not the product.
Channel: If TELEGRAM_CHANNEL_ID is set (-100xxxxxxxxxx), auto-scan
signal alerts and level-breach alerts are also posted to
that channel. Pair it with a Telegram Stars subscription
invite link to sell access — Telegram manages join/renewal/
kick; the bot just posts there (needs channel-admin +
post-messages). Manual scans, status and cache replies stay
owner-DM only. The channel feed runs even when the owner
toggles their own DM auto-scan off.
Usage: Usage:
python telegram_bot.py python telegram_bot.py
TELEGRAM_TOKEN=<token> TELEGRAM_CHAT_ID=<id> python telegram_bot.py TELEGRAM_TOKEN=<token> TELEGRAM_CHAT_ID=<id> \
TELEGRAM_CHANNEL_ID=<-100…> python telegram_bot.py
""" """
import asyncio import asyncio
import functools
import json import json
import logging import logging
import logging.handlers import logging.handlers
@@ -79,6 +93,18 @@ logger = logging.getLogger("telegram_bot")
TOKEN = os.environ.get("TELEGRAM_TOKEN", "8228251480:AAFW2OAyQ2idMicql2t_CIXk1r9-qfRTfNE") TOKEN = os.environ.get("TELEGRAM_TOKEN", "8228251480:AAFW2OAyQ2idMicql2t_CIXk1r9-qfRTfNE")
CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "6025899022") CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "6025899022")
# Owner lock: only this user/chat may drive the bot (commands + buttons).
# The bot is the product's signal source — without the lock, anyone who
# finds it could /autoscan and receive paid signals for free in DM.
OWNER_ID: Optional[int] = int(CHAT_ID) if CHAT_ID else None
# Subscriber channel (paid, Stars-subscription invite link managed by
# Telegram). When set, auto-scan signal alerts and level-breach alerts
# are also posted there. Manual scans, status, cache and boot messages
# stay owner-DM only. Set TELEGRAM_CHANNEL_ID="" to disable the feed.
_CHANNEL_ID_RAW = os.environ.get("TELEGRAM_CHANNEL_ID", "-1003942265371")
CHANNEL_ID: Optional[int] = int(_CHANNEL_ID_RAW) if _CHANNEL_ID_RAW else None
# ── Pairs & buttons ─────────────────────────────────────────────────── # ── Pairs & buttons ───────────────────────────────────────────────────
# The scanned pair list is config-driven: PlatformConfig.scan_pairs is # The scanned pair list is config-driven: PlatformConfig.scan_pairs is
# the single switch for single- vs multi-pair operation. Buttons, WS # the single switch for single- vs multi-pair operation. Buttons, WS
@@ -225,6 +251,13 @@ def _live_price_line(symbol: str) -> Optional[str]:
return f"📡 Live: *{_fmt_price(price)}* ({age:.0f}s tick)" return f"📡 Live: *{_fmt_price(price)}* ({age:.0f}s tick)"
_DISCLAIMER = (
"⚠️ _Not financial advice. For educational and research purposes only. "
"Trading forex/CFDs carries a high risk of loss — do your own analysis "
"and never risk money you cannot afford to lose._"
)
def format_signal_message(report: SignalReport) -> str: def format_signal_message(report: SignalReport) -> str:
sig = report.final_signal sig = report.final_signal
emoji = _SIGNAL_EMOJI.get(sig, "⬜") emoji = _SIGNAL_EMOJI.get(sig, "⬜")
@@ -310,8 +343,7 @@ def format_signal_message(report: SignalReport) -> str:
f"behind best price — TP1 is a guide, let it run" f"behind best price — TP1 is a guide, let it run"
) )
lines += ["", "━━━━━━━━━━━━━━━━━━━━", lines += ["", "━━━━━━━━━━━━━━━━━━━━", _DISCLAIMER]
"_Educational / research use only._"]
return "\n".join(lines) return "\n".join(lines)
@@ -342,6 +374,8 @@ def _format_scan_summary(reports: List[SignalReport], tf: Timeframe) -> str:
else: else:
lines.append("No actionable signals this cycle.") lines.append("No actionable signals this cycle.")
lines += ["", _DISCLAIMER]
return "\n".join(lines) return "\n".join(lines)
@@ -477,6 +511,20 @@ _autoscan_chats: Set[int] = set()
_autoscan_tasks: list[asyncio.Task] = [] _autoscan_tasks: list[asyncio.Task] = []
def _feed_active() -> bool:
"""Scan loops keep running while anyone is listening: owner DM
auto-scan chats or the subscriber channel."""
return bool(_autoscan_chats) or CHANNEL_ID is not None
def _signal_targets() -> List[int]:
"""Where signal/breach alerts go: owner auto-scan chats + channel."""
targets: List[int] = list(_autoscan_chats)
if CHANNEL_ID is not None:
targets.append(CHANNEL_ID)
return targets
def _seconds_until_boundary(interval_minutes: int, buffer: int = 10) -> float: def _seconds_until_boundary(interval_minutes: int, buffer: int = 10) -> float:
"""Seconds until the next candle close + buffer.""" """Seconds until the next candle close + buffer."""
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
@@ -515,7 +563,7 @@ async def _scan_timeframe(
if _passes_quality(r.signal_quality) if _passes_quality(r.signal_quality)
and _should_alert(f"{r.symbol}_{tf.value}", r) and _should_alert(f"{r.symbol}_{tf.value}", r)
] ]
for chat_id in list(_autoscan_chats): for chat_id in _signal_targets():
for report in actionable: for report in actionable:
detail = format_signal_message(report) detail = format_signal_message(report)
try: try:
@@ -610,7 +658,7 @@ async def _handle_breach(bot, ws_symbol: str, pair_symbol: str, price: float, le
f"*{_escape_md(label)}* @ `{_fmt_price(price)}`\n\n" f"*{_escape_md(label)}* @ `{_fmt_price(price)}`\n\n"
) )
detail = header + format_signal_message(report) detail = header + format_signal_message(report)
for chat_id in list(_autoscan_chats): for chat_id in _signal_targets():
try: try:
await bot.send_message(chat_id, detail, parse_mode=ParseMode.MARKDOWN) await bot.send_message(chat_id, detail, parse_mode=ParseMode.MARKDOWN)
except Exception as exc: except Exception as exc:
@@ -627,7 +675,7 @@ async def _ws_heartbeat(ws) -> None:
async def _ws_monitor(bot) -> None: async def _ws_monitor(bot) -> None:
backoff = 1 backoff = 1
while _autoscan_chats: while _feed_active():
hb_task: Optional[asyncio.Task] = None hb_task: Optional[asyncio.Task] = None
try: try:
async with websockets.connect(_WS_URL) as ws: async with websockets.connect(_WS_URL) as ws:
@@ -641,7 +689,7 @@ async def _ws_monitor(bot) -> None:
hb_task = asyncio.create_task(_ws_heartbeat(ws)) hb_task = asyncio.create_task(_ws_heartbeat(ws))
async for raw in ws: async for raw in ws:
if not _autoscan_chats: if not _feed_active():
break break
try: try:
@@ -699,11 +747,11 @@ async def _ws_monitor(bot) -> None:
async def _candle_loop(bot, tf: Timeframe, interval_minutes: int) -> None: async def _candle_loop(bot, tf: Timeframe, interval_minutes: int) -> None:
label = tf.value label = tf.value
try: try:
while _autoscan_chats: while _feed_active():
wait = _seconds_until_boundary(interval_minutes, buffer=10) wait = _seconds_until_boundary(interval_minutes, buffer=10)
logger.info(f"{label} auto-scan: next in {wait:.0f}s") logger.info(f"{label} auto-scan: next in {wait:.0f}s")
await asyncio.sleep(wait) await asyncio.sleep(wait)
if not _autoscan_chats: if not _feed_active():
break break
logger.info(f"{label} auto-scan: firing") logger.info(f"{label} auto-scan: firing")
await _scan_timeframe( await _scan_timeframe(
@@ -758,6 +806,35 @@ def _stop_scheduler() -> None:
# ── Handlers ────────────────────────────────────────────────────────── # ── Handlers ──────────────────────────────────────────────────────────
def _owner_only(handler):
"""Reject commands/buttons from anyone but the owner.
Signals are sold via the subscriber channel; the bot itself is the
owner's control panel. Without this, any user who finds the bot
could /autoscan and receive the paid feed for free in their DM.
"""
@functools.wraps(handler)
async def wrapped(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
user = update.effective_user
chat = update.effective_chat
uid = user.id if user else None
if OWNER_ID is not None and (uid == OWNER_ID or (chat and chat.id == OWNER_ID)):
return await handler(update, context)
logger.warning(
f"Unauthorized access blocked: user={uid} "
f"chat={chat.id if chat else None} handler={handler.__name__}"
)
if update.callback_query:
await update.callback_query.answer("🔒 Not authorized.")
elif update.message:
await update.message.reply_text(
"🔒 This is a private control bot. Signals are published "
"in the subscriber channel."
)
return wrapped
@_owner_only
async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
scanning = "running" if _autoscan_chats else "off" scanning = "running" if _autoscan_chats else "off"
text = ( text = (
@@ -788,10 +865,14 @@ _HELP_TEXT = (
"*Auto-scan* fires at candle close: M5/M15/H1/H4.\n" "*Auto-scan* fires at candle close: M5/M15/H1/H4.\n"
"*Alerts* button sets the minimum quality grade that gets sent.\n" "*Alerts* button sets the minimum quality grade that gets sent.\n"
"*WebSocket* streams live prices; key-level breaches trigger an " "*WebSocket* streams live prices; key-level breaches trigger an "
"instant M15 rescan and alert (5-min cooldown)." "instant M15 rescan and alert (5-min cooldown).\n"
"*Channel feed*: signal + breach alerts mirror to the subscriber "
"channel when TELEGRAM_CHANNEL_ID is set; /autoscan only toggles "
"your DM copy."
) )
@_owner_only
async def cmd_help(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: async def cmd_help(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
await update.message.reply_text(_HELP_TEXT, parse_mode=ParseMode.MARKDOWN) await update.message.reply_text(_HELP_TEXT, parse_mode=ParseMode.MARKDOWN)
@@ -806,6 +887,9 @@ def _status_text() -> str:
else: else:
lines.append("⏹ Auto-scan: *stopped*") lines.append("⏹ Auto-scan: *stopped*")
if CHANNEL_ID is not None:
lines.append(f"📢 Channel feed: *on* (`{CHANNEL_ID}`)")
if _ws_task and not _ws_task.done(): if _ws_task and not _ws_task.done():
lines.append("📡 WebSocket: *connected*") lines.append("📡 WebSocket: *connected*")
else: else:
@@ -848,10 +932,12 @@ def _status_text() -> str:
return "\n".join(lines) return "\n".join(lines)
@_owner_only
async def cmd_status(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: async def cmd_status(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
await update.message.reply_text(_status_text(), parse_mode=ParseMode.MARKDOWN) await update.message.reply_text(_status_text(), parse_mode=ParseMode.MARKDOWN)
@_owner_only
async def cmd_cache(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: async def cmd_cache(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
dp = _get_shared_dp() dp = _get_shared_dp()
if not dp._cache_timestamps: if not dp._cache_timestamps:
@@ -881,6 +967,7 @@ async def cmd_cache(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
await update.message.reply_text("\n".join(lines), parse_mode=ParseMode.MARKDOWN) await update.message.reply_text("\n".join(lines), parse_mode=ParseMode.MARKDOWN)
@_owner_only
async def cmd_clearcache(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: async def cmd_clearcache(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
dp = _get_shared_dp() dp = _get_shared_dp()
count = dp.clear_cache() count = dp.clear_cache()
@@ -890,17 +977,19 @@ async def cmd_clearcache(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
) )
@_owner_only
async def cmd_autoscan(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: async def cmd_autoscan(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
chat_id = update.effective_chat.id chat_id = update.effective_chat.id
if chat_id in _autoscan_chats: if chat_id in _autoscan_chats:
_autoscan_chats.discard(chat_id) _autoscan_chats.discard(chat_id)
if not _autoscan_chats: if not _feed_active():
_stop_scheduler() _stop_scheduler()
await update.message.reply_text( msg = "⏹ Auto-scan stopped."
"⏹ Auto-scan stopped.", else:
parse_mode=ParseMode.MARKDOWN, msg = ("⏹ Auto-scan stopped for this chat.\n"
) "📢 Subscriber channel feed continues.")
await update.message.reply_text(msg, parse_mode=ParseMode.MARKDOWN)
return return
_autoscan_chats.add(chat_id) _autoscan_chats.add(chat_id)
@@ -921,6 +1010,7 @@ async def cmd_autoscan(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No
) )
@_owner_only
async def cmd_scan(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: async def cmd_scan(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
await update.message.reply_text( await update.message.reply_text(
f"🔍 *{_PAIRS_LABEL} — Pick a timeframe*", f"🔍 *{_PAIRS_LABEL} — Pick a timeframe*",
@@ -929,6 +1019,7 @@ async def cmd_scan(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
) )
@_owner_only
async def cb_scan(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: async def cb_scan(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
query = update.callback_query query = update.callback_query
await query.answer() await query.answer()
@@ -954,6 +1045,7 @@ async def cb_scan(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
await _safe_edit(query, text, reply_markup=main_menu_keyboard()) await _safe_edit(query, text, reply_markup=main_menu_keyboard())
@_owner_only
async def cb_menu(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: async def cb_menu(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Interactive menu actions: autoscan toggle, quality cycle, status, help.""" """Interactive menu actions: autoscan toggle, quality cycle, status, help."""
global _alert_min_quality global _alert_min_quality
@@ -964,9 +1056,11 @@ async def cb_menu(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
chat_id = query.message.chat.id chat_id = query.message.chat.id
if chat_id in _autoscan_chats: if chat_id in _autoscan_chats:
_autoscan_chats.discard(chat_id) _autoscan_chats.discard(chat_id)
if not _autoscan_chats: if not _feed_active():
_stop_scheduler() _stop_scheduler()
await query.answer("Auto-scan stopped") await query.answer("Auto-scan stopped")
else:
await query.answer("DM alerts off — channel feed continues")
else: else:
_autoscan_chats.add(chat_id) _autoscan_chats.add(chat_id)
_start_scheduler(context.bot) _start_scheduler(context.bot)
@@ -1044,6 +1138,7 @@ async def _cb_scan_all(query, symbol: str = XAUUSD) -> None:
lines.append(err) lines.append(err)
lines.append("_Use buttons below for detailed single-TF view._") lines.append("_Use buttons below for detailed single-TF view._")
lines += ["", _DISCLAIMER]
await _safe_edit(query, "\n".join(lines), reply_markup=main_menu_keyboard()) await _safe_edit(query, "\n".join(lines), reply_markup=main_menu_keyboard())
@@ -1062,13 +1157,16 @@ async def _post_init(app: Application) -> None:
("help", "All commands"), ("help", "All commands"),
]) ])
if not CHAT_ID: if CHAT_ID:
logger.info("TELEGRAM_CHAT_ID not set — auto-scan disabled on boot. " _autoscan_chats.add(int(CHAT_ID))
"Use /autoscan in chat to enable.")
if not _feed_active():
logger.info("Neither TELEGRAM_CHAT_ID nor TELEGRAM_CHANNEL_ID set — "
"auto-scan disabled on boot. Use /autoscan in chat to enable.")
return return
chat_id = int(CHAT_ID) if CHANNEL_ID is not None:
_autoscan_chats.add(chat_id) logger.info(f"Subscriber channel feed enabled: {CHANNEL_ID}")
logger.info("Running initial scan to populate level cache and signal stats...") logger.info("Running initial scan to populate level cache and signal stats...")
try: try:
@@ -1094,16 +1192,21 @@ async def _post_init(app: Application) -> None:
next_h1 = _seconds_until_boundary(60, buffer=10) next_h1 = _seconds_until_boundary(60, buffer=10)
next_h4 = _seconds_until_boundary(240, buffer=10) next_h4 = _seconds_until_boundary(240, buffer=10)
logger.info( logger.info(
f"Auto-scan started for chat {chat_id} — " f"Auto-scan started (chats={sorted(_autoscan_chats)}, channel={CHANNEL_ID}) — "
f"M5 {next_m5:.0f}s, M15 {next_m15:.0f}s, H1 {next_h1:.0f}s, H4 {next_h4:.0f}s" f"M5 {next_m5:.0f}s, M15 {next_m15:.0f}s, H1 {next_h1:.0f}s, H4 {next_h4:.0f}s"
) )
# Boot message goes to the owner DM only — the subscriber channel
# receives nothing but signal and breach alerts.
if OWNER_ID is not None:
channel_line = "📢 Subscriber channel feed active.\n" if CHANNEL_ID else ""
try: try:
await app.bot.send_message( await app.bot.send_message(
chat_id, OWNER_ID,
f"🤖 *ForexBot2 started*\n\n" f"🤖 *ForexBot2 started*\n\n"
f"Auto-scanning {_PAIRS_LABEL} on M5/M15/H1/H4.\n" f"Auto-scanning {_PAIRS_LABEL} on M5/M15/H1/H4.\n"
f"📡 Live price monitoring active.\n" f"📡 Live price monitoring active.\n"
f"{channel_line}"
f"Next: M5 {next_m5 / 60:.0f}m | M15 {next_m15 / 60:.0f}m | " 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"H1 {next_h1 / 60:.0f}m | H4 {next_h4 / 60:.0f}m\n\n"
f"/autoscan to stop", f"/autoscan to stop",
+41
View File
@@ -1203,6 +1203,43 @@ def _():
assert report.action_recommendation != "" assert report.action_recommendation != ""
@test("Event-risk signal gating obeys news.event_risk_caps_signals")
def _():
config = PlatformConfig()
agg = SignalAggregator(config)
dfs = {
Timeframe.H1: make_ohlcv(300, seed=42),
Timeframe.M15: make_ohlcv(300, seed=43),
Timeframe.H4: make_ohlcv(300, seed=44),
}
report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False)
def recompute(risk, caps):
config.news.event_risk_caps_signals = caps
report.event_risk = risk
report.event_description = "USD Test Event in 10min"
report.warnings = []
agg._compute_final_signal(report)
return report.final_confidence
baseline = recompute("clear", True)
# default XAU behavior (caps off): danger warns but does not gut
conf_off = recompute("danger", False)
assert conf_off == baseline, "flag off must not change confidence"
assert any("EVENT RISK" in w for w in report.warnings), "warning must persist"
# caps on: danger hard-caps confidence at 30
conf_on = recompute("danger", True)
assert conf_on <= 30, f"danger with caps on must cap conf at 30, got {conf_on}"
# caution with caps off is also warning-only
conf_caut_off = recompute("caution", False)
assert conf_caut_off == baseline
assert any("Upcoming event" in w for w in report.warnings)
# PlatformConfig default is off (XAU-tuned); EUR factory re-enables
assert PlatformConfig().news.event_risk_caps_signals is False
from pair_signals.eurusd import make_config as eur_make
assert eur_make(Timeframe.M15).news.event_risk_caps_signals is True
@test("SignalAggregator._generate_trade_setup BUY direction") @test("SignalAggregator._generate_trade_setup BUY direction")
def _(): def _():
config = PlatformConfig() config = PlatformConfig()
@@ -1807,6 +1844,10 @@ def _():
primary_timeframe=tf, primary_timeframe=tf,
confirmation_timeframes=ctfs, confirmation_timeframes=ctfs,
) )
if tf == Timeframe.M15:
# Documented per-TF override (see xauusd.py docstring):
# 1R minimum targets validated for M15 only.
inline_cfg.signals.tp_floor_rr = 1.0
assert dataclasses.asdict(factory_cfg) == dataclasses.asdict(inline_cfg), \ assert dataclasses.asdict(factory_cfg) == dataclasses.asdict(inline_cfg), \
f"XAU factory config differs from inline construction for {tf}" f"XAU factory config differs from inline construction for {tf}"