From 8ec51d92a8b1e3aa720a5b603e662c4d21ae40ed Mon Sep 17 00:00:00 2001 From: alireza Date: Wed, 26 Aug 2026 10:56:10 +0330 Subject: [PATCH] feat(lme): add color legend, dual Y-axis scaling for price vs stocks, and full Persian localization --- backend/scrape_lme.py | 84 +++++--- frontend/src/components/LmeView.tsx | 299 ++++++++++++++++++---------- 2 files changed, 258 insertions(+), 125 deletions(-) diff --git a/backend/scrape_lme.py b/backend/scrape_lme.py index 212fa2f..4bd6b63 100644 --- a/backend/scrape_lme.py +++ b/backend/scrape_lme.py @@ -35,17 +35,17 @@ FIELD_MAP = { } FA_NAMES = { - "Copper": "مس (Copper)", - "Tin": "قلع (Tin)", - "Lead": "سرب (Lead)", - "Zinc": "روی (Zinc)", - "Aluminium": "آلومینیوم (Aluminium)", - "Nickel": "نیکل (Nickel)", - "WM_Cu_low": "مس کاتد آلمان (WM-Notiz کف)", - "WM_Cu_high": "مس کاتد آلمان (WM-Notiz سقف)", - "WI_Cu": "مس ویلند (Wieland Copper)", + "Copper": "مس", + "Tin": "قلع", + "Lead": "سرب", + "Zinc": "روی", + "Aluminium": "آلومینیوم", + "Nickel": "نیکل", + "WM_Cu_low": "مس کاتد آلمان (کف قیمت)", + "WM_Cu_high": "مس کاتد آلمان (سقف قیمت)", + "WI_Cu": "مس ویلند (Wieland)", "ACI": "شاخص مس پیشرفته (ACI)", - "MB_bronze_94_6": "برنز 94/6", + "MB_bronze_94_6": "برنز ۹۴/۶", "MB_MS_58_1": "برنج MS 58 (مرحله ۱)", "MB_MS_58_2": "برنج MS 58 (مرحله ۲)", "MB_MS_63_37": "برنج MS 63/37", @@ -57,6 +57,28 @@ FA_NAMES = { "Ag_processed": "نقره ساخته‌شده در اروپا", } +SERIES_FA_NAMES = { + "LME Copper Cash-Settlement": "قیمت تسویه نقدی مس", + "LME Copper stock": "موجودی انبار مس", + "LME Aluminium Cash-Settlement": "قیمت تسویه نقدی آلومینیوم", + "LME Aluminium stock": "موجودی انبار آلومینیوم", + "LME Zinc Cash-Settlement": "قیمت تسویه نقدی روی", + "LME Zinc stock": "موجودی انبار روی", + "LME Nickel Cash-Settlement": "قیمت تسویه نقدی نیکل", + "LME Nickel stock": "موجودی انبار نیکل", + "LME Lead Cash-Settlement": "قیمت تسویه نقدی سرب", + "LME Lead stock": "موجودی انبار سرب", + "LME Tin Cash-Settlement": "قیمت تسویه نقدی قلع", + "LME Tin stock": "موجودی انبار قلع", + "WM_Cu_low": "کف قیمت مس کاتد آلمان", + "WM_Cu_high": "سقف قیمت مس کاتد آلمان", + "Gold London Fixing": "انس طلای لندن", + "Fine Silver in Euro/kg": "نقره خالص در اروپا", + "Gold in Euro/kg": "طلای شمش در اروپا", + "Advanced Copper Index (ACI)": "شاخص مس پیشرفته (ACI)", + "Wieland-Copper": "مس ویلند", +} + def clean_num(val: str) -> float: if not val: return 0.0 @@ -98,7 +120,7 @@ def fetch_raw(): "cash_str": cells[1], "three_months": clean_num(cells[2]), "three_months_str": cells[2], - "unit": "USD/mt" + "unit": "دلار بر تن" }) # Table 1: LME Stocks @@ -118,7 +140,7 @@ def fetch_raw(): "stocks_str": cells[1], "change": clean_num(cells[2]), "change_str": cells[2], - "unit": "mt" + "unit": "تن" }) # Combine Prices + Stocks by metal @@ -142,7 +164,7 @@ def fetch_raw(): "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" + "unit": "دلار بر تن" }) # Table 2: FX Rates @@ -171,7 +193,7 @@ def fetch_raw(): "field": field, "price_str": cells[1], "prev_str": cells[2], - "unit": "EUR/100kg" + "unit": "یورو بر ۱۰۰ کیلوگرم" }) # Table 4: Precious Metals @@ -218,12 +240,7 @@ def get_lme_data(ttl_seconds: int = 180): return _cache["data"] 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() @@ -245,17 +262,35 @@ def get_entire_diagram_data(field: str, ttl_seconds: int = 3600): lines = [] for line in series_elem: raw_name = line.attrib.get('name', field_clean) + fa_line_name = SERIES_FA_NAMES.get(raw_name, raw_name) color = line.attrib.get('lineColor', '#D02E00') - unit = line.attrib.get('yUnit', '') + raw_unit = line.attrib.get('yUnit', '') precision = int(line.attrib.get('yPrecision', '2')) + # Persian unit & type + is_stock = "stock" in raw_name.lower() or "bestände" in raw_name.lower() or "انبار" in fa_line_name + if is_stock: + unit = "تن" + series_type = "stocks" + color = "#f97316" # vibrant orange for stocks + elif "€" in raw_unit or "euro" in raw_unit.lower(): + unit = "یورو" + series_type = "price" + color = "#D02E00" # red for primary price + elif "$" in raw_unit or "usd" in raw_unit.lower(): + unit = "دلار" + series_type = "price" + color = "#D02E00" + else: + unit = raw_unit + series_type = "price" + 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 + date_display = f"{parts[0]}/{parts[1]}/{parts[2]}" if len(parts) == 3 else date_iso y_str = val.attrib.get('y', '0').replace(',', '') try: @@ -270,7 +305,9 @@ def get_entire_diagram_data(field: str, ttl_seconds: int = 3600): }) lines.append({ - "name": raw_name, + "name": fa_line_name, + "raw_name": raw_name, + "series_type": series_type, "color": color, "unit": unit, "precision": precision, @@ -290,6 +327,5 @@ def get_entire_diagram_data(field: str, ttl_seconds: int = 3600): 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 14358d4..a26c11a 100644 --- a/frontend/src/components/LmeView.tsx +++ b/frontend/src/components/LmeView.tsx @@ -9,12 +9,11 @@ import { Globe, X, LineChart as LineChartIcon, - ChevronLeft, - ChevronRight, Maximize2, Loader2, Calendar, - Sparkles + Eye, + EyeOff } from "lucide-react"; import { ResponsiveContainer, @@ -25,7 +24,6 @@ import { YAxis, Tooltip, CartesianGrid, - Legend } from "recharts"; import { API_BASE } from "@/lib/api"; @@ -83,12 +81,14 @@ export interface LmeData { export interface DiagramPoint { x: string; // YYYY-MM-DD - date_display: string; // DD/MM/YYYY + date_display: string; y: number; } export interface DiagramLine { name: string; + raw_name: string; + series_type: "price" | "stocks"; color: string; unit: string; precision: number; @@ -105,21 +105,6 @@ export interface DiagramResponse { 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); @@ -140,6 +125,10 @@ export function LmeView() { const [timeframe, setTimeframe] = useState("ENTIRE"); const [offsetIndex, setOffsetIndex] = useState(0); + // Visibility toggles for dual-series charts + const [showPrice, setShowPrice] = useState(true); + const [showStocks, setShowStocks] = useState(true); + // Load overview catalog const loadData = async () => { try { @@ -169,6 +158,8 @@ export function LmeView() { let alive = true; setDiagramLoading(true); setOffsetIndex(0); + setShowPrice(true); + setShowStocks(true); fetch(`${API}/api/lme/diagram?field=${encodeURIComponent(selectedSymbol.field)}`, { credentials: "include" @@ -199,11 +190,20 @@ export function LmeView() { return () => window.removeEventListener("keydown", handleKeyDown); }, []); + // Line separation: price line vs stock line + const priceLine = useMemo(() => { + return diagramData?.lines?.find((l) => l.series_type === "price") || diagramData?.lines?.[0]; + }, [diagramData]); + + const stockLine = useMemo(() => { + return diagramData?.lines?.find((l) => l.series_type === "stocks"); + }, [diagramData]); + // Filter main series by timeframe & offset const chartData = useMemo(() => { if (!diagramData?.lines?.length) return []; - const mainLine = diagramData.lines[0]; - const total = mainLine.points.length; + const baseLine = priceLine || diagramData.lines[0]; + const total = baseLine.points.length; if (!total) return []; let sliceCount = total; @@ -213,38 +213,36 @@ export function LmeView() { 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); + const windowPoints = baseLine.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])); + if (stockLine) { + const stockMap = new Map(stockLine.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, + priceVal: p.y, + stockVal: stockMap.get(p.x) ?? null, })); } return windowPoints.map((p) => ({ x: p.x, date_display: p.date_display, - [mainLine.name]: p.y, + priceVal: p.y, })); - }, [diagramData, timeframe, offsetIndex]); + }, [diagramData, priceLine, stockLine, 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 (!chartData.length) return null; + const values = chartData + .map((d) => d.priceVal) + .filter((v): v is number => typeof v === "number" && v > 0); if (!values.length) return null; const max = Math.max(...values); @@ -256,14 +254,14 @@ export function LmeView() { const pct = first > 0 ? (diff / first) * 100 : 0; return { max, min, avg, diff, pct, first, last }; - }, [chartData, diagramData]); + }, [chartData]); const canStepPrev = useMemo(() => { if (timeframe === "ENTIRE") return false; - const total = diagramData?.lines?.[0]?.points?.length || 0; + const total = (priceLine || 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]); + }, [timeframe, priceLine, diagramData, offsetIndex]); const canStepNext = useMemo(() => { return offsetIndex > 0 && timeframe !== "ENTIRE"; @@ -319,7 +317,7 @@ export function LmeView() { {data?.date && (
- تاریخ: {data.date} + تاریخ بازار: {data.date}
)} @@ -389,7 +387,7 @@ export function LmeView() { field: m.field, title: m.metal, name_fa: m.name_fa, - unit: "USD/mt", + unit: "دلار بر تن", currentPrice: m.cash_str, }) } @@ -400,7 +398,7 @@ export function LmeView() {
{m.name_fa}
-
{m.metal} • USD/mt
+
{m.metal} • دلار بر تن
نمودار کامل @@ -410,13 +408,13 @@ export function LmeView() {
-
تسویه نقدی (Cash)
+
تسویه نقدی
${m.cash_str}
-
۳ ماهه (3-Month)
+
قرارداد ۳ ماهه
${m.three_months_str}
@@ -479,7 +477,7 @@ export function LmeView() {
-
آخرین مظنه (EUR/100kg)
+
آخرین مظنه (یورو بر ۱۰۰ کیلو)
{s.price_str} €
@@ -565,15 +563,13 @@ export function LmeView() { {selectedSymbol && (
{/* Header */}
-
+
@@ -586,7 +582,7 @@ export function LmeView() {

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

@@ -600,29 +596,29 @@ export function LmeView() {
- {/* Quick Stat Pill */} + {/* Quick Stat Pills (100% Persian) */} {stats && (
-
آخرین قیمت (Latest)
+
آخرین قیمت
- {stats.last.toLocaleString()} {diagramData?.lines?.[0]?.unit || ""} + {stats.last.toLocaleString()} {priceLine?.unit || ""}
-
بالاترین دوره (Max)
+
بالاترین قیمت دوره
- {stats.max.toLocaleString()} {diagramData?.lines?.[0]?.unit || ""} + {stats.max.toLocaleString()} {priceLine?.unit || ""}
-
پایین‌ترین دوره (Min)
+
پایین‌ترین قیمت دوره
- {stats.min.toLocaleString()} {diagramData?.lines?.[0]?.unit || ""} + {stats.min.toLocaleString()} {priceLine?.unit || ""}
-
تغییر بازه انتخابی
+
تغییر در بازه انتخابی
= 0 ? "text-emerald-500" : "text-rose-500" @@ -634,12 +630,59 @@ export function LmeView() {
)} - {/* Chart Area */} -
+ {/* Chart Container */} +
+ {/* LEGEND & TOGGLE BAR (راهنمای خطوط و رنگ‌ها) */} +
+
+ راهنمای رنگ‌ها: + + {/* Price Line Toggle */} + {priceLine && ( + + )} + + {/* Stock Line Toggle (if exists) */} + {stockLine && ( + + )} +
+ + {stockLine && ( + + * محور راست: قیمت ({priceLine?.unit}) • محور چپ: موجودی انبار ({stockLine?.unit}) + + )} +
+ {diagramLoading ? (
- در حال بارگذاری دیتای کامل تاریخی (Entire Data)... + در حال بارگذاری تمام داده‌های تاریخی...
) : chartData.length === 0 ? (
@@ -648,14 +691,20 @@ export function LmeView() { ) : (
- + - + + + + + + + {/* X Axis */} + + {/* Right Y-Axis for Price */} v.toLocaleString()} + yAxisId="price" orientation="right" + domain={["auto", "auto"]} + tick={{ fontSize: 10, fill: "#D02E00" }} + tickFormatter={(v) => v.toLocaleString()} + hide={!showPrice} /> - {/* Tooltip styled exactly like Westmetall screenshot */} + + {/* Left Y-Axis for Warehouse Stocks (Scaled properly!) */} + {stockLine && ( + `${(v / 1000).toFixed(0)}k`} + hide={!showStocks} + /> + )} + + {/* Persian Tooltip */} { 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} +
+
+ تاریخ: {ptData.date_display || label}
+ + {showPrice && ptData.priceVal !== undefined && ( +
+
+ + {priceLine?.name || "قیمت"}: +
+ + {Number(ptData.priceVal).toLocaleString(undefined, { minimumFractionDigits: 2 })} {priceLine?.unit || ""} + +
+ )} + + {showStocks && ptData.stockVal !== null && ptData.stockVal !== undefined && ( +
+
+ + {stockLine?.name || "موجودی انبار"}: +
+ + {Number(ptData.stockVal).toLocaleString()} {stockLine?.unit || "تن"} + +
+ )}
); }} /> - {diagramData?.lines?.map((line, idx) => ( + + {/* Price Curve */} + {showPrice && ( - ))} + )} + + {/* Stock Curve (Mapped to Left Y-Axis!) */} + {showStocks && stockLine && ( + + )}
)} - {/* TIMEFRAME BAR — Exactly matching user's screenshot */} -
+ {/* TIMEFRAME BAR (100% Persian) */} +
{/* Step Backwards */} {/* 3 months */} @@ -732,13 +829,13 @@ export function LmeView() { setTimeframe("3M"); setOffsetIndex(0); }} - className={`rounded-lg border px-3 py-1.5 text-xs font-medium transition ${ + className={`rounded-xl border px-3.5 py-1.5 text-xs font-semibold 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 + ۳ ماه {/* 6 months */} @@ -747,13 +844,13 @@ export function LmeView() { setTimeframe("6M"); setOffsetIndex(0); }} - className={`rounded-lg border px-3 py-1.5 text-xs font-medium transition ${ + className={`rounded-xl border px-3.5 py-1.5 text-xs font-semibold 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 */} @@ -762,13 +859,13 @@ export function LmeView() { setTimeframe("1Y"); setOffsetIndex(0); }} - className={`rounded-lg border px-3 py-1.5 text-xs font-medium transition ${ + className={`rounded-xl border px-3.5 py-1.5 text-xs font-semibold 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 */} @@ -777,13 +874,13 @@ export function LmeView() { setTimeframe("2Y"); setOffsetIndex(0); }} - className={`rounded-lg border px-3 py-1.5 text-xs font-medium transition ${ + className={`rounded-xl border px-3.5 py-1.5 text-xs font-semibold 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) */} @@ -792,23 +889,23 @@ export function LmeView() { setTimeframe("ENTIRE"); setOffsetIndex(0); }} - className={`rounded-lg border px-3 py-1.5 text-xs font-bold transition ${ + className={`rounded-xl border px-4 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 */}