feat(bonds): integrate TradingView sovereign and government bonds market into world economy view
This commit is contained in:
parent
b6d99c1270
commit
334ea1a3de
|
|
@ -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']}%")
|
||||||
|
|
@ -43,6 +43,7 @@ from live_stream import broadcast_loop, connect as ws_connect, get_latest as get
|
||||||
import currency_stream
|
import currency_stream
|
||||||
import world_economy
|
import world_economy
|
||||||
import steel_stocks
|
import steel_stocks
|
||||||
|
import bonds
|
||||||
import reports
|
import reports
|
||||||
import scrape_lme
|
import scrape_lme
|
||||||
import crypto_market
|
import crypto_market
|
||||||
|
|
@ -240,6 +241,10 @@ def get_coin(_: object = Depends(require_session)):
|
||||||
def get_world_economy(_: object = Depends(require_session)):
|
def get_world_economy(_: object = Depends(require_session)):
|
||||||
return world_economy.get_latest()
|
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")
|
@app.get("/api/steel-stocks")
|
||||||
def get_steel_stocks(_: object = Depends(require_session)):
|
def get_steel_stocks(_: object = Depends(require_session)):
|
||||||
return steel_stocks.get_latest()
|
return steel_stocks.get_latest()
|
||||||
|
|
|
||||||
|
|
@ -1634,6 +1634,26 @@ type WorldRow = {
|
||||||
fetched_at: string;
|
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 }> = {
|
const COUNTRY_FA: Record<string, { fa: string; flag: string }> = {
|
||||||
"United States": { fa: "آمریکا", flag: "🇺🇸" },
|
"United States": { fa: "آمریکا", flag: "🇺🇸" },
|
||||||
China: { fa: "چین", flag: "🇨🇳" },
|
China: { fa: "چین", flag: "🇨🇳" },
|
||||||
|
|
@ -2678,25 +2698,29 @@ function TrendingChip({ coin }: { coin: TrendingCoin }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function WorldEconomyView() {
|
function WorldEconomyView() {
|
||||||
|
const [subTab, setSubTab] = useState<"macro" | "bonds">("macro");
|
||||||
const [rows, setRows] = useState<WorldRow[]>([]);
|
const [rows, setRows] = useState<WorldRow[]>([]);
|
||||||
|
const [bonds, setBonds] = useState<BondRow[]>([]);
|
||||||
const [lastUpdate, setLastUpdate] = useState("");
|
const [lastUpdate, setLastUpdate] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
const data: WorldRow[] = await fetch(`${API}/api/world-economy`, {
|
const [macroData, bondsData] = await Promise.all([
|
||||||
cache: "no-store",
|
fetch(`${API}/api/world-economy`, { cache: "no-store" }).then((r) => r.json()).catch(() => []),
|
||||||
}).then((r) => r.json());
|
fetch(`${API}/api/bonds`, { cache: "no-store" }).then((r) => r.json()).catch(() => [])
|
||||||
if (!active || !Array.isArray(data) || data.length === 0) return;
|
]);
|
||||||
setRows(data);
|
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"));
|
setLastUpdate(new Date().toLocaleTimeString("fa-IR"));
|
||||||
} catch {
|
} catch {
|
||||||
/* keep last good data */
|
/* keep last good data */
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
load();
|
load();
|
||||||
const id = setInterval(load, 5 * 60_000); // macro data changes slowly
|
const id = setInterval(load, 60_000);
|
||||||
return () => {
|
return () => {
|
||||||
active = false;
|
active = false;
|
||||||
clearInterval(id);
|
clearInterval(id);
|
||||||
|
|
@ -2730,16 +2754,47 @@ function WorldEconomyView() {
|
||||||
return formatNumber(val) + (col.suffix || "");
|
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 (
|
return (
|
||||||
<main className="mx-auto max-w-6xl p-4 sm:p-6 space-y-5">
|
<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
|
<ViewHeader
|
||||||
live={rows.length > 0}
|
live={rows.length > 0 || bonds.length > 0}
|
||||||
title="اقتصاد جهانی"
|
title="اقتصاد جهانی و اوراق قرضه"
|
||||||
subtitle="شاخصهای کلانِ اقتصادهای بزرگ — رنگبندی حرارتی برای هر ستون"
|
subtitle={subTab === "macro" ? "شاخصهای کلانِ اقتصادهای بزرگ دنیا" : "بازار اوراق قرضه دولتی و خزانهداری — منبع TradingView"}
|
||||||
badge="Trading Economics"
|
badge={subTab === "macro" ? "Trading Economics" : "TradingView Bonds"}
|
||||||
lastUpdate={lastUpdate}
|
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} />
|
<SkeletonGrid count={12} />
|
||||||
) : (
|
) : (
|
||||||
<div className="panel overflow-x-auto">
|
<div className="panel overflow-x-auto">
|
||||||
|
|
@ -2784,6 +2839,159 @@ function WorldEconomyView() {
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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>
|
</main>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue