fix(ime): pre-fetch and embed real prices on all card faces, remove duplicate chart links and placeholders
This commit is contained in:
parent
bdc08dad52
commit
9a36873dbe
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue