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'', 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 */}
+
+
+ setActiveTab("lme")}
+ className={`px-3.5 py-1.5 text-xs font-semibold rounded-lg transition ${
+ activeTab === "lme"
+ ? "bg-background text-foreground shadow-sm"
+ : "text-muted-foreground hover:text-foreground"
+ }`}
+ >
+ فلزات رسمی LME
+
+ setActiveTab("german")}
+ className={`px-3.5 py-1.5 text-xs font-semibold rounded-lg transition ${
+ activeTab === "german"
+ ? "bg-background text-foreground shadow-sm"
+ : "text-muted-foreground hover:text-foreground"
+ }`}
+ >
+ مظنههای فلزات اروپا (WM & ACI)
+
+ setActiveTab("precious")}
+ className={`px-3.5 py-1.5 text-xs font-semibold rounded-lg transition ${
+ activeTab === "precious"
+ ? "bg-background text-foreground shadow-sm"
+ : "text-muted-foreground hover:text-foreground"
+ }`}
+ >
+ فلزات گرانبها و ارز
+
+
- 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"
- />
-
-
-
-
-
-
-
- فلز (Metal)
- تسویه نقدی (Cash)
- ۳ ماهه (3-Month)
- اسپرد (3M - Cash)
- موجودی انبارها (Stocks)
- تغییرات موجودی
- عملیات
-
-
-
- {filteredMetals.map((m) => (
- setSelectedMetal(m)}
- className="cursor-pointer transition-colors hover:bg-muted/50"
+
+ {data?.exchange_rates?.map((fx, idx) => (
+
-
- {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} تن
-
-
-
-
-
- نمودار
-
-
-
+ {fx.pair}
+ {fx.rate_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()} روز معاملاتی ثبتشده` : "در حال بارگذاری..."}
setSelectedMetal(null)}
+ onClick={() => setSelectedSymbol(null)}
className="rounded-xl border border-border/60 bg-muted/40 p-2 text-muted-foreground transition hover:bg-muted hover:text-foreground"
aria-label="بستن"
>
@@ -487,112 +600,71 @@ export function LmeView() {
- {/* 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 */}
-
- setChartMode("price")}
- className={`px-3 py-1 text-xs font-semibold rounded-lg transition ${
- chartMode === "price"
- ? "bg-background text-foreground shadow-sm"
- : "text-muted-foreground hover:text-foreground"
- }`}
- >
- قیمت نقدی و ۳ ماهه
-
- setChartMode("stocks")}
- className={`px-3 py-1 text-xs font-semibold rounded-lg transition ${
- chartMode === "stocks"
- ? "bg-background text-foreground shadow-sm"
- : "text-muted-foreground hover:text-foreground"
- }`}
- >
- موجودی انبارها (Stocks)
-
-
-
- {/* Timeframe Buttons */}
-
- {(["1M", "3M", "6M", "ALL"] as const).map((r) => (
-
setTimeRange(r)}
- className={`px-3 py-1 text-xs font-mono font-semibold rounded-lg transition ${
- timeRange === r
- ? "bg-primary text-primary-foreground shadow-sm"
- : "text-muted-foreground hover:text-foreground"
+
+
تغییر بازه انتخابی
+
= 0 ? "text-emerald-500" : "text-rose-500"
}`}
>
- {r === "ALL" ? "همه (YTD)" : 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 */}
+
stepWindow("prev")}
+ disabled={!canStepPrev}
+ className="rounded-lg border border-border/80 bg-background/80 px-2.5 py-1.5 text-xs text-foreground transition hover:bg-accent disabled:opacity-30 disabled:cursor-not-allowed"
+ title="نمایش دادههای قدیمیتر"
+ >
+ <
+
- {/* Historical Data Table Inside Modal */}
-
-
-
-
-
- جدول روزشمار قیمتهای تاریخی ({displayPoints.length} روز کاری)
-
-
-
+ {/* 3 months */}
+
{
+ setTimeframe("3M");
+ setOffsetIndex(0);
+ }}
+ className={`rounded-lg border px-3 py-1.5 text-xs font-medium transition ${
+ timeframe === "3M"
+ ? "border-primary bg-primary text-primary-foreground shadow-sm"
+ : "border-border/80 bg-background/80 text-muted-foreground hover:text-foreground hover:bg-accent"
+ }`}
+ >
+ 3 months
+
-
-
-
-
- تاریخ
- تسویه نقدی (Cash)
- ۳ ماهه (3-Month)
- اسپرد (3M - Cash)
- موجودی انبارها
-
-
-
- {[...displayPoints].reverse().map((p, idx) => (
-
- {p.date_display}
- ${p.cash_str}
- ${p.three_months_str}
-
- {p.spread > 0 ? "+" : ""}{p.spread} $
-
- {p.stocks_str} تن
-
- ))}
-
-
+ {/* 6 months */}
+
{
+ setTimeframe("6M");
+ setOffsetIndex(0);
+ }}
+ className={`rounded-lg border px-3 py-1.5 text-xs font-medium transition ${
+ timeframe === "6M"
+ ? "border-primary bg-primary text-primary-foreground shadow-sm"
+ : "border-border/80 bg-background/80 text-muted-foreground hover:text-foreground hover:bg-accent"
+ }`}
+ >
+ 6 months
+
+
+ {/* 1 year */}
+
{
+ setTimeframe("1Y");
+ setOffsetIndex(0);
+ }}
+ className={`rounded-lg border px-3 py-1.5 text-xs font-medium transition ${
+ timeframe === "1Y"
+ ? "border-primary bg-primary text-primary-foreground shadow-sm"
+ : "border-border/80 bg-background/80 text-muted-foreground hover:text-foreground hover:bg-accent"
+ }`}
+ >
+ 1 year
+
+
+ {/* 2 year */}
+
{
+ setTimeframe("2Y");
+ setOffsetIndex(0);
+ }}
+ className={`rounded-lg border px-3 py-1.5 text-xs font-medium transition ${
+ timeframe === "2Y"
+ ? "border-primary bg-primary text-primary-foreground shadow-sm"
+ : "border-border/80 bg-background/80 text-muted-foreground hover:text-foreground hover:bg-accent"
+ }`}
+ >
+ 2 year
+
+
+ {/* entire data (ACTIVE HIGHLIGHT) */}
+
{
+ setTimeframe("ENTIRE");
+ setOffsetIndex(0);
+ }}
+ className={`rounded-lg border px-3 py-1.5 text-xs font-bold transition ${
+ timeframe === "ENTIRE"
+ ? "border-primary bg-primary text-primary-foreground shadow-md ring-2 ring-primary/20"
+ : "border-border/80 bg-background/80 text-muted-foreground hover:text-foreground hover:bg-accent"
+ }`}
+ >
+ entire data
+
+
+ {/* Step Forwards */}
+
stepWindow("next")}
+ disabled={!canStepNext}
+ className="rounded-lg border border-border/80 bg-background/80 px-2.5 py-1.5 text-xs text-foreground transition hover:bg-accent disabled:opacity-30 disabled:cursor-not-allowed"
+ title="نمایش دادههای جدیدتر"
+ >
+ >
+