repos
/ finance-rust master

finance-rust

mirror archived upstream

Single-binary self-hosted market watcher for stocks, ETFs, indexes, and futures: live charts, key stats, fundamentals, SEC filings, and SSE streaming.

axumdockerfinancerustself-hostedsqlitestocksvite

43.3 KB · 859 lines · HTML Raw History
  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 &mdash; 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 %} &middot; <span data-field="quoted">quoted {{ symbol.last_quote_at|ago }}</span>{% endif %}{% else %}Last close &middot; {{ 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' %}&uarr;{% elif health.trajectory == 'bad' %}&darr;{% else %}&rarr;{% 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> &middot; <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                &mdash;
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 &amp; 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> &middot; <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 &middot; 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&rsquo;s indicators &mdash; 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) }}&times; 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&times; 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">&mdash;<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' %} &middot; estimated from cadence{% endif %}
427        </span></dd>
428        {% else %}
429        <dd class="earn__val">&mdash;<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 }}&rsquo;s 8-K item&nbsp;2.02 filings; the next date is Yahoo&rsquo;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 }}&rsquo;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 &mdash; 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 }}&rsquo;s recent SEC ownership filings (Forms&nbsp;3,&nbsp;4 and&nbsp;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 &mdash; 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' %}&uarr;{% elif e.glyph == 'down' %}&darr;{% elif e.glyph == 'drawdown' %}&#x21A1;{% elif e.glyph == 'fund-up' %}&plus;{% elif e.glyph == 'fund-down' %}&minus;{% else %}&#x2756;{% 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 &mdash; 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 &mdash; 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 }}&rsquo;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&rsquo;s daily closes since inception.{% if symbol.benchmark %} The dashed line tracks the same amount in {{ symbol.benchmark }} from the fund&rsquo;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 &mdash; 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 }}&rsquo;s declared payments so far this year up to the inferred cadence, then compares to last year&rsquo;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 &mdash; 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 %} &middot; 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 %}