GoshawkVortexAI commited on
Commit
4aa9230
·
verified ·
1 Parent(s): 0331f02

Delete news_feed.py

Browse files
Files changed (1) hide show
  1. news_feed.py +0 -730
news_feed.py DELETED
@@ -1,730 +0,0 @@
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