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'
', 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"
+ />
+
+
+
+
+
+
+
+ | فلز (Metal) |
+ تسویه نقدی (Cash) |
+ ۳ ماهه (3-Months) |
+ اسپرد (3M - Cash) |
+ وضعیت بازار |
+ موجودی انبارها (LME Stocks) |
+ تغییرات موجودی |
+
+
+
+ {filteredMetals.map((m) => {
+ const isBackwardation = m.spread < 0;
+ return (
+
+ |
+ {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" && }