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 %}{{ symbol.ticker }}{% endblock %}
3{% block description %}{{ symbol.name }} ({{ symbol.ticker }}): price chart, key stats, and history.{% endblock %}
4{% block extra_css %}<link rel="stylesheet" href="{{ vite_asset('static_src/symbol/index.js', 'css') }}">{% endblock %}
5
6{% from "includes/macros.html" import verdict_badge %}
7
8{# One financials table (annual or quarterly). Both are rendered; the toggle
9 script shows one. `active` decides which is visible on load. #}
10{% macro fin_panel(table, period, active) %}
11<div class="fin__panel" data-period="{{ period }}"{% if not active %} hidden{% endif %}>
12 {% if table.periods %}
13 <div class="fin__scroll">
14 <table class="fin__table">
15 <thead>
16 <tr><th scope="col"></th>{% for p in table.periods %}<th scope="col" class="num">{{ p }}</th>{% endfor %}</tr>
17 </thead>
18 <tbody>
19 {% for row in table.rows %}
20 <tr>
21 <th scope="row">{{ row.label }}</th>
22 {% for c in row.cells %}<td class="num{% if c.sense %} fin-cell--{{ c.sense }}{% endif %}">{{ c.display }}{% if c.dir %}<span class="fin-cell__dir fin-cell__dir--{{ c.dir }}" aria-hidden="true"></span>{% endif %}</td>{% endfor %}
23 </tr>
24 {% endfor %}
25 </tbody>
26 </table>
27 </div>
28 {% else %}
29 <p class="fin__empty">No {{ period }} figures have been reported.</p>
30 {% endif %}
31</div>
32{% endmacro %}
33
34{% block main %}
35<div class="wrap">
36 {# On-demand refresh control (Phase B). On load the page pulls the latest
37 data for this symbol (live price always; slow SEC / metadata only when
38 stale), showing the progress bar; the button re-pulls everything. The data
39 for each section carries its own "synced … ago" age in its heading. #}
40 <div class="refresh" data-refresh-root data-ticker="{{ symbol.ticker }}">
41 <button type="button" class="refresh__btn" data-refresh aria-label="Refresh all data">
42 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a9 9 0 1 1-2.64-6.36M21 3v6h-6"/></svg>
43 <span>Refresh</span>
44 </button>
45 <span class="refresh__status" data-refresh-status aria-live="polite"></span>
46 <div class="refresh__bar" data-refresh-bar hidden><div class="refresh__fill" data-refresh-fill></div></div>
47 </div>
48 {% if stale_data %}
49 <div class="stale-banner" role="note">
50 <strong>No recent trading data.</strong> The last price we hold for {{ symbol.ticker }}
51 is from {{ stale_data.last_date|shortdate }} ({{ stale_data.days }} days ago). This symbol
52 may be delisted, renamed, or halted — the figures below reflect its last active
53 session, not the current market.
54 </div>
55 {% endif %}
56 <header class="sym-head{% if health or etf_quality %} sym-head--has-health{% endif %}">
57 <div class="sym-head__id">
58 <h1 class="sym-head__ticker">{{ symbol.ticker }}</h1>
59 <span class="sym-head__tag">{{ symbol.kind }}</span>
60 {% if symbol.exchange %}<span class="sym-head__tag">{{ symbol.exchange }}</span>{% endif %}
61 {% if symbol.sector %}<span class="sym-head__tag">{{ symbol.sector }}</span>{% endif %}
62 {% if symbol.industry %}<span class="sym-head__tag">{{ symbol.industry }}</span>{% endif %}
63 </div>
64 <div class="sym-head__name">{{ symbol.name }}</div>
65 {# Prefer the live quote; fall back to the most recent daily close. The
66 stream client patches the data-field nodes in place as quotes arrive. #}
67 {% if quote or stats %}
68 {% set price = quote.price if quote else stats.close %}
69 {% set chg_abs = quote.change_abs if quote else stats.change_abs %}
70 {% set chg_pct = quote.change_pct if quote else stats.change_pct %}
71 <div class="sym-head__quote" data-ticker="{{ symbol.ticker }}">
72 <span class="sym-head__price num" data-field="price">{{ price|money }}</span>
73 {% if chg_pct is not none %}
74 <span class="sym-head__chg num {{ 'is-up' if chg_pct >= 0 else 'is-down' }}" data-field="change">
75 {{ chg_abs|signed }} ({{ chg_pct|pct }})
76 </span>
77 {% endif %}
78 <span class="sym-head__asof">{% if quote %}{{ quote.state_label }}{% if symbol.last_quote_at %} · <span data-field="quoted">quoted {{ symbol.last_quote_at|ago }}</span>{% endif %}{% else %}Last close · {{ stats.date|shortdate }}{% endif %}</span>
79 </div>
80 {% endif %}
81
82 {# Mobile health verdict line (Phase 6): the same read the donut carries on
83 desktop, flattened into a one-line "verdict · % · trajectory" band that
84 sits directly under the price where a phone reads it first. Hidden on
85 desktop, where the donut (below) is the treatment. Works for a stock
86 (`health`, with a trajectory arrow) or an ETF (`etf_quality`, no
87 trajectory); a symbol only ever has one. #}
88 {% if health or etf_quality %}
89 {% set verdict = health if health else etf_quality %}
90 <div class="sym-verdict sym-verdict--{{ verdict.overall }}">
91 <span class="sym-verdict__dot" aria-hidden="true"></span>
92 <span class="sym-verdict__text">{{ verdict.verdict }}</span>
93 <span class="sym-verdict__pct num">{{ verdict.percent }}%</span>
94 {% if health and health.trajectory != 'unknown' %}
95 <span class="sym-verdict__traj sym-verdict__traj--{{ health.trajectory }}">
96 <span class="sym-verdict__arrow" aria-hidden="true">{% if health.trajectory == 'good' %}↑{% elif health.trajectory == 'bad' %}↓{% else %}→{% endif %}</span>{{ health.trajectory_label }}
97 </span>
98 {% endif %}
99 </div>
100 {% endif %}
101
102 {# Stock health badge (Phase 17, redesigned): a circular % donut anchors
103 the top-right of the header. Hovering or focusing reveals the three
104 sub-readings (fundamentals / trajectory / leadership) — the previous
105 full Stock-health section is gone in favour of this glanceable badge. #}
106 {% if health %}
107 <div class="sym-head__health">
108 <button type="button" class="health-badge health-badge--{{ health.overall }}"
109 aria-label="Stock health: {{ health.verdict }}, {{ health.percent }}%"
110 aria-describedby="health-pop">
111 {# r = 50/π ≈ 15.9155 makes the circle's circumference exactly 100,
112 so stroke-dasharray "N, 100" fills N% of the ring with no maths
113 in the template. #}
114 <svg class="health-badge__ring" viewBox="0 0 36 36" aria-hidden="true">
115 <circle class="health-badge__track" cx="18" cy="18" r="15.9155" fill="none"/>
116 <circle class="health-badge__fill" cx="18" cy="18" r="15.9155" fill="none"
117 stroke-dasharray="{{ health.percent }}, 100"/>
118 </svg>
119 <span class="health-badge__num num">
120 <span class="health-badge__pct">{{ health.percent }}</span><span class="health-badge__unit">%</span>
121 </span>
122 <span class="health-badge__caption">{{ health.verdict }}</span>
123 </button>
124 <div class="health-pop" id="health-pop" role="tooltip">
125 <p class="health-pop__head">Overall <strong class="health-pop__verdict health-pop__verdict--{{ health.overall }}">{{ health.verdict }}</strong> · <span class="num">{{ health.percent }}%</span></p>
126 <ul class="health-pop__rows">
127 <li class="health-pop__row health-pop__row--{{ health.strength }}">
128 <span class="health-pop__label">Fundamentals</span>
129 <span class="health-pop__value">{{ health.strength_label }}</span>
130 </li>
131 <li class="health-pop__row health-pop__row--{{ health.trajectory }}">
132 <span class="health-pop__label">Trajectory</span>
133 <span class="health-pop__value">{{ health.trajectory_label }}</span>
134 </li>
135 <li class="health-pop__row health-pop__row--{{ health.stability }}">
136 <span class="health-pop__label">Leadership</span>
137 <span class="health-pop__value">
138 {%- if health.stability == 'unknown' -%}
139 —
140 {%- else -%}
141 {{ health.stability_label }}
142 {%- endif -%}
143 </span>
144 </li>
145 </ul>
146 <p class="health-pop__src">A read across fundamentals, recent price & growth trajectory, and leadership stability. For fun and reading at a glance, not investment advice.</p>
147 </div>
148 </div>
149 {% endif %}
150
151 {# ETF quality badge (Phase 4): the fund-side sibling of the stock health
152 donut. Same glanceable % ring, but the four sub-readings are the wrapper's
153 quality — cost, tracking (price vs NAV), diversification, size. Reuses the
154 `health-badge` / `health-pop` styling; a symbol is either a stock or an
155 ETF, so only one of the two badges ever renders. #}
156 {% if etf_quality %}
157 <div class="sym-head__health sym-head__health--etf">
158 <button type="button" class="health-badge health-badge--{{ etf_quality.overall }}"
159 aria-label="ETF quality: {{ etf_quality.verdict }}, {{ etf_quality.percent }}%"
160 aria-describedby="etf-quality-pop">
161 <svg class="health-badge__ring" viewBox="0 0 36 36" aria-hidden="true">
162 <circle class="health-badge__track" cx="18" cy="18" r="15.9155" fill="none"/>
163 <circle class="health-badge__fill" cx="18" cy="18" r="15.9155" fill="none"
164 stroke-dasharray="{{ etf_quality.percent }}, 100"/>
165 </svg>
166 <span class="health-badge__num num">
167 <span class="health-badge__pct">{{ etf_quality.percent }}</span><span class="health-badge__unit">%</span>
168 </span>
169 <span class="health-badge__caption">{{ etf_quality.verdict }}</span>
170 </button>
171 <div class="health-pop" id="etf-quality-pop" role="tooltip">
172 <p class="health-pop__head">ETF quality <strong class="health-pop__verdict health-pop__verdict--{{ etf_quality.overall }}">{{ etf_quality.verdict }}</strong> · <span class="num">{{ etf_quality.percent }}%</span></p>
173 <ul class="health-pop__rows">
174 <li class="health-pop__row health-pop__row--{{ etf_quality.cost }}">
175 <span class="health-pop__label">Cost</span>
176 <span class="health-pop__value">{{ etf_quality.cost_label }}</span>
177 </li>
178 <li class="health-pop__row health-pop__row--{{ etf_quality.tracking }}">
179 <span class="health-pop__label">Tracking</span>
180 <span class="health-pop__value">{{ etf_quality.tracking_label }}</span>
181 </li>
182 <li class="health-pop__row health-pop__row--{{ etf_quality.diversification }}">
183 <span class="health-pop__label">Diversification</span>
184 <span class="health-pop__value">{{ etf_quality.diversification_label }}</span>
185 </li>
186 <li class="health-pop__row health-pop__row--{{ etf_quality.size }}">
187 <span class="health-pop__label">Size</span>
188 <span class="health-pop__value">{{ etf_quality.size_label }}</span>
189 </li>
190 </ul>
191 <p class="health-pop__src">A read on the fund wrapper: cost, how tightly it tracks NAV, diversification, and size. For reading at a glance, not investment advice.</p>
192 </div>
193 </div>
194 {% endif %}
195 </header>
196
197 {# Chart and key stats need daily history. A future has none by design
198 (live quotes only), and so do the historyless indexes; those fall to the
199 explanatory empty state below instead of an empty chart. #}
200 {% if stats %}
201 <section class="panel chart-panel">
202 <div class="chart-bar">
203 {# Daily-candle ranges. Default 1Y. (The old 1D / 1W intraday ranges were
204 dropped — the demand-only model can't keep enough 15m bars to draw them
205 legibly; short-range detail comes from the denser-candle ranges.) #}
206 <div class="range-bar">
207 {% for r in ["YTD", "1M", "3M", "6M", "1Y", "3Y", "5Y", "MAX"] %}
208 <button type="button" class="range-btn{% if r == '1Y' %} is-active{% endif %}" data-range="{{ r }}">{{ r }}</button>
209 {% endfor %}
210 </div>
211 {# Filled by chart.js after each load: the % / absolute move over the
212 visible range, so the headline change agrees with the chart. #}
213 <p class="range-summary" id="range-summary" hidden></p>
214 </div>
215 {# Indicator toggles. `is-active` is the initial visibility; chart.js reads
216 it and paints each swatch from its own ink palette. #}
217 <div class="ind-bar" role="group" aria-label="Chart indicators">
218 {% for ind in [
219 {"key": "sma50", "label": "SMA 50", "on": true, "dot": true, "hide": false},
220 {"key": "sma200", "label": "SMA 200", "on": true, "dot": true, "hide": false},
221 {"key": "ema21", "label": "EMA 21", "on": false, "dot": true, "hide": false},
222 {"key": "supertrend", "label": "Supertrend", "on": false, "dot": true, "hide": false},
223 {"key": "volume", "label": "Volume", "on": true, "dot": false, "hide": false},
224 {"key": "rsi", "label": "RSI", "on": true, "dot": true, "hide": false},
225 {"key": "benchmark", "label": (symbol.benchmark or "Benchmark"), "on": false, "dot": true, "hide": (not symbol.benchmark)}
226 ] %}
227 <button type="button" class="ind-btn{% if ind.on %} is-active{% endif %}"
228 data-ind="{{ ind.key }}" aria-pressed="{{ 'true' if ind.on else 'false' }}"
229 {% if ind.hide %}hidden{% endif %}>
230 {% if ind.dot %}<span class="ind-btn__dot" aria-hidden="true"></span>{% endif %}{{ ind.label }}
231 </button>
232 {% endfor %}
233 </div>
234 <div id="chart" data-ticker="{{ symbol.ticker }}"></div>
235 </section>
236
237 {# Colour-coded read of the chart's indicators: an overall trend verdict, an
238 RSI momentum gauge, and one tile per moving-average signal. Mechanical, not
239 advice. #}
240 {% if indicators %}
241 <section class="panel ind-read">
242 <div class="ind-read__head">
243 <h2 class="ind-read__title">What the indicators say</h2>
244 <span class="ind-verdict ind-verdict--{{ indicators.verdict_tone }}">
245 <span class="ind-verdict__dot" aria-hidden="true"></span>{{ indicators.verdict }}
246 <span class="ind-verdict__note">{{ indicators.verdict_note }}</span>
247 </span>
248 </div>
249
250 {# RSI momentum gauge: a 0–100 track with oversold / overbought zones and a
251 marker at the current value, coloured by its zone. #}
252 <div class="ind-rsi ind-tone--{{ indicators.rsi_tone }}">
253 <div class="ind-rsi__top">
254 <span class="ind-rsi__label">Momentum · RSI 14</span>
255 <span class="ind-rsi__verdict">{{ indicators.rsi_label }}</span>
256 </div>
257 <div class="ind-rsi__track">
258 <span class="ind-rsi__zone ind-rsi__zone--over"></span>
259 <span class="ind-rsi__zone ind-rsi__zone--under"></span>
260 <span class="ind-rsi__marker" style="left: {{ indicators.rsi_pos }}%">
261 <span class="ind-rsi__bubble num">{{ indicators.rsi|round(0)|int }}</span>
262 </span>
263 </div>
264 <div class="ind-rsi__scale">
265 <span>0</span><span>oversold 30</span><span>overbought 70</span><span>100</span>
266 </div>
267 <p class="ind-rsi__note">{{ indicators.rsi_note }}</p>
268 </div>
269
270 {# Trend signal tiles, colour-coded bullish / bearish. #}
271 <div class="ind-signals">
272 {% for sig in indicators.signals %}
273 <div class="ind-sig ind-tone--{{ sig.tone }}">
274 <div class="ind-sig__top">
275 <span class="ind-sig__label">{{ sig.label }}</span>
276 {% if sig.value %}<span class="ind-sig__value num">{{ sig.value }}</span>{% endif %}
277 </div>
278 <div class="ind-sig__status">{{ sig.status }}</div>
279 <div class="ind-sig__note">{{ sig.note }}</div>
280 </div>
281 {% endfor %}
282 </div>
283
284 <p class="ind-read__foot">A mechanical reading of the chart’s indicators — not investment advice.</p>
285 </section>
286 {% endif %}
287
288 <h2 class="section-title">Key stats<span class="section-title__asof">as of {{ stats.date|shortdate }}</span></h2>
289 <section class="keystats">
290
291 {# --- the trading day: open & close placed in the day's range --- #}
292 <div class="gauge">
293 <div class="gauge__row">
294 <span class="gauge__label">The day</span>
295 <span class="gauge__meta">open and close within the day's range</span>
296 </div>
297 <div class="track gauge__track">
298 <span class="track__pip track__pip--ghost" style="left:{{ stats.day_open_pos }}%"></span>
299 <span class="track__pip track__pip--{{ 'down' if stats.change_pct is not none and stats.change_pct < 0 else 'up' }}" style="left:{{ stats.day_close_pos }}%"></span>
300 </div>
301 <div class="gauge__ends">
302 <span><span class="gauge__cap">Low</span> <span class="num">{{ stats.low|money }}</span></span>
303 <span class="gauge__ends-r"><span class="gauge__cap">High</span> <span class="num">{{ stats.high|money }}</span></span>
304 </div>
305 <dl class="legend">
306 <div class="legend__item">
307 <dt><i class="legend__dot legend__dot--ghost"></i> Open</dt>
308 <dd class="num">{{ stats.open|money }}</dd>
309 {% if stats.open_change_pct is not none %}
310 <dd class="legend__delta num {{ 'is-up' if stats.open_change_pct >= 0 else 'is-down' }}">{{ stats.open_change_pct|pct }}</dd>
311 {% else %}<dd></dd>{% endif %}
312 </div>
313 <div class="legend__item">
314 <dt><i class="legend__dot legend__dot--{{ 'down' if stats.change_pct is not none and stats.change_pct < 0 else 'up' }}"></i> Close</dt>
315 <dd class="num">{{ stats.close|money }}</dd>
316 {% if stats.change_pct is not none %}
317 <dd class="legend__delta num {{ 'is-up' if stats.change_pct >= 0 else 'is-down' }}">{{ stats.change_pct|pct }}</dd>
318 {% else %}<dd></dd>{% endif %}
319 </div>
320 </dl>
321 </div>
322
323 {# --- 52-week range: current price & prev close along the year --- #}
324 <div class="gauge">
325 <div class="gauge__row">
326 <span class="gauge__label">52-week range</span>
327 <span class="gauge__meta">where the price sits across its year</span>
328 </div>
329 <div class="track gauge__track">
330 {% if stats.yr_prev_pos is not none %}
331 <span class="track__pip track__pip--ghost" style="left:{{ stats.yr_prev_pos }}%"></span>
332 {% endif %}
333 <span class="track__pip track__pip--{{ 'down' if stats.change_pct is not none and stats.change_pct < 0 else 'up' }}" style="left:{{ stats.yr_close_pos }}%"></span>
334 </div>
335 <div class="gauge__ends">
336 <span><span class="gauge__cap">52w low</span> <span class="num">{{ stats.low_52w|money }}</span></span>
337 <span class="gauge__ends-r"><span class="gauge__cap">52w high</span> <span class="num">{{ stats.high_52w|money }}</span></span>
338 </div>
339 <dl class="legend">
340 <div class="legend__item">
341 <dt><i class="legend__dot legend__dot--{{ 'down' if stats.change_pct is not none and stats.change_pct < 0 else 'up' }}"></i> Current</dt>
342 <dd class="num">{{ stats.close|money }}</dd><dd></dd>
343 </div>
344 {% if stats.prev_close is not none %}
345 <div class="legend__item">
346 <dt><i class="legend__dot legend__dot--ghost"></i> Prev close</dt>
347 <dd class="num">{{ stats.prev_close|money }}</dd><dd></dd>
348 </div>
349 {% endif %}
350 </dl>
351 </div>
352
353 {# --- volume vs its own 3-month average --- #}
354 <div class="gauge">
355 <div class="gauge__row">
356 <span class="gauge__label">Volume</span>
357 <span class="gauge__meta">
358 {% if stats.vol_ratio is not none %}{{ stats.vol_ratio|round(2) }}× the 3-month average{% else %}today's share volume{% endif %}
359 </span>
360 </div>
361 <div class="track gauge__track gauge__track--bar">
362 <span class="track__fill" style="width:{{ stats.vol_fill_pct }}%"></span>
363 <span class="track__pip track__pip--ghost" style="left:50%"></span>
364 </div>
365 <div class="gauge__ends">
366 <span><span class="gauge__cap">0</span></span>
367 <span class="gauge__ends-c"><span class="gauge__cap">avg</span></span>
368 <span class="gauge__ends-r"><span class="gauge__cap">2× avg</span></span>
369 </div>
370 <dl class="legend">
371 <div class="legend__item">
372 <dt><i class="legend__dot"></i> Today</dt>
373 <dd class="num">{{ stats.volume|compact }}</dd><dd></dd>
374 </div>
375 <div class="legend__item">
376 <dt><i class="legend__dot legend__dot--ghost"></i> 3-mo avg</dt>
377 <dd class="num">{{ stats.avg_volume|compact }}</dd><dd></dd>
378 </div>
379 </dl>
380 </div>
381
382 </section>
383 {% else %}
384 <section class="empty">
385 {% if symbol.kind == 'future' %}
386 <p><strong>{{ symbol.ticker }}</strong> is a futures contract. Finance follows
387 futures with live quotes only, so there is no historical daily chart; the
388 price above updates live while the market is trading.</p>
389 {% else %}
390 <p>No daily price history is available for {{ symbol.ticker }}.</p>
391 {% endif %}
392 </section>
393 {% endif %}
394
395 {# --- fundamentals + financials: stocks only --- #}
396 {% if symbol.kind == 'stock' %}
397
398 {# --- earnings dates (Phase 25): next-expected date with provenance, days
399 since the last print, and a short list of recent past dates. Past
400 dates come from 8-K item-2.02 filings (already stored by Phase 14);
401 the next date is Yahoo's `calendarEvents` when present, otherwise a
402 cadence estimate from those past dates. --- #}
403 {% if earnings %}
404 <h2 class="section-title">Earnings{% if earnings.earnings_synced_at %}<span class="section-title__asof">calendar synced from Yahoo {{ earnings.earnings_synced_at|ago }}</span>{% endif %}</h2>
405 <section class="panel earn">
406 <dl class="earn__pair">
407 <div class="earn__cell">
408 <dt class="earn__cap">Most recent</dt>
409 {% if earnings.most_recent %}
410 <dd class="earn__val num">{{ earnings.most_recent.date|shortdate }}<span class="earn__sub">{{ earnings.most_recent.days_ago }} day{% if earnings.most_recent.days_ago != 1 %}s{% endif %} ago</span></dd>
411 {% else %}
412 <dd class="earn__val">—<span class="earn__sub">no past earnings filed</span></dd>
413 {% endif %}
414 </div>
415 <div class="earn__cell">
416 <dt class="earn__cap">Next expected</dt>
417 {% if earnings.next_date %}
418 <dd class="earn__val num">{{ earnings.next_date|shortdate }}<span class="earn__sub">
419 {%- if earnings.next_days is not none -%}
420 {%- if earnings.next_days >= 0 -%}
421 in {{ earnings.next_days }} day{% if earnings.next_days != 1 %}s{% endif %}
422 {%- else -%}
423 {{ earnings.next_days|abs }} day{% if earnings.next_days|abs != 1 %}s{% endif %} ago
424 {%- endif -%}
425 {%- endif -%}
426 {% if earnings.next_source == 'estimate' %} · estimated from cadence{% endif %}
427 </span></dd>
428 {% else %}
429 <dd class="earn__val">—<span class="earn__sub">no upcoming date on file</span></dd>
430 {% endif %}
431 </div>
432 </dl>
433 {% if earnings.past %}
434 <div class="earn__past">
435 <h3 class="earn__past-title">Recent earnings dates</h3>
436 <ul class="earn__list">
437 {% for e in earnings.past %}
438 <li class="earn-row">
439 <span class="earn-row__date num">{{ e.date|shortdate }}</span>
440 <span class="earn-row__ago">{{ e.days_ago }} day{% if e.days_ago != 1 %}s{% endif %} ago</span>
441 </li>
442 {% endfor %}
443 </ul>
444 </div>
445 {% endif %}
446 <p class="earn__src">Past earnings dates come from {{ symbol.ticker }}’s 8-K item 2.02 filings; the next date is Yahoo’s when available, otherwise estimated from the recent reporting cadence.</p>
447 </section>
448 {% endif %}
449
450 <h2 class="section-title">Fundamentals{% if fundamentals and fundamentals.ratios and symbol.fundamentals_synced_at %}<span class="section-title__asof">synced from SEC {{ symbol.fundamentals_synced_at|ago }}</span>{% endif %}</h2>
451 {% if fundamentals and fundamentals.ratios %}
452 {# The rolled-up standing badge sits above the per-ratio cards (Phase 20). #}
453 {% if standing %}
454 <div class="fund-standing">
455 {{ verdict_badge(standing) }}
456 <p class="fund-standing__text">Across the nine graded ratios{% if fundamentals.basis %}
457 ({{ fundamentals.basis }} balance-sheet figures{% if fundamentals.pe_basis %}, P/E on {{ fundamentals.pe_basis }} earnings{% endif %}, against the latest price){% endif %},
458 {{ symbol.ticker }}’s fundamentals read as <strong>{{ standing.verdict|lower }}</strong> overall.</p>
459 </div>
460 {% elif fundamentals.basis %}
461 <p class="fund-basis">Balance-sheet ratios read {{ fundamentals.basis }} figures{% if fundamentals.pe_basis %}; the P/E uses {{ fundamentals.pe_basis }} EPS{% endif %}, against the latest price.</p>
462 {% endif %}
463 {% if fundamentals.earnings_stale %}<p class="fund-stale" role="note">Latest SEC earnings are from {{ fundamentals.earnings_period }}; the price-based ratios may lag a more recent quarter.</p>{% endif %}
464 <section class="ratios">
465 {% for r in fundamentals.ratios %}
466 <div class="ratio ratio--{{ r.grade }}">
467 <div class="ratio__head">
468 <span class="ratio__label">{{ r.label }}</span>
469 <span class="ratio__badge ratio__badge--{{ r.grade }}">{{ r.verdict }}</span>
470 </div>
471 <div class="ratio__value num">{{ r.display }}</div>
472 <p class="ratio__reading">{{ r.reading }}</p>
473 <p class="ratio__explain">{{ r.explain }}</p>
474 </div>
475 {% endfor %}
476 </section>
477 {% else %}
478 <div class="fund-pending">
479 {% if symbol.fundamentals_synced_at %}
480 No SEC fundamentals are available for {{ symbol.ticker }}. It may not file
481 financial reports with the SEC (a foreign issuer or non-filer), or the
482 symbol may be delisted. (Checked {{ symbol.fundamentals_synced_at|ago }}.)
483 {% else %}
484 SEC fundamentals for {{ symbol.ticker }} have not synced yet. They are pulled
485 on demand — hit <strong>Refresh</strong> above to fetch them now.
486 {% endif %}
487 </div>
488 {% endif %}
489
490 {% if fundamentals and (fundamentals.has_annual or fundamentals.has_quarterly) %}
491 <h2 class="section-title">Financials{% if symbol.fundamentals_synced_at %}<span class="section-title__asof">synced from SEC {{ symbol.fundamentals_synced_at|ago }}</span>{% endif %}</h2>
492 <section class="panel fin">
493 <div class="fin__toggle" role="tablist" aria-label="Reporting period">
494 <button type="button" class="fin__tab is-active" data-period="annual" aria-selected="true">Annual</button>
495 <button type="button" class="fin__tab" data-period="quarterly" aria-selected="false">Quarterly</button>
496 </div>
497 {{ fin_panel(fundamentals.annual, "annual", true) }}
498 {{ fin_panel(fundamentals.quarterly, "quarterly", false) }}
499 </section>
500 {% endif %}
501
502 {# --- leadership: officers, board & recent changes (Phase 14) --- #}
503 <h2 class="section-title">Leadership{% if symbol.leadership_synced_at %}<span class="section-title__asof">synced from SEC {{ symbol.leadership_synced_at|ago }}</span>{% endif %}</h2>
504 {% if leadership and leadership.roster %}
505 <section class="panel leadership">
506 <ul class="roster">
507 {% for p in leadership.roster %}
508 <li class="roster__row">
509 <span class="roster__name">{{ p.name }}</span>
510 <span class="roster__role">{{ p.role }}</span>
511 </li>
512 {% endfor %}
513 </ul>
514 <p class="roster__src">Officers and board are drawn from {{ symbol.ticker }}’s recent SEC ownership filings (Forms 3, 4 and 5).</p>
515 {% if leadership.changes %}
516 <div class="lead-changes">
517 <h3 class="lead-changes__title">Recent leadership changes</h3>
518 <ul class="lead-changes__list">
519 {% for c in leadership.changes %}
520 <li class="lead-change">
521 <a class="lead-change__link" href="{{ c.url }}" target="_blank" rel="noopener noreferrer">
522 <span class="lead-change__body">
523 <span class="lead-change__desc">Officer or director change</span>
524 <span class="lead-change__meta">Reported in an 8-K filed {{ c.filed_at|shortdate }}</span>
525 </span>
526 <svg class="filing__ext" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
527 <path d="M14 5h5v5M19 5l-9 9M11 5H6a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-5"/>
528 </svg>
529 </a>
530 </li>
531 {% endfor %}
532 </ul>
533 </div>
534 {% endif %}
535 </section>
536 {% elif leadership and leadership.synced %}
537 <div class="fund-pending">No leadership data is available for {{ symbol.ticker }}.</div>
538 {% else %}
539 <div class="fund-pending">
540 Leadership data for {{ symbol.ticker }} has not synced yet. It is pulled on
541 demand — hit <strong>Refresh</strong> above to fetch it now.
542 </div>
543 {% endif %}
544
545 {% endif %}
546
547 {# --- per-ticker anomaly feed (Phase 16): hidden when there are no
548 qualifying events in the past year --- #}
549 {% if anomalies %}
550 <h2 class="section-title">Notable recent events<span class="section-title__asof">past year</span></h2>
551 <section class="panel anomalies">
552 <ul class="anomaly-list">
553 {% for e in anomalies.events %}
554 <li class="anomaly anomaly--{{ e.glyph }} anomaly--{{ e.polarity }}">
555 {% if e.url %}<a class="anomaly__link" href="{{ e.url }}" target="_blank" rel="noopener noreferrer">{% else %}<span class="anomaly__link">{% endif %}
556 <span class="anomaly__date num">{{ e.date|shortdate }}</span>
557 <span class="anomaly__glyph anomaly__glyph--{{ e.glyph }}" aria-hidden="true">{% if e.glyph == 'up' %}↑{% elif e.glyph == 'down' %}↓{% elif e.glyph == 'drawdown' %}↡{% elif e.glyph == 'fund-up' %}+{% elif e.glyph == 'fund-down' %}−{% else %}❖{% endif %}</span>
558 <span class="anomaly__body">{{ e.headline }}</span>
559 <span class="anomaly__ext" aria-hidden="true">
560 {% if e.url %}
561 <svg class="filing__ext" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
562 <path d="M14 5h5v5M19 5l-9 9M11 5H6a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-5"/>
563 </svg>
564 {% endif %}
565 </span>
566 {% if e.url %}</a>{% else %}</span>{% endif %}
567 </li>
568 {% endfor %}
569 </ul>
570 <p class="anomaly-list__src">Large daily moves, new 6-month lows, year-over-year fundamentals swings, and reported leadership changes — pulled from the data this app already holds, never investment advice.</p>
571 </section>
572 {% endif %}
573
574 {# --- ETFs only: Phase 18 (fund profile) + Phase 28 (about / sector / --- #}
575 {# --- geography / trailing returns / growth of $10k / benchmark) --- #}
576 {% if symbol.kind == 'etf' %}
577
578 {# About this fund: expense ratio, yield, NAV + premium/discount, family, #}
579 {# category, inception, and the issuer's strategy paragraph (Phase 28). #}
580 <h2 class="section-title">About this fund{% if fund_meta and symbol.fund_metadata_synced_at %}<span class="section-title__asof">synced from Yahoo {{ symbol.fund_metadata_synced_at|ago }}</span>{% endif %}</h2>
581 {% if fund_meta %}
582 <section class="panel fund-about">
583 <dl class="fund-about__stats">
584 <div class="fund-about__stat">
585 <dt class="fund-about__cap">Expense ratio</dt>
586 <dd class="fund-about__val num">{{ fund_meta.expense_ratio }}</dd>
587 </div>
588 <div class="fund-about__stat">
589 <dt class="fund-about__cap">Distribution yield</dt>
590 <dd class="fund-about__val num">{{ fund_meta.yield_pct }}</dd>
591 </div>
592 {% if fund_meta.nav_price is not none %}
593 <div class="fund-about__stat">
594 <dt class="fund-about__cap">NAV</dt>
595 <dd class="fund-about__val num">{{ fund_meta.nav_price|money }}{% if fund_meta.premium %}<span class="fund-about__sub fund-about__sub--{{ fund_meta.premium.grade }}">{{ fund_meta.premium.text }} to price</span>{% endif %}</dd>
596 </div>
597 {% endif %}
598 {% if fund_meta.inception_date %}
599 <div class="fund-about__stat">
600 <dt class="fund-about__cap">Inception</dt>
601 <dd class="fund-about__val num">{{ fund_meta.inception_date|shortdate }}</dd>
602 </div>
603 {% endif %}
604 {% if fund_meta.category %}
605 <div class="fund-about__stat">
606 <dt class="fund-about__cap">Category</dt>
607 <dd class="fund-about__val">{{ fund_meta.category }}</dd>
608 </div>
609 {% endif %}
610 {% if fund_meta.fund_family %}
611 <div class="fund-about__stat">
612 <dt class="fund-about__cap">Family</dt>
613 <dd class="fund-about__val">{{ fund_meta.fund_family }}</dd>
614 </div>
615 {% endif %}
616 </dl>
617 {% if fund_meta.strategy_summary %}
618 <p class="fund-about__summary">{{ fund_meta.strategy_summary }}</p>
619 {% endif %}
620 </section>
621 {% else %}
622 <div class="fund-pending">
623 {% if symbol.fund_metadata_synced_at %}
624 No fund details are available for {{ symbol.ticker }} from Yahoo. The fund may
625 be delisted or renamed, or Yahoo may not carry details for it.
626 (Checked {{ symbol.fund_metadata_synced_at|ago }}.)
627 {% else %}
628 Fund details for {{ symbol.ticker }} have not synced yet. They are pulled on
629 demand — hit <strong>Refresh</strong> above to fetch them now.
630 {% endif %}
631 </div>
632 {% endif %}
633
634 {# Trailing returns (Phase 28). Eight periods, annualised past 1 year. #}
635 {% if returns %}
636 <h2 class="section-title">Trailing returns{% if symbol.history_synced_at %}<span class="section-title__asof">from price history</span>{% endif %}</h2>
637 <section class="panel ret-panel">
638 <table class="ret-table">
639 <thead>
640 <tr><th scope="col">Window</th><th scope="col" class="num">Return</th><th scope="col" class="num">Annualised</th></tr>
641 </thead>
642 <tbody>
643 {% for r in returns %}
644 <tr>
645 <th scope="row">{{ r.label }}</th>
646 <td class="num{% if r.dir > 0 %} ret-cell--up{% elif r.dir < 0 %} ret-cell--down{% endif %}">{{ r.pct }}</td>
647 <td class="num ret-cell--ann">{{ r.annualised }}</td>
648 </tr>
649 {% endfor %}
650 </tbody>
651 </table>
652 <p class="ret-panel__src">Cumulative price returns from {{ symbol.ticker }}’s stored daily closes. Distributions are not folded in; see Distributions below.</p>
653 </section>
654
655 {# Growth of $10,000 (Phase 28). Drawn client-side from the longest #}
656 {# available range, alongside the curated benchmark when one is set. #}
657 <h2 class="section-title">Growth of $10,000{% if symbol.benchmark %}<span class="section-title__asof">vs {{ symbol.benchmark }}</span>{% endif %}</h2>
658 <section class="panel growth-panel">
659 <div id="growth-chart" data-ticker="{{ symbol.ticker }}"{% if symbol.benchmark %} data-benchmark="{{ symbol.benchmark }}"{% endif %}></div>
660 <p class="growth-panel__src">A $10,000 investment scaled by the fund’s daily closes since inception.{% if symbol.benchmark %} The dashed line tracks the same amount in {{ symbol.benchmark }} from the fund’s first trading day forward.{% endif %}</p>
661 </section>
662 {% endif %}
663
664 <h2 class="section-title">Fund profile{% if fund and symbol.fund_synced_at %}<span class="section-title__asof">synced from SEC {{ symbol.fund_synced_at|ago }}</span>{% endif %}</h2>
665 {% if fund %}
666 <section class="panel fund">
667 <dl class="fund__stats">
668 <div class="fund__stat">
669 <dt class="fund__stat-cap">Net assets</dt>
670 <dd class="fund__stat-val num">{{ fund.net_assets if fund.net_assets else '—' }}</dd>
671 </div>
672 {% if not fund.is_commodity %}
673 <div class="fund__stat">
674 <dt class="fund__stat-cap">Holdings</dt>
675 <dd class="fund__stat-val num">{{ fund.holdings_count if fund.holdings_count is not none else '—' }}</dd>
676 </div>
677 {% endif %}
678 {% if fund.report_date %}
679 <div class="fund__stat">
680 <dt class="fund__stat-cap">As of</dt>
681 <dd class="fund__stat-val num">{{ fund.report_date }}</dd>
682 </div>
683 {% endif %}
684 </dl>
685 {% if fund.is_commodity %}
686 <p class="fund__note">{{ symbol.ticker }} is a grantor trust: it holds the physical commodity directly rather than a portfolio of securities, so it reports no holdings.</p>
687 {% elif fund.asset_mix %}
688 <div class="fund__mix">
689 <h3 class="fund__mix-label">Asset mix</h3>
690 <div class="mixbar">
691 {% for s in fund.asset_mix %}
692 <span class="mixbar__seg" style="width:{{ s.width }}%" title="{{ s.label }} {{ s.pct }}"></span>
693 {% endfor %}
694 </div>
695 <dl class="mixlegend">
696 {% for s in fund.asset_mix %}
697 <div class="mixlegend__item">
698 <dt><i class="mixlegend__dot"></i>{{ s.label }}</dt>
699 <dd class="num">{{ s.pct }}</dd>
700 </div>
701 {% endfor %}
702 </dl>
703 </div>
704 {% endif %}
705 {# Phase 28 mixes hide when degenerate: an equity ETF whose issuer #}
706 {# category rolls up entirely to "Corporate" gets a single-bucket #}
707 {# sector_mix, which carries no information — skip it then. #}
708 {% if fund.sector_mix and fund.sector_mix|length > 1 %}
709 <div class="fund__mix">
710 <h3 class="fund__mix-label">Issuer sectors</h3>
711 <div class="mixbar">
712 {% for s in fund.sector_mix %}
713 <span class="mixbar__seg" style="width:{{ s.width }}%" title="{{ s.label }} {{ s.pct }}"></span>
714 {% endfor %}
715 </div>
716 <dl class="mixlegend">
717 {% for s in fund.sector_mix %}
718 <div class="mixlegend__item">
719 <dt><i class="mixlegend__dot"></i>{{ s.label }}</dt>
720 <dd class="num">{{ s.pct }}</dd>
721 </div>
722 {% endfor %}
723 </dl>
724 </div>
725 {% endif %}
726 {% if fund.geography_mix and fund.geography_mix|length > 1 %}
727 <div class="fund__mix">
728 <h3 class="fund__mix-label">Geography</h3>
729 <div class="mixbar">
730 {% for s in fund.geography_mix %}
731 <span class="mixbar__seg" style="width:{{ s.width }}%" title="{{ s.label }} {{ s.pct }}"></span>
732 {% endfor %}
733 </div>
734 <dl class="mixlegend">
735 {% for s in fund.geography_mix %}
736 <div class="mixlegend__item">
737 <dt><i class="mixlegend__dot"></i>{{ s.label }}</dt>
738 <dd class="num">{{ s.pct }}</dd>
739 </div>
740 {% endfor %}
741 </dl>
742 </div>
743 {% endif %}
744 </section>
745
746 {% if fund.holdings %}
747 <h2 class="section-title">Top holdings{% if fund.report_date %}<span class="section-title__asof">holdings as of {{ fund.report_date|shortdate }}</span>{% endif %}</h2>
748 <section class="panel holdings">
749 <ol class="hold-list">
750 {% for h in fund.holdings %}
751 <li class="hold">
752 <span class="hold__fill" style="width:{{ h.bar_pct }}%"></span>
753 <span class="hold__rank num">{{ h.rank }}</span>
754 <span class="hold__name">{{ h.name }}</span>
755 <span class="hold__wt num">{{ h.weight }}</span>
756 <span class="hold__val num">{{ h.value }}</span>
757 </li>
758 {% endfor %}
759 </ol>
760 </section>
761 {% endif %}
762 {% else %}
763 <div class="fund-pending">
764 {% if symbol.fund_synced_at %}
765 No fund profile is available for {{ symbol.ticker }} from the SEC. The fund may
766 be delisted or renamed, or it does not file N-PORT portfolio reports.
767 (Checked {{ symbol.fund_synced_at|ago }}.)
768 {% else %}
769 The fund profile for {{ symbol.ticker }} has not synced yet. It is pulled on
770 demand — hit <strong>Refresh</strong> above to fetch it now.
771 {% endif %}
772 </div>
773 {% endif %}
774 {% endif %}
775
776 {# --- dividends / distributions: stocks AND ETFs (Phase 26 + 28) --- #}
777 {% if dividends %}
778 {% set div_label = 'Distributions' if symbol.kind == 'etf' else 'Dividends' %}
779 <h2 class="section-title">{{ div_label }}{% if symbol.dividends_synced_at %}<span class="section-title__asof">synced from Yahoo {{ symbol.dividends_synced_at|ago }}</span>{% endif %}</h2>
780 {% if dividends.synced and dividends.history %}
781 <section class="panel div-panel">
782 {# headline: inferred cadence + the on-track verdict pill #}
783 <div class="div-head">
784 <span class="div-cadence">{{ dividends.pace.cadence_caption }}</span>
785 {% if dividends.projection_display %}
786 <span class="vbadge vbadge--{{ dividends.pace.grade }}">{{ dividends.pace.verdict }}</span>
787 {% endif %}
788 </div>
789
790 {# pace: prior calendar year vs YTD-projected current year #}
791 <dl class="div-pace">
792 <div class="div-pace__item">
793 <dt class="div-pace__cap">{{ dividends.current_year - 1 }} total</dt>
794 <dd class="div-pace__val num">{% if dividends.pace.prior_year_total > 0 %}{{ dividends.prior_year_display }}{% else %}—{% endif %}</dd>
795 </div>
796 <div class="div-pace__item">
797 <dt class="div-pace__cap">{{ dividends.current_year }} so far</dt>
798 <dd class="div-pace__val num">{{ dividends.ytd_display }}<span class="div-pace__sub">{{ dividends.pace.ytd_count }} payment{% if dividends.pace.ytd_count != 1 %}s{% endif %}</span></dd>
799 </div>
800 {% if dividends.projection_display %}
801 <div class="div-pace__item">
802 <dt class="div-pace__cap">{{ dividends.current_year }} projected</dt>
803 <dd class="div-pace__val num">{{ dividends.projection_display }}<span class="div-pace__sub div-pace__sub--{{ dividends.pace.grade }}">{{ dividends.pct_change_display }} vs {{ dividends.current_year - 1 }}</span></dd>
804 </div>
805 {% endif %}
806 </dl>
807 <p class="div-pace__src">Projection scales {{ symbol.ticker }}’s declared payments so far this year up to the inferred cadence, then compares to last year’s total. Between payouts it is an estimate, not a guarantee.</p>
808
809 {# per-event history, newest first #}
810 <h3 class="div-hist__title">Payment history</h3>
811 <ul class="div-hist">
812 {% for d in dividends.history %}
813 <li class="div-row">
814 <span class="div-row__date">{{ d.ex_date|shortdate }}</span>
815 <span class="div-row__amt num">{{ d.amount }}</span>
816 </li>
817 {% endfor %}
818 </ul>
819 </section>
820 {% elif dividends.synced %}
821 <div class="fund-pending">{{ symbol.ticker }} has not {% if symbol.kind == 'etf' %}made a distribution{% else %}paid a dividend{% endif %} in the past five years.</div>
822 {% else %}
823 <div class="fund-pending">
824 {{ div_label }} history for {{ symbol.ticker }} has not synced yet. It is
825 pulled on demand — hit <strong>Refresh</strong> above to fetch it now.
826 </div>
827 {% endif %}
828 {% endif %}
829
830 {# --- SEC filings: stocks and ETFs --- #}
831 {% if filings %}
832 {# A stock's filings sync stamps `filings_synced_at`; an ETF's ride along
833 with the Phase 18 fund-profile sweep, which stamps `fund_synced_at`. #}
834 {% set filings_synced = symbol.filings_synced_at if symbol.filings_synced_at else symbol.fund_synced_at %}
835 <h2 class="section-title">Recent SEC filings{% if filings_synced %}<span class="section-title__asof">synced from SEC {{ filings_synced|ago }}</span>{% endif %}</h2>
836 <section class="panel filings">
837 <ul class="filing-list">
838 {% for f in filings %}
839 <li class="filing">
840 <a class="filing__link" href="{{ f.url }}" target="_blank" rel="noopener noreferrer">
841 <span class="filing__form num">{{ f.form }}</span>
842 <span class="filing__body">
843 <span class="filing__desc">{{ f.title }}</span>
844 <span class="filing__meta">Filed {{ f.filed_at|shortdate }}{% if f.period_of_report %} · period {{ f.period_of_report|shortdate }}{% endif %}</span>
845 </span>
846 <svg class="filing__ext" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
847 <path d="M14 5h5v5M19 5l-9 9M11 5H6a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-5"/>
848 </svg>
849 </a>
850 </li>
851 {% endfor %}
852 </ul>
853 </section>
854 {% endif %}
855</div>
856{% endblock %}
857
858{% block extra_js %}<script type="module" src="{{ vite_asset('static_src/symbol/index.js') }}"></script>{% endblock %}