Decision-support tooling for discretionary trading of Oslo Børs (OBX) equities. Hugin ingests prices, news, and earnings material for a curated Norwegian universe, classifies and summarises them with Claude, ranks tickers by a transparent daily attention score, and renders the result as a local Streamlit dashboard and a morning brief.
Hugin is named after one of Odin's two ravens — Huginn ("thought") — who flies out each day to gather information from the world and returns to whisper it in Odin's ear. That is exactly the job of this project.
Is: a research aid that surfaces a ranked daily watchlist with human-readable explanations, so a human trader can decide what to look at and why.
Is not: a trading bot. Hugin never places, modifies, or cancels broker orders. All order execution is manual via Nordnet. There is no broker API integration, and there never will be.
Core invariants enforced throughout the codebase:
- Order execution is always manual. No broker API writes. Ever.
- The dashboard binds to
localhostonly;scripts/run_dashboard.shrefuses to start on any non-loopback address. - The LLM does classification, extraction, and narrative summarisation. Python does all arithmetic — prices, returns, stops, position sizes, and ranking are never computed by the model.
- Every LLM response is validated against a Pydantic schema before use.
- External text (news headlines, earnings extracts) is wrapped in
<untrusted_content>tags in every prompt to defend against prompt injection. .env,data/, andlogs/are gitignored.
The daily cycle:
- Ingest prices (
hugin.ingest.prices) — pull OHLCV from yfinance for every active ticker inconfig/universe.yamland write to thepricestable. Supports a throttled concurrent historical backfill over an explicit date range. - Ingest news (
hugin.ingest.news) — fetch RSS feeds (Oslo Børs, E24, DN, Finansavisen), deduplicate by content hash, store innews_items. - Classify news (
hugin.llm.classify) — Claude Haiku tags each article with tickers, category, sentiment, materiality, and horizon; results land innews_classifications. - Compute features (
hugin.features.compute) — deterministic Python computes SMAs, RSI, ATR, 52-week range, volume spike, gap %, and sector-relative strength. - Rank (
hugin.ranking) — combine features and classified news into the unsigned attention score (see weights below). Full component breakdown is persisted inattention_scores.component_scores_jsonfor audit. - Directional score (
hugin.directional) — pure-Python signed[-1.0, +1.0]view of the same inputs, weighted perconfig/directional_weights.yamland damped by earnings proximity. Persisted todirectional_scoreswith a JSON component breakdown. - Per-ticker stance (
hugin.llm.stance) — Claude Opus emits a qualitativebullish/bearish/neutralstance plus reasoning, key drivers, and key risks for each candidate. Numeric outputs are forbidden by prompt and rejected by a regex post-validator. Persisted tostance_outputs. - Advice (
hugin.advice) — deterministic rule tree that combines the directional score, LLM stance, andconfig/positions.yamlto emit one ofbuy_candidate / watch / hold / reduce / exit / avoidper ticker, with a byte-reproducible rationale. Thresholds live inconfig/advice_thresholds.yaml. Persisted toadvice. - Summarise earnings (
hugin.llm.summarizer) — Claude Opus reads Oslo Børs earnings PDFs and emits structured summaries (key drivers, management tone, watch items, red flags). - Narrate the brief (
hugin.brief) — Claude Opus produces the morning brief using the ranked list and relevant classified news. - Review — open the Streamlit dashboard to read the Advice page, drill into individual tickers, and log manual trades in the journal.
The advice engine is gated by a point-in-time backtest harness
(hugin.backtest) that replays a date range against the historical
inputs available on each day, records forward returns and max drawdown
into backtest_outcomes, and emits hit-rate, calibration, and
confidence-conditioned diagnostics so the advice can be evaluated
before being trusted.
┌────────────┐ ┌────────────┐ ┌───────────────┐
Sources → │ yfinance │ │ RSS feeds │ │ Oslo Børs PDFs│
└─────┬──────┘ └──────┬─────┘ └───────┬───────┘
│ │ │
┌─────▼─────────────────▼─────────────────▼──────┐
Ingest → │ hugin.ingest.{prices,news} + summarizer │
└────────────────────────┬───────────────────────┘
│
▼
┌──────────────────────────┐
Storage → │ DuckDB (data/*.duckdb) │
│ prices / news_items / │
│ classifications / ... │
└─────────────┬────────────┘
│
┌──────────────────┼──────────────────┐
▼ ▼ ▼
┌──────────────┐ ┌────────────┐ ┌──────────────┐
Compute → │ features. │ │ llm. │ │ ranking + │
│ compute │ │ classify │ │ directional │
└──────┬───────┘ └──────┬─────┘ └──────┬───────┘
│ │ │
└─────────┬─────────┴────────┬────────┘
▼ ▼
┌───────────┐ ┌──────────────┐
Decide → │ llm. │ + │ advice │
│ stance │ │ (rule tree) │
│ (Opus) │ └──────┬───────┘
└───────────┘ │
┌─────────┴────────┐
▼ ▼
┌───────────┐ ┌────────────────────┐
Outputs → │ brief.py │ │ dashboard/app.py │
│ (Opus) │ │ (Streamlit, local) │
└───────────┘ └────────────────────┘
│
▼
┌───────────────────────┐
Gate → │ backtest (replay, │
│ hit-rate, calib.) │
└───────────────────────┘
- Python 3.12, managed exclusively with
uv - DuckDB — single local database at
data/hugin.duckdb - Pydantic v2 — all data models and LLM output validation
- httpx + tenacity — async HTTP with retries (never
requests) - feedparser — RSS ingestion
- yfinance — OHLCV price data
- Anthropic SDK — Claude Haiku and Opus
- pypdf + BeautifulSoup — earnings PDF and HTML parsing
- Streamlit — local dashboard (localhost only)
- ruff, mypy, pytest / pytest-asyncio — lint, types, tests
hugin/
├── .claude/ Claude Code config, skills, agents, CLAUDE.md
├── config/
│ ├── universe.yaml OBX primary + secondary watchlist
│ ├── weights.yaml unsigned attention-score weights
│ ├── directional_weights.yaml signed directional-score weights
│ ├── advice_thresholds.yaml rule-tree thresholds for advice engine
│ ├── positions.yaml current long positions (system of record)
│ ├── positions.example.yaml template for the above
│ └── sizing_rules.md per-position risk reminder, rendered inline
├── hugin/ source code (Python package)
│ ├── db.py DuckDB connection + versioned schema migrations
│ ├── ingest/ prices.py, news.py
│ ├── features/ compute.py (technical indicators)
│ ├── llm/ client.py, classify.py, summarizer.py, stance.py
│ ├── ranking/ engine.py + __main__.py CLI (unsigned attention)
│ ├── directional.py signed [-1, +1] directional-score engine
│ ├── advice.py deterministic rule-tree advice engine (Stage 5)
│ ├── backtest/ replay.py, report.py, __main__.py CLI (Stage 7)
│ ├── positions.py load + validate config/positions.yaml
│ ├── pipeline/ runs.py (freshness + run logging)
│ ├── scheduler/ in-process APScheduler wrapper + filelock
│ ├── cronlock.py cross-process single-instance filelock
│ ├── scoring_common.py shared clamp / normalise / weight loaders
│ ├── app/ dashboard support: actions, budget, cost meter, …
│ ├── brief.py morning brief narration
│ ├── journal/ entry.py (trade journal validation + P&L)
│ ├── health.py freshness + ingest counts for the Health page
│ └── outputs/ delivery helpers (email, Telegram)
├── dashboard/
│ └── app.py Streamlit UI (8 pages — see "Running the dashboard")
├── scripts/
│ ├── run_dashboard.sh loopback-only dashboard launcher
│ ├── hugin.sh / .ps1 double-click launcher (Linux/macOS, Windows)
│ ├── install-shortcut.ps1 Start Menu / desktop shortcut installer
│ └── run_morning.py / run_close.py / run_weekly.py cron entry points
├── tests/ pytest suite (asyncio auto-mode)
├── data/ gitignored — DuckDB file lives here
├── logs/ gitignored — structured JSON logs
├── .env.example template for secrets
├── pyproject.toml dependencies + tool config
└── uv.lock lockfile
Defined and migrated in hugin/db.py. All writes are idempotent
(INSERT OR IGNORE / ON CONFLICT DO NOTHING).
| Table | Purpose |
|---|---|
tickers |
Ticker metadata: name, sector, watchlist group, active flag |
prices |
Daily OHLCV + adjusted close from yfinance |
news_items |
Raw RSS articles, deduplicated by content hash |
news_classifications |
LLM tags: tickers, category, sentiment, materiality, horizon |
earnings_events |
Forecasted and confirmed earnings release dates |
earnings_summaries |
Structured post-earnings summaries from Opus |
features |
Technical indicators per ticker/date |
feature_runs |
Per-day feature-compute provenance for the Health page |
attention_scores |
Unsigned ranked scores with per-component breakdown for audit |
directional_scores |
Signed [-1, +1] scores with per-component JSON breakdown |
stance_outputs |
Per-ticker LLM stance (bullish/bearish/neutral, drivers, risks) |
advice |
Deterministic action per ticker + byte-reproducible rationale |
backtest_runs |
Header row per replay (date range, model, harness version) |
backtest_outcomes |
Per-ticker per-day forward returns + max drawdown vs. advice |
briefs |
Generated morning + weekly brief narratives |
trade_journal |
Manually logged trades (thesis, size, entry, planned exit, P&L) |
pipeline_runs |
Run metadata for freshness tracking and the Health page |
app_settings |
Dashboard-owned spend caps and the llm_paused kill switch |
Hugin runs two pure-Python scoring engines side-by-side. Both persist a full per-component breakdown so any row is fully auditable.
config/weights.yaml, sum 1.0. Captures "what deserves a look today"
without taking a directional view. Output range [0.0, 1.0].
| Component | Weight |
|---|---|
momentum_20d_zscore |
0.15 |
distance_from_52w_high |
0.10 |
volume_spike |
0.15 |
gap_from_prior_close |
0.10 |
news_sentiment_24h |
0.15 |
material_news_flag |
0.15 |
days_to_earnings_inverse |
0.10 |
sector_relative_strength |
0.10 |
config/directional_weights.yaml, sum 1.0 across the additive
components. Output range [-1.0, +1.0]. The earnings_proximity_damp
multiplier scales the post-weighted sum down when a report is
imminent — it is not an additive component. Missing or NaN raw values
contribute 0.0 to their component (absence never masquerades as a
signal). Deliberately not called "probability": there is no calibration
evidence yet — that is what the Stage 7 backtest exists to produce.
| Component | Weight |
|---|---|
news_sentiment |
0.20 |
material_news_flag |
0.15 |
momentum_20d_z |
0.20 |
dist_52w_high |
0.10 |
volume_confirmation |
0.15 |
sector_rel_strength |
0.20 |
earnings_proximity_damp (post-multiplier) |
— |
Re-weight either engine by editing the corresponding YAML and re-running
the module. Thresholds for the rule-tree advice engine sit in
config/advice_thresholds.yaml and are loaded + Pydantic-validated at
import time.
Model routing (see hugin/llm/client.py):
| Job | Model | Why |
|---|---|---|
| News classification | claude-haiku-4-5-20251001 |
Bulk, low cost |
| Earnings summarisation | claude-opus-4-7 |
Careful reading |
| Per-ticker stance | claude-opus-4-7 |
Qualitative judgment |
| Morning brief narration | claude-opus-4-7 |
Coherent prose |
Every call is logged to logs/llm_costs.jsonl:
{"ts": "...", "model": "...", "job": "...",
"input_tokens": N, "output_tokens": N, "usd": N}Guardrails:
- Hard monthly Anthropic spend cap: $100 (set in the Anthropic console).
- Use Haiku for any batch of more than 5 news items.
- Use Opus only for earnings summaries, per-ticker stance, and the morning brief.
- Warn and skip if projected session spend exceeds $10.
- The dashboard-owned
app_settingsrow stores configurable monthly / daily caps and anllm_pausedkill switch. Every Opus path (hugin.llm.stance, the brief, summarisation triggered from Operations) takes a wall-clock spend reservation throughhugin.app.budget.check_can_spendso two concurrent runs cannot both pass the gate against the same residual budget. - Every per-ticker stance prompt forbids numeric outputs (price levels,
percentage returns, target prices, stops). A regex post-validator in
hugin.llm.stancerejects any response whose reasoning orkey_drivers/key_risksitems contain a digit adjacent to a Norwegian currency / percent marker (NOK,kr,kroner,øre,%,prosent,bps, …) — a model-drift backstop, surfaced as a validation failure inpipeline_runs.detail.
Prompt-injection defence: all externally sourced text (headlines, article
bodies, PDF extracts) is wrapped in <untrusted_content> tags before being
passed to the model, and every response is parsed through a Pydantic
schema before it is trusted.
Prerequisites:
- Python 3.12
uv(never pip or poetry)- An Anthropic API key
Install dependencies and set up the environment:
uv sync
cp .env.example .env
# edit .env and fill in ANTHROPIC_API_KEY (and optionally Telegram/SMTP)- Edit
config/universe.yamlto change the tracked ticker universe (OBX primary and secondary watchlist, 46 tickers total). - Edit
config/weights.yamlto re-weight attention-score components. Weights should still sum to 1.0 after edits. - Edit
config/positions.yamlto record current holdings. This file is the system of record for what you currently have on (long or short) and must be updated whenever a trade is executed in Nordnet — Hugin never touches broker APIs. The Trade Journal dashboard form writes this file atomically when you open or close a trade; you can also edit it by hand. Each entry takes adirection: long | shortfield (defaulting tolongfor backward-compat). Seeconfig/positions.example.yamlfor the shape. The file is committed to the repository so position history is preserved under git; the repo is therefore private-by-assumption (this is the same assumption.envalready makes for API keys). Downstream advisory logic reads this file to branch on whether the operator is flat, long, or short a ticker.
All pipelines are Python modules and are run via uv. Typical flags:
--date YYYY-MM-DD to target a specific trading day, --backfill to
populate history.
# Prices — rolling refresh (period window)
uv run python -m hugin.ingest.prices --backfill
# Prices — explicit historical range (throttled concurrent fetch)
uv run python -m hugin.ingest.prices --fetch --start-date 2024-01-01 --end-date 2024-12-31
# News (RSS fetch + dedup)
uv run python -m hugin.ingest.news --fetch
# Classify any unclassified news items (Haiku)
uv run python -m hugin.llm.classify
# Technical features
uv run python -m hugin.features.compute --date 2026-04-21
# Unsigned attention-score ranking
uv run python -m hugin.ranking --date 2026-04-21
# Signed directional score
uv run python -m hugin.directional --date 2026-04-24
# Per-ticker stance (Opus). --all-positions iterates the holdings file.
uv run python -m hugin.llm.stance --ticker EQNR.OL
uv run python -m hugin.llm.stance --all-positions
# Backtest the advice engine against a historical date range
uv run python -m hugin.backtest --from 2025-10-01 --to 2026-04-01
# Earnings summary for one quarter (Opus)
uv run python -m hugin.llm.summarizer --ticker EQNR.OL --quarter 2026Q1
# Morning brief (Opus)
uv run python -m hugin.brief --morning --date 2026-04-21
# Weekly brief variant (7-day news window; Sunday cron)
uv run python -m hugin.brief --weekly --date 2026-04-19The advice rule tree itself has no standalone CLI — it runs as a step
inside hugin.pipeline.cron.run_morning_pipeline, exercised in
practice via scripts/run_morning.py. Iterate on its inputs (prices,
features, ranking, directional, stance) with the CLIs above; trigger
the engine itself either from the cron script or from the dashboard's
Operations page.
hugin.cronlock.single_instance_or_exit wraps the three cron entry
scripts (scripts/run_morning.py, scripts/run_close.py,
scripts/run_weekly.py) and python -m hugin.llm.stance so a manual
run during a scheduled run exits cleanly (exit code 0) rather than
racing the scheduler for the same DuckDB writer lock. The standalone
CLIs for hugin.brief, hugin.llm.classify, and
hugin.llm.summarizer are not currently lock-wrapped — invoke them
manually only when you know no scheduled job is in flight.
Three cron entry points in scripts/ orchestrate the daily sequence
and log per-step start/end/status rows to the pipeline_runs table
(pipeline keys run_morning:<step> / run_close:<step> /
run_weekly:<step>). Each script is idempotent against same-day
reruns — the brief skips its Anthropic call if a row already exists.
scripts/run_morning.py— weekdays 06:30 Oslo. Prices, news, classify, features, ranking, morning brief, then a health-check email via SMTP (subjectHugin health — <date> ✅/⚠️).scripts/run_close.py— weekdays 16:45 Oslo. Same ingest + compute steps as the morning without the brief (saves LLM spend — the brief only runs once per day).scripts/run_weekly.py— Sunday 18:00 Oslo. Runs the pipeline withinclude_weekly=True(weekly brief variant covers the past 7 days and stores withbrief_type='weekly'). Sunday skips the morning brief step so the inbox receives one brief, not two.
Example crontab:
CRON_TZ=Europe/Oslo
30 6 * * 1-5 cd /path/to/hugin && uv run python scripts/run_morning.py
45 16 * * 1-5 cd /path/to/hugin && uv run python scripts/run_close.py
0 18 * * 0 cd /path/to/hugin && uv run python scripts/run_weekly.py
The CRON_TZ=Europe/Oslo header is load-bearing: the hour/minute
fields are interpreted in that zone, so 06:30 fires at 06:30 Oslo
regardless of the host clock. Without it, a UTC server fires at 08:30
Oslo (summer) or 07:30 (winter) — the zoneinfo("Europe/Oslo") calls
inside the scripts only set run_date, they do not steer cron. Each
script additionally logs a loud WARNING at startup if the Oslo wall
clock is more than 30 minutes off the expected hour, so a missing
CRON_TZ after a server rebuild is caught at the first run. If your
cron implementation does not support CRON_TZ, set TZ on the user
crontab or run the host on Europe/Oslo.
Discretionary trade tracking lives in hugin.journal. Three
subcommands, with validation (including the 3-sentence thesis cap and
strict European-number parsing) shared with the dashboard's Trade
Journal page:
uv run python -m hugin.journal add— interactive prompts for date / ticker / direction / thesis /size_nok/entry_price/planned_exit. Writes a newtrade_journalrow.uv run python -m hugin.journal close TICKER— prompts for exit price and a free-form note; computes P&L automatically from(direction, entry_price, exit_price, size_nok)and setsoutcome = win | loss | breakevenbased on the P&L sign.uv run python -m hugin.journal list— prints every open position (outcome IS NULL) as a table.
The parser rejects ambiguous numeric input like 50.000 and 50,000
(thousands vs decimal) and requires the operator to disambiguate with
space-grouping (50 000) or an explicit two-decimal fraction
(50.00) — silently mis-parsing a 50 kNOK size as 50 NOK would
corrupt the journal's P&L and win-rate stats.
# Terminal launcher (cross-platform, loopback-enforced)
bash scripts/run_dashboard.sh
# Double-click launcher — opens the browser automatically
bash scripts/hugin.sh # Linux / macOS
.\scripts\hugin.ps1 # Windows PowerShellscripts/hugin.sh / scripts/hugin.ps1 are designed for a desktop
shortcut: they start the dashboard bound to 127.0.0.1, wait for the
socket to bind, and then open the default browser. On Windows,
scripts/install-shortcut.ps1 creates a Start Menu / desktop shortcut
that points at the PowerShell launcher.
All launchers enforce loopback-only binding and will refuse to start if asked to listen on a non-localhost address. Every page renders two global headers: a month-to-date LLM cost meter so spend is always visible next to the trigger buttons, and a crash-recovery banner with re-run buttons when a previous pipeline step failed mid-flight. The dashboard has eight pages:
- Advice — landing page; per-position cards, buy candidates,
watchlist, and avoid/exit signals built from the Stage 5 advice
table. Renders
config/sizing_rules.mdinline as a per-position risk reminder. Every numeric field (P&L, days held, counts) is computed in Python. - Morning brief — today's narrative and ranked watchlist (was the landing page until Stage 6; still the source for email and Telegram delivery).
- Ticker deep-dive — price chart, features, recent news, earnings.
- Trade journal — log manual trades; P&L is computed in Python.
- Backtest — hit-rate by action, calibration by directional-score
decile, and conditional accuracy by LLM confidence, split on the
degraded_stanceflag so score-only fallback rows never pollute full-engine aggregates. - Operations — on-demand triggers for the morning / close / weekly
pipelines. Every click goes through
hugin.app.actions, which refuses to dispatch unless the server is bound to loopback, and pipelines execute under the scheduler's filelock so two processes cannot double-fire. - Settings — configurable spend caps (monthly / daily) and the "pause LLM" toggle that gate every Operations trigger.
- Health — freshness of ingestion jobs, recent pipeline runs, and the scheduler status panel.
Run all four before every commit. CI enforces the same checks.
uv run ruff check . # lint
uv run ruff format --check . # formatting (use `ruff format .` to fix)
uv run mypy # type-check (config targets hugin/ + tests/)
uv run pytest # testsRun uv run pytest before opening a pull request against main.
| Variable | Required | Purpose |
|---|---|---|
ANTHROPIC_API_KEY |
yes | Claude API access |
HUGIN_DB_PATH |
no | Override DuckDB path (default data/hugin.duckdb) |
TELEGRAM_BOT_TOKEN |
no | Telegram brief delivery |
TELEGRAM_CHAT_ID |
no | Telegram brief delivery |
SMTP_HOST |
no | Email brief delivery |
SMTP_PORT |
no | Email brief delivery (default 587) |
SMTP_USER |
no | Email brief delivery |
SMTP_PASS |
no | Email brief delivery |
SMTP_FROM |
no | Email brief delivery |
SMTP_TO |
no | Email brief delivery |
See .env.example for the canonical list.
Hugin will never:
- place, modify, or cancel broker orders;
- expose the Streamlit dashboard outside localhost;
- commit
.env,data/, orlogs/to git; - let the LLM compute numeric trading signals.
These rules live in .claude/CLAUDE.md and are enforced by code, tests,
and review.
Hugin is in active development.
The source code is published in this repository under the Hugin
Commercial License (see LICENSE). It is source-available,
not open source. Public visibility is for transparency, evaluation, and
security review only.
In particular, without a signed commercial agreement with the copyright holder, you may not:
- use, execute, or run the software for any purpose beyond a 30-day personal evaluation;
- use it in any production, commercial, internal-business, or revenue-generating context;
- copy, modify, redistribute, or create derivative works;
- offer it as a hosted or managed service; or
- use it to build a competing product.
Any contribution submitted to this repository is assigned to the copyright holder under the terms of the licence.
For commercial licensing enquiries, contact licensing@bd73.com.