Skip to main content

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 for the full project charter.

Status

See docs/phases.md for the running log.

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

Requirements

  • macOS or Linux
  • uv (dependency manager)
  • 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

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:

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:

# .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:

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:

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:

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.

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}.

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).
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:

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.

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).

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 REPLACEs 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.

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.

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 REPLACEs — 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.

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.

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.

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:

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). Two settings turn it on:

# .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.

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:

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:

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:

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.

# .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
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):

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)

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

Then, as a subscriber would:

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)

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)

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)

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)

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)

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)

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)

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:

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:

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:

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:

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:

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:

"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. 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.

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

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:

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.

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.

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).

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.

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.

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 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:

just refresh-fixtures

Release files for kodji-terminal 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Built distribution (wheel)

Table of built distributions (wheels) for kodji-terminal 0.1.0
File Interpreter ABI Platform
kodji_terminal-0.1.0-py3-none-any.whl Python 3 none any Details

Release files / kodji_terminal-0.1.0-py3-none-any.whl

Download URL kodji_terminal-0.1.0-py3-none-any.whl
Size 279.3 kB
Tags Python 3
SHA-256 checksum
How to use checksums
1d1cda55299b4dbe0181165dc13ddf50c5e682f87e6df052464dd00ce93d2e1e
BLAKE2b-256 checksum
How to use checksums
adeaa302be4925c78d0c68e03bca6175eeddcdaedb85e280804387af4414b10c
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.12.5 {"installer":{"name":"uv","version":"0.12.5","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Release history Release notifications | RSS feed

This release

0.1.0 This release

1 release file

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page