feat(bonds): integrate TradingView sovereign and government bonds market into world economy view

This commit is contained in:
alireza 2026-08-26 14:29:58 +03:30
parent b6d99c1270
commit 334ea1a3de
3 changed files with 367 additions and 54 deletions

100
backend/bonds.py Normal file
View File

@ -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']}%")

View File

@ -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()

View File

@ -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<string, { fa: string; flag: string }> = {
"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<WorldRow[]>([]);
const [bonds, setBonds] = useState<BondRow[]>([]);
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,16 +2754,47 @@ 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 (
<main className="mx-auto max-w-6xl p-4 sm:p-6 space-y-5">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<ViewHeader
live={rows.length > 0}
title="اقتصاد جهانی"
subtitle="شاخص‌های کلانِ اقتصادهای بزرگ — رنگ‌بندی حرارتی برای هر ستون"
badge="Trading Economics"
live={rows.length > 0 || bonds.length > 0}
title="اقتصاد جهانی و اوراق قرضه"
subtitle={subTab === "macro" ? "شاخص‌های کلانِ اقتصادهای بزرگ دنیا" : "بازار اوراق قرضه دولتی و خزانه‌داری — منبع TradingView"}
badge={subTab === "macro" ? "Trading Economics" : "TradingView Bonds"}
lastUpdate={lastUpdate}
/>
{rows.length === 0 ? (
<div className="flex items-center gap-1 bg-secondary/70 border border-border rounded-lg p-1 w-fit self-start sm:self-auto">
<button
onClick={() => setSubTab("macro")}
className={cn(
"px-3.5 h-8 rounded-md text-xs font-semibold transition",
subTab === "macro" ? "bg-primary text-primary-foreground shadow" : "text-muted-foreground hover:text-foreground"
)}
>
شاخصهای کلان اقتصادی
</button>
<button
onClick={() => setSubTab("bonds")}
className={cn(
"px-3.5 h-8 rounded-md text-xs font-semibold transition",
subTab === "bonds" ? "bg-primary text-primary-foreground shadow" : "text-muted-foreground hover:text-foreground"
)}
>
اوراق قرضه دولتی (Bonds)
</button>
</div>
</div>
{subTab === "macro" ? (
rows.length === 0 ? (
<SkeletonGrid count={12} />
) : (
<div className="panel overflow-x-auto">
@ -2784,6 +2839,159 @@ function WorldEconomyView() {
</tbody>
</table>
</div>
)
) : (
/* Bonds view from TradingView */
bonds.length === 0 ? (
<SkeletonGrid count={12} />
) : (
<div className="space-y-5">
{/* Top Yield KPIs */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div className="panel p-3.5 flex flex-col justify-between">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>🇺🇸 آمریکا ۱۰ ساله (بنچمارک)</span>
<span className="font-mono text-[10px]">US10Y</span>
</div>
<div className="mt-2 flex items-baseline justify-between">
<span className="text-2xl font-bold font-mono tabular-nums text-foreground">
{us10y?.yield ? us10y.yield.toFixed(3) + "%" : "—"}
</span>
{us10y && <DeltaPill pct={us10y.change} dir={us10y.change >= 0 ? "up" : "down"} />}
</div>
<div className="mt-1 text-[11px] text-muted-foreground font-mono">
تغییر: {us10y ? (us10y.change_abs >= 0 ? "+" : "") + (us10y.change_abs * 100).toFixed(1) + " bps" : "—"}
</div>
</div>
<div className="panel p-3.5 flex flex-col justify-between">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>🇩🇪 آلمان ۱۰ ساله (Bund)</span>
<span className="font-mono text-[10px]">DE10Y</span>
</div>
<div className="mt-2 flex items-baseline justify-between">
<span className="text-2xl font-bold font-mono tabular-nums text-foreground">
{de10y?.yield ? de10y.yield.toFixed(3) + "%" : "—"}
</span>
{de10y && <DeltaPill pct={de10y.change} dir={de10y.change >= 0 ? "up" : "down"} />}
</div>
<div className="mt-1 text-[11px] text-muted-foreground font-mono">
تغییر: {de10y ? (de10y.change_abs >= 0 ? "+" : "") + (de10y.change_abs * 100).toFixed(1) + " bps" : "—"}
</div>
</div>
<div className="panel p-3.5 flex flex-col justify-between">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>🇬🇧 انگلیس ۱۰ ساله (Gilt)</span>
<span className="font-mono text-[10px]">GB10Y</span>
</div>
<div className="mt-2 flex items-baseline justify-between">
<span className="text-2xl font-bold font-mono tabular-nums text-foreground">
{gb10y?.yield ? gb10y.yield.toFixed(3) + "%" : "—"}
</span>
{gb10y && <DeltaPill pct={gb10y.change} dir={gb10y.change >= 0 ? "up" : "down"} />}
</div>
<div className="mt-1 text-[11px] text-muted-foreground font-mono">
تغییر: {gb10y ? (gb10y.change_abs >= 0 ? "+" : "") + (gb10y.change_abs * 100).toFixed(1) + " bps" : "—"}
</div>
</div>
<div className="panel p-3.5 flex flex-col justify-between">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>📐 اسپرد منحنی ۱۰-۲ سال آمریکا</span>
<span className="font-mono text-[10px]">10Y - 2Y</span>
</div>
<div className="mt-2 flex items-baseline justify-between">
<span className={cn("text-2xl font-bold font-mono tabular-nums", (spread10_2 ?? 0) >= 0 ? "text-emerald-700" : "text-red-600")}>
{spread10_2 != null ? (spread10_2 >= 0 ? "+" : "") + (spread10_2 * 100).toFixed(1) + " bps" : "—"}
</span>
<span className="text-[11px] px-2 py-0.5 rounded-full font-mono font-medium bg-muted text-muted-foreground">
{(spread10_2 ?? 0) >= 0 ? "نرمال" : "وارونه (Inverted)"}
</span>
</div>
<div className="mt-1 text-[11px] text-muted-foreground">
{spread10_2 != null && spread10_2 < 0 ? "⚠️ هشدار رکود اقتصادی" : "شیب صعودی منحنی بازدهی"}
</div>
</div>
</div>
{/* Bonds Table */}
<div className="panel overflow-x-auto">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="border-b border-border text-muted-foreground text-xs">
<th className="text-right p-3 font-semibold">اوراق قرضه دولتی</th>
<th className="p-3 font-semibold text-center">سررسید</th>
<th className="p-3 font-semibold text-center">نرخ بازدهی (Yield)</th>
<th className="p-3 font-semibold text-center">تغییر روزانه</th>
<th className="p-3 font-semibold text-center">تغییر پایه (bps)</th>
<th className="p-3 font-semibold text-center">کف / سقف امروز</th>
<th className="p-3 font-semibold text-center">بازدهی هفتگی</th>
<th className="p-3 font-semibold text-center">بازدهی ۱ ماهه</th>
<th className="p-3 font-semibold text-center">بازدهی ۱ ساله</th>
<th className="p-3 font-semibold text-center w-28">روند</th>
</tr>
</thead>
<tbody>
{bonds.map((b) => {
const isUp = b.change >= 0;
return (
<tr key={b.symbol} className="border-b border-border/60 hover:bg-muted/40 transition">
<td className="p-3 font-medium">
<div className="flex items-center gap-2">
<span className="text-lg">{b.flag}</span>
<div>
<div className="font-semibold text-foreground">{b.title}</div>
<div className="text-[11px] font-mono text-muted-foreground">{b.code} · {b.country}</div>
</div>
</div>
</td>
<td className="p-3 text-center font-mono text-xs text-muted-foreground">
<span className="px-2 py-0.5 rounded bg-muted/80">{b.tenor}</span>
</td>
<td className="p-3 text-center font-mono font-bold text-base text-foreground tabular-nums">
{b.yield != null ? b.yield.toFixed(3) + "%" : "—"}
</td>
<td className="p-3 text-center font-mono text-xs tabular-nums">
<DeltaPill pct={b.change} dir={isUp ? "up" : "down"} />
</td>
<td className="p-3 text-center font-mono text-xs tabular-nums text-foreground">
{b.change_abs != null ? (b.change_abs >= 0 ? "+" : "") + (b.change_abs * 100).toFixed(1) : "—"}
</td>
<td className="p-3 text-center font-mono text-xs text-muted-foreground tabular-nums">
{b.low != null && b.high != null ? `${b.low.toFixed(2)} - ${b.high.toFixed(2)}` : "—"}
</td>
<td className={cn("p-3 text-center font-mono text-xs tabular-nums", (b.perf_w ?? 0) >= 0 ? "text-emerald-700" : "text-red-600")}>
{b.perf_w != null ? (b.perf_w >= 0 ? "+" : "") + b.perf_w.toFixed(2) + "%" : "—"}
</td>
<td className={cn("p-3 text-center font-mono text-xs tabular-nums", (b.perf_m ?? 0) >= 0 ? "text-emerald-700" : "text-red-600")}>
{b.perf_m != null ? (b.perf_m >= 0 ? "+" : "") + b.perf_m.toFixed(2) + "%" : "—"}
</td>
<td className={cn("p-3 text-center font-mono text-xs tabular-nums", (b.perf_y ?? 0) >= 0 ? "text-emerald-700" : "text-red-600")}>
{b.perf_y != null ? (b.perf_y >= 0 ? "+" : "") + b.perf_y.toFixed(2) + "%" : "—"}
</td>
<td className="p-3 text-center w-28">
{b.sparkline && b.sparkline.length > 1 && (
<div className="w-24 h-7 mx-auto">
<Sparkline
data={b.sparkline}
dir={isUp ? "up" : "down"}
width={96}
height={28}
strokeWidth={1.5}
fillOpacity={0.25}
/>
</div>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)
)}
</main>
);