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 |
|
| `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
|
||||||
|
|
||||||
|
|||||||
@@ -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"])
|
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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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}"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user