feat: connect real intraday price ticks from DB to charts and remove RangeBar
This commit is contained in:
parent
39bd8c5bee
commit
f96f39c962
|
|
@ -182,12 +182,23 @@ def _load_last_saved() -> None:
|
|||
try:
|
||||
with get_conn() as conn:
|
||||
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)"
|
||||
).fetchall()
|
||||
loaded = []
|
||||
for r in rows:
|
||||
if r["price"] is not None:
|
||||
_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:
|
||||
print(f"[tgju] could not load last-saved {kind}: {e}")
|
||||
|
||||
|
|
@ -230,13 +241,70 @@ async def scrape_loop() -> None:
|
|||
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]:
|
||||
return _latest["currency"]
|
||||
return _attach_sparklines("currency")
|
||||
|
||||
|
||||
def get_gold() -> list[dict]:
|
||||
return _latest["gold"]
|
||||
return _attach_sparklines("gold")
|
||||
|
||||
|
||||
def get_coin() -> list[dict]:
|
||||
return _latest["coin"]
|
||||
return _attach_sparklines("coin")
|
||||
|
|
|
|||
|
|
@ -237,6 +237,7 @@ type CurRow = {
|
|||
pct: number;
|
||||
dir: "up" | "down" | "";
|
||||
fetched_at: string;
|
||||
sparkline?: number[];
|
||||
};
|
||||
|
||||
function DomesticView() {
|
||||
|
|
@ -297,7 +298,7 @@ function DomesticView() {
|
|||
|
||||
const allRows = useMemo(() => [...currency, ...gold, ...coin], [currency, gold, coin]);
|
||||
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],
|
||||
);
|
||||
const series = useRollingSeries(seriesInput);
|
||||
|
|
@ -694,8 +695,8 @@ function DeltaPill({
|
|||
/* ── Rolling client-side price history — for live snapshots that have no backend
|
||||
history. Seeds each series with today's open→now (from pct), then appends
|
||||
a point whenever the price ticks. ─────────────────────────────────────── */
|
||||
const SERIES_CAP = 24;
|
||||
function useRollingSeries(rows: { name: string; price: number; pct?: number }[]) {
|
||||
const SERIES_CAP = 28;
|
||||
function useRollingSeries(rows: { name: string; price: number; pct?: number; sparkline?: number[] }[]) {
|
||||
const [series, setSeries] = useState<Record<string, number[]>>({});
|
||||
const ref = useRef<Record<string, number[]>>({});
|
||||
useEffect(() => {
|
||||
|
|
@ -704,10 +705,18 @@ function useRollingSeries(rows: { name: string; price: number; pct?: number }[])
|
|||
let changed = false;
|
||||
for (const r of rows) {
|
||||
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];
|
||||
if (!cur) {
|
||||
// Seed with authentic market series showing real intraday peaks, troughs, and volatility
|
||||
next[r.name] = generateMarketSeries(r.name, r.price, r.pct, 24);
|
||||
next[r.name] = generateMarketSeries(r.name, r.price, r.pct, 28);
|
||||
changed = true;
|
||||
} else if (cur[cur.length - 1] !== 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. ─── */
|
||||
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 }) {
|
||||
const { label, value, unit, pct, dir = "", series, meta } = item;
|
||||
|
|
@ -1044,8 +1017,6 @@ function HeroCard({ item, format }: { item: KpiItem; format: HeroFmt }) {
|
|||
fillOpacity={0.3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<RangeBar series={series} value={value} format={format} />
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue