Skip to content

bd73-com/hugin

Repository files navigation

Hugin — OBX Research Dashboard

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.

What Hugin is — and is not

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 localhost only; scripts/run_dashboard.sh refuses 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/, and logs/ are gitignored.

How it works

The daily cycle:

  1. Ingest prices (hugin.ingest.prices) — pull OHLCV from yfinance for every active ticker in config/universe.yaml and write to the prices table. Supports a throttled concurrent historical backfill over an explicit date range.
  2. Ingest news (hugin.ingest.news) — fetch RSS feeds (Oslo Børs, E24, DN, Finansavisen), deduplicate by content hash, store in news_items.
  3. Classify news (hugin.llm.classify) — Claude Haiku tags each article with tickers, category, sentiment, materiality, and horizon; results land in news_classifications.
  4. Compute features (hugin.features.compute) — deterministic Python computes SMAs, RSI, ATR, 52-week range, volume spike, gap %, and sector-relative strength.
  5. Rank (hugin.ranking) — combine features and classified news into the unsigned attention score (see weights below). Full component breakdown is persisted in attention_scores.component_scores_json for audit.
  6. Directional score (hugin.directional) — pure-Python signed [-1.0, +1.0] view of the same inputs, weighted per config/directional_weights.yaml and damped by earnings proximity. Persisted to directional_scores with a JSON component breakdown.
  7. Per-ticker stance (hugin.llm.stance) — Claude Opus emits a qualitative bullish / bearish / neutral stance plus reasoning, key drivers, and key risks for each candidate. Numeric outputs are forbidden by prompt and rejected by a regex post-validator. Persisted to stance_outputs.
  8. Advice (hugin.advice) — deterministic rule tree that combines the directional score, LLM stance, and config/positions.yaml to emit one of buy_candidate / watch / hold / reduce / exit / avoid per ticker, with a byte-reproducible rationale. Thresholds live in config/advice_thresholds.yaml. Persisted to advice.
  9. 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).
  10. Narrate the brief (hugin.brief) — Claude Opus produces the morning brief using the ranked list and relevant classified news.
  11. 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.

Architecture at a glance

             ┌────────────┐   ┌────────────┐   ┌───────────────┐
 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.)    │
                                └───────────────────────┘

Tech stack

  • 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

Repository layout

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

Database schema

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

Scoring engines

Hugin runs two pure-Python scoring engines side-by-side. Both persist a full per-component breakdown so any row is fully auditable.

Unsigned attention score (hugin.ranking)

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

Signed directional score (hugin.directional)

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.

LLM usage and cost guardrails

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_settings row stores configurable monthly / daily caps and an llm_paused kill switch. Every Opus path (hugin.llm.stance, the brief, summarisation triggered from Operations) takes a wall-clock spend reservation through hugin.app.budget.check_can_spend so 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.stance rejects any response whose reasoning or key_drivers / key_risks items 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 in pipeline_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.

Getting started

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)

Configuration

  • Edit config/universe.yaml to change the tracked ticker universe (OBX primary and secondary watchlist, 46 tickers total).
  • Edit config/weights.yaml to re-weight attention-score components. Weights should still sum to 1.0 after edits.
  • Edit config/positions.yaml to 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 a direction: long | short field (defaulting to long for backward-compat). See config/positions.example.yaml for 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 .env already makes for API keys). Downstream advisory logic reads this file to branch on whether the operator is flat, long, or short a ticker.

Running the pipelines

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

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

Scheduling

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 (subject Hugin 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 with include_weekly=True (weekly brief variant covers the past 7 days and stores with brief_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.

Trade-journal CLI

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 new trade_journal row.
  • 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 sets outcome = win | loss | breakeven based 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.

Running the dashboard

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

scripts/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.md inline 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_stance flag 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.

Development workflow

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

Run uv run pytest before opening a pull request against main.

Environment variables

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.

Safety rails

Hugin will never:

  • place, modify, or cancel broker orders;
  • expose the Streamlit dashboard outside localhost;
  • commit .env, data/, or logs/ to git;
  • let the LLM compute numeric trading signals.

These rules live in .claude/CLAUDE.md and are enforced by code, tests, and review.

Status and license

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.

About

Decision-support tooling for discretionary trading of Oslo Børs (OBX) equities

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages