Metadata-Version: 2.4
Name: led-ticker-stocks
Version: 0.4.0
Summary: Stock / equities ticker widget for led-ticker (Finnhub).
Project-URL: Homepage, https://docs.ledticker.dev
Project-URL: Documentation, https://docs.ledticker.dev/plugins/
Project-URL: Repository, https://github.com/JamesAwesome/led-ticker-plugins
Project-URL: Issues, https://github.com/JamesAwesome/led-ticker-plugins/issues
Project-URL: Changelog, https://github.com/JamesAwesome/led-ticker-plugins/releases
Author-email: James Awesome <james@morelli.nyc>
License-Expression: MIT
License-File: LICENSE
Keywords: equities,finnhub,led-matrix,led-ticker,led-ticker-plugin,raspberry-pi,rgb-led-matrix,stocks,ticker
Classifier: Development Status :: 4 - Beta
Classifier: Framework :: AsyncIO
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: POSIX :: Linux
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Multimedia :: Graphics
Classifier: Topic :: System :: Hardware
Requires-Python: >=3.14
Requires-Dist: aiohttp
Requires-Dist: led-ticker-core>=4.9
Provides-Extra: dev
Requires-Dist: pre-commit>=4.0; extra == 'dev'
Requires-Dist: pyright>=1.1; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: ruff>=0.4; extra == 'dev'
Description-Content-Type: text/markdown

# led-ticker-stocks

A stock / equities ticker **plugin** for [led-ticker](https://github.com/JamesAwesome/led-ticker), backed by the [Finnhub](https://finnhub.io/) API. It contributes a `stocks.ticker` **Container widget** that cycles one price view per configured symbol, trend-colored green/red and dimmed by market state (full brightness while the market is open, dimmed while pre/post-market, dimmest while closed). Three layouts are registered and auto-selected by panel geometry — see [Layouts](#layouts):

- `crawl` (smallsign, ~160px) — a single scrolling line: `SYM  price  ▲/▼ change change%`.
- `card` (bigsign, ~256px) — a held hero card: symbol + price + change, a brand chip, a sparkline, a state-chip label, and paging dots.
- `dashboard` (longboi, ~512px) — a held trading dashboard: the same hero block plus a watch column showing the next symbols and a bigger sparkline.

All three layouts animate: the price line pops white on a change and settles back to amber (Bloomberg-style flash), the LIVE state chip breathes while the market is open, and the sparkline's tip pulses. It also contributes a `stocks.quote` **source** — a live `:id:` price token you can weave into any other widget's text (a headline, a two-row detail line, a clock/date composite) — see [Inline price tokens](#inline-price-tokens). **Scope:** US equities only. FX/forex is out of scope — see [Equities only](#equities-only--fx-requires-a-paid-tier).

## Prerequisites

- A working [led-ticker](https://github.com/JamesAwesome/led-ticker) install.
- Internet access on the Pi (the widget calls the Finnhub REST API).
- A free [Finnhub](https://finnhub.io/register) account and API token — **or** run in `demo = true` mode with no token at all (see [Demo mode](#demo-mode-no-token-required)).

## Install

The widget auto-registers via the `led_ticker.plugins` entry point — once the package is installed, no `[plugins]` config change is needed.

**Into a containerized led-ticker (recommended):** add this package to `config/requirements-plugins.txt` (copy it from `config/requirements-plugins.example.txt`), then restart:

```bash
# in your led-ticker checkout
cp config/requirements-plugins.example.txt config/requirements-plugins.txt
# add the line below to config/requirements-plugins.txt, then:
docker compose restart
```

```text
led-ticker-stocks
```

**Standalone (a venv that already has led-ticker):**

```bash
pip install led-ticker-stocks
```

See the led-ticker [Plugins docs](https://docs.ledticker.dev/plugins/) for the constraint-based install the Docker image uses, and pin production signs to an exact version (`led-ticker-stocks==0.3.0`) so a restart doesn't silently pick up a new release.

## Configuration

Reference the widget in a playlist section by `type = "stocks.ticker"`:

```toml
[[playlist.section]]
mode = "ticker"

[[playlist.section.widget]]
type = "stocks.ticker"
symbols = ["AAPL", "MSFT", "NVDA", "TSLA"]
```

The **recommended** section `mode` depends on which layout your panel will auto-select (see [Layouts](#layouts) below):

- `crawl` (smallsign) wants `mode = "ticker"` (continuous side-by-side scroll) — the same shape as the built-in `crypto.coingecko` / `rss.feed` tickers. The crawl text is designed to keep moving; `slideshow` also works but holds each symbol still with nothing to look at until the next transition.
- `card` / `dashboard` (bigsign / longboi) are **held** layouts — they paint a full static view per symbol and don't scroll. Use `mode = "slideshow"` with a `hold_time` long enough to read (the smoke configs in `examples/` use `5.2`).

New to led-ticker configs? The [first-config tutorial](https://docs.ledticker.dev/tutorial/02-first-config/) walks through the overall structure — the block above shows just the stocks-specific keys.

### Options

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `symbols` | list of strings | — | **Required.** Ticker symbols (e.g. `["AAPL", "MSFT"]`). US equities only — a symbol containing `/` (e.g. `"EUR/USD"`) fails validation with a message pointing at the FX limitation below. |
| `layout` | string | auto | Force a specific render layout (`"crawl"`, `"card"`, or `"dashboard"`). Omit this field and let the widget auto-select by real panel width — see [Layouts](#layouts). |
| `demo` | bool | `false` | Run against a seeded, offline random-walk feed instead of Finnhub — no token, no network call. See [Demo mode](#demo-mode-no-token-required). |
| `update_interval` | int | `60` | Seconds between Finnhub polls. The widget silently raises this to `len(symbols) + 1` if you set it lower — see [Rate limits](#rate-limits--api-token) below. |
| `padding` | int | `6` | Horizontal spacing (logical px) after each symbol's segment, before the next symbol. Crawl layout only — `card`/`dashboard` use a fixed layout grid instead. |
| `green_up` | bool | `true` | Set `false` to flip the up/down colors (green-down/red-up) for non-US market conventions. Applies to all three layouts. |

At least `symbols` must be a non-empty list — the widget fails at config validation otherwise (`led-ticker validate` catches this before boot).

### Layouts

`resolve_layout` auto-selects one of three registered layouts by the REAL (physical) panel width — a `layout =` override always wins if set:

| Layout | Auto-selected at | Built for | Shape |
|---|---|---|---|
| `crawl` | ≤160px wide | smallsign (160×16) | Single scrolling line per symbol: `SYM  price  ▲/▼ change change%`. |
| `card` | ~160–400px wide | bigsign (256×64) | Held hero card: brand chip + symbol + price + change line, a sparkline, a state-chip label, and paging dots showing position in the symbol list. |
| `dashboard` | ≥400px wide | longboi (512×64) | Held trading dashboard: the same hero block (chip + symbol + price + change + a "PREV" reference price) plus a bigger sparkline and a **watch column** listing the next 3 symbols with their percent change, and paging dots. |

`card` and `dashboard` are both **held** — they paint a complete static frame per symbol rather than scrolling (see the `mode` guidance above). Every symbol renders a brand chip: an abstract two-tone diagonal block, deterministically colored from a hash of the symbol (trademark-safe — never a logo or wordmark); `chip_colors` on `SymbolQuote` lets a future data source override the pair per-symbol.

The dashboard's watch column reads the widget's own full `symbols` list — it shows the neighbors of the currently-focused symbol, not anything from another widget. **Give the stocks widget its own section** (no sibling widgets) on longboi so the watch column always reflects this widget's symbol rotation.

Both held layouts animate: the price flashes white then settles back to amber on a change, the LIVE state chip breathes while the market is open, and the sparkline's tip pulses continuously.

### Demo mode (no token required)

Set `demo = true` (or simply omit `FINNHUB_API_TOKEN` from the environment — an unset token silently routes to the same demo feed, it is not an error) to drive prices from a deterministic, seeded random-walk generator instead of live Finnhub data. Useful for previewing the widget, `render-demo` GIFs, or a sign that doesn't have a Finnhub token yet. See [`docs/demo.toml`](docs/demo.toml) for a runnable example.

Demo mode moves the price every step, so the price-flash animation fires continuously (a real Finnhub feed only flashes when a poll observes an actual price change) — it's a good way to preview the flash without waiting for a live tick.

**Demo is a process-wide setting, not a per-widget one.** Every `stocks.ticker` widget and `stocks.quote` [inline token](#inline-price-tokens) in the same process shares one `QuoteCache` (see [Rate limits](#rate-limits--api-token)), and that cache is single-mode: the FIRST consumer to actually start it (widget `start()` or a token's first `update()` tick) decides live vs. demo for *everything* reading it that session. `demo = true` on one widget forces the whole cache into demo — but only if that widget wins the race to start it first; a config that mixes a `demo = true` widget with a plain live widget/token resolves to whichever one starts first, which isn't something the config file controls. In practice this rarely matters: no `FINNHUB_API_TOKEN` in the environment already routes everyone to demo regardless of start order, so `demo = true` is mostly useful for forcing demo output even when a real token IS configured (e.g. a `render-demo` GIF run against a sign that also has a live token in `.env`).

### Smoke-test configs (per sign)

Ready-to-run hardware smoke configs live in [`examples/`](examples/), one per sign form factor — each with a token-free demo section plus a live section, and a "what to look for" header:

| File | Sign | Notes |
|---|---|---|
| [`config.stocks-smoke.smallsign.toml`](examples/config.stocks-smoke.smallsign.toml) | 160×16 | `crawl` auto-selected, `mode = "ticker"` |
| [`config.stocks-smoke.bigsign.toml`](examples/config.stocks-smoke.bigsign.toml) | 256×64 | `card` auto-selected, `mode = "slideshow"` |
| [`config.stocks-smoke.longboi.toml`](examples/config.stocks-smoke.longboi.toml) | 512×64 | `dashboard` auto-selected, `mode = "slideshow"` (widget alone in its section — see [Layouts](#layouts)) |

Copy one to `config/config.toml` on the sign, `led-ticker validate` it, then `make restart`.

### Rate limits & API token

Get a free API token at [finnhub.io/register](https://finnhub.io/register) and supply it via the `FINNHUB_API_TOKEN` environment variable — **never** put it in `config.toml`; secrets are env-only in led-ticker (see the [Plugins docs](https://docs.ledticker.dev/plugins/)):

```bash
export FINNHUB_API_TOKEN="your-token-here"
```

Finnhub's free tier allows **60 requests per minute, per API key** — not per widget. All `stocks.ticker` widgets and `stocks.quote` [inline tokens](#inline-price-tokens) in one process share a single poll loop and a single deduplicated symbol set (registering `"AAPL"` from three different widgets/tokens still costs one `AAPL` fetch per cycle, not three — see the field's own [Inline price tokens](#inline-price-tokens) section). Every poll cycle costs `len(symbols) + 1` requests, where `symbols` is the UNION of every symbol registered process-wide (one market-status call plus one quote call per distinct symbol). While the market is **closed**, a symbol is fetched only until it holds a price — Finnhub returns the last close even when the exchange is shut, so a sign booted after hours still shows the close (and inline tokens still resolve); once warm, closed cycles freeze it and cost just the one market-status call. The cadence self-widens to `max(update_interval, len(symbols) + 1)`, recomputed every cycle so it reacts to late registrants (a token added after boot, a second widget in another section) — so a single sign's stocks usage on its own can't blow the budget. The `update_interval` floor itself is set by whichever consumer starts the cache first: `ensure_started` is a no-op after the first call, so in a mixed config a widget's `update_interval` and a token's `interval` don't average — the first one to boot wins, and the loser's value is ignored (same first-started-wins rule as demo mode). Give overlapping consumers the same interval if you care which one applies. The 60/min ceiling is still shared across **everything** using that token, though: two signs pointed at the same Finnhub key, or another Finnhub-backed widget/script on the same key, split the same 60 requests. Give each sign (or each concurrent consumer) its own free Finnhub account/token if you're running more than one.

### Equities only — FX requires a paid tier

Finnhub's free tier returns HTTP 403 on forex (`/forex/*`) endpoints. This plugin only implements the equities `/quote` + `/stock/market-status` endpoints — FX pairs are out of scope until a paid-tier client ships (not on the current roadmap). `validate_config` rejects any symbol containing `/` (the conventional FX pair separator, e.g. `"EUR/USD"`) at config-load time with a message explaining why, rather than letting it fail opaquely at runtime.

### `layout` override

All three layouts (`"crawl"`, `"card"`, `"dashboard"`) are registered — `layout =` selects among them explicitly. Omit the field and `resolve_layout` picks by real panel width (see [Layouts](#layouts)): ≤160px → `crawl`, ~160–400px → `card`, ≥400px → `dashboard`. An unrecognized `layout` value fails config validation naming the registered set.

## Inline price tokens

Besides the `stocks.ticker` **widget**, this plugin registers a `stocks.quote` **source** — a live price value you embed in any *other* widget's text with a `:id:` token (led-ticker's [value tokens](https://docs.ledticker.dev/concepts/value-tokens/) mechanism, requires led-ticker-core `>= 4.9`). Declare it as a `[[source]]` block, give it an `id`, and reference `:id:` anywhere text is drawn:

```toml
[[source]]
id = "stocks.aapl"
type = "stocks.quote"
symbol = "AAPL"
format = "{price}"          # optional; this is the default
# placeholder = "…"         # optional; shown until the first fetch

[[playlist.section.widget]]
type = "message"
text = "AAPL :stocks.aapl:"   # -> "AAPL 317.31", updating live
```

### Fields

`format` is a plain [`str.format`](https://docs.python.org/3/library/string.html#format-string-syntax) string over these named fields — reference only the ones you need, the rest are never computed (lazy, weather-style):

| Field | Example | Description |
|---|---|---|
| `price` | `317.31` | Last price, decimal-formatted. |
| `change` | `+1.99` | Signed absolute change vs. previous close. |
| `pct` | `+0.63%` | Signed percent change vs. previous close. |
| `arrow` | `▲` | `▲` up / `▼` down / `·` flat — plain text, not colored (see the caveat below). |
| `symbol` | `AAPL` | The configured ticker symbol. |
| `prev` | `315.32` | Previous close. |
| `high` | `320.00` | Session high (em dash `—` if not yet known). |
| `low` | `310.50` | Session low (em dash `—` if not yet known). |
| `day_range` | `310.50–320.00` | `{low}–{high}` combined. |

A symbol containing `/` (e.g. `"EUR/USD"`) fails validation — same FX restriction as the widget, see [Equities only](#equities-only--fx-requires-a-paid-tier).

### Token color is inherited, not per-token

A price token renders as part of the HOST widget's text — it takes whatever `font_color` (or per-char provider, e.g. `rainbow`) that widget already has. Unlike the `stocks.ticker` **widget**, which independently colors its change line green/red per `green_up`, a `▲`/`▼` produced by `{arrow}` inside a token **cannot** be independently colored green or red — it's just another character in the string. If you need the widget's colored up/down semantics, use `stocks.ticker` directly; use the token when you want a compact price reference woven into other text (a headline, a two-row detail line, a clock/date composite) and don't need per-direction coloring.

### Shared cache, one poll loop

Every `stocks.quote` token and every `stocks.ticker` widget in the same process reads the SAME `QuoteCache` — see [Rate limits](#rate-limits--api-token). Registering `AAPL` via a token and via a widget (or via three separate tokens) still only fetches `AAPL` **once** per poll cycle; the cache dedups by symbol, not by consumer. A token-only config (no `stocks.ticker` widget anywhere) still works — the first `:id:` token to draw self-starts the shared poll loop.

### Demo behavior

With no `FINNHUB_API_TOKEN` set (or `demo = true` on any `stocks.ticker` widget sharing the process — see [Demo mode](#demo-mode-no-token-required) for the first-started-wins nuance), tokens show a moving, synthesized price instead of the `…` placeholder — useful for previewing a token-driven config or a `render-demo` GIF with no Finnhub account at all. [`examples/config.stocks-token.smallsign.toml`](examples/config.stocks-token.smallsign.toml) is a ready-to-run, token-free example.

## Roadmap

Phase 4 (the `stocks.quote` inline price token, backed by a shared `QuoteCache` that dedups Finnhub requests across every widget/token in the process) is shipping as `stocks-v0.4.0` — this is now the final planned phase for v1. `crawl` shipped in v0.1.0, `card`/`dashboard` in v0.2.0, the price-flash/pulse animation layer in v0.3.0. Deferred / out of scope: indices/FX, per-token color, sparkline/history in a token, a change-field flash on tokens, new layouts or widgets, and `font_color`/`border`/rainbow-gradient styling knobs on the three canonical widget layouts.

- **Release:** docs-site page, catalog `provides` entry, demo GIFs, `stocks-v0.4.0` release.

## Development

Install dev deps and run the checks:

```bash
uv sync --extra dev      # resolves led-ticker-core from PyPI
uv run pytest -q
uv run ruff check src tests
```

> **Note:** tests that need a headless canvas obtain one via `HeadlessBackend(...).create_canvas()` from `led_ticker.plugin` — the software backend shipped in led-ticker-core. No rgbmatrix test stub or `PYTHONPATH` plumbing is required.

The plugin imports only the public `led_ticker.plugin` surface — `tests/test_import_purity.py` enforces it.

## Links

- [led-ticker](https://github.com/JamesAwesome/led-ticker) — the core project
- [Docs site](https://docs.ledticker.dev) · [Plugin system](https://docs.ledticker.dev/plugins/)
