fix(ime): pre-fetch and embed real prices on all card faces, remove duplicate chart links and placeholders

This commit is contained in:
alireza 2026-08-26 11:13:42 +03:30
parent bdc08dad52
commit 9a36873dbe
2 changed files with 61 additions and 122 deletions

View File

@ -4,6 +4,7 @@ import ssl
import time import time
import re import re
import threading import threading
from concurrent.futures import ThreadPoolExecutor
from typing import Dict, Any, List, Optional from typing import Dict, Any, List, Optional
IME_TOKEN = "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9uYW1lIjoiMDkzNjMxNDAyNjIiLCJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9uYW1laWRlbnRpZmllciI6IjE0ODgiLCJleHAiOjE3ODgzMjQ5MzN9.9r-XUKmRUBQ6uEOZGq09l474ZaDDPazNVUDHuXY2_QmBtS2RU78xJLt753GaGU2SZtpndzNYKWDYaoc58SNyAA" IME_TOKEN = "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9uYW1lIjoiMDkzNjMxNDAyNjIiLCJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9uYW1laWRlbnRpZmllciI6IjE0ODgiLCJleHAiOjE3ODgzMjQ5MzN9.9r-XUKmRUBQ6uEOZGq09l474ZaDDPazNVUDHuXY2_QmBtS2RU78xJLt753GaGU2SZtpndzNYKWDYaoc58SNyAA"
@ -16,7 +17,6 @@ _cache_categories = {
"data": None, "data": None,
"last_fetched": 0 "last_fetched": 0
} }
_cache_sub_symbols = {}
_cache_symbols = {} _cache_symbols = {}
_lock = threading.Lock() _lock = threading.Lock()
@ -39,7 +39,6 @@ def clean_persian_title(title: str) -> tuple[str, str]:
return "", "" return "", ""
parts = [p.strip() for p in title.split(" - ") if p.strip()] parts = [p.strip() for p in title.split(" - ") if p.strip()]
if len(parts) >= 3: if len(parts) >= 3:
# Check if middle part is an English symbol code
if re.search(r'^[A-Za-z0-9_-]+$', parts[1]): if re.search(r'^[A-Za-z0-9_-]+$', parts[1]):
product = parts[0] product = parts[0]
manufacturer = parts[2] manufacturer = parts[2]
@ -49,11 +48,21 @@ def clean_persian_title(title: str) -> tuple[str, str]:
return parts[0], "" return parts[0], ""
return f"{parts[0]} ({parts[1]})", parts[1] 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'\b[A-Za-z0-9]{3,}-[A-Za-z0-9_-]+\b', '', title)
cleaned = re.sub(r'\s*-\s*-+\s*', ' - ', cleaned).strip(" -") cleaned = re.sub(r'\s*-\s*-+\s*', ' - ', cleaned).strip(" -")
return cleaned or title, "" 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]]: def get_ime_categories(ttl_seconds: int = CACHE_TTL) -> List[Dict[str, Any]]:
now = time.time() now = time.time()
with _lock: 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: with urllib.request.urlopen(req, context=ctx, timeout=12) as resp:
raw_cats = json.loads(resp.read().decode('utf-8')) 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 = [] cleaned = []
for root in raw_cats: for root in raw_cats:
root_title = root.get("title", "") 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", "") sub_title = sub.get("title", "")
symbols = [] symbols = []
for s in sub.get("symbols", []): for s in sub.get("symbols", []):
s_id = s.get("id")
raw_title = s.get("title", "") raw_title = s.get("title", "")
clean_title, mfr = clean_persian_title(raw_title) clean_title, mfr = clean_persian_title(raw_title)
product_name = s.get("productName") or clean_title product_name = s.get("productName") or clean_title
latest_p = price_map.get(s_id)
symbols.append({ symbols.append({
"id": s.get("id"), "id": s_id,
"title": clean_title, "title": clean_title,
"product_name": product_name, "product_name": product_name,
"manufacturer": s.get("manufacturer") or mfr, "manufacturer": s.get("manufacturer") or mfr,
"latest_price": latest_p,
"is_active": s.get("isActive", True), "is_active": s.get("isActive", True),
"updated_at": s.get("updatedAt", "") "updated_at": s.get("updatedAt", "")
}) })
@ -112,58 +149,6 @@ def get_ime_categories(ttl_seconds: int = CACHE_TTL) -> List[Dict[str, Any]]:
return cleaned 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]: def get_ime_symbol_detail(symbol_id: int, ttl_seconds: int = CACHE_TTL) -> Dict[str, Any]:
now = time.time() now = time.time()
with _lock: 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} _cache_symbols[symbol_id] = {"time": now, "data": result}
return result return result
# Pre-warm cache on boot
threading.Thread(target=get_ime_categories, daemon=True).start()

View File

@ -5,9 +5,9 @@ import {
LineChart as LineChartIcon, LineChart as LineChartIcon,
X, X,
Loader2, Loader2,
Calendar,
Building2, Building2,
TrendingUp, Calendar,
Layers,
ArrowUpRight ArrowUpRight
} from "lucide-react"; } from "lucide-react";
import { import {
@ -80,17 +80,13 @@ export function ImeView() {
const [selectedSubId, setSelectedSubId] = useState<number | "ALL">("ALL"); const [selectedSubId, setSelectedSubId] = useState<number | "ALL">("ALL");
const [search, setSearch] = useState<string>(""); const [search, setSearch] = useState<string>("");
// Subcategory price cache (populated when subcategory is clicked)
const [subPricesMap, setSubPricesMap] = useState<Record<number, Record<number, ImePricePoint>>>({});
const [subLoading, setSubLoading] = useState<boolean>(false);
// Modal Detail // Modal Detail
const [selectedSymbol, setSelectedSymbol] = useState<ImeSymbol | null>(null); const [selectedSymbol, setSelectedSymbol] = useState<ImeSymbol | null>(null);
const [symbolDetail, setSymbolDetail] = useState<ImeSymbolDetail | null>(null); const [symbolDetail, setSymbolDetail] = useState<ImeSymbolDetail | null>(null);
const [detailLoading, setDetailLoading] = useState<boolean>(false); const [detailLoading, setDetailLoading] = useState<boolean>(false);
const [timeframe, setTimeframe] = useState<Timeframe>("ENTIRE"); const [timeframe, setTimeframe] = useState<Timeframe>("ENTIRE");
// Load categories once on mount (no aggressive interval) // Load enriched categories with embedded latest prices on mount
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
setLoading(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<ImeSymbol & { latest_price?: ImePricePoint }>) => {
if (!alive) return;
const pMap: Record<number, ImePricePoint> = {};
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 // Fetch symbol detail & full price history when modal opens
useEffect(() => { useEffect(() => {
if (!selectedSymbol) { if (!selectedSymbol) {
@ -199,9 +160,9 @@ export function ImeView() {
return cat ? cat.subcategories : []; return cat ? cat.subcategories : [];
}, [categories, selectedRootId]); }, [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(() => { const filteredSymbols = useMemo(() => {
let list: Array<ImeSymbol & { rootTitle: string; subTitle: string; subId: number }> = []; let list: Array<ImeSymbol & { rootTitle: string; subTitle: string }> = [];
for (const root of categories) { for (const root of categories) {
if (selectedRootId !== "ALL" && root.id !== selectedRootId) continue; if (selectedRootId !== "ALL" && root.id !== selectedRootId) continue;
@ -210,15 +171,10 @@ export function ImeView() {
if (selectedSubId !== "ALL" && sub.id !== selectedSubId) continue; if (selectedSubId !== "ALL" && sub.id !== selectedSubId) continue;
for (const sym of sub.symbols) { 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({ list.push({
...sym, ...sym,
latest_price: priceObj,
rootTitle: root.title, rootTitle: root.title,
subTitle: sub.title, subTitle: sub.title,
subId: sub.id
}); });
} }
} }
@ -235,7 +191,7 @@ export function ImeView() {
s.rootTitle.toLowerCase().includes(q) || s.rootTitle.toLowerCase().includes(q) ||
s.subTitle.toLowerCase().includes(q) s.subTitle.toLowerCase().includes(q)
); );
}, [categories, selectedRootId, selectedSubId, search, subPricesMap]); }, [categories, selectedRootId, selectedSubId, search]);
// Filtered prices for modal chart by timeframe // Filtered prices for modal chart by timeframe
const chartPoints = useMemo(() => { const chartPoints = useMemo(() => {
@ -378,12 +334,6 @@ export function ImeView() {
<span> <span>
نمایش <strong className="text-foreground">{filteredSymbols.length.toLocaleString()}</strong> نماد معاملاتی نمایش <strong className="text-foreground">{filteredSymbols.length.toLocaleString()}</strong> نماد معاملاتی
</span> </span>
{subLoading && (
<span className="flex items-center gap-1 text-[11px] text-primary">
<Loader2 className="w-3 h-3 animate-spin" />
<span>در حال بارگذاری قیمتهای زیردسته...</span>
</span>
)}
</div> </div>
{/* Loading state */} {/* Loading state */}
@ -421,32 +371,33 @@ export function ImeView() {
{/* LATEST PRICE PROMINENTLY DISPLAYED ON CARD FACE */} {/* LATEST PRICE PROMINENTLY DISPLAYED ON CARD FACE */}
<div className="mt-4 pt-3 border-t border-border/40 space-y-2"> <div className="mt-4 pt-3 border-t border-border/40 space-y-2">
<div className="flex items-baseline justify-between"> <div className="flex items-baseline justify-between">
<span className="text-[11px] text-muted-foreground">آخرین قیمت:</span> <span className="text-xs text-muted-foreground">آخرین قیمت:</span>
<div className="text-left font-mono"> <div className="text-left font-mono">
{sym.latest_price?.mid ? ( {sym.latest_price?.mid ? (
<div> <div>
<div className="text-base font-bold text-primary"> <div className="text-lg font-bold text-primary">
{sym.latest_price.mid.toLocaleString()} <span className="text-[11px] font-normal text-muted-foreground">ریال</span> {sym.latest_price.mid.toLocaleString()} <span className="text-xs font-normal text-muted-foreground">ریال</span>
</div> </div>
<div className="text-[10px] text-muted-foreground"> <div className="text-[11px] text-muted-foreground">
{(Math.round(sym.latest_price.mid / 10)).toLocaleString()} تومان {(Math.round(sym.latest_price.mid / 10)).toLocaleString()} تومان
</div> </div>
</div> </div>
) : ( ) : (
<span className="text-xs font-semibold text-primary">مشاهده سابقه</span> <span className="text-xs text-muted-foreground">ثبت معامله ندارد</span>
)} )}
</div> </div>
</div> </div>
<div className="flex items-center justify-between text-xs pt-1 border-t border-border/30"> <div className="flex items-center justify-between text-xs pt-1.5 border-t border-border/30">
<span className="text-[10px] px-2 py-0.5 rounded-md bg-muted/60 text-muted-foreground font-medium"> <span className="text-[10px] px-2 py-0.5 rounded-md bg-muted/60 text-muted-foreground font-medium">
{sym.subTitle || sym.rootTitle} {sym.subTitle || sym.rootTitle}
</span> </span>
<span className="text-[11px] font-medium text-primary flex items-center gap-1 group-hover:translate-x-[-2px] transition-transform"> {sym.latest_price?.date && (
<span>نمودار کامل</span> <span className="text-[10px] text-muted-foreground font-mono">
<ArrowUpRight className="w-3.5 h-3.5" /> {sym.latest_price.date}
</span> </span>
)}
</div> </div>
</div> </div>
</div> </div>