From cf2ab562cca1ae83fb78a67790c35359bc126d62 Mon Sep 17 00:00:00 2001 From: alireza Date: Wed, 26 Aug 2026 11:04:33 +0330 Subject: [PATCH] =?UTF-8?q?feat(ime):=20add=20Iran=20Mercantile=20Exchange?= =?UTF-8?q?=20(=D8=A8=D9=88=D8=B1=D8=B3=20=D9=81=D9=84=D8=B2=D8=A7=D8=AA?= =?UTF-8?q?=20=D8=A7=DB=8C=D8=B1=D8=A7=D9=86)=20tab=20with=20categories,?= =?UTF-8?q?=201250+=20symbols,=20and=20historical=20price=20charts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/ime_metals.py | 132 ++++++ backend/main.py | 24 ++ frontend/src/components/ImeView.tsx | 616 ++++++++++++++++++++++++++++ frontend/src/routes/dashboard.tsx | 6 +- 4 files changed, 777 insertions(+), 1 deletion(-) create mode 100644 backend/ime_metals.py create mode 100644 frontend/src/components/ImeView.tsx diff --git a/backend/ime_metals.py b/backend/ime_metals.py new file mode 100644 index 0000000..cd71334 --- /dev/null +++ b/backend/ime_metals.py @@ -0,0 +1,132 @@ +import urllib.request +import json +import ssl +import time +import threading +from typing import Dict, Any, List, Optional + +IME_TOKEN = "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9uYW1lIjoiMDkzNjMxNDAyNjIiLCJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9uYW1laWRlbnRpZmllciI6IjE0ODgiLCJleHAiOjE3ODgzMjQ5MzN9.9r-XUKmRUBQ6uEOZGq09l474ZaDDPazNVUDHuXY2_QmBtS2RU78xJLt753GaGU2SZtpndzNYKWDYaoc58SNyAA" +BASE_URL = "https://api.yektazob.com" + +_cache_categories = { + "data": None, + "last_fetched": 0 +} +_cache_symbols = {} +_lock = threading.Lock() + +ctx = ssl.create_default_context() +ctx.check_hostname = False +ctx.verify_mode = ssl.CERT_NONE + +HEADERS = { + "Authorization": f"Bearer {IME_TOKEN}", + "Content-Type": "application/json", + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" +} + +def get_ime_categories(ttl_seconds: int = 3600) -> List[Dict[str, Any]]: + now = time.time() + with _lock: + if _cache_categories["data"] and (now - _cache_categories["last_fetched"]) < ttl_seconds: + return _cache_categories["data"] + + url = f"{BASE_URL}/Symbols/IME/Categories/GetAll" + payload = json.dumps({"PageNumber": 1, "PageSize": 1000}).encode('utf-8') + req = urllib.request.Request(url, data=payload, headers=HEADERS) + + with urllib.request.urlopen(req, context=ctx, timeout=12) as resp: + raw_cats = json.loads(resp.read().decode('utf-8')) + + cleaned = [] + for root in raw_cats: + root_title = root.get("title", "") + root_id = root.get("id") + subs = [] + for sub in root.get("children", []): + sub_id = sub.get("id") + sub_title = sub.get("title", "") + symbols = [] + for s in sub.get("symbols", []): + s_title = s.get("title", "") + parts = [p.strip() for p in s_title.split(" - ") if p.strip()] + product_name = s.get("productName") or (parts[0] if len(parts) > 0 else s_title) + symbol_code = s.get("symbol") or (parts[1] if len(parts) > 1 else "") + manufacturer = s.get("manufacturer") or (parts[2] if len(parts) > 2 else "") + + symbols.append({ + "id": s.get("id"), + "title": s_title, + "product_name": product_name, + "symbol_code": symbol_code, + "manufacturer": manufacturer, + "is_active": s.get("isActive", True), + "updated_at": s.get("updatedAt", "") + }) + + subs.append({ + "id": sub_id, + "title": sub_title, + "symbols_count": len(symbols), + "symbols": symbols + }) + + total_symbols = sum(s["symbols_count"] for s in subs) + cleaned.append({ + "id": root_id, + "title": root_title, + "subcategories_count": len(subs), + "total_symbols": total_symbols, + "subcategories": subs + }) + + with _lock: + _cache_categories["data"] = cleaned + _cache_categories["last_fetched"] = now + + return cleaned + + +def get_ime_symbol_detail(symbol_id: int, ttl_seconds: int = 1800) -> Dict[str, Any]: + now = time.time() + with _lock: + cached = _cache_symbols.get(symbol_id) + if cached and (now - cached["time"]) < ttl_seconds: + return cached["data"] + + url = f"{BASE_URL}/Symbols/IME/Symbols/Get/{symbol_id}" + req = urllib.request.Request(url, headers=HEADERS) + + with urllib.request.urlopen(req, context=ctx, timeout=12) as resp: + raw_detail = json.loads(resp.read().decode('utf-8')) + + raw_prices = raw_detail.get("symbolPrices", []) or [] + sorted_prices = sorted(raw_prices, key=lambda p: p.get("date", "")) + + clean_prices = [] + for p in sorted_prices: + d_str = p.get("date", "") + date_iso = d_str.split("T")[0] if "T" in d_str else d_str + clean_prices.append({ + "id": p.get("id"), + "date": date_iso, + "high": p.get("high"), + "low": p.get("low"), + "mid": p.get("mid") + }) + + result = { + "id": raw_detail.get("id"), + "cat_id": raw_detail.get("catID"), + "title": raw_detail.get("title"), + "order_no": raw_detail.get("orderNo"), + "small_desc": raw_detail.get("smallDesc"), + "total_points": len(clean_prices), + "latest_price": clean_prices[-1] if clean_prices else None, + "prices": clean_prices + } + + with _lock: + _cache_symbols[symbol_id] = {"time": now, "data": result} + + return result diff --git a/backend/main.py b/backend/main.py index 519aa36..ce06577 100644 --- a/backend/main.py +++ b/backend/main.py @@ -251,6 +251,23 @@ def get_crypto(_: object = Depends(require_session)): + +import ime_metals + +@app.get("/api/ime/categories") +def get_ime_categories_route(_: object = Depends(require_session)): + try: + return ime_metals.get_ime_categories() + except Exception as e: + raise HTTPException(status_code=502, detail=f"IME categories error: {e}") + +@app.get("/api/ime/symbol/{symbol_id}") +def get_ime_symbol_route(symbol_id: int, _: object = Depends(require_session)): + try: + return ime_metals.get_ime_symbol_detail(symbol_id) + except Exception as e: + raise HTTPException(status_code=502, detail=f"IME symbol error: {e}") + @app.get("/api/lme/diagram") def get_lme_diagram(field: str = Query("WM_Cu_low"), _: object = Depends(require_session)): try: @@ -448,6 +465,13 @@ def v1_world_economy(): return get_world_economy(None) + +@data_api.get("/ime/categories") +def v1_ime_categories(): return ime_metals.get_ime_categories() + +@data_api.get("/ime/symbol/{symbol_id}") +def v1_ime_symbol(symbol_id: int): return ime_metals.get_ime_symbol_detail(symbol_id) + @data_api.get("/lme/diagram") def v1_lme_diagram(field: str = "WM_Cu_low"): return scrape_lme.get_entire_diagram_data(field) diff --git a/frontend/src/components/ImeView.tsx b/frontend/src/components/ImeView.tsx new file mode 100644 index 0000000..fe21528 --- /dev/null +++ b/frontend/src/components/ImeView.tsx @@ -0,0 +1,616 @@ +import { useState, useEffect, useMemo } from "react"; +import { + Search, + Factory, + Layers, + ChevronDown, + LineChart as LineChartIcon, + X, + Loader2, + Calendar, + Sparkles, + ArrowUpRight, + TrendingUp, + TrendingDown, + Building2, + Hash +} from "lucide-react"; +import { + ResponsiveContainer, + ComposedChart, + Area, + Line, + XAxis, + YAxis, + Tooltip, + CartesianGrid +} from "recharts"; +import { API_BASE } from "@/lib/api"; + +const API = API_BASE; + +export interface ImeSymbol { + id: number; + title: string; + product_name: string; + symbol_code: string; + manufacturer: string; + is_active: boolean; + updated_at: string; +} + +export interface ImeSubcategory { + id: number; + title: string; + symbols_count: number; + symbols: ImeSymbol[]; +} + +export interface ImeCategory { + id: number; + title: string; + subcategories_count: number; + total_symbols: number; + subcategories: ImeSubcategory[]; +} + +export interface ImePricePoint { + id: number; + date: string; // YYYY-MM-DD + high: number; + low: number; + mid: number; +} + +export interface ImeSymbolDetail { + id: number; + cat_id: number; + title: string; + order_no?: number; + small_desc?: string; + total_points: number; + latest_price?: ImePricePoint; + prices: ImePricePoint[]; +} + +type Timeframe = "3M" | "6M" | "1Y" | "ENTIRE"; + +export function ImeView() { + const [categories, setCategories] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Filters + const [selectedRootId, setSelectedRootId] = useState("ALL"); + const [selectedSubId, setSelectedSubId] = useState("ALL"); + const [search, setSearch] = useState(""); + + // Modal Detail + const [selectedSymbol, setSelectedSymbol] = useState(null); + const [symbolDetail, setSymbolDetail] = useState(null); + const [detailLoading, setDetailLoading] = useState(false); + const [timeframe, setTimeframe] = useState("ENTIRE"); + + // Load all IME categories + useEffect(() => { + let alive = true; + setLoading(true); + fetch(`${API}/api/ime/categories`, { credentials: "include" }) + .then((r) => { + if (!r.ok) throw new Error(`HTTP ${r.status}`); + return r.json(); + }) + .then((data: ImeCategory[]) => { + if (alive) { + setCategories(data); + setError(null); + } + }) + .catch((err: any) => { + if (alive) setError(err.message || "خطا در دریافت اطلاعات بورس فلزات ایران"); + }) + .finally(() => { + if (alive) setLoading(false); + }); + + return () => { + alive = false; + }; + }, []); + + // Fetch symbol detail & price history when modal opens + useEffect(() => { + if (!selectedSymbol) { + setSymbolDetail(null); + return; + } + + let alive = true; + setDetailLoading(true); + setTimeframe("ENTIRE"); + + fetch(`${API}/api/ime/symbol/${selectedSymbol.id}`, { credentials: "include" }) + .then((r) => { + if (!r.ok) throw new Error(`HTTP ${r.status}`); + return r.json(); + }) + .then((data: ImeSymbolDetail) => { + if (alive) setSymbolDetail(data); + }) + .catch(() => { + if (alive) setSymbolDetail(null); + }) + .finally(() => { + if (alive) setDetailLoading(false); + }); + + return () => { + alive = false; + }; + }, [selectedSymbol]); + + // Handle escape key + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") setSelectedSymbol(null); + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, []); + + // Active subcategories based on root category selection + const currentSubcategories = useMemo(() => { + if (selectedRootId === "ALL") { + return []; + } + const cat = categories.find((c) => c.id === selectedRootId); + return cat ? cat.subcategories : []; + }, [categories, selectedRootId]); + + // Flattened symbols filtered by root, subcategory, and search query + const filteredSymbols = useMemo(() => { + let list: Array = []; + + for (const root of categories) { + if (selectedRootId !== "ALL" && root.id !== selectedRootId) continue; + + for (const sub of root.subcategories) { + if (selectedSubId !== "ALL" && sub.id !== selectedSubId) continue; + + for (const sym of sub.symbols) { + list.push({ + ...sym, + rootTitle: root.title, + subTitle: sub.title + }); + } + } + } + + if (!search.trim()) return list; + + const q = search.toLowerCase().trim(); + return list.filter( + (s) => + s.title.toLowerCase().includes(q) || + s.product_name.toLowerCase().includes(q) || + s.symbol_code.toLowerCase().includes(q) || + s.manufacturer.toLowerCase().includes(q) || + s.rootTitle.toLowerCase().includes(q) || + s.subTitle.toLowerCase().includes(q) + ); + }, [categories, selectedRootId, selectedSubId, search]); + + // Filtered prices for modal chart by timeframe + const chartPoints = useMemo(() => { + if (!symbolDetail?.prices?.length) return []; + const prices = symbolDetail.prices; + const total = prices.length; + + let sliceCount = total; + if (timeframe === "3M") sliceCount = 15; + else if (timeframe === "6M") sliceCount = 30; + else if (timeframe === "1Y") sliceCount = 60; + else if (timeframe === "ENTIRE") sliceCount = total; + + const start = Math.max(0, total - sliceCount); + return prices.slice(start); + }, [symbolDetail, timeframe]); + + // Stats calculation for the symbol modal + const stats = useMemo(() => { + if (!chartPoints.length) return null; + const mids = chartPoints.map((p) => p.mid).filter((v): v is number => typeof v === "number" && v > 0); + if (!mids.length) return null; + + const max = Math.max(...mids); + const min = Math.min(...mids); + const avg = mids.reduce((a, b) => a + b, 0) / mids.length; + const first = mids[0]; + const last = mids[mids.length - 1]; + const diff = last - first; + const pct = first > 0 ? (diff / first) * 100 : 0; + + return { max, min, avg, diff, pct, last }; + }, [chartPoints]); + + return ( +
+ {/* Header */} +
+
+
+ +
+
+

+ بورس فلزات ایران (IME) +

+

+ تابلوی معاملات فیزیکی بورس کالای ایران، نرخ‌های کشف‌شده و آرشیو تاریخی +

+
+
+ + {/* Global Search */} +
+ + 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" + /> +
+
+ + {error && ( +
+ {error} +
+ )} + + {/* Root Category Navigation Tabs */} +
+ + + {categories.map((cat) => ( + + ))} +
+ + {/* Subcategory Pills (when a root is selected) */} + {currentSubcategories.length > 0 && ( +
+ + + {currentSubcategories.map((sub) => ( + + ))} +
+ )} + + {/* Total symbols count indicator */} +
+ + نمایش {filteredSymbols.length.toLocaleString()} نماد معاملاتی + +
+ + {/* Loading state */} + {loading ? ( +
+ + در حال دریافت نمادهای بورس فلزات ایران... +
+ ) : filteredSymbols.length === 0 ? ( +
+ نمادی با این مشخصات یافت نشد. +
+ ) : ( + /* Symbols Cards Grid */ +
+ {filteredSymbols.slice(0, 120).map((sym) => ( +
setSelectedSymbol(sym)} + 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 flex flex-col justify-between" + > +
+
+
+ {sym.product_name || sym.title} +
+ {sym.symbol_code && ( + + {sym.symbol_code} + + )} +
+ + {sym.manufacturer && ( +
+ + {sym.manufacturer} +
+ )} +
+ +
+
+ + {sym.subTitle || sym.rootTitle} + +
+ + + نمودار سابقه + + +
+
+ ))} +
+ )} + + {/* FULL HISTORICAL MODAL FOR IME SYMBOL */} + {selectedSymbol && ( +
+
+ {/* Modal Header */} +
+
+
+ +
+
+

+ {selectedSymbol.product_name || selectedSymbol.title} +

+
+ {selectedSymbol.manufacturer && ( + + + {selectedSymbol.manufacturer} + + )} + {selectedSymbol.symbol_code && ( + + {selectedSymbol.symbol_code} + + )} +
+
+
+ + +
+ + {/* Quick Stats Pills */} + {stats && ( +
+
+
آخرین قیمت (ریال/کیلوگرم)
+
+ {stats.last.toLocaleString()} +
+
+ {(stats.last / 10).toLocaleString()} تومان +
+
+ +
+
بالاترین قیمت دوره
+
+ {stats.max.toLocaleString()} +
+
+ +
+
پایین‌ترین قیمت دوره
+
+ {stats.min.toLocaleString()} +
+
+ +
+
تغییر در بازه انتخابی
+
= 0 ? "text-emerald-500" : "text-rose-500" + }`} + > + {stats.diff >= 0 ? "+" : ""} + {stats.diff.toLocaleString()} ({stats.pct.toFixed(1)}%) +
+
+
+ )} + + {/* Chart Area */} +
+ {detailLoading ? ( +
+ + در حال دریافت سابقه قیمت‌های معاملاتی بورس کالا... +
+ ) : chartPoints.length === 0 ? ( +
+ اطلاعات قیمتی برای این نماد ثبت نشده است. +
+ ) : ( +
+ + + + + + + + + + + `${(v / 1000).toLocaleString()}k`} + orientation="right" + /> + { + if (!active || !payload?.length) return null; + const pt = payload[0].payload as ImePricePoint; + return ( +
+
+ تاریخ: {pt.date} +
+
+ میانگین موزون: + + {pt.mid.toLocaleString()} ریال + +
+ {pt.high !== pt.mid && ( +
+ بالاترین: + {pt.high.toLocaleString()} +
+ )} + {pt.low !== pt.mid && ( +
+ پایین‌ترین: + {pt.low.toLocaleString()} +
+ )} +
+ ); + }} + /> + +
+
+
+ )} + + {/* Timeframe selector bar */} +
+ + + + + + + +
+
+
+
+ )} +
+ ); +} diff --git a/frontend/src/routes/dashboard.tsx b/frontend/src/routes/dashboard.tsx index ccacdbd..839cd82 100644 --- a/frontend/src/routes/dashboard.tsx +++ b/frontend/src/routes/dashboard.tsx @@ -76,6 +76,8 @@ import { API_BASE } from "@/lib/api"; import { motion, AnimatePresence } from "framer-motion"; import { TubelightNavbar } from "@/components/TubelightNavbar"; import { LmeView } from "@/components/LmeView"; +import { ImeView } from "@/components/ImeView"; +import { Building2 } from "lucide-react"; const API = API_BASE; // dev: "" (same-origin via Vite proxy); prod: VITE_API_BASE @@ -89,12 +91,13 @@ export const Route = createFileRoute("/dashboard")({ component: Dashboard, }); -type View = "domestic" | "commodity" | "metals" | "lme" | "crypto" | "heatmap" | "steel" | "reports"; +type View = "domestic" | "commodity" | "metals" | "lme" | "ime" | "crypto" | "heatmap" | "steel" | "reports"; const VIEW_NAV: { key: View; name: string; icon: LucideIcon }[] = [ { key: "domestic", name: "بازار داخلی", icon: Banknote }, { key: "commodity", name: "کامودیتی", icon: Flame }, { key: "metals", name: "فلزات و فولاد", icon: Factory }, { key: "lme", name: "LME", icon: Globe }, + { key: "ime", name: "بورس فلزات ایران", icon: Building2 }, { key: "crypto", name: "کریپتو", icon: Bitcoin }, { key: "heatmap", name: "اقتصاد جهانی", icon: LayoutGrid }, { key: "steel", name: "سهام فولادی", icon: BarChart3 }, @@ -216,6 +219,7 @@ function Dashboard() { {view === "commodity" && } {view === "metals" && setJumpTo(null)} />} {view === "lme" && } + {view === "ime" && } {view === "crypto" && } {view === "heatmap" && } {view === "steel" && }