Metadata-Version: 2.5
Name: kodji-terminal
Version: 0.1.0
Summary: Lightweight market terminal for the BRVM (Bourse Régionale des Valeurs Mobilières, WAEMU).
Project-URL: Homepage, https://kodji.app
Project-URL: Terminal, https://kodji.app/terminal
Project-URL: Methodology, https://kodji.app/methodologie
Author-email: cmguinan <cmguinan@yahoo.fr>
License: MIT
Keywords: abidjan,brvm,finance,stock-market,terminal,tui,uemoa,waemu
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: Financial and Insurance Industry
Classifier: License :: OSI Approved :: MIT License
Classifier: Natural Language :: English
Classifier: Natural Language :: French
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Office/Business :: Financial :: Investment
Requires-Python: <3.13,>=3.12
Requires-Dist: httpx>=0.28
Requires-Dist: markdown-it-py>=3.0
Requires-Dist: pydantic-settings>=2.6
Requires-Dist: pydantic>=2.9
Requires-Dist: python-dateutil>=2.9
Requires-Dist: selectolax>=0.3.28
Requires-Dist: textual-plotext>=0.5
Requires-Dist: textual>=0.85
Provides-Extra: server
Requires-Dist: anthropic>=0.69; extra == 'server'
Requires-Dist: apscheduler>=3.10; extra == 'server'
Requires-Dist: cryptography>=50.0.1; extra == 'server'
Requires-Dist: fastapi>=0.115; extra == 'server'
Requires-Dist: jinja2>=3.1; extra == 'server'
Requires-Dist: pypdf>=5.1; extra == 'server'
Requires-Dist: python-multipart>=0.0.20; extra == 'server'
Requires-Dist: sqlalchemy>=2.0; extra == 'server'
Requires-Dist: uvicorn[standard]>=0.32; extra == 'server'
Description-Content-Type: text/markdown

# kodji-terminal

A lightweight, terminal-aesthetic dashboard for the **BRVM** (Bourse
Régionale des Valeurs Mobilières — the regional stock exchange for the 8
WAEMU countries, based in Abidjan). Single-user, low-memory, reliability
over features. See [CLAUDE.md](./CLAUDE.md) for the full project charter.

## Status

See [`docs/phases.md`](./docs/phases.md) for the running log.

- [x] Phase 0 — scaffold
- [x] Phase 1 — reference data + quotes
- [x] Phase 2 — web UI v1
- [x] Phase 2.5 — search + directory + company tab shell
- [x] Phase 3a — news + corporate actions (ingest)
- [x] Phase 3b — news + corporate actions (Haiku tagging, $1/day cap)
- [x] Phase 3c — news + corporate actions (UI: `/news`, tabs, 30-day strip)
- [x] Phase 4a — fundamentals (filings corpus + storage)
- [x] Phase 4b — fundamentals (Haiku extraction + Financials/Ownership/Segments tabs)
- [x] Phase 4c — fundamentals (OCR + interim extraction + sikafinance-communiqué fallback)
- [x] Phase 4d — fundamentals (financial ratios on the Financials + Peers tabs)
- [x] Phase 5 — TUI (Textual, parity with the web)
- [x] Phase 6a — alerts (price move + new filing + news relevance)
- [x] Phase 6b — daily brief (post-close, Haiku)
- [x] Phase 6c — analyst-note synthesis (weekly per-ticker, Sonnet)
- [x] Phase 7 — cash-flow extraction (P/FCF, FCF yield, EV/EBITDA) + filings-link references on the Financials tab
- [x] PR-X — accounts, users and per-account ownership
- [x] PR-X2 — magic-link sign-in (Resend) + session cookies

## Requirements

- macOS or Linux
- [uv](https://docs.astral.sh/uv/) (dependency manager)
- [just](https://github.com/casey/just) (task runner)
- Python 3.12
- **Optional (Phase 4c OCR):** `ocrmypdf` + tesseract with the French
  language pack. Without it, `just filings-ocr` no-ops with a warning and
  scanned filings stay unextractable — everything else works.

On macOS: `brew install uv just python@3.12`.
For OCR: `brew install ocrmypdf tesseract-lang`.

## Setup

```bash
cp env.example .env        # edit if you have any keys; defaults work
just sync                  # create .venv, install deps
just migrate               # create data/kodji.sqlite with initial schema
just test                  # offline fixture-based tests
just dev                   # http://127.0.0.1:8765
```

`ANTHROPIC_API_KEY` in `.env` is the only key that changes behaviour
today — it turns on the Phase 3b news tagger. Leave it blank and
everything else still works; news is simply stored untagged.

### Schema drift

Both entry points refuse to start when `migrations/` is ahead of the DB
recorded in `_schema_migrations`:

```
kodji.db.PendingMigrations: 1 migration(s) not applied to ./data/kodji.sqlite:
0020_example. Run `just migrate` before starting the app.
```

This is deliberate. Before the check, a deploy that shipped a migration
but never ran `just migrate` booted clean and then returned a 500 on the
first request that touched the new column — at whatever hour a user
happened to open that page. Now the mistake surfaces at the moment it is
made, when the fix is one command.

To ask without starting anything — for a deploy script, ahead of the
service restart:

```bash
just migrate-check     # exit 0 = up to date, 1 = pending (names them)
```

A DB that is *ahead* of the files (rolled back to older code) is not
drift the app can fix by migrating, so it does not block startup.

### Upgrading from `brvm-terminal`

The project was renamed to `kodji-terminal`. Your `.env` is not tracked by
git, so the rename cannot reach it — update it by hand on every machine
(including the VPS) before starting the app:

```bash
# .env
DB_PATH=./data/kodji.sqlite                              # was ./data/brvm.sqlite
HTTP_USER_AGENT=kodji-terminal/0.1 (+contact: you@example.com)
```

Then move the database to match:

```bash
sqlite3 data/brvm.sqlite 'PRAGMA wal_checkpoint(TRUNCATE);'
mv data/brvm.sqlite data/kodji.sqlite
rm -f data/brvm.sqlite-shm data/brvm.sqlite-wal
```

Do not skip this. SQLite **creates an empty database** rather than failing
when `DB_PATH` points at a file that no longer exists, so a stale `.env`
gives you a silently empty app — HTTP 200s with no securities — instead of
a startup error. The schema itself is unaffected: `_schema_migrations`
tracks migration ids only, so no re-migration is needed.

The console script is now `kodji-tui` (was `brvm-tui`), and the installed
package is `kodji` (was `brvm`); re-run `just sync` to refresh both.

## Try it (Phase 2)

After `just migrate` + `just snapshot`, run the web app and open the
terminal in your browser:

```bash
just snapshot   # populate the DB with the latest quotes
just dev        # http://127.0.0.1:8765
```

Available pages:

- `/` — market overview (indices strip + gainers / losers / turnover leaders,
  auto-refresh every 60s during market hours, 5 min otherwise)
- `/directory` — full securities table with country / sector / kind /
  text filters (HTMX)
- Topbar **search** — type ticker or name; Enter jumps to the first hit
- `/s/{TICKER}` — single security page with tabs: Chart (Lightweight
  Charts price history) · Description · Peers · News · Corporate actions ·
  Financials · Ownership · Segments. Tabs with no data yet render a
  graceful empty state.
- `/news` — filterable news feed (ticker / category / date / min-relevance)
  with HTMX pagination
- `/watchlists` — create and manage named watchlists
- `/watchlists/{slug}` — quote board for one list, add/remove tickers inline
- `/health` — JSON liveness, plus the scheduler's verdict under `jobs`
  (see *Ops — the job watchdog* below)

## Try it (Phase 3a demo)

After `just migrate`, run one news+communiqués+dividends poll:

```bash
just news-poll
```

Prints the row-count summary (news / communiqués inserted vs deduped,
dividend-calendar rows inserted vs updated), the 5 latest news items,
and the next-30-day corporate-actions calendar. Second run against the
same fixtures reports 0 new rows — dedupe on `url_hash` for news, and
`(ticker, kind, ex_date)` pre-check for corporate actions.

The web UI still shows the Phase 2.5 shell tabs ("Coming in Phase 3");
the news/actions tabs light up in Phase 3c.

## Try it (Phase 3b demo — news tagging)

Tags every news item ingested by `just news-poll` with Claude Haiku:
tickers, relevance 0-10, category, and a 1-2 sentence summary in both
French and English.

```bash
cp env.example .env         # then set ANTHROPIC_API_KEY=sk-ant-...
just news-poll              # ingest first (Phase 3a)
just news-tag-dry           # see the batch plan; spends nothing
just news-tag               # tag for real
```

Sample output:

```
news tagging:
   pending_before = 40
          batches = 5
           tagged = 40
       unanswered = 0
   failed_batches = 0
   skipped_budget = 0
    pending_after = 0
    cost this run = $0.0295
      spend today = $0.0295 / $1.0000 cap

llm_spend 2026-08-21: calls=5 in=7000 out=4500 ($0.0295)
```

(Batch counts are from a real 40-item pass over the committed fixtures;
the token/cost figures are indicative — actual usage depends on how much
of the ~1.2k-token system prefix comes back as a cache read.)

What it guarantees:

- **Hard $1/day cap.** Real per-call cost is written to `llm_spend` in
  micro-dollars right after every call, and the budget is re-checked
  before each batch. Once the day is spent the worker no-ops with a
  warning until UTC midnight. Change the ceiling with
  `LLM_DAILY_CAP_CENTS`.
- **Never re-processed.** Every item handed to a successful call gets
  `tagged_utc` stamped, so re-running `just news-tag` costs nothing.
- **Degrades quietly.** No `ANTHROPIC_API_KEY`, an exhausted budget, or a
  failing API all end in counts + a log line, never a crash — the
  scheduled job is safe to leave on.

Tagging also runs on the scheduler (7 minutes behind each news poll:
`*/15` during market hours, hourly otherwise), so `just dev` keeps the
feed tagged on its own.

The tagged fields (`tickers_llm`, `relevance`, `category_llm`,
`summary_fr`, `summary_en`) power the `/news` page and the per-ticker
News tab that Phase 3c wired up.

## Try it (Phase 4a / 4b demo — filings + fundamentals extraction)

Phase 4a pulls annual/interim PDFs from `brvm.org` into `data/filings/`
and records one row per PDF in `filings`; Phase 4b extracts structured
fundamentals from those PDFs with Haiku and fills the Financials /
Ownership / Segments tabs on `/s/{TICKER}`.

```bash
MAX_ISSUERS=6 just filings-pull       # walk 6 issuers, download PDFs
just fundamentals-extract-dry         # see the plan + estimated cost
just fundamentals-extract             # extract for real ($2/day cap)
just dev                              # /s/BOAC/financials etc.
```

`just fundamentals-extract-dry` is read-only — it probes each PDF with
pypdf, reports which are scanned (skipped by 4b — real OCR is on the
backlog) and how much a full pass would cost, without spending a cent or
mutating the DB. `just fundamentals-extract` writes to the fundamentals
tables and to `filings_spend` (its own daily counter, separate from
`llm_spend` — an annual report is orders of magnitude bigger than a news
batch, so extraction has its own $2/day ceiling via
`LLM_EXTRACT_DAILY_CAP_CENTS`).

What it guarantees:

- **Hard $2/day cap.** Same shape as 3b: real cost accounted in
  `filings_spend` micros right after every call, budget re-checked
  before every filing, worker no-ops with a warning until UTC midnight
  once crossed.
- **Never re-processed.** Every filing handed to a call (successful,
  failed, or empty) gets `filings.extracted_utc` stamped so a re-run
  costs nothing. Scanned PDFs also get `is_scanned=1` so pypdf never
  probes them again.
- **Degrades quietly.** No `ANTHROPIC_API_KEY`, an exhausted budget, a
  missing PDF on disk, or a failing API all end in counts + a log line,
  never a crash.

Extraction also runs daily on the scheduler at 03:00 Africa/Abidjan
(`fundamentals_extract_daily`), well after market close.

## Try it (Phase 4c demo — OCR + sikafinance fallback + interim)

Phase 4c fills the gaps 4b left open:

- **OCR** rescues scanned French annual reports so the extractor can pick
  them up. Requires the `ocrmypdf` binary (see Requirements above).
- **Sikafinance-communiqué fallback** promotes filing-worthy communiqué
  rows (états financiers / rapport d'activités) into the `filings`
  corpus, catching reports brvm.org missed. Runs automatically at the
  tail of `just filings-pull`.
- **Interim extraction** extends the extractor's default gate to include
  `rapport_activites`, and the Financials tab now shows the most recent
  H1/Q1/Q3 as a separate card above the annual table (period-to-date
  figures don't belong in a year-over-year row).

```bash
just filings-pull            # brvm.org walk + sikafinance promotion
just filings-ocr             # OCR every is_scanned=1 filing (free, CPU-only)
just fundamentals-extract    # extract, including newly-OCR'd + interim
```

Guarantees:

- **Never re-OCR automatically.** Every filing handed to the OCR runner —
  success or failure — gets `filings.ocr_attempted_utc` stamped. An
  operator forcing a retry clears that column manually.
- **Cross-source dedupe.** The sikafinance promoter checks the
  `(ticker, doc_type, period_kind, period_year)` triple before
  downloading, so the same H1 report from both brvm.org and sikafinance
  is stored once.
- **Bounded per-file OCR time.** `OCR_TIMEOUT_S=600` (per file) and
  `OCR_MAX_FILES_PER_RUN=20` keep the nightly slot honest.

OCR runs daily on the scheduler at 02:00 Africa/Abidjan
(`filings_ocr_daily`), one hour ahead of the extractor so newly-text-
layered filings land in the same night's cycle.

## Try it (Phase 4d demo — financial ratios)

Phase 4d turns the extracted `financials` rows into ratios (P/E, P/B,
P/S, dividend yield, payout, ROE, ROA, margins, YoY growth, financial
leverage, equity ratio) and renders them on:

- **`/s/{TICKER}/financials`** — a Ratios table under the annual
  financials, plus a small interim-ratios block (net margin, operating
  margin, ROE) under the interim card.
- **`/s/{TICKER}/peers`** — new P/E / ROE / net-margin columns for
  cross-ticker comparison in the same sector.

Ratios need `securities.shares_outstanding` (fetched from
sikafinance). Refresh it weekly:

```bash
just company-refresh   # walk stale rows, hit sikafinance societe pages
just dev               # /s/SNTS/financials → Ratios block + Peers with P/E
```

The runner is polite (0.5s between requests) and idempotent within a
week — a rerun within `OCR_MAX_AGE_DAYS` (default 7) is a no-op.
Runs automatically on the scheduler every Sunday at 04:30 Africa/Abidjan
(`company_facts_refresh_weekly`).

**Follow-up (shipped in Phase 7)**: P/FCF, FCF yield, and EV/EBITDA now
render alongside the earlier ratios — see the Phase 7 Try-it section
below. `docs/phases.md` has the full writeup for both phases.

## Try it (Phase 6a demo — alerts)

Phase 6a adds a rule engine over the existing snapshots / filings /
tagged news. Since PR-AA matched events reach the account's members as
Web Push notifications (every device they enabled on `/alerts`) or by
email for members with no device on file — see the PR-AA demo below.

```bash
just dev                    # /alerts — create + toggle + delete rules
just alerts-eval            # one eval pass — fires matching events
just alerts-deliver         # drain queue: push per device, email otherwise
```

Rule kinds:

- **`price_move`** — fires when `|change_pct| ≥ threshold_pct` on the
  latest snapshot. `ticker=None` scans every security (watchlist-wide).
- **`new_filing`** — fires on each new row in `filings`. Narrow by
  `ticker` and/or a CSV of `doc_types`.
- **`news`** — fires on Haiku-tagged news whose `relevance ≥
  min_relevance` and whose attribution (`ticker_hint` or `tickers_llm`
  CSV) matches the rule's ticker. Untagged rows don't participate.

Guarantees:

- **Never re-fire.** `(rule_id, dedupe_key)` is UNIQUE at the store
  layer — a re-eval on the same snapshot / filing / news row is a no-op.
- **Never lose an event.** `delivered_utc IS NULL` is the queue; a push
  service or mailer outage leaves rows for the next pass. Batch cap
  (`ALERTS_DELIVERY_BATCH=10`) keeps recovery from becoming a flood.
- **Degrades quietly.** No VAPID keys and no email → events are marked
  `skipped` and stay visible on `/alerts` for manual review. An account
  with no reachable member is `skipped` too.

Alerts also run on the scheduler: eval every 15 min during market hours
(offset +11 from the news poll so tagged relevance has settled), hourly
otherwise; delivery every 5 min.

## Try it (Phase 6b demo — daily brief)

Post-close markdown brief synthesized by Haiku from the day's indices,
top movers, high-relevance tagged news, and next-7-day corporate
actions. Overwrites the same-day row on rerun (there's only one brief
for a given day).

```bash
just brief-run-dry            # gather-only: prints context counts
just brief-run                # real call ($0.50/day cap)
just dev                      # /brief (latest) + /brief/YYYY-MM-DD
```

The scheduler wires `brief_daily` at 15:30 Africa/Abidjan Mon-Fri —
BRVM closes ~15:00, and the news tagger has run by then so relevance
scores are settled. `/brief` renders the latest brief server-side via
`markdown-it-py`; the sidebar lists the last 30 days for archive
browsing.

Guarantees:

- **Hard $0.50/day cap** in `brief_spend` (a separate counter from
  `llm_spend` and `filings_spend`). One brief per weekday at Haiku
  rates rounds to fractions of a cent; the cap is a safety net.
- **Overwrite on rerun**, not append — the store `INSERT OR REPLACE`s
  the row for `day`, so a mid-day dry-run followed by the real
  post-close run leaves the good one.
- **Degrades quietly.** No `ANTHROPIC_API_KEY`, an exhausted cap,
  an empty reply, or a transport error all end in counts + a log
  line, never a crash. `context_json` is stored so a future re-run
  with a different prompt doesn't need to re-gather the source data.

The brief is **clearly labelled machine-generated** in the UI so
readers don't mistake the synthesis for editorial commentary.

## Try it (Phase 5 demo — Textual TUI)

The TUI reads the same SQLite as the web app and calls the same
services layer. A dense terminal shell with a persistent watchlist
sidebar and a right pane that swaps between screens.

```bash
just tui              # or: uv run python -m kodji.apps.tui
# also available as `kodji-tui` on the PATH after `just sync`
```

Layout:

```
┌ header ─────────────────────────────────────────────────────────┐
│ ● OPEN   last snapshot: 42s ago             2026-08-26 09:42 Abidjan │
├────────────────────────┬────────────────────────────────────────┤
│ Watchlist / Turnover   │  Home / Ticker / Directory / News /    │
│   leaders (◂/▸ arrows) │  Watchlists / Alerts (h/t/d/F5/w/a)    │
│   Enter → open ticker  │                                        │
├────────────────────────┴────────────────────────────────────────┤
│ footer: keybinding hints                                        │
└─────────────────────────────────────────────────────────────────┘
```

Keybindings:

| Key       | Action                                            |
|-----------|---------------------------------------------------|
| `h`       | Home (indices strip + movers + high-relevance news) |
| `t`       | Ticker view (last selected)                       |
| `d`       | Directory (sortable columns: 1W / 1M / 3M / YTD / 1Y / ALL) |
| `F5`      | News feed (`/` inside for filters)                |
| `w`       | Watchlists (create / delete / add / remove)       |
| `a`       | Alerts (events inbox + rules editor)              |
| `ctrl+k`  | Command palette — search ticker or company name   |
| `shift+w` | Cycle sidebar watchlist                           |
| `r`       | Force refresh now                                 |
| `q`       | Quit                                              |

Refresh model: `set_interval(30)` during market hours (paused
off-hours via `clock.is_market_open()`); repaints preserve
`DataTable.cursor_coordinate` and scroll offset so the timer
doesn't yank the cursor around. Manual `r` always works.

The ticker view mirrors the web `/s/{ticker}` tabs (Overview, Chart,
News, Financials, Peers, Corp actions, Brief, Analyst view). Charts
use `plotext` for an inline Braille line-plot. Markdown (brief +
analyst note) renders via Textual's `Markdown` widget.

## Try it (Phase 6c demo — analyst notes)

Weekly per-ticker synthesis on the new `Analyst view` tab of
`/s/{ticker}`. Sonnet reads the last 30 days of tagged news, 5-year
annual financials + latest interim, computed ratios, 90-day price
stats, ownership + segments — then writes a ~1000-word markdown note
grouped as: Snapshot / Recent developments / Financial position /
Ratios read-across / Risks & watch items.

```bash
just analyst-notes-run-dry --ticker SNTS   # gather-only: context counts
just analyst-notes-run --ticker SNTS       # one ticker, real Sonnet call
just analyst-notes-run --limit 5           # smoke-run 5 tickers
just analyst-notes-run                     # full weekly pass ($3/day cap)
just dev                                   # /s/SNTS/analyst
```

The scheduler wires `analyst_notes_weekly` at 20:00 Africa/Abidjan on
Saturday — after Friday's close, all the weekend enrichment jobs
(sector, company-facts, history backfill are set for Sunday but the
note doesn't need them). By Monday's open the archive lists the newly-
written notes on every equity page. Archive sidebar links to
`/s/{TICKER}/analyst/YYYY-MM-DD` for prior weeks.

Guarantees:

- **Hard $3/day cap** in `note_spend` (its own counter, separate from
  `llm_spend` / `filings_spend` / `brief_spend`). A full 47-ticker
  pass at Sonnet rates ≈ $1.90; the cap gives one full retry of
  headroom, and `NOTES_DAILY_CAP_CENTS` gates a rerun that would
  drain the budget.
- **Overwrite by week, not append.** The store keys on
  `(ticker, week_start)` and `INSERT OR REPLACE`s — a rerun mid-week
  produces a fresher take on the same week's data, which is what a
  reader expects.
- **Only active equities.** Indices and bonds are skipped; the tab
  404s for indices.
- **Degrades quietly.** No `ANTHROPIC_API_KEY`, an exhausted cap, an
  empty reply, or a transport error all end in counts + a log line,
  never a crash. `context_json` is stored so a future re-run with a
  different prompt doesn't need to re-gather the source data.

The note is **clearly labelled machine-generated** in the UI so
readers don't mistake the synthesis for sell-side research. There is
no sell-side research on the BRVM — that's the whole point.

## Try it (Phase 7 demo — cash-flow + filings references)

Three new cash-flow columns on `financials` — `cash_flow_ops`, `capex`,
`free_cash_flow` — populated by the Haiku extractor from the
"Flux de trésorerie" section of each annual report. The Financials tab
gains a P/FCF, FCF yield, and EV/EBITDA proxy in the ratios table, and
a **References** section that lists the source filings behind each
row with a link back to the original PDF.

```bash
just fundamentals-recover-cashflow-dry   # count filings needing re-extract
just fundamentals-recover-cashflow       # clear extracted_utc on those
just fundamentals-extract                # re-run against the reset filings
just dev                                 # /s/SNTS/financials
```

The recovery job is idempotent — once a row has any of the three
cash-flow columns populated, it's out of the query. Extraction still
respects the `LLM_EXTRACT_DAILY_CAP_CENTS=200` daily cap; a full ~200-
filing backfill fits comfortably in one day at Haiku rates.

Notes on the multiples:

- **P/FCF** and **FCF yield** use market cap (`shares * price`) and are
  suppressed on currency mismatch or when FCF ≤ 0 (yield still shown as
  a signed % — the direction matters).
- **EV/EBITDA** is a **proxy** — we don't yet ingest net debt or D&A,
  so EV = market cap and EBITDA ≈ operating income (RBE). The Ratios
  cell hovers a `title` with the exact formula, and the table footer
  spells out the caveat so nobody screens on it as a textbook multiple.
- **References** section lists every `(period, doc_type)` currently
  backing the persisted rows, joined onto `filings` for the audit
  trail. Annual filings are surfaced above interims inside a given
  year (that's usually what the reader came for).

## Try it (Phase 8 demo — bond ingestion)

Bonds finally join the securities table. brvm.org publishes three
category pages (state / regional / private); `just bonds-poll` walks
all three and upserts the rows.

```bash
just migrate
just bonds-poll        # first run: securities=~100 bars=~100
just bonds-poll        # re-run:    all UPSERTs, no growth
just dev               # /directory?kind=bond
```

`kind=bond` rows land in `securities` (with `sector` set to the French
category label — `Obligations d'Etat` / `Obligations régionales` /
`Obligations privées`), and today's price lands in `daily_bars.close`
so the same period-return SQL that powers equities and indices covers
bonds too. Period returns will read mostly 0% — bonds anchor to par
(10 000 XOF) and only drift on rare secondary-market trades.

State bond issuer country is derived from `ETAT DU {country}` in the
name (mapping covers all eight WAEMU members). Regional and private
bonds stay `country=NULL` — they aren't tied to a single country and
we prefer honest nulls over guesses.

## Try it (PR-X2 demo — magic-link sign-in)

No password anywhere. You submit an email address, we mail a link **and**
a 6-digit code, and either one signs you in.

With no `RESEND_API_KEY` set, the mailer logs the message instead of
sending it — so you can complete a real sign-in locally without signing
up for anything:

```bash
just migrate
just dev                       # http://127.0.0.1:8765/login
```

Submit your address, then read the link (or the code) off the terminal:

```
WARNING kodji.services.mailer: email not sent (no RESEND_API_KEY) — to=you@example.ci subject=Votre lien de connexion Kodji
Bonjour,

Voici votre lien de connexion à Kodji Terminal :

    http://127.0.0.1:8765/login/t/lS3k...

Ou saisissez ce code dans l'onglet où vous avez demandé la connexion :

    418207
```

Paste the link, or type the code into the form that's already on screen.
The topbar then shows your address and a **Sign out** button, and the
watchlists and alert rules you create belong to your account and nobody
else's.

### Sending real email

Resend is the provider (chosen 31 Aug 2026 — see
[`docs/kodji-plan.md`](./docs/kodji-plan.md)). Two settings turn it on:

```bash
# .env
RESEND_API_KEY=re_...
EMAIL_FROM=Kodji <connexion@mail.kodji.app>
EMAIL_REPLY_TO=support@kodji.app      # optional; see below
PUBLIC_BASE_URL=https://kodji.app     # required in production, see below
```

`EMAIL_REPLY_TO` matters more than it looks. The sender is on
`mail.kodji.app`, which has no mailbox behind it (only Resend's bounce
handler), so a user who hits Reply on the sign-in mail — "I never got
the code" is a common one — bounces. Point it at a PrivateEmail alias
on the apex that you actually read.

Three things matter more than the vendor choice:

- **Authenticate a sending subdomain**, not the apex: SPF, DKIM and
  DMARC on `mail.kodji.app`. Gmail and Yahoo have required alignment
  from bulk senders since 2024, and a good chunk of BRVM's audience is
  on one or the other. The apex belongs to the human mailbox
  (PrivateEmail) — keeping the two apart means an app-side spam
  complaint cannot touch your own mail.
- **Keep the daily brief off this sender.** A brief blast is bulk-shaped
  and attracts complaints; sign-in mail must not share its reputation.
- **Set `PUBLIC_BASE_URL` in production.** Behind Cloudflare and Caddy
  the request's own host is whatever the last proxy claimed, and a link
  built from a spoofed `Host` header is a live credential pointed at
  someone else's domain.

Note the value is unquoted in `.env`: `EMAIL_FROM=Kodji <connexion@...>`.
If you do quote it, use straight ASCII quotes on both ends — a smart
quote from a text editor becomes part of the address and Resend rejects
every message with a 422.

DNS, when the domain already hosts a mailbox (kodji.app on
PrivateEmail):

| Host | Type | Why |
| --- | --- | --- |
| `mail.kodji.app` | TXT (DKIM) + MX + SPF, all from Resend's dashboard | The sending subdomain. Resend's MX is the bounce return path; it does not touch apex mail. |
| `kodji.app` | MX → PrivateEmail, TXT SPF → `include:spf.privateemail.com` | Unchanged. This is where you *receive*. |
| `_dmarc.kodji.app` | TXT `v=DMARC1; p=none; rua=mailto:you@kodji.app` | Start at `p=none`, read the reports for a week, then tighten to `quarantine`. It covers subdomains too. |

If you move the nameservers to Cloudflare, copy **every** PrivateEmail
record across before the switch — MX, apex SPF, DKIM, the autodiscover
CNAMEs — and leave all of them DNS-only (grey cloud). Proxying an MX
host silently breaks mail delivery.

### Abuse caps on the sign-in form

Three, in the order they are checked:

| Cap | Setting | On trip |
| --- | --- | --- |
| Per address, per hour | `LOGIN_MAX_PER_HOUR=5` | Same "check your email" page, nothing sent — a stranger learns nothing about who has been asking for links. |
| Global, per hour / per day | `LOGIN_MAX_SENDS_PER_HOUR=30` · `LOGIN_MAX_SENDS_PER_DAY=80` | `503` with `Retry-After`, an honest "temporarily unavailable", and an `ERROR` log line. |
| Wrong-code guesses per challenge | `LOGIN_CODE_MAX_ATTEMPTS=5` | The challenge is burned; ask for a new link. |

The global one is the spray defence: a script posting 100 *different*
addresses passes the per-address cap every time and would otherwise
spend Resend's free-tier quota (100/day) in a minute — locking every
real user out until the reset and making `mail.kodji.app` a source of
unwanted mail. Keep the daily cap under the provider's quota.

**Per-IP is deliberately not done in the app.** Behind Caddy the app
sees `127.0.0.1` for every request, and trusting a forwarded header is
a deploy-time decision. Once the site is behind Cloudflare, add a
Rate Limiting rule (the free plan includes one, with the period and
block fixed at 10 seconds): *if* `URI Path equals /login` *and*
`Request Method equals POST`, *then* block above 3 requests per
10 seconds per IP. That throttles a burst; the global cap underneath
it is what actually bounds the damage. Exact steps are in the
[deploy runbook](./docs/deploy-kodji-app.md).

### Turning sign-in from optional into required

`AUTH_REQUIRED` is `false` today, which keeps the existing single-user
box working exactly as it does: a request with no session resolves to
the account migration 0017 seeded. **Set it to `true` before the app is
reachable by anyone but you** — with it off, an anonymous visitor reads
that account's data.

**First, claim that account.** Nothing links an email address to the
seeded account 1, so your own first sign-in would mint a fresh free
account and none of your watchlists or alert rules would be in it:

```bash
just claim-owner you@example.com     # idempotent; then sign out and in
```

Plan gating (PR-Y) does *not* depend on that flag: an anonymous request
resolves to the default account and is enforced against whatever plan it
holds. The two are independent — gating decides *what* a caller sees,
`AUTH_REQUIRED` decides *whether* a caller has to identify themselves.

## Try it (PR-Y demo — plan gating)

Market facts, the chart and peers are free; what is personal (alerts,
unlimited watchlists), intelligent (brief, analyst view, ratios) or
terminal is paid. The split is `docs/kodji-plan.md` P4, and `/pricing`
renders it.

Your own account stays on paid — migration 0019 puts account 1 there, so
gating can't lock the operator out of their own terminal. To *see* the
free tier, flip it and flip it back:

```bash
just migrate                 # applies 0019
uv run python - <<'EOF'
from kodji.db import connect
from kodji.config import settings
from kodji.store import accounts as repo
with connect(settings.db_path) as c:
    repo.set_plan(c, 1, "free")
EOF
just dev
# /              → renders; Alerts and Brief drop off the topbar
# /s/SNTS/chart  → 200 (free since 23 Sep 2026, and so is /api/history/SNTS)
# /s/SNTS/financials → 402 with an upgrade wall
# /pricing       → free vs paid, always reachable
# adding an 11th distinct ticker to a watchlist → 402 + a cap notice
```

Put yourself back with `repo.set_plan(c, 1, "paid")`.

Where the enforcement lives:

- `apps/web/tabs.py` — `TabSpec.min_plan` marks a tab paid, and
  `visible_for(kind, plan)` drops it from the tabbar.
- `apps/web/_gating.py` — `refuse_if_unpaid(request, feature=...)`, called
  as the first statement of every paid route across **all three** route
  families (pages, `_frag` fragments, `/api`). Hiding a tab is not access
  control; the URL stays typeable.
- `services/watchlist.py` — `FREE_WATCHLIST_LIMIT`, counted on distinct
  tickers across all of an account's lists. Enforced in the service, not
  the route, because the TUI adds items too.

`tests/test_gating.py` walks every paid tab across all three route
families and asserts a free caller is refused on each. Adding a paid tab
without a guard fails that test.

## Try it (Phase 1 demo)

After `just migrate`, run one live snapshot cycle and print the top-10
securities by daily turnover:

```bash
just snapshot
```

Example output:

```
TICKER   NAME                                     LAST     CHG%       VOLUME     TURNOVER XOF
---------------------------------------------------------------------------------------------
SPHC     SAPH CI                              8,990.00   +7.02%       52,971      476,209,290
BICB     BANQUE INTERNATIONALE POUR LE CO     8,295.00   -2.35%       52,947      439,195,365
SGBC     SGBCI                               39,200.00   -0.25%        7,088      277,849,600
...
```


## Try it (PR-Z demo — Flutterwave billing)

Paid periods are bought through Flutterwave's hosted checkout, in XOF,
**one payment per period**. Flutterwave's recurring "payment plans" pin the
checkout to card, which would exclude Orange Money, Wave and MTN MoMo —
most customers here — so nothing auto-renews: a customer pays for 1 month
or 12 months, paying again *extends* the current period, and reminder
mail goes out 7 days and 1 day before it ends. The plan reads as free the
moment the period ends; an hourly job stamps it `expired` and says so
once by email.

```bash
# .env — test keys from the sandbox account (prefixed _TEST); prices in
# INTEGER francs, XOF is zero-decimal.
FLW_PUBLIC_KEY=FLWPUBK_TEST-...
FLW_SECRET_KEY=FLWSECK_TEST-...
FLW_ENCRYPTION_KEY=FLWSECK_TEST...
FLW_WEBHOOK_HASH=<the "secret hash" you set on Settings → Webhooks>
PRICE_MONTH_XOF=12000
PRICE_YEAR_XOF=120000
```

```bash
just migrate                # 0021_payments
just dev                    # sign in, then /pricing → "Pay 1 month"
```

The flow: `POST /billing/checkout` (signed in, Origin-checked) records a
`pending` payment with a `tx_ref` we mint and 303s to the hosted page.
The customer comes back on `GET /billing/return`; the webhook lands on
`POST /billing/webhook` authenticated by the `verif-hash` header. **Both
are hints, not proof**: the plan is activated only after
`GET /v3/transactions/{id}/verify` says `successful`, `XOF`, amount ≥
price, same `tx_ref`. Activation is idempotent, so redirect and webhook
can both arrive in any order. `/billing` shows the account's plan, period
end and payment history.

**Two gateways, one setting.** `BILLING_PROVIDER=flutterwave|paystack`.
Both adapters answer the same three calls (checkout link, verify a
reference, authenticate a webhook) and normalise to one shape, so the
plan lifecycle, pages and jobs are provider-blind. A payment remembers
which provider issued it, so the switch is safe mid-flight. Paystack
(Côte d'Ivoire: Orange Money, Wave, MTN MoMo, cards):

```bash
BILLING_PROVIDER=paystack
PAYSTACK_SECRET_KEY=sk_test_...
PAYSTACK_PUBLIC_KEY=pk_test_...
PAYSTACK_CHANNELS=card,mobile_money
# dashboard → Settings → Webhooks: https://kodji.app/billing/webhook/paystack
# (authenticated by HMAC-SHA512 with the secret key — no separate hash)
```

Paystack bills XOF in hundredths (its checkout shows "XOF 120" for an
amount of 12000); the adapter multiplies by 100 on the way out and divides
on the way back, so prices stay integer francs everywhere else. Each
provider has its own webhook URL (`/billing/webhook/flutterwave`,
`/billing/webhook/paystack`); the bare `/billing/webhook` is whichever
`BILLING_PROVIDER` names.

Signed in and paid, the topbar link becomes **My plan** (`/billing`):
period end, the two extend buttons, payment history, and the note that
there is no automatic renewal and nothing to cancel — the plan simply ends
on the date unless extended. Signed out (or free) it stays **Plans**.

**A visitor with no session is always the free tier**, whatever plan
account 1 holds and whether or not `AUTH_REQUIRED` is on. Before PR-Z an
anonymous request with the flag off resolved to account 1 — paid since
migration 0019 — and saw the whole paid product. Sign in to see yours.

The hosted page offers what `FLW_PAYMENT_OPTIONS` names (default
`card, mobilemoneyxof` — Orange Money, Wave, MTN MoMo, Moov for XOF), but
Flutterwave honours that only after you **uncheck "Enable Dashboard
Payment Options"** in the dashboard's account settings; until then a
fresh sandbox shows card alone.

Test mode: any mobile number with OTP `123456` mocks a successful mobile
money payment; test cards are in Flutterwave's docs. Without keys the
pricing page says checkout is not open and the webhook answers 401.

## Try it (PR-AC demo — the terminal client)

```bash
just migrate                          # 0032: sync_tokens
just dev
# /terminal                           → paid: install steps + mint a sync token
```

Then, as a subscriber would:

```bash
uv run kodji login --token kodji_sync_... --url http://127.0.0.1:8765
uv run kodji sync                     # ~11 MB, one file
uv run kodji status
uv run kodji tui                      # the terminal, offline, against the copy
```

**How a subscriber gets the client.** The repository is private, so
`pip install git+...` is not a path they can take. `just build-client`
builds a wheel into `dist/` and `/terminal` serves the newest one to a
paid session at `/terminal/client.whl`; the page then shows `uv tool
install ./<that file>`. Run it on the box after every deploy so the
client and the server are the same build. `just publish-client` pushes
the same wheel to a package index once one is chosen.

**The built wheel is the client, not the application.** `uv build`
excludes the web app, the jobs and the server-only services, so the wheel
carries 74 modules instead of 239 and a private repository stays private
when the client ships. Excluded: `apps/web`, `jobs`, and the assistant,
billing, sign-in, mailer, web-push, extraction, tagging, social,
analytics and snapshot-builder services with their stores. Kept, because
client code reaches them: `services/corporate_actions` (the security tab
imports its labels), `sources/brvm_org_avis` (bonds imports its types),
`store/auth` (session plumbing behind `accounts`).

**No prompt ships.** The brief and the analyst note were each one module
doing two jobs: assembling a prompt and calling a model, then reading the
result back. The terminal only ever does the second. They are now
`services/brief.py` + `services/brief_writer.py` and
`services/analyst_notes.py` + `services/analyst_notes_writer.py`, with
the readers keeping the module name because that is what every caller
imports. The writers, `services/llm.py` and `services/translation.py`
then fall out of the client's reach on their own. A test greps every
module that survives into the wheel for the system-prompt constant, so a
new writer landing in a shipped module fails even if nobody updates a
list.

`uv sync` installs this project **editable** from `src/`, so none of this
touches the repo or the box — it only shapes what `uv build` produces.
Two tests keep it honest: one recomputes what the client can reach (its
runtime import closure, then expanded through the AST so an aliased
deferred import counts) and fails if any of it is excluded; the other
fails if a named sensitive module would ship. The first one earned its
place immediately — the first trimmed build broke `market.overview` on a
deferred `import offerings as offerings_svc`, which a text search had
missed.

**Client and server dependencies are split.** `dependencies` in
`pyproject.toml` is now only what the terminal needs — eight packages,
the measured import closure of `kodji.cli` and `kodji.apps.tui` — and
everything the web app, the jobs and the LLM calls need lives in a
`server` extra. Installing the client no longer drags in FastAPI,
uvicorn, SQLAlchemy, pypdf, the Anthropic SDK, APScheduler or
`cryptography`.

Two imports had to move for that to be true, because a type annotation
was pulling a whole dependency into the client: `services/watchdog.py`
only ever needs APScheduler's trigger as a *type*, and
`services/alerts.py` only needs `webpush` (and therefore `cryptography`)
where it actually sends. Both are under `TYPE_CHECKING` now, with the
send-side import inside the delivery function.
`tests/test_packaging.py` imports the client's entry points in a clean
subprocess and fails if any server-only distribution loads, so the list
stays honest rather than being a comment that rots.

Nothing changes on the box: the server's packages are also a `server`
**dependency-group**, and `[tool.uv] default-groups` includes it, so
`uv sync --no-dev` and `uv run` still install the full application
without a new flag. A test pins the extra and the group to each other.

The terminal is not an API client and was not rewritten into one. Every
service already resolves the database through `settings.db_path`, so the
client downloads a *copy of the database* and points the existing TUI at
it. `kodji sync` is a few hundred lines because of that.

**The replica is single-tenant.** The migrations seed account 1 as a paid
personal account, so a freshly-migrated file already has the shape the
app had before multi-tenancy. `services/sync.py` re-keys the
subscriber's own watchlists, portfolios and alert rules onto account 1 on
the way in — which is why the TUI, which hardcodes `DEFAULT_ACCOUNT_ID`
everywhere, needed no changes at all.

**Every table is classified once**, in `TABLE_POLICY`: shared market
data (optionally trimmed to a window), the caller's own rows, seeded by
the migrations, or never shipped with the reason written down. Building a
snapshot from a database holding a table nobody classified *fails*
rather than shipping it, and `tests/test_sync_snapshot.py` asserts the
policy covers the schema exactly. Rows are copied with an explicit column
list, never `SELECT *`, so a new column arrives deliberately; the columns
that must not travel are overwritten in place (`filings.file_path`, and
the model, token counts and cost behind every brief, note and weekly) so
the schema still matches. `alert_events` has no account column and the
TUI's own query does not filter — the snapshot scopes it through the
rule, which is what stops one subscriber reading another's fired alerts.

**Tokens** are bearer credentials minted on `/terminal`, shown once and
stored as SHA-256 digests like sessions. Five live per account,
`SYNC_TOKEN_TTL_DAYS` (90) each, revocable. `/api/sync/*` is the only
place in the app authenticated by something other than the session
cookie, and a session cookie is deliberately *not* enough: pulling the
database takes a token. The plan is re-checked on every call, so a lapsed
subscription stops the replica **updating**, not working — the copy
already on the laptop keeps opening, and syncing resumes on renewal.
That is also the honest thing to sell, since there is no revoking a file
somebody already has.

**Offline.** A synced file gets a small JSON sidecar beside it, and the
terminal turns on `OFFLINE` when it sees one: a subscriber's laptop must
not start scraping sikafinance on its own account because a cache went
cold. In that mode the chart and news read the copy, peers fall back to
same-sector names from `securities`, and the company description — which
is not stored anywhere, both of its sources being live pages — is simply
absent. The header shows both "last snapshot" (how old the market data
is) and "synced N ago" (how old the copy is), because those are not the
same number.

Windows: `SYNC_HISTORY_DAYS` (1200, so the 3-year risk window survives),
`SYNC_QUOTES_DAYS` (180), `SYNC_NEWS_DAYS` (400). `kodji sync --full`
ignores all three. Paths follow XDG, `$KODJI_CONFIG` and `$KODJI_REPLICA`
override them, and the download is written to a temporary file, checked,
then moved into place — an interrupted sync never leaves an unopenable
terminal.

Still English-only: the TUI has no i18n wiring and about 290 chrome
strings. That is its own change, not this one.

## Try it (PR-AK demo — the assistant)

```bash
just migrate                          # 0031: assistant_messages + assistant_spend
just dev
# /assistant                          → paid: ask in plain language, FR or EN
```

Paid — the "intelligent" line of `docs/fiducy-gap-analysis.md`. The page
is the reader's recent exchanges plus a form; each answer carries the
list of services it consulted and the "machine-generated" badge.

The model never sees the database. `services/assistant.py` exposes twelve
tools, each a thin JSON view over a service a page already renders —
`search_securities`, `get_security`, `compare`, `get_financials`,
`get_dividends`, `get_news`, `market_overview`, `rankings`, `screen`,
`get_risk`, `my_portfolios`, `my_watchlists` — and the system prompt tells
it to quote only figures it just read from a tool result, to say when the
tools do not cover a question, and to link the `page` each result names.
A figure in an answer is therefore a figure a page on the site shows, in
the same rounding, from the same code path. Raw HTML in a reply is
escaped by the same markdown renderer as the brief.

Two caps, both checked before the first call:

- **per account per UTC day** — `ASSISTANT_DAILY_QUESTIONS` (20), a COUNT
  over `assistant_messages`, failed attempts included;
- **global per day** — `ASSISTANT_DAILY_CAP_CENTS` (200) in
  `assistant_spend`, the same shape as the tagger's and the brief's
  tables, so a chatty day cannot eat another job's budget line.

The loop is bounded by `ASSISTANT_MAX_TOOL_ROUNDS` (6): past it the model
is told so and the final call forbids tools, so it always ends in a text
answer. The last `ASSISTANT_HISTORY_TURNS` (4) exchanges of the account
are handed back as context, so "and over three years?" works. The
system prompt (instructions + the equity universe) and the tool list are
the cached prefix; the date, language and question come after it.

**Operator-only preview.** `ASSISTANT_OWNER_ONLY` (default `true`) keeps
the assistant to the owner account: anyone else gets a 404 on every
assistant route, no nav link and no card on the homepage map, as if the
feature did not exist. Set it to `false` in `.env` once the paid plan is
open; the ordinary paid gate stays underneath either way.

`ASSISTANT_MODEL` defaults to `claude-opus-5` with adaptive thinking at
`ASSISTANT_EFFORT=medium`; every model must have a row in
`_PRICES_PER_MTOK` (`services/llm.py`) or spend is billed at the ceiling
rate. `tests/test_assistant_service.py` runs the loop against the fake
client (tool round, history, both caps, refusal, the tool budget) and
every tool once over a seeded database; `tests/test_assistant_ui.py`
covers the gate, the HTMX fragments and the escaping.

## Try it (PR-AI demo — risk metrics and the comparison page)

```bash
just dev
# /s/SNTS/risk                        → paid: vol, drawdown, beta vs BRVMC over 6M / 1Y / 3Y
# /comparer?t=SNTS&t=ORAC&t=SGBC      → paid: up to five equities side by side + rebased chart
# /api/compare?t=SNTS&t=ORAC          → the chart's data (closes rebased to 100, Composite dashed)
```

Both are paid — the "intelligent" line of `docs/fiducy-gap-analysis.md`.
The Risk tab is a `TabSpec` with `min_plan="paid"`, so the leak test in
`tests/test_gating.py` walks it like every other paid tab; `/comparer`
and `/api/compare` gate themselves with `refuse_if_unpaid`. The Peers
tab links into the comparison pre-filled with the company and its first
four peers (a free reader sees the link with a "Paid" tag and lands on
the plans).

The maths lives in `services/risk.py` — pure functions over newest-first
close lists, moved there from the analyst note, which imports them back
so one definition of beta serves the note, the tab and the comparison.
Windows are calendar days back from the last close (182 / 365 / 1095);
beta and correlation align the stock and the Composite by session date
so a holiday gap never pairs two shifted returns; nothing is annualised
from fewer than 20 returns. The metrics read `daily_bars` only
(`history.get_history_offline`) — a 3-year beta must not be the thing
that triggers a sikafinance fetch.

One BRVM-specific row: **sessions with a move**. A name that trades two
days a week shows a string of unchanged closes, which pulls its measured
volatility *down*, so the tab says on what share of sessions the price
actually changed. The methodology page (`/methodologie#risk`) and four
new glossary terms explain the rest.

`services/compare.py` builds the columns from one paid screener pass
(same ratios, same fallbacks) plus the 1-year risk window, normalises
the tickers (`?t=snts&t=ORAC&t=SNTS` → SNTS, ORAC), reports unknown or
non-equity names rather than dropping them, caps at five, and marks the
best value per row only when at least two columns carry the metric.

## Try it (PR-AJ demo — glossary, methodology, SGI comparator, public weekly)

```bash
just dev
# /glossaire      → ~45 terms in six groups, FR and EN side by side, anchors (#per, #sgi)
# /methodologie   → how every number is computed, bilingual; #ratios, #portfolio…
# /sgi            → every licensed broker from brvm.org's directory; ?country=SN
# /hebdo          → the weekly posts the operator marked "posted" on /ops/social
just sgi-refresh  # re-read brvm.org's SGI list into src/kodji/data/sgi.json
```

The glossary is `apps/web/glossary.py`: each term carries both languages,
so a missing translation is a diff, not a discovery. The methodology page
is prose in two `{% if locale %}` blocks like the legal pages, and is
where the screener's `*` / `†` footnotes and the portfolio's benchmark
send a reader.

The SGI table is the exchange's own list (`sources/brvm_org.
parse_sgi_directory`, fixture-tested; `fetch_sgi_directory` walks the
~9 pages with a pause). Fees are **not** published centrally — each SGI
has its own tariff sheet — so `sgi.json` carries an operator-maintained
`fees` block per broker (brokerage %, minimum per order, custody %,
account opening, online platform, source URL, date checked). The page
shows "not published" until a row is filled; `just sgi-refresh` keeps
those fields when it re-reads the directory. As shipped, no fee is filled
in: the honest state, not a guess.

`/hebdo` shows only `social_posts` rows with `status = 'posted'` — the
weekly draft becomes public the moment it is marked posted on
`/ops/social`, and a draft's Monday 404s.

## Try it (PR-AH demo — portfolio)

```bash
just migrate          # 0030: portfolios + portfolio_transactions
just dev
# /portfolio            → your portfolios; create one
# /portfolio/<slug>     → positions, P&L, the trade form, the ledger
# paid: value vs the BRVM Composite, realised P&L, dividends received
#       and ahead; /api/portfolio/<slug>/history feeds the chart
```

A portfolio is a **ledger of buys and sells**, not a table of positions.
Quantity, average cost (fees included), market value, unrealised and
realised P&L, the day's move, dividends and the benchmark are all
replayed from it in `services/portfolio.py`, so correcting a mistyped
trade is deleting one row. Average-cost method; a sell realises
`qty × (price − average cost) − fees`. Equities only for now: the
service refuses bond tickers, which need price-as-%-of-nominal and
accrued coupon.

Plan line: **one portfolio of ten open positions free** (distinct
tickers with a positive quantity across all the account's portfolios,
so closing one frees a slot) showing the ledger, quantities, average
cost and last price; **every P&L figure is paid** — market value,
unrealised and realised P&L, the day's move, dividends — along with
unlimited size. Paid also gets the **benchmark**: a shadow portfolio that put the same cash into the
Composite on the same days (and took the same cash out on sells), so the
curve answers "what would this money have done in the index", not "did
the index go up". Dividends received are shares held the day before
each ex-date × the announced amount, gross of withholding; they exist
only as far back as notice ingestion goes. Prices are the latest
snapshot; history uses `daily_bars` and `index_levels`, nothing is fetched.

## Try it (PR-AG demo — the screener)

```bash
just dev
# /screener                          → every trading equity, market cap descending
# /screener?cap_min_bn=200           → large caps (billions of XOF)
# /screener?period=1m&ret_min=5      → up more than 5% over a month
# /screener?pe_max=8&yield_min=5     → paid: P/E under 8 and yield over 5%
# /screener?sector=Banques&sort=roe&direction=desc
```

The page is a GET form, so a screen is a URL you can bookmark or send.
The basic filters — search, country, sector, price, market cap, one
period return — are free and run over the same directory query as
`/palmares`. The **Fundamentals** block (P/E, P/B, yield, ROE, net
margin, payout) is paid: `services/screener.screen(..., fundamentals=False)`
drops those filters and never reads `financials`, so a free reader who
pastes `?pe_max=8` into the URL gets the basic screen plus a notice, not
a quiet leak or a 402 on a free page. The inputs are `disabled` for a
free reader, so a browser never sends them in the first place.

Ratios come from each issuer's newest annual `financials` row and the
live price through the same `ratios.compute_ratios` as the Financials
tab, with two fallbacks the tab does not need: when the filing gave no
EPS, EPS is net income over `shares_outstanding` (marked `*`); when it
gave no dividend, the yield is the trailing-12-month dividends from
`/dividendes` (marked `†`). Without them most banks have no P/E at all,
because their reports state net income and share count but not EPS.

## Try it (PR-AF demo — dividends & coupons)

```bash
just dev
# /dividendes           → next 90 days of dividends (ex-dates) and coupon
#                         payments, merged on date, undated dividends last;
#                         trailing-12m dividend yields; next coupon per bond
# /dividendes?days=180  → wider window (7..365)
```

Dividends are the `corporate_actions` rows the notice scrapers write, so
the yield table is only as deep as the ingestion history. Coupons come
from the same `build_schedule` the bond tabs use — residual nominal and
cadence inferred from the exchange's last payment — so a date here is the
date the Cash flow tab shows, and a bond with no anchor date is absent
from both.

## Try it (PR-AE demo — rankings, market map, indices)

```bash
just dev
# /palmares            → top-10 gainers and losers for the day; breadth line
# /palmares?period=1m  → same over a month (1d 1w 1m 3m ytd 1y all)
# /palmares?sector=BRVM%20-%20SERVICES%20FINANCIERS
# /indices             → every index, level + DAY/1W/1M/3M/YTD/1Y/ALL
```

The market map at the top of `/palmares` is every trading equity, area
proportional to `securities.market_cap_xof` (from the company-facts
refresh), colour and opacity from the day's move. It is a squarified
treemap laid out in Python (`services/rankings.squarify`) and rendered as
inline SVG — no chart library, nothing to load. Suspended names are left
out of both the map and the rankings: their last move is the trade before
the halt.

`/indices` lists one row per index name: Sikafinance publishes some sector
indices under two codes (`BRVM-SP` and `BRVMSP`), and the shorter code
wins. Its own indices and the capitalisation series sit in a second table.

## Try it (PR-AD demo — the homepage)

`/` is two pages. A visitor with no session gets the landing: what the
site is, the live indices and movers straight from the database, a map of
every feature tagged Free / Account / Paid, where the numbers come from,
the price, and the TUI. A signed-in reader gets the market overview,
because they already know what this is. Both are reachable by name:

```bash
just dev
# /          → landing when signed out, market overview when signed in
# /about     → the landing, always
# /market    → the overview, always (the topbar's "Overview" points here)
# /sitemap.xml → public pages + one URL per listed security
```

The feature map is `apps/web/features.py`, one entry per page with its
plan flag. `tests/test_landing.py` opens every card's link with a paid
client and asserts 200, checks the paid tags against `tabs.paid_keys()`,
and asserts every string on the page has French — so a card can't point
at a dead route or promise the wrong plan.

Every page now carries `description` / `og:*` / `twitter:card` meta for
link unfurls; the card image is `static/img/og-1200x630.png` (from
`brand/`, regenerated by `just logo`). `og:image` is absolute only when
`PUBLIC_BASE_URL` is set, and so is the `Sitemap:` line in `robots.txt`.

## Try it (PR-AA demo — installable app + Web Push)

The web app is a PWA: `/manifest.webmanifest`, an icon set, and a service
worker at `/sw.js` that precaches only the static shell and an `/offline`
page. It deliberately caches **no data** — every page and HTMX fragment
comes from the network — so a stale price can never look current.

Alerts go out by Web Push. One-time setup:

```bash
just vapid-keygen            # prints VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY → paste into .env
just migrate                 # 0022_push_subscriptions
just dev
```

Then open <http://127.0.0.1:8765/alerts>, tap **Enable on this device**,
accept the browser prompt, and:

```bash
just alerts-eval             # fire something (or wait for the scheduler)
just alerts-deliver          # → a notification on that device
```

What to know:

- **Per device, per user.** A member with two browsers gets two
  notifications; an account with two members fans out to both. The
  `devices on file` count on `/alerts` is the signed-in user's own.
- **Email is the fallback**, not a second copy: a member with no device
  on file gets the alert by email (needs `RESEND_API_KEY` + `EMAIL_FROM`).
  That is the iPhone-user-who-never-installed case — iOS delivers Web Push
  only to an app added to the Home Screen (16.4+); the page says so.
- **Dead subscriptions clean themselves up.** A 404/410 from the push
  service deletes the row; the browser's `pushsubscriptionchange` event
  re-registers a rotated one.
- **Keep the key pair.** The public key is inside every subscription, so
  rotating it means every device must enable notifications again.
- **Discord is ops-only now** (`DISCORD_WEBHOOK_URL` feeds the job
  watchdog); user alerts never go there.

`/api/push/config` reports whether push is configured; `/api/push/subscribe`
(POST / DELETE) is what the button calls. Both need a session and the paid
plan, like the alerts page.

## Ops — the job watchdog (PR-AB)

An uptime monitor on `/health` says whether the process answers. It says
nothing about whether the 15:45 brief actually ran. The watchdog does.

Every scheduled job is wrapped so each run lands in `job_runs` (start,
finish, `ok` / `skipped` / `failed`, a one-line note). Every 15 minutes
the `job_watchdog` job asks each job's **own cron trigger** for its
recent due times and compares them with that table, so a job added to
`build_scheduler` is covered automatically. It reports three kinds of
problem:

- **missed** — the due time passed and no run was recorded. Typically a
  restart across the cron minute: APScheduler's in-memory store forgets
  a fire time the moment the process dies.
- **failed** — the job raised. Daily and weekly jobs are reported on the
  first failure; jobs that fire at least hourly get three strikes so a
  single scraper timeout is not an alert.
- **stuck** — a run started and never finished (hung, or the process was
  killed mid-run — the next pass closes such runs as `interrupted`).

Each problem is announced once when it appears, once a day while it
lasts (`OPS_ALERT_REPEAT_HOURS`), and once when it clears. Channels:

```bash
OPS_ALERT_EMAIL=you@example.ci      # through the sign-in mailer (Resend)
DISCORD_WEBHOOK_URL=https://...     # the alerts webhook doubles as ops
```

With neither set, the alert is an `ERROR` line in the journal. Look at
the state from the shell any time — both are read-only and safe beside
the running service:

```bash
just jobs-status    # every job: next due, last run, status, duration, note
just jobs-check     # what the watchdog would flag right now; exit 1 if anything
```

`/health` carries the summary for the external monitor:

```json
"jobs": {"status": "ok", "open": [], "checked_utc": "2026-09-10T15:45:12Z"}
```

`status` is `degraded` while a problem is open, `stale` when the
watchdog's own heartbeat is older than 45 minutes (the scheduler thread
died while uvicorn kept answering), and `unknown` when the DB cannot be
read. Point a keyword monitor at it (see the deploy runbook). Only
problem keys are exposed — the endpoint is public and failure notes can
contain exception text.

Daily and weekly jobs also get a 30-minute misfire grace, so a job whose
cron minute fell while the executor was busy runs late instead of
tomorrow.

## Deploy

The production runbook — a 4 GB Vultr VPS behind Cloudflare, Caddy with an
origin certificate, systemd, the `.env` diff, the owner claim, smoke
tests, day-2 operations — is
[`docs/deploy-kodji-app.md`](./docs/deploy-kodji-app.md). It is written
to be followed top to bottom.

## Bond issues (primary market)

Bonds otherwise enter only through brvm.org's price tables, which list a
bond once it is admitted to trading, weeks after the subscription window
has closed. Nothing public lists an issue *during* its window in a
machine-readable way; the brokerage (SGI) email does. `/ops/offerings`
(operator only, 404 to anyone else) is where those get typed in: name,
issuer, arranger, coupon and its period, amount, term and any deferral,
placement type, nominal, the subscription window, issue date as a date
or as worded, and the note d'information as a PDF.

Everyone sees them on `/offerings`, and open or upcoming ones appear on
the overview. Free, since it is public information and the rule is to
gate only what Kodji computes. A semi-annual or quarterly coupon also
shows its effective annual rate, so a 6.80% semi-annual reads against an
annual one honestly.

**By email.** Forward an SGI announcement to `emissions@kodji.app` (or
register that address with the SGI). A job polls the mailbox every
fifteen minutes over IMAP, drops mail from senders not on
`EMISSIONS_SENDER_ALLOWLIST`, dedupes on Message-ID, and asks Haiku, under
the same daily spend cap as the news tagger, whether the mail announces a
new issue and what its terms are, against a strict JSON schema of exactly
the form's fields. The result is a **draft**: never public, shown at the
top of `/ops/offerings` with the extracted terms, the source email, the
PDF if one was attached, and every field that still fails validation. You
get a notification on the ops channel with a link. Publishing re-runs the
strict validation, so a draft with an unreadable coupon cannot go live by
accident. An LLM's reading of a rate never reaches a public page unreviewed.

```bash
just emissions-poll     # read the mailbox now
```

If the model is unavailable or the day's cap is spent, the message is
left unread and the next pass retries it.

### Structured-output schemas

Every LLM call that must return a fixed shape (news tagging, fundamentals
extraction, bond-issue emails) sends a JSON schema through the API's
structured-output feature. The API validates the schema before the model
sees anything, and its limits are not documented. Measured 2026-09-22:
an object with more than twelve **optional** properties is refused as
"Schema is too complex" (size, nesting, types and enums do not matter),
and an `enum` on a nullable type union is refused outright. So every
schema in the app makes every key required, uses `["<type>", "null"]`
for unknowns, and keeps enums single-typed with an explicit `"unknown"`.
`tests/test_llm_schemas.py` pins that shape offline; the real check is

```bash
just llm-schemas-check   # sends each schema to the API, ~1 000 tokens
```

Run it after touching a schema or when the extractors start failing on
every document. A refused request (HTTP 400) now aborts the fundamentals
pass without stamping the filing and sends an ops alert, and defers the
email with an alert — before this, the extractor stamped each filing as
done on failure and ran for three weeks without a financials row and
without a word.

## Corporate actions from the BRVM notice feed

Until 2026-09-22 `corporate_actions` held dividends only, from the
sikafinance calendar. The exchange's own avis feed announces everything
else and the daily suspensions sync already walked it, so the same fetch
now feeds a second reader (`services/corporate_actions.py`). One row per
notice, keyed on (ticker, kind, notice date), so the daily re-read is a
no-op:

| notice wording | kind | side effect |
|---|---|---|
| `RADIATION DE LA COTE` | `delisting` | `securities.active = 0`, badge on the page, out of directory/movers/search; ops alert |
| `Division du nominal par N` | `split` | ratio in `amount`; chart divides pre-split bars at read time; ops alert (asks for the ratio if the title has none) |
| `Remboursement anticipé` | `early_redemption` | recorded on the bond; ops alert |
| `Augmentation de capital`, `Droit préférentiel de souscription` | `rights` | recorded (a *radiated* DPS is a rights row, not a delisting) |
| `Offre Publique d'Echange` | `exchange_offer` | recorded when a security resolves |
| `<ISSUER> : … dividende …` | `dividend` | recorded; the exchange-wide *calendrier* is skipped (sikafinance covers it) |

Bonds resolve by the ticker in the notice (`TPCI.O36`, sukuk `SUKTG.S1`);
equities by issuer name, through the suspensions resolver. Alerts go out
only for notices from the last 30 days, so a backfill stays quiet:

```bash
just suspensions-sync            # daily: front page
just suspensions-sync --pages 40 # backfill ~3 years
```

**Split adjustment** is applied when the chart is read, never to stored
bars, and only when the data shows the cliff: if the last close before
the split over the first close after is nearer the ratio than 1, the
pre-split bars are divided (volumes multiplied). Sikafinance may restate
history after a split; once the weekly backfill has pulled the restated
series the jump is ~1 and nothing is touched, so a series is never
adjusted twice. The security tab lists the last year and the next 90 days.

## Trading suspensions

A halted security keeps being scraped: sikafinance goes on showing the
last trade, so every hourly snapshot re-records it as if fresh, and
nothing on the row says otherwise. SCRC sat in the daily losers table for
five days on a move from 16 September and re-fired a price-move alert
every trading day.

The BRVM publishes `<ISSUER> : Suspension de la cotation` and `<ISSUER> :
Reprise de la cotation` on its official notice feed. A daily job before
the open reads the front page and sets or clears
`securities.suspended_utc`. While set, the security is left out of
gainers, losers and turnover leaders, the price-move evaluator skips it,
and its page shows a **SUSPENDED** badge with the notice date and a link
to the PDF instead of a live change. Snapshots keep being written
unchanged, so lifting a suspension is one column update.

```bash
just suspensions-sync            # front page of the feed
just suspensions-sync --pages 5  # backfill further back
```

Issuer names on a notice carry no ticker and are not quite the sikafinance
names (`SUCRIVOIRE S.A` against `SUCRIVOIRE`), so resolution reuses the
filings service's fuzzy matcher. An issuer it cannot place is reported in
the job note rather than guessed.

## Free preview

`FREE_PREVIEW_UNTIL=2026-10-31` opens the whole paid feature set to every
**signed-in** account through that day, without touching anyone's
subscription. Blank turns it off, and it expires on its own.

A date rather than a switch, deliberately: a promotion nobody remembers
to end becomes the price. Anonymous visitors are excluded on purpose —
the payoff of a preview is a list of people who can be told when it ends,
and sign-in is one magic link.

Gating reads `accounts.effective_plan()`; billing and the pricing page
keep reading `plan_for()`, which is the truth about what was bought. That
separation is what stops the preview from telling someone they are
subscribed. A banner names the end date, with a different message for
readers who have not signed in yet.

## Early-adopter discount

Accounts created on or before `EARLY_ADOPTER_CUTOFF` pay
`EARLY_PRICE_MONTH_XOF` / `EARLY_PRICE_YEAR_XOF` for their **first** paid
period: 11 000 and 100 000 XOF against list prices of 12 000 and 120 000.

Absolute prices rather than percentages, because those figures are 8.33%
and 16.67% off and no integer percentage expresses them — and a round
number is what a customer here reads. A value of 0, or one at or above
the list price, disables the discount rather than raising the charge.

First period, not for ever. The list prices sit deliberately above the
local competition, so a permanent cut would undo that positioning; one
period is an acquisition cost instead.

The cutoff is its own date rather than a reuse of `FREE_PREVIEW_UNTIL`,
so turning the preview off cannot silently revoke a discount people were
already promised. Eligibility also requires never having completed a
payment, and a *failed* payment does not burn it.

`billing.quote()` is the single source for both the number displayed and
the number charged, so the two cannot drift. The pricing headline stays
the list price; the discount appears as a note and on the buttons, which
carry the amount that will actually be billed.

## Analytics

Pageviews are counted **first-party and server-side**, into the same
SQLite file. No third-party script, no analytics cookie, and no IP
address or user agent is ever stored.

```bash
just stats              # last 14 days
just stats --days 30
```

A visitor is counted via `sha256(salt + ip + user_agent + day)`, where the
salt is random, belongs to one UTC day, and is overwritten when the day
rolls. Once it is gone that day's hashes cannot be re-derived from an IP
nor matched against another day — they become opaque per-day counters.
Read the numbers accordingly: **a visitor is counted once per day, so two
days' visitor counts cannot be added together.**

Only successful full-page HTML GETs count. Fragments, `/health`, static
assets and the API are excluded, and the counter swallows its own errors
so it can never fail a page render. Rows are pruned after
`ANALYTICS_RETAIN_DAYS` (180) by a daily job.

**Crawlers are judged twice.** A name match (`_BOTS`) is a crawler we can
identify and is never recorded. Anything else is judged by how it asks:
a browser doing a top-level navigation always sends `Accept-Language` and
an `Accept` that names HTML, and almost nothing automated does both.
Requests failing that are recorded but flagged, kept out of the headline
figures, and reported as the number excluded — so an unnamed crawler
shows up as a discrepancy instead of quietly inflating the numbers. They
are flagged rather than dropped on purpose: the user agent is not stored,
so a dropped request could never be reviewed and a wrong rule would be
undetectable.

`robots.txt` keeps crawlers off the endpoints and per-account pages.
Quote, news and company pages stay indexable — they are what anyone
searching "cours SNTS BRVM" needs to find.

There is also an owner-only page at **`/ops/stats`**, deliberately
unlinked, showing the same figures with a per-day bar and a window
switcher. Anyone who is not signed in on the operator's account gets a
404, not a 403 — an operations page should not confirm its own existence.

Plausible and Umami were the alternatives. Hosted Plausible sends visitor
data offsite and costs money; self-hosting it wants ClickHouse, and Umami
wants Node plus Postgres — none of which fits a 4 GB box with a < 500 MB
RSS budget. See `src/kodji/services/analytics.py`.

## Security headers and disclosure

Response headers are set at the origin in `deploy/Caddyfile.example`
rather than with the Cloudflare toggle, so one file in git says what the
site promises: HSTS, `nosniff`, `Referrer-Policy`, `frame-ancestors
'none'` + `X-Frame-Options`, a minimal `Permissions-Policy`, and the
`Server` header removed. Both the apex and the `www` redirect carry
them — a browser that only ever touches `www` must still learn the HSTS
policy.

**HSTS is set to 30 days, deliberately.** A browser caches the policy
for the whole `max-age`, so a mistake is not a rollback, it is a wait.
Raise it to `31536000` once every subdomain is known to work over HTTPS.
`includeSubDomains` and `preload` are both absent on purpose: preload is
close to irreversible, and this zone has mail subdomains that were never
audited for it.

`Content-Security-Policy` currently carries `frame-ancestors` only. A
real `script-src` needs nonces first — the chart pages load Lightweight
Charts from a CDN and several templates use inline `<script>` — and
shipping `'unsafe-inline'` to be able to say the header exists would buy
nothing.

`/.well-known/security.txt` (RFC 9116) publishes where to report a
problem, with `/security.txt` redirecting to it. `Expires` is a literal
date, and `tests/test_security_txt.py` fails 30 days before it lapses —
a lapsed security.txt reads as an abandoned channel, and nothing in
production would ever notice the date passing.

## Backup retention

The deploy runbook takes a `.backup` of the database before every
migration — that copy is the rollback, because migrations are
forward-only. Nothing used to delete them, so they accumulated one per
deploy (11 files, 167 MB, on the box by 2026-09-22).

```bash
just backups-prune-dry     # list every backup and what would go
just backups-prune         # delete them
```

`backups_prune_daily` runs at 03:45 Africa/Abidjan, in the same quiet
window as the other housekeeping jobs.

A backup is deleted only when it is **both** outside the newest
`BACKUP_KEEP` (3) **and** older than `BACKUP_RETAIN_DAYS` (14). Either
condition alone is not enough, which gives two properties worth having:
ten deploys in one day never prune that day's own rollbacks, and a box
that hasn't deployed in a year still keeps three.

Scope is deliberately narrow. It matches the full stamped filename
(`pre-migrate-YYYY-MM-DD-HHMM.sqlite`) rather than globbing `*.sqlite`,
because `data/` also holds the live database, its WAL and shm sidecars,
the Litestream replica directory and the filings corpus — a loose sweep
beside a live database is how a backup job becomes an outage. The
timestamp is read from the **name**, not `mtime`, so a backup that has
been rsynced or restored from R2 is still ranked by when it was taken.

The nightly `.backup` copies in `backups/` are **not** touched: the cron
line that writes them already prunes them with `-mtime +14`, and two
owners for one set of files is worse than none.

## Weekly LinkedIn post

Once a week the operator publishes a post summarising what the terminal
saw and what shipped. Everything that makes such a post worth reading is
already in this database, so the server writes the draft and a human
approves it.

```bash
just social-run-dry                  # gather-only: what the week holds
just social-run                      # real call, draft + delivery
just social-run --week 2026-09-07    # an earlier week
just dev                             # then /ops/social
```

`social_weekly` runs **Friday 07:00 Africa/Abidjan** — before the open,
so it doesn't compete with the market-hours jobs, and early enough that
the draft is waiting at the start of the day. It covers Monday to Friday
of the current ISO week; Friday's own session lands in next week's post,
because the alternative (drafting after Friday's close) means publishing
on a Saturday.

What the writer reads:

- the week's **daily briefs**, verbatim — the point of the post is to
  summarise what they already highlighted
- **weekly index moves** and the **three best and worst movers**,
  computed here in Python and handed over as finished numbers. The
  prompt forbids the model from computing, re-basing or rounding one.
- **what shipped**, read from `git log` on the deployed checkout. The
  repo uses conventional commits, so `feat:` / `fix:` / `perf:` since
  Monday is an accurate, free release feed; everything else is
  housekeeping and is dropped.

When nothing shipped, the writer highlights one evergreen feature from
`FEATURES` in `services/social.py` instead — skipping the ones the last
four posts already used, so a quiet week is still a post rather than a
gap.

**Nothing is published to LinkedIn.** The draft is delivered to the same
ops channel as the job watchdog (Discord and/or `OPS_ALERT_EMAIL`) and
reviewed at **`/ops/social`** — owner-only and unlinked, 404 to anyone
else, like `/ops/stats`. Copy the text, post it, then mark the week
posted. Auto-posting is phase 2: it needs an approved LinkedIn app and a
token that expires on the order of weeks, and neither is worth having
between the draft and a human on something going out under a real name.

Guarantees:

- **A posted week is never silently rewritten.** Once a row is marked
  `posted`, a re-run leaves it alone (`just social-run --force`
  overrides). Text already live on LinkedIn cannot be edited back into
  agreement with a regenerated draft.
- **Hard $0.50/day cap** in `social_spend`, its own counter. One pass
  costs about $0.08 at Opus rates; a call that returns something
  unusable is still billed against the cap, so a failure can't hide its
  cost.
- **Degrades quietly**: no API key, cap spent, or a week already posted
  all no-op with a log line rather than failing the scheduler.

Both price sources are read for the movers — `daily_bars` is the
corrected close but is filled by the weekly backfill, so on a Friday
morning it routinely stops before the week being written about;
`quote_snapshots` always has it. Bars win where both exist.

`SOCIAL_MODEL` defaults to `claude-opus-5`: one call a week for a post
going out under the operator's own name is the one place in this app
where quality beats cost. `claude-sonnet-5` is the cheaper swap; spend
accounting reads the price table either way. Note that Opus thinks
before it writes and those tokens count against
`SOCIAL_MAX_OUTPUT_TOKENS` — hence 8192, not 2048.

## Language

The interface is French first — the audience is majority francophone —
with English available. What a given request renders is resolved in this
order:

1. `?lang=fr` / `?lang=en` on the URL (an explicit, shareable override)
2. the `brvm_lang` cookie, set by the FR|EN toggle in the topbar
3. the browser's `Accept-Language`, quality values honoured
4. French

Step 3 is what a first-time visitor hits, since they have no cookie yet.
An anglophone browser gets English; everyone else gets French.

Source strings in the templates are English because they are the
catalogue keys (`src/kodji/i18n.py`). That is not the same as the
reader's default — a page shipping without French coverage is a bug, not
a backlog item.

## Brand and icons

The mark is the letter **K** in the site's accent `#ffb454` on its
background `#0b0f14`, drawn as vector paths — no font, so it renders the
same everywhere.

```bash
just logo        # needs `brew install librsvg`
```

One script, `scripts/logo.py`, writes both sets from the same geometry, so
the favicon and the Home Screen icon can never drift apart:

- `src/kodji/apps/web/static/icons/` — what the app serves: the PWA icons
  the manifest names, `favicon.ico` (16/32/48) served at the root, the
  apple-touch icon, and the monochrome notification badge.
- `brand/` — marketing exports, not served: SVG masters, 1024px PNGs, a
  1200×630 social card, and light/dark/white variants of the bare glyph.
  See [`brand/README.md`](./brand/README.md) for which file to use where.

Do not hand-edit the outputs. Change the colours or geometry at the top of
the script and rerun.

## Data sources

Phase 1 goes scraper-first — the "BRVM Market Data API" referenced in
`CLAUDE.md` was withdrawn in June 2026. Sources actually used:

- **sikafinance.com** — canonical A-to-Z listing, per-ticker cotation and
  historique, palmarès. French number formatting (space thousands, comma
  decimal).
- **afx.kwayisi.org/brvm** — cross-check + last-10-day OHLCV per ticker.
- **brvm.org** — daily Bulletin Officiel de la Cote PDF, sector quotes.

The `BRVM_API_*` env vars are still recognised so a future paid feed
(EODHD, ICE) can be dropped in behind `services/providers.py` without
touching the service layer.

The news intelligence layer calls the Anthropic API with
`claude-haiku-4-5-20251001` (override with `ANTHROPIC_MODEL`). It is the
only outbound non-scraping call the app makes.

## Layout

```
src/kodji/
  sources/    # fetchers + pure parsers, one module per source
  store/      # thin SQLite repositories (WAL mode)
  services/   # business logic; only layer the UI touches
  jobs/       # APScheduler tasks (market-hours aware, Africa/Abidjan)
  apps/web/   # FastAPI + Jinja2 (dark terminal aesthetic)
```

## Testing

All tests run offline: scrapers against committed HTML/PDF fixtures in
`tests/fixtures/`, and the tagging pipeline against a fake Anthropic
client (`tests/_fake_anthropic.py`) — `just test` never spends a cent or
touches the network. Refresh the fixtures (dev-only, hits the network)
with:

```bash
just refresh-fixtures
```
