feat(ime/lme): 6-hour caching, remove polling timer, remove English symbol codes, and display latest prices on card faces

This commit is contained in:
alireza 2026-08-26 11:10:59 +03:30
parent cf2ab562cc
commit bdc08dad52
5 changed files with 232 additions and 100 deletions

View File

@ -2,16 +2,21 @@ import urllib.request
import json
import ssl
import time
import re
import threading
from typing import Dict, Any, List, Optional
IME_TOKEN = "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9uYW1lIjoiMDkzNjMxNDAyNjIiLCJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9uYW1laWRlbnRpZmllciI6IjE0ODgiLCJleHAiOjE3ODgzMjQ5MzN9.9r-XUKmRUBQ6uEOZGq09l474ZaDDPazNVUDHuXY2_QmBtS2RU78xJLt753GaGU2SZtpndzNYKWDYaoc58SNyAA"
BASE_URL = "https://api.yektazob.com"
# 6 hours cache
CACHE_TTL = 21600
_cache_categories = {
"data": None,
"last_fetched": 0
}
_cache_sub_symbols = {}
_cache_symbols = {}
_lock = threading.Lock()
@ -25,7 +30,31 @@ HEADERS = {
"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]]:
def clean_persian_title(title: str) -> tuple[str, str]:
"""
Removes English symbol codes (e.g. FTKC-BSG017OO-00, KHSS-DRIBRI-00)
and returns (clean_title, manufacturer).
"""
if not title:
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]
return f"{product} ({manufacturer})", manufacturer
elif len(parts) == 2:
if re.search(r'^[A-Za-z0-9_-]+$', parts[1]):
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 get_ime_categories(ttl_seconds: int = CACHE_TTL) -> List[Dict[str, Any]]:
now = time.time()
with _lock:
if _cache_categories["data"] and (now - _cache_categories["last_fetched"]) < ttl_seconds:
@ -48,18 +77,15 @@ def get_ime_categories(ttl_seconds: int = 3600) -> List[Dict[str, Any]]:
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 "")
raw_title = s.get("title", "")
clean_title, mfr = clean_persian_title(raw_title)
product_name = s.get("productName") or clean_title
symbols.append({
"id": s.get("id"),
"title": s_title,
"title": clean_title,
"product_name": product_name,
"symbol_code": symbol_code,
"manufacturer": manufacturer,
"manufacturer": s.get("manufacturer") or mfr,
"is_active": s.get("isActive", True),
"updated_at": s.get("updatedAt", "")
})
@ -86,8 +112,59 @@ def get_ime_categories(ttl_seconds: int = 3600) -> 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"]
def get_ime_symbol_detail(symbol_id: int, ttl_seconds: int = 1800) -> Dict[str, Any]:
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:
cached = _cache_symbols.get(symbol_id)
@ -104,21 +181,32 @@ def get_ime_symbol_detail(symbol_id: int, ttl_seconds: int = 1800) -> Dict[str,
sorted_prices = sorted(raw_prices, key=lambda p: p.get("date", ""))
clean_prices = []
last_valid_mid = None
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
mid_val = p.get("mid") or p.get("high") or p.get("low") or 0
if mid_val <= 0 and last_valid_mid is not None:
mid_val = last_valid_mid
else:
last_valid_mid = mid_val
clean_prices.append({
"id": p.get("id"),
"date": date_iso,
"high": p.get("high"),
"low": p.get("low"),
"mid": p.get("mid")
"high": p.get("high") or mid_val,
"low": p.get("low") or mid_val,
"mid": mid_val
})
raw_title = raw_detail.get("title", "")
clean_title, mfr = clean_persian_title(raw_title)
result = {
"id": raw_detail.get("id"),
"cat_id": raw_detail.get("catID"),
"title": raw_detail.get("title"),
"title": clean_title,
"manufacturer": mfr,
"order_no": raw_detail.get("orderNo"),
"small_desc": raw_detail.get("smallDesc"),
"total_points": len(clean_prices),

View File

@ -261,6 +261,14 @@ def get_ime_categories_route(_: object = Depends(require_session)):
except Exception as e:
raise HTTPException(status_code=502, detail=f"IME categories error: {e}")
@app.get("/api/ime/subcategory/{sub_id}/symbols")
def get_ime_sub_symbols_route(sub_id: int, _: object = Depends(require_session)):
try:
return ime_metals.get_ime_subcategory_symbols(sub_id)
except Exception as e:
raise HTTPException(status_code=502, detail=f"IME subcategory symbols error: {e}")
@app.get("/api/ime/symbol/{symbol_id}")
def get_ime_symbol_route(symbol_id: int, _: object = Depends(require_session)):
try:

View File

@ -222,7 +222,7 @@ def fetch_raw():
"precious_metals": precious
}
def get_lme_data(ttl_seconds: int = 180):
def get_lme_data(ttl_seconds: int = 21600): # 6 hours cache
now = time.time()
with _lock:
if _cache["data"] and (now - _cache["last_fetched"]) < ttl_seconds:
@ -239,7 +239,7 @@ def get_lme_data(ttl_seconds: int = 180):
raise e
return _cache["data"]
def get_entire_diagram_data(field: str, ttl_seconds: int = 3600):
def get_entire_diagram_data(field: str, ttl_seconds: int = 21600): # 6 hours cache
field_clean = field.strip()
field_clean = FIELD_MAP.get(field_clean.lower(), field_clean)

View File

@ -2,24 +2,18 @@ import { useState, useEffect, useMemo } from "react";
import {
Search,
Factory,
Layers,
ChevronDown,
LineChart as LineChartIcon,
X,
Loader2,
Calendar,
Sparkles,
ArrowUpRight,
TrendingUp,
TrendingDown,
Building2,
Hash
TrendingUp,
ArrowUpRight
} from "lucide-react";
import {
ResponsiveContainer,
ComposedChart,
Area,
Line,
XAxis,
YAxis,
Tooltip,
@ -29,14 +23,22 @@ import { API_BASE } from "@/lib/api";
const API = API_BASE;
export interface ImePricePoint {
id: number;
date: string; // YYYY-MM-DD
high: number;
low: number;
mid: number;
}
export interface ImeSymbol {
id: number;
title: string;
product_name: string;
symbol_code: string;
manufacturer: string;
is_active: boolean;
updated_at: string;
latest_price?: ImePricePoint;
}
export interface ImeSubcategory {
@ -54,18 +56,11 @@ export interface ImeCategory {
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;
manufacturer?: string;
order_no?: number;
small_desc?: string;
total_points: number;
@ -85,13 +80,17 @@ export function ImeView() {
const [selectedSubId, setSelectedSubId] = useState<number | "ALL">("ALL");
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
const [selectedSymbol, setSelectedSymbol] = useState<ImeSymbol | null>(null);
const [symbolDetail, setSymbolDetail] = useState<ImeSymbolDetail | null>(null);
const [detailLoading, setDetailLoading] = useState<boolean>(false);
const [timeframe, setTimeframe] = useState<Timeframe>("ENTIRE");
// Load all IME categories
// Load categories once on mount (no aggressive interval)
useEffect(() => {
let alive = true;
setLoading(true);
@ -118,7 +117,42 @@ export function ImeView() {
};
}, []);
// Fetch symbol detail & price history when modal opens
// 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
useEffect(() => {
if (!selectedSymbol) {
setSymbolDetail(null);
@ -160,16 +194,14 @@ export function ImeView() {
// Active subcategories based on root category selection
const currentSubcategories = useMemo(() => {
if (selectedRootId === "ALL") {
return [];
}
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
// Flattened symbols filtered by root, subcategory, and search query (100% clean of English codes)
const filteredSymbols = useMemo(() => {
let list: Array<ImeSymbol & { rootTitle: string; subTitle: string }> = [];
let list: Array<ImeSymbol & { rootTitle: string; subTitle: string; subId: number }> = [];
for (const root of categories) {
if (selectedRootId !== "ALL" && root.id !== selectedRootId) continue;
@ -178,10 +210,15 @@ 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
subTitle: sub.title,
subId: sub.id
});
}
}
@ -194,12 +231,11 @@ export function ImeView() {
(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]);
}, [categories, selectedRootId, selectedSubId, search, subPricesMap]);
// Filtered prices for modal chart by timeframe
const chartPoints = useMemo(() => {
@ -247,7 +283,7 @@ export function ImeView() {
بورس فلزات ایران (IME)
</h1>
<p className="text-xs text-muted-foreground mt-0.5">
تابلوی معاملات فیزیکی بورس کالای ایران، نرخهای کشفشده و آرشیو تاریخی
تابلوی معاملات فیزیکی بورس کالا آخرین نرخهای کشفشده و آرشیو تاریخی
</p>
</div>
</div>
@ -259,7 +295,7 @@ export function ImeView() {
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="جستجوی نماد، محصول یا تولیدکننده..."
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"
/>
</div>
@ -306,7 +342,7 @@ export function ImeView() {
))}
</div>
{/* Subcategory Pills (when a root is selected) */}
{/* Subcategory Pills */}
{currentSubcategories.length > 0 && (
<div className="flex items-center gap-1.5 overflow-x-auto pb-1 no-scrollbar border-t border-border/40 pt-3">
<button
@ -337,11 +373,17 @@ export function ImeView() {
</div>
)}
{/* Total symbols count indicator */}
{/* Indicator */}
<div className="flex items-center justify-between text-xs text-muted-foreground px-1">
<span>
نمایش <strong className="text-foreground">{filteredSymbols.length.toLocaleString()}</strong> نماد معاملاتی
</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>
{/* Loading state */}
@ -364,15 +406,8 @@ export function ImeView() {
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"
>
<div className="space-y-2">
<div className="flex items-start justify-between gap-2">
<div className="text-sm font-bold text-foreground group-hover:text-primary transition-colors line-clamp-2">
{sym.product_name || sym.title}
</div>
{sym.symbol_code && (
<span className="text-[10px] font-mono px-2 py-0.5 rounded-md bg-muted/80 text-muted-foreground border border-border/60 shrink-0">
{sym.symbol_code}
</span>
)}
<div className="text-sm font-bold text-foreground group-hover:text-primary transition-colors line-clamp-2">
{sym.product_name || sym.title}
</div>
{sym.manufacturer && (
@ -383,17 +418,36 @@ export function ImeView() {
)}
</div>
<div className="mt-4 pt-3 border-t border-border/40 flex items-center justify-between text-xs">
<div className="flex items-center gap-1.5">
<span className="text-[11px] px-2 py-0.5 rounded-md bg-primary/10 text-primary font-medium">
{sym.subTitle || sym.rootTitle}
</span>
{/* LATEST PRICE PROMINENTLY DISPLAYED ON CARD FACE */}
<div className="mt-4 pt-3 border-t border-border/40 space-y-2">
<div className="flex items-baseline justify-between">
<span className="text-[11px] text-muted-foreground">آخرین قیمت:</span>
<div className="text-left font-mono">
{sym.latest_price?.mid ? (
<div>
<div className="text-base font-bold text-primary">
{sym.latest_price.mid.toLocaleString()} <span className="text-[11px] font-normal text-muted-foreground">ریال</span>
</div>
<div className="text-[10px] text-muted-foreground">
{(Math.round(sym.latest_price.mid / 10)).toLocaleString()} تومان
</div>
</div>
) : (
<span className="text-xs font-semibold text-primary">مشاهده سابقه</span>
)}
</div>
</div>
<span className="text-[11px] font-medium text-primary flex items-center gap-1 group-hover:translate-x-[-2px] transition-transform">
<span>نمودار سابقه</span>
<ArrowUpRight className="w-3.5 h-3.5" />
</span>
<div className="flex items-center justify-between text-xs pt-1 border-t border-border/30">
<span className="text-[10px] px-2 py-0.5 rounded-md bg-muted/60 text-muted-foreground font-medium">
{sym.subTitle || sym.rootTitle}
</span>
<span className="text-[11px] font-medium text-primary flex items-center gap-1 group-hover:translate-x-[-2px] transition-transform">
<span>نمودار کامل</span>
<ArrowUpRight className="w-3.5 h-3.5" />
</span>
</div>
</div>
</div>
))}
@ -417,19 +471,12 @@ export function ImeView() {
<h2 className="text-lg sm:text-xl font-bold text-foreground">
{selectedSymbol.product_name || selectedSymbol.title}
</h2>
<div className="flex flex-wrap items-center gap-2 mt-1">
{selectedSymbol.manufacturer && (
<span className="text-xs text-muted-foreground flex items-center gap-1">
<Building2 className="w-3 h-3 text-primary/70" />
{selectedSymbol.manufacturer}
</span>
)}
{selectedSymbol.symbol_code && (
<span className="text-xs font-mono bg-muted/60 px-2 py-0.5 rounded text-muted-foreground">
{selectedSymbol.symbol_code}
</span>
)}
</div>
{selectedSymbol.manufacturer && (
<div className="text-xs text-muted-foreground flex items-center gap-1 mt-1">
<Building2 className="w-3.5 h-3.5 text-primary/70" />
<span>{selectedSymbol.manufacturer}</span>
</div>
)}
</div>
</div>
@ -446,26 +493,26 @@ export function ImeView() {
{stats && (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div className="rounded-2xl border border-border/60 bg-card/60 p-3.5">
<div className="text-[11px] text-muted-foreground">آخرین قیمت (ریال/کیلوگرم)</div>
<div className="text-[11px] text-muted-foreground">آخرین قیمت کشفشده</div>
<div className="text-base sm:text-lg font-bold font-mono text-foreground mt-0.5">
{stats.last.toLocaleString()}
{stats.last.toLocaleString()} ریال
</div>
<div className="text-[10px] text-muted-foreground mt-0.5">
{(stats.last / 10).toLocaleString()} تومان
{(Math.round(stats.last / 10)).toLocaleString()} تومان
</div>
</div>
<div className="rounded-2xl border border-border/60 bg-card/60 p-3.5">
<div className="text-[11px] text-muted-foreground">بالاترین قیمت دوره</div>
<div className="text-base sm:text-lg font-bold font-mono text-foreground mt-0.5">
{stats.max.toLocaleString()}
{stats.max.toLocaleString()} ریال
</div>
</div>
<div className="rounded-2xl border border-border/60 bg-card/60 p-3.5">
<div className="text-[11px] text-muted-foreground">پایینترین قیمت دوره</div>
<div className="text-base sm:text-lg font-bold font-mono text-foreground mt-0.5">
{stats.min.toLocaleString()}
{stats.min.toLocaleString()} ریال
</div>
</div>
@ -523,26 +570,17 @@ export function ImeView() {
return (
<div className="rounded-xl border border-border/80 bg-card/95 p-3 text-foreground shadow-2xl backdrop-blur-md space-y-1 min-w-[160px]" dir="rtl">
<div className="text-[11px] text-muted-foreground font-mono pb-1 border-b border-border/40">
تاریخ: {pt.date}
تاریخ معامله: {pt.date}
</div>
<div className="flex items-center justify-between text-xs pt-1">
<span className="text-muted-foreground">میانگین موزون:</span>
<span className="text-muted-foreground">قیمت پایانی:</span>
<span className="font-mono font-bold text-primary">
{pt.mid.toLocaleString()} ریال
</span>
</div>
{pt.high !== pt.mid && (
<div className="flex items-center justify-between text-[11px] text-muted-foreground">
<span>بالاترین:</span>
<span className="font-mono">{pt.high.toLocaleString()}</span>
</div>
)}
{pt.low !== pt.mid && (
<div className="flex items-center justify-between text-[11px] text-muted-foreground">
<span>پایینترین:</span>
<span className="font-mono">{pt.low.toLocaleString()}</span>
</div>
)}
<div className="text-[10px] text-muted-foreground">
معادل {(Math.round(pt.mid / 10)).toLocaleString()} تومان
</div>
</div>
);
}}
@ -550,7 +588,7 @@ export function ImeView() {
<Area
type="monotone"
dataKey="mid"
name="قیمت پایانی"
name="قیمت کشف‌شده"
stroke="#2563eb"
strokeWidth={2}
fill="url(#imePriceGrad)"

View File

@ -143,8 +143,6 @@ export function LmeView() {
useEffect(() => {
loadData();
const interval = setInterval(loadData, 30000);
return () => clearInterval(interval);
}, []);
// Fetch full multi-year diagram data whenever selectedSymbol changes