File size: 6,219 Bytes
ed25d75
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
"""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>"
    )