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:
|
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")
|
||||||
|
|
|
||||||
|
|
@ -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 open→now (from pct), then appends
|
history. Seeds each series with today's open→now (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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue