From 380605daa4540f76adc554fa81b21680def1c1bd Mon Sep 17 00:00:00 2001 From: alireza Date: Wed, 26 Aug 2026 10:54:18 +0330 Subject: [PATCH] feat(lme): full multi-year diagram engine for all symbols with entire data timeline --- backend/main.py | 12 + backend/scrape_lme.py | 170 +++-- frontend/src/components/LmeView.tsx | 1066 ++++++++++++++------------- 3 files changed, 690 insertions(+), 558 deletions(-) diff --git a/backend/main.py b/backend/main.py index 7a8beb2..519aa36 100644 --- a/backend/main.py +++ b/backend/main.py @@ -250,6 +250,14 @@ def get_crypto(_: object = Depends(require_session)): + +@app.get("/api/lme/diagram") +def get_lme_diagram(field: str = Query("WM_Cu_low"), _: object = Depends(require_session)): + try: + return scrape_lme.get_entire_diagram_data(field) + except Exception as e: + raise HTTPException(status_code=502, detail=f"Failed to fetch diagram data: {e}") + @app.get("/api/lme/history") def get_lme_history_route(metal: str = Query("Copper"), _: object = Depends(require_session)): try: @@ -439,6 +447,10 @@ def v1_world_economy(): return get_world_economy(None) + +@data_api.get("/lme/diagram") +def v1_lme_diagram(field: str = "WM_Cu_low"): return scrape_lme.get_entire_diagram_data(field) + @data_api.get("/lme/history") def v1_lme_history(metal: str = "Copper"): return scrape_lme.get_metal_history(metal) diff --git a/backend/scrape_lme.py b/backend/scrape_lme.py index b8409e5..212fa2f 100644 --- a/backend/scrape_lme.py +++ b/backend/scrape_lme.py @@ -1,4 +1,5 @@ import urllib.request +import xml.etree.ElementTree as ET import re from datetime import datetime import threading @@ -14,7 +15,7 @@ _cache = { "data": None, "last_fetched": 0 } -_history_cache = {} +_diagram_cache = {} _lock = threading.Lock() FIELD_MAP = { @@ -40,12 +41,20 @@ FA_NAMES = { "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" + "WM_Cu_low": "مس کاتد آلمان (WM-Notiz کف)", + "WM_Cu_high": "مس کاتد آلمان (WM-Notiz سقف)", + "WI_Cu": "مس ویلند (Wieland Copper)", + "ACI": "شاخص مس پیشرفته (ACI)", + "MB_bronze_94_6": "برنز 94/6", + "MB_MS_58_1": "برنج MS 58 (مرحله ۱)", + "MB_MS_58_2": "برنج MS 58 (مرحله ۲)", + "MB_MS_63_37": "برنج MS 63/37", + "MB_MS_63_wire": "مفتول برنجی MS 63", + "USD_ozt_London": "انس طلای لندن (Fixing)", + "Au": "طلای شمش در اروپا", + "Au_processed": "طلای ساخته‌شده در اروپا", + "Ag": "نقره خالص در اروپا", + "Ag_processed": "نقره ساخته‌شده در اروپا", } def clean_num(val: str) -> float: @@ -57,19 +66,6 @@ def clean_num(val: str) -> float: except: return 0.0 -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' req = urllib.request.Request(url, headers=HEADERS) @@ -91,10 +87,13 @@ def fetch_raw(): for r in t0_rows[2:]: cells = [re.sub(r'<.*?>', '', c).replace(' ', ' ').strip() for c in re.findall(r']*>([\s\S]*?)', r)] + links = re.findall(r'field=([a-zA-Z0-9_]+)', r) + field = links[0] if links else f"LME_{cells[0][:2]}_cash" if len(cells) >= 3 and cells[0]: prices.append({ "metal": cells[0], "name_fa": FA_NAMES.get(cells[0], cells[0]), + "field": field, "cash": clean_num(cells[1]), "cash_str": cells[1], "three_months": clean_num(cells[2]), @@ -108,10 +107,13 @@ def fetch_raw(): 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)] + links = re.findall(r'field=([a-zA-Z0-9_]+)', r) + field = links[0] if links else "" if len(cells) >= 3 and cells[0]: stocks.append({ "metal": cells[0], "name_fa": FA_NAMES.get(cells[0], cells[0]), + "field": field, "stocks": clean_num(cells[1]), "stocks_str": cells[1], "change": clean_num(cells[2]), @@ -129,6 +131,7 @@ def fetch_raw(): combined_metals.append({ "metal": m_name, "name_fa": p["name_fa"], + "field": p["field"], "cash_settlement": p["cash"], "cash_str": p["cash_str"], "three_months": p["three_months"], @@ -154,15 +157,37 @@ def fetch_raw(): "rate_str": cells[1] }) + # Table 3: German Metal Quotations + german_symbols = [] + if len(tables) > 3: + for r in re.findall(r']*>([\s\S]*?)', tables[3])[2:]: + cells = [re.sub(r'<.*?>', '', c).replace(' ', ' ').strip() for c in re.findall(r']*>([\s\S]*?)', r)] + links = re.findall(r'field=([a-zA-Z0-9_]+)', r) + field = links[0] if links else "" + if len(cells) >= 3 and cells[0]: + german_symbols.append({ + "name": cells[0], + "name_fa": FA_NAMES.get(field, cells[0]), + "field": field, + "price_str": cells[1], + "prev_str": cells[2], + "unit": "EUR/100kg" + }) + # 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)] + links = re.findall(r'field=([a-zA-Z0-9_]+)', r) + field = links[0] if links else "" if len(cells) >= 2 and cells[0]: precious.append({ "name": cells[0], - "price_str": cells[1] + "name_fa": FA_NAMES.get(field, cells[0]), + "field": field, + "price_str": cells[1], + "prev_str": cells[2] if len(cells) > 2 else "-" }) return { @@ -170,6 +195,7 @@ def fetch_raw(): "updated_at": datetime.utcnow().isoformat(), "metals": combined_metals, "stocks": stocks, + "german_symbols": german_symbols, "exchange_rates": fx, "precious_metals": precious } @@ -191,65 +217,79 @@ def get_lme_data(ttl_seconds: int = 180): 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" +def get_entire_diagram_data(field: str, ttl_seconds: int = 3600): + """ + Fetches the ENTIRE multi-year historical dataset for any symbol + from the official XML marketdata API. + """ + field_clean = field.strip() + # Resolve aliases (e.g. "copper" -> "LME_Cu_cash") + field_clean = FIELD_MAP.get(field_clean.lower(), field_clean) now = time.time() with _lock: - cached = _history_cache.get(field) + cached = _diagram_cache.get(field_clean) if cached and (now - cached["time"]) < ttl_seconds: return cached["data"] - url = f'https://www.westmetall.com/en/markdaten.php?action=table&field={field}' + url = f'https://www.westmetall.com/api/marketdata/en/{field_clean}/' req = urllib.request.Request(url, headers=HEADERS) - with urllib.request.urlopen(req, timeout=12) as resp: - html = resp.read().decode('utf-8', errors='ignore') + with urllib.request.urlopen(req, timeout=15) as r: + xml_text = r.read().decode('utf-8') - tables = re.findall(r']*>([\s\S]*?)', html) - if not tables: - return {"metal": metal_key, "field": field, "points": []} + root = ET.fromstring(xml_text) + series_elem = root.find('series') + if series_elem is None: + return {"field": field_clean, "lines": []} - 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], + lines = [] + for line in series_elem: + raw_name = line.attrib.get('name', field_clean) + color = line.attrib.get('lineColor', '#D02E00') + unit = line.attrib.get('yUnit', '') + precision = int(line.attrib.get('yPrecision', '2')) + + pts = [] + for val in line: + raw_x = val.attrib.get('x', '') # YYYY/MM/DD + date_iso = raw_x.replace('/', '-') + # Also format DD/MM/YYYY for tooltips as shown in user screenshot + parts = date_iso.split('-') + date_display = f"{parts[2]}/{parts[1]}/{parts[0]}" if len(parts) == 3 else date_iso + + y_str = val.attrib.get('y', '0').replace(',', '') + try: + y_val = float(y_str) + except: + y_val = 0.0 + + pts.append({ + "x": date_iso, + "date_display": date_display, + "y": y_val }) - # Reverse points to chronological order (oldest to newest) for charting - points.reverse() + lines.append({ + "name": raw_name, + "color": color, + "unit": unit, + "precision": precision, + "count": len(pts), + "points": pts + }) result = { - "metal": metal_key, - "name_fa": FA_NAMES.get(metal_key.capitalize(), metal_key), - "field": field, - "total_points": len(points), - "points": points + "field": field_clean, + "name_fa": FA_NAMES.get(field_clean, field_clean), + "total_points": lines[0]["count"] if lines else 0, + "lines": lines } with _lock: - _history_cache[field] = {"time": now, "data": result} + _diagram_cache[field_clean] = {"time": now, "data": result} return result + +# Backward compatibility alias +def get_metal_history(metal_key: str, ttl_seconds: int = 300): + return get_entire_diagram_data(metal_key, ttl_seconds) diff --git a/frontend/src/components/LmeView.tsx b/frontend/src/components/LmeView.tsx index 0e99cdc..14358d4 100644 --- a/frontend/src/components/LmeView.tsx +++ b/frontend/src/components/LmeView.tsx @@ -9,11 +9,12 @@ import { Globe, X, LineChart as LineChartIcon, - TrendingUp, - TrendingDown, - Calendar, + ChevronLeft, + ChevronRight, + Maximize2, Loader2, - Maximize2 + Calendar, + Sparkles } from "lucide-react"; import { ResponsiveContainer, @@ -33,6 +34,7 @@ const API = API_BASE; export interface LmeMetal { metal: string; name_fa: string; + field: string; cash_settlement: number; cash_str: string; three_months: number; @@ -46,16 +48,21 @@ 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 GermanSymbol { + name: string; + name_fa: string; + field: string; + price_str: string; + prev_str: string; + unit: string; +} + +export interface PreciousSymbol { + name: string; + name_fa: string; + field: string; + price_str: string; + prev_str: string; } export interface LmeFx { @@ -64,41 +71,76 @@ export interface LmeFx { rate_str: string; } -export interface LmePrecious { - name: string; - price_str: string; -} - export interface LmeData { date: string; updated_at: string; metals: LmeMetal[]; stocks: any[]; + german_symbols?: GermanSymbol[]; exchange_rates: LmeFx[]; - precious_metals: LmePrecious[]; + precious_metals: PreciousSymbol[]; } -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 interface DiagramPoint { + x: string; // YYYY-MM-DD + date_display: string; // DD/MM/YYYY + y: number; +} + +export interface DiagramLine { + name: string; + color: string; + unit: string; + precision: number; + count: number; + points: DiagramPoint[]; +} + +export interface DiagramResponse { + field: string; + name_fa: string; + total_points: number; + lines: DiagramLine[]; +} + +type Timeframe = "3M" | "6M" | "1Y" | "2Y" | "ENTIRE"; + +const METAL_COLORS: Record = { + Copper: "#D02E00", + WM_Cu_low: "#D02E00", + WM_Cu_high: "#D02E00", + Aluminium: "#0ea5e9", + Zinc: "#10b981", + Nickel: "#8b5cf6", + Lead: "#64748b", + Tin: "#06b6d4", + ACI: "#f59e0b", + USD_ozt_London: "#eab308", + Au: "#eab308", + Ag: "#94a3b8", }; export function LmeView() { const [data, setData] = useState(null); const [error, setError] = useState(null); const [search, setSearch] = useState(""); + const [activeTab, setActiveTab] = useState<"lme" | "german" | "precious">("lme"); - // 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"); + // Selected item for full history modal + const [selectedSymbol, setSelectedSymbol] = useState<{ + field: string; + title: string; + name_fa: string; + unit?: string; + currentPrice?: string; + } | null>(null); + const [diagramData, setDiagramData] = useState(null); + const [diagramLoading, setDiagramLoading] = useState(false); + const [timeframe, setTimeframe] = useState("ENTIRE"); + const [offsetIndex, setOffsetIndex] = useState(0); + + // Load overview catalog const loadData = async () => { try { const res = await fetch(`${API}/api/lme`, { credentials: "include" }); @@ -117,103 +159,149 @@ export function LmeView() { return () => clearInterval(interval); }, []); - // Fetch full history when a card is selected + // Fetch full multi-year diagram data whenever selectedSymbol changes useEffect(() => { - if (!selectedMetal) return; + if (!selectedSymbol) { + setDiagramData(null); + return; + } let alive = true; - setHistoryLoading(true); + setDiagramLoading(true); + setOffsetIndex(0); - fetch(`${API}/api/lme/history?metal=${encodeURIComponent(selectedMetal.metal)}`, { + fetch(`${API}/api/lme/diagram?field=${encodeURIComponent(selectedSymbol.field)}`, { credentials: "include" }) .then((r) => r.json()) - .then((json) => { + .then((json: DiagramResponse) => { if (!alive) return; - if (json.points && Array.isArray(json.points)) { - setHistoryPoints(json.points); - } else { - setHistoryPoints([]); - } + setDiagramData(json); }) .catch(() => { - if (alive) setHistoryPoints([]); + if (alive) setDiagramData(null); }) .finally(() => { - if (alive) setHistoryLoading(false); + if (alive) setDiagramLoading(false); }); return () => { alive = false; }; - }, [selectedMetal]); + }, [selectedSymbol]); - // Close modal on Escape key + // Handle Escape key to close modal useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape") setSelectedMetal(null); + if (e.key === "Escape") setSelectedSymbol(null); }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, []); + // Filter main series by timeframe & offset + const chartData = useMemo(() => { + if (!diagramData?.lines?.length) return []; + const mainLine = diagramData.lines[0]; + const total = mainLine.points.length; + if (!total) return []; + + let sliceCount = total; + if (timeframe === "3M") sliceCount = 65; + else if (timeframe === "6M") sliceCount = 130; + else if (timeframe === "1Y") sliceCount = 260; + else if (timeframe === "2Y") sliceCount = 520; + else if (timeframe === "ENTIRE") sliceCount = total; + + // Windowing with offsetIndex + const maxOffset = Math.max(0, total - sliceCount); + const effectiveOffset = Math.min(offsetIndex, maxOffset); + const end = total - effectiveOffset; + const start = Math.max(0, end - sliceCount); + + const windowPoints = mainLine.points.slice(start, end); + + // Merge secondary lines (e.g. stock line in LME_Cu_cash) + if (diagramData.lines.length > 1) { + const secondLine = diagramData.lines[1]; + const secondMap = new Map(secondLine.points.map((p) => [p.x, p.y])); + return windowPoints.map((p) => ({ + x: p.x, + date_display: p.date_display, + [mainLine.name]: p.y, + [secondLine.name]: secondMap.get(p.x) ?? null, + })); + } + + return windowPoints.map((p) => ({ + x: p.x, + date_display: p.date_display, + [mainLine.name]: p.y, + })); + }, [diagramData, timeframe, offsetIndex]); + + // Statistics calculation for the active window + const stats = useMemo(() => { + if (!chartData.length || !diagramData?.lines?.[0]) return null; + const mainName = diagramData.lines[0].name; + const values = chartData.map((d: any) => d[mainName]).filter((v): v is number => typeof v === "number" && v > 0); + if (!values.length) return null; + + const max = Math.max(...values); + const min = Math.min(...values); + const avg = values.reduce((a, b) => a + b, 0) / values.length; + const first = values[0]; + const last = values[values.length - 1]; + const diff = last - first; + const pct = first > 0 ? (diff / first) * 100 : 0; + + return { max, min, avg, diff, pct, first, last }; + }, [chartData, diagramData]); + + const canStepPrev = useMemo(() => { + if (timeframe === "ENTIRE") return false; + const total = diagramData?.lines?.[0]?.points?.length || 0; + const count = timeframe === "3M" ? 65 : timeframe === "6M" ? 130 : timeframe === "1Y" ? 260 : 520; + return offsetIndex + count < total; + }, [timeframe, diagramData, offsetIndex]); + + const canStepNext = useMemo(() => { + return offsetIndex > 0 && timeframe !== "ENTIRE"; + }, [offsetIndex, timeframe]); + + const stepWindow = (dir: "prev" | "next") => { + const step = timeframe === "3M" ? 22 : timeframe === "6M" ? 45 : timeframe === "1Y" ? 65 : 130; + if (dir === "prev") { + setOffsetIndex((prev) => prev + step); + } else { + setOffsetIndex((prev) => Math.max(0, prev - step)); + } + }; + 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) - ); + return data.metals.filter((m) => m.metal.toLowerCase().includes(q) || m.name_fa.toLowerCase().includes(q)); }, [data, search]); - // Filter history points based on selected timeRange - const displayPoints = useMemo(() => { - if (!historyPoints.length) return []; - if (timeRange === "ALL") return historyPoints; + const filteredGerman = useMemo(() => { + if (!data?.german_symbols) return []; + if (!search.trim()) return data.german_symbols; + const q = search.toLowerCase().trim(); + return data.german_symbols.filter((s) => s.name.toLowerCase().includes(q) || s.name_fa.toLowerCase().includes(q)); + }, [data, search]); - 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; + const filteredPrecious = useMemo(() => { + if (!data?.precious_metals) return []; + if (!search.trim()) return data.precious_metals; + const q = search.toLowerCase().trim(); + return data.precious_metals.filter((s) => s.name.toLowerCase().includes(q) || s.name_fa.toLowerCase().includes(q)); + }, [data, search]); return (
- {/* Clean Header */} + {/* Header */}
@@ -224,7 +312,7 @@ export function LmeView() { بورس فلزات لندن (LME)

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

@@ -242,244 +330,269 @@ export function LmeView() {
)} - {/* KPI Cards Grid - Clickable */} -
- {data?.metals.map((m) => { - const style = METAL_STYLES[m.metal] || { - border: "border-border", - bg: "from-card to-transparent", - text: "text-primary", - }; + {/* Category Tabs */} +
+
+ + + +
- return ( +
+ + 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" + /> +
+
+ + {/* TAB 1: LME Metals */} + {activeTab === "lme" && ( +
+
+ {filteredMetals.map((m) => ( +
+ setSelectedSymbol({ + field: m.field, + title: m.metal, + name_fa: m.name_fa, + unit: "USD/mt", + currentPrice: m.cash_str, + }) + } + className="group cursor-pointer relative overflow-hidden rounded-2xl border border-border/70 bg-gradient-to-b from-card to-card/40 p-5 backdrop-blur-md transition-all duration-200 hover:shadow-xl hover:-translate-y-1 hover:border-primary" + > +
+
+
+ {m.name_fa} +
+
{m.metal} • USD/mt
+
+ + نمودار کامل + + +
+ +
+
+
تسویه نقدی (Cash)
+
+ ${m.cash_str} +
+
+
+
۳ ماهه (3-Month)
+
+ ${m.three_months_str} +
+
+
+ +
+
+ + موجودی انبار: + {m.stocks_str} تن +
+ {m.stocks_change !== 0 && ( + 0 + ? "text-emerald-600 dark:text-emerald-400" + : "text-rose-600 dark:text-rose-400" + }`} + > + {m.stocks_change > 0 ? "+" : ""}{m.stocks_change_str} تن + + )} +
+
+ ))} +
+
+ )} + + {/* TAB 2: German & European Metals */} + {activeTab === "german" && ( +
+ {filteredGerman.map((s) => (
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`} + key={s.field} + onClick={() => + setSelectedSymbol({ + field: s.field, + title: s.name, + name_fa: s.name_fa, + unit: s.unit, + currentPrice: s.price_str, + }) + } + className="group cursor-pointer relative overflow-hidden rounded-2xl border border-border/70 bg-gradient-to-b from-card to-card/40 p-5 backdrop-blur-md transition-all duration-200 hover:shadow-xl hover:-translate-y-1 hover:border-primary" >
- {m.name_fa} + {s.name_fa}
-
{m.metal} • USD/mt
+
{s.name}
-
- - نمودار + + نمودار کامل + + +
+ +
+
+
آخرین مظنه (EUR/100kg)
+
+ {s.price_str} € +
+
+
+
روز قبل
+
+ {s.prev_str} € +
+
+
+
+ ))} +
+ )} + + {/* TAB 3: Precious Metals & FX */} + {activeTab === "precious" && ( +
+
+ {filteredPrecious.map((pm) => ( +
+ setSelectedSymbol({ + field: pm.field, + title: pm.name, + name_fa: pm.name_fa, + currentPrice: pm.price_str, + }) + } + className="group cursor-pointer relative overflow-hidden rounded-2xl border border-border/70 bg-gradient-to-b from-card to-card/40 p-5 backdrop-blur-md transition-all duration-200 hover:shadow-xl hover:-translate-y-1 hover:border-amber-500/80" + > +
+
+
+ {pm.name_fa} +
+
{pm.name}
+
+ + نمودار کامل -
- - - {m.spread_str} $ - -
-
- {/* Prices Section */} -
-
-
تسویه نقدی (Cash)
-
- ${m.cash_str} -
-
-
-
۳ ماهه (3-Month)
-
- ${m.three_months_str} +
+
مظنه روز
+
+ {pm.price_str}
+ ))} +
- {/* Warehouse Stocks */} -
-
- - موجودی انبار: - {m.stocks_str} تن -
- {m.stocks_change !== 0 && ( - 0 - ? "text-emerald-600 dark:text-emerald-400" - : "text-rose-600 dark:text-rose-400" - }`} - > - {m.stocks_change > 0 ? "+" : ""}{m.stocks_change_str} تن - - )} + {/* Exchange Rates Panel */} +
+
+
+ +

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

+ EUR / USD
- ); - })} -
- {/* Main Quotations Table - Clickable Rows */} -
-
-
- -

جدول مظنه‌های رسمی LME

-
-
- - 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) => ( - setSelectedMetal(m)} - className="cursor-pointer transition-colors hover:bg-muted/50" +
+ {data?.exchange_rates?.map((fx, idx) => ( +
-
- - - - - - - + {fx.pair} + {fx.rate_str} + ))} - -
فلز (Metal)تسویه نقدی (Cash)۳ ماهه (3-Month)اسپرد (3M - Cash)موجودی انبارها (Stocks)تغییرات موجودیعملیات
-
{m.name_fa}
-
{m.metal}
-
- ${m.cash_str} / mt - - ${m.three_months_str} / mt - - {m.spread_str} $ - - {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)

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

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

-
- Gold & Silver -
- -
- {data?.precious_metals?.map((pm, idx) => ( -
- {pm.name} - {pm.price_str} -
- ))} -
-
-
- - {/* FULL HISTORY CHART MODAL */} - {selectedMetal && ( -
+ {/* FULL MULTI-YEAR HISTORICAL DIAGRAM MODAL */} + {selectedSymbol && ( +
- {/* Modal Header */} + {/* Header */}
- +

- {selectedMetal.name_fa} + {selectedSymbol.name_fa}

- - {selectedMetal.metal} + + {selectedSymbol.field}

- نمودار کامل روند تاریخی قیمت نقدی، قراردادهای ۳ ماهه و موجودی انبارها + نمودار کامل تاریخی • {diagramData?.total_points ? `${diagramData.total_points.toLocaleString()} روز معاملاتی ثبت‌شده` : "در حال بارگذاری..."}

- {/* Quick KPI Row in Modal */} -
-
-
قیمت نقدی (Cash)
-
- ${selectedMetal.cash_str} + {/* Quick Stat Pill */} + {stats && ( +
+
+
آخرین قیمت (Latest)
+
+ {stats.last.toLocaleString()} {diagramData?.lines?.[0]?.unit || ""} +
-
-
-
قرارداد ۳ ماهه (3M)
-
- ${selectedMetal.three_months_str} +
+
بالاترین دوره (Max)
+
+ {stats.max.toLocaleString()} {diagramData?.lines?.[0]?.unit || ""} +
-
-
-
اسپرد نقدی با ۳ ماهه
-
- {selectedMetal.spread_str} $ +
+
پایین‌ترین دوره (Min)
+
+ {stats.min.toLocaleString()} {diagramData?.lines?.[0]?.unit || ""} +
-
-
-
موجودی انبار LME
-
- {selectedMetal.stocks_str} تن -
-
-
- - {/* Controls Bar: Timeframe & Metric Toggle */} -
- {/* Metric Switcher */} -
- - -
- - {/* Timeframe Buttons */} -
- {(["1M", "3M", "6M", "ALL"] as const).map((r) => ( - - ))} + {stats.diff >= 0 ? "+" : ""}{stats.diff.toFixed(2)} ({stats.pct.toFixed(1)}%) +
+
-
+ )} {/* Chart Area */}
- {historyLoading ? ( -
- - در حال دریافت تاریخچه معاملات... + {diagramLoading ? ( +
+ + در حال بارگذاری دیتای کامل تاریخی (Entire Data)...
- ) : displayPoints.length === 0 ? ( -
- داده‌ای برای این بازه یافت نشد. + ) : chartData.length === 0 ? ( +
+ اطلاعاتی برای این نماد دریافت نشد.
) : ( -
+
- + - - - - - - - + + + - + val?.slice(5) || ""} + tickFormatter={(val) => { + if (!val) return ""; + const p = val.split("-"); + return p.length === 3 ? `${p[0]}/${p[1]}` : val; + }} + minTickGap={40} /> v.toLocaleString()} orientation="right" /> + {/* Tooltip styled exactly like Westmetall screenshot */} { - 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; + content={({ active, payload, label }) => { + if (!active || !payload?.length) return null; + const pt = payload[0]; + const ptData = pt.payload; + const lineName = pt.name; + const val = pt.value; + const unit = diagramData?.lines?.[0]?.unit || ""; + + return ( +
+
+ {lineName} +
+
+ {ptData.date_display || label} +
+
+ {typeof val === "number" ? val.toLocaleString(undefined, { minimumFractionDigits: 2 }) : val} {unit} +
+
+ ); }} /> - {chartMode === "price" ? ( - <> - - - - ) : ( + {diagramData?.lines?.map((line, idx) => ( - )} + ))}
)} - {/* 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)}%) - -
-
- )} -
+ {/* TIMEFRAME BAR — Exactly matching user's screenshot */} +
+ {/* Step Backwards */} + - {/* Historical Data Table Inside Modal */} -
-
-
- -

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

-
-
+ {/* 3 months */} + -
- - - - - - - - - - - - {[...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} تن
+ {/* 6 months */} + + + {/* 1 year */} + + + {/* 2 year */} + + + {/* entire data (ACTIVE HIGHLIGHT) */} + + + {/* Step Forwards */} +