Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 43d9064d1e | |||
| ec13cd4c07 | |||
| f88e39f5be | |||
| 02bd61f378 | |||
| 2842351f9b | |||
| 58597de6d7 |
@@ -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 |
|
||||
| `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
|
||||
|
||||
@@ -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
|
||||
- **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`
|
||||
- **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
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Research tooling: calendar sourcing/store and edge-screening utilities.
|
||||
|
||||
Nothing here is imported by the live bot; these modules support
|
||||
backtesting and edge validation only.
|
||||
"""
|
||||
@@ -0,0 +1,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
|
||||
@@ -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()
|
||||
@@ -168,6 +168,14 @@ class NewsConfig:
|
||||
tracked_currencies: List[str] = field(default_factory=lambda: ["USD", "XAU", "EUR"])
|
||||
pre_event_buffer_minutes: int = 30
|
||||
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
|
||||
# vs forecast (inline releases use post_event_buffer_minutes).
|
||||
post_event_surprise_buffer_minutes: int = 45
|
||||
|
||||
+39
-2
@@ -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
|
||||
- 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
|
||||
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
|
||||
validated edge. M15 tuning is a future session.
|
||||
or add to scan_pairs) but treat all EUR M5/M15 signals as informational
|
||||
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
|
||||
@@ -93,6 +125,11 @@ def make_config(
|
||||
# belt-and-braces so no gold logic can ever fire on an EUR config.
|
||||
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_end_hour = 20
|
||||
config.backtest.blocked_hours_utc = [22, 23, 0, 1, 2, 3]
|
||||
|
||||
+16
-3
@@ -1,9 +1,19 @@
|
||||
"""XAU/USD signal producer config.
|
||||
|
||||
PlatformConfig defaults ARE the tuned XAU/USD configuration, so this
|
||||
factory only sets the timeframes. It must stay equivalent to the inline
|
||||
PlatformConfig(symbol=XAUUSD, ...) construction used by the backtester
|
||||
and Telegram bot (enforced by a test in test_all.py).
|
||||
factory sets the timeframes plus documented per-timeframe overrides.
|
||||
It must stay equivalent to the inline PlatformConfig(symbol=XAUUSD,
|
||||
...) construction (plus those overrides) used by the backtester and
|
||||
Telegram bot (enforced by a test in test_all.py).
|
||||
|
||||
M15 override (validated 2026-07-11, window 2025-03-30 -> 2026-05-31,
|
||||
$0.30 spread, trailing exits): tp_floor_rr 1.0 scores +116.6R/460
|
||||
trades PF 1.83 vs +105.4R/424 PF 1.82 at the 0.5 default — better in
|
||||
both train/val halves and on the 2024-06 -> 2025-03 out-of-sample
|
||||
window (+30.3R vs +10.6R; the 1R floor lifts targets past the min-TP
|
||||
spread gate, roughly doubling trades there). The same knob HURTS M5
|
||||
(pinned 12mo book: +131.3R/699 vs +139.9R/547, PF 1.55 vs 1.78), so it
|
||||
is per-timeframe, exactly as PENDING_STEPS' decision rule anticipated.
|
||||
"""
|
||||
|
||||
from typing import Optional, List
|
||||
@@ -26,4 +36,7 @@ def make_config(
|
||||
config.confirmation_timeframes = (
|
||||
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
|
||||
|
||||
@@ -1138,10 +1138,14 @@ class SignalAggregator:
|
||||
confidence += 5
|
||||
|
||||
# ── 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":
|
||||
report.warnings.append(f"EVENT RISK: {report.event_description}")
|
||||
confidence = min(confidence, 30)
|
||||
report.action_recommendation = f"WAIT — {report.event_description}"
|
||||
if self.config.news.event_risk_caps_signals:
|
||||
confidence = min(confidence, 30)
|
||||
report.action_recommendation = f"WAIT — {report.event_description}"
|
||||
|
||||
# ── Penalties (diminishing cap system) ────────────────────
|
||||
# Collect all applicable penalties, then apply with exponential
|
||||
@@ -1163,7 +1167,8 @@ class SignalAggregator:
|
||||
|
||||
if report.event_risk == "caution":
|
||||
report.warnings.append(f"Upcoming event: {report.event_description}")
|
||||
penalties.append(("event_caution", 15))
|
||||
if self.config.news.event_risk_caps_signals:
|
||||
penalties.append(("event_caution", 15))
|
||||
|
||||
is_counter_trend = (
|
||||
(report.market_regime == "trending_up" and score < 0) or
|
||||
|
||||
+139
-36
@@ -18,12 +18,26 @@ WebSocket: Streams live ticks from TwelveData for XAU/USD.
|
||||
immediate M15 scan and sends a Level Break Alert.
|
||||
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:
|
||||
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 functools
|
||||
import json
|
||||
import logging
|
||||
import logging.handlers
|
||||
@@ -79,6 +93,18 @@ logger = logging.getLogger("telegram_bot")
|
||||
TOKEN = os.environ.get("TELEGRAM_TOKEN", "8228251480:AAFW2OAyQ2idMicql2t_CIXk1r9-qfRTfNE")
|
||||
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 ───────────────────────────────────────────────────
|
||||
# The scanned pair list is config-driven: PlatformConfig.scan_pairs is
|
||||
# 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)"
|
||||
|
||||
|
||||
_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:
|
||||
sig = report.final_signal
|
||||
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"
|
||||
)
|
||||
|
||||
lines += ["", "━━━━━━━━━━━━━━━━━━━━",
|
||||
"_Educational / research use only._"]
|
||||
lines += ["", "━━━━━━━━━━━━━━━━━━━━", _DISCLAIMER]
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
@@ -342,6 +374,8 @@ def _format_scan_summary(reports: List[SignalReport], tf: Timeframe) -> str:
|
||||
else:
|
||||
lines.append("No actionable signals this cycle.")
|
||||
|
||||
lines += ["", _DISCLAIMER]
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@@ -477,6 +511,20 @@ _autoscan_chats: Set[int] = set()
|
||||
_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:
|
||||
"""Seconds until the next candle close + buffer."""
|
||||
now = datetime.now(timezone.utc)
|
||||
@@ -515,7 +563,7 @@ async def _scan_timeframe(
|
||||
if _passes_quality(r.signal_quality)
|
||||
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:
|
||||
detail = format_signal_message(report)
|
||||
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"
|
||||
)
|
||||
detail = header + format_signal_message(report)
|
||||
for chat_id in list(_autoscan_chats):
|
||||
for chat_id in _signal_targets():
|
||||
try:
|
||||
await bot.send_message(chat_id, detail, parse_mode=ParseMode.MARKDOWN)
|
||||
except Exception as exc:
|
||||
@@ -627,7 +675,7 @@ async def _ws_heartbeat(ws) -> None:
|
||||
|
||||
async def _ws_monitor(bot) -> None:
|
||||
backoff = 1
|
||||
while _autoscan_chats:
|
||||
while _feed_active():
|
||||
hb_task: Optional[asyncio.Task] = None
|
||||
try:
|
||||
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))
|
||||
|
||||
async for raw in ws:
|
||||
if not _autoscan_chats:
|
||||
if not _feed_active():
|
||||
break
|
||||
|
||||
try:
|
||||
@@ -699,11 +747,11 @@ async def _ws_monitor(bot) -> None:
|
||||
async def _candle_loop(bot, tf: Timeframe, interval_minutes: int) -> None:
|
||||
label = tf.value
|
||||
try:
|
||||
while _autoscan_chats:
|
||||
while _feed_active():
|
||||
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:
|
||||
if not _feed_active():
|
||||
break
|
||||
logger.info(f"{label} auto-scan: firing")
|
||||
await _scan_timeframe(
|
||||
@@ -758,6 +806,35 @@ def _stop_scheduler() -> None:
|
||||
|
||||
# ── 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:
|
||||
scanning = "running" if _autoscan_chats else "off"
|
||||
text = (
|
||||
@@ -788,10 +865,14 @@ _HELP_TEXT = (
|
||||
"*Auto-scan* fires at candle close: M5/M15/H1/H4.\n"
|
||||
"*Alerts* button sets the minimum quality grade that gets sent.\n"
|
||||
"*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:
|
||||
await update.message.reply_text(_HELP_TEXT, parse_mode=ParseMode.MARKDOWN)
|
||||
|
||||
@@ -806,6 +887,9 @@ def _status_text() -> str:
|
||||
else:
|
||||
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():
|
||||
lines.append("📡 WebSocket: *connected*")
|
||||
else:
|
||||
@@ -848,10 +932,12 @@ def _status_text() -> str:
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@_owner_only
|
||||
async def cmd_status(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
await update.message.reply_text(_status_text(), parse_mode=ParseMode.MARKDOWN)
|
||||
|
||||
|
||||
@_owner_only
|
||||
async def cmd_cache(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
dp = _get_shared_dp()
|
||||
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)
|
||||
|
||||
|
||||
@_owner_only
|
||||
async def cmd_clearcache(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
dp = _get_shared_dp()
|
||||
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:
|
||||
chat_id = update.effective_chat.id
|
||||
|
||||
if chat_id in _autoscan_chats:
|
||||
_autoscan_chats.discard(chat_id)
|
||||
if not _autoscan_chats:
|
||||
if not _feed_active():
|
||||
_stop_scheduler()
|
||||
await update.message.reply_text(
|
||||
"⏹ Auto-scan stopped.",
|
||||
parse_mode=ParseMode.MARKDOWN,
|
||||
)
|
||||
msg = "⏹ Auto-scan stopped."
|
||||
else:
|
||||
msg = ("⏹ Auto-scan stopped for this chat.\n"
|
||||
"📢 Subscriber channel feed continues.")
|
||||
await update.message.reply_text(msg, parse_mode=ParseMode.MARKDOWN)
|
||||
return
|
||||
|
||||
_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:
|
||||
await update.message.reply_text(
|
||||
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:
|
||||
query = update.callback_query
|
||||
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())
|
||||
|
||||
|
||||
@_owner_only
|
||||
async def cb_menu(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
"""Interactive menu actions: autoscan toggle, quality cycle, status, help."""
|
||||
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
|
||||
if chat_id in _autoscan_chats:
|
||||
_autoscan_chats.discard(chat_id)
|
||||
if not _autoscan_chats:
|
||||
if not _feed_active():
|
||||
_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:
|
||||
_autoscan_chats.add(chat_id)
|
||||
_start_scheduler(context.bot)
|
||||
@@ -1044,6 +1138,7 @@ async def _cb_scan_all(query, symbol: str = XAUUSD) -> None:
|
||||
lines.append(err)
|
||||
|
||||
lines.append("_Use buttons below for detailed single-TF view._")
|
||||
lines += ["", _DISCLAIMER]
|
||||
|
||||
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"),
|
||||
])
|
||||
|
||||
if not CHAT_ID:
|
||||
logger.info("TELEGRAM_CHAT_ID not set — auto-scan disabled on boot. "
|
||||
"Use /autoscan in chat to enable.")
|
||||
if CHAT_ID:
|
||||
_autoscan_chats.add(int(CHAT_ID))
|
||||
|
||||
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
|
||||
|
||||
chat_id = int(CHAT_ID)
|
||||
_autoscan_chats.add(chat_id)
|
||||
if CHANNEL_ID is not None:
|
||||
logger.info(f"Subscriber channel feed enabled: {CHANNEL_ID}")
|
||||
|
||||
logger.info("Running initial scan to populate level cache and signal stats...")
|
||||
try:
|
||||
@@ -1094,23 +1192,28 @@ async def _post_init(app: Application) -> None:
|
||||
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"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"
|
||||
)
|
||||
|
||||
try:
|
||||
await app.bot.send_message(
|
||||
chat_id,
|
||||
f"🤖 *ForexBot2 started*\n\n"
|
||||
f"Auto-scanning {_PAIRS_LABEL} on M5/M15/H1/H4.\n"
|
||||
f"📡 Live price monitoring active.\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",
|
||||
parse_mode=ParseMode.MARKDOWN,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to send boot message")
|
||||
# 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:
|
||||
await app.bot.send_message(
|
||||
OWNER_ID,
|
||||
f"🤖 *ForexBot2 started*\n\n"
|
||||
f"Auto-scanning {_PAIRS_LABEL} on M5/M15/H1/H4.\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"H1 {next_h1 / 60:.0f}m | H4 {next_h4 / 60:.0f}m\n\n"
|
||||
f"/autoscan to stop",
|
||||
parse_mode=ParseMode.MARKDOWN,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to send boot message")
|
||||
|
||||
|
||||
# ── Graceful shutdown ─────────────────────────────────────────────────
|
||||
|
||||
+41
@@ -1203,6 +1203,43 @@ def _():
|
||||
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")
|
||||
def _():
|
||||
config = PlatformConfig()
|
||||
@@ -1807,6 +1844,10 @@ def _():
|
||||
primary_timeframe=tf,
|
||||
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), \
|
||||
f"XAU factory config differs from inline construction for {tf}"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user