From a857102b98545c4d59bb7a500f68091f354f50cd Mon Sep 17 00:00:00 2001 From: alireza Date: Wed, 26 Aug 2026 10:50:37 +0330 Subject: [PATCH] feat(lme): interactive modal chart with full history on card click --- backend/main.py | 12 + backend/scrape_lme.py | 117 ++++++- frontend/src/components/LmeView.tsx | 467 ++++++++++++++++++++++++++-- 3 files changed, 566 insertions(+), 30 deletions(-) diff --git a/backend/main.py b/backend/main.py index 0f90d13..7a8beb2 100644 --- a/backend/main.py +++ b/backend/main.py @@ -249,6 +249,14 @@ def get_crypto(_: object = Depends(require_session)): return crypto_market.get_latest() + +@app.get("/api/lme/history") +def get_lme_history_route(metal: str = Query("Copper"), _: object = Depends(require_session)): + try: + return scrape_lme.get_metal_history(metal) + except Exception as e: + raise HTTPException(status_code=502, detail=f"Failed to fetch LME history: {e}") + @app.get("/api/lme") def get_lme(_: object = Depends(require_session)): try: @@ -430,6 +438,10 @@ def v1_crypto(): return get_crypto(None) def v1_world_economy(): return get_world_economy(None) + +@data_api.get("/lme/history") +def v1_lme_history(metal: str = "Copper"): return scrape_lme.get_metal_history(metal) + @data_api.get("/lme") def v1_lme(): return scrape_lme.get_lme_data() diff --git a/backend/scrape_lme.py b/backend/scrape_lme.py index 8301b57..b8409e5 100644 --- a/backend/scrape_lme.py +++ b/backend/scrape_lme.py @@ -14,8 +14,40 @@ _cache = { "data": None, "last_fetched": 0 } +_history_cache = {} _lock = threading.Lock() +FIELD_MAP = { + "copper": "LME_Cu_cash", + "cu": "LME_Cu_cash", + "aluminium": "LME_Al_cash", + "al": "LME_Al_cash", + "aluminum": "LME_Al_cash", + "zinc": "LME_Zn_cash", + "zn": "LME_Zn_cash", + "nickel": "LME_Ni_cash", + "ni": "LME_Ni_cash", + "lead": "LME_Pb_cash", + "pb": "LME_Pb_cash", + "tin": "LME_Sn_cash", + "sn": "LME_Sn_cash", +} + +FA_NAMES = { + "Copper": "مس (Copper)", + "Tin": "قلع (Tin)", + "Lead": "سرب (Lead)", + "Zinc": "روی (Zinc)", + "Aluminium": "آلومینیوم (Aluminium)", + "Nickel": "نیکل (Nickel)", +} + +MONTHS_MAP = { + "january": "01", "february": "02", "march": "03", "april": "04", + "may": "05", "june": "06", "july": "07", "august": "08", + "september": "09", "october": "10", "november": "11", "december": "12" +} + def clean_num(val: str) -> float: if not val: return 0.0 @@ -25,16 +57,18 @@ def clean_num(val: str) -> float: except: return 0.0 -FA_NAMES = { - "Copper": "مس (Copper)", - "Tin": "قلع (Tin)", - "Lead": "سرب (Lead)", - "Zinc": "روی (Zinc)", - "Aluminium": "آلومینیوم (Aluminium)", - "Nickel": "نیکل (Nickel)", - "Aluminium Alloy": "آلیاژ آلومینیوم", - "NASAAC": "نازاک (NASAAC)" -} +def parse_date_iso(raw: str) -> str: + # Example: "25. August 2026" + try: + parts = raw.replace('.', '').split() + if len(parts) >= 3: + day = parts[0].zfill(2) + month = MONTHS_MAP.get(parts[1].lower(), "01") + year = parts[2] + return f"{year}-{month}-{day}" + except: + pass + return raw def fetch_raw(): url = 'https://www.westmetall.com/en/markdaten.php' @@ -156,3 +190,66 @@ def get_lme_data(ttl_seconds: int = 180): return _cache["data"] raise e return _cache["data"] + +def get_metal_history(metal_key: str, ttl_seconds: int = 300): + norm_key = metal_key.lower().strip() + field = FIELD_MAP.get(norm_key) + if not field: + # Fallback: check if the key already is a field + if norm_key.startswith("lme_"): + field = metal_key + else: + field = f"LME_{norm_key[:2].capitalize()}_cash" + + now = time.time() + with _lock: + cached = _history_cache.get(field) + if cached and (now - cached["time"]) < ttl_seconds: + return cached["data"] + + url = f'https://www.westmetall.com/en/markdaten.php?action=table&field={field}' + 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 not tables: + return {"metal": metal_key, "field": field, "points": []} + + rows = re.findall(r']*>([\s\S]*?)', tables[0]) + points = [] + # Skip header + for r in rows[1:]: + cells = [re.sub(r'<.*?>', '', c).replace(' ', ' ').strip() for c in re.findall(r']*>([\s\S]*?)', r)] + if len(cells) >= 4 and cells[0]: + c_val = clean_num(cells[1]) + tm_val = clean_num(cells[2]) + s_val = clean_num(cells[3]) + date_iso = parse_date_iso(cells[0]) + points.append({ + "date": date_iso, + "date_display": cells[0], + "cash": c_val, + "cash_str": cells[1], + "three_months": tm_val, + "three_months_str": cells[2], + "spread": round(tm_val - c_val, 2), + "stocks": s_val, + "stocks_str": cells[3], + }) + + # Reverse points to chronological order (oldest to newest) for charting + points.reverse() + + result = { + "metal": metal_key, + "name_fa": FA_NAMES.get(metal_key.capitalize(), metal_key), + "field": field, + "total_points": len(points), + "points": points + } + + with _lock: + _history_cache[field] = {"time": now, "data": result} + + return result diff --git a/frontend/src/components/LmeView.tsx b/frontend/src/components/LmeView.tsx index a93ff51..0e99cdc 100644 --- a/frontend/src/components/LmeView.tsx +++ b/frontend/src/components/LmeView.tsx @@ -6,8 +6,26 @@ import { DollarSign, Layers, ArrowUpDown, - Globe + Globe, + X, + LineChart as LineChartIcon, + TrendingUp, + TrendingDown, + Calendar, + Loader2, + Maximize2 } from "lucide-react"; +import { + ResponsiveContainer, + ComposedChart, + Line, + Area, + XAxis, + YAxis, + Tooltip, + CartesianGrid, + Legend +} from "recharts"; import { API_BASE } from "@/lib/api"; const API = API_BASE; @@ -28,6 +46,18 @@ export interface LmeMetal { unit: string; } +export interface LmeHistoryPoint { + date: string; + date_display: string; + cash: number; + cash_str: string; + three_months: number; + three_months_str: string; + spread: number; + stocks: number; + stocks_str: string; +} + export interface LmeFx { pair: string; rate: number; @@ -48,13 +78,13 @@ export interface LmeData { precious_metals: LmePrecious[]; } -const METAL_STYLES: Record = { - Copper: { border: "border-amber-500/30", bg: "from-amber-500/10 to-transparent", text: "text-amber-500" }, - Aluminium: { border: "border-sky-500/30", bg: "from-sky-500/10 to-transparent", text: "text-sky-500" }, - Zinc: { border: "border-emerald-500/30", bg: "from-emerald-500/10 to-transparent", text: "text-emerald-500" }, - Nickel: { border: "border-purple-500/30", bg: "from-purple-500/10 to-transparent", text: "text-purple-500" }, - Lead: { border: "border-slate-500/30", bg: "from-slate-500/10 to-transparent", text: "text-slate-400" }, - Tin: { border: "border-cyan-500/30", bg: "from-cyan-500/10 to-transparent", text: "text-cyan-500" }, +const METAL_STYLES: Record = { + Copper: { border: "border-amber-500/30", bg: "from-amber-500/10 to-transparent", text: "text-amber-500", primaryColor: "#f59e0b", secondaryColor: "#10b981" }, + Aluminium: { border: "border-sky-500/30", bg: "from-sky-500/10 to-transparent", text: "text-sky-500", primaryColor: "#0ea5e9", secondaryColor: "#6366f1" }, + Zinc: { border: "border-emerald-500/30", bg: "from-emerald-500/10 to-transparent", text: "text-emerald-500", primaryColor: "#10b981", secondaryColor: "#06b6d4" }, + Nickel: { border: "border-purple-500/30", bg: "from-purple-500/10 to-transparent", text: "text-purple-500", primaryColor: "#a855f7", secondaryColor: "#ec4899" }, + Lead: { border: "border-slate-500/30", bg: "from-slate-500/10 to-transparent", text: "text-slate-400", primaryColor: "#94a3b8", secondaryColor: "#38bdf8" }, + Tin: { border: "border-cyan-500/30", bg: "from-cyan-500/10 to-transparent", text: "text-cyan-500", primaryColor: "#06b6d4", secondaryColor: "#8b5cf6" }, }; export function LmeView() { @@ -62,6 +92,13 @@ export function LmeView() { const [error, setError] = useState(null); const [search, setSearch] = useState(""); + // Modal State + const [selectedMetal, setSelectedMetal] = useState(null); + const [historyLoading, setHistoryLoading] = useState(false); + const [historyPoints, setHistoryPoints] = useState([]); + const [timeRange, setTimeRange] = useState<"1M" | "3M" | "6M" | "ALL">("ALL"); + const [chartMode, setChartMode] = useState<"price" | "stocks">("price"); + const loadData = async () => { try { const res = await fetch(`${API}/api/lme`, { credentials: "include" }); @@ -80,6 +117,46 @@ export function LmeView() { return () => clearInterval(interval); }, []); + // Fetch full history when a card is selected + useEffect(() => { + if (!selectedMetal) return; + + let alive = true; + setHistoryLoading(true); + + fetch(`${API}/api/lme/history?metal=${encodeURIComponent(selectedMetal.metal)}`, { + credentials: "include" + }) + .then((r) => r.json()) + .then((json) => { + if (!alive) return; + if (json.points && Array.isArray(json.points)) { + setHistoryPoints(json.points); + } else { + setHistoryPoints([]); + } + }) + .catch(() => { + if (alive) setHistoryPoints([]); + }) + .finally(() => { + if (alive) setHistoryLoading(false); + }); + + return () => { + alive = false; + }; + }, [selectedMetal]); + + // Close modal on Escape key + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") setSelectedMetal(null); + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, []); + const filteredMetals = useMemo(() => { if (!data?.metals) return []; if (!search.trim()) return data.metals; @@ -91,6 +168,49 @@ export function LmeView() { ); }, [data, search]); + // Filter history points based on selected timeRange + const displayPoints = useMemo(() => { + if (!historyPoints.length) return []; + if (timeRange === "ALL") return historyPoints; + + const days = timeRange === "1M" ? 30 : timeRange === "3M" ? 90 : 180; + // Each trading day is ~1 point + const pointsCount = timeRange === "1M" ? 22 : timeRange === "3M" ? 65 : 130; + return historyPoints.slice(-pointsCount); + }, [historyPoints, timeRange]); + + // Statistics calculation for the chart + const stats = useMemo(() => { + if (!displayPoints.length) return null; + const cashVals = displayPoints.map((p) => p.cash).filter((v) => v > 0); + const stockVals = displayPoints.map((p) => p.stocks).filter((v) => v > 0); + if (!cashVals.length) return null; + + const maxCash = Math.max(...cashVals); + const minCash = Math.min(...cashVals); + const avgCash = cashVals.reduce((a, b) => a + b, 0) / cashVals.length; + + const firstCash = cashVals[0]; + const lastCash = cashVals[cashVals.length - 1]; + const changeCash = lastCash - firstCash; + const changePct = firstCash > 0 ? (changeCash / firstCash) * 100 : 0; + + const maxStock = stockVals.length ? Math.max(...stockVals) : 0; + const minStock = stockVals.length ? Math.min(...stockVals) : 0; + + return { + maxCash, + minCash, + avgCash, + changeCash, + changePct, + maxStock, + minStock, + }; + }, [displayPoints]); + + const activeStyle = selectedMetal ? (METAL_STYLES[selectedMetal.metal] || METAL_STYLES.Copper) : METAL_STYLES.Copper; + return (
{/* Clean Header */} @@ -104,7 +224,7 @@ export function LmeView() { بورس فلزات لندن (LME)

- قیمت‌های رسمی نقدی، قراردادهای ۳ ماهه و موجودی انبارها + قیمت‌های رسمی نقدی، قراردادهای ۳ ماهه و موجودی انبارها • برای مشاهده نمودار روی هر کارت کلیک کنید

@@ -122,7 +242,7 @@ export function LmeView() { )} - {/* KPI Cards Grid */} + {/* KPI Cards Grid - Clickable */}
{data?.metals.map((m) => { const style = METAL_STYLES[m.metal] || { @@ -134,18 +254,27 @@ export function LmeView() { return (
setSelectedMetal(m)} + className={`group cursor-pointer relative overflow-hidden rounded-2xl border ${style.border} bg-gradient-to-b ${style.bg} bg-card/40 p-5 backdrop-blur-md transition-all duration-200 hover:shadow-lg hover:-translate-y-1 hover:border-primary/60`} >
-
{m.name_fa}
+
+ {m.name_fa} +
{m.metal} • USD/mt
-
- - - {m.spread_str} $ +
+ + نمودار + +
+ + + {m.spread_str} $ + +
@@ -189,7 +318,7 @@ export function LmeView() { })}
- {/* Main Quotations Table */} + {/* Main Quotations Table - Clickable Rows */}
@@ -218,11 +347,16 @@ export function LmeView() { اسپرد (3M - Cash) موجودی انبارها (Stocks) تغییرات موجودی + عملیات {filteredMetals.map((m) => ( - + setSelectedMetal(m)} + className="cursor-pointer transition-colors hover:bg-muted/50" + >
{m.name_fa}
{m.metal}
@@ -253,6 +387,12 @@ export function LmeView() { {m.stocks_change_str} تن + + + + نمودار + + ))} @@ -262,7 +402,6 @@ export function LmeView() { {/* Side Panels: FX Rates and Precious Metals */}
- {/* Exchange Rates Panel */}
@@ -285,7 +424,6 @@ export function LmeView() {
- {/* Precious Metals Panel */}
@@ -308,6 +446,295 @@ export function LmeView() {
+ + {/* FULL HISTORY CHART MODAL */} + {selectedMetal && ( +
+
+ {/* Modal Header */} +
+
+
+ +
+
+
+

+ {selectedMetal.name_fa} +

+ + {selectedMetal.metal} + +
+

+ نمودار کامل روند تاریخی قیمت نقدی، قراردادهای ۳ ماهه و موجودی انبارها +

+
+
+ + +
+ + {/* Quick KPI Row in Modal */} +
+
+
قیمت نقدی (Cash)
+
+ ${selectedMetal.cash_str} +
+
+
+
قرارداد ۳ ماهه (3M)
+
+ ${selectedMetal.three_months_str} +
+
+
+
اسپرد نقدی با ۳ ماهه
+
+ {selectedMetal.spread_str} $ +
+
+
+
موجودی انبار LME
+
+ {selectedMetal.stocks_str} تن +
+
+
+ + {/* Controls Bar: Timeframe & Metric Toggle */} +
+ {/* Metric Switcher */} +
+ + +
+ + {/* Timeframe Buttons */} +
+ {(["1M", "3M", "6M", "ALL"] as const).map((r) => ( + + ))} +
+
+ + {/* Chart Area */} +
+ {historyLoading ? ( +
+ + در حال دریافت تاریخچه معاملات... +
+ ) : displayPoints.length === 0 ? ( +
+ داده‌ای برای این بازه یافت نشد. +
+ ) : ( +
+ + + + + + + + + + + + + + val?.slice(5) || ""} + /> + v.toLocaleString()} + orientation="right" + /> + { + const num = typeof val === "number" ? val.toLocaleString() : val; + if (name === "cash") return [`$${num}`, "تسویه نقدی"]; + if (name === "three_months") return [`$${num}`, "۳ ماهه"]; + if (name === "stocks") return [`${num} تن`, "موجودی انبار"]; + return [num, name]; + }} + labelFormatter={(l) => `تاریخ: ${l}`} + /> + { + if (value === "cash") return "تسویه نقدی (Cash)"; + if (value === "three_months") return "قرارداد ۳ ماهه (3-Month)"; + if (value === "stocks") return "موجودی انبارها (Stocks mt)"; + return value; + }} + /> + {chartMode === "price" ? ( + <> + + + + ) : ( + + )} + + +
+ )} + + {/* Stats Footer */} + {stats && ( +
+
+ بالاترین قیمت دوره: + ${stats.maxCash.toLocaleString()} +
+
+ پایین‌ترین قیمت دوره: + ${stats.minCash.toLocaleString()} +
+
+ میانگین دوره: + + ${Math.round(stats.avgCash).toLocaleString()} + +
+
+ تغییر در دوره: + = 0 ? "text-emerald-500" : "text-rose-500" + }`} + > + {stats.changeCash >= 0 ? "+" : ""}${stats.changeCash.toLocaleString()} ({stats.changePct.toFixed(1)}%) + +
+
+ )} +
+ + {/* Historical Data Table Inside Modal */} +
+
+
+ +

+ جدول روزشمار قیمت‌های تاریخی ({displayPoints.length} روز کاری) +

+
+
+ +
+ + + + + + + + + + + + {[...displayPoints].reverse().map((p, idx) => ( + + + + + + + + ))} + +
تاریختسویه نقدی (Cash)۳ ماهه (3-Month)اسپرد (3M - Cash)موجودی انبارها
{p.date_display}${p.cash_str}${p.three_months_str} + {p.spread > 0 ? "+" : ""}{p.spread} $ + {p.stocks_str} تن
+
+
+
+
+ )}
); }