GoshawkVortexAI commited on
Commit
63d07bc
·
verified ·
1 Parent(s): b898797

Upload 2 files

Browse files
Files changed (2) hide show
  1. app.py +0 -0
  2. news_feed.py +730 -0
app.py ADDED
The diff for this file is too large to render. See raw diff
 
news_feed.py ADDED
@@ -0,0 +1,730 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 sqlite3
11
+ import urllib.request
12
+ from urllib.parse import quote
13
+ import xml.etree.ElementTree as ET
14
+ from concurrent.futures import ThreadPoolExecutor
15
+ from datetime import datetime, timezone
16
+ from email.utils import parsedate_to_datetime
17
+
18
+ FEEDS = {
19
+ "CoinDesk": "https://www.coindesk.com/arc/outboundfeeds/rss/",
20
+ "Cointelegraph": "https://cointelegraph.com/rss",
21
+ "Decrypt": "https://decrypt.co/feed",
22
+ "Bitcoin Magazine": "https://bitcoinmagazine.com/.rss/full/",
23
+ "The Block": "https://www.theblock.co/rss.xml",
24
+ "CryptoSlate": "https://cryptoslate.com/feed/",
25
+ "CryptoPotato": "https://cryptopotato.com/feed/",
26
+ "NewsBTC": "https://www.newsbtc.com/feed/",
27
+ "Bitcoinist": "https://bitcoinist.com/feed/",
28
+ "U.Today": "https://u.today/rss",
29
+ "BeInCrypto": "https://beincrypto.com/feed/",
30
+ "CoinGape": "https://coingape.com/feed/",
31
+ "AMBCrypto": "https://ambcrypto.com/feed/",
32
+ "Crypto.News": "https://crypto.news/feed/",
33
+ "The Daily Hodl": "https://dailyhodl.com/feed/",
34
+ "Blockworks": "https://blockworks.co/feed",
35
+ "CryptoNews": "https://cryptonews.com/news/feed/",
36
+ "Bitcoin.com": "https://news.bitcoin.com/feed/",
37
+ "Cointelegraph Markets": "https://cointelegraph.com/rss/tag/market-analysis",
38
+ }
39
+
40
+ # Varlıktan bağımsız genel Google News aramaları (makro, regülasyon, ETF...)
41
+ GENERAL_QUERIES = [
42
+ "crypto market", "bitcoin ETF", "crypto regulation SEC", "crypto hack exploit",
43
+ "stablecoin", "crypto whale", "Federal Reserve bitcoin", "altcoin rally",
44
+ ]
45
+ MAX_CLASSIFY = 24 # tek taramada LLM ile etiketlenecek en fazla haber (kalanlar sonraki taramada)
46
+ DEFAULT_LIMIT = {"Günlük": 30, "Aylık": 50}
47
+
48
+ # regex -> ağırlık (önem puanı)
49
+ KEYWORDS = {
50
+ r"\betf": 3, r"\bsec\b": 3, r"\bhack": 3, r"\bexploit": 3, r"\bliquidat": 3,
51
+ r"\bbankrupt": 3, r"\bfed\b": 3, r"\brate (cut|hike)": 3, r"\bcpi\b": 2,
52
+ r"\bhalving": 3, r"\bcrash": 3, r"\bplunge": 3, r"\bsurge": 2, r"\brall(y|ies)": 2,
53
+ r"all-time high|\bath\b": 3, r"\blawsuit|\bsued?\b": 2, r"\bban(s|ned)?\b": 2,
54
+ r"\bapprov": 2, r"\bregulat": 2, r"\bwhale": 2, r"\binflow|\boutflow": 2,
55
+ r"\bblackrock": 2, r"\bdelist": 2, r"\bstablecoin": 1, r"\btether|\busdt": 1,
56
+ r"\bbinance": 1, r"\bbreakout|\bbreakdown": 2, r"\bsettle|\bfine[sd]?\b": 1,
57
+ }
58
+ _KW = [(re.compile(k), w) for k, w in KEYWORDS.items()]
59
+
60
+ ASSET_QUERY = {
61
+ "BTC": "bitcoin OR BTC", "ETH": "ethereum OR ETH", "SOL": "solana", "BNB": "binance coin OR BNB",
62
+ "XRP": "ripple OR XRP", "ADA": "cardano", "DOGE": "dogecoin", "AVAX": "avalanche AVAX",
63
+ "LINK": "chainlink", "DOT": "polkadot",
64
+ }
65
+ # dönem -> (saat, google 'when', recency yarı ömrü saat)
66
+ PERIODS = {"Günlük": (24, "1d", 12), "Aylık": (720, "30d", 96)}
67
+ NEWS_DB = "news.db"
68
+ LINES = ["#f7931a", "#60a5fa", "#a78bfa", "#eab308", "#22c55e"]
69
+
70
+ TIMEFRAMES = {"1G": ("2d", "15m"), "7G": ("7d", "1h"), "1A": ("1mo", "1d"), "3A": ("3mo", "1d")}
71
+ COL = {"Bullish": "#22c55e", "Neutral": "#eab308", "Bearish": "#ef4444"}
72
+
73
+ _news_cache = {}
74
+ _price_cache = {}
75
+ _cls_cache = {}
76
+ NEWS_TTL = 300
77
+
78
+
79
+ # ------------------------------------------------------------------ FETCH
80
+ def _strip(s):
81
+ return html.unescape(re.sub(r"<[^>]+>", " ", s or "")).strip()
82
+
83
+
84
+ def _parse_feed(xml_bytes, source):
85
+ root = ET.fromstring(xml_bytes)
86
+ atom = "{http://www.w3.org/2005/Atom}"
87
+ out = []
88
+ for it in root.iter():
89
+ tag = it.tag.replace(atom, "")
90
+ if tag not in ("item", "entry"):
91
+ continue
92
+
93
+ def g(name):
94
+ el = it.find(name)
95
+ if el is None:
96
+ el = it.find(atom + name)
97
+ return el
98
+
99
+ title = g("title")
100
+ link = g("link")
101
+ date = next((e for e in (g("pubDate"), g("published"), g("updated")) if e is not None), None)
102
+ desc = next((e for e in (g("description"), g("summary")) if e is not None), None)
103
+ href = None
104
+ if link is not None:
105
+ href = link.get("href") or (link.text or "").strip()
106
+ ts = None
107
+ if date is not None and date.text:
108
+ try:
109
+ ts = parsedate_to_datetime(date.text)
110
+ except Exception:
111
+ try:
112
+ ts = datetime.fromisoformat(date.text.replace("Z", "+00:00"))
113
+ except Exception:
114
+ ts = None
115
+ if ts is not None and ts.tzinfo is None:
116
+ ts = ts.replace(tzinfo=timezone.utc)
117
+ if title is not None and title.text:
118
+ ttl, sname = _strip(title.text), source
119
+ if source == "Google":
120
+ se = g("source")
121
+ if se is not None and se.text:
122
+ sname = se.text.strip()
123
+ if ttl.endswith(" - " + sname):
124
+ ttl = ttl[: -len(sname) - 3]
125
+ desc = None
126
+ out.append({
127
+ "title": ttl, "link": href or "#", "source": sname,
128
+ "ts": ts or datetime.now(timezone.utc),
129
+ "summary": _strip(desc.text if desc is not None else "")[:300],
130
+ })
131
+ return out
132
+
133
+
134
+ def _fetch_one(job):
135
+ name, url, hint = job
136
+ try:
137
+ req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0 GoshawkVortex/1.0"})
138
+ with urllib.request.urlopen(req, timeout=8) as r:
139
+ items = _parse_feed(r.read(), name)
140
+ for i in items:
141
+ i["hint"] = [hint] if hint else []
142
+ return items
143
+ except Exception:
144
+ return None
145
+
146
+
147
+ def _gurl(asset, when):
148
+ q = quote(f"({ASSET_QUERY[asset]}) crypto when:{when}")
149
+ return f"https://news.google.com/rss/search?q={q}&hl=en-US&gl=US&ceid=US:en"
150
+
151
+
152
+ def _gurl_general(q, when):
153
+ return f"https://news.google.com/rss/search?q={quote(q + ' when:' + when)}&hl=en-US&gl=US&ceid=US:en"
154
+
155
+
156
+ def fetch_news(period="Günlük", force=False):
157
+ c = _news_cache.get(period)
158
+ if c and not force and time.time() - c["t"] < NEWS_TTL:
159
+ return c["items"], c["ok"], c["n"]
160
+ when = PERIODS[period][1]
161
+ jobs = [(n, u, None) for n, u in FEEDS.items()] + [("Google", _gurl(a, when), a) for a in ASSET_QUERY] \
162
+ + [("Google", _gurl_general(q, when), None) for q in GENERAL_QUERIES]
163
+ with ThreadPoolExecutor(max_workers=24) as ex:
164
+ results = list(ex.map(_fetch_one, jobs))
165
+ items = [i for r in results if r for i in r]
166
+ ok = sum(1 for r in results if r)
167
+ if items:
168
+ _news_cache[period] = {"t": time.time(), "items": items, "ok": ok, "n": len(jobs)}
169
+ return items, ok, len(jobs)
170
+
171
+
172
+ # ------------------------------------------------------------------ DB (geçmiş birikimi)
173
+ def _id(title):
174
+ return hashlib.sha1(re.sub(r"\W+", " ", title.lower()).strip().encode()).hexdigest()[:20]
175
+
176
+
177
+ def _db():
178
+ c = sqlite3.connect(NEWS_DB, timeout=10)
179
+ c.execute("CREATE TABLE IF NOT EXISTS news(id TEXT PRIMARY KEY, title TEXT, link TEXT, source TEXT, "
180
+ "ts REAL, summary TEXT, assets TEXT, label TEXT, score REAL)")
181
+ c.execute("CREATE INDEX IF NOT EXISTS ix_ts ON news(ts)")
182
+ return c
183
+
184
+
185
+ def db_store(items):
186
+ c = _db()
187
+ c.executemany("INSERT OR IGNORE INTO news(id,title,link,source,ts,summary,assets) VALUES(?,?,?,?,?,?,?)",
188
+ [(_id(i["title"]), i["title"], i["link"], i["source"], i["ts"].timestamp(),
189
+ i["summary"], ",".join(i["assets"])) for i in items])
190
+ c.commit()
191
+ c.close()
192
+
193
+
194
+ def db_load(hours):
195
+ c = _db()
196
+ since = time.time() - hours * 3600
197
+ rows = c.execute("SELECT id,title,link,source,ts,summary,assets,label,score FROM news WHERE ts>=?", (since,)).fetchall()
198
+ c.execute("DELETE FROM news WHERE ts<?", (time.time() - 45 * 86400,)) # 45 günden eskiyi temizle
199
+ c.commit()
200
+ c.close()
201
+ return [{"id": r[0], "title": r[1], "link": r[2], "source": r[3],
202
+ "ts": datetime.fromtimestamp(r[4], timezone.utc), "summary": r[5] or "",
203
+ "assets": [a for a in (r[6] or "").split(",") if a], "label": r[7], "score": r[8]} for r in rows]
204
+
205
+
206
+ def db_label(nid, label, score):
207
+ c = _db()
208
+ c.execute("UPDATE news SET label=?, score=? WHERE id=?", (label, float(score), nid))
209
+ c.commit()
210
+ c.close()
211
+
212
+
213
+ # ------------------------------------------------------------------ HIZLI LEKSİKON (LLM yedeği)
214
+ _BULL = re.compile(r"surge|rall(y|ies)|soar|jump|gain|approv|inflow|record|adopt|bullish|breakout|all-time high|\bath\b|"
215
+ r"buy|accumulat|upgrade|partnership|launch|rebound|recover|climb|boost")
216
+ _BEAR = re.compile(r"crash|plunge|hack|exploit|\bban|lawsuit|\bsued?\b|sell-?off|outflow|liquidat|bankrupt|fraud|"
217
+ r"drop|declin|slump|tumble|bearish|breakdown|delist|scam|probe|fine[sd]?\b|fear|dump")
218
+
219
+
220
+ def quick_sentiment(text):
221
+ t = text.lower()
222
+ b, r = len(_BULL.findall(t)), len(_BEAR.findall(t))
223
+ sc = max(-1.0, min(1.0, (b - r) / (b + r + 1) * 1.4))
224
+ return ("Bullish" if sc > 0.15 else "Bearish" if sc < -0.15 else "Neutral"), sc
225
+
226
+
227
+ # ------------------------------------------------------------------ RANK
228
+ def _tokens(t):
229
+ return {w for w in re.findall(r"[a-z0-9]+", t.lower()) if len(w) > 3}
230
+
231
+
232
+ def rank_news(items, limit=12, max_age_h=48, half=12):
233
+ now = datetime.now(timezone.utc)
234
+ items = [i for i in items if (now - i["ts"]).total_seconds() < max_age_h * 3600]
235
+ items.sort(key=lambda i: i["ts"], reverse=True)
236
+ clusters = []
237
+ for it in items:
238
+ tk = _tokens(it["title"])
239
+ for c in clusters:
240
+ if tk and len(tk & c["tk"]) / len(tk | c["tk"]) >= 0.5:
241
+ c["srcs"].add(it["source"])
242
+ c["n"] += 1
243
+ break
244
+ else:
245
+ clusters.append({"it": it, "tk": tk, "srcs": {it["source"]}, "n": 1})
246
+ ranked = []
247
+ for c in clusters:
248
+ it = c["it"]
249
+ text = (it["title"] + " " + it["summary"]).lower()
250
+ kw = sum(w for rx, w in _KW if rx.search(text))
251
+ age_h = (now - it["ts"]).total_seconds() / 3600
252
+ rec = 0.5 ** (age_h / half)
253
+ imp = (kw + 1.5 * (len(c["srcs"]) - 1)) * (0.35 + 0.65 * rec)
254
+ if kw == 0 and len(c["srcs"]) == 1:
255
+ imp *= 0.3
256
+ ranked.append({**it, "srcs": sorted(c["srcs"]), "imp": min(100, imp * 10), "age_h": age_h})
257
+ ranked.sort(key=lambda r: r["imp"], reverse=True)
258
+ return ranked[:limit]
259
+
260
+
261
+ # ------------------------------------------------------------------ PRICE
262
+ def price_series(asset, timeframe):
263
+ k = (asset, timeframe)
264
+ c = _price_cache.get(k)
265
+ if c and time.time() - c[0] < NEWS_TTL:
266
+ return c[1]
267
+ rows = _price_series(asset, timeframe)
268
+ _price_cache[k] = (time.time(), rows)
269
+ return rows
270
+
271
+
272
+ def _price_series(asset, timeframe):
273
+ import yfinance as yf # lazy: sadece grafik için
274
+ period, interval = TIMEFRAMES.get(timeframe, TIMEFRAMES["1A"])
275
+ df = yf.Ticker(f"{asset}-USD").history(period=period, interval=interval)
276
+ rows = []
277
+ for ts, r in df.iterrows():
278
+ if r["Close"] == r["Close"]:
279
+ rows.append((ts.to_pydatetime(), float(r["Open"]), float(r["High"]),
280
+ float(r["Low"]), float(r["Close"]), float(r.get("Volume", 0) or 0)))
281
+ return rows
282
+
283
+
284
+ def _fmt(v):
285
+ if v >= 1000:
286
+ return f"{v:,.0f}"
287
+ if v >= 1:
288
+ return f"{v:,.2f}"
289
+ return f"{v:.5f}"
290
+
291
+
292
+ # ------------------------------------------------------------------ SVG
293
+ def svg_candles(rows, asset, timeframe, marks=()):
294
+ """Mum grafiği + hacim + SMA20 + son fiyat etiketi. marks: [(datetime, renk)] haber işaretleri."""
295
+ if len(rows) < 3:
296
+ return "<div class='nw-empty'>Fiyat verisi alınamadı.</div>"
297
+ W, H, L, R, T = 780, 380, 62, 14, 16
298
+ PB, VT, VB = 270, 286, 350
299
+ n = len(rows)
300
+ lo = min(r[3] for r in rows)
301
+ hi = max(r[2] for r in rows)
302
+ pad = (hi - lo) * 0.06 or hi * 0.01
303
+ lo, hi = lo - pad, hi + pad
304
+ vmax = max(r[5] for r in rows) or 1
305
+ cw = (W - L - R) / n
306
+ px = lambda i: L + cw * (i + 0.5)
307
+ py = lambda v: T + (PB - T) * (1 - (v - lo) / (hi - lo))
308
+ up = rows[-1][4] >= rows[0][1]
309
+ line = "#22c55e" if up else "#ef4444"
310
+ o = [f"<svg class='nw-svg' viewBox='0 0 {W} {H}' xmlns='http://www.w3.org/2000/svg'>",
311
+ "<defs><linearGradient id='nwa' x1='0' y1='0' x2='0' y2='1'>"
312
+ f"<stop offset='0' stop-color='{line}' stop-opacity='.28'/>"
313
+ f"<stop offset='1' stop-color='{line}' stop-opacity='0'/></linearGradient></defs>"]
314
+ for k in range(6):
315
+ v = lo + (hi - lo) * k / 5
316
+ y = py(v)
317
+ o.append(f"<line x1='{L}' x2='{W-R}' y1='{y:.1f}' y2='{y:.1f}' stroke='#94a3b8' stroke-opacity='.12'/>")
318
+ o.append(f"<text x='{L-8}' y='{y+4:.1f}' text-anchor='end' fill='#8295ad' font-size='11'>{_fmt(v)}</text>")
319
+ # alan dolgusu (kapanış)
320
+ pts = " ".join(f"{px(i):.1f},{py(r[4]):.1f}" for i, r in enumerate(rows))
321
+ o.append(f"<polygon points='{px(0):.1f},{PB} {pts} {px(n-1):.1f},{PB}' fill='url(#nwa)'/>")
322
+ # hacim + mumlar
323
+ for i, (ts, op, h, l, c, v) in enumerate(rows):
324
+ col = "#22c55e" if c >= op else "#ef4444"
325
+ x = px(i)
326
+ bw = max(1.2, cw * 0.62)
327
+ vh = (VB - VT) * (v / vmax)
328
+ o.append(f"<rect x='{x-bw/2:.1f}' y='{VB-vh:.1f}' width='{bw:.1f}' height='{vh:.1f}' fill='{col}' opacity='.35'/>")
329
+ y1, y2 = py(max(op, c)), py(min(op, c))
330
+ tip = f"{ts:%d %b %H:%M} O {_fmt(op)} H {_fmt(h)} L {_fmt(l)} C {_fmt(c)}"
331
+ 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'/>"
332
+ 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>")
333
+ # SMA20
334
+ if n >= 20:
335
+ sma = [(i, sum(r[4] for r in rows[i-19:i+1]) / 20) for i in range(19, n)]
336
+ o.append("<polyline fill='none' stroke='#60a5fa' stroke-width='2' opacity='.9' points='"
337
+ + " ".join(f"{px(i):.1f},{py(v):.1f}" for i, v in sma) + "'/>")
338
+ # haber işaretleri (zamana en yakın muma)
339
+ for mts, mcol, mtitle in marks:
340
+ try:
341
+ idx = min(range(n), key=lambda i: abs((rows[i][0].replace(tzinfo=None) - mts.replace(tzinfo=None)).total_seconds()))
342
+ if abs((rows[idx][0].replace(tzinfo=None) - mts.replace(tzinfo=None)).total_seconds()) > 3 * 86400:
343
+ continue
344
+ except Exception:
345
+ continue
346
+ x = px(idx)
347
+ 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>")
348
+ # son fiyat etiketi
349
+ last = rows[-1][4]
350
+ y = py(last)
351
+ o.append(f"<line x1='{L}' x2='{W-R}' y1='{y:.1f}' y2='{y:.1f}' stroke='{line}' stroke-dasharray='4 4' opacity='.8'/>")
352
+ o.append(f"<rect x='{W-R-70}' y='{y-10:.1f}' width='70' height='20' rx='5' fill='{line}'/>"
353
+ f"<text x='{W-R-35}' y='{y+4:.1f}' text-anchor='middle' fill='#fff' font-size='11' font-weight='700'>{_fmt(last)}</text>")
354
+ # x ekseni
355
+ fmt = "%H:%M" if timeframe == "1G" else "%d %b"
356
+ for k in range(5):
357
+ i = int((n - 1) * k / 4)
358
+ o.append(f"<text x='{px(i):.1f}' y='{H-8}' text-anchor='middle' fill='#8295ad' font-size='11'>{rows[i][0]:{fmt}}</text>")
359
+ o.append("</svg>")
360
+ chg = (last / rows[0][1] - 1) * 100
361
+ head = (f"<div class='nw-head'><b>{asset}/USD</b><span class='nw-price'>{_fmt(last)}</span>"
362
+ f"<span style='color:{line}'>{chg:+.2f}%</span><span class='nw-dim'>{timeframe} • mum + hacim + SMA20 • ▲ haber</span></div>")
363
+ return head + "".join(o)
364
+
365
+
366
+ def svg_donut(counts, net):
367
+ tot = sum(counts.values()) or 1
368
+ r, C = 52, 2 * 3.14159265 * 52
369
+ o = ["<svg class='nw-donut' viewBox='0 0 140 140' xmlns='http://www.w3.org/2000/svg'>",
370
+ "<circle cx='70' cy='70' r='52' fill='none' stroke='#172235' stroke-width='16'/>"]
371
+ off = 0
372
+ for k in ("Bullish", "Neutral", "Bearish"):
373
+ ln = C * counts[k] / tot
374
+ if ln > 0:
375
+ o.append(f"<circle cx='70' cy='70' r='52' fill='none' stroke='{COL[k]}' stroke-width='16' "
376
+ f"stroke-dasharray='{ln:.2f} {C-ln:.2f}' stroke-dashoffset='{-off:.2f}' transform='rotate(-90 70 70)'/>")
377
+ off += ln
378
+ c = "#22c55e" if net > 0.05 else "#ef4444" if net < -0.05 else "#eab308"
379
+ o.append(f"<text x='70' y='68' text-anchor='middle' fill='{c}' font-size='24' font-weight='800'>{net:+.2f}</text>"
380
+ "<text x='70' y='86' text-anchor='middle' fill='#8295ad' font-size='9' letter-spacing='1'>NET SKOR</text></svg>")
381
+ return "".join(o)
382
+
383
+
384
+ def svg_compare(series, timeframe):
385
+ """Normalize % getiri karşılaştırması (ALL modu)."""
386
+ series = {k: v for k, v in series.items() if len(v) > 3}
387
+ if not series:
388
+ return "<div class='nw-empty'>Fiyat verisi alınamadı.</div>"
389
+ W, H, L, R, T, B = 780, 300, 52, 110, 14, 28
390
+ norm = {k: [(r[0], (r[4] / v[0][1] - 1) * 100) for r in v] for k, v in series.items()}
391
+ allv = [p for v in norm.values() for _, p in v]
392
+ lo, hi = min(allv + [0]), max(allv + [0])
393
+ pad = (hi - lo) * 0.08 or 1
394
+ lo, hi = lo - pad, hi + pad
395
+ py = lambda v: T + (H - T - B) * (1 - (v - lo) / (hi - lo))
396
+ o = [f"<svg class='nw-svg' viewBox='0 0 {W} {H}' xmlns='http://www.w3.org/2000/svg'>"]
397
+ for k in range(6):
398
+ v = lo + (hi - lo) * k / 5
399
+ o.append(f"<line x1='{L}' x2='{W-R}' y1='{py(v):.1f}' y2='{py(v):.1f}' stroke='#94a3b8' stroke-opacity='.12'/>"
400
+ f"<text x='{L-8}' y='{py(v)+4:.1f}' text-anchor='end' fill='#8295ad' font-size='11'>{v:+.0f}%</text>")
401
+ o.append(f"<line x1='{L}' x2='{W-R}' y1='{py(0):.1f}' y2='{py(0):.1f}' stroke='#e2e8f0' stroke-opacity='.4' stroke-dasharray='5 4'/>")
402
+ first = next(iter(norm.values()))
403
+ ends = []
404
+ for ci, (k, v) in enumerate(norm.items()):
405
+ col = LINES[ci % len(LINES)]
406
+ n = len(v)
407
+ pts = " ".join(f"{L + (W-L-R)*i/(n-1):.1f},{py(p):.1f}" for i, (_, p) in enumerate(v))
408
+ o.append(f"<polyline fill='none' stroke='{col}' stroke-width='2.4' stroke-linejoin='round' points='{pts}'/>")
409
+ ends.append([py(v[-1][1]), col, f"{k} {v[-1][1]:+.1f}%"])
410
+ ends.sort()
411
+ for j in range(1, len(ends)): # etiket çakışmasını önle
412
+ ends[j][0] = max(ends[j][0], ends[j-1][0] + 14)
413
+ for y, col, txt in ends:
414
+ o.append(f"<text x='{W-R+8}' y='{y+4:.1f}' fill='{col}' font-size='11' font-weight='700'>{txt}</text>")
415
+ fmt = "%H:%M" if timeframe == "1G" else "%d %b"
416
+ for k in range(5):
417
+ i = int((len(first) - 1) * k / 4)
418
+ o.append(f"<text x='{L + (W-L-R)*i/(len(first)-1):.1f}' y='{H-8}' text-anchor='middle' fill='#8295ad' font-size='11'>{first[i][0]:{fmt}}</text>")
419
+ o.append("</svg>")
420
+ return f"<div class='nw-head'><b>Varlık performansı</b><span class='nw-dim'>{timeframe} • başlangıca göre % değişim</span></div>" + "".join(o)
421
+
422
+
423
+ def svg_assets(stats):
424
+ """stats: {asset: (haber_sayısı, net_skor|None)} -> yatay çubuklar."""
425
+ rows = sorted([(a, n, s) for a, (n, s) in stats.items() if n > 0], key=lambda x: -x[1])
426
+ if not rows:
427
+ return "<div class='nw-empty'>Bu dönemde varlık bazlı haber bulunamadı.</div>"
428
+ mx = max(n for _, n, _ in rows)
429
+ H = 12 + 28 * len(rows)
430
+ o = [f"<svg class='nw-svg' viewBox='0 0 780 {H}' xmlns='http://www.w3.org/2000/svg'>"]
431
+ for i, (a, n, sc) in enumerate(rows):
432
+ y = 8 + 28 * i
433
+ col = "#475569" if sc is None else "#22c55e" if sc > 0.05 else "#ef4444" if sc < -0.05 else "#eab308"
434
+ w = 480 * n / mx
435
+ o.append(f"<text x='8' y='{y+15}' fill='#dbeafe' font-size='12' font-weight='700'>{a}</text>"
436
+ f"<rect x='60' y='{y+2}' width='480' height='18' rx='9' fill='#172235'/>"
437
+ f"<rect x='60' y='{y+2}' width='{max(w, 6):.0f}' height='18' rx='9' fill='{col}'><title>{a}: {n} haber</title></rect>"
438
+ f"<text x='552' y='{y+15}' fill='#94a3b8' font-size='11'>{n} haber • "
439
+ f"{'skor —' if sc is None else f'skor {sc:+.2f}'}</text>")
440
+ o.append("</svg>")
441
+ return "<div class='nw-head'><b>Varlık haber yoğunluğu</b><span class='nw-dim'>çubuk = haber sayısı • renk = duygu</span></div>" + "".join(o)
442
+
443
+
444
+ def svg_timeline(items, hours):
445
+ """Zamana göre ortalama duygu (sınıflandırılmış haberler)."""
446
+ nb = 6 if hours <= 24 else 30
447
+ step = hours * 3600 / nb
448
+ end = time.time()
449
+ b = [[0.0, 0] for _ in range(nb)]
450
+ for it in items:
451
+ if it.get("label") is None:
452
+ continue
453
+ k = int((it["ts"].timestamp() - (end - hours * 3600)) // step)
454
+ if 0 <= k < nb:
455
+ b[k][0] += it["score"]
456
+ b[k][1] += 1
457
+ if not any(n for _, n in b):
458
+ return ""
459
+ W, H, L, mid = 780, 170, 40, 78
460
+ bw = (W - L - 10) / nb
461
+ o = [f"<svg class='nw-svg' viewBox='0 0 {W} {H}' xmlns='http://www.w3.org/2000/svg'>",
462
+ f"<line x1='{L}' x2='{W-10}' y1='{mid}' y2='{mid}' stroke='#e2e8f0' stroke-opacity='.35'/>"]
463
+ for k, (sm, n) in enumerate(b):
464
+ t0 = datetime.fromtimestamp(end - hours * 3600 + k * step)
465
+ if n:
466
+ v = sm / n
467
+ h = abs(v) * 62
468
+ col = "#22c55e" if v >= 0 else "#ef4444"
469
+ y = mid - h if v >= 0 else mid
470
+ o.append(f"<rect x='{L + k*bw + 3:.1f}' y='{y:.1f}' width='{bw-6:.1f}' height='{max(h, 2):.1f}' rx='4' fill='{col}' "
471
+ f"opacity='{min(1, .4 + n/6):.2f}'><title>{t0:%d %b %H:%M} • {n} haber • skor {v:+.2f}</title></rect>")
472
+ if k % max(1, nb // 6) == 0:
473
+ fmt = "%H:%M" if hours <= 24 else "%d %b"
474
+ o.append(f"<text x='{L + k*bw + bw/2:.1f}' y='{H-6}' text-anchor='middle' fill='#8295ad' font-size='10'>{t0:{fmt}}</text>")
475
+ o.append(f"<text x='6' y='{mid-52}' fill='#22c55e' font-size='10'>+1</text><text x='6' y='{mid+60}' fill='#ef4444' font-size='10'>-1</text></svg>")
476
+ return ("<div class='nw-head'><b>Duygu zaman çizgisi</b><span class='nw-dim'>sınıflandırılan haberlerin ortalaması • "
477
+ "koyuluk = haber adedi</span></div>" + "".join(o))
478
+
479
+
480
+ # ------------------------------------------------------------------ PANEL
481
+ NEWS_CSS = """
482
+ .nw-wrap { margin-top:12px; color:#dbeafe; }
483
+ .nw-card, .nw-news { padding:18px; border-radius:18px; margin-bottom:14px;
484
+ background:linear-gradient(180deg, rgba(8,18,34,.9), rgba(5,13,25,.85));
485
+ border:1px solid rgba(96,165,250,.18); box-shadow:0 14px 36px rgba(0,0,0,.25); }
486
+ .nw-head { display:flex; gap:14px; align-items:baseline; flex-wrap:wrap; margin-bottom:6px; }
487
+ .nw-price { font-size:26px; font-weight:800; }
488
+ .nw-dim { color:#64748b; font-size:11px; margin-left:auto; }
489
+ .nw-svg { width:100%; height:auto; display:block; }
490
+ .nw-grid { display:grid; grid-template-columns:160px 1fr; gap:18px; align-items:center; }
491
+ .nw-donut { width:150px; height:150px; }
492
+ .nw-legend span { display:inline-block; margin:3px 14px 3px 0; font-size:12px; }
493
+ .nw-dot { display:inline-block; width:9px; height:9px; border-radius:50%; margin-right:6px; }
494
+ .nw-empty { color:#8295ad; padding:18px; }
495
+
496
+ /* ---- canlı şerit (ticker) ---- */
497
+ .nw-ticker { overflow:hidden; border-radius:14px; margin-bottom:14px; padding:10px 0;
498
+ background:linear-gradient(90deg, rgba(8,18,34,.95), rgba(10,28,50,.9));
499
+ border:1px solid rgba(96,165,250,.22); position:relative; }
500
+ .nw-ticker:before, .nw-ticker:after { content:""; position:absolute; top:0; bottom:0; width:60px; z-index:2; pointer-events:none; }
501
+ .nw-ticker:before { left:0; background:linear-gradient(90deg,#08121f,transparent); }
502
+ .nw-ticker:after { right:0; background:linear-gradient(270deg,#08121f,transparent); }
503
+ .nw-track { display:inline-flex; white-space:nowrap; gap:34px; padding-left:100%; animation:nwScroll 140s linear infinite; }
504
+ .nw-ticker:hover .nw-track { animation-play-state:paused; }
505
+ .nw-track a { color:#cbd5e1; font-size:13px; text-decoration:none; }
506
+ .nw-track a:hover { color:#fff; text-decoration:underline; }
507
+ .nw-live { display:inline-block; width:8px; height:8px; border-radius:50%; margin-right:8px; background:#ef4444;
508
+ box-shadow:0 0 0 0 rgba(239,68,68,.7); animation:nwPing 1.6s infinite; }
509
+ @keyframes nwScroll { to { transform:translateX(-100%); } }
510
+ @keyframes nwPing { 70% { box-shadow:0 0 0 9px rgba(239,68,68,0); } 100% { box-shadow:0 0 0 0 rgba(239,68,68,0); } }
511
+
512
+ /* ---- istatistik kutuları ---- */
513
+ .nw-stats { display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:12px; margin-bottom:14px; }
514
+ .nw-stat { padding:14px 16px; border-radius:16px; position:relative; overflow:hidden;
515
+ background:linear-gradient(135deg, rgba(14,30,54,.92), rgba(7,16,30,.9));
516
+ border:1px solid rgba(96,165,250,.2); transition:transform .2s, box-shadow .2s; animation:nwUp .5s ease both; }
517
+ .nw-stat:hover { transform:translateY(-3px); box-shadow:0 10px 26px rgba(96,165,250,.2); }
518
+ .nw-stat:after { content:""; position:absolute; right:-18px; top:-18px; width:70px; height:70px; border-radius:50%;
519
+ background:var(--c,#60a5fa); opacity:.14; }
520
+ .nw-stat b { display:block; font-size:28px; font-weight:800; color:var(--c,#e2e8f0); letter-spacing:-1px; }
521
+ .nw-stat span { font-size:11px; letter-spacing:1.2px; color:#8295ad; text-transform:uppercase; }
522
+
523
+ /* ---- filtre ---- */
524
+ .nw-filters { display:flex; gap:8px; flex-wrap:wrap; margin:6px 0 14px; }
525
+ .nw-filters input { display:none; }
526
+ .nw-filters label { cursor:pointer; padding:7px 16px; border-radius:99px; font-size:12px; font-weight:700;
527
+ background:#0f1d33; color:#9fb0c6; border:1px solid rgba(96,165,250,.25); transition:all .15s; user-select:none; }
528
+ .nw-filters label:hover { transform:translateY(-2px); color:#fff; }
529
+ .nw-wrap:has(#nwf-all:checked) label[for=nwf-all],
530
+ .nw-wrap:has(#nwf-bull:checked) label[for=nwf-bull],
531
+ .nw-wrap:has(#nwf-neu:checked) label[for=nwf-neu],
532
+ .nw-wrap:has(#nwf-bear:checked) label[for=nwf-bear],
533
+ .nw-wrap:has(#nwf-hot:checked) label[for=nwf-hot] { background:#60a5fa; color:#04101f; border-color:#bfdbfe; box-shadow:0 0 16px rgba(96,165,250,.5); }
534
+ .nw-wrap:has(#nwf-bull:checked) .nw-news:not([data-l=Bullish]),
535
+ .nw-wrap:has(#nwf-neu:checked) .nw-news:not([data-l=Neutral]),
536
+ .nw-wrap:has(#nwf-bear:checked) .nw-news:not([data-l=Bearish]),
537
+ .nw-wrap:has(#nwf-hot:checked) .nw-news:not([data-hot="1"]) { display:none; }
538
+
539
+ /* ---- haber kartları ---- */
540
+ .nw-list { display:grid; grid-template-columns:repeat(auto-fill,minmax(340px,1fr)); gap:12px; }
541
+ .nw-news { border-left:4px solid var(--c); padding:14px 16px; margin:0; position:relative; overflow:hidden;
542
+ transition:transform .2s ease, box-shadow .2s ease, border-color .2s; animation:nwUp .55s ease both;
543
+ animation-delay:calc(var(--i) * 28ms); display:flex; flex-direction:column; gap:8px; }
544
+ .nw-news:before { content:""; position:absolute; inset:0; pointer-events:none; opacity:0; transition:opacity .25s;
545
+ background:radial-gradient(circle at 0% 0%, var(--c), transparent 55%); }
546
+ .nw-news:hover { transform:translateY(-4px) scale(1.01); box-shadow:0 16px 36px rgba(0,0,0,.45), 0 0 22px color-mix(in srgb, var(--c) 40%, transparent); }
547
+ .nw-news:hover:before { opacity:.12; }
548
+ .nw-news a { color:#e8eef9; font-weight:650; text-decoration:none; line-height:1.4; font-size:14.5px; position:relative; }
549
+ .nw-news a:hover { color:#93c5fd; }
550
+ .nw-sum { color:#8b9bb3; font-size:12px; line-height:1.45; position:relative;
551
+ display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; }
552
+ .nw-top { display:flex; align-items:center; gap:8px; position:relative; }
553
+ .nw-av { width:24px; height:24px; border-radius:8px; display:grid; place-items:center; font-size:11px; font-weight:800;
554
+ color:#fff; background:hsl(var(--h),55%,38%); flex:none; }
555
+ .nw-src { font-size:11.5px; color:#a9b8cf; font-weight:600; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
556
+ .nw-badge { margin-left:auto; font-size:10px; font-weight:800; letter-spacing:.6px; padding:2px 8px; border-radius:99px; white-space:nowrap; }
557
+ .nw-hot { background:linear-gradient(90deg,#f97316,#ef4444); color:#fff; animation:nwGlow 1.8s ease-in-out infinite alternate; }
558
+ .nw-new { background:#38bdf8; color:#04101f; }
559
+ @keyframes nwGlow { to { box-shadow:0 0 14px rgba(249,115,22,.9); } }
560
+ @keyframes nwUp { from { opacity:0; transform:translateY(14px); } to { opacity:1; transform:none; } }
561
+ .nw-meta { color:#7d8ea6; font-size:11px; display:flex; gap:8px; flex-wrap:wrap; align-items:center; position:relative; }
562
+ .nw-chip { padding:2px 9px; border-radius:99px; font-weight:700; font-size:11px; background:var(--c); color:#06101f; }
563
+ .nw-imp { width:70px; height:6px; border-radius:9px; background:#172235; overflow:hidden; margin-left:auto; }
564
+ .nw-imp i { display:block; height:100%; background:linear-gradient(90deg,#60a5fa,#f97316); }
565
+ .nw-tag { padding:1px 7px; border-radius:6px; background:#1e293b; color:#93c5fd; font-weight:700; }
566
+ .nw-gauge { width:100%; max-width:230px; }
567
+ @media (max-width:640px){ .nw-grid { grid-template-columns:1fr; justify-items:center; } .nw-list { grid-template-columns:1fr; } }
568
+ """
569
+
570
+
571
+ def svg_gauge(net):
572
+ """Yarım daire piyasa ruh hali ibresi (-1..+1)."""
573
+ import math
574
+ ang = math.pi * (1 - (max(-1, min(1, net)) + 1) / 2) # pi (sol) .. 0 (sağ)
575
+ cx, cy, r = 100, 100, 78
576
+ nx, ny = cx + (r - 14) * math.cos(ang), cy - (r - 14) * math.sin(ang)
577
+ segs = [("#ef4444", 180, 120), ("#f97316", 120, 90), ("#eab308", 90, 60), ("#84cc16", 60, 30), ("#22c55e", 30, 0)]
578
+ o = ["<svg class='nw-gauge' viewBox='0 0 200 122' xmlns='http://www.w3.org/2000/svg'>"]
579
+ for col, a0, a1 in segs:
580
+ x0, y0 = cx + r * math.cos(math.radians(a0)), cy - r * math.sin(math.radians(a0))
581
+ x1, y1 = cx + r * math.cos(math.radians(a1)), cy - r * math.sin(math.radians(a1))
582
+ o.append(f"<path d='M{x0:.1f},{y0:.1f} A{r},{r} 0 0 1 {x1:.1f},{y1:.1f}' fill='none' stroke='{col}' stroke-width='14' opacity='.85'/>")
583
+ o.append(f"<line x1='{cx}' y1='{cy}' x2='{nx:.1f}' y2='{ny:.1f}' stroke='#f8fafc' stroke-width='3' stroke-linecap='round'>"
584
+ f"<animateTransform attributeName='transform' type='rotate' from='-90 {cx} {cy}' to='0 {cx} {cy}' dur='1.1s' fill='freeze'/></line>"
585
+ f"<circle cx='{cx}' cy='{cy}' r='7' fill='#f8fafc'/>"
586
+ "<text x='14' y='118' fill='#ef4444' font-size='9' font-weight='700'>KORKU</text>"
587
+ "<text x='186' y='118' text-anchor='end' fill='#22c55e' font-size='9' font-weight='700'>HIRS</text></svg>")
588
+ return "".join(o)
589
+
590
+
591
+ def _mood(net):
592
+ return ("Aşırı iyimser" if net > .5 else "İyimser" if net > .15 else "Dengeli" if net > -.15
593
+ else "Temkinli" if net > -.5 else "Aşırı kötümser")
594
+
595
+
596
+ def build_news_panel(classify, detect_assets, asset="ALL", timeframe="1A", period="Günlük", limit=None):
597
+ hours, _, half = PERIODS.get(period, PERIODS["Günlük"])
598
+ limit = int(limit or DEFAULT_LIMIT.get(period, 30))
599
+ fresh, ok, total = fetch_news(period)
600
+ if not fresh and not db_load(hours):
601
+ return ("<div class='nw-card nw-empty'>Haber kaynaklarına ulaşılamadı. "
602
+ "Ağ erişimini veya FEEDS adreslerini kontrol et.</div>")
603
+ for i in fresh:
604
+ i["assets"] = sorted(set(i.get("hint", [])) | set(detect_assets(i["title"])))
605
+ db_store(fresh)
606
+ window = db_load(hours)
607
+
608
+ stats = {}
609
+ for a in ASSET_QUERY:
610
+ mine = [i for i in window if a in i["assets"]]
611
+ lab = [i["score"] for i in mine if i["label"] is not None]
612
+ stats[a] = (len(mine), sum(lab) / len(lab) if lab else None)
613
+
614
+ pool = window if asset == "ALL" else [i for i in window if asset in i["assets"]]
615
+ top = rank_news(pool, limit=limit, max_age_h=hours, half=half)
616
+
617
+ # LLM ile en önemli (etiketsiz) haberleri etiketle; kalanlar için hızlı leksikon
618
+ done = 0
619
+ for t in top:
620
+ if t.get("label") is None:
621
+ if done < MAX_CLASSIFY:
622
+ try:
623
+ t["label"], t["score"] = classify(t["title"] + ". " + t["summary"][:200])
624
+ except Exception:
625
+ t["label"], t["score"] = "Neutral", 0.0
626
+ db_label(t["id"], t["label"], t["score"])
627
+ done += 1
628
+ for w in pool:
629
+ if w["id"] == t["id"]:
630
+ w["label"], w["score"] = t["label"], t["score"]
631
+ else:
632
+ t["label"], t["score"] = quick_sentiment(t["title"] + " " + t["summary"])
633
+ t["approx"] = True
634
+
635
+ counts = {"Bullish": 0, "Neutral": 0, "Bearish": 0}
636
+ wsum = nsum = 0.0
637
+ for t in top:
638
+ counts[t["label"] if t["label"] in counts else "Neutral"] += 1
639
+ w = 1 + t["imp"] / 25
640
+ wsum += w
641
+ nsum += w * t["score"]
642
+ net = nsum / wsum if wsum else 0.0
643
+
644
+ if asset == "ALL":
645
+ syms = ["BTC", "ETH", "SOL", "BNB", "XRP"]
646
+ with ThreadPoolExecutor(max_workers=5) as ex:
647
+ res = list(ex.map(lambda a: (a, _safe_price(a, timeframe)), syms))
648
+ chart = f"<div class='nw-card'>{svg_compare({a: r for a, r in res if r}, timeframe)}</div>"
649
+ chart += f"<div class='nw-card'>{svg_assets(stats)}</div>"
650
+ else:
651
+ try:
652
+ rows = price_series(asset, timeframe)
653
+ marks = [(t["ts"], COL.get(t["label"], "#eab308"), t["title"]) for t in top]
654
+ chart = f"<div class='nw-card'>{svg_candles(rows, asset, timeframe, marks)}</div>"
655
+ except Exception as e:
656
+ chart = f"<div class='nw-card nw-empty'>Fiyat grafiği yüklenemedi: {html.escape(str(e))[:120]}</div>"
657
+ tl = svg_timeline(pool, hours)
658
+ if tl:
659
+ chart += f"<div class='nw-card'>{tl}</div>"
660
+
661
+ # ---- üst şerit + istatistik kutuları
662
+ tick = "".join(
663
+ f"<a href='{html.escape(t['link'])}' target='_blank' rel='noopener'>"
664
+ f"<i class='nw-dot' style='background:{COL.get(t['label'], '#eab308')}'></i>{html.escape(t['title'])}</a>"
665
+ for t in top[:15])
666
+ ticker = (f"<div class='nw-ticker'><div class='nw-track'><span style='color:#f87171;font-weight:800;font-size:12px'>"
667
+ f"<i class='nw-live'></i>CANLI</span>{tick}</div></div>") if top else ""
668
+ n_src = len({s for t in top for s in t["srcs"]})
669
+ hot = sum(1 for t in top if t["imp"] >= 50)
670
+ bull_pct = 100 * counts["Bullish"] / max(1, len(top))
671
+ tiles = (f"<div class='nw-stats'>"
672
+ f"<div class='nw-stat' style='--c:#60a5fa'><b>{len(pool)}</b><span>Taranan haber</span></div>"
673
+ f"<div class='nw-stat' style='--c:#a78bfa'><b>{n_src}</b><span>Aktif kaynak</span></div>"
674
+ f"<div class='nw-stat' style='--c:#f97316'><b>{hot}</b><span>🔥 Sıcak haber</span></div>"
675
+ f"<div class='nw-stat' style='--c:#22c55e'><b>%{bull_pct:.0f}</b><span>Yükseliş payı</span></div></div>")
676
+
677
+ legend = "".join(f"<span><i class='nw-dot' style='background:{COL[k]}'></i>{k} {counts[k]}</span>" for k in COL)
678
+ summary = (f"<div class='nw-card nw-grid'>{svg_donut(counts, net)}<div>"
679
+ f"<b>{period} haber duygu özeti • {asset}</b> <span class='nw-dim'>önem ağırlıklı • {len(top)}/{len(pool)} haber • "
680
+ f"{ok}/{total} kaynak</span><div class='nw-legend' style='margin-top:8px'>{legend}</div></div></div>")
681
+ mood = (f"<div class='nw-card' style='text-align:center'><b>Piyasa ruh hali</b><div style='display:grid;place-items:center'>"
682
+ f"{svg_gauge(net)}</div><div style='font-size:20px;font-weight:800;margin-top:-4px'>{_mood(net)}</div></div>")
683
+
684
+ filters = ("<div class='nw-filters'>"
685
+ "<input type='radio' name='nwf' id='nwf-all' checked><label for='nwf-all'>Tümü</label>"
686
+ "<input type='radio' name='nwf' id='nwf-hot'><label for='nwf-hot'>🔥 Sıcak</label>"
687
+ "<input type='radio' name='nwf' id='nwf-bull'><label for='nwf-bull'>🟢 Bullish</label>"
688
+ "<input type='radio' name='nwf' id='nwf-neu'><label for='nwf-neu'>🟡 Neutral</label>"
689
+ "<input type='radio' name='nwf' id='nwf-bear'><label for='nwf-bear'>🔴 Bearish</label></div>")
690
+
691
+ cards = []
692
+ for idx, t in enumerate(top):
693
+ c = COL.get(t["label"], "#eab308")
694
+ age = f"{t['age_h']*60:.0f} dk" if t["age_h"] < 1 else f"{t['age_h']:.0f} sa" if t["age_h"] < 48 else f"{t['age_h']/24:.0f} gün"
695
+ tags = " ".join(f"<span class='nw-tag'>{a}</span>" for a in t["assets"][:3])
696
+ src = t["srcs"][0] if t["srcs"] else t["source"]
697
+ hue = int(hashlib.md5(src.encode()).hexdigest()[:4], 16) % 360
698
+ is_hot = t["imp"] >= 50
699
+ badge = ("<span class='nw-badge nw-hot'>🔥 SICAK</span>" if is_hot
700
+ else "<span class='nw-badge nw-new'>YENİ</span>" if t["age_h"] < 1 else "")
701
+ more = f" +{len(t['srcs'])-1}" if len(t["srcs"]) > 1 else ""
702
+ summ = f"<div class='nw-sum'>{html.escape(t['summary'])}</div>" if t["summary"] else ""
703
+ approx = "~" if t.get("approx") else ""
704
+ cards.append(
705
+ f"<div class='nw-news' data-l='{t['label']}' data-hot='{int(is_hot)}' style='--c:{c};--h:{hue};--i:{min(idx, 20)}'>"
706
+ f"<div class='nw-top'><span class='nw-av'>{html.escape(src[:1].upper())}</span>"
707
+ f"<span class='nw-src'>{html.escape(src)}{more}</span>{badge}</div>"
708
+ f"<a href='{html.escape(t['link'])}' target='_blank' rel='noopener'>{html.escape(t['title'])}</a>{summ}"
709
+ f"<div class='nw-meta'><span class='nw-chip'>{approx}{t['label']} {t['score']:+.1f}</span>{tags}"
710
+ f"<span>⏱ {age} önce</span>"
711
+ f"<span class='nw-imp' title='Önem {t['imp']:.0f}/100'><i style='width:{t['imp']:.0f}%'></i></span></div></div>")
712
+ if not cards:
713
+ cards = ["<div class='nw-card nw-empty'>Bu dönem/varlık için önemli haber bulunamadı.</div>"]
714
+
715
+ note = ("<div class='nw-dim' style='margin:0 0 8px'>Aylık görünüm Google News + biriken tarama geçmişinden gelir; "
716
+ "zaman çizgisi uygulama çalıştıkça dolar. “~” işareti hızlı anahtar kelime tahminidir; sonraki taramalarda model etiketler.</div>"
717
+ if hours > 24 else
718
+ "<div class='nw-dim' style='margin:0 0 8px'>“~” işareti hızlı anahtar kelime tahminidir; sonraki taramalarda model etiketler.</div>")
719
+ stamp = datetime.now().strftime("%H:%M:%S")
720
+ return (f"<div class='nw-wrap'>{ticker}"
721
+ f"<div class='nw-dim' style='margin:0 0 8px'>Son tarama: {stamp} • önbellek {NEWS_TTL//60} dk • {ok}/{total} kaynak yanıt verdi</div>"
722
+ f"{tiles}<div class='nw-grid' style='grid-template-columns:1fr 1fr;align-items:stretch'>{summary}{mood}</div>"
723
+ f"{chart}{note}{filters}<div class='nw-list'>{''.join(cards)}</div></div>")
724
+
725
+
726
+ def _safe_price(a, tf):
727
+ try:
728
+ return price_series(a, tf)
729
+ except Exception:
730
+ return None