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'
', 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}
+
+
+
+ نمودار کامل روند تاریخی قیمت نقدی، قراردادهای ۳ ماهه و موجودی انبارها
+
+
+
+
+
setSelectedMetal(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="بستن"
+ >
+
+
+
+
+ {/* 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 */}
+
+ 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"
+ }`}
+ >
+ {r === "ALL" ? "همه (YTD)" : 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} روز کاری)
+
+
+
+
+
+
+
+
+ تاریخ
+ تسویه نقدی (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} تن
+
+ ))}
+
+
+
+
+
+
+ )}
);
}