diff --git a/backend/ime_metals.py b/backend/ime_metals.py index 2127daa..d85d3ee 100644 --- a/backend/ime_metals.py +++ b/backend/ime_metals.py @@ -4,6 +4,7 @@ import ssl import time import re import threading +from concurrent.futures import ThreadPoolExecutor from typing import Dict, Any, List, Optional IME_TOKEN = "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9uYW1lIjoiMDkzNjMxNDAyNjIiLCJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9uYW1laWRlbnRpZmllciI6IjE0ODgiLCJleHAiOjE3ODgzMjQ5MzN9.9r-XUKmRUBQ6uEOZGq09l474ZaDDPazNVUDHuXY2_QmBtS2RU78xJLt753GaGU2SZtpndzNYKWDYaoc58SNyAA" @@ -16,7 +17,6 @@ _cache_categories = { "data": None, "last_fetched": 0 } -_cache_sub_symbols = {} _cache_symbols = {} _lock = threading.Lock() @@ -39,7 +39,6 @@ def clean_persian_title(title: str) -> tuple[str, str]: return "", "" parts = [p.strip() for p in title.split(" - ") if p.strip()] if len(parts) >= 3: - # Check if middle part is an English symbol code if re.search(r'^[A-Za-z0-9_-]+$', parts[1]): product = parts[0] manufacturer = parts[2] @@ -49,11 +48,21 @@ def clean_persian_title(title: str) -> tuple[str, str]: return parts[0], "" return f"{parts[0]} ({parts[1]})", parts[1] - # Remove any standalone English codes like FTKC-... cleaned = re.sub(r'\b[A-Za-z0-9]{3,}-[A-Za-z0-9_-]+\b', '', title) cleaned = re.sub(r'\s*-\s*-+\s*', ' - ', cleaned).strip(" -") return cleaned or title, "" +def fetch_sub_symbols(sub_id: int) -> tuple[int, List[Dict[str, Any]]]: + try: + url = f"{BASE_URL}/Symbols/IME/Symbols/GetAll" + payload = json.dumps({"PageNumber": 1, "PageSize": 1000, "CategoryId": sub_id}).encode('utf-8') + req = urllib.request.Request(url, data=payload, headers=HEADERS) + with urllib.request.urlopen(req, context=ctx, timeout=10) as resp: + data = json.loads(resp.read().decode('utf-8')) + return sub_id, data if isinstance(data, list) else [] + except: + return sub_id, [] + def get_ime_categories(ttl_seconds: int = CACHE_TTL) -> List[Dict[str, Any]]: now = time.time() with _lock: @@ -67,6 +76,31 @@ def get_ime_categories(ttl_seconds: int = CACHE_TTL) -> List[Dict[str, Any]]: with urllib.request.urlopen(req, context=ctx, timeout=12) as resp: raw_cats = json.loads(resp.read().decode('utf-8')) + # Collect subcategories for parallel price enrichment + sub_ids = [] + for root in raw_cats: + for sub in root.get("children", []): + sub_ids.append(sub.get("id")) + + # Enrich symbols with their latest prices in parallel + price_map: Dict[int, Dict[str, Any]] = {} + with ThreadPoolExecutor(max_workers=14) as executor: + results = list(executor.map(fetch_sub_symbols, sub_ids)) + + for s_id, sym_list in results: + for s in sym_list: + s_num_id = s.get("id") + prices = s.get("symbolPrices") or [] + if prices and s_num_id: + last_p = prices[-1] + price_map[s_num_id] = { + "id": last_p.get("id"), + "date": last_p.get("date", "").split("T")[0], + "high": last_p.get("high"), + "low": last_p.get("low"), + "mid": last_p.get("mid") + } + cleaned = [] for root in raw_cats: root_title = root.get("title", "") @@ -77,15 +111,18 @@ def get_ime_categories(ttl_seconds: int = CACHE_TTL) -> List[Dict[str, Any]]: sub_title = sub.get("title", "") symbols = [] for s in sub.get("symbols", []): + s_id = s.get("id") raw_title = s.get("title", "") clean_title, mfr = clean_persian_title(raw_title) product_name = s.get("productName") or clean_title + latest_p = price_map.get(s_id) symbols.append({ - "id": s.get("id"), + "id": s_id, "title": clean_title, "product_name": product_name, "manufacturer": s.get("manufacturer") or mfr, + "latest_price": latest_p, "is_active": s.get("isActive", True), "updated_at": s.get("updatedAt", "") }) @@ -112,58 +149,6 @@ def get_ime_categories(ttl_seconds: int = CACHE_TTL) -> List[Dict[str, Any]]: return cleaned -def get_ime_subcategory_symbols(sub_id: int, ttl_seconds: int = CACHE_TTL) -> List[Dict[str, Any]]: - """ - Fetches all symbols of a subcategory WITH their latest prices attached. - """ - now = time.time() - with _lock: - cached = _cache_sub_symbols.get(sub_id) - if cached and (now - cached["time"]) < ttl_seconds: - return cached["data"] - - url = f"{BASE_URL}/Symbols/IME/Symbols/GetAll" - payload = json.dumps({"PageNumber": 1, "PageSize": 1000, "CategoryId": sub_id}).encode('utf-8') - req = urllib.request.Request(url, data=payload, headers=HEADERS) - - with urllib.request.urlopen(req, context=ctx, timeout=12) as resp: - raw_syms = json.loads(resp.read().decode('utf-8')) - - results = [] - for s in raw_syms: - raw_title = s.get("title", "") - clean_title, mfr = clean_persian_title(raw_title) - product_name = s.get("productName") or clean_title - - # Latest price from symbolPrices - prices = s.get("symbolPrices") or [] - latest_p = None - if prices: - last = prices[-1] - latest_p = { - "id": last.get("id"), - "date": last.get("date", "").split("T")[0], - "high": last.get("high"), - "low": last.get("low"), - "mid": last.get("mid") - } - - results.append({ - "id": s.get("id"), - "cat_id": s.get("imeCategoryId") or sub_id, - "title": clean_title, - "product_name": product_name, - "manufacturer": s.get("manufacturer") or mfr, - "latest_price": latest_p, - "total_points": len(prices), - "is_active": s.get("isActive", True) - }) - - with _lock: - _cache_sub_symbols[sub_id] = {"time": now, "data": results} - - return results - def get_ime_symbol_detail(symbol_id: int, ttl_seconds: int = CACHE_TTL) -> Dict[str, Any]: now = time.time() with _lock: @@ -218,3 +203,6 @@ def get_ime_symbol_detail(symbol_id: int, ttl_seconds: int = CACHE_TTL) -> Dict[ _cache_symbols[symbol_id] = {"time": now, "data": result} return result + +# Pre-warm cache on boot +threading.Thread(target=get_ime_categories, daemon=True).start() diff --git a/frontend/src/components/ImeView.tsx b/frontend/src/components/ImeView.tsx index 8943311..a274e7e 100644 --- a/frontend/src/components/ImeView.tsx +++ b/frontend/src/components/ImeView.tsx @@ -5,9 +5,9 @@ import { LineChart as LineChartIcon, X, Loader2, - Calendar, Building2, - TrendingUp, + Calendar, + Layers, ArrowUpRight } from "lucide-react"; import { @@ -80,17 +80,13 @@ export function ImeView() { const [selectedSubId, setSelectedSubId] = useState("ALL"); const [search, setSearch] = useState(""); - // Subcategory price cache (populated when subcategory is clicked) - const [subPricesMap, setSubPricesMap] = useState>>({}); - const [subLoading, setSubLoading] = useState(false); - // Modal Detail const [selectedSymbol, setSelectedSymbol] = useState(null); const [symbolDetail, setSymbolDetail] = useState(null); const [detailLoading, setDetailLoading] = useState(false); const [timeframe, setTimeframe] = useState("ENTIRE"); - // Load categories once on mount (no aggressive interval) + // Load enriched categories with embedded latest prices on mount useEffect(() => { let alive = true; setLoading(true); @@ -117,41 +113,6 @@ export function ImeView() { }; }, []); - // When a specific subcategory is selected, fetch all its symbols with latest prices attached - useEffect(() => { - if (selectedSubId === "ALL") return; - - let alive = true; - setSubLoading(true); - - fetch(`${API}/api/ime/subcategory/${selectedSubId}/symbols`, { credentials: "include" }) - .then((r) => { - if (!r.ok) throw new Error(`HTTP ${r.status}`); - return r.json(); - }) - .then((symbols: Array) => { - if (!alive) return; - const pMap: Record = {}; - for (const s of symbols) { - if (s.latest_price) { - pMap[s.id] = s.latest_price; - } - } - setSubPricesMap((prev) => ({ - ...prev, - [selectedSubId as number]: pMap - })); - }) - .catch(() => {}) - .finally(() => { - if (alive) setSubLoading(false); - }); - - return () => { - alive = false; - }; - }, [selectedSubId]); - // Fetch symbol detail & full price history when modal opens useEffect(() => { if (!selectedSymbol) { @@ -199,9 +160,9 @@ export function ImeView() { return cat ? cat.subcategories : []; }, [categories, selectedRootId]); - // Flattened symbols filtered by root, subcategory, and search query (100% clean of English codes) + // Flattened symbols filtered by root, subcategory, and search query const filteredSymbols = useMemo(() => { - let list: Array = []; + let list: Array = []; for (const root of categories) { if (selectedRootId !== "ALL" && root.id !== selectedRootId) continue; @@ -210,15 +171,10 @@ export function ImeView() { if (selectedSubId !== "ALL" && sub.id !== selectedSubId) continue; for (const sym of sub.symbols) { - // Look up latest price from subPricesMap if available - const priceObj = subPricesMap[sub.id]?.[sym.id] || sym.latest_price; - list.push({ ...sym, - latest_price: priceObj, rootTitle: root.title, subTitle: sub.title, - subId: sub.id }); } } @@ -235,7 +191,7 @@ export function ImeView() { s.rootTitle.toLowerCase().includes(q) || s.subTitle.toLowerCase().includes(q) ); - }, [categories, selectedRootId, selectedSubId, search, subPricesMap]); + }, [categories, selectedRootId, selectedSubId, search]); // Filtered prices for modal chart by timeframe const chartPoints = useMemo(() => { @@ -378,12 +334,6 @@ export function ImeView() { نمایش {filteredSymbols.length.toLocaleString()} نماد معاملاتی - {subLoading && ( - - - در حال بارگذاری قیمت‌های زیردسته... - - )} {/* Loading state */} @@ -421,32 +371,33 @@ export function ImeView() { {/* LATEST PRICE PROMINENTLY DISPLAYED ON CARD FACE */}
- آخرین قیمت: + آخرین قیمت:
{sym.latest_price?.mid ? (
-
- {sym.latest_price.mid.toLocaleString()} ریال +
+ {sym.latest_price.mid.toLocaleString()} ریال
-
+
{(Math.round(sym.latest_price.mid / 10)).toLocaleString()} تومان
) : ( - مشاهده سابقه + ثبت معامله ندارد )}
-
+
{sym.subTitle || sym.rootTitle} - - نمودار کامل - - + {sym.latest_price?.date && ( + + {sym.latest_price.date} + + )}