feat: connect real intraday price ticks from DB to charts and remove RangeBar

This commit is contained in:
alireza 2026-08-26 14:24:00 +03:30
parent 39bd8c5bee
commit f96f39c962
2 changed files with 87 additions and 48 deletions

View File

@ -182,12 +182,23 @@ def _load_last_saved() -> None:
try: try:
with get_conn() as conn: with get_conn() as conn:
rows = conn.execute( rows = conn.execute(
f"SELECT name, price FROM {table} " f"SELECT name, price, change_val, pct, dir, fetched_at FROM {table} "
f"WHERE id IN (SELECT MAX(id) FROM {table} GROUP BY name)" f"WHERE id IN (SELECT MAX(id) FROM {table} GROUP BY name)"
).fetchall() ).fetchall()
loaded = []
for r in rows: for r in rows:
if r["price"] is not None: if r["price"] is not None:
_last_saved[kind][r["name"]] = r["price"] _last_saved[kind][r["name"]] = r["price"]
loaded.append({
"name": r["name"],
"price": r["price"],
"change_val": r["change_val"],
"pct": r["pct"],
"dir": r["dir"],
"fetched_at": r["fetched_at"]
})
if loaded and not _latest[kind]:
_latest[kind] = loaded
except Exception as e: except Exception as e:
print(f"[tgju] could not load last-saved {kind}: {e}") print(f"[tgju] could not load last-saved {kind}: {e}")
@ -230,13 +241,70 @@ async def scrape_loop() -> None:
await asyncio.sleep(INTERVAL_SECONDS) await asyncio.sleep(INTERVAL_SECONDS)
def get_real_day_series(kind: str, max_points: int = 28) -> dict[str, list[float]]:
table = _TABLES.get(kind)
if not table:
return {}
today_prefix = datetime.now(timezone.utc).strftime("%Y-%m-%d")
out: dict[str, list[float]] = {}
try:
with get_conn() as conn:
query = f"""
SELECT name, price FROM {table}
WHERE fetched_at LIKE ? AND price IS NOT NULL AND price > 0
ORDER BY name, id ASC
"""
rows = conn.execute(query, (f"{today_prefix}%",)).fetchall()
grouped: dict[str, list[float]] = {}
for r in rows:
name = r["name"]
if name not in grouped:
grouped[name] = []
grouped[name].append(r["price"])
for name, raw_prices in grouped.items():
if not raw_prices:
continue
deduped = [raw_prices[0]]
for p in raw_prices[1:]:
if p != deduped[-1]:
deduped.append(p)
if len(deduped) > max_points:
step = (len(deduped) - 1) / (max_points - 1)
sampled = [deduped[int(i * step)] for i in range(max_points - 1)]
sampled.append(deduped[-1])
deduped = sampled
out[name] = deduped
return out
except Exception as e:
print(f"[currency_stream] error loading day series for {kind}: {e}")
return {}
def _attach_sparklines(kind: str) -> list[dict]:
day_series = get_real_day_series(kind)
res = []
for r in _latest.get(kind, []):
name = r.get("name", "")
price = r.get("price", 0.0) or 0.0
pct = r.get("pct", 0.0) or 0.0
series = day_series.get(name)
if not series or len(series) < 2:
open_p = price / (1.0 + pct / 100.0) if (1.0 + pct / 100.0) > 0 else price
series = [round(open_p, 2), round(price, 2)]
elif series[-1] != price:
series = series + [price]
res.append({**r, "sparkline": series})
return res
def get_latest() -> list[dict]: def get_latest() -> list[dict]:
return _latest["currency"] return _attach_sparklines("currency")
def get_gold() -> list[dict]: def get_gold() -> list[dict]:
return _latest["gold"] return _attach_sparklines("gold")
def get_coin() -> list[dict]: def get_coin() -> list[dict]:
return _latest["coin"] return _attach_sparklines("coin")

View File

@ -237,6 +237,7 @@ type CurRow = {
pct: number; pct: number;
dir: "up" | "down" | ""; dir: "up" | "down" | "";
fetched_at: string; fetched_at: string;
sparkline?: number[];
}; };
function DomesticView() { function DomesticView() {
@ -297,7 +298,7 @@ function DomesticView() {
const allRows = useMemo(() => [...currency, ...gold, ...coin], [currency, gold, coin]); const allRows = useMemo(() => [...currency, ...gold, ...coin], [currency, gold, coin]);
const seriesInput = useMemo( const seriesInput = useMemo(
() => allRows.map((r) => ({ name: r.name, price: r.price, pct: r.pct })), () => allRows.map((r) => ({ name: r.name, price: r.price, pct: r.pct, sparkline: r.sparkline })),
[allRows], [allRows],
); );
const series = useRollingSeries(seriesInput); const series = useRollingSeries(seriesInput);
@ -694,8 +695,8 @@ function DeltaPill({
/* Rolling client-side price history for live snapshots that have no backend /* Rolling client-side price history for live snapshots that have no backend
history. Seeds each series with today's opennow (from pct), then appends history. Seeds each series with today's opennow (from pct), then appends
a point whenever the price ticks. */ a point whenever the price ticks. */
const SERIES_CAP = 24; const SERIES_CAP = 28;
function useRollingSeries(rows: { name: string; price: number; pct?: number }[]) { function useRollingSeries(rows: { name: string; price: number; pct?: number; sparkline?: number[] }[]) {
const [series, setSeries] = useState<Record<string, number[]>>({}); const [series, setSeries] = useState<Record<string, number[]>>({});
const ref = useRef<Record<string, number[]>>({}); const ref = useRef<Record<string, number[]>>({});
useEffect(() => { useEffect(() => {
@ -704,10 +705,18 @@ function useRollingSeries(rows: { name: string; price: number; pct?: number }[])
let changed = false; let changed = false;
for (const r of rows) { for (const r of rows) {
if (!Number.isFinite(r.price)) continue; if (!Number.isFinite(r.price)) continue;
// If backend provided real recorded market series from DB, use it directly!
if (r.sparkline && r.sparkline.length > 1) {
const existing = next[r.name];
if (!existing || existing.length !== r.sparkline.length || existing[existing.length - 1] !== r.price) {
next[r.name] = r.sparkline;
changed = true;
}
continue;
}
const cur = next[r.name]; const cur = next[r.name];
if (!cur) { if (!cur) {
// Seed with authentic market series showing real intraday peaks, troughs, and volatility next[r.name] = generateMarketSeries(r.name, r.price, r.pct, 28);
next[r.name] = generateMarketSeries(r.name, r.price, r.pct, 24);
changed = true; changed = true;
} else if (cur[cur.length - 1] !== r.price) { } else if (cur[cur.length - 1] !== r.price) {
const arr = cur.concat(r.price); const arr = cur.concat(r.price);
@ -954,43 +963,7 @@ function AssetBadge({ meta, size = "md" }: { meta: AssetMeta; size?: "md" | "lg"
domestic & commodity views. One big hero, a stack of side stats. */ domestic & commodity views. One big hero, a stack of side stats. */
type HeroFmt = (n: number) => string; type HeroFmt = (n: number) => string;
// Day low→high track with a marker at the current value (from the rolling series).
function RangeBar({
series,
value,
format,
}: {
series?: number[];
value: number;
format: HeroFmt;
}) {
if (!series || series.length < 2) return null;
const lo = Math.min(...series);
const hi = Math.max(...series);
if (hi <= lo) return null;
const pos = Math.min(100, Math.max(0, ((value - lo) / (hi - lo)) * 100));
return (
<div className="relative" dir="ltr">
<div className="mb-1 flex items-center justify-between text-[10px] font-mono text-muted-foreground">
<span>{format(lo)}</span>
<span className="text-[9px] opacity-70" dir="rtl">
کف سقف امروز
</span>
<span>{format(hi)}</span>
</div>
<div className="relative h-1.5 rounded-full bg-muted">
<div
className="absolute top-0 h-full rounded-full bg-foreground/15"
style={{ width: `${pos}%` }}
/>
<div
className="absolute top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-background bg-foreground shadow"
style={{ left: `${pos}%` }}
/>
</div>
</div>
);
}
function HeroCard({ item, format }: { item: KpiItem; format: HeroFmt }) { function HeroCard({ item, format }: { item: KpiItem; format: HeroFmt }) {
const { label, value, unit, pct, dir = "", series, meta } = item; const { label, value, unit, pct, dir = "", series, meta } = item;
@ -1044,8 +1017,6 @@ function HeroCard({ item, format }: { item: KpiItem; format: HeroFmt }) {
fillOpacity={0.3} fillOpacity={0.3}
/> />
</div> </div>
<RangeBar series={series} value={value} format={format} />
</motion.div> </motion.div>
); );
} }