GoshawkVortexAI commited on
Commit
64ab688
·
verified ·
1 Parent(s): 95c815f

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +63 -735
app.py CHANGED
@@ -1,757 +1,85 @@
1
- """
2
- Bitcoin Market Signal Engine — Semi-Institutional Adaptive Quant Engine
3
- ========================================================================
4
- Hugging Face Spaces · Gradio UI (unchanged from v1)
5
-
6
- Architecture:
7
- Part 1 — Time Decay Weighting
8
- Part 2 — Source Quality Weighting
9
- Part 3 — Market Regime Detection
10
- Part 4 — Volatility-Adjusted Threshold
11
- Part 5 — Technical Confluence Filter
12
- Part 6 — On-Chain Bias
13
- Part 7 — Confidence & Dominance Metrics
14
- Part 8 — Sentiment Divergence Detection
15
- Part 9 — Online Learning / Weight Persistence
16
- Final — Signal Assembly & JSON Output
17
- """
18
-
19
- from __future__ import annotations
20
-
21
- import json
22
- import math
23
- import re
24
- import statistics
25
- import traceback
26
- from datetime import datetime, timezone
27
- from email.utils import parsedate_to_datetime
28
- from pathlib import Path
29
-
30
- import feedparser
31
  import gradio as gr
32
- import numpy as np
33
- import pandas as pd
34
- from transformers import pipeline
35
 
36
- # ═══════════════════════════════════════════════════════════════════════════════
37
- # Constants & Config
38
- # ═══════════════════════════════════════════════════════════════════════════════
39
 
40
- RSS_URL = (
41
- "https://news.google.com/rss/search"
42
- "?q=Bitcoin&hl=en-US&gl=US&ceid=US:en"
43
- )
44
-
45
- SOURCE_WEIGHTS: dict[str, float] = {
46
- "bloomberg": 1.30,
47
- "reuters": 1.25,
48
- "coindesk": 1.15,
49
- }
50
 
51
- WEIGHT_FILE = Path("model_weights.json")
52
- SIGNAL_HISTORY_FILE = Path("signal_history.json")
53
 
54
- # ═══════════════════════════════════════════════════════════════════════════════
55
- # Model (loaded once at startup)
56
- # ═══════════════════════════════════════════════════════════════════════════════
57
 
58
- print("Loading DistilBERT sentiment model...")
59
- sentiment_pipe = pipeline(
60
- "sentiment-analysis",
61
- model="distilbert-base-uncased-finetuned-sst-2-english",
62
- device=-1, # CPU; set to 0 if GPU available
63
- truncation=True,
64
- max_length=512,
65
  )
66
- print("Model ready.")
67
-
68
-
69
- # ═══════════════════════════════════════════════════════════════════════════════
70
- # Preprocessing
71
- # ═══════════════════════════════════════════════════════════════════════════════
72
-
73
- def preprocess(text: str) -> str:
74
- text = text.replace("%", "percent").replace("$", "USD")
75
- text = re.sub(r"[^a-zA-Z0-9 ]", " ", text)
76
- return re.sub(r"\s+", " ", text).strip().lower()
77
-
78
-
79
- # ═══════════════════════════════════════════════════════════════════════════════
80
- # Part 1 — Time Decay Weighting
81
- # ═══════════════════════════════════════════════════════════════════════════════
82
-
83
- def compute_time_weight(published: str | None) -> float | None:
84
- """
85
- weight = exp(-age_hours / 6).
86
- Returns None → ignore headline (unrecoverable parse failure).
87
- Returns 0.5 → published missing/empty.
88
- """
89
- if not published:
90
- return 0.5
91
-
92
- for parser in (
93
- parsedate_to_datetime,
94
- lambda s: datetime.fromisoformat(s),
95
- ):
96
- try:
97
- dt = parser(published)
98
- if dt.tzinfo is None:
99
- dt = dt.replace(tzinfo=timezone.utc)
100
- age_h = max((datetime.now(timezone.utc) - dt).total_seconds() / 3600, 0)
101
- return math.exp(-age_h / 6)
102
- except Exception:
103
- continue
104
-
105
- return None # both parsers failed → drop headline
106
-
107
-
108
- # ═══════════════════════════════════════════════════════════════════════════════
109
- # Part 2 — Source Quality Weighting
110
- # ═══════════════════════════════════════════════════════════════════════════════
111
-
112
- def compute_source_weight(source_name: str | None) -> float:
113
- if not source_name:
114
- return 1.0
115
- name_lower = source_name.lower()
116
- for key, w in SOURCE_WEIGHTS.items():
117
- if key in name_lower:
118
- return w
119
- return 1.0
120
-
121
-
122
- # ═══════════════════════════════════════════════════════════════════════════════
123
- # RSS Fetch + Classify
124
- # ═══════════════════════════════════════════════════════════════════════════════
125
-
126
- def fetch_headlines(max_items: int = 30) -> list[dict]:
127
- feed = feedparser.parse(RSS_URL)
128
- entries = feed.entries[:max_items]
129
- if not entries:
130
- raise ValueError("RSS feed returned no items.")
131
- results = []
132
- for e in entries:
133
- source = (
134
- getattr(getattr(e, "source", None), "title", None)
135
- or e.get("source", {}).get("title", "")
136
- )
137
- results.append({
138
- "raw": e.get("title", ""),
139
- "link": e.get("link", ""),
140
- "published": e.get("published", ""),
141
- "source": source,
142
- })
143
- return results
144
-
145
-
146
- def classify_headlines(headlines: list[dict]) -> list[dict]:
147
- """
148
- Run DistilBERT → apply Parts 1 & 2 → return scored list.
149
- Headlines with unrecoverable timestamps are dropped.
150
- """
151
- cleaned = [preprocess(h["raw"]) for h in headlines]
152
- raw_preds = sentiment_pipe(cleaned, batch_size=16)
153
-
154
- scored = []
155
- for h, pred in zip(headlines, raw_preds):
156
- label = pred["label"]
157
- confidence = float(pred["score"])
158
- numeric = confidence if label == "POSITIVE" else -confidence
159
-
160
- time_w = compute_time_weight(h.get("published"))
161
- if time_w is None:
162
- continue # drop
163
-
164
- source_w = compute_source_weight(h.get("source"))
165
- final_score = numeric * time_w * source_w
166
-
167
- scored.append({
168
- **h,
169
- "clean": preprocess(h["raw"]),
170
- "label": label,
171
- "confidence": round(confidence, 6),
172
- "numeric_score": round(numeric, 6),
173
- "time_weight": round(time_w, 6),
174
- "source_weight": source_w,
175
- "final_score": round(final_score, 6),
176
- })
177
- return scored
178
-
179
-
180
- # ═══════════════════════════════════════════════════════════════════════════════
181
- # Part 3 — Market Regime Detection
182
- # ═══════════════════════════════════════════════════════════════════════════════
183
-
184
- def _ema(arr: np.ndarray, period: int) -> np.ndarray:
185
- out = np.empty(len(arr))
186
- out[0] = arr[:period].mean() if len(arr) >= period else arr[0]
187
- k = 2 / (period + 1)
188
- for i in range(1, len(arr)):
189
- out[i] = arr[i] * k + out[i - 1] * (1 - k)
190
- return out
191
-
192
-
193
- def detect_regime(price_df: pd.DataFrame | None) -> dict:
194
- BASE = 0.05
195
- empty = {"regime": "UNKNOWN", "adx": None, "atr": None,
196
- "ema200_slope": None, "threshold": BASE}
197
-
198
- if price_df is None or len(price_df) < 50:
199
- return empty
200
-
201
- df = price_df.copy()
202
- df.columns = [c.lower() for c in df.columns]
203
- high, low, close = df["high"].values, df["low"].values, df["close"].values
204
-
205
- n = 14
206
- tr = np.maximum(high[1:] - low[1:],
207
- np.maximum(np.abs(high[1:] - close[:-1]),
208
- np.abs(low[1:] - close[:-1])))
209
- plus_dm = np.where((high[1:] - high[:-1]) > (low[:-1] - low[1:]),
210
- np.maximum(high[1:] - high[:-1], 0), 0.0)
211
- minus_dm = np.where((low[:-1] - low[1:]) > (high[1:] - high[:-1]),
212
- np.maximum(low[:-1] - low[1:], 0), 0.0)
213
-
214
- atr14 = _ema(tr, n)
215
- plus_di = 100 * _ema(plus_dm, n) / (atr14 + 1e-9)
216
- minus_di = 100 * _ema(minus_dm, n) / (atr14 + 1e-9)
217
- dx = 100 * np.abs(plus_di - minus_di) / (plus_di + minus_di + 1e-9)
218
- adx = float(_ema(dx, n)[-1])
219
- atr = float(atr14[-1])
220
-
221
- high_vol = atr >= float(np.percentile(atr14, 80))
222
-
223
- ema200_slope = 0.0
224
- if len(close) >= 200:
225
- e200 = _ema(close[-200:], 200)
226
- ema200_slope = float(e200[-1] - e200[-5])
227
-
228
- if adx > 25:
229
- regime, threshold = "TRENDING", BASE
230
- elif adx < 20:
231
- regime, threshold = "RANGING", 0.08
232
- else:
233
- regime, threshold = "NEUTRAL_REGIME", BASE
234
-
235
- if high_vol:
236
- regime = f"{regime}+HIGH_VOL"
237
- threshold += 0.03
238
-
239
- return {"regime": regime, "adx": round(adx, 4), "atr": round(atr, 4),
240
- "ema200_slope": round(ema200_slope, 6), "threshold": round(threshold, 4)}
241
-
242
-
243
- # ═══════════════════════════════════════════════════════════════════════════════
244
- # Part 4 — Volatility-Adjusted Threshold
245
- # ═══════════════════════════════════════════════════════════════════════════════
246
-
247
- def compute_dynamic_threshold(price_df: pd.DataFrame | None,
248
- regime_threshold: float) -> dict:
249
- if price_df is None or len(price_df) < 30:
250
- return {"realized_volatility": None,
251
- "dynamic_threshold": regime_threshold,
252
- "final_threshold": regime_threshold}
253
-
254
- close = price_df["close"].values[-30:]
255
- log_ret = np.diff(np.log(close + 1e-9))
256
- real_vol = float(np.std(log_ret) * math.sqrt(365))
257
- dynamic = max(0.05, min(0.15, 0.05 + real_vol * 0.2))
258
- final = max(regime_threshold, dynamic)
259
-
260
- return {"realized_volatility": round(real_vol, 6),
261
- "dynamic_threshold": round(dynamic, 4),
262
- "final_threshold": round(final, 4)}
263
-
264
-
265
- # ═══════════════════════════════════════════════════════════════════════════════
266
- # Part 5 — Technical Confluence Filter
267
- # ═══════════════════════════════════════════════════════════════════════════════
268
-
269
- def apply_technical_confluence(raw_signal: str, confidence: float,
270
- price_df: pd.DataFrame | None,
271
- funding_rate: float | None = None) -> dict:
272
- if price_df is None or len(price_df) < 50:
273
- return {"confirmed_signal": raw_signal, "confidence": round(confidence, 4),
274
- "rsi": None, "ema200": None,
275
- "confluence_notes": ["No price data — confluence skipped"]}
276
-
277
- df = price_df.copy()
278
- df.columns = [c.lower() for c in df.columns]
279
- close = df["close"].values
280
- volume = df["volume"].values if "volume" in df.columns else None
281
-
282
- # RSI(14)
283
- delta = np.diff(close)
284
- gain = np.where(delta > 0, delta, 0.0)
285
- loss = np.where(delta < 0, -delta, 0.0)
286
- avg_gain = float(np.convolve(gain, np.ones(14) / 14, "valid")[-1])
287
- avg_loss = float(np.convolve(loss, np.ones(14) / 14, "valid")[-1])
288
- rsi = 100 - (100 / (1 + avg_gain / (avg_loss + 1e-9)))
289
-
290
- # EMA200
291
- if len(close) >= 200:
292
- k = 2 / 201
293
- ema200 = close[-200]
294
- for p in close[-199:]:
295
- ema200 = p * k + ema200 * (1 - k)
296
- else:
297
- ema200 = float(np.mean(close))
298
-
299
- price_now = float(close[-1])
300
- notes = [f"RSI(14)={rsi:.2f}", f"EMA200={ema200:.2f}", f"Price={price_now:.2f}"]
301
- confirmed = raw_signal
302
-
303
- if raw_signal == "LONG" and not (rsi < 45 and price_now > ema200):
304
- confirmed = "NEUTRAL"
305
- notes.append("LONG suppressed: RSI/EMA200 not met")
306
- elif raw_signal == "SHORT" and not (rsi > 55 and price_now < ema200):
307
- confirmed = "NEUTRAL"
308
- notes.append("SHORT suppressed: RSI/EMA200 not met")
309
-
310
- if confirmed != "NEUTRAL":
311
- if volume is not None and len(volume) >= 21:
312
- avg_vol = float(np.mean(volume[-21:-1]))
313
- if volume[-1] > 1.5 * avg_vol:
314
- confidence += 0.15
315
- notes.append("+0.15: volume spike")
316
- if funding_rate is not None:
317
- if confirmed == "LONG" and funding_rate < -0.01:
318
- confidence += 0.10
319
- notes.append("+0.10: extreme negative funding (contrarian bullish)")
320
- elif confirmed == "SHORT" and funding_rate > 0.01:
321
- confidence += 0.10
322
- notes.append("+0.10: extreme positive funding (contrarian bearish)")
323
-
324
- return {"confirmed_signal": confirmed, "confidence": round(min(confidence, 1.0), 4),
325
- "rsi": round(rsi, 4), "ema200": round(ema200, 4), "confluence_notes": notes}
326
-
327
-
328
- # ═══════════════════════════���═══════════════════════════════════════════════════
329
- # Part 6 — On-Chain Bias
330
- # ═══════════════════════════════════════════════════════════════════════════════
331
-
332
- def compute_onchain_bias(onchain: dict | None) -> dict:
333
- if not onchain:
334
- return {"onchain_bias": "NEUTRAL", "onchain_notes": ["No on-chain data"]}
335
-
336
- inflow = onchain.get("exchange_inflow", 0)
337
- outflow = onchain.get("exchange_outflow", 0)
338
- avg_in = onchain.get("avg_inflow", 1)
339
- avg_out = onchain.get("avg_outflow", 1)
340
- bearish = bullish = 0
341
- notes: list[str] = []
342
 
343
- if avg_in > 0 and inflow > 2 * avg_in:
344
- bearish += 1
345
- notes.append("Inflow spike >2x avg → bearish")
346
- if avg_out > 0 and outflow > 2 * avg_out:
347
- bullish += 1
348
- notes.append("Outflow spike >2x avg → bullish")
349
 
350
- if bearish > 0 and bullish > 0:
351
- bias = "CONFLICT"
352
- notes.append("Conflict → NEUTRAL override")
353
- elif bearish > 0:
354
- bias = "BEARISH"
355
- elif bullish > 0:
356
- bias = "BULLISH"
357
- else:
358
- bias = "NEUTRAL"
359
 
360
- return {"onchain_bias": bias, "onchain_notes": notes}
 
 
361
 
 
362
 
363
- # ═══════════════════════════════════════════════════════════════════════════════
364
- # Part 7 — Confidence & Dominance Metrics
365
- # ═══════════════════════════════════════════════════════════════════════════════
 
 
 
366
 
367
- def compute_dominance_metrics(scored: list[dict], avg_score: float,
368
- threshold: float) -> dict:
369
- scores = [s["final_score"] for s in scored]
370
- n = len(scores)
371
- std_dev = statistics.stdev(scores) if n > 1 else 0.0
372
- bull_count = sum(1 for s in scores if s > 0)
373
- bear_count = sum(1 for s in scores if s < 0)
374
- bull_dom = bull_count / n * 100 if n else 0.0
375
- bear_dom = bear_count / n * 100 if n else 0.0
376
-
377
- labels = [s["label"] for s in scored]
378
- entropy = 0.0
379
- for lbl in set(labels):
380
- p = labels.count(lbl) / n
381
- if p > 0:
382
- entropy -= p * math.log2(p)
383
-
384
- if avg_score > 0.08 and bull_dom > 65:
385
- strength = "STRONG LONG"
386
- elif avg_score > threshold:
387
- strength = "WEAK LONG"
388
- elif avg_score < -0.08 and bear_dom > 65:
389
- strength = "STRONG SHORT"
390
- elif avg_score < -threshold:
391
- strength = "WEAK SHORT"
392
- else:
393
- strength = "NEUTRAL"
394
-
395
- risk = ("HIGH" if std_dev > 0.3 or entropy > 0.95
396
- else "MEDIUM" if std_dev > 0.15 else "LOW")
397
-
398
- return {"signal_strength": strength, "risk_level": risk,
399
- "bull_dominance_pct": round(bull_dom, 2),
400
- "bear_dominance_pct": round(bear_dom, 2),
401
- "score_std_dev": round(std_dev, 6),
402
- "entropy": round(entropy, 6)}
403
-
404
-
405
- # ═══════════════════════════════════════════════════════════════════════════════
406
- # Part 8 — Sentiment Divergence Detection
407
- # ═══════════════════════════════════════════════════════════════════════════════
408
-
409
- def detect_divergence(price_df: pd.DataFrame | None,
410
- sentiment_history: list[float]) -> dict:
411
- no_div = {"divergence_type": "NONE", "early_reversal_warning": False,
412
- "divergence_notes": []}
413
-
414
- if price_df is None or len(price_df) < 10:
415
- return {**no_div, "divergence_notes": ["Insufficient price data"]}
416
- if len(sentiment_history) < 2:
417
- return {**no_div, "divergence_notes": ["Need ≥2 sentiment history points"]}
418
-
419
- close = price_df["close"].values[-10:]
420
- price_trend = float(close[-1]) - float(close[0])
421
- sent_trend = sentiment_history[-1] - sentiment_history[0]
422
- notes = [f"Price Δ={price_trend:+.4f}", f"Sentiment Δ={sent_trend:+.4f}"]
423
-
424
- if price_trend < 0 and sent_trend > 0:
425
- return {"divergence_type": "BULLISH_DIVERGENCE", "early_reversal_warning": True,
426
- "divergence_notes": notes + ["Lower lows + improving sentiment → bullish reversal"]}
427
- if price_trend > 0 and sent_trend < 0:
428
- return {"divergence_type": "BEARISH_DIVERGENCE", "early_reversal_warning": True,
429
- "divergence_notes": notes + ["Higher highs + declining sentiment → bearish reversal"]}
430
-
431
- return {**no_div, "divergence_notes": notes}
432
-
433
-
434
- # ═══════════════════════════════════════════════════════════════════════════════
435
- # Part 9 — Online Learning / Weight Persistence
436
- # ═══════════════════════════════════════════════════════════════════════════════
437
-
438
- def load_model_weight() -> float:
439
- try:
440
- if WEIGHT_FILE.exists():
441
- return float(max(0.5, min(2.0,
442
- json.loads(WEIGHT_FILE.read_text()).get("model_weight", 1.0))))
443
- except Exception:
444
- pass
445
- return 1.0
446
-
447
-
448
- def save_model_weight(w: float) -> None:
449
- try:
450
- WEIGHT_FILE.write_text(json.dumps({"model_weight": round(w, 6)}))
451
- except Exception:
452
- pass
453
-
454
-
455
- def record_signal(direction: str, price: float | None) -> None:
456
- try:
457
- history: list[dict] = []
458
- if SIGNAL_HISTORY_FILE.exists():
459
- history = json.loads(SIGNAL_HISTORY_FILE.read_text())
460
- history.append({"timestamp": datetime.now(timezone.utc).isoformat(),
461
- "predicted_direction": direction,
462
- "price_at_signal": price,
463
- "evaluated": False})
464
- SIGNAL_HISTORY_FILE.write_text(json.dumps(history[-200:], indent=2))
465
- except Exception:
466
- pass
467
-
468
-
469
- def evaluate_past_signals(current_price: float | None) -> float:
470
- weight = load_model_weight()
471
- if current_price is None or not SIGNAL_HISTORY_FILE.exists():
472
- return weight
473
- try:
474
- history: list[dict] = json.loads(SIGNAL_HISTORY_FILE.read_text())
475
- now = datetime.now(timezone.utc)
476
- updated = False
477
- for rec in history:
478
- if rec.get("evaluated"):
479
- continue
480
- try:
481
- age_h = (now - datetime.fromisoformat(rec["timestamp"])).total_seconds() / 3600
482
- except Exception:
483
- continue
484
- if age_h < 6 or rec.get("price_at_signal") is None:
485
- continue
486
- direction = rec.get("predicted_direction", "NEUTRAL")
487
- delta = current_price - float(rec["price_at_signal"])
488
- correct = (direction == "LONG" and delta > 0) or (direction == "SHORT" and delta < 0)
489
- weight = max(0.5, min(2.0, weight * (1.01 if correct else 0.99)))
490
- rec["evaluated"] = True
491
- updated = True
492
- if updated:
493
- SIGNAL_HISTORY_FILE.write_text(json.dumps(history[-200:], indent=2))
494
- save_model_weight(weight)
495
- except Exception:
496
- pass
497
- return weight
498
-
499
-
500
- # ═══════════════════════════════════════════════════════════════════════════════
501
- # Weighted Average
502
- # ═══════════════════════════════════════════════════════════════════════════════
503
-
504
- def compute_weighted_average(scored: list[dict]) -> float:
505
- total_w = sum(abs(s["time_weight"] * s["source_weight"]) for s in scored)
506
- if total_w == 0:
507
- return 0.0
508
- return sum(s["final_score"] * abs(s["time_weight"] * s["source_weight"])
509
- for s in scored) / total_w
510
-
511
-
512
- # ═══════════════════════════════════════════════════════════════════════════════
513
- # Final Signal Assembly
514
- # ═══════════════════════════════════════════════════════════════════════════════
515
-
516
- def assemble_signal(scored: list[dict],
517
- price_df: pd.DataFrame | None = None,
518
- onchain: dict | None = None,
519
- funding_rate: float | None = None,
520
- sentiment_history: list[float] | None = None,
521
- current_price: float | None = None) -> dict:
522
-
523
- # Parts 3 & 4 — Regime + Threshold
524
- regime_info = detect_regime(price_df)
525
- vol_info = compute_dynamic_threshold(price_df, regime_info["threshold"])
526
- final_threshold = vol_info["final_threshold"]
527
-
528
- # Weighted score (Parts 1+2 already in final_score)
529
- avg_score = compute_weighted_average(scored)
530
-
531
- # Part 9 — Online learning
532
- model_weight = evaluate_past_signals(current_price)
533
- adjusted_score = avg_score * model_weight
534
-
535
- # Raw signal
536
- if adjusted_score > final_threshold:
537
- raw_signal = "LONG"
538
- elif adjusted_score < -final_threshold:
539
- raw_signal = "SHORT"
540
- else:
541
- raw_signal = "NEUTRAL"
542
-
543
- # Part 7 — Dominance
544
- dominance = compute_dominance_metrics(scored, adjusted_score, final_threshold)
545
- raw_conf = min(abs(adjusted_score) / (final_threshold + 1e-9), 1.0)
546
-
547
- # Part 5 — Confluence
548
- confluence = apply_technical_confluence(raw_signal, raw_conf, price_df, funding_rate)
549
- confirmed_signal = confluence["confirmed_signal"]
550
- confidence_score = confluence["confidence"]
551
-
552
- # Part 6 — On-chain
553
- onchain_result = compute_onchain_bias(onchain)
554
- onchain_bias = onchain_result["onchain_bias"]
555
- if onchain_bias == "CONFLICT":
556
- confirmed_signal = "NEUTRAL"
557
-
558
- # Part 8 — Divergence
559
- hist = sentiment_history or [avg_score]
560
- divergence = detect_divergence(price_df, hist)
561
-
562
- # Record for future evaluation
563
- price_now = (float(price_df["close"].values[-1])
564
- if price_df is not None and len(price_df) > 0 else current_price)
565
- record_signal(confirmed_signal, price_now)
566
-
567
- # Explanation
568
- explanation = " | ".join([
569
- f"Signal={confirmed_signal} (raw={raw_signal})",
570
- f"WeightedScore={adjusted_score:.4f} threshold=±{final_threshold}",
571
- f"Regime={regime_info['regime']} ADX={regime_info['adx']}",
572
- f"RealVol={vol_info['realized_volatility']} dynThresh={vol_info['dynamic_threshold']}",
573
- f"ModelWeight={model_weight:.4f} Strength={dominance['signal_strength']}",
574
- f"OnChain={onchain_bias}",
575
- f"Divergence={divergence['divergence_type']}",
576
- f"Risk={dominance['risk_level']} Confidence={confidence_score:.4f}",
577
- *(confluence.get("confluence_notes") or []),
578
- *(["⚠ " + divergence["divergence_type"]] if divergence["early_reversal_warning"] else []),
579
- ])
580
-
581
- return {
582
- # — Core spec-required fields —
583
- "timestamp": datetime.now(timezone.utc).isoformat(),
584
- "headline_count": len(scored),
585
- "regime": regime_info["regime"],
586
- "dynamic_threshold": final_threshold,
587
- "weighted_average_score": round(adjusted_score, 6),
588
- "final_signal": confirmed_signal,
589
- "signal_strength": dominance["signal_strength"],
590
- "confidence_score": confidence_score,
591
- "risk_level": dominance["risk_level"],
592
- "onchain_bias": onchain_bias,
593
- "divergence_type": divergence["divergence_type"],
594
- "early_reversal_warning": divergence["early_reversal_warning"],
595
- "explanation_string": explanation,
596
- # — Extended diagnostics —
597
- "model_weight": round(model_weight, 6),
598
- "realized_volatility": vol_info["realized_volatility"],
599
- "adx": regime_info["adx"],
600
- "atr": regime_info["atr"],
601
- "ema200_slope": regime_info["ema200_slope"],
602
- "bull_dominance_pct": dominance["bull_dominance_pct"],
603
- "bear_dominance_pct": dominance["bear_dominance_pct"],
604
- "score_std_dev": dominance["score_std_dev"],
605
- "entropy": dominance["entropy"],
606
- "onchain_notes": onchain_result["onchain_notes"],
607
- "divergence_notes": divergence["divergence_notes"],
608
- "confluence_notes": confluence.get("confluence_notes", []),
609
- "top_positive_headlines": [s["raw"] for s in
610
- sorted(scored, key=lambda x: x["final_score"], reverse=True)
611
- if s["final_score"] > 0][:5],
612
- "top_negative_headlines": [s["raw"] for s in
613
- sorted(scored, key=lambda x: x["final_score"])
614
- if s["final_score"] < 0][:5],
615
- }
616
-
617
-
618
- # ═══════════════════════════════════════════════════════════════════════════════
619
- # Gradio Handler (UI unchanged from v1)
620
- # ═══════════════════════════════════════════════════════════════════════════════
621
-
622
- def run_analysis(max_headlines: int, show_detail: bool) -> tuple:
623
- try:
624
- headlines = fetch_headlines(int(max_headlines))
625
- scored = classify_headlines(headlines)
626
-
627
- if not scored:
628
- return "", "", [], "No scoreable headlines after filtering."
629
-
630
- result = assemble_signal(
631
- scored,
632
- price_df=None, # inject real pd.DataFrame(OHLCV) here
633
- onchain=None, # inject on-chain dict here
634
- funding_rate=None, # inject float here
635
- sentiment_history=None,
636
- current_price=None,
637
- )
638
-
639
- signal_md = (
640
- f"## Signal: **{result['final_signal']}** ({result['signal_strength']})\n\n"
641
- f"- **Weighted avg score:** `{result['weighted_average_score']}`\n"
642
- f"- **Confidence:** `{result['confidence_score']}` "
643
- f"**Risk:** `{result['risk_level']}`\n"
644
- f"- **Regime:** `{result['regime']}` "
645
- f"**Threshold:** `{result['dynamic_threshold']}`\n"
646
- f"- **On-chain bias:** `{result['onchain_bias']}` "
647
- f"**Divergence:** `{result['divergence_type']}`\n"
648
- f"- **Model weight:** `{result['model_weight']}` "
649
- f"**Headlines:** {result['headline_count']}\n"
650
- f"- **Timestamp:** {result['timestamp']}\n\n"
651
- f"> {result['explanation_string']}"
652
- )
653
-
654
- core_keys = [
655
- "timestamp", "headline_count", "regime", "dynamic_threshold",
656
- "weighted_average_score", "final_signal", "signal_strength",
657
- "confidence_score", "risk_level", "onchain_bias",
658
- "divergence_type", "early_reversal_warning", "explanation_string",
659
- "top_positive_headlines", "top_negative_headlines",
660
- ]
661
- json_out = json.dumps({k: result[k] for k in core_keys}, indent=2)
662
-
663
- detail_rows = []
664
- if show_detail:
665
- detail_rows = [
666
- [
667
- i + 1,
668
- s["label"],
669
- f"{s['numeric_score']:+.4f}",
670
- f"{s['time_weight']:.4f}",
671
- f"{s['source_weight']:.2f}",
672
- f"{s['final_score']:+.4f}",
673
- s.get("source") or "—",
674
- s["raw"][:110],
675
- ]
676
- for i, s in enumerate(scored)
677
- ]
678
-
679
- return signal_md, json_out, detail_rows, ""
680
-
681
- except Exception as exc:
682
- return "", "", [], f"Error: {exc}\n{traceback.format_exc()}"
683
-
684
-
685
- # ═══════════════════════════════════════════════════════════════════════════════
686
- # Gradio UI (structure unchanged from v1)
687
- # ═══════════════════════════════════════════════════════════════════════════════
688
-
689
- with gr.Blocks(
690
- title="Bitcoin Market Signal Engine v2",
691
- theme=gr.themes.Monochrome(),
692
- css="""
693
- #signal-md { font-size: 1.15rem; }
694
- #json-box textarea { font-family: monospace; font-size: 0.82rem; }
695
- footer { display: none !important; }
696
- """,
697
- ) as demo:
698
-
699
- gr.Markdown(
700
- """
701
- # ₿ Bitcoin Market Signal Engine · v2 — Adaptive Quant Edition
702
- DistilBERT NLP · Time decay · Source quality · Regime detection ·
703
- Technical confluence · On-chain bias · Divergence · Online learning
704
- """
705
  )
706
 
707
- with gr.Row():
708
- max_h = gr.Slider(5, 30, value=20, step=1, label="Max headlines to fetch")
709
- show_d = gr.Checkbox(value=True, label="Show per-headline detail table")
710
- run_btn = gr.Button("▶ Run Analysis", variant="primary")
711
 
712
- signal_out = gr.Markdown(elem_id="signal-md", label="Market Signal")
 
 
713
 
714
- json_out = gr.Code(
715
- label="JSON Output (spec-compliant)",
716
- language="json",
717
- elem_id="json-box",
718
- )
719
-
720
- detail_tbl = gr.Dataframe(
721
- headers=["#", "Label", "NLP Score", "Time W",
722
- "Source W", "Final Score", "Source", "Headline"],
723
- label="Headline Sentiment Detail",
724
- wrap=True,
725
- )
726
 
727
- error_box = gr.Textbox(label="Errors", interactive=False)
 
 
 
728
 
729
- run_btn.click(
730
- fn=run_analysis,
731
- inputs=[max_h, show_d],
732
- outputs=[signal_out, json_out, detail_tbl, error_box],
733
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
734
 
735
- gr.Markdown(
736
- """
737
- ---
738
- **9-module pipeline:**
739
- RSS fetch → preprocess → DistilBERT →
740
- **[1]** time-decay weight →
741
- **[2]** source-quality weight →
742
- **[3]** regime detection (ADX / ATR / EMA200) →
743
- **[4]** volatility-adjusted threshold →
744
- **[5]** technical confluence (RSI / EMA200 / volume / funding) →
745
- **[6]** on-chain bias →
746
- **[7]** dominance & entropy →
747
- **[8]** sentiment divergence →
748
- **[9]** online learning →
749
- **final signal assembly**
750
 
751
- *Inject a real OHLCV `pd.DataFrame` and on-chain dict into `assemble_signal()`
752
- to activate Parts 3–8 fully.*
753
- """
754
- )
755
 
756
- if __name__ == "__main__":
757
- demo.launch()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import gradio as gr
2
+ import torch
3
+ from transformers import AutoTokenizer
4
+ from peft import AutoPeftModelForSequenceClassification
5
 
6
+ # =========================
7
+ # MODEL
8
+ # =========================
9
 
10
+ MODEL_PATH = "."
 
 
 
 
 
 
 
 
 
11
 
12
+ device = "cuda" if torch.cuda.is_available() else "cpu"
 
13
 
14
+ tokenizer = AutoTokenizer.from_pretrained(MODEL_PATH)
 
 
15
 
16
+ model = AutoPeftModelForSequenceClassification.from_pretrained(
17
+ MODEL_PATH,
18
+ torch_dtype=torch.float16 if device == "cuda" else torch.float32
 
 
 
 
19
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
 
21
+ model.to(device)
22
+ model.eval()
 
 
 
 
23
 
 
 
 
 
 
 
 
 
 
24
 
25
+ # =========================
26
+ # PREDICTION
27
+ # =========================
28
 
29
+ def predict(text):
30
 
31
+ if not text or not text.strip():
32
+ return {
33
+ "Bullish": 0.0,
34
+ "Neutral": 0.0,
35
+ "Bearish": 0.0
36
+ }
37
 
38
+ inputs = tokenizer(
39
+ text,
40
+ return_tensors="pt",
41
+ truncation=True,
42
+ max_length=512
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  )
44
 
45
+ inputs = {k: v.to(device) for k, v in inputs.items()}
 
 
 
46
 
47
+ with torch.no_grad():
48
+ outputs = model(**inputs)
49
+ probabilities = torch.softmax(outputs.logits, dim=-1)[0]
50
 
51
+ labels = ["Bearish", "Neutral", "Bullish"]
 
 
 
 
 
 
 
 
 
 
 
52
 
53
+ result = {
54
+ labels[i]: float(probabilities[i])
55
+ for i in range(len(labels))
56
+ }
57
 
58
+ return result
59
+
60
+
61
+ # =========================
62
+ # GRADIO UI
63
+ # =========================
64
+
65
+ demo = gr.Interface(
66
+ fn=predict,
67
+ inputs=gr.Textbox(
68
+ label="Crypto Text",
69
+ placeholder="Bitcoin ETF inflows increased sharply today...",
70
+ lines=5
71
+ ),
72
+ outputs=gr.Label(
73
+ label="Crypto Sentiment",
74
+ num_top_classes=3
75
+ ),
76
+ title="CryptoBERT Sentiment",
77
+ description="Bullish / Neutral / Bearish crypto sentiment analysis"
78
+ )
79
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
 
81
+ # =========================
82
+ # START
83
+ # =========================
 
84
 
85
+ demo.launch()