GoshawkVortexAI commited on
Commit
af0405b
·
verified ·
1 Parent(s): a6d6343

Upload 2 files

Browse files
Files changed (2) hide show
  1. app.py +0 -0
  2. backtest.py +370 -0
app.py ADDED
The diff for this file is too large to render. See raw diff
 
backtest.py ADDED
@@ -0,0 +1,370 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ backtest.py — Haber duygusunu piyasa fiyatıyla eşleştirip backtest eder.
3
+
4
+ Akış:
5
+ 1. Haberleri al (news_feed varsa onu, yoksa RSS yedeği)
6
+ 2. Her habere model etiketi ver (Bullish / Neutral / Bearish)
7
+ 3. Haber yayın zamanındaki Binance mumuyla eşleştir (giriş fiyatı)
8
+ 4. Belirlenen ufuklarda (1s, 4s, 24s) ileri getiriyi hesapla
9
+ 5. İsabet oranı, ortalama getiri, equity eğrisi ve haber bazlı tablo üret
10
+
11
+ Not: Sinyal, haber yayınlandıktan sonraki İLK mumun açılışıyla işleme girer
12
+ (look-ahead yok). Komisyon + kayma (bps) her işlemden düşülür.
13
+ """
14
+
15
+ import html
16
+ import json
17
+ import math
18
+ import re
19
+ import time
20
+ import urllib.request
21
+ import xml.etree.ElementTree as ET
22
+ from datetime import datetime, timezone
23
+ from email.utils import parsedate_to_datetime
24
+
25
+ BINANCE_HOSTS = [
26
+ "https://api.binance.com",
27
+ "https://data-api.binance.vision",
28
+ ]
29
+
30
+ RSS_FEEDS = [
31
+ "https://www.coindesk.com/arc/outboundfeeds/rss/",
32
+ "https://cointelegraph.com/rss",
33
+ "https://decrypt.co/feed",
34
+ ]
35
+
36
+ HORIZONS = {"1 saat": 1, "4 saat": 4, "24 saat": 24}
37
+
38
+ _UA = {"User-Agent": "Mozilla/5.0 (GoshawkVortex-Backtest)"}
39
+
40
+
41
+ # ------------------------------------------------------------
42
+ # HTTP yardımcıları
43
+ # ------------------------------------------------------------
44
+
45
+ def _get(url, timeout=12):
46
+ req = urllib.request.Request(url, headers=_UA)
47
+ with urllib.request.urlopen(req, timeout=timeout) as r:
48
+ return r.read()
49
+
50
+
51
+ # ------------------------------------------------------------
52
+ # HABER KAYNAĞI
53
+ # ------------------------------------------------------------
54
+
55
+ def fetch_rss_news(limit=120):
56
+ """Yedek haber kaynağı. Her öğe: {title, ts (epoch sn), source}."""
57
+ items = []
58
+ for url in RSS_FEEDS:
59
+ try:
60
+ root = ET.fromstring(_get(url))
61
+ except Exception:
62
+ continue
63
+ for it in root.iter("item"):
64
+ title = (it.findtext("title") or "").strip()
65
+ pub = it.findtext("pubDate")
66
+ if not title or not pub:
67
+ continue
68
+ try:
69
+ ts = parsedate_to_datetime(pub).astimezone(timezone.utc).timestamp()
70
+ except Exception:
71
+ continue
72
+ items.append({
73
+ "title": html.unescape(re.sub(r"<[^>]+>", "", title)),
74
+ "ts": ts,
75
+ "source": url.split("/")[2],
76
+ })
77
+ items.sort(key=lambda x: x["ts"], reverse=True)
78
+ return items[:limit]
79
+
80
+
81
+ # ------------------------------------------------------------
82
+ # FİYAT VERİSİ (Binance)
83
+ # ------------------------------------------------------------
84
+
85
+ def fetch_klines(symbol, start_ms, end_ms, interval="1h"):
86
+ """Binance mum verisi. Satır: [open_time_ms, open, high, low, close]."""
87
+ out, cursor, last_err = [], int(start_ms), None
88
+ while cursor < end_ms:
89
+ data = None
90
+ for host in BINANCE_HOSTS:
91
+ url = (f"{host}/api/v3/klines?symbol={symbol}&interval={interval}"
92
+ f"&startTime={cursor}&endTime={int(end_ms)}&limit=1000")
93
+ try:
94
+ data = json.loads(_get(url))
95
+ break
96
+ except Exception as e: # noqa: BLE001
97
+ last_err = e
98
+ if data is None:
99
+ raise RuntimeError(f"Fiyat verisi alınamadı ({symbol}): {last_err}")
100
+ if not data:
101
+ break
102
+ for k in data:
103
+ out.append([int(k[0]), float(k[1]), float(k[2]), float(k[3]), float(k[4])])
104
+ nxt = int(data[-1][0]) + 3_600_000
105
+ if nxt <= cursor:
106
+ break
107
+ cursor = nxt
108
+ if len(data) < 1000:
109
+ break
110
+ return out
111
+
112
+
113
+ # ------------------------------------------------------------
114
+ # EŞLEŞTİRME + BACKTEST ÇEKİRDEĞİ (ağ gerektirmez, test edilebilir)
115
+ # ------------------------------------------------------------
116
+
117
+ def _candle_index(candles, ts_ms):
118
+ """ts'den SONRA açılan ilk mumun indeksi (look-ahead yok)."""
119
+ lo, hi = 0, len(candles)
120
+ while lo < hi:
121
+ mid = (lo + hi) // 2
122
+ if candles[mid][0] > ts_ms:
123
+ hi = mid
124
+ else:
125
+ lo = mid + 1
126
+ return lo if lo < len(candles) else None
127
+
128
+
129
+ def direction_of(label, score=None, threshold=0.0):
130
+ """Bullish → +1, Bearish → -1, Neutral → 0."""
131
+ l = (label or "").strip().lower()
132
+ if l.startswith("bull"):
133
+ return 1
134
+ if l.startswith("bear"):
135
+ return -1
136
+ return 0
137
+
138
+
139
+ def match_and_evaluate(signals, candles_by_symbol, horizon_h=4,
140
+ fee_bps=10, slippage_bps=5, long_only=False):
141
+ """
142
+ signals: [{title, ts, symbol, label, score}]
143
+ candles_by_symbol: {symbol: [[open_ms, o, h, l, c], ...]} (1s mumlar)
144
+ Döner: trades listesi.
145
+ """
146
+ cost = 2 * (fee_bps + slippage_bps) / 10_000 # gir + çık
147
+ trades = []
148
+ for s in signals:
149
+ d = direction_of(s["label"])
150
+ if d == 0 or (long_only and d < 0):
151
+ continue
152
+ candles = candles_by_symbol.get(s["symbol"])
153
+ if not candles:
154
+ continue
155
+ i = _candle_index(candles, int(s["ts"] * 1000))
156
+ if i is None or i + horizon_h >= len(candles):
157
+ continue # ufuk henüz dolmadı → değerlendirilemez
158
+ entry = candles[i][1] # sonraki mumun açılışı
159
+ exit_ = candles[i + horizon_h][1] # ufuk sonundaki açılış
160
+ if entry <= 0:
161
+ continue
162
+ raw = (exit_ / entry - 1) * d
163
+ ret = raw - cost
164
+ trades.append({
165
+ "ts": s["ts"], "symbol": s["symbol"], "title": s["title"],
166
+ "label": s["label"], "dir": d, "entry": entry, "exit": exit_,
167
+ "ret": ret, "win": ret > 0,
168
+ })
169
+ trades.sort(key=lambda t: t["ts"])
170
+ return trades
171
+
172
+
173
+ def summarize(trades):
174
+ n = len(trades)
175
+ if n == 0:
176
+ return {"n": 0}
177
+ rets = [t["ret"] for t in trades]
178
+ wins = sum(1 for t in trades if t["win"])
179
+ mean = sum(rets) / n
180
+ var = sum((r - mean) ** 2 for r in rets) / (n - 1) if n > 1 else 0.0
181
+ std = math.sqrt(var)
182
+ equity, peak, mdd, eq = 1.0, 1.0, 0.0, [1.0]
183
+ for r in rets:
184
+ equity *= (1 + r) # işlemler sırayla, eşit ağırlık
185
+ peak = max(peak, equity)
186
+ mdd = max(mdd, (peak - equity) / peak)
187
+ eq.append(equity)
188
+ gross_win = sum(r for r in rets if r > 0)
189
+ gross_loss = -sum(r for r in rets if r < 0)
190
+ return {
191
+ "n": n,
192
+ "win_rate": wins / n,
193
+ "mean": mean,
194
+ "total": equity - 1,
195
+ "std": std,
196
+ "t_stat": (mean / (std / math.sqrt(n))) if std > 0 and n > 1 else 0.0,
197
+ "profit_factor": (gross_win / gross_loss) if gross_loss > 0 else float("inf"),
198
+ "max_dd": mdd,
199
+ "equity": eq,
200
+ "best": max(rets), "worst": min(rets),
201
+ }
202
+
203
+
204
+ # ------------------------------------------------------------
205
+ # ÜST SEVİYE: haberleri çek → etiketle → fiyatla → raporla
206
+ # ------------------------------------------------------------
207
+
208
+ def _symbol_for(asset):
209
+ a = (asset or "").upper().replace("/", "").replace("-", "")
210
+ if not a or a == "ALL":
211
+ return None
212
+ return a if a.endswith(("USDT", "USDC")) else a + "USDT"
213
+
214
+
215
+ def collect_signals(classify_fn, detect_fn, asset="ALL", max_news=60,
216
+ news_fn=None, log=None):
217
+ """Haberleri çekip varlıklara ve duygu etiketine bağlar."""
218
+ news = (news_fn or fetch_rss_news)(max_news * 3)
219
+ want = _symbol_for(asset)
220
+ signals = []
221
+ for n in news:
222
+ if len(signals) >= max_news:
223
+ break
224
+ try:
225
+ assets = detect_fn(n["title"]) or []
226
+ except Exception:
227
+ assets = []
228
+ # detect_assets çıktısı str veya (str, ...) olabilir → normalize et
229
+ syms = []
230
+ for a in assets:
231
+ a = a[0] if isinstance(a, (tuple, list)) and a else a
232
+ sym = _symbol_for(str(a))
233
+ if sym:
234
+ syms.append(sym)
235
+ if want:
236
+ if want not in syms:
237
+ continue
238
+ syms = [want]
239
+ if not syms:
240
+ continue
241
+ try:
242
+ label, score = classify_fn(n["title"])
243
+ except Exception as e: # noqa: BLE001
244
+ if log is not None:
245
+ log.append(f"Etiketleme hatası: {e}")
246
+ continue
247
+ signals.append({"title": n["title"], "ts": n["ts"], "symbol": syms[0],
248
+ "label": label, "score": score,
249
+ "source": n.get("source", "")})
250
+ return signals
251
+
252
+
253
+ def run_backtest(classify_fn, detect_fn, asset="ALL", max_news=60,
254
+ horizon_label="4 saat", fee_bps=10, slippage_bps=5,
255
+ long_only=False, news_fn=None, price_fn=None):
256
+ """Tam akış. Döner: (signals, trades, summary, notes)."""
257
+ notes = []
258
+ horizon_h = HORIZONS.get(horizon_label, 4)
259
+ signals = collect_signals(classify_fn, detect_fn, asset, max_news,
260
+ news_fn=news_fn, log=notes)
261
+ if not signals:
262
+ return [], [], {"n": 0}, notes + ["Eşleşen haber bulunamadı."]
263
+
264
+ price_fn = price_fn or fetch_klines
265
+ candles = {}
266
+ t0 = min(s["ts"] for s in signals) * 1000 - 3_600_000
267
+ t1 = max(time.time() * 1000, max(s["ts"] for s in signals) * 1000)
268
+ for sym in sorted({s["symbol"] for s in signals}):
269
+ try:
270
+ candles[sym] = price_fn(sym, t0, t1 + horizon_h * 3_600_000)
271
+ except Exception as e: # noqa: BLE001
272
+ notes.append(str(e))
273
+
274
+ trades = match_and_evaluate(signals, candles, horizon_h, fee_bps,
275
+ slippage_bps, long_only)
276
+ skipped = sum(1 for s in signals if direction_of(s["label"]) != 0) - len(trades)
277
+ if skipped > 0:
278
+ notes.append(f"{skipped} sinyal atlandı (ufuk henüz dolmadı veya fiyat yok).")
279
+ return signals, trades, summarize(trades), notes
280
+
281
+
282
+ # ------------------------------------------------------------
283
+ # HTML RAPOR (uygulamanın koyu temasına uygun)
284
+ # ------------------------------------------------------------
285
+
286
+ def _pct(x, d=2):
287
+ return f"{x * 100:+.{d}f}%"
288
+
289
+
290
+ def _equity_svg(eq, w=640, h=140):
291
+ if len(eq) < 2:
292
+ return ""
293
+ lo, hi = min(eq), max(eq)
294
+ span = (hi - lo) or 1.0
295
+ pts = " ".join(
296
+ f"{i * w / (len(eq) - 1):.1f},{h - (v - lo) / span * (h - 12) - 6:.1f}"
297
+ for i, v in enumerate(eq)
298
+ )
299
+ base = h - (1.0 - lo) / span * (h - 12) - 6
300
+ color = "#22c55e" if eq[-1] >= 1 else "#ef4444"
301
+ return (f"<svg viewBox='0 0 {w} {h}' style='width:100%;height:{h}px'>"
302
+ f"<line x1='0' x2='{w}' y1='{base:.1f}' y2='{base:.1f}' "
303
+ f"stroke='#334155' stroke-dasharray='4 4'/>"
304
+ f"<polyline fill='none' stroke='{color}' stroke-width='2.5' points='{pts}'/></svg>")
305
+
306
+
307
+ def build_report_html(signals, trades, s, notes, horizon_label, asset):
308
+ box = ("padding:18px;border-radius:16px;margin-top:14px;color:#dbeafe;"
309
+ "background:rgba(10,18,32,.82);border:1px solid rgba(96,165,250,.18)")
310
+ if not s.get("n"):
311
+ msg = "<br>".join(html.escape(n) for n in notes) or "İşlem üretilemedi."
312
+ return f"<div style='{box}'>Backtest sonucu yok.<br><small>{msg}</small></div>"
313
+
314
+ def card(label, val, color="#e2e8f0"):
315
+ return (f"<div style='flex:1;min-width:120px;padding:12px;border-radius:12px;"
316
+ f"background:#0f1a2c'><div style='font-size:11px;color:#8295ad;"
317
+ f"letter-spacing:1px'>{label}</div><div style='font-size:22px;"
318
+ f"font-weight:800;color:{color}'>{val}</div></div>")
319
+
320
+ up, dn = "#22c55e", "#ef4444"
321
+ pf = "∞" if s["profit_factor"] == float("inf") else f"{s['profit_factor']:.2f}"
322
+ cards = "".join([
323
+ card("İŞLEM", s["n"]),
324
+ card("İSABET", f"{s['win_rate'] * 100:.0f}%", up if s["win_rate"] > .5 else dn),
325
+ card("ORT. GETİRİ", _pct(s["mean"]), up if s["mean"] > 0 else dn),
326
+ card("TOPLAM", _pct(s["total"]), up if s["total"] > 0 else dn),
327
+ card("PROFIT FACTOR", pf),
328
+ card("MAX DD", f"-{s['max_dd'] * 100:.1f}%", dn),
329
+ card("t-İSTATİSTİĞİ", f"{s['t_stat']:.2f}"),
330
+ ])
331
+
332
+ rows = []
333
+ for t in reversed(trades[-60:]):
334
+ c = up if t["ret"] > 0 else dn
335
+ when = datetime.fromtimestamp(t["ts"], tz=timezone.utc).strftime("%d.%m %H:%M")
336
+ arrow = "▲" if t["dir"] > 0 else "▼"
337
+ rows.append(
338
+ f"<tr><td>{when}</td><td>{t['symbol'].replace('USDT', '')}</td>"
339
+ f"<td>{arrow} {html.escape(t['label'])}</td>"
340
+ f"<td style='max-width:360px'>{html.escape(t['title'][:110])}</td>"
341
+ f"<td>{t['entry']:,.2f}</td><td>{t['exit']:,.2f}</td>"
342
+ f"<td style='color:{c};font-weight:700'>{_pct(t['ret'])}</td></tr>")
343
+
344
+ caveat = ""
345
+ if s["n"] < 30:
346
+ caveat = ("<div style='margin-top:10px;color:#eab308;font-size:12px'>"
347
+ f"⚠️ Yalnızca {s['n']} işlem var; bu örneklem istatistiksel olarak "
348
+ "güvenilir sonuç vermek için küçük. t-istatistiği |2| altındaysa "
349
+ "sonuç şansla açıklanabilir.</div>")
350
+ note_html = "".join(f"<div style='color:#8295ad;font-size:12px'>• {html.escape(n)}</div>"
351
+ for n in notes)
352
+ th = "text-align:left;color:#8295ad;font-size:11px;padding:6px 8px"
353
+ return f"""
354
+ <div style='{box}'>
355
+ <div style='font-weight:800;letter-spacing:2px;font-size:15px'>HABER → FİYAT BACKTEST</div>
356
+ <div style='color:#8295ad;font-size:12px;margin:5px 0 14px'>
357
+ Varlık: {html.escape(asset)} • Ufuk: {horizon_label} • Giriş: haberden sonraki ilk mum açılışı
358
+ </div>
359
+ <div style='display:flex;gap:10px;flex-wrap:wrap'>{cards}</div>
360
+ <div style='margin-top:16px'>{_equity_svg(s['equity'])}</div>
361
+ {caveat}{note_html}
362
+ </div>
363
+ <div style='{box};overflow-x:auto'>
364
+ <table style='width:100%;border-collapse:collapse;font-size:12px'>
365
+ <tr><th style='{th}'>Zaman (UTC)</th><th style='{th}'>Parite</th><th style='{th}'>Sinyal</th>
366
+ <th style='{th}'>Haber</th><th style='{th}'>Giriş</th><th style='{th}'>Çıkış</th>
367
+ <th style='{th}'>Getiri</th></tr>
368
+ {''.join(rows)}
369
+ </table>
370
+ </div>"""