diff --git a/backend/main.py b/backend/main.py index 157154c..0f90d13 100644 --- a/backend/main.py +++ b/backend/main.py @@ -44,6 +44,7 @@ import currency_stream import world_economy import steel_stocks import reports +import scrape_lme import crypto_market @asynccontextmanager @@ -247,6 +248,14 @@ def get_steel_stocks(_: object = Depends(require_session)): def get_crypto(_: object = Depends(require_session)): return crypto_market.get_latest() + +@app.get("/api/lme") +def get_lme(_: object = Depends(require_session)): + try: + return scrape_lme.get_lme_data() + except Exception as e: + raise HTTPException(status_code=502, detail=f"Failed to fetch LME data: {e}") + @app.get("/api/reports") def get_reports(_: object = Depends(require_session)): return reports.get_index() @@ -420,4 +429,8 @@ def v1_crypto(): return get_crypto(None) @data_api.get("/world-economy") def v1_world_economy(): return get_world_economy(None) + +@data_api.get("/lme") +def v1_lme(): return scrape_lme.get_lme_data() + app.include_router(data_api) diff --git a/backend/scrape_lme.py b/backend/scrape_lme.py new file mode 100644 index 0000000..90f3d61 --- /dev/null +++ b/backend/scrape_lme.py @@ -0,0 +1,161 @@ +import urllib.request +import re +from datetime import datetime +import threading +import time + +HEADERS = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'Accept-Language': 'en-US,en;q=0.5' +} + +_cache = { + "data": None, + "last_fetched": 0 +} +_lock = threading.Lock() + +def clean_num(val: str) -> float: + if not val: + return 0.0 + val = val.replace(',', '').replace('+', '').replace('$', '').strip() + try: + return float(val) + except: + return 0.0 + +FA_NAMES = { + "Copper": "مس (Copper)", + "Tin": "قلع (Tin)", + "Lead": "سرب (Lead)", + "Zinc": "روی (Zinc)", + "Aluminium": "آلومینیوم (Aluminium)", + "Nickel": "نیکل (Nickel)", + "Aluminium Alloy": "آلیاژ آلومینیوم", + "NASAAC": "نازاک (NASAAC)" +} + +def fetch_raw(): + url = 'https://www.westmetall.com/en/markdaten.php' + req = urllib.request.Request(url, headers=HEADERS) + with urllib.request.urlopen(req, timeout=12) as resp: + html = resp.read().decode('utf-8', errors='ignore') + + tables = re.findall(r']*>([\s\S]*?)', html) + if len(tables) < 2: + return None + + # Table 0: Official LME Prices + t0_rows = re.findall(r']*>([\s\S]*?)', tables[0]) + date_str = "" + prices = [] + if t0_rows: + header_cells = re.findall(r']*>([\s\S]*?)', t0_rows[0]) + if len(header_cells) > 1: + date_str = re.sub(r'<.*?>', '', header_cells[1]).strip() + + for r in t0_rows[2:]: + cells = [re.sub(r'<.*?>', '', c).replace(' ', ' ').strip() for c in re.findall(r']*>([\s\S]*?)', r)] + if len(cells) >= 3 and cells[0]: + prices.append({ + "metal": cells[0], + "name_fa": FA_NAMES.get(cells[0], cells[0]), + "cash": clean_num(cells[1]), + "cash_str": cells[1], + "three_months": clean_num(cells[2]), + "three_months_str": cells[2], + "unit": "USD/mt" + }) + + # Table 1: LME Stocks + t1_rows = re.findall(r']*>([\s\S]*?)', tables[1]) + stocks = [] + if len(t1_rows) > 2: + for r in t1_rows[2:]: + cells = [re.sub(r'<.*?>', '', c).replace(' ', ' ').strip() for c in re.findall(r']*>([\s\S]*?)', r)] + if len(cells) >= 3 and cells[0]: + stocks.append({ + "metal": cells[0], + "name_fa": FA_NAMES.get(cells[0], cells[0]), + "stocks": clean_num(cells[1]), + "stocks_str": cells[1], + "change": clean_num(cells[2]), + "change_str": cells[2], + "unit": "mt" + }) + + # Combine Prices + Stocks by metal + combined_metals = [] + stocks_map = {s["metal"]: s for s in stocks} + for p in prices: + m_name = p["metal"] + s_data = stocks_map.get(m_name, {}) + spread = round(p["three_months"] - p["cash"], 2) + combined_metals.append({ + "metal": m_name, + "name_fa": p["name_fa"], + "cash_settlement": p["cash"], + "cash_str": p["cash_str"], + "three_months": p["three_months"], + "three_months_str": p["three_months_str"], + "spread": spread, + "spread_str": f"{spread:+,.2f}", + "market_condition": "Contango" if spread > 0 else ("Backwardation" if spread < 0 else "Flat"), + "stocks": s_data.get("stocks", 0), + "stocks_str": s_data.get("stocks_str", "-"), + "stocks_change": s_data.get("change", 0), + "stocks_change_str": s_data.get("change_str", "-"), + "unit": "USD/mt" + }) + + # Table 2: FX Rates + fx = [] + if len(tables) > 2: + for r in re.findall(r']*>([\s\S]*?)', tables[2])[1:]: + cells = [re.sub(r'<.*?>', '', c).replace(' ', ' ').strip() for c in re.findall(r']*>([\s\S]*?)', r)] + if len(cells) >= 2 and cells[0]: + fx.append({ + "pair": cells[0], + "rate": clean_num(cells[1]), + "rate_str": cells[1] + }) + + # Table 4: Precious Metals + precious = [] + if len(tables) > 4: + for r in re.findall(r']*>([\s\S]*?)', tables[4])[1:]: + cells = [re.sub(r'<.*?>', '', c).replace(' ', ' ').strip() for c in re.findall(r']*>([\s\S]*?)', r)] + if len(cells) >= 2 and cells[0]: + precious.append({ + "name": cells[0], + "price_str": cells[1] + }) + + return { + "source": "Westmetall GmbH / London Metal Exchange (LME)", + "source_url": "https://www.westmetall.com/en/markdaten.php", + "date": date_str or datetime.utcnow().strftime("%d. %B %Y"), + "updated_at": datetime.utcnow().isoformat(), + "metals": combined_metals, + "stocks": stocks, + "exchange_rates": fx, + "precious_metals": precious + } + +def get_lme_data(ttl_seconds: int = 180): + now = time.time() + with _lock: + if _cache["data"] and (now - _cache["last_fetched"]) < ttl_seconds: + return _cache["data"] + try: + fresh = fetch_raw() + if fresh: + _cache["data"] = fresh + _cache["last_fetched"] = now + return fresh + except Exception as e: + if _cache["data"]: + return _cache["data"] + raise e + return _cache["data"] diff --git a/frontend/src/components/LmeView.tsx b/frontend/src/components/LmeView.tsx new file mode 100644 index 0000000..23d80fa --- /dev/null +++ b/frontend/src/components/LmeView.tsx @@ -0,0 +1,377 @@ +import { useState, useEffect, useMemo } from "react"; +import { + Search, + ExternalLink, + RefreshCw, + TrendingUp, + TrendingDown, + Warehouse, + Coins, + DollarSign, + Layers, + ArrowUpDown, + ShieldCheck, + Globe +} from "lucide-react"; +import { API_BASE } from "@/lib/api"; + +const API = API_BASE; + +export interface LmeMetal { + metal: string; + name_fa: string; + cash_settlement: number; + cash_str: string; + three_months: number; + three_months_str: string; + spread: number; + spread_str: string; + market_condition: "Contango" | "Backwardation" | "Flat"; + stocks: number; + stocks_str: string; + stocks_change: number; + stocks_change_str: string; + unit: string; +} + +export interface LmeFx { + pair: string; + rate: number; + rate_str: string; +} + +export interface LmePrecious { + name: string; + price_str: string; +} + +export interface LmeData { + source: string; + source_url: string; + date: string; + updated_at: string; + metals: LmeMetal[]; + stocks: any[]; + exchange_rates: LmeFx[]; + precious_metals: LmePrecious[]; +} + +const METAL_COLORS: Record = { + Copper: { border: "border-amber-500/30", bg: "from-amber-500/10 to-orange-500/5", text: "text-amber-500", badge: "bg-amber-500/10 text-amber-600 dark:text-amber-400" }, + Aluminium: { border: "border-sky-500/30", bg: "from-sky-500/10 to-blue-500/5", text: "text-sky-500", badge: "bg-sky-500/10 text-sky-600 dark:text-sky-400" }, + Zinc: { border: "border-emerald-500/30", bg: "from-emerald-500/10 to-teal-500/5", text: "text-emerald-500", badge: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400" }, + Nickel: { border: "border-purple-500/30", bg: "from-purple-500/10 to-indigo-500/5", text: "text-purple-500", badge: "bg-purple-500/10 text-purple-600 dark:text-purple-400" }, + Lead: { border: "border-slate-500/30", bg: "from-slate-500/10 to-zinc-500/5", text: "text-slate-400", badge: "bg-slate-500/10 text-slate-600 dark:text-slate-300" }, + Tin: { border: "border-cyan-500/30", bg: "from-cyan-500/10 to-teal-500/5", text: "text-cyan-500", badge: "bg-cyan-500/10 text-cyan-600 dark:text-cyan-400" }, +}; + +export function LmeView() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [search, setSearch] = useState(""); + const [lastRefreshed, setLastRefreshed] = useState(""); + + const loadData = async () => { + setLoading(true); + try { + const res = await fetch(`${API}/api/lme`, { credentials: "include" }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const json: LmeData = await res.json(); + setData(json); + setError(null); + setLastRefreshed(new Date().toLocaleTimeString("fa-IR")); + } catch (err: any) { + setError(err.message || "خطا در دریافت داده‌های LME"); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + loadData(); + const interval = setInterval(loadData, 30000); + return () => clearInterval(interval); + }, []); + + const filteredMetals = useMemo(() => { + if (!data?.metals) return []; + if (!search.trim()) return data.metals; + const q = search.toLowerCase().trim(); + return data.metals.filter( + (m) => + m.metal.toLowerCase().includes(q) || + m.name_fa.toLowerCase().includes(q) + ); + }, [data, search]); + + return ( +
+ {/* Header Banner */} +
+
+
+
+ + + بورس فلزات لندن (London Metal Exchange) + +
+

+ قیمت‌های رسمی LME & موجودی انبارها +

+

+ بروزرسانی روزانه قیمت‌های تسویه نقدی (Cash Settlement)، قراردادهای ۳ ماهه و انبارداری از مرجع رسمی Westmetall +

+
+ +
+ {data?.date && ( +
+ تاریخ بازار: {data.date} +
+ )} + + + سایت Westmetall + + +
+
+
+ + {error && ( +
+ {error} +
+ )} + + {/* KPI Cards Grid */} +
+ {data?.metals.map((m) => { + const style = METAL_COLORS[m.metal] || { + border: "border-border", + bg: "from-card to-card", + text: "text-primary", + badge: "bg-primary/10 text-primary", + }; + const isBackwardation = m.spread < 0; + + return ( +
+
+
+
{m.name_fa}
+
{m.metal} • {m.unit}
+
+ + {m.market_condition} + +
+ + {/* Prices Section */} +
+
+
تسویه نقدی (Cash)
+
+ ${m.cash_str} +
+
+
+
قرارداد ۳ ماهه (3M)
+
+ ${m.three_months_str} +
+
+
+ + {/* Spread & Warehouse Stocks */} +
+
+ + اسپرد: + + {m.spread_str} $ + +
+
+ + {m.stocks_str} تن + {m.stocks_change !== 0 && ( + 0 + ? "text-emerald-600 dark:text-emerald-400 bg-emerald-500/10" + : "text-rose-600 dark:text-rose-400 bg-rose-500/10" + }`} + > + {m.stocks_change_str} + + )} +
+
+
+ ); + })} +
+ + {/* Main Quotations Table */} +
+
+
+ +

جدول جامع مظنه‌های رسمی LME (Westmetall)

+
+
+ + setSearch(e.target.value)} + placeholder="جستجو در نمادها و فلزات..." + className="w-full rounded-xl border border-border/80 bg-background/80 py-1.5 pr-9 pl-3 text-xs text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary" + /> +
+
+ +
+ + + + + + + + + + + + + + {filteredMetals.map((m) => { + const isBackwardation = m.spread < 0; + return ( + + + + + + + + + + ); + })} + +
فلز (Metal)تسویه نقدی (Cash)۳ ماهه (3-Months)اسپرد (3M - Cash)وضعیت بازارموجودی انبارها (LME Stocks)تغییرات موجودی
+
{m.name_fa}
+
{m.metal}
+
+ ${m.cash_str} / mt + + ${m.three_months_str} / mt + + {m.spread_str} $ + + + {m.market_condition} + + + {m.stocks_str} تن + + 0 + ? "text-emerald-600 dark:text-emerald-400" + : m.stocks_change < 0 + ? "text-rose-600 dark:text-rose-400" + : "text-muted-foreground" + }`} + > + {m.stocks_change > 0 ? "+" : ""} + {m.stocks_change_str} تن + +
+
+
+ + {/* Side Panels: FX Rates and Precious Metals */} +
+ {/* Exchange Rates Panel */} +
+
+
+ +

نرخ‌های برابری ارز LME & ECB (Exchange Rates)

+
+ EUR / USD +
+ +
+ {data?.exchange_rates?.map((fx, idx) => ( +
+ {fx.pair} + {fx.rate_str} +
+ ))} +
+
+ + {/* Precious Metals Panel */} +
+
+
+ +

فلزات گرانبها (Precious Metals London)

+
+ Gold & Silver +
+ +
+ {data?.precious_metals?.map((pm, idx) => ( +
+ {pm.name} + {pm.price_str} +
+ ))} +
+
+
+
+ ); +} diff --git a/frontend/src/routes/dashboard.tsx b/frontend/src/routes/dashboard.tsx index e445516..ccacdbd 100644 --- a/frontend/src/routes/dashboard.tsx +++ b/frontend/src/routes/dashboard.tsx @@ -59,6 +59,7 @@ import { Bean, Flower2, Citrus, + Globe, type LucideIcon, } from "lucide-react"; import { formatNumber, formatPct, toJalali, type PriceRow } from "@/lib/metals-data"; @@ -74,6 +75,7 @@ import { cn } from "@/lib/utils"; import { API_BASE } from "@/lib/api"; import { motion, AnimatePresence } from "framer-motion"; import { TubelightNavbar } from "@/components/TubelightNavbar"; +import { LmeView } from "@/components/LmeView"; const API = API_BASE; // dev: "" (same-origin via Vite proxy); prod: VITE_API_BASE @@ -87,12 +89,13 @@ export const Route = createFileRoute("/dashboard")({ component: Dashboard, }); -type View = "domestic" | "commodity" | "crypto" | "metals" | "heatmap" | "steel" | "reports"; +type View = "domestic" | "commodity" | "metals" | "lme" | "crypto" | "heatmap" | "steel" | "reports"; const VIEW_NAV: { key: View; name: string; icon: LucideIcon }[] = [ { key: "domestic", name: "بازار داخلی", icon: Banknote }, { key: "commodity", name: "کامودیتی", icon: Flame }, - { key: "crypto", name: "کریپتو", icon: Bitcoin }, { key: "metals", name: "فلزات و فولاد", icon: Factory }, + { key: "lme", name: "LME", icon: Globe }, + { key: "crypto", name: "کریپتو", icon: Bitcoin }, { key: "heatmap", name: "اقتصاد جهانی", icon: LayoutGrid }, { key: "steel", name: "سهام فولادی", icon: BarChart3 }, { key: "reports", name: "گزارش‌ها", icon: FileText }, @@ -211,8 +214,9 @@ function Dashboard() { {view === "domestic" && } {view === "commodity" && } - {view === "crypto" && } {view === "metals" && setJumpTo(null)} />} + {view === "lme" && } + {view === "crypto" && } {view === "heatmap" && } {view === "steel" && } {view === "reports" && }