Single-binary self-hosted market watcher for stocks, ETFs, indexes, and futures: live charts, key stats, fundamentals, SEC filings, and SSE streaming.
axumdockerfinancerustself-hostedsqlitestocksvite
1{% extends "base.html" %}
2{% block title %}Markets{% endblock %}
3{% block description %}A live market overview — the major indexes, gold, crude, and bitcoin, each its own day chart — plus your personal watchlist.{% endblock %}
4{% block extra_css %}<link rel="stylesheet" href="{{ vite_asset('static_src/home/index.js', 'css') }}">{% endblock %}
5
6{% block main %}
7<div class="wrap dash">
8
9 {# Market-hours banner: makes the current session unmistakable. The dot colour
10 keys off data-session; hero.js keeps it live as the session flips. #}
11 <div class="dash-banner" data-role="session-banner" data-session="{{ session }}">
12 <span class="dash-banner__dot"></span>
13 <span class="dash-banner__label" data-role="session-label">{{ session_label }}</span>
14 <span class="dash-banner__sub" data-role="session-note">{% if session == 'closed' %}Prices update during market hours · ET{% else %}US equities · all times ET{% endif %}</span>
15 </div>
16
17 {# The market overview: one chart per instrument (the major indexes + gold,
18 crude and bitcoin), each on its own axis showing the day's actual values
19 (points or dollars) with its live value + % change from the open. Cash
20 indexes during the regular session, the E-mini futures off-hours. hero.js
21 builds the cards into the grid from /api/dashboard; this is its mount point. #}
22 <section class="hero-graph">
23 <div class="hero-graph__head">
24 <h1 class="hero-graph__title">Market overview</h1>
25 <span class="hero-graph__note">value & % change vs prev close</span>
26 </div>
27 {# Until /api/dashboard answers, the grid shows one shimmer-skeleton card
28 per overview slot — same shape as the real sparkline cards, so the page
29 doesn't jump when they land. hero.js hides this wrapper (display: contents,
30 so the skeletons are real grid items) on the first draw. #}
31 <div class="overview-grid" data-role="overview-grid">
32 <div class="ov-skels" data-role="hero-empty" aria-hidden="true">
33 {% for t in overview_tickers %}{% if loop.index <= 6 %}
34 <div class="ov-card ov-card--skel">
35 <span class="ov-skel ov-skel--name"></span>
36 <span class="ov-skel ov-skel--num"></span>
37 <div class="ov-card__chart ov-skel ov-skel--chart"></div>
38 </div>
39 {% endif %}{% endfor %}
40 </div>
41 </div>
42 </section>
43
44 {# Overview symbols as hidden data-ticker nodes: the live stream registers them
45 with the interest registry so the demand-driven intraday poll keeps their
46 bars fresh while the dashboard is open. The graph itself is drawn from
47 /api/dashboard; these carry no fields, so quote events no-op on them. #}
48 <div class="overview-interest" hidden aria-hidden="true">
49 {% for t in overview_tickers %}<span data-ticker="{{ t }}"></span>{% endfor %}
50 </div>
51
52 {# Sector heatmap: the 11 SPDR sector ETFs, coloured by today's move, so the
53 dashboard answers "what part of the market is driving it". hero.js fills the
54 grid from /api/dashboard; the hidden interest nodes keep the ETFs polled. #}
55 <section class="sectors">
56 <div class="sectors__head">
57 <h2 class="section-title">What’s moving</h2>
58 <span class="sectors__note">sectors · latest-session % move</span>
59 </div>
60 <div class="sectors__grid" data-role="sectors-grid" aria-busy="true"></div>
61 </section>
62 <div class="sectors-interest" hidden aria-hidden="true">
63 {% for t in sector_tickers %}<span data-ticker="{{ t }}"></span>{% endfor %}
64 </div>
65
66 {# Headline market reads (context the graph doesn't carry). VIX and SPY carry
67 data-ticker only (to keep them polled) and are patched by hero.js, which also
68 fills volume + the S&P trend. The S&P price itself lives on the graph now. #}
69 <div class="reads-head">
70 <span class="reads-head__asof" data-role="reads-asof">{% if reads.asof %}Prices as of {{ reads.asof|asof }}{% else %}Awaiting first quotes…{% endif %}</span>
71 <span class="reads-head__refresh" data-role="refresh-state" hidden><span class="reads-head__spinner" aria-hidden="true"></span>Refreshing…</span>
72 </div>
73 <section class="reads">
74 <div class="read read--lead">
75 <div class="read__label">S&P from high</div>
76 <div class="read__value num read__tone--{{ reads.drawdown_tone or 'steady' }}" data-role="drawdown-pct">{{ reads.drawdown_pct|pct if reads.drawdown_pct is not none else "—" }}</div>
77 <div class="read__sub" data-role="drawdown-label">{{ reads.drawdown_label or "Drawdown from record close" }}</div>
78 </div>
79
80 <div class="read" data-ticker="{{ vix }}">
81 <div class="read__label">Volatility · VIX</div>
82 <div class="read__value num" data-role="vix-level">{{ reads.vix_level|round(2) if reads.vix_level is not none else "—" }}</div>
83 <div class="read__sub read__tone read__tone--{{ reads.vix_tone or 'steady' }}" data-role="vix-tone">{{ reads.vix_tone|capitalize if reads.vix_tone else "—" }}</div>
84 </div>
85
86 <div class="read" data-ticker="{{ credit_proxy }}">
87 <div class="read__label">Credit · high yield</div>
88 <div class="read__value num read__tone--{{ reads.credit_tone or 'steady' }}" data-role="credit-pct">{{ reads.credit_pct|pct if reads.credit_pct is not none else "—" }}</div>
89 <div class="read__sub" data-role="credit-label">{{ reads.credit_label or "HYG vs prev close" }}</div>
90 </div>
91
92 <div class="read" data-ticker="{{ volume_proxy }}">
93 <div class="read__label">Market volume</div>
94 <div class="read__value num" data-role="volume">{{ reads.volume|compact if reads.volume is not none else "—" }}</div>
95 <div class="read__sub" data-role="volume-label">{% if reads.volume_label %}{{ reads.volume_label }} vs avg{% else %}—{% endif %}</div>
96 </div>
97
98 <div class="read">
99 <div class="read__label">S&P trend</div>
100 <div class="read__value read__value--text read__tone--{{ reads.sma_tone or 'steady' }}" data-role="sma-read">{{ reads.sma_read or "—" }}</div>
101 <div class="read__sub">50 & 200-day average</div>
102 </div>
103 </section>
104
105 {# The editable, session-scoped watchlist. #}
106 <section class="watch">
107 <div class="watch__head">
108 <h2 class="section-title">Watchlist</h2>
109 <form class="watch-add" data-role="watch-add" autocomplete="off">
110 <input class="watch-add__input" name="ticker" type="text" inputmode="text"
111 placeholder="Add a symbol — e.g. AAPL" aria-label="Add a symbol to your watchlist" maxlength="15">
112 <button class="watch-add__btn" type="submit">Add</button>
113 </form>
114 </div>
115 <p class="watch-msg" data-role="watch-msg" role="status" hidden></p>
116
117 {% if cards %}
118 {# Same chart-card shell as the overview. data-ticker registers the symbol
119 with the live stream (so its intraday bars stay polled); hero.js draws the
120 Schwab-day chart + refreshes the value/% from /api/dashboard. The remove
121 button sits outside the link so the whole card stays navigable. #}
122 <div class="overview-grid watch-grid">
123 {% for c in cards %}
124 <div class="ov-card ov-card--watch" data-ticker="{{ c.ticker }}" data-unit="{{ c.unit }}">
125 <a class="ov-card__link" href="/s/{{ c.ticker|urlencode }}">
126 <div class="ov-card__name ov-card__name--watch">{{ c.ticker }}<span class="ov-card__sub">{{ c.name }}</span></div>
127 <div class="ov-card__nums">
128 <span class="ov-card__value num">{{ c.price|money }}</span>
129 <span class="ov-card__chg num {% if c.change_pct is none %}is-flat{% elif c.change_pct >= 0 %}is-up{% else %}is-down{% endif %}">{{ c.change_pct|pct }}</span>
130 </div>
131 <div class="ov-card__chart"></div>
132 </a>
133 <button class="ov-card__remove" type="button" data-remove data-ticker="{{ c.ticker }}" aria-label="Remove {{ c.ticker }} from watchlist" title="Remove">×</button>
134 </div>
135 {% endfor %}
136 </div>
137 {% else %}
138 <p class="watch-empty">Your watchlist is empty. Add a stock or ETF above, or
139 <a href="/search">browse the universe</a> to find one.</p>
140 {% endif %}
141 </section>
142
143 {# Market movers: top gainers / losers / most active, the "what specific names
144 moved and why" detail. hero.js fills the three lists from /api/movers (an
145 8-minute server-side cache), after first paint so it never blocks the page. #}
146 <section class="movers">
147 <div class="movers__head">
148 <h2 class="section-title">Market movers <span class="movers__scope">S&P 500</span></h2>
149 <span class="movers__asof" data-role="movers-asof"></span>
150 </div>
151 <div class="movers__cols" data-role="movers" aria-busy="true">
152 <div class="movers__col" data-mv="gainers">
153 <h3 class="movers__title movers__title--up">Gainers</h3>
154 <div class="movers__list"></div>
155 </div>
156 <div class="movers__col" data-mv="losers">
157 <h3 class="movers__title movers__title--down">Losers</h3>
158 <div class="movers__list"></div>
159 </div>
160 <div class="movers__col" data-mv="actives">
161 <h3 class="movers__title">Most active</h3>
162 <div class="movers__list"></div>
163 </div>
164 </div>
165 </section>
166</div>
167{% endblock %}
168
169{% block extra_js %}<script type="module" src="{{ vite_asset('static_src/home/index.js') }}"></script>{% endblock %}