Spaces:
Sleeping
Sleeping
Upload 2 files
Browse files- app.py +0 -0
- news_feed.py +347 -0
app.py
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
news_feed.py
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""news_feed.py — RSS haber taraması + SVG grafikler (ek bağımlılık yok).
|
| 2 |
+
|
| 3 |
+
app.py'den: build_news_panel(classify, detect_assets, asset, timeframe)
|
| 4 |
+
classify(text) -> (label: "Bullish"|"Neutral"|"Bearish", score: -1..1)
|
| 5 |
+
"""
|
| 6 |
+
import re
|
| 7 |
+
import time
|
| 8 |
+
import html
|
| 9 |
+
import hashlib
|
| 10 |
+
import urllib.request
|
| 11 |
+
import xml.etree.ElementTree as ET
|
| 12 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 13 |
+
from datetime import datetime, timezone
|
| 14 |
+
from email.utils import parsedate_to_datetime
|
| 15 |
+
|
| 16 |
+
FEEDS = {
|
| 17 |
+
"CoinDesk": "https://www.coindesk.com/arc/outboundfeeds/rss/",
|
| 18 |
+
"Cointelegraph": "https://cointelegraph.com/rss",
|
| 19 |
+
"Decrypt": "https://decrypt.co/feed",
|
| 20 |
+
"Bitcoin Magazine": "https://bitcoinmagazine.com/.rss/full/",
|
| 21 |
+
"The Block": "https://www.theblock.co/rss.xml",
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
# regex -> ağırlık (önem puanı)
|
| 25 |
+
KEYWORDS = {
|
| 26 |
+
r"\betf": 3, r"\bsec\b": 3, r"\bhack": 3, r"\bexploit": 3, r"\bliquidat": 3,
|
| 27 |
+
r"\bbankrupt": 3, r"\bfed\b": 3, r"\brate (cut|hike)": 3, r"\bcpi\b": 2,
|
| 28 |
+
r"\bhalving": 3, r"\bcrash": 3, r"\bplunge": 3, r"\bsurge": 2, r"\brall(y|ies)": 2,
|
| 29 |
+
r"all-time high|\bath\b": 3, r"\blawsuit|\bsued?\b": 2, r"\bban(s|ned)?\b": 2,
|
| 30 |
+
r"\bapprov": 2, r"\bregulat": 2, r"\bwhale": 2, r"\binflow|\boutflow": 2,
|
| 31 |
+
r"\bblackrock": 2, r"\bdelist": 2, r"\bstablecoin": 1, r"\btether|\busdt": 1,
|
| 32 |
+
r"\bbinance": 1, r"\bbreakout|\bbreakdown": 2, r"\bsettle|\bfine[sd]?\b": 1,
|
| 33 |
+
}
|
| 34 |
+
_KW = [(re.compile(k), w) for k, w in KEYWORDS.items()]
|
| 35 |
+
|
| 36 |
+
TIMEFRAMES = {"1G": ("2d", "15m"), "7G": ("7d", "1h"), "1A": ("1mo", "1d"), "3A": ("3mo", "1d")}
|
| 37 |
+
COL = {"Bullish": "#22c55e", "Neutral": "#eab308", "Bearish": "#ef4444"}
|
| 38 |
+
|
| 39 |
+
_news_cache = {"t": 0.0, "items": [], "ok": 0}
|
| 40 |
+
_cls_cache = {}
|
| 41 |
+
NEWS_TTL = 300
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
# ------------------------------------------------------------------ FETCH
|
| 45 |
+
def _strip(s):
|
| 46 |
+
return html.unescape(re.sub(r"<[^>]+>", " ", s or "")).strip()
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _parse_feed(xml_bytes, source):
|
| 50 |
+
root = ET.fromstring(xml_bytes)
|
| 51 |
+
atom = "{http://www.w3.org/2005/Atom}"
|
| 52 |
+
out = []
|
| 53 |
+
for it in root.iter():
|
| 54 |
+
tag = it.tag.replace(atom, "")
|
| 55 |
+
if tag not in ("item", "entry"):
|
| 56 |
+
continue
|
| 57 |
+
|
| 58 |
+
def g(name):
|
| 59 |
+
el = it.find(name)
|
| 60 |
+
if el is None:
|
| 61 |
+
el = it.find(atom + name)
|
| 62 |
+
return el
|
| 63 |
+
|
| 64 |
+
title = g("title")
|
| 65 |
+
link = g("link")
|
| 66 |
+
date = g("pubDate") or g("published") or g("updated")
|
| 67 |
+
desc = g("description") or g("summary")
|
| 68 |
+
href = None
|
| 69 |
+
if link is not None:
|
| 70 |
+
href = link.get("href") or (link.text or "").strip()
|
| 71 |
+
ts = None
|
| 72 |
+
if date is not None and date.text:
|
| 73 |
+
try:
|
| 74 |
+
ts = parsedate_to_datetime(date.text)
|
| 75 |
+
except Exception:
|
| 76 |
+
try:
|
| 77 |
+
ts = datetime.fromisoformat(date.text.replace("Z", "+00:00"))
|
| 78 |
+
except Exception:
|
| 79 |
+
ts = None
|
| 80 |
+
if ts is not None and ts.tzinfo is None:
|
| 81 |
+
ts = ts.replace(tzinfo=timezone.utc)
|
| 82 |
+
if title is not None and title.text:
|
| 83 |
+
out.append({
|
| 84 |
+
"title": _strip(title.text), "link": href or "#", "source": source,
|
| 85 |
+
"ts": ts or datetime.now(timezone.utc),
|
| 86 |
+
"summary": _strip(desc.text if desc is not None else "")[:300],
|
| 87 |
+
})
|
| 88 |
+
return out
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def _fetch_one(item):
|
| 92 |
+
name, url = item
|
| 93 |
+
try:
|
| 94 |
+
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0 GoshawkVortex/1.0"})
|
| 95 |
+
with urllib.request.urlopen(req, timeout=8) as r:
|
| 96 |
+
return _parse_feed(r.read(), name)
|
| 97 |
+
except Exception:
|
| 98 |
+
return None
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def fetch_news(force=False):
|
| 102 |
+
if not force and time.time() - _news_cache["t"] < NEWS_TTL and _news_cache["items"]:
|
| 103 |
+
return _news_cache["items"], _news_cache["ok"]
|
| 104 |
+
with ThreadPoolExecutor(max_workers=len(FEEDS)) as ex:
|
| 105 |
+
results = list(ex.map(_fetch_one, FEEDS.items()))
|
| 106 |
+
items = [i for r in results if r for i in r]
|
| 107 |
+
ok = sum(1 for r in results if r)
|
| 108 |
+
if items:
|
| 109 |
+
_news_cache.update(t=time.time(), items=items, ok=ok)
|
| 110 |
+
return items or _news_cache["items"], ok
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
# ------------------------------------------------------------------ RANK
|
| 114 |
+
def _tokens(t):
|
| 115 |
+
return {w for w in re.findall(r"[a-z0-9]+", t.lower()) if len(w) > 3}
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def rank_news(items, limit=12, max_age_h=48):
|
| 119 |
+
now = datetime.now(timezone.utc)
|
| 120 |
+
items = [i for i in items if (now - i["ts"]).total_seconds() < max_age_h * 3600]
|
| 121 |
+
items.sort(key=lambda i: i["ts"], reverse=True)
|
| 122 |
+
clusters = []
|
| 123 |
+
for it in items:
|
| 124 |
+
tk = _tokens(it["title"])
|
| 125 |
+
for c in clusters:
|
| 126 |
+
if tk and len(tk & c["tk"]) / len(tk | c["tk"]) >= 0.5:
|
| 127 |
+
c["srcs"].add(it["source"])
|
| 128 |
+
c["n"] += 1
|
| 129 |
+
break
|
| 130 |
+
else:
|
| 131 |
+
clusters.append({"it": it, "tk": tk, "srcs": {it["source"]}, "n": 1})
|
| 132 |
+
ranked = []
|
| 133 |
+
for c in clusters:
|
| 134 |
+
it = c["it"]
|
| 135 |
+
text = (it["title"] + " " + it["summary"]).lower()
|
| 136 |
+
kw = sum(w for rx, w in _KW if rx.search(text))
|
| 137 |
+
age_h = (now - it["ts"]).total_seconds() / 3600
|
| 138 |
+
rec = 0.5 ** (age_h / 12)
|
| 139 |
+
imp = (kw + 1.5 * (len(c["srcs"]) - 1)) * (0.35 + 0.65 * rec)
|
| 140 |
+
if kw == 0 and len(c["srcs"]) == 1:
|
| 141 |
+
imp *= 0.3
|
| 142 |
+
ranked.append({**it, "srcs": sorted(c["srcs"]), "imp": min(100, imp * 10), "age_h": age_h})
|
| 143 |
+
ranked.sort(key=lambda r: r["imp"], reverse=True)
|
| 144 |
+
return ranked[:limit]
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def classify_cached(classify, item):
|
| 148 |
+
key = hashlib.sha1(item["title"].encode()).hexdigest()
|
| 149 |
+
if key not in _cls_cache:
|
| 150 |
+
if len(_cls_cache) > 500:
|
| 151 |
+
_cls_cache.clear()
|
| 152 |
+
try:
|
| 153 |
+
_cls_cache[key] = classify(item["title"] + ". " + item["summary"][:200])
|
| 154 |
+
except Exception:
|
| 155 |
+
_cls_cache[key] = ("Neutral", 0.0)
|
| 156 |
+
return _cls_cache[key]
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
# ------------------------------------------------------------------ PRICE
|
| 160 |
+
def price_series(asset, timeframe):
|
| 161 |
+
import yfinance as yf # lazy: sadece grafik için
|
| 162 |
+
period, interval = TIMEFRAMES.get(timeframe, TIMEFRAMES["1A"])
|
| 163 |
+
df = yf.Ticker(f"{asset}-USD").history(period=period, interval=interval)
|
| 164 |
+
rows = []
|
| 165 |
+
for ts, r in df.iterrows():
|
| 166 |
+
if r["Close"] == r["Close"]:
|
| 167 |
+
rows.append((ts.to_pydatetime(), float(r["Open"]), float(r["High"]),
|
| 168 |
+
float(r["Low"]), float(r["Close"]), float(r.get("Volume", 0) or 0)))
|
| 169 |
+
return rows
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def _fmt(v):
|
| 173 |
+
if v >= 1000:
|
| 174 |
+
return f"{v:,.0f}"
|
| 175 |
+
if v >= 1:
|
| 176 |
+
return f"{v:,.2f}"
|
| 177 |
+
return f"{v:.5f}"
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
# ------------------------------------------------------------------ SVG
|
| 181 |
+
def svg_candles(rows, asset, timeframe, marks=()):
|
| 182 |
+
"""Mum grafiği + hacim + SMA20 + son fiyat etiketi. marks: [(datetime, renk)] haber işaretleri."""
|
| 183 |
+
if len(rows) < 3:
|
| 184 |
+
return "<div class='nw-empty'>Fiyat verisi alınamadı.</div>"
|
| 185 |
+
W, H, L, R, T = 780, 380, 62, 14, 16
|
| 186 |
+
PB, VT, VB = 270, 286, 350
|
| 187 |
+
n = len(rows)
|
| 188 |
+
lo = min(r[3] for r in rows)
|
| 189 |
+
hi = max(r[2] for r in rows)
|
| 190 |
+
pad = (hi - lo) * 0.06 or hi * 0.01
|
| 191 |
+
lo, hi = lo - pad, hi + pad
|
| 192 |
+
vmax = max(r[5] for r in rows) or 1
|
| 193 |
+
cw = (W - L - R) / n
|
| 194 |
+
px = lambda i: L + cw * (i + 0.5)
|
| 195 |
+
py = lambda v: T + (PB - T) * (1 - (v - lo) / (hi - lo))
|
| 196 |
+
up = rows[-1][4] >= rows[0][1]
|
| 197 |
+
line = "#22c55e" if up else "#ef4444"
|
| 198 |
+
o = [f"<svg class='nw-svg' viewBox='0 0 {W} {H}' xmlns='http://www.w3.org/2000/svg'>",
|
| 199 |
+
"<defs><linearGradient id='nwa' x1='0' y1='0' x2='0' y2='1'>"
|
| 200 |
+
f"<stop offset='0' stop-color='{line}' stop-opacity='.28'/>"
|
| 201 |
+
f"<stop offset='1' stop-color='{line}' stop-opacity='0'/></linearGradient></defs>"]
|
| 202 |
+
for k in range(6):
|
| 203 |
+
v = lo + (hi - lo) * k / 5
|
| 204 |
+
y = py(v)
|
| 205 |
+
o.append(f"<line x1='{L}' x2='{W-R}' y1='{y:.1f}' y2='{y:.1f}' stroke='#94a3b8' stroke-opacity='.12'/>")
|
| 206 |
+
o.append(f"<text x='{L-8}' y='{y+4:.1f}' text-anchor='end' fill='#8295ad' font-size='11'>{_fmt(v)}</text>")
|
| 207 |
+
# alan dolgusu (kapanış)
|
| 208 |
+
pts = " ".join(f"{px(i):.1f},{py(r[4]):.1f}" for i, r in enumerate(rows))
|
| 209 |
+
o.append(f"<polygon points='{px(0):.1f},{PB} {pts} {px(n-1):.1f},{PB}' fill='url(#nwa)'/>")
|
| 210 |
+
# hacim + mumlar
|
| 211 |
+
for i, (ts, op, h, l, c, v) in enumerate(rows):
|
| 212 |
+
col = "#22c55e" if c >= op else "#ef4444"
|
| 213 |
+
x = px(i)
|
| 214 |
+
bw = max(1.2, cw * 0.62)
|
| 215 |
+
vh = (VB - VT) * (v / vmax)
|
| 216 |
+
o.append(f"<rect x='{x-bw/2:.1f}' y='{VB-vh:.1f}' width='{bw:.1f}' height='{vh:.1f}' fill='{col}' opacity='.35'/>")
|
| 217 |
+
y1, y2 = py(max(op, c)), py(min(op, c))
|
| 218 |
+
tip = f"{ts:%d %b %H:%M} O {_fmt(op)} H {_fmt(h)} L {_fmt(l)} C {_fmt(c)}"
|
| 219 |
+
o.append(f"<g><title>{tip}</title><line x1='{x:.1f}' x2='{x:.1f}' y1='{py(h):.1f}' y2='{py(l):.1f}' stroke='{col}' stroke-width='1.2'/>"
|
| 220 |
+
f"<rect x='{x-bw/2:.1f}' y='{y1:.1f}' width='{bw:.1f}' height='{max(1.2, y2-y1):.1f}' fill='{col}' rx='1'/></g>")
|
| 221 |
+
# SMA20
|
| 222 |
+
if n >= 20:
|
| 223 |
+
sma = [(i, sum(r[4] for r in rows[i-19:i+1]) / 20) for i in range(19, n)]
|
| 224 |
+
o.append("<polyline fill='none' stroke='#60a5fa' stroke-width='2' opacity='.9' points='"
|
| 225 |
+
+ " ".join(f"{px(i):.1f},{py(v):.1f}" for i, v in sma) + "'/>")
|
| 226 |
+
# haber işaretleri (zamana en yakın muma)
|
| 227 |
+
for mts, mcol, mtitle in marks:
|
| 228 |
+
try:
|
| 229 |
+
idx = min(range(n), key=lambda i: abs((rows[i][0].replace(tzinfo=None) - mts.replace(tzinfo=None)).total_seconds()))
|
| 230 |
+
if abs((rows[idx][0].replace(tzinfo=None) - mts.replace(tzinfo=None)).total_seconds()) > 3 * 86400:
|
| 231 |
+
continue
|
| 232 |
+
except Exception:
|
| 233 |
+
continue
|
| 234 |
+
x = px(idx)
|
| 235 |
+
o.append(f"<g><title>{html.escape(mtitle)}</title><path d='M{x-6:.1f},{PB+14} L{x+6:.1f},{PB+14} L{x:.1f},{PB+4} Z' fill='{mcol}'/></g>")
|
| 236 |
+
# son fiyat etiketi
|
| 237 |
+
last = rows[-1][4]
|
| 238 |
+
y = py(last)
|
| 239 |
+
o.append(f"<line x1='{L}' x2='{W-R}' y1='{y:.1f}' y2='{y:.1f}' stroke='{line}' stroke-dasharray='4 4' opacity='.8'/>")
|
| 240 |
+
o.append(f"<rect x='{W-R-70}' y='{y-10:.1f}' width='70' height='20' rx='5' fill='{line}'/>"
|
| 241 |
+
f"<text x='{W-R-35}' y='{y+4:.1f}' text-anchor='middle' fill='#fff' font-size='11' font-weight='700'>{_fmt(last)}</text>")
|
| 242 |
+
# x ekseni
|
| 243 |
+
fmt = "%H:%M" if timeframe == "1G" else "%d %b"
|
| 244 |
+
for k in range(5):
|
| 245 |
+
i = int((n - 1) * k / 4)
|
| 246 |
+
o.append(f"<text x='{px(i):.1f}' y='{H-8}' text-anchor='middle' fill='#8295ad' font-size='11'>{rows[i][0]:{fmt}}</text>")
|
| 247 |
+
o.append("</svg>")
|
| 248 |
+
chg = (last / rows[0][1] - 1) * 100
|
| 249 |
+
head = (f"<div class='nw-head'><b>{asset}/USD</b><span class='nw-price'>{_fmt(last)}</span>"
|
| 250 |
+
f"<span style='color:{line}'>{chg:+.2f}%</span><span class='nw-dim'>{timeframe} • mum + hacim + SMA20 • ▲ haber</span></div>")
|
| 251 |
+
return head + "".join(o)
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
def svg_donut(counts, net):
|
| 255 |
+
tot = sum(counts.values()) or 1
|
| 256 |
+
r, C = 52, 2 * 3.14159265 * 52
|
| 257 |
+
o = ["<svg class='nw-donut' viewBox='0 0 140 140' xmlns='http://www.w3.org/2000/svg'>",
|
| 258 |
+
"<circle cx='70' cy='70' r='52' fill='none' stroke='#172235' stroke-width='16'/>"]
|
| 259 |
+
off = 0
|
| 260 |
+
for k in ("Bullish", "Neutral", "Bearish"):
|
| 261 |
+
ln = C * counts[k] / tot
|
| 262 |
+
if ln > 0:
|
| 263 |
+
o.append(f"<circle cx='70' cy='70' r='52' fill='none' stroke='{COL[k]}' stroke-width='16' "
|
| 264 |
+
f"stroke-dasharray='{ln:.2f} {C-ln:.2f}' stroke-dashoffset='{-off:.2f}' transform='rotate(-90 70 70)'/>")
|
| 265 |
+
off += ln
|
| 266 |
+
c = "#22c55e" if net > 0.05 else "#ef4444" if net < -0.05 else "#eab308"
|
| 267 |
+
o.append(f"<text x='70' y='68' text-anchor='middle' fill='{c}' font-size='24' font-weight='800'>{net:+.2f}</text>"
|
| 268 |
+
"<text x='70' y='86' text-anchor='middle' fill='#8295ad' font-size='9' letter-spacing='1'>NET SKOR</text></svg>")
|
| 269 |
+
return "".join(o)
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
# ------------------------------------------------------------------ PANEL
|
| 273 |
+
NEWS_CSS = """
|
| 274 |
+
.nw-wrap { margin-top:12px; color:#dbeafe; }
|
| 275 |
+
.nw-card, .nw-news { padding:18px; border-radius:18px; margin-bottom:14px;
|
| 276 |
+
background:linear-gradient(180deg, rgba(8,18,34,.9), rgba(5,13,25,.85));
|
| 277 |
+
border:1px solid rgba(96,165,250,.18); box-shadow:0 14px 36px rgba(0,0,0,.25); }
|
| 278 |
+
.nw-head { display:flex; gap:14px; align-items:baseline; flex-wrap:wrap; margin-bottom:6px; }
|
| 279 |
+
.nw-price { font-size:26px; font-weight:800; }
|
| 280 |
+
.nw-dim { color:#64748b; font-size:11px; margin-left:auto; }
|
| 281 |
+
.nw-svg { width:100%; height:auto; display:block; }
|
| 282 |
+
.nw-grid { display:grid; grid-template-columns:160px 1fr; gap:18px; align-items:center; }
|
| 283 |
+
.nw-donut { width:150px; height:150px; }
|
| 284 |
+
.nw-legend span { display:inline-block; margin:3px 14px 3px 0; font-size:12px; }
|
| 285 |
+
.nw-dot { display:inline-block; width:9px; height:9px; border-radius:50%; margin-right:6px; }
|
| 286 |
+
.nw-news { border-left:4px solid var(--c); padding:14px 16px; margin-bottom:10px; }
|
| 287 |
+
.nw-news a { color:#e2e8f0; font-weight:600; text-decoration:none; }
|
| 288 |
+
.nw-news a:hover { color:#60a5fa; }
|
| 289 |
+
.nw-meta { color:#7d8ea6; font-size:11px; margin-top:6px; display:flex; gap:10px; flex-wrap:wrap; align-items:center; }
|
| 290 |
+
.nw-chip { padding:2px 9px; border-radius:99px; font-weight:700; font-size:11px; background:var(--c); color:#06101f; }
|
| 291 |
+
.nw-imp { width:70px; height:6px; border-radius:9px; background:#172235; overflow:hidden; }
|
| 292 |
+
.nw-imp i { display:block; height:100%; background:linear-gradient(90deg,#60a5fa,#f97316); }
|
| 293 |
+
.nw-empty { color:#8295ad; padding:18px; }
|
| 294 |
+
@media (max-width:640px){ .nw-grid { grid-template-columns:1fr; justify-items:center; } }
|
| 295 |
+
"""
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
def build_news_panel(classify, detect_assets, asset="BTC", timeframe="1A", limit=10):
|
| 299 |
+
items, ok = fetch_news()
|
| 300 |
+
if not items:
|
| 301 |
+
return ("<div class='nw-card nw-empty'>Haber kaynaklarına ulaşılamadı. "
|
| 302 |
+
"İnternet/ağ erişimini veya FEEDS adreslerini kontrol et.</div>")
|
| 303 |
+
if asset != "ALL":
|
| 304 |
+
items = [i for i in items if asset in detect_assets(i["title"] + " " + i["summary"])]
|
| 305 |
+
top = rank_news(items, limit=limit)
|
| 306 |
+
for t in top:
|
| 307 |
+
t["label"], t["score"] = classify_cached(classify, t)
|
| 308 |
+
|
| 309 |
+
counts = {"Bullish": 0, "Neutral": 0, "Bearish": 0}
|
| 310 |
+
wsum = nsum = 0.0
|
| 311 |
+
for t in top:
|
| 312 |
+
counts[t["label"] if t["label"] in counts else "Neutral"] += 1
|
| 313 |
+
w = 1 + t["imp"] / 25
|
| 314 |
+
wsum += w
|
| 315 |
+
nsum += w * t["score"]
|
| 316 |
+
net = nsum / wsum if wsum else 0.0
|
| 317 |
+
|
| 318 |
+
chart = ""
|
| 319 |
+
if asset != "ALL":
|
| 320 |
+
try:
|
| 321 |
+
rows = price_series(asset, timeframe)
|
| 322 |
+
marks = [(t["ts"], COL.get(t["label"], "#eab308"), t["title"]) for t in top]
|
| 323 |
+
chart = f"<div class='nw-card'>{svg_candles(rows, asset, timeframe, marks)}</div>"
|
| 324 |
+
except Exception as e:
|
| 325 |
+
chart = f"<div class='nw-card nw-empty'>Fiyat grafiği yüklenemedi: {html.escape(str(e))[:120]}</div>"
|
| 326 |
+
|
| 327 |
+
legend = "".join(f"<span><i class='nw-dot' style='background:{COL[k]}'></i>{k} {counts[k]}</span>" for k in COL)
|
| 328 |
+
summary = (f"<div class='nw-card nw-grid'>{svg_donut(counts, net)}<div>"
|
| 329 |
+
f"<b>Haber duygu özeti</b> <span class='nw-dim'>önem ağırlıklı • {len(top)} haber • {ok}/{len(FEEDS)} kaynak</span>"
|
| 330 |
+
f"<div class='nw-legend' style='margin-top:8px'>{legend}</div></div></div>")
|
| 331 |
+
|
| 332 |
+
cards = []
|
| 333 |
+
for t in top:
|
| 334 |
+
c = COL.get(t["label"], "#eab308")
|
| 335 |
+
age = f"{t['age_h']*60:.0f} dk" if t["age_h"] < 1 else f"{t['age_h']:.0f} sa"
|
| 336 |
+
srcs = ", ".join(t["srcs"])
|
| 337 |
+
cards.append(
|
| 338 |
+
f"<div class='nw-news' style='--c:{c}'><a href='{html.escape(t['link'])}' target='_blank' rel='noopener'>"
|
| 339 |
+
f"{html.escape(t['title'])}</a><div class='nw-meta'><span class='nw-chip'>{t['label']} {t['score']:+.1f}</span>"
|
| 340 |
+
f"<span>{html.escape(srcs)}</span><span>{age} önce</span>"
|
| 341 |
+
f"<span class='nw-imp' title='Önem {t['imp']:.0f}/100'><i style='width:{t['imp']:.0f}%'></i></span></div></div>")
|
| 342 |
+
if not cards:
|
| 343 |
+
cards = ["<div class='nw-card nw-empty'>Bu varlık için son 48 saatte önemli haber bulunamadı.</div>"]
|
| 344 |
+
|
| 345 |
+
stamp = datetime.now().strftime("%H:%M:%S")
|
| 346 |
+
return (f"<div class='nw-wrap'><div class='nw-dim' style='margin:0 0 8px'>Son tarama: {stamp} • "
|
| 347 |
+
f"önbellek {NEWS_TTL//60} dk</div>{chart}{summary}{''.join(cards)}</div>")
|