utkarshpathak48 commited on
Commit
9e26574
·
1 Parent(s): c4eac91

Strategy Fix Deploy A: confidence tiering, entry-momentum readout, dynamic TP, synthetic-instrument filter

Browse files

- F1a: Telegram alerts tier 'TRADE-GRADE' (conf>=9.0) vs 'WATCH ONLY' (8.8-9.0); alert threshold and dedup unchanged
- F2a/2b: card.entry_momentum (m5/m15 ROC%, aligned) - zero extra API calls; alert line shows 15m momentum + alignment
- F3a: card.levels.tp_dyn additive field (0.7R via TP_DYN_R env); alert line under TP3; TP1/2/3 unchanged
- F5b: exclude BingX synthetic/CFD instruments (oil, forex, metals, index proxies) from scan universe; /api/scan reports synthetics_excluded count

Source: analysis/signal_verdict.md (n=40, Jul 8-11) + automation-docs/STRATEGY-FIX-PLAN.md
All additive/display-only, zero change to scoring weights or trading logic.

Files changed (3) hide show
  1. main.py +37 -7
  2. scorer.py +46 -1
  3. telegram_alert.py +31 -0
main.py CHANGED
@@ -9,7 +9,7 @@ Endpoints:
9
  All computation is deterministic — no AI in the data path.
10
  """
11
  from __future__ import annotations
12
- import math, statistics, time, os
13
  from pathlib import Path
14
  from functools import lru_cache
15
  from datetime import datetime, timezone
@@ -37,6 +37,28 @@ logger = logging.getLogger("main")
37
 
38
  ALERT_INTERVAL = 300 # 5 minutes
39
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  async def _alert_loop():
41
  """Background task: scan every 5 min and fire Telegram alerts for high-confidence cards."""
42
  from telegram_alert import send_alert, prune_sent_cache, send_raw_message
@@ -56,7 +78,7 @@ async def _alert_loop():
56
  try:
57
  src = get_source()
58
  secondary = get_secondary()
59
- candidates = rank_universe(src, src.min_vol, 30)
60
  for c in candidates:
61
  try:
62
  card = score_symbol(src, c["symbol"], alert_account, 1.0,
@@ -143,20 +165,27 @@ def get_secondary():
143
  return _binance if _binance_ok else None
144
 
145
 
146
- def rank_universe(src, min_volume: float, top_n: int) -> list[dict]:
 
147
  perps = src.universe()
148
  rows = []
 
149
  for t in src.tickers():
150
  if t["symbol"] not in perps:
151
  continue
 
 
 
152
  qv = t["quote_volume_usd"]
153
  lo, hi = t.get("low", 0), t.get("high", 0)
154
  if qv < min_volume or lo <= 0:
155
  continue
156
  t["range_24h_pct"] = (hi - lo) / lo * 100
157
  rows.append(t)
 
 
158
  if len(rows) < 3:
159
- return [{"symbol": s} for s in src.anchors]
160
 
161
  log_vols = [math.log(r["quote_volume_usd"]) for r in rows]
162
  ranges = [r["range_24h_pct"] for r in rows]
@@ -174,7 +203,7 @@ def rank_universe(src, min_volume: float, top_n: int) -> list[dict]:
174
  if extra:
175
  extra["anchor"] = True
176
  picked.append(extra)
177
- return picked
178
 
179
 
180
  # ── API routes ─────────────────────────────────────────────────────────────
@@ -196,7 +225,7 @@ def scan(top: int = 8, min_volume: float = None, account: float = 100000,
196
  try:
197
  src = get_source()
198
  floor = min_volume or src.min_vol
199
- candidates = rank_universe(src, floor, top)
200
  secondary = get_secondary()
201
  cards = []
202
  for c in candidates:
@@ -229,7 +258,8 @@ def scan(top: int = 8, min_volume: float = None, account: float = 100000,
229
  cards.append({"symbol": c["symbol"], "error": str(e)[:120]})
230
  cards.sort(key=lambda c: c.get("confidence", 0), reverse=True)
231
  return {"fetched_at": datetime.now(timezone.utc).isoformat(),
232
- "source": src.name, "market": src.market_note, "cards": cards}
 
233
  except Exception as e:
234
  raise HTTPException(503, str(e))
235
 
 
9
  All computation is deterministic — no AI in the data path.
10
  """
11
  from __future__ import annotations
12
+ import math, statistics, time, os, re
13
  from pathlib import Path
14
  from functools import lru_cache
15
  from datetime import datetime, timezone
 
37
 
38
  ALERT_INTERVAL = 300 # 5 minutes
39
 
40
+ # ── Synthetic-instrument hygiene (Strategy Fix Plan F5b) ───────────────────
41
+ # BingX lists non-crypto CFD proxies (oil, forex, metals, index futures) on the
42
+ # same USDT-M perpetual endpoint as real crypto. 10/50 alerts in the Jul 8-11
43
+ # batch were these — unanalyzable (no independent data source) and off-mission.
44
+ # Patterns below are derived from the 7 confirmed synthetic symbols found in
45
+ # analysis/replay_results.csv (NO_DATA rows), not guessed:
46
+ # CO1OILBRENT2USD-USDT, NCCO1OILWTI2USD-USDT, NCCOGOLD2USD-USDT,
47
+ # NCCOXAG2USD-USDT, NCFXGBP2JPY-USDT, NCSISP5002USD-USDT, SINASDAQ1002USD-USDT
48
+ # The 3rd pattern (digit + 3-letter fake-FX code immediately before "-USDT")
49
+ # alone catches all 7 observed cases and is low-false-positive: real crypto
50
+ # tickers (BTC-USDT, 1000PEPE-USDT, etc.) never end in <digit><3 letters>-USDT.
51
+ EXCLUDE_SYNTHETICS = os.environ.get("EXCLUDE_SYNTHETICS", "true").lower() == "true"
52
+ _SYNTHETIC_PATTERNS = [
53
+ re.compile(r"^(CO1|NCFX|NCS|NCCO|SIN)", re.IGNORECASE), # known prefixes
54
+ re.compile(r"(XAU|XAG|NGAS|NATGAS|BRENT|WTI|GOLD|SILVER|NASDAQ|SP500)", re.IGNORECASE), # commodity/index keywords
55
+ re.compile(r"\d[A-Z]{3}-USDT$"), # embedded fake-FX quote suffix
56
+ ]
57
+
58
+
59
+ def is_synthetic(symbol: str) -> bool:
60
+ return any(p.search(symbol) for p in _SYNTHETIC_PATTERNS)
61
+
62
  async def _alert_loop():
63
  """Background task: scan every 5 min and fire Telegram alerts for high-confidence cards."""
64
  from telegram_alert import send_alert, prune_sent_cache, send_raw_message
 
78
  try:
79
  src = get_source()
80
  secondary = get_secondary()
81
+ candidates, _synth_excluded = rank_universe(src, src.min_vol, 30)
82
  for c in candidates:
83
  try:
84
  card = score_symbol(src, c["symbol"], alert_account, 1.0,
 
165
  return _binance if _binance_ok else None
166
 
167
 
168
+ def rank_universe(src, min_volume: float, top_n: int) -> tuple[list[dict], int]:
169
+ """Returns (picked_candidates, synthetics_excluded_count)."""
170
  perps = src.universe()
171
  rows = []
172
+ synth_excluded = 0
173
  for t in src.tickers():
174
  if t["symbol"] not in perps:
175
  continue
176
+ if EXCLUDE_SYNTHETICS and is_synthetic(t["symbol"]):
177
+ synth_excluded += 1
178
+ continue
179
  qv = t["quote_volume_usd"]
180
  lo, hi = t.get("low", 0), t.get("high", 0)
181
  if qv < min_volume or lo <= 0:
182
  continue
183
  t["range_24h_pct"] = (hi - lo) / lo * 100
184
  rows.append(t)
185
+ if synth_excluded:
186
+ logger.info("rank_universe: %d synthetic instruments excluded", synth_excluded)
187
  if len(rows) < 3:
188
+ return [{"symbol": s} for s in src.anchors], synth_excluded
189
 
190
  log_vols = [math.log(r["quote_volume_usd"]) for r in rows]
191
  ranges = [r["range_24h_pct"] for r in rows]
 
203
  if extra:
204
  extra["anchor"] = True
205
  picked.append(extra)
206
+ return picked, synth_excluded
207
 
208
 
209
  # ── API routes ─────────────────────────────────────────────────────────────
 
225
  try:
226
  src = get_source()
227
  floor = min_volume or src.min_vol
228
+ candidates, synth_excluded = rank_universe(src, floor, top)
229
  secondary = get_secondary()
230
  cards = []
231
  for c in candidates:
 
258
  cards.append({"symbol": c["symbol"], "error": str(e)[:120]})
259
  cards.sort(key=lambda c: c.get("confidence", 0), reverse=True)
260
  return {"fetched_at": datetime.now(timezone.utc).isoformat(),
261
+ "source": src.name, "market": src.market_note, "cards": cards,
262
+ "synthetics_excluded": synth_excluded}
263
  except Exception as e:
264
  raise HTTPException(503, str(e))
265
 
scorer.py CHANGED
@@ -14,13 +14,35 @@ PA is loaded lazily — if patterns.py / chart_patterns.py / order_blocks.py /
14
  confluence.py are missing, falls back to 40/20/20/20 (old formula).
15
  """
16
  from __future__ import annotations
17
- import math, statistics
18
  import pandas as pd
19
  from indicators import analyze_timeframe
20
  from exchange import pick_source, INTERVAL_MS
21
  from catalyst import score_catalyst as _catalyst_score
22
  import time
23
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  # Markov + HMM — imported lazily to avoid startup errors if not yet installed
25
  try:
26
  from markov import analyze_symbol as _markov_analyze
@@ -691,6 +713,19 @@ def score_symbol(src, symbol: str,
691
  else:
692
  direction = "long" if m15.get("rsi14", 50) < 50 else "short"
693
 
 
 
 
 
 
 
 
 
 
 
 
 
 
694
  s_pos, n_pos = _score_positioning(funding_primary, oi_primary,
695
  funding_secondary, oi_secondary,
696
  taker_ratio=taker_ratio,
@@ -717,6 +752,15 @@ def score_symbol(src, symbol: str,
717
  close = m15.get("close", 0)
718
  atr_pct = m15.get("atr_pct", 0)
719
  levels = _stop_target(tf_data, direction) if close > 0 else {}
 
 
 
 
 
 
 
 
 
720
  sizing = _size_suggestion(account, risk_pct, close, levels.get("sl", close), user_leverage)
721
 
722
  # ── New: probability, leverage, duration ─────────────────────────────
@@ -888,6 +932,7 @@ def score_symbol(src, symbol: str,
888
  "close": close,
889
  "levels": levels,
890
  "sizing": sizing,
 
891
  # ── Probability & EV ──
892
  "probability": {
893
  "p_win": p_win,
 
14
  confluence.py are missing, falls back to 40/20/20/20 (old formula).
15
  """
16
  from __future__ import annotations
17
+ import math, statistics, os
18
  import pandas as pd
19
  from indicators import analyze_timeframe
20
  from exchange import pick_source, INTERVAL_MS
21
  from catalyst import score_catalyst as _catalyst_score
22
  import time
23
 
24
+ # ── Strategy Fix Plan config (F2a, F3a) — additive, no scoring-engine change ──
25
+ # TP_DYN_R: dynamic-TP distance in R multiples, evidence zone 0.5-0.8R (default 0.7).
26
+ TP_DYN_R = float(os.environ.get("TP_DYN_R", "0.7"))
27
+
28
+
29
+ def _roc_pct(df, n: int) -> float | None:
30
+ """% change from the closed candle n bars back to the latest closed candle.
31
+
32
+ Used for entry-momentum readout (Strategy Fix Plan F2a) — no extra API calls,
33
+ reuses the already-fetched 5m/15m raw OHLCV DataFrames.
34
+ """
35
+ if df is None or len(df) < n + 1:
36
+ return None
37
+ try:
38
+ prev = float(df["close"].iloc[-1 - n])
39
+ cur = float(df["close"].iloc[-1])
40
+ if prev == 0:
41
+ return None
42
+ return round((cur - prev) / prev * 100, 4)
43
+ except Exception:
44
+ return None
45
+
46
  # Markov + HMM — imported lazily to avoid startup errors if not yet installed
47
  try:
48
  from markov import analyze_symbol as _markov_analyze
 
713
  else:
714
  direction = "long" if m15.get("rsi14", 50) < 50 else "short"
715
 
716
+ # ── Entry momentum (Strategy Fix Plan F2a) — data only, no scoring change ──
717
+ # Reuses the 5m/15m raw DataFrames already fetched above — zero extra API calls.
718
+ m5_roc_pct = _roc_pct(_raw_dfs.get("5m"), 3) # ~last 15 min on 5m tf
719
+ m15_roc_pct = _roc_pct(_raw_dfs.get("15m"), 1) # last closed 15m candle
720
+ entry_momentum = {
721
+ "m5_roc_pct": m5_roc_pct,
722
+ "m15_roc_pct": m15_roc_pct,
723
+ "aligned": bool(
724
+ (direction == "long" and m5_roc_pct is not None and m5_roc_pct > 0) or
725
+ (direction == "short" and m5_roc_pct is not None and m5_roc_pct < 0)
726
+ ),
727
+ }
728
+
729
  s_pos, n_pos = _score_positioning(funding_primary, oi_primary,
730
  funding_secondary, oi_secondary,
731
  taker_ratio=taker_ratio,
 
752
  close = m15.get("close", 0)
753
  atr_pct = m15.get("atr_pct", 0)
754
  levels = _stop_target(tf_data, direction) if close > 0 else {}
755
+
756
+ # ── Dynamic TP (Strategy Fix Plan F3a) — additive field only; TP1/2/3 unchanged ──
757
+ if levels:
758
+ sl_dist = abs(close - levels.get("sl", close))
759
+ levels["tp_dyn"] = round(
760
+ close + TP_DYN_R * sl_dist if direction == "long" else close - TP_DYN_R * sl_dist,
761
+ 6,
762
+ )
763
+
764
  sizing = _size_suggestion(account, risk_pct, close, levels.get("sl", close), user_leverage)
765
 
766
  # ── New: probability, leverage, duration ─────────────────────────────
 
932
  "close": close,
933
  "levels": levels,
934
  "sizing": sizing,
935
+ "entry_momentum": entry_momentum,
936
  # ── Probability & EV ──
937
  "probability": {
938
  "p_win": p_win,
telegram_alert.py CHANGED
@@ -103,6 +103,31 @@ def _rr(card: dict) -> str:
103
 
104
  # ── Message builder ───────────────────────────────────────────────────────────
105
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
106
  def _order_flow_line(card: dict) -> str:
107
  """Format the taker buy/sell ratio line for Telegram. Returns '' if unavailable."""
108
  taker_ratio = (
@@ -158,8 +183,12 @@ def build_message(card: dict, tv_sym: str) -> str:
158
  else f"{state_emoji} HMM <b>{state}</b>"
159
  )
160
  flow_line = _order_flow_line(card)
 
 
 
161
 
162
  lines = [
 
163
  f"{dir_emoji} <b>{sym} {direction} · {conf:.1f}/10</b>",
164
  "",
165
  f"📍 <b>Entry</b> <code>{_fmt_price(card.get('close'))}</code>",
@@ -167,6 +196,8 @@ def build_message(card: dict, tv_sym: str) -> str:
167
  f"🎯 <b>TP1</b> <code>{_fmt_price(lvl.get('tp1'))}</code> R/R {_rr(card)}",
168
  f" <b>TP2</b> <code>{_fmt_price(lvl.get('tp2'))}</code>",
169
  f" <b>TP3</b> <code>{_fmt_price(lvl.get('tp3'))}</code>",
 
 
170
  "",
171
  f"⚙️ Leverage <b>{lev.get('user_leverage', '—')}x</b> Margin <b>{_money(lev.get('margin_to_post'))}</b>",
172
  f"💸 Loss@SL <b>{_money(lev.get('loss_at_stop'))}</b> Risk <b>{_money(sz.get('risk_usd'))}</b>",
 
103
 
104
  # ── Message builder ───────────────────────────────────────────────────────────
105
 
106
+ def _tier_line(conf: float) -> str:
107
+ """Confidence tier prefix (Strategy Fix Plan F1a).
108
+
109
+ Alert threshold stays 8.8 (data collection continues); this only labels
110
+ the message so Utkarsh can tell trade-grade from watch-only at a glance.
111
+ Auto-trade gate (CONF_MIN=9.0, per ADDENDUM) is a separate, later concern.
112
+ """
113
+ if conf >= 9.0:
114
+ return "💎 <b>TRADE-GRADE</b>"
115
+ if conf >= 8.8:
116
+ return "🔭 <b>WATCH ONLY</b> — below auto-trade bar"
117
+ return ""
118
+
119
+
120
+ def _momentum_line(card: dict) -> str:
121
+ """Entry-momentum line (Strategy Fix Plan F2b). Empty if data unavailable."""
122
+ mom = card.get("entry_momentum") or {}
123
+ m15v = mom.get("m15_roc_pct")
124
+ if m15v is None:
125
+ return ""
126
+ mark = "✓ aligned" if mom.get("aligned") else "⚠ AGAINST direction"
127
+ sign = "+" if m15v >= 0 else ""
128
+ return f"⚡ Momentum 15m: {sign}{m15v:.2f}% ({mark})"
129
+
130
+
131
  def _order_flow_line(card: dict) -> str:
132
  """Format the taker buy/sell ratio line for Telegram. Returns '' if unavailable."""
133
  taker_ratio = (
 
183
  else f"{state_emoji} HMM <b>{state}</b>"
184
  )
185
  flow_line = _order_flow_line(card)
186
+ tier_line = _tier_line(float(conf))
187
+ momentum_line = _momentum_line(card)
188
+ tp_dyn = lvl.get("tp_dyn")
189
 
190
  lines = [
191
+ *([tier_line] if tier_line else []),
192
  f"{dir_emoji} <b>{sym} {direction} · {conf:.1f}/10</b>",
193
  "",
194
  f"📍 <b>Entry</b> <code>{_fmt_price(card.get('close'))}</code>",
 
196
  f"🎯 <b>TP1</b> <code>{_fmt_price(lvl.get('tp1'))}</code> R/R {_rr(card)}",
197
  f" <b>TP2</b> <code>{_fmt_price(lvl.get('tp2'))}</code>",
198
  f" <b>TP3</b> <code>{_fmt_price(lvl.get('tp3'))}</code>",
199
+ *([f" <b>TP-dyn</b> <code>{_fmt_price(tp_dyn)}</code> (0.7R)"] if tp_dyn is not None else []),
200
+ *([momentum_line] if momentum_line else []),
201
  "",
202
  f"⚙️ Leverage <b>{lev.get('user_leverage', '—')}x</b> Margin <b>{_money(lev.get('margin_to_post'))}</b>",
203
  f"💸 Loss@SL <b>{_money(lev.get('loss_at_stop'))}</b> Risk <b>{_money(sz.get('risk_usd'))}</b>",