GoshawkVortexAI commited on
Commit
9f6ea71
·
verified ·
1 Parent(s): 1e8440c

Upload 2 files

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