bbca-forecast / ui /render.py
nandutt's picture
Add BBCA forecast app
ed25d75 verified
Raw History Blame Contribute Delete
6.22 kB
"""HTML pieces for the page. Every dynamic string goes through esc()."""
from __future__ import annotations
import html
import math
import pandas as pd
from bbca.data import TZ
from bbca.backtest import Backtest
from bbca.model import Forecast
from bbca.service import Prediction
def esc(value) -> str:
return html.escape(str(value), quote=True)
def rp(x: float) -> str:
return f"Rp {x:,.0f}"
def pct(x: float, signed: bool = False) -> str:
if x is None or (isinstance(x, float) and math.isnan(x)):
return "n/a"
return f"{x * 100:+.2f}%" if signed else f"{x * 100:.2f}%"
def day(ts: pd.Timestamp) -> str:
return pd.Timestamp(ts).strftime("%a %d %b %Y")
def placeholder() -> str:
return (
'<div class="bb-board bb-empty">'
'<p class="bb-kicker">Waiting for a run</p>'
"<p>Press <b>Get fresh forecast</b>. The app pulls the latest BBCA prices, "
"refits the model and shows where the next closes are likely to land.</p>"
"</div>"
)
def error(message: str) -> str:
return f'<div class="bb-board bb-error"><p class="bb-kicker">No forecast</p><p>{esc(message)}</p></div>'
def board(pred: Prediction, run_no: int) -> str:
fc: Forecast = pred.forecast
prices = pred.prices
last_close = float(prices.closes.iloc[-1])
last_date = prices.closes.index[-1]
nxt = float(fc.mean[0])
move = nxt / fc.base_price - 1
width = (fc.upper[0] - fc.lower[0]) / nxt
tone = "up" if move > 0.0005 else "down" if move < -0.0005 else "flat"
live = ""
if prices.live_price is not None:
used = "used as the starting point" if fc.base_is_live else "not used (switched off)"
live = (
'<div class="bb-live"><span class="bb-dot"></span>'
f"Market open. Running price {esc(rp(prices.live_price))}, not a close yet, {esc(used)}.</div>"
)
demo = ""
if prices.source == "demo":
demo = '<div class="bb-demo">DEMO MODE · synthetic prices, not real BBCA data</div>'
base_label = "Running price" if fc.base_is_live else "Last close"
base_date = "today, intraday" if fc.base_is_live else day(last_date)
base_value = fc.base_price if fc.base_is_live else last_close
return f"""
<div class="bb-board">
{demo}
<div class="bb-run">RUN {run_no:03d} · {esc(pd.Timestamp.now(tz=TZ).strftime('%H:%M:%S'))} WIB</div>
<div class="bb-tiles">
<div class="bb-tile">
<p class="bb-kicker">{esc(base_label)}</p>
<p class="bb-num">{esc(rp(base_value))}</p>
<p class="bb-sub">{esc(base_date)}</p>
</div>
<div class="bb-tile bb-main bb-{tone}">
<p class="bb-kicker">Forecast · {esc(day(fc.dates[0]))}</p>
<p class="bb-num">{esc(rp(nxt))}</p>
<p class="bb-sub"><span class="bb-chip">{esc(pct(move, signed=True))}</span> vs {esc(base_label.lower())}</p>
</div>
<div class="bb-tile">
<p class="bb-kicker">95% range</p>
<p class="bb-num bb-num-sm">{esc(rp(fc.lower[0]))} – {esc(rp(fc.upper[0]))}</p>
<p class="bb-sub">±{esc(pct(width / 2))} around the forecast</p>
</div>
</div>
{live}
</div>"""
def table(fc: Forecast) -> str:
rows = []
prev = fc.base_price
for d, m, lo, hi in zip(fc.dates, fc.mean, fc.lower, fc.upper):
move = m / fc.base_price - 1
rows.append(
"<tr>"
f"<td>{esc(day(d))}</td>"
f"<td class='bb-r'>{esc(rp(m))}</td>"
f"<td class='bb-r bb-{('up' if m > prev else 'down' if m < prev else 'flat')}'>{esc(pct(move, signed=True))}</td>"
f"<td class='bb-r bb-muted'>{esc(rp(lo))} – {esc(rp(hi))}</td>"
"</tr>"
)
prev = m
return (
'<div class="bb-card"><p class="bb-kicker">Forecast by session</p>'
'<div class="bb-scroll"><table class="bb-table"><thead><tr>'
"<th>Session</th><th class='bb-r'>Close</th><th class='bb-r'>vs start</th><th class='bb-r'>95% range</th>"
"</tr></thead><tbody>" + "".join(rows) + "</tbody></table></div>"
'<p class="bb-note">Weekdays only. IDX holidays are not skipped, so a date can land on a day the market is closed. '
"The range widens fast: past a few days it says more about uncertainty than about price.</p></div>"
)
def backtest(b: Backtest) -> str:
gap = b.model_mape / b.naive_mape - 1 if b.naive_mape else float("nan")
if gap < -0.02:
verdict = f"SARIMA beat the naive guess, with {pct(-gap)} less error."
tone = "up"
elif gap <= 0.02:
verdict = "SARIMA and the naive guess were about even."
tone = "flat"
else:
verdict = f"SARIMA did worse than just repeating the last close ({pct(gap)} more error)."
tone = "down"
hit = "n/a" if math.isnan(b.direction_hit) else f"{b.direction_hit * 100:.0f}% of {b.direction_days} days"
return f"""
<div class="bb-card">
<p class="bb-kicker">How it did over the last {len(b.actual)} sessions</p>
<p class="bb-verdict bb-{tone}">{esc(verdict)}</p>
<div class="bb-scroll"><table class="bb-table">
<thead><tr><th></th><th class='bb-r'>MAPE</th><th class='bb-r'>MAE</th></tr></thead>
<tbody>
<tr><td>SARIMA, next-day</td><td class='bb-r'>{esc(pct(b.model_mape))}</td><td class='bb-r'>{esc(rp(b.model_mae))}</td></tr>
<tr><td>Naive: same as last close</td><td class='bb-r'>{esc(pct(b.naive_mape))}</td><td class='bb-r'>{esc(rp(b.naive_mae))}</td></tr>
</tbody>
</table></div>
<p class="bb-note">Up/down called right: {esc(hit)}. The model is fitted before this window and only sees each
real close after forecasting it. A low MAPE on daily prices is easy, since the naive guess already gets
{esc(pct(b.naive_mape))}. Beating that is the real test.</p>
</div>"""
def status(pred: Prediction) -> str:
p = pred.prices
src = "synthetic demo data" if p.source == "demo" else "Yahoo Finance (BBCA.JK, adjusted)"
return (
f'<p class="bb-status">Data: {esc(src)}, fetched {esc(p.fetched_at.strftime("%d %b %Y %H:%M"))} WIB. '
f"Model: SARIMA(0,1,0)(2,1,0)[5] on the last {pred.forecast.fit_points} sessions. "
"A statistics exercise, not investment advice.</p>"
)