diff --git a/backend/bonds.py b/backend/bonds.py new file mode 100644 index 0000000..9688658 --- /dev/null +++ b/backend/bonds.py @@ -0,0 +1,100 @@ +import urllib.request +import json +import time + +_CACHE = {"data": [], "last_fetch": 0} +CACHE_TTL = 120 # 2 minutes + +BONDS_DEF = [ + # US Curve + {"symbol": "TVC:US02Y", "code": "US02Y", "title": "آمریکا ۲ ساله", "desc": "اوراق ۲ ساله خزانه‌داری آمریکا", "country": "United States", "flag": "🇺🇸", "tenor": "2Y"}, + {"symbol": "TVC:US10Y", "code": "US10Y", "title": "آمریکا ۱۰ ساله", "desc": "اوراق ۱۰ ساله خزانه‌داری آمریکا (بنچ‌مارک)", "country": "United States", "flag": "🇺🇸", "tenor": "10Y"}, + {"symbol": "TVC:US30Y", "code": "US30Y", "title": "آمریکا ۳۰ ساله", "desc": "اوراق ۳۰ ساله خزانه‌داری آمریکا", "country": "United States", "flag": "🇺🇸", "tenor": "30Y"}, + # Global 10Y Benchmarks + {"symbol": "TVC:DE10Y", "code": "DE10Y", "title": "آلمان ۱۰ ساله (Bund)", "desc": "اوراق ۱۰ ساله دولتی آلمان", "country": "Germany", "flag": "🇩🇪", "tenor": "10Y"}, + {"symbol": "TVC:GB10Y", "code": "GB10Y", "title": "بریتانیا ۱۰ ساله (Gilt)", "desc": "اوراق ۱۰ ساله دولتی انگلیس", "country": "United Kingdom", "flag": "🇬🇧", "tenor": "10Y"}, + {"symbol": "TVC:JP10Y", "code": "JP10Y", "title": "ژاپن ۱۰ ساله (JGB)", "desc": "اوراق ۱۰ ساله دولتی ژاپن", "country": "Japan", "flag": "🇯🇵", "tenor": "10Y"}, + {"symbol": "TVC:CN10Y", "code": "CN10Y", "title": "چین ۱۰ ساله", "desc": "اوراق ۱۰ ساله دولتی چین", "country": "China", "flag": "🇨🇳", "tenor": "10Y"}, + {"symbol": "TVC:FR10Y", "code": "FR10Y", "title": "فرانسه ۱۰ ساله (OAT)", "desc": "اوراق ۱۰ ساله دولتی فرانسه", "country": "France", "flag": "🇫🇷", "tenor": "10Y"}, + {"symbol": "TVC:IT10Y", "code": "IT10Y", "title": "ایتالیا ۱۰ ساله (BTP)", "desc": "اوراق ۱۰ ساله دولتی ایتالیا", "country": "Italy", "flag": "🇮🇹", "tenor": "10Y"}, + {"symbol": "TVC:CA10Y", "code": "CA10Y", "title": "کانادا ۱۰ ساله", "desc": "اوراق ۱۰ ساله دولتی کانادا", "country": "Canada", "flag": "🇨🇦", "tenor": "10Y"}, + {"symbol": "TVC:AU10Y", "code": "AU10Y", "title": "استرالیا ۱۰ ساله", "desc": "اوراق ۱۰ ساله دولتی استرالیا", "country": "Australia", "flag": "🇦🇺", "tenor": "10Y"}, + {"symbol": "TVC:IN10Y", "code": "IN10Y", "title": "هند ۱۰ ساله", "desc": "اوراق ۱۰ ساله دولتی هند", "country": "India", "flag": "🇮🇳", "tenor": "10Y"}, + {"symbol": "TVC:BR10Y", "code": "BR10Y", "title": "برزیل ۱۰ ساله", "desc": "اوراق ۱۰ ساله دولتی برزیل", "country": "Brazil", "flag": "🇧🇷", "tenor": "10Y"}, + {"symbol": "TVC:TR10Y", "code": "TR10Y", "title": "ترکیه ۱۰ ساله", "desc": "اوراق ۱۰ ساله دولتی ترکیه", "country": "Turkey", "flag": "🇹🇷", "tenor": "10Y"}, + {"symbol": "TVC:EU10Y", "code": "EU10Y", "title": "منطقه یورو ۱۰ ساله", "desc": "اوراق ۱۰ ساله بنچ‌مارک اتحادیه اروپا", "country": "Euro Area", "flag": "🇪🇺", "tenor": "10Y"}, +] + +def get_bonds(): + now = time.time() + if _CACHE["data"] and (now - _CACHE["last_fetch"] < CACHE_TTL): + return _CACHE["data"] + + url = "https://scanner.tradingview.com/bonds/scan" + tickers = [b["symbol"] for b in BONDS_DEF] + payload = { + "symbols": {"tickers": tickers}, + "columns": [ + "name", "description", "close", "change", "change_abs", "open", "high", "low", + "Perf.W", "Perf.1M", "Perf.Y", "Perf.YTD" + ] + } + try: + req = urllib.request.Request(url, data=json.dumps(payload).encode('utf-8'), headers={ + "User-Agent": "Mozilla/5.0", + "Content-Type": "application/json" + }) + with urllib.request.urlopen(req, timeout=8) as resp: + data = json.loads(resp.read().decode('utf-8')) + scan_map = {r.get('s'): r.get('d', []) for r in data.get('data', [])} + + out = [] + for b in BONDS_DEF: + d = scan_map.get(b["symbol"], []) + close = round(d[2], 4) if len(d) > 2 and d[2] is not None else None + change = round(d[3], 2) if len(d) > 3 and d[3] is not None else 0.0 + chg_abs = round(d[4], 4) if len(d) > 4 and d[4] is not None else 0.0 + open_val = round(d[5], 4) if len(d) > 5 and d[5] is not None else close + high_val = round(d[6], 4) if len(d) > 6 and d[6] is not None else close + low_val = round(d[7], 4) if len(d) > 7 and d[7] is not None else close + perf_w = round(d[8], 2) if len(d) > 8 and d[8] is not None else None + perf_m = round(d[9], 2) if len(d) > 9 and d[9] is not None else None + perf_y = round(d[10], 2) if len(d) > 10 and d[10] is not None else None + + if close is not None and open_val is not None: + step = (close - open_val) / 11.0 + sparkline = [round(open_val + step * i, 4) for i in range(11)] + [close] + else: + sparkline = [close] if close is not None else [] + + out.append({ + "symbol": b["symbol"], + "code": b["code"], + "title": b["title"], + "desc": b["desc"], + "country": b["country"], + "flag": b["flag"], + "tenor": b["tenor"], + "yield": close, + "change": change, + "change_abs": chg_abs, + "open": open_val, + "high": high_val, + "low": low_val, + "perf_w": perf_w, + "perf_m": perf_m, + "perf_y": perf_y, + "sparkline": sparkline + }) + _CACHE["data"] = out + _CACHE["last_fetch"] = now + return out + except Exception as e: + print(f"[bonds] TradingView scanner error: {e}") + return _CACHE["data"] if _CACHE["data"] else [] + +if __name__ == "__main__": + bonds = get_bonds() + print(f"Loaded {len(bonds)} sovereign bonds from TradingView:") + for b in bonds[:5]: + print(f" {b['flag']} {b['code']:6} | {b['title']:20} | Yield: {b['yield']}% | Chg: {b['change']}%") diff --git a/backend/main.py b/backend/main.py index 9b84e17..69c7534 100644 --- a/backend/main.py +++ b/backend/main.py @@ -43,6 +43,7 @@ from live_stream import broadcast_loop, connect as ws_connect, get_latest as get import currency_stream import world_economy import steel_stocks +import bonds import reports import scrape_lme import crypto_market @@ -240,6 +241,10 @@ def get_coin(_: object = Depends(require_session)): def get_world_economy(_: object = Depends(require_session)): return world_economy.get_latest() +@app.get("/api/bonds") +def get_bonds(_: object = Depends(require_session)): + return bonds.get_bonds() + @app.get("/api/steel-stocks") def get_steel_stocks(_: object = Depends(require_session)): return steel_stocks.get_latest() diff --git a/frontend/src/routes/dashboard.tsx b/frontend/src/routes/dashboard.tsx index 469027e..4ad30bc 100644 --- a/frontend/src/routes/dashboard.tsx +++ b/frontend/src/routes/dashboard.tsx @@ -1634,6 +1634,26 @@ type WorldRow = { fetched_at: string; }; +type BondRow = { + symbol: string; + code: string; + title: string; + desc: string; + country: string; + flag: string; + tenor: string; + yield: number | null; + change: number; + change_abs: number; + open: number | null; + high: number | null; + low: number | null; + perf_w: number | null; + perf_m: number | null; + perf_y: number | null; + sparkline: number[]; +}; + const COUNTRY_FA: Record = { "United States": { fa: "آمریکا", flag: "🇺🇸" }, China: { fa: "چین", flag: "🇨🇳" }, @@ -2678,25 +2698,29 @@ function TrendingChip({ coin }: { coin: TrendingCoin }) { } function WorldEconomyView() { + const [subTab, setSubTab] = useState<"macro" | "bonds">("macro"); const [rows, setRows] = useState([]); + const [bonds, setBonds] = useState([]); const [lastUpdate, setLastUpdate] = useState(""); useEffect(() => { let active = true; const load = async () => { try { - const data: WorldRow[] = await fetch(`${API}/api/world-economy`, { - cache: "no-store", - }).then((r) => r.json()); - if (!active || !Array.isArray(data) || data.length === 0) return; - setRows(data); + const [macroData, bondsData] = await Promise.all([ + fetch(`${API}/api/world-economy`, { cache: "no-store" }).then((r) => r.json()).catch(() => []), + fetch(`${API}/api/bonds`, { cache: "no-store" }).then((r) => r.json()).catch(() => []) + ]); + if (!active) return; + if (Array.isArray(macroData) && macroData.length > 0) setRows(macroData); + if (Array.isArray(bondsData) && bondsData.length > 0) setBonds(bondsData); setLastUpdate(new Date().toLocaleTimeString("fa-IR")); } catch { /* keep last good data */ } }; load(); - const id = setInterval(load, 5 * 60_000); // macro data changes slowly + const id = setInterval(load, 60_000); return () => { active = false; clearInterval(id); @@ -2730,60 +2754,244 @@ function WorldEconomyView() { return formatNumber(val) + (col.suffix || ""); }; + const us10y = bonds.find((b) => b.code === "US10Y"); + const us02y = bonds.find((b) => b.code === "US02Y"); + const de10y = bonds.find((b) => b.code === "DE10Y"); + const gb10y = bonds.find((b) => b.code === "GB10Y"); + + const spread10_2 = us10y?.yield && us02y?.yield ? (us10y.yield - us02y.yield) : null; + return (
- 0} - title="اقتصاد جهانی" - subtitle="شاخص‌های کلانِ اقتصادهای بزرگ — رنگ‌بندی حرارتی برای هر ستون" - badge="Trading Economics" - lastUpdate={lastUpdate} - /> - {rows.length === 0 ? ( - - ) : ( -
- - - - - {WE_COLS.map((c) => ( - +
+ 0 || bonds.length > 0} + title="اقتصاد جهانی و اوراق قرضه" + subtitle={subTab === "macro" ? "شاخص‌های کلانِ اقتصادهای بزرگ دنیا" : "بازار اوراق قرضه دولتی و خزانه‌داری — منبع TradingView"} + badge={subTab === "macro" ? "Trading Economics" : "TradingView Bonds"} + lastUpdate={lastUpdate} + /> +
+ + +
+
+ + {subTab === "macro" ? ( + rows.length === 0 ? ( + + ) : ( +
+
کشور - {c.label} -
+ + + + {WE_COLS.map((c) => ( + + ))} + + + + {rows.map((r) => ( + + + {WE_COLS.map((c) => { + const v = r[c.key] as number | null; + return ( + + ); + })} + ))} - - - - {rows.map((r) => ( - - - {WE_COLS.map((c) => { - const v = r[c.key] as number | null; + +
کشور + {c.label} +
+ {COUNTRY_FA[r.country]?.flag || "🏳️"} + {COUNTRY_FA[r.country]?.fa || r.country} + + {fmtCell(c, v)} +
- {COUNTRY_FA[r.country]?.flag || "🏳️"} - {COUNTRY_FA[r.country]?.fa || r.country} -
+
+ ) + ) : ( + /* Bonds view from TradingView */ + bonds.length === 0 ? ( + + ) : ( +
+ {/* Top Yield KPIs */} +
+
+
+ 🇺🇸 آمریکا ۱۰ ساله (بنچ‌مارک) + US10Y +
+
+ + {us10y?.yield ? us10y.yield.toFixed(3) + "%" : "—"} + + {us10y && = 0 ? "up" : "down"} />} +
+
+ تغییر: {us10y ? (us10y.change_abs >= 0 ? "+" : "") + (us10y.change_abs * 100).toFixed(1) + " bps" : "—"} +
+
+ +
+
+ 🇩🇪 آلمان ۱۰ ساله (Bund) + DE10Y +
+
+ + {de10y?.yield ? de10y.yield.toFixed(3) + "%" : "—"} + + {de10y && = 0 ? "up" : "down"} />} +
+
+ تغییر: {de10y ? (de10y.change_abs >= 0 ? "+" : "") + (de10y.change_abs * 100).toFixed(1) + " bps" : "—"} +
+
+ +
+
+ 🇬🇧 انگلیس ۱۰ ساله (Gilt) + GB10Y +
+
+ + {gb10y?.yield ? gb10y.yield.toFixed(3) + "%" : "—"} + + {gb10y && = 0 ? "up" : "down"} />} +
+
+ تغییر: {gb10y ? (gb10y.change_abs >= 0 ? "+" : "") + (gb10y.change_abs * 100).toFixed(1) + " bps" : "—"} +
+
+ +
+
+ 📐 اسپرد منحنی ۱۰-۲ سال آمریکا + 10Y - 2Y +
+
+ = 0 ? "text-emerald-700" : "text-red-600")}> + {spread10_2 != null ? (spread10_2 >= 0 ? "+" : "") + (spread10_2 * 100).toFixed(1) + " bps" : "—"} + + + {(spread10_2 ?? 0) >= 0 ? "نرمال" : "وارونه (Inverted)"} + +
+
+ {spread10_2 != null && spread10_2 < 0 ? "⚠️ هشدار رکود اقتصادی" : "شیب صعودی منحنی بازدهی"} +
+
+
+ + {/* Bonds Table */} +
+ + + + + + + + + + + + + + + + + {bonds.map((b) => { + const isUp = b.change >= 0; return ( - + + + + + + + + + + + + ); })} - - ))} - -
اوراق قرضه دولتیسررسیدنرخ بازدهی (Yield)تغییر روزانهتغییر پایه (bps)کف / سقف امروزبازدهی هفتگیبازدهی ۱ ماههبازدهی ۱ سالهروند
- {fmtCell(c, v)} -
+
+ {b.flag} +
+
{b.title}
+
{b.code} · {b.country}
+
+
+
+ {b.tenor} + + {b.yield != null ? b.yield.toFixed(3) + "%" : "—"} + + + + {b.change_abs != null ? (b.change_abs >= 0 ? "+" : "") + (b.change_abs * 100).toFixed(1) : "—"} + + {b.low != null && b.high != null ? `${b.low.toFixed(2)} - ${b.high.toFixed(2)}` : "—"} + = 0 ? "text-emerald-700" : "text-red-600")}> + {b.perf_w != null ? (b.perf_w >= 0 ? "+" : "") + b.perf_w.toFixed(2) + "%" : "—"} + = 0 ? "text-emerald-700" : "text-red-600")}> + {b.perf_m != null ? (b.perf_m >= 0 ? "+" : "") + b.perf_m.toFixed(2) + "%" : "—"} + = 0 ? "text-emerald-700" : "text-red-600")}> + {b.perf_y != null ? (b.perf_y >= 0 ? "+" : "") + b.perf_y.toFixed(2) + "%" : "—"} + + {b.sparkline && b.sparkline.length > 1 && ( +
+ +
+ )} +
-
+ + +
+ + ) )}
);