GoshawkVortexAI commited on
Commit
9b26a54
·
verified ·
1 Parent(s): 3b0672b

Upload news_feed(3).py

Browse files
Files changed (1) hide show
  1. news_feed(3).py +1038 -0
news_feed(3).py ADDED
@@ -0,0 +1,1038 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ "Crypto Briefing": "https://cryptobriefing.com/feed/",
39
+ "Protos": "https://protos.com/feed/",
40
+ "DL News": "https://www.dlnews.com/arc/outboundfeeds/rss/",
41
+ "CoinJournal": "https://coinjournal.net/feed/",
42
+ "Bitcoin Sistemi (EN)": "https://bitcoinsistemi.com/en/feed/",
43
+ }
44
+
45
+ # Varlıktan bağımsız genel Google News aramaları (makro, regülasyon, ETF...)
46
+ GENERAL_QUERIES = [
47
+ "crypto market", "bitcoin ETF", "crypto regulation SEC", "crypto hack exploit",
48
+ "stablecoin", "crypto whale", "Federal Reserve bitcoin", "altcoin rally",
49
+ "ethereum ETF", "crypto exchange listing", "token unlock", "crypto liquidations",
50
+ "memecoin", "DeFi", "crypto lawsuit", "crypto airdrop",
51
+ ]
52
+ MAX_CLASSIFY = 24 # tek taramada LLM ile etiketlenecek en fazla haber (kalanlar sonraki taramada)
53
+ MAX_BULK_CLASSIFY = 40 # toplu taramada varsayılan LLM etiketleme bütçesi
54
+ DEFAULT_LIMIT = {"Günlük": 30, "Aylık": 50}
55
+
56
+ # regex -> ağırlık (önem puanı)
57
+ KEYWORDS = {
58
+ r"\betf": 3, r"\bsec\b": 3, r"\bhack": 3, r"\bexploit": 3, r"\bliquidat": 3,
59
+ r"\bbankrupt": 3, r"\bfed\b": 3, r"\brate (cut|hike)": 3, r"\bcpi\b": 2,
60
+ r"\bhalving": 3, r"\bcrash": 3, r"\bplunge": 3, r"\bsurge": 2, r"\brall(y|ies)": 2,
61
+ r"all-time high|\bath\b": 3, r"\blawsuit|\bsued?\b": 2, r"\bban(s|ned)?\b": 2,
62
+ r"\bapprov": 2, r"\bregulat": 2, r"\bwhale": 2, r"\binflow|\boutflow": 2,
63
+ r"\bblackrock": 2, r"\bdelist": 2, r"\bstablecoin": 1, r"\btether|\busdt": 1,
64
+ r"\bbinance": 1, r"\bbreakout|\bbreakdown": 2, r"\bsettle|\bfine[sd]?\b": 1,
65
+ }
66
+ _KW = [(re.compile(k), w) for k, w in KEYWORDS.items()]
67
+
68
+ # ------------------------------------------------------------------ VARLIK KAYIT DEFTERİ
69
+ # (sembol, [tam adlar], ci)
70
+ # ci=True -> sembol küçük/büyük harf fark etmeden eşleşir (btc, Btc, BTC)
71
+ # ci=False -> sembol yalnızca BÜYÜK harfle ya da $sembol olarak eşleşir; "link", "dot",
72
+ # "near", "sol" gibi sıradan kelimelerden kaynaklanan yanlış eşleşmeyi önler.
73
+ # Yeni parite eklemek için bu listeye bir satır eklemek yeterli.
74
+ ASSETS_DEF = [
75
+ ("BTC", ["bitcoin"], True), ("ETH", ["ethereum"], True), ("BNB", ["binance coin", "bnb chain"], True),
76
+ ("SOL", ["solana"], False), ("XRP", ["ripple", "xrp ledger"], True), ("ADA", ["cardano"], False),
77
+ ("DOGE", ["dogecoin"], True), ("AVAX", ["avalanche"], True), ("LINK", ["chainlink"], False),
78
+ ("DOT", ["polkadot"], False), ("TON", ["toncoin", "the open network"], False),
79
+ ("TRX", ["tron", "tronix"], True), ("SHIB", ["shiba inu"], True), ("LTC", ["litecoin"], True),
80
+ ("BCH", ["bitcoin cash"], True), ("NEAR", ["near protocol"], False), ("UNI", ["uniswap"], False),
81
+ ("ATOM", ["cosmos hub"], False), ("XLM", ["stellar lumens", "stellar network"], True),
82
+ ("ETC", ["ethereum classic"], False), ("HBAR", ["hedera"], True), ("APT", ["aptos"], False),
83
+ ("ARB", ["arbitrum"], False), ("OP", ["op mainnet", "optimism chain"], False),
84
+ ("SUI", ["sui network", "sui blockchain"], False), ("INJ", ["injective"], False),
85
+ ("FIL", ["filecoin"], False), ("ICP", ["internet computer"], False), ("AAVE", ["aave"], True),
86
+ ("MKR", ["makerdao", "maker dao"], False), ("PEPE", ["pepe coin", "pepecoin"], True),
87
+ ("TIA", ["celestia"], False), ("SEI", ["sei network"], False),
88
+ ("RENDER", ["render network", "render token", "rndr"], False), ("ALGO", ["algorand"], False),
89
+ ("VET", ["vechain"], False), ("XMR", ["monero"], True),
90
+ ("FET", ["fetch.ai", "artificial superintelligence alliance"], False),
91
+ ("HYPE", ["hyperliquid"], False), ("ENA", ["ethena"], False),
92
+ ]
93
+ ASSET_LIST = [a[0] for a in ASSETS_DEF]
94
+ MAJORS = ["BTC", "ETH", "SOL", "BNB", "XRP"]
95
+
96
+
97
+ def _gq(sym, names, ci):
98
+ q = " OR ".join(f'"{n}"' for n in names)
99
+ return q + (f" OR {sym}" if ci else "")
100
+
101
+
102
+ ASSET_QUERY = {sym: _gq(sym, names, ci) for sym, names, ci in ASSETS_DEF}
103
+
104
+ # yfinance'ta "SYM-USD" biçimine uymayan sembollerin karşılıkları
105
+ YF_SYMBOL = {
106
+ "TON": "TON11419-USD", "UNI": "UNI7083-USD", "APT": "APT21794-USD", "ARB": "ARB11841-USD",
107
+ "SUI": "SUI20947-USD", "TIA": "TIA22861-USD", "PEPE": "PEPE24478-USD", "SEI": "SEI-USD",
108
+ "HYPE": "HYPE32196-USD", "ENA": "ENA36530-USD", "MKR": "MKR-USD", "RENDER": "RENDER-USD",
109
+ }
110
+
111
+ _ASSET_RX = []
112
+ for _sym, _names, _ci in ASSETS_DEF:
113
+ _name_rx = re.compile(r"(?<![a-z0-9])(?:" + "|".join(re.escape(n) for n in _names) + r")(?![a-z0-9])", re.I)
114
+ if _ci:
115
+ _tick_rx = re.compile(r"(?<![A-Za-z0-9])\$?" + _sym + r"(?![A-Za-z0-9])", re.I)
116
+ else:
117
+ _tick_rx = re.compile(r"(?<![A-Za-z0-9])(?:" + _sym + r"|(?i:\$" + _sym + r"))(?![A-Za-z0-9])")
118
+ _ASSET_RX.append((_sym, _name_rx, _tick_rx))
119
+
120
+
121
+ _PARENT = {"ETC": "ETH", "BCH": "BTC"} # "Ethereum Classic" haberi ETH'ye de yazılmasın
122
+
123
+
124
+ def detect_assets(text):
125
+ """Metinde geçen varlık sembollerini döndürür (app.py'deki eski detect_assets yerine geçer)."""
126
+ text = text or ""
127
+ rx = {sym: (nrx, trx) for sym, nrx, trx in _ASSET_RX}
128
+ out = []
129
+ for sym, nrx, trx in _ASSET_RX:
130
+ t = text
131
+ for child, parent in _PARENT.items():
132
+ if sym == parent:
133
+ t = rx[child][0].sub(" ", t)
134
+ if nrx.search(t) or trx.search(t):
135
+ out.append(sym)
136
+ return out
137
+
138
+
139
+ # dönem -> (saat, google 'when', recency yarı ömrü saat)
140
+ PERIODS = {"Günlük": (24, "1d", 12), "Aylık": (720, "30d", 96)}
141
+ NEWS_DB = "news.db"
142
+ LINES = ["#f7931a", "#60a5fa", "#a78bfa", "#eab308", "#22c55e"]
143
+
144
+ TIMEFRAMES = {"1G": ("2d", "15m"), "7G": ("7d", "1h"), "1A": ("1mo", "1d"), "3A": ("3mo", "1d")}
145
+ COL = {"Bullish": "#22c55e", "Neutral": "#eab308", "Bearish": "#ef4444"}
146
+
147
+ _news_cache = {}
148
+ _price_cache = {}
149
+ _cls_cache = {}
150
+ NEWS_TTL = 300
151
+
152
+
153
+ # ------------------------------------------------------------------ FETCH
154
+ def _strip(s):
155
+ return html.unescape(re.sub(r"<[^>]+>", " ", s or "")).strip()
156
+
157
+
158
+ def _parse_feed(xml_bytes, source):
159
+ root = ET.fromstring(xml_bytes)
160
+ atom = "{http://www.w3.org/2005/Atom}"
161
+ out = []
162
+ for it in root.iter():
163
+ tag = it.tag.replace(atom, "")
164
+ if tag not in ("item", "entry"):
165
+ continue
166
+
167
+ def g(name):
168
+ el = it.find(name)
169
+ if el is None:
170
+ el = it.find(atom + name)
171
+ return el
172
+
173
+ title = g("title")
174
+ link = g("link")
175
+ date = next((e for e in (g("pubDate"), g("published"), g("updated")) if e is not None), None)
176
+ desc = next((e for e in (g("description"), g("summary")) if e is not None), None)
177
+ href = None
178
+ if link is not None:
179
+ href = link.get("href") or (link.text or "").strip()
180
+ ts = None
181
+ if date is not None and date.text:
182
+ try:
183
+ ts = parsedate_to_datetime(date.text)
184
+ except Exception:
185
+ try:
186
+ ts = datetime.fromisoformat(date.text.replace("Z", "+00:00"))
187
+ except Exception:
188
+ ts = None
189
+ if ts is not None and ts.tzinfo is None:
190
+ ts = ts.replace(tzinfo=timezone.utc)
191
+ if title is not None and title.text:
192
+ ttl, sname = _strip(title.text), source
193
+ if source == "Google":
194
+ se = g("source")
195
+ if se is not None and se.text:
196
+ sname = se.text.strip()
197
+ if ttl.endswith(" - " + sname):
198
+ ttl = ttl[: -len(sname) - 3]
199
+ desc = None
200
+ out.append({
201
+ "title": ttl, "link": href or "#", "source": sname,
202
+ "ts": ts or datetime.now(timezone.utc),
203
+ "summary": _strip(desc.text if desc is not None else "")[:300],
204
+ })
205
+ return out
206
+
207
+
208
+ def _fetch_one(job):
209
+ name, url, hint = job
210
+ try:
211
+ req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0 GoshawkVortex/1.0"})
212
+ with urllib.request.urlopen(req, timeout=8) as r:
213
+ items = _parse_feed(r.read(), name)
214
+ for i in items:
215
+ i["hint"] = [hint] if hint else []
216
+ return items
217
+ except Exception:
218
+ return None
219
+
220
+
221
+ def _gurl(asset, when):
222
+ q = quote(f"({ASSET_QUERY[asset]}) crypto when:{when}")
223
+ return f"https://news.google.com/rss/search?q={q}&hl=en-US&gl=US&ceid=US:en"
224
+
225
+
226
+ def _gurl_general(q, when):
227
+ return f"https://news.google.com/rss/search?q={quote(q + ' when:' + when)}&hl=en-US&gl=US&ceid=US:en"
228
+
229
+
230
+ def fetch_news(period="Günlük", force=False):
231
+ c = _news_cache.get(period)
232
+ if c and not force and time.time() - c["t"] < NEWS_TTL:
233
+ return c["items"], c["ok"], c["n"]
234
+ when = PERIODS[period][1]
235
+ jobs = [(n, u, None) for n, u in FEEDS.items()] + [("Google", _gurl(a, when), a) for a in ASSET_QUERY] \
236
+ + [("Google", _gurl_general(q, when), None) for q in GENERAL_QUERIES]
237
+ with ThreadPoolExecutor(max_workers=32) as ex:
238
+ results = list(ex.map(_fetch_one, jobs))
239
+ items = [i for r in results if r for i in r]
240
+ ok = sum(1 for r in results if r)
241
+ if items:
242
+ _news_cache[period] = {"t": time.time(), "items": items, "ok": ok, "n": len(jobs)}
243
+ return items, ok, len(jobs)
244
+
245
+
246
+ # ------------------------------------------------------------------ DB (geçmiş birikimi)
247
+ def _id(title):
248
+ return hashlib.sha1(re.sub(r"\W+", " ", title.lower()).strip().encode()).hexdigest()[:20]
249
+
250
+
251
+ def _db():
252
+ c = sqlite3.connect(NEWS_DB, timeout=10)
253
+ c.execute("CREATE TABLE IF NOT EXISTS news(id TEXT PRIMARY KEY, title TEXT, link TEXT, source TEXT, "
254
+ "ts REAL, summary TEXT, assets TEXT, label TEXT, score REAL)")
255
+ c.execute("CREATE INDEX IF NOT EXISTS ix_ts ON news(ts)")
256
+ return c
257
+
258
+
259
+ def db_store(items):
260
+ c = _db()
261
+ c.executemany("INSERT OR IGNORE INTO news(id,title,link,source,ts,summary,assets) VALUES(?,?,?,?,?,?,?)",
262
+ [(_id(i["title"]), i["title"], i["link"], i["source"], i["ts"].timestamp(),
263
+ i["summary"], ",".join(i["assets"])) for i in items])
264
+ c.commit()
265
+ c.close()
266
+
267
+
268
+ def db_load(hours):
269
+ c = _db()
270
+ since = time.time() - hours * 3600
271
+ rows = c.execute("SELECT id,title,link,source,ts,summary,assets,label,score FROM news WHERE ts>=?", (since,)).fetchall()
272
+ c.execute("DELETE FROM news WHERE ts<?", (time.time() - 45 * 86400,)) # 45 günden eskiyi temizle
273
+ c.commit()
274
+ c.close()
275
+ return [{"id": r[0], "title": r[1], "link": r[2], "source": r[3],
276
+ "ts": datetime.fromtimestamp(r[4], timezone.utc), "summary": r[5] or "",
277
+ "assets": [a for a in (r[6] or "").split(",") if a], "label": r[7], "score": r[8]} for r in rows]
278
+
279
+
280
+ def db_label(nid, label, score):
281
+ c = _db()
282
+ c.execute("UPDATE news SET label=?, score=? WHERE id=?", (label, float(score), nid))
283
+ c.commit()
284
+ c.close()
285
+
286
+
287
+ # ------------------------------------------------------------------ HIZLI LEKSİKON (LLM yedeği)
288
+ _BULL = re.compile(r"surge|rall(y|ies)|soar|jump|gain|approv|inflow|record|adopt|bullish|breakout|all-time high|\bath\b|"
289
+ r"buy|accumulat|upgrade|partnership|launch|rebound|recover|climb|boost")
290
+ _BEAR = re.compile(r"crash|plunge|hack|exploit|\bban|lawsuit|\bsued?\b|sell-?off|outflow|liquidat|bankrupt|fraud|"
291
+ r"drop|declin|slump|tumble|bearish|breakdown|delist|scam|probe|fine[sd]?\b|fear|dump")
292
+
293
+
294
+ def quick_sentiment(text):
295
+ t = text.lower()
296
+ b, r = len(_BULL.findall(t)), len(_BEAR.findall(t))
297
+ sc = max(-1.0, min(1.0, (b - r) / (b + r + 1) * 1.4))
298
+ return ("Bullish" if sc > 0.15 else "Bearish" if sc < -0.15 else "Neutral"), sc
299
+
300
+
301
+ # ------------------------------------------------------------------ RANK
302
+ def _tokens(t):
303
+ return {w for w in re.findall(r"[a-z0-9]+", t.lower()) if len(w) > 3}
304
+
305
+
306
+ def rank_news(items, limit=12, max_age_h=48, half=12):
307
+ now = datetime.now(timezone.utc)
308
+ items = [i for i in items if (now - i["ts"]).total_seconds() < max_age_h * 3600]
309
+ items.sort(key=lambda i: i["ts"], reverse=True)
310
+ clusters = []
311
+ index = {} # anahtar kelime -> küme numaraları (binlerce haberde O(n^2) taramayı önler)
312
+ for it in items:
313
+ tk = _tokens(it["title"])
314
+ keys = sorted(tk, key=lambda w: (-len(w), w))[:4]
315
+ cand = {ci for k in keys for ci in index.get(k, ())}
316
+ for ci in sorted(cand):
317
+ c = clusters[ci]
318
+ if tk and len(tk & c["tk"]) / len(tk | c["tk"]) >= 0.5:
319
+ c["srcs"].add(it["source"])
320
+ c["n"] += 1
321
+ break
322
+ else:
323
+ clusters.append({"it": it, "tk": tk, "srcs": {it["source"]}, "n": 1})
324
+ for k in keys:
325
+ index.setdefault(k, []).append(len(clusters) - 1)
326
+ ranked = []
327
+ for c in clusters:
328
+ it = c["it"]
329
+ text = (it["title"] + " " + it["summary"]).lower()
330
+ kw = sum(w for rx, w in _KW if rx.search(text))
331
+ age_h = (now - it["ts"]).total_seconds() / 3600
332
+ rec = 0.5 ** (age_h / half)
333
+ imp = (kw + 1.5 * (len(c["srcs"]) - 1)) * (0.35 + 0.65 * rec)
334
+ if kw == 0 and len(c["srcs"]) == 1:
335
+ imp *= 0.3
336
+ ranked.append({**it, "srcs": sorted(c["srcs"]), "imp": min(100, imp * 10), "age_h": age_h})
337
+ ranked.sort(key=lambda r: r["imp"], reverse=True)
338
+ return ranked[:limit]
339
+
340
+
341
+ # ------------------------------------------------------------------ PRICE
342
+ def price_series(asset, timeframe):
343
+ k = (asset, timeframe)
344
+ c = _price_cache.get(k)
345
+ if c and time.time() - c[0] < NEWS_TTL:
346
+ return c[1]
347
+ rows = _price_series(asset, timeframe)
348
+ _price_cache[k] = (time.time(), rows)
349
+ return rows
350
+
351
+
352
+ def _price_series(asset, timeframe):
353
+ import yfinance as yf # lazy: sadece grafik için
354
+ period, interval = TIMEFRAMES.get(timeframe, TIMEFRAMES["1A"])
355
+ df = yf.Ticker(YF_SYMBOL.get(asset, f"{asset}-USD")).history(period=period, interval=interval)
356
+ rows = []
357
+ for ts, r in df.iterrows():
358
+ if r["Close"] == r["Close"]:
359
+ rows.append((ts.to_pydatetime(), float(r["Open"]), float(r["High"]),
360
+ float(r["Low"]), float(r["Close"]), float(r.get("Volume", 0) or 0)))
361
+ return rows
362
+
363
+
364
+ def _fmt(v):
365
+ if v >= 1000:
366
+ return f"{v:,.0f}"
367
+ if v >= 1:
368
+ return f"{v:,.2f}"
369
+ return f"{v:.5f}"
370
+
371
+
372
+ # ------------------------------------------------------------------ SVG
373
+ def svg_candles(rows, asset, timeframe, marks=()):
374
+ """Mum grafiği + hacim + SMA20 + son fiyat etiketi. marks: [(datetime, renk)] haber işaretleri."""
375
+ if len(rows) < 3:
376
+ return "<div class='nw-empty'>Fiyat verisi alınamadı.</div>"
377
+ W, H, L, R, T = 780, 380, 62, 14, 16
378
+ PB, VT, VB = 270, 286, 350
379
+ n = len(rows)
380
+ lo = min(r[3] for r in rows)
381
+ hi = max(r[2] for r in rows)
382
+ pad = (hi - lo) * 0.06 or hi * 0.01
383
+ lo, hi = lo - pad, hi + pad
384
+ vmax = max(r[5] for r in rows) or 1
385
+ cw = (W - L - R) / n
386
+ px = lambda i: L + cw * (i + 0.5)
387
+ py = lambda v: T + (PB - T) * (1 - (v - lo) / (hi - lo))
388
+ up = rows[-1][4] >= rows[0][1]
389
+ line = "#22c55e" if up else "#ef4444"
390
+ o = [f"<svg class='nw-svg' viewBox='0 0 {W} {H}' xmlns='http://www.w3.org/2000/svg'>",
391
+ "<defs><linearGradient id='nwa' x1='0' y1='0' x2='0' y2='1'>"
392
+ f"<stop offset='0' stop-color='{line}' stop-opacity='.28'/>"
393
+ f"<stop offset='1' stop-color='{line}' stop-opacity='0'/></linearGradient></defs>"]
394
+ for k in range(6):
395
+ v = lo + (hi - lo) * k / 5
396
+ y = py(v)
397
+ o.append(f"<line x1='{L}' x2='{W-R}' y1='{y:.1f}' y2='{y:.1f}' stroke='#94a3b8' stroke-opacity='.12'/>")
398
+ o.append(f"<text x='{L-8}' y='{y+4:.1f}' text-anchor='end' fill='#8295ad' font-size='11'>{_fmt(v)}</text>")
399
+ # alan dolgusu (kapanış)
400
+ pts = " ".join(f"{px(i):.1f},{py(r[4]):.1f}" for i, r in enumerate(rows))
401
+ o.append(f"<polygon points='{px(0):.1f},{PB} {pts} {px(n-1):.1f},{PB}' fill='url(#nwa)'/>")
402
+ # hacim + mumlar
403
+ for i, (ts, op, h, l, c, v) in enumerate(rows):
404
+ col = "#22c55e" if c >= op else "#ef4444"
405
+ x = px(i)
406
+ bw = max(1.2, cw * 0.62)
407
+ vh = (VB - VT) * (v / vmax)
408
+ o.append(f"<rect x='{x-bw/2:.1f}' y='{VB-vh:.1f}' width='{bw:.1f}' height='{vh:.1f}' fill='{col}' opacity='.35'/>")
409
+ y1, y2 = py(max(op, c)), py(min(op, c))
410
+ tip = f"{ts:%d %b %H:%M} O {_fmt(op)} H {_fmt(h)} L {_fmt(l)} C {_fmt(c)}"
411
+ 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'/>"
412
+ 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>")
413
+ # SMA20
414
+ if n >= 20:
415
+ sma = [(i, sum(r[4] for r in rows[i-19:i+1]) / 20) for i in range(19, n)]
416
+ o.append("<polyline fill='none' stroke='#60a5fa' stroke-width='2' opacity='.9' points='"
417
+ + " ".join(f"{px(i):.1f},{py(v):.1f}" for i, v in sma) + "'/>")
418
+ # haber işaretleri (zamana en yakın muma)
419
+ for mts, mcol, mtitle in marks:
420
+ try:
421
+ idx = min(range(n), key=lambda i: abs((rows[i][0].replace(tzinfo=None) - mts.replace(tzinfo=None)).total_seconds()))
422
+ if abs((rows[idx][0].replace(tzinfo=None) - mts.replace(tzinfo=None)).total_seconds()) > 3 * 86400:
423
+ continue
424
+ except Exception:
425
+ continue
426
+ x = px(idx)
427
+ 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>")
428
+ # son fiyat etiketi
429
+ last = rows[-1][4]
430
+ y = py(last)
431
+ o.append(f"<line x1='{L}' x2='{W-R}' y1='{y:.1f}' y2='{y:.1f}' stroke='{line}' stroke-dasharray='4 4' opacity='.8'/>")
432
+ o.append(f"<rect x='{W-R-70}' y='{y-10:.1f}' width='70' height='20' rx='5' fill='{line}'/>"
433
+ f"<text x='{W-R-35}' y='{y+4:.1f}' text-anchor='middle' fill='#fff' font-size='11' font-weight='700'>{_fmt(last)}</text>")
434
+ # x ekseni
435
+ fmt = "%H:%M" if timeframe == "1G" else "%d %b"
436
+ for k in range(5):
437
+ i = int((n - 1) * k / 4)
438
+ o.append(f"<text x='{px(i):.1f}' y='{H-8}' text-anchor='middle' fill='#8295ad' font-size='11'>{rows[i][0]:{fmt}}</text>")
439
+ o.append("</svg>")
440
+ chg = (last / rows[0][1] - 1) * 100
441
+ head = (f"<div class='nw-head'><b>{asset}/USD</b><span class='nw-price'>{_fmt(last)}</span>"
442
+ f"<span style='color:{line}'>{chg:+.2f}%</span><span class='nw-dim'>{timeframe} • mum + hacim + SMA20 • ▲ haber</span></div>")
443
+ return head + "".join(o)
444
+
445
+
446
+ def svg_donut(counts, net):
447
+ tot = sum(counts.values()) or 1
448
+ r, C = 52, 2 * 3.14159265 * 52
449
+ o = ["<svg class='nw-donut' viewBox='0 0 140 140' xmlns='http://www.w3.org/2000/svg'>",
450
+ "<circle cx='70' cy='70' r='52' fill='none' stroke='#172235' stroke-width='16'/>"]
451
+ off = 0
452
+ for k in ("Bullish", "Neutral", "Bearish"):
453
+ ln = C * counts[k] / tot
454
+ if ln > 0:
455
+ o.append(f"<circle cx='70' cy='70' r='52' fill='none' stroke='{COL[k]}' stroke-width='16' "
456
+ f"stroke-dasharray='{ln:.2f} {C-ln:.2f}' stroke-dashoffset='{-off:.2f}' transform='rotate(-90 70 70)'/>")
457
+ off += ln
458
+ c = "#22c55e" if net > 0.05 else "#ef4444" if net < -0.05 else "#eab308"
459
+ o.append(f"<text x='70' y='68' text-anchor='middle' fill='{c}' font-size='24' font-weight='800'>{net:+.2f}</text>"
460
+ "<text x='70' y='86' text-anchor='middle' fill='#8295ad' font-size='9' letter-spacing='1'>NET SKOR</text></svg>")
461
+ return "".join(o)
462
+
463
+
464
+ def svg_compare(series, timeframe):
465
+ """Normalize % getiri karşılaştırması (ALL modu)."""
466
+ series = {k: v for k, v in series.items() if len(v) > 3}
467
+ if not series:
468
+ return "<div class='nw-empty'>Fiyat verisi alınamadı.</div>"
469
+ W, H, L, R, T, B = 780, 300, 52, 110, 14, 28
470
+ norm = {k: [(r[0], (r[4] / v[0][1] - 1) * 100) for r in v] for k, v in series.items()}
471
+ allv = [p for v in norm.values() for _, p in v]
472
+ lo, hi = min(allv + [0]), max(allv + [0])
473
+ pad = (hi - lo) * 0.08 or 1
474
+ lo, hi = lo - pad, hi + pad
475
+ py = lambda v: T + (H - T - B) * (1 - (v - lo) / (hi - lo))
476
+ o = [f"<svg class='nw-svg' viewBox='0 0 {W} {H}' xmlns='http://www.w3.org/2000/svg'>"]
477
+ for k in range(6):
478
+ v = lo + (hi - lo) * k / 5
479
+ o.append(f"<line x1='{L}' x2='{W-R}' y1='{py(v):.1f}' y2='{py(v):.1f}' stroke='#94a3b8' stroke-opacity='.12'/>"
480
+ f"<text x='{L-8}' y='{py(v)+4:.1f}' text-anchor='end' fill='#8295ad' font-size='11'>{v:+.0f}%</text>")
481
+ 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'/>")
482
+ first = next(iter(norm.values()))
483
+ ends = []
484
+ for ci, (k, v) in enumerate(norm.items()):
485
+ col = LINES[ci % len(LINES)]
486
+ n = len(v)
487
+ pts = " ".join(f"{L + (W-L-R)*i/(n-1):.1f},{py(p):.1f}" for i, (_, p) in enumerate(v))
488
+ o.append(f"<polyline fill='none' stroke='{col}' stroke-width='2.4' stroke-linejoin='round' points='{pts}'/>")
489
+ ends.append([py(v[-1][1]), col, f"{k} {v[-1][1]:+.1f}%"])
490
+ ends.sort()
491
+ for j in range(1, len(ends)): # etiket çakışmasını önle
492
+ ends[j][0] = max(ends[j][0], ends[j-1][0] + 14)
493
+ for y, col, txt in ends:
494
+ o.append(f"<text x='{W-R+8}' y='{y+4:.1f}' fill='{col}' font-size='11' font-weight='700'>{txt}</text>")
495
+ fmt = "%H:%M" if timeframe == "1G" else "%d %b"
496
+ for k in range(5):
497
+ i = int((len(first) - 1) * k / 4)
498
+ 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>")
499
+ o.append("</svg>")
500
+ 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)
501
+
502
+
503
+ def svg_assets(stats, top=15):
504
+ """stats: {asset: (haber_sayısı, net_skor|None)} -> yatay çubuklar."""
505
+ rows = sorted([(a, n, s) for a, (n, s) in stats.items() if n > 0], key=lambda x: -x[1])[:top]
506
+ if not rows:
507
+ return "<div class='nw-empty'>Bu dönemde varlık bazlı haber bulunamadı.</div>"
508
+ mx = max(n for _, n, _ in rows)
509
+ H = 12 + 28 * len(rows)
510
+ o = [f"<svg class='nw-svg' viewBox='0 0 780 {H}' xmlns='http://www.w3.org/2000/svg'>"]
511
+ for i, (a, n, sc) in enumerate(rows):
512
+ y = 8 + 28 * i
513
+ col = "#475569" if sc is None else "#22c55e" if sc > 0.05 else "#ef4444" if sc < -0.05 else "#eab308"
514
+ w = 480 * n / mx
515
+ o.append(f"<text x='8' y='{y+15}' fill='#dbeafe' font-size='12' font-weight='700'>{a}</text>"
516
+ f"<rect x='60' y='{y+2}' width='480' height='18' rx='9' fill='#172235'/>"
517
+ f"<rect x='60' y='{y+2}' width='{max(w, 6):.0f}' height='18' rx='9' fill='{col}'><title>{a}: {n} haber</title></rect>"
518
+ f"<text x='552' y='{y+15}' fill='#94a3b8' font-size='11'>{n} haber • "
519
+ f"{'skor —' if sc is None else f'skor {sc:+.2f}'}</text>")
520
+ o.append("</svg>")
521
+ 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)
522
+
523
+
524
+ def svg_timeline(items, hours):
525
+ """Zamana göre ortalama duygu (sınıflandırılmış haberler)."""
526
+ nb = 6 if hours <= 24 else 30
527
+ step = hours * 3600 / nb
528
+ end = time.time()
529
+ b = [[0.0, 0] for _ in range(nb)]
530
+ for it in items:
531
+ if it.get("label") is None:
532
+ continue
533
+ k = int((it["ts"].timestamp() - (end - hours * 3600)) // step)
534
+ if 0 <= k < nb:
535
+ b[k][0] += it["score"]
536
+ b[k][1] += 1
537
+ if not any(n for _, n in b):
538
+ return ""
539
+ W, H, L, mid = 780, 170, 40, 78
540
+ bw = (W - L - 10) / nb
541
+ o = [f"<svg class='nw-svg' viewBox='0 0 {W} {H}' xmlns='http://www.w3.org/2000/svg'>",
542
+ f"<line x1='{L}' x2='{W-10}' y1='{mid}' y2='{mid}' stroke='#e2e8f0' stroke-opacity='.35'/>"]
543
+ for k, (sm, n) in enumerate(b):
544
+ t0 = datetime.fromtimestamp(end - hours * 3600 + k * step)
545
+ if n:
546
+ v = sm / n
547
+ h = abs(v) * 62
548
+ col = "#22c55e" if v >= 0 else "#ef4444"
549
+ y = mid - h if v >= 0 else mid
550
+ 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}' "
551
+ f"opacity='{min(1, .4 + n/6):.2f}'><title>{t0:%d %b %H:%M} • {n} haber • skor {v:+.2f}</title></rect>")
552
+ if k % max(1, nb // 6) == 0:
553
+ fmt = "%H:%M" if hours <= 24 else "%d %b"
554
+ 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>")
555
+ 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>")
556
+ return ("<div class='nw-head'><b>Duygu zaman çizgisi</b><span class='nw-dim'>sınıflandırılan haberlerin ortalaması • "
557
+ "koyuluk = haber adedi</span></div>" + "".join(o))
558
+
559
+
560
+ # ------------------------------------------------------------------ PANEL
561
+ NEWS_CSS = """
562
+ .nw-wrap { margin-top:12px; color:#dbeafe; }
563
+ .nw-card, .nw-news { padding:18px; border-radius:18px; margin-bottom:14px;
564
+ background:linear-gradient(180deg, rgba(8,18,34,.9), rgba(5,13,25,.85));
565
+ border:1px solid rgba(96,165,250,.18); box-shadow:0 14px 36px rgba(0,0,0,.25); }
566
+ .nw-head { display:flex; gap:14px; align-items:baseline; flex-wrap:wrap; margin-bottom:6px; }
567
+ .nw-price { font-size:26px; font-weight:800; }
568
+ .nw-dim { color:#64748b; font-size:11px; margin-left:auto; }
569
+ .nw-svg { width:100%; height:auto; display:block; }
570
+ .nw-grid { display:grid; grid-template-columns:160px 1fr; gap:18px; align-items:center; }
571
+ .nw-donut { width:150px; height:150px; }
572
+ .nw-legend span { display:inline-block; margin:3px 14px 3px 0; font-size:12px; }
573
+ .nw-dot { display:inline-block; width:9px; height:9px; border-radius:50%; margin-right:6px; }
574
+ .nw-empty { color:#8295ad; padding:18px; }
575
+
576
+ /* ---- canlı şerit (ticker) ---- */
577
+ .nw-ticker { overflow:hidden; border-radius:14px; margin-bottom:14px; padding:10px 0;
578
+ background:linear-gradient(90deg, rgba(8,18,34,.95), rgba(10,28,50,.9));
579
+ border:1px solid rgba(96,165,250,.22); position:relative; }
580
+ .nw-ticker:before, .nw-ticker:after { content:""; position:absolute; top:0; bottom:0; width:60px; z-index:2; pointer-events:none; }
581
+ .nw-ticker:before { left:0; background:linear-gradient(90deg,#08121f,transparent); }
582
+ .nw-ticker:after { right:0; background:linear-gradient(270deg,#08121f,transparent); }
583
+ .nw-track { display:inline-flex; white-space:nowrap; gap:34px; padding-left:100%; animation:nwScroll 140s linear infinite; }
584
+ .nw-ticker:hover .nw-track { animation-play-state:paused; }
585
+ .nw-track a { color:#cbd5e1; font-size:13px; text-decoration:none; }
586
+ .nw-track a:hover { color:#fff; text-decoration:underline; }
587
+ .nw-live { display:inline-block; width:8px; height:8px; border-radius:50%; margin-right:8px; background:#ef4444;
588
+ box-shadow:0 0 0 0 rgba(239,68,68,.7); animation:nwPing 1.6s infinite; }
589
+ @keyframes nwScroll { to { transform:translateX(-100%); } }
590
+ @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); } }
591
+
592
+ /* ---- istatistik kutuları ---- */
593
+ .nw-stats { display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:12px; margin-bottom:14px; }
594
+ .nw-stat { padding:14px 16px; border-radius:16px; position:relative; overflow:hidden;
595
+ background:linear-gradient(135deg, rgba(14,30,54,.92), rgba(7,16,30,.9));
596
+ border:1px solid rgba(96,165,250,.2); transition:transform .2s, box-shadow .2s; animation:nwUp .5s ease both; }
597
+ .nw-stat:hover { transform:translateY(-3px); box-shadow:0 10px 26px rgba(96,165,250,.2); }
598
+ .nw-stat:after { content:""; position:absolute; right:-18px; top:-18px; width:70px; height:70px; border-radius:50%;
599
+ background:var(--c,#60a5fa); opacity:.14; }
600
+ .nw-stat b { display:block; font-size:28px; font-weight:800; color:var(--c,#e2e8f0); letter-spacing:-1px; }
601
+ .nw-stat span { font-size:11px; letter-spacing:1.2px; color:#8295ad; text-transform:uppercase; }
602
+
603
+ /* ---- filtre ---- */
604
+ .nw-filters { display:flex; gap:8px; flex-wrap:wrap; margin:6px 0 14px; }
605
+ .nw-filters input { display:none; }
606
+ .nw-filters label { cursor:pointer; padding:7px 16px; border-radius:99px; font-size:12px; font-weight:700;
607
+ background:#0f1d33; color:#9fb0c6; border:1px solid rgba(96,165,250,.25); transition:all .15s; user-select:none; }
608
+ .nw-filters label:hover { transform:translateY(-2px); color:#fff; }
609
+ .nw-wrap:has(#nwf-all:checked) label[for=nwf-all],
610
+ .nw-wrap:has(#nwf-bull:checked) label[for=nwf-bull],
611
+ .nw-wrap:has(#nwf-neu:checked) label[for=nwf-neu],
612
+ .nw-wrap:has(#nwf-bear:checked) label[for=nwf-bear],
613
+ .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); }
614
+ .nw-wrap:has(#nwf-bull:checked) .nw-news:not([data-l=Bullish]),
615
+ .nw-wrap:has(#nwf-neu:checked) .nw-news:not([data-l=Neutral]),
616
+ .nw-wrap:has(#nwf-bear:checked) .nw-news:not([data-l=Bearish]),
617
+ .nw-wrap:has(#nwf-hot:checked) .nw-news:not([data-hot="1"]) { display:none; }
618
+
619
+ /* ---- haber kartları ---- */
620
+ .nw-list { display:grid; grid-template-columns:repeat(auto-fill,minmax(340px,1fr)); gap:12px; }
621
+ .nw-news { border-left:4px solid var(--c); padding:14px 16px; margin:0; position:relative; overflow:hidden;
622
+ transition:transform .2s ease, box-shadow .2s ease, border-color .2s; animation:nwUp .55s ease both;
623
+ animation-delay:calc(var(--i) * 28ms); display:flex; flex-direction:column; gap:8px; }
624
+ .nw-news:before { content:""; position:absolute; inset:0; pointer-events:none; opacity:0; transition:opacity .25s;
625
+ background:radial-gradient(circle at 0% 0%, var(--c), transparent 55%); }
626
+ .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); }
627
+ .nw-news:hover:before { opacity:.12; }
628
+ .nw-news a { color:#e8eef9; font-weight:650; text-decoration:none; line-height:1.4; font-size:14.5px; position:relative; }
629
+ .nw-news a:hover { color:#93c5fd; }
630
+ .nw-sum { color:#8b9bb3; font-size:12px; line-height:1.45; position:relative;
631
+ display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; }
632
+ .nw-top { display:flex; align-items:center; gap:8px; position:relative; }
633
+ .nw-av { width:24px; height:24px; border-radius:8px; display:grid; place-items:center; font-size:11px; font-weight:800;
634
+ color:#fff; background:hsl(var(--h),55%,38%); flex:none; }
635
+ .nw-src { font-size:11.5px; color:#a9b8cf; font-weight:600; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
636
+ .nw-badge { margin-left:auto; font-size:10px; font-weight:800; letter-spacing:.6px; padding:2px 8px; border-radius:99px; white-space:nowrap; }
637
+ .nw-hot { background:linear-gradient(90deg,#f97316,#ef4444); color:#fff; animation:nwGlow 1.8s ease-in-out infinite alternate; }
638
+ .nw-new { background:#38bdf8; color:#04101f; }
639
+ @keyframes nwGlow { to { box-shadow:0 0 14px rgba(249,115,22,.9); } }
640
+ @keyframes nwUp { from { opacity:0; transform:translateY(14px); } to { opacity:1; transform:none; } }
641
+ .nw-meta { color:#7d8ea6; font-size:11px; display:flex; gap:8px; flex-wrap:wrap; align-items:center; position:relative; }
642
+ .nw-chip { padding:2px 9px; border-radius:99px; font-weight:700; font-size:11px; background:var(--c); color:#06101f; }
643
+ .nw-imp { width:70px; height:6px; border-radius:9px; background:#172235; overflow:hidden; margin-left:auto; }
644
+ .nw-imp i { display:block; height:100%; background:linear-gradient(90deg,#60a5fa,#f97316); }
645
+ .nw-tag { padding:1px 7px; border-radius:6px; background:#1e293b; color:#93c5fd; font-weight:700; }
646
+ .nw-gauge { width:100%; max-width:230px; }
647
+ /* ---- toplu tarama ---- */
648
+ .nw-heat { display:grid; grid-template-columns:repeat(auto-fill,minmax(150px,1fr)); gap:10px; margin-bottom:14px; }
649
+ .nw-hcell { padding:10px 12px; border-radius:14px; border:1px solid rgba(96,165,250,.2); position:relative; overflow:hidden;
650
+ background:linear-gradient(135deg, color-mix(in srgb, var(--c) 28%, #0a1424), #07101e); transition:transform .15s; }
651
+ .nw-hcell:hover { transform:translateY(-3px); }
652
+ .nw-hcell b { font-size:15px; letter-spacing:.5px; }
653
+ .nw-hcell i { font-style:normal; float:right; font-weight:800; color:var(--c); font-size:14px; }
654
+ .nw-hcell span { display:block; font-size:11px; color:#8295ad; margin-top:2px; }
655
+ .nw-mini { display:flex; height:5px; border-radius:9px; overflow:hidden; margin-top:6px; background:#172235; }
656
+ .nw-mini u { display:block; height:100%; text-decoration:none; }
657
+ .nw-ast { border-radius:14px; margin-bottom:8px; border:1px solid rgba(96,165,250,.16); border-left:4px solid var(--c);
658
+ background:linear-gradient(180deg, rgba(8,18,34,.9), rgba(5,13,25,.85)); }
659
+ .nw-ast > summary { cursor:pointer; list-style:none; padding:11px 14px; display:flex; gap:12px; align-items:center; flex-wrap:wrap; }
660
+ .nw-ast > summary::-webkit-details-marker { display:none; }
661
+ .nw-ast > summary b { min-width:62px; font-size:15px; }
662
+ .nw-ast .nw-body { padding:2px 14px 12px; display:flex; flex-direction:column; gap:6px; }
663
+ .nw-ast .nw-body a { color:#dbe7fb; text-decoration:none; font-size:13.5px; line-height:1.4; }
664
+ .nw-ast .nw-body a:hover { color:#93c5fd; text-decoration:underline; }
665
+ .nw-ast .nw-row { display:flex; gap:8px; align-items:baseline; }
666
+ .nw-ast .nw-row small { color:#7d8ea6; white-space:nowrap; }
667
+ @media (max-width:640px){ .nw-grid { grid-template-columns:1fr; justify-items:center; } .nw-list { grid-template-columns:1fr; } }
668
+ """
669
+
670
+
671
+ def svg_gauge(net):
672
+ """Yarım daire piyasa ruh hali ibresi (-1..+1)."""
673
+ import math
674
+ ang = math.pi * (1 - (max(-1, min(1, net)) + 1) / 2) # pi (sol) .. 0 (sağ)
675
+ cx, cy, r = 100, 100, 78
676
+ nx, ny = cx + (r - 14) * math.cos(ang), cy - (r - 14) * math.sin(ang)
677
+ segs = [("#ef4444", 180, 120), ("#f97316", 120, 90), ("#eab308", 90, 60), ("#84cc16", 60, 30), ("#22c55e", 30, 0)]
678
+ o = ["<svg class='nw-gauge' viewBox='0 0 200 122' xmlns='http://www.w3.org/2000/svg'>"]
679
+ for col, a0, a1 in segs:
680
+ x0, y0 = cx + r * math.cos(math.radians(a0)), cy - r * math.sin(math.radians(a0))
681
+ x1, y1 = cx + r * math.cos(math.radians(a1)), cy - r * math.sin(math.radians(a1))
682
+ 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'/>")
683
+ o.append(f"<line x1='{cx}' y1='{cy}' x2='{nx:.1f}' y2='{ny:.1f}' stroke='#f8fafc' stroke-width='3' stroke-linecap='round'>"
684
+ f"<animateTransform attributeName='transform' type='rotate' from='-90 {cx} {cy}' to='0 {cx} {cy}' dur='1.1s' fill='freeze'/></line>"
685
+ f"<circle cx='{cx}' cy='{cy}' r='7' fill='#f8fafc'/>"
686
+ "<text x='14' y='118' fill='#ef4444' font-size='9' font-weight='700'>KORKU</text>"
687
+ "<text x='186' y='118' text-anchor='end' fill='#22c55e' font-size='9' font-weight='700'>HIRS</text></svg>")
688
+ return "".join(o)
689
+
690
+
691
+ def _mood(net):
692
+ return ("Aşırı iyimser" if net > .5 else "İyimser" if net > .15 else "Dengeli" if net > -.15
693
+ else "Temkinli" if net > -.5 else "Aşırı kötümser")
694
+
695
+
696
+ def _ingest(detect_assets, period):
697
+ """Kaynakları tara, varlıkları etiketle, DB'ye yaz; (pencere, ok, toplam, saat, yarı_ömür, taze_sayısı) döndür."""
698
+ hours, _, half = PERIODS.get(period, PERIODS["Günlük"])
699
+ fresh, ok, total = fetch_news(period)
700
+ if not fresh and not db_load(hours):
701
+ return None
702
+ for i in fresh:
703
+ i["assets"] = sorted(set(i.get("hint", [])) | set(detect_assets(i["title"])))
704
+ db_store(fresh)
705
+ return db_load(hours), ok, total, hours, half, len(fresh)
706
+
707
+
708
+ def build_news_panel(classify, detect_assets, asset="ALL", timeframe="1A", period="Günlük", limit=None, query=None):
709
+ limit = int(limit or DEFAULT_LIMIT.get(period, 30))
710
+ got = _ingest(detect_assets, period)
711
+ if got is None:
712
+ return ("<div class='nw-card nw-empty'>Haber kaynaklarına ulaşılamadı. "
713
+ "Ağ erişimini veya FEEDS adreslerini kontrol et.</div>")
714
+ window, ok, total, hours, half, _ = got
715
+
716
+ stats = {}
717
+ for a in ASSET_QUERY:
718
+ mine = [i for i in window if a in i["assets"]]
719
+ lab = [i["score"] for i in mine if i["label"] is not None]
720
+ stats[a] = (len(mine), sum(lab) / len(lab) if lab else None)
721
+
722
+ pool = window if asset == "ALL" else [i for i in window if asset in i["assets"]]
723
+ if query and query.strip():
724
+ qwords = [w for w in re.findall(r"[a-z0-9$]+", query.lower()) if w]
725
+ pool = [i for i in pool
726
+ if all(w in (i["title"] + " " + i["summary"]).lower() for w in qwords)]
727
+ top = rank_news(pool, limit=limit, max_age_h=hours, half=half)
728
+
729
+ # LLM ile en önemli (etiketsiz) haberleri etiketle; kalanlar için hızlı leksikon
730
+ done = 0
731
+ for t in top:
732
+ if t.get("label") is None:
733
+ if done < MAX_CLASSIFY:
734
+ try:
735
+ t["label"], t["score"] = classify(t["title"] + ". " + t["summary"][:200])
736
+ except Exception:
737
+ t["label"], t["score"] = "Neutral", 0.0
738
+ db_label(t["id"], t["label"], t["score"])
739
+ done += 1
740
+ for w in pool:
741
+ if w["id"] == t["id"]:
742
+ w["label"], w["score"] = t["label"], t["score"]
743
+ else:
744
+ t["label"], t["score"] = quick_sentiment(t["title"] + " " + t["summary"])
745
+ t["approx"] = True
746
+
747
+ counts = {"Bullish": 0, "Neutral": 0, "Bearish": 0}
748
+ wsum = nsum = 0.0
749
+ for t in top:
750
+ counts[t["label"] if t["label"] in counts else "Neutral"] += 1
751
+ w = 1 + t["imp"] / 25
752
+ wsum += w
753
+ nsum += w * t["score"]
754
+ net = nsum / wsum if wsum else 0.0
755
+
756
+ if asset == "ALL":
757
+ hot_syms = [a for a, (n, _) in sorted(stats.items(), key=lambda kv: -kv[1][0]) if n > 0][:5]
758
+ syms = hot_syms if len(hot_syms) >= 3 else MAJORS
759
+ with ThreadPoolExecutor(max_workers=5) as ex:
760
+ res = list(ex.map(lambda a: (a, _safe_price(a, timeframe)), syms))
761
+ chart = f"<div class='nw-card'>{svg_compare({a: r for a, r in res if r}, timeframe)}</div>"
762
+ chart += f"<div class='nw-card'>{svg_assets(stats)}</div>"
763
+ else:
764
+ try:
765
+ rows = price_series(asset, timeframe)
766
+ marks = [(t["ts"], COL.get(t["label"], "#eab308"), t["title"]) for t in top]
767
+ chart = f"<div class='nw-card'>{svg_candles(rows, asset, timeframe, marks)}</div>"
768
+ except Exception as e:
769
+ chart = f"<div class='nw-card nw-empty'>Fiyat grafiği yüklenemedi: {html.escape(str(e))[:120]}</div>"
770
+ tl = svg_timeline(pool, hours)
771
+ if tl:
772
+ chart += f"<div class='nw-card'>{tl}</div>"
773
+
774
+ # ---- üst şerit + istatistik kutuları
775
+ tick = "".join(
776
+ f"<a href='{html.escape(t['link'])}' target='_blank' rel='noopener'>"
777
+ f"<i class='nw-dot' style='background:{COL.get(t['label'], '#eab308')}'></i>{html.escape(t['title'])}</a>"
778
+ for t in top[:15])
779
+ ticker = (f"<div class='nw-ticker'><div class='nw-track'><span style='color:#f87171;font-weight:800;font-size:12px'>"
780
+ f"<i class='nw-live'></i>CANLI</span>{tick}</div></div>") if top else ""
781
+ n_src = len({s for t in top for s in t["srcs"]})
782
+ hot = sum(1 for t in top if t["imp"] >= 50)
783
+ bull_pct = 100 * counts["Bullish"] / max(1, len(top))
784
+ tiles = (f"<div class='nw-stats'>"
785
+ f"<div class='nw-stat' style='--c:#60a5fa'><b>{len(pool)}</b><span>Taranan haber</span></div>"
786
+ f"<div class='nw-stat' style='--c:#a78bfa'><b>{n_src}</b><span>Aktif kaynak</span></div>"
787
+ f"<div class='nw-stat' style='--c:#f97316'><b>{hot}</b><span>🔥 Sıcak haber</span></div>"
788
+ f"<div class='nw-stat' style='--c:#22c55e'><b>%{bull_pct:.0f}</b><span>Yükseliş payı</span></div></div>")
789
+
790
+ legend = "".join(f"<span><i class='nw-dot' style='background:{COL[k]}'></i>{k} {counts[k]}</span>" for k in COL)
791
+ summary = (f"<div class='nw-card nw-grid'>{svg_donut(counts, net)}<div>"
792
+ f"<b>{period} haber duygu özeti • {asset}</b> <span class='nw-dim'>önem ağırlıklı • {len(top)}/{len(pool)} haber • "
793
+ f"{ok}/{total} kaynak</span><div class='nw-legend' style='margin-top:8px'>{legend}</div></div></div>")
794
+ mood = (f"<div class='nw-card' style='text-align:center'><b>Piyasa ruh hali</b><div style='display:grid;place-items:center'>"
795
+ f"{svg_gauge(net)}</div><div style='font-size:20px;font-weight:800;margin-top:-4px'>{_mood(net)}</div></div>")
796
+
797
+ filters = ("<div class='nw-filters'>"
798
+ "<input type='radio' name='nwf' id='nwf-all' checked><label for='nwf-all'>Tümü</label>"
799
+ "<input type='radio' name='nwf' id='nwf-hot'><label for='nwf-hot'>🔥 Sıcak</label>"
800
+ "<input type='radio' name='nwf' id='nwf-bull'><label for='nwf-bull'>🟢 Bullish</label>"
801
+ "<input type='radio' name='nwf' id='nwf-neu'><label for='nwf-neu'>🟡 Neutral</label>"
802
+ "<input type='radio' name='nwf' id='nwf-bear'><label for='nwf-bear'>🔴 Bearish</label></div>")
803
+
804
+ cards = []
805
+ for idx, t in enumerate(top):
806
+ c = COL.get(t["label"], "#eab308")
807
+ 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"
808
+ tags = " ".join(f"<span class='nw-tag'>{a}</span>" for a in t["assets"][:3])
809
+ src = t["srcs"][0] if t["srcs"] else t["source"]
810
+ hue = int(hashlib.md5(src.encode()).hexdigest()[:4], 16) % 360
811
+ is_hot = t["imp"] >= 50
812
+ badge = ("<span class='nw-badge nw-hot'>🔥 SICAK</span>" if is_hot
813
+ else "<span class='nw-badge nw-new'>YENİ</span>" if t["age_h"] < 1 else "")
814
+ more = f" +{len(t['srcs'])-1}" if len(t["srcs"]) > 1 else ""
815
+ summ = f"<div class='nw-sum'>{html.escape(t['summary'])}</div>" if t["summary"] else ""
816
+ approx = "~" if t.get("approx") else ""
817
+ cards.append(
818
+ f"<div class='nw-news' data-l='{t['label']}' data-hot='{int(is_hot)}' style='--c:{c};--h:{hue};--i:{min(idx, 20)}'>"
819
+ f"<div class='nw-top'><span class='nw-av'>{html.escape(src[:1].upper())}</span>"
820
+ f"<span class='nw-src'>{html.escape(src)}{more}</span>{badge}</div>"
821
+ f"<a href='{html.escape(t['link'])}' target='_blank' rel='noopener'>{html.escape(t['title'])}</a>{summ}"
822
+ f"<div class='nw-meta'><span class='nw-chip'>{approx}{t['label']} {t['score']:+.1f}</span>{tags}"
823
+ f"<span>⏱ {age} önce</span>"
824
+ f"<span class='nw-imp' title='Önem {t['imp']:.0f}/100'><i style='width:{t['imp']:.0f}%'></i></span></div></div>")
825
+ if not cards:
826
+ cards = ["<div class='nw-card nw-empty'>Bu dönem/varlık için önemli haber bulunamadı.</div>"]
827
+
828
+ note = ("<div class='nw-dim' style='margin:0 0 8px'>Aylık görünüm Google News + biriken tarama geçmişinden gelir; "
829
+ "zaman çizgisi uygulama çalıştıkça dolar. “~” işareti hızlı anahtar kelime tahminidir; sonraki taramalarda model etiketler.</div>"
830
+ if hours > 24 else
831
+ "<div class='nw-dim' style='margin:0 0 8px'>“~” işareti hızlı anahtar kelime tahminidir; sonraki taramalarda model etiketler.</div>")
832
+ stamp = datetime.now().strftime("%H:%M:%S")
833
+ return (f"<div class='nw-wrap'>{ticker}"
834
+ 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>"
835
+ f"{tiles}<div class='nw-grid' style='grid-template-columns:1fr 1fr;align-items:stretch'>{summary}{mood}</div>"
836
+ f"{chart}{note}{filters}<div class='nw-list'>{''.join(cards)}</div></div>")
837
+
838
+
839
+ def _safe_price(a, tf):
840
+ try:
841
+ return price_series(a, tf)
842
+ except Exception:
843
+ return None
844
+
845
+
846
+ # ------------------------------------------------------------------ TOPLU TARAMA
847
+ BULK_SORTS = ["Haber sayısı", "En bullish", "En bearish"]
848
+
849
+
850
+ def _lab_color(sc):
851
+ return "#22c55e" if sc > 0.05 else "#ef4444" if sc < -0.05 else "#eab308"
852
+
853
+
854
+ def build_bulk_panel(classify, detect_assets, period="Günlük", per_asset=3, budget=None, sort="Haber sayısı"):
855
+ """Tüm paritelerin haberlerini tek seferde tarar; parite başına özet + ısı haritası üretir.
856
+
857
+ budget: bu taramada LLM ile etiketlenecek en fazla başlık (kalanlar anahtar-kelime tahmini "~";
858
+ etiketler DB'de saklandığı için her taramada daha fazlası model etiketine dönüşür).
859
+ """
860
+ per_asset = max(1, int(per_asset or 3))
861
+ budget = MAX_BULK_CLASSIFY if budget is None else max(0, int(budget))
862
+ got = _ingest(detect_assets, period)
863
+ if got is None:
864
+ return ("<div class='nw-card nw-empty'>Haber kaynaklarına ulaşılamadı. "
865
+ "Ağ erişimini veya FEEDS adreslerini kontrol et.</div>")
866
+ window, ok, total, hours, half, n_fresh = got
867
+
868
+ ranked = rank_news(window, limit=10 ** 6, max_age_h=hours, half=half) # tekilleştirilmiş hikâyeler
869
+ by_asset = {a: [] for a in ASSET_LIST}
870
+ for t in ranked:
871
+ for a in t["assets"]:
872
+ if a in by_asset:
873
+ by_asset[a].append(t)
874
+ for a in by_asset: # her varlık için en önemli per_asset haber
875
+ by_asset[a].sort(key=lambda t: t["imp"], reverse=True)
876
+
877
+ # etiketleme: her paritenin en önemli haberleri, önem sırasıyla, bütçe dolana dek model ile
878
+ picks, seen = [], set()
879
+ for a in ASSET_LIST:
880
+ for t in by_asset[a][:per_asset]:
881
+ if t["id"] not in seen:
882
+ seen.add(t["id"])
883
+ picks.append(t)
884
+ picks.sort(key=lambda t: t["imp"], reverse=True)
885
+ done = 0
886
+ for t in picks:
887
+ if t.get("label") is None:
888
+ if done < budget:
889
+ try:
890
+ t["label"], t["score"] = classify(t["title"] + ". " + t["summary"][:200])
891
+ except Exception:
892
+ t["label"], t["score"] = "Neutral", 0.0
893
+ db_label(t["id"], t["label"], t["score"])
894
+ done += 1
895
+ else:
896
+ t["label"], t["score"] = quick_sentiment(t["title"] + " " + t["summary"])
897
+ t["approx"] = True
898
+
899
+ # parite istatistikleri (yalnızca gösterilen/etiketlenen haberlerden)
900
+ rows = []
901
+ for a in ASSET_LIST:
902
+ lst = by_asset[a]
903
+ if not lst:
904
+ continue
905
+ shown = lst[:per_asset]
906
+ cnt = {"Bullish": 0, "Neutral": 0, "Bearish": 0}
907
+ ws = ns = 0.0
908
+ for t in shown:
909
+ cnt[t["label"] if t["label"] in cnt else "Neutral"] += 1
910
+ w = 1 + t["imp"] / 25
911
+ ws += w
912
+ ns += w * t["score"]
913
+ rows.append({"a": a, "n": len(lst), "net": ns / ws if ws else 0.0, "cnt": cnt, "shown": shown,
914
+ "hot": sum(1 for t in lst if t["imp"] >= 50)})
915
+ if sort == "En bullish":
916
+ rows.sort(key=lambda r: (-r["net"], -r["n"]))
917
+ elif sort == "En bearish":
918
+ rows.sort(key=lambda r: (r["net"], -r["n"]))
919
+ else:
920
+ rows.sort(key=lambda r: (-r["n"], -r["net"]))
921
+
922
+ if not rows:
923
+ return "<div class='nw-card nw-empty'>Bu dönemde hiçbir parite için haber bulunamadı.</div>"
924
+
925
+ uniq_total = len(ranked)
926
+ tagged = sum(1 for t in ranked if t["assets"])
927
+ model_labeled = sum(1 for t in picks if not t.get("approx"))
928
+ tiles = (f"<div class='nw-stats'>"
929
+ f"<div class='nw-stat' style='--c:#60a5fa'><b>{len(window)}</b><span>Taranan haber</span></div>"
930
+ f"<div class='nw-stat' style='--c:#a78bfa'><b>{uniq_total}</b><span>Tekil hikâye</span></div>"
931
+ f"<div class='nw-stat' style='--c:#22c55e'><b>{len(rows)}/{len(ASSET_LIST)}</b><span>Haberi olan parite</span></div>"
932
+ f"<div class='nw-stat' style='--c:#f97316'><b>{sum(r['hot'] for r in rows)}</b><span>🔥 Sıcak haber</span></div>"
933
+ f"<div class='nw-stat' style='--c:#eab308'><b>{model_labeled}/{len(picks)}</b><span>Model etiketli</span></div></div>")
934
+
935
+ heat = "".join(
936
+ f"<div class='nw-hcell' style='--c:{_lab_color(r['net'])}'><i>{r['net']:+.2f}</i><b>{r['a']}</b>"
937
+ f"<span>{r['n']} haber • 🔥{r['hot']}</span>"
938
+ f"<div class='nw-mini'><u style='width:{100*r['cnt']['Bullish']/len(r['shown']):.0f}%;background:{COL['Bullish']}'></u>"
939
+ f"<u style='width:{100*r['cnt']['Neutral']/len(r['shown']):.0f}%;background:{COL['Neutral']}'></u>"
940
+ f"<u style='width:{100*r['cnt']['Bearish']/len(r['shown']):.0f}%;background:{COL['Bearish']}'></u></div></div>"
941
+ for r in rows)
942
+
943
+ details = []
944
+ for r in rows:
945
+ lines = []
946
+ for t in r["shown"]:
947
+ ap = "~" if t.get("approx") else ""
948
+ 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
949
+ else f"{t['age_h']/24:.0f} gün")
950
+ lines.append(
951
+ f"<div class='nw-row'><span class='nw-chip' style='--c:{COL.get(t['label'], '#eab308')}'>"
952
+ f"{ap}{t['label']} {t['score']:+.1f}</span>"
953
+ f"<a href='{html.escape(t['link'])}' target='_blank' rel='noopener'>{html.escape(t['title'])}</a>"
954
+ f"<small>{html.escape(t['srcs'][0] if t['srcs'] else t['source'])} • {age}</small></div>")
955
+ details.append(
956
+ f"<details class='nw-ast' style='--c:{_lab_color(r['net'])}'><summary><b>{r['a']}</b>"
957
+ f"<span class='nw-chip' style='--c:{_lab_color(r['net'])}'>net {r['net']:+.2f}</span>"
958
+ f"<span class='nw-dim' style='margin-left:0'>{r['n']} haber • 🟢{r['cnt']['Bullish']} "
959
+ f"🟡{r['cnt']['Neutral']} 🔴{r['cnt']['Bearish']}</span></summary>"
960
+ f"<div class='nw-body'>{''.join(lines)}</div></details>")
961
+
962
+ stamp = datetime.now().strftime("%H:%M:%S")
963
+ note = ("<div class='nw-dim' style='margin:0 0 8px'>“~” işareti hızlı anahtar kelime tahminidir. Model etiketleri DB’de saklanır; "
964
+ "her taramada daha fazla başlık modele geçer. Net skor, her paritenin en önemli haberlerinden hesaplanır.</div>")
965
+ return (f"<div class='nw-wrap'>"
966
+ f"<div class='nw-dim' style='margin:0 0 8px'>{period} toplu tarama • Son tarama: {stamp} • "
967
+ f"{ok}/{total} kaynak yanıt verdi • {n_fresh} taze haber • bu turda {done} başlık modelle etiketlendi</div>"
968
+ f"{tiles}<div class='nw-card'><div class='nw-head'><b>Parite ısı haritası</b>"
969
+ f"<span class='nw-dim'>renk = net duygu • sayı = haber adedi • çubuk = bullish/neutral/bearish</span></div>"
970
+ f"<div class='nw-heat'>{heat}</div></div>{note}{''.join(details)}</div>")
971
+
972
+
973
+ # ------------------------------------------------------------------ FİYAT PANOSU
974
+ def price_snapshot(assets, hours=24):
975
+ """Varlıklar için (sembol, son_fiyat, değişim_% , spark noktaları) döndürür."""
976
+ import yfinance as yf
977
+ out = []
978
+ for a in assets:
979
+ try:
980
+ df = yf.Ticker(YF_SYMBOL.get(a, f"{a}-USD")).history(period="2d", interval="1h")
981
+ closes = [float(c) for c in df["Close"] if c == c]
982
+ if len(closes) < 2:
983
+ continue
984
+ win = closes[-(hours + 1):]
985
+ last = win[-1]
986
+ chg = (last / win[0] - 1) * 100
987
+ out.append({"asset": a, "last": last, "chg": chg, "spark": win})
988
+ except Exception:
989
+ continue
990
+ return out
991
+
992
+
993
+ def _svg_spark(vals, w=110, h=30):
994
+ if len(vals) < 2:
995
+ return ""
996
+ lo, hi = min(vals), max(vals)
997
+ rng = (hi - lo) or (hi * 0.01 or 1)
998
+ n = len(vals)
999
+ pts = " ".join(f"{w * i / (n - 1):.1f},{h - 3 - (h - 6) * (v - lo) / rng:.1f}" for i, v in enumerate(vals))
1000
+ col = "#22c55e" if vals[-1] >= vals[0] else "#ef4444"
1001
+ return (f"<svg width='{w}' height='{h}' viewBox='0 0 {w} {h}' xmlns='http://www.w3.org/2000/svg'>"
1002
+ f"<polyline fill='none' stroke='{col}' stroke-width='2' stroke-linejoin='round' points='{pts}'/></svg>")
1003
+
1004
+
1005
+ def build_price_board(assets, hours=24):
1006
+ """İzleme listesi fiyat panosu HTML'i."""
1007
+ snap = price_snapshot(assets, hours)
1008
+ if not snap:
1009
+ return "<div class='nw-card nw-empty'>Fiyat verisi alınamadı.</div>"
1010
+ snap.sort(key=lambda s: -abs(s["chg"]))
1011
+ rows = []
1012
+ for s in snap:
1013
+ col = "#22c55e" if s["chg"] >= 0 else "#ef4444"
1014
+ arrow = "▲" if s["chg"] >= 0 else "▼"
1015
+ rows.append(
1016
+ f"<tr><td style='font-weight:800'>{s['asset']}/USD</td>"
1017
+ f"<td style='text-align:right;font-weight:700'>{_fmt(s['last'])}</td>"
1018
+ f"<td style='text-align:right;color:{col};font-weight:800'>{arrow} {s['chg']:+.2f}%</td>"
1019
+ f"<td style='text-align:right'>{_svg_spark(s['spark'])}</td></tr>")
1020
+ gainers = sum(1 for s in snap if s["chg"] > 0)
1021
+ stamp = datetime.now().strftime("%H:%M:%S")
1022
+ return (f"<div class='nw-wrap'><div class='nw-dim' style='margin:0 0 8px'>"
1023
+ f"Son güncelleme: {stamp} • {gainers}/{len(snap)} parite yükselişte • son {hours} saat</div>"
1024
+ f"<div class='nw-card' style='overflow-x:auto'><table class='nw-board'>"
1025
+ f"<thead><tr><th>Parite</th><th style='text-align:right'>Fiyat</th>"
1026
+ f"<th style='text-align:right'>Değişim</th><th style='text-align:right'>Trend</th></tr></thead>"
1027
+ f"<tbody>{''.join(rows)}</tbody></table></div></div>")
1028
+
1029
+
1030
+ BOARD_CSS = """
1031
+ .nw-board { width:100%; border-collapse:collapse; font-size:13.5px; }
1032
+ .nw-board th { text-align:left; color:#8295ad; font-size:11px; letter-spacing:1px;
1033
+ text-transform:uppercase; padding:6px 10px; border-bottom:1px solid rgba(96,165,250,.2); }
1034
+ .nw-board td { padding:9px 10px; border-bottom:1px solid rgba(148,163,184,.08); color:#dbeafe; }
1035
+ .nw-board tbody tr:hover { background:rgba(96,165,250,.07); }
1036
+ """
1037
+
1038
+ NEWS_CSS += BOARD_CSS