From f52850ec72a9e37016bd7c083e1b45375aa2e041 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 20:17:52 +0000 Subject: [PATCH 1/2] Add fund-selection tooling for monthly Nordnet CSV exports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turns a series of downloaded Nordnet fund lists into a basis for choosing funds. Pure stdlib Python, self-contained under fond/ and outside the petrolib package and test path. The core problem the tooling addresses: ranking a single export by trailing return measures which market did well, not which fund is good. Morningstar's category column is too coarse to fix this on its own -- it files Norwegian, Swedish, Danish, Nordic and broad European index funds together under "Europe Equity Large Cap", a 25-percentage- point spread that is pure geography. So funds are grouped by an exposure key derived from the fund name (market, sector, size segment, currency hedging, factor/ESG tilt) and ranked only within a group. Name matching deliberately omits word boundaries for most tokens: Norwegian fund names are compounds ("KLP AksjeNorge Indeks", "AksjeUSA", "AksjeVerden") where a \b anchor never matches, and Amundi contracts to "EurSRIClmtPrsAlgd". The name takes priority over the category, which is only a fallback. Scoring within a group weights cost highest (0.45) -- it is the one field known in advance -- then return relative to the group median, then consistency across periods. Morningstar stars, the risk number (4 for 74 of 84 funds) and owner counts are carried in the panel but kept out of the score. Stacking exports also yields a NAV series per ISIN, which gives volatility, max drawdown and pairwise correlation -- the last of these identifies funds that are duplicates of each other. These switch on at four snapshots; below that the report says so and falls back to the export's own return columns. Classification by name is a heuristic, so funds deviating more than 3pp from their group are flagged and sorted last rather than topping the list, and overrides.csv pins exposure per ISIN. It ships with one real case: Alfred Berg Indeks R tracks Oslo Børs, but its name says nothing and Morningstar files it under Europe. Parsing handles Nordnet's actual format: UTF-16LE, tab-separated, decimal comma, and U+2212 MINUS SIGN rather than ASCII hyphen. 27 tests under fond/test_fond.py (stdlib unittest, also runnable under pytest); pyproject's testpaths keeps them out of the petrolib suite. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01L8cF2pbLZuncatMezhAV63 --- fond/README.md | 192 +++++++++++++ fond/analyze.py | 488 ++++++++++++++++++++++++++++++++++ fond/data/fond_2026-08-19.csv | Bin 0 -> 42288 bytes fond/data/snapshots.csv | 85 ++++++ fond/ingest.py | 97 +++++++ fond/nordnet.py | 422 +++++++++++++++++++++++++++++ fond/overrides.csv | 5 + fond/test_fond.py | 322 ++++++++++++++++++++++ 8 files changed, 1611 insertions(+) create mode 100644 fond/README.md create mode 100644 fond/analyze.py create mode 100644 fond/data/fond_2026-08-19.csv create mode 100644 fond/data/snapshots.csv create mode 100644 fond/ingest.py create mode 100644 fond/nordnet.py create mode 100644 fond/overrides.csv create mode 100644 fond/test_fond.py diff --git a/fond/README.md b/fond/README.md new file mode 100644 index 0000000..99afbc3 --- /dev/null +++ b/fond/README.md @@ -0,0 +1,192 @@ +# Fondsvalg fra månedlige Nordnet-eksporter + +Verktøy for å gjøre en serie nedlastede fondslister om til et grunnlag for +å velge fond. Ren Python 3.10+, ingen avhengigheter. + +``` +python3 fond/ingest.py ~/Nedlastinger/fond_*.csv # legg filene i panelet +python3 fond/analyze.py # rangering +python3 fond/analyze.py --endringer # hva endret seg? +python3 fond/analyze.py --overlapp # hvilke fond er duplikater? +python3 fond/test_fond.py # tester +``` + +--- + +## Hovedpoenget + +Én nedlastet fil er et øyeblikksbilde. Rangerer du hele fila etter «1 år %», +måler du hvilket **marked** som gikk best det siste året – ikke hvilket **fond** +som er best. Det er en helt annen beslutning enn den du prøver å ta. + +I fila fra 18.8.2026 topper hedgede Asia- og Norge-fond ettårslista. Det er +kronekurs og ett godt børsår, ikke forvaltning. Velger du derfra, kjøper du det +som nettopp har steget. + +Verktøyet gjør to ting med det: + +1. **Sammenligner bare fond som er sammenlignbare** – samme marked, samme + valutasikring, samme størrelsessegment, samme (fravær av) tilt. +2. **Bygger en NAV-historikk** av de månedlige filene, som lar deg regne ut + ting fila ikke inneholder: volatilitet, maksimalt fall, og hvilke fond som + i praksis er samme fond. + +--- + +## Hvorfor Morningstar-kategorien ikke holder + +Kategorikolonnen i fila legger disse fem i samme bøtte, «Europe Equity Large Cap»: + +| Fond | 1 år | +|---|---| +| Nordnet Norge Indeks | 29,6 % | +| Nordnet Sverige Index | 13,7 % | +| DNB Norden Indeks A | 11,8 % | +| Nordnet Europa Indeks | 6,7 % | +| Nordnet Danmark Indeks B | 4,8 % | + +Spennet på 25 prosentpoeng er ren geografi. Rangerer du innenfor kategorien, +vinner Norge-fondet – ikke fordi det er godt forvaltet, men fordi Oslo Børs +gikk bra. `nordnet.py` utleder derfor et eget marked fra fondsnavnet, og faller +tilbake på kategorien bare når navnet ikke sier noe. + +Det krever litt varsomhet med norske sammensatte ord: «KLP AksjeNorge Indeks» +har ingen ordgrense foran «Norge», så et vanlig `\bnorge`-søk bommer og fondet +havner i Europa-gruppa. Det samme gjelder «AksjeUSA», «AksjeVerden» og Amundis +«EurSRIClmtPrsAlgd». + +--- + +## Valutasikring er en egen gruppe + +Samme indeks, samme forvalter, ett års forskjell i resultat: + +| Fond | 1 år | 3 år ann. | +|---|---|---| +| KLP AksjeUSA Indeks **Valutasikret** N | 20,3 % | 21,0 % | +| KLP AksjeUSA Indeks N | 11,7 % | 17,7 % | + +De 8,6 prosentpoengene er kronekursen, ikke fondet. Sikret og usikret variant +rangeres derfor aldri mot hverandre – valget mellom dem er en beslutning om +valutarisiko, som du tar før du velger fond. + +Merk også at avkastningstallene for SEK-, EUR- og USD-fond er i fondets egen +valuta. En norsk sparer får ikke det tallet. + +--- + +## Hva som faktisk scores + +Innenfor hver gruppe, z-score mot gruppa: + +| Ledd | Vekt | Hvorfor | +|---|---|---| +| **Kostnad** | 0,45 | Det eneste feltet i fila som er kjent på forhånd og gjelder framover. | +| **Etterslep** | 0,35 | Avkastning minus gruppemedianen. To indeksfond på samme marked skal ligge likt; avviket er forvaltningskvalitet og kostnader som ikke står i prisfeltet. | +| **Konsistens** | 0,20 | Andel perioder (3m/1år/3år/5år) fondet slår medianen. Skiller vedvarende kvalitet fra ett heldig år. | + +Kostnad veier tyngst med vilje. Historisk avkastning er et svakt signal for +framtidig avkastning; kostnaden er det ikke – den påløper hvert år uansett hva +markedet gjør. + +**Felter som bevisst ikke inngår i scoren:** + +- *Rangering* (Morningstar-stjerner) – bakoverskuende, og mangler for 22 av 84 fond. +- *Risiko* – 74 av 84 fond har verdien 4. Ingen informasjon. +- *Antall eiere* – popularitet, ikke kvalitet. +- *Bærekraft* – artikkel 6/8/9 er en juridisk klassifisering, ikke en kvalitetsrangering. Den hører hjemme i valget av *gruppe*, ikke i rangeringen innenfor den. + +De ligger fortsatt i panelet og i `--csv`-uttrekket, så du kan bruke dem som +filter om du vil. + +--- + +## Feilklassifisering flagges + +Navnebasert klassifisering bommer noen ganger. Rapporten flagger derfor fond +som avviker mer enn 3 prosentpoeng fra gruppa si, og sorterer dem sist i stedet +for å la dem toppe lista: + +``` +Mulig feilklassifisering -- avviket mot gruppa er for stort til å være +forvaltningskvalitet: + -3,0 pp mot «Europa» Nordnet Europa Indeks (IE00BMTD2N07) +``` + +To fond som følger samme marked skal ikke ligge tre prosentpoeng fra hverandre. +Enten følger fondet en annen indeks enn du tror, eller så er det plassert feil. + +Er det plassert feil, retter du det i `overrides.csv`: + +```csv +isin,marked,sektor,eksponering,tilt,smallcap,valutasikret,notat +NO0010700891,Norge,,,,,,"Alfred Berg Indeks R følger Oslo Børs" +``` + +Den linjen ligger der allerede, og er et ekte tilfelle: fondet følger Oslo Børs, +men navnet sier ikke «Norge» og Morningstar kaller det «Europe Equity Large Cap». +Uten overstyringen så det ut til å slå Europa-gruppa med seks prosentpoeng. + +--- + +## Det de månedlige filene gir deg som én fil ikke gjør + +`Siste`-kolonnen er andelskursen. Stabler du filene, får du en NAV-serie per +ISIN – og NAV for norske og nordiske verdipapirfond er totalavkastning, så +prisendringen *er* avkastningen. Fra fire snapshots og oppover regner +`analyze.py` ut: + +- **Volatilitet** og **maksimalt fall** – målt på dine egne data, likt for alle fond og over samme vindu. Fila har ingen risikotall utover et 1–7-nummer som er 4 for nesten alle. +- **Overlapp** (`--overlapp`) – fondspar som beveger seg tilnærmet likt. Korrelasjon over 0,98 betyr samme eksponering, og da er det billigste av dem det åpenbare valget. Dette er det praktisk viktigste: det fanger at du eier tre fond som er samme fond. +- **Endringer** (`--endringer`) – nye fond, fond som forsvinner, og prisendringer. Prisendringer varsler ingen deg om. + +At fond forsvinner fra lista er verdt å merke seg for seg selv. Sammenligner du +bare dagens liste med dagens liste, ser du aldri de som ble lagt ned – og de +gikk sjelden ned fordi de gjorde det bra. Panelet husker dem. + +**Kadensen bestemmer hva du kan måle.** 12 månedlige snapshots gir brukbar +volatilitet og korrelasjon. Årlig nedlasting gir det ikke. Vil du ha +korrelasjonene raskt, last ned ukentlig det første halvåret. + +--- + +## Foreslått arbeidsflyt + +**Hver måned** – samme dag i måneden, `ingest.py`, så `--endringer`. Tar et +minutt. Ikke handle på det. + +**Hvert kvartal** – `analyze.py`, se på gruppene du faktisk eier eller vurderer. +Bytt bare hvis noe er varig: fondet har blitt dyrere, eller ligger under +gruppemedianen over flere perioder, ikke bare siste. + +**Hvert år** – `--overlapp` for å luke ut duplikater, og en gjennomgang av +fordelingen mellom grupper. Fordelingen mellom Global / USA / Norge / Fremvoksende +avgjør mesteparten av resultatet ditt; hvilket globalt indeksfond du valgte +avgjør nesten ingenting. + +--- + +## Hva verktøyet ikke gjør + +Det velger ikke **hvilke** markeder du skal eie, og det er den beslutningen som +faktisk betyr noe. Rangeringen svarer bare på: «gitt at jeg vil eie globale +aksjer, hvilket av disse tolv fondene?» Der er forskjellene små – som de skal +være – og da er billigst med lavt etterslep riktig svar. + +Det korrigerer heller ikke for valuta, og det sier ingenting om framtidig +avkastning. Ingenting i denne fila gjør det. + +Dette er verktøystøtte for egne beslutninger, ikke investeringsrådgivning. + +--- + +## Filer + +| Fil | | +|---|---| +| `nordnet.py` | Parsing (UTF-16, desimalkomma, U+2212-minus) og klassifisering | +| `ingest.py` | Legger eksporter inn i `data/snapshots.csv`, idempotent | +| `analyze.py` | Rangering, overlapp, endringer | +| `overrides.csv` | Manuell omklassifisering per ISIN | +| `test_fond.py` | 27 tester, stdlib `unittest` | +| `data/snapshots.csv` | Panelet – én rad per (snapshot-dato, ISIN) | diff --git a/fond/analyze.py b/fond/analyze.py new file mode 100644 index 0000000..8fb7674 --- /dev/null +++ b/fond/analyze.py @@ -0,0 +1,488 @@ +#!/usr/bin/env python3 +"""Rangér fondene i panelet innenfor sammenlignbare eksponeringsgrupper. + + python3 fond/analyze.py # rapport til skjerm + python3 fond/analyze.py --csv ut.csv # samme rangering som CSV + python3 fond/analyze.py --endringer # hva som endret seg siste måned + +Premisset: å rangere alle fondene mot hverandre måler hvilket *marked* +som gikk best, ikke hvilket *fond* som er best. Sammenligningen må skje +mellom fond som følger samme marked, med samme valutasikring, samme +størrelsessegment og samme (fravær av) tilt. Innenfor en slik gruppe er +forskjellene små og reelle, og da er kostnad og etterslep mot gruppa de +signalene som faktisk bærer informasjon. +""" + +from __future__ import annotations + +import argparse +import csv +import math +import statistics as st +import sys +from collections import defaultdict +from datetime import date +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +DEFAULT_PANEL = Path(__file__).resolve().parent / "data" / "snapshots.csv" + +# Antall snapshots før NAV-avledede mål (volatilitet, korrelasjon, +# tracking) er verdt å regne på. Under dette faller vi tilbake på +# Nordnets egne avkastningskolonner. +MIN_SNAPSHOTS_FOR_NAV = 4 + +# Vekter i totalscoren. Kostnad veier tyngst fordi den er det eneste +# feltet i fila som er kjent på forhånd og gjelder framover; historisk +# avkastning er et svakt signal og vektes deretter. +WEIGHTS = { + "kostnad": 0.45, # lav totalpris + "etterslep": 0.35, # avkastning mot gruppemedianen (fanger skjulte kostnader) + "konsistens": 0.20, # slår gruppa i flere perioder, ikke bare én +} + +# To indeksfond på samme marked skal ligge tett. Et avvik større enn +# dette mot gruppemedianen betyr nesten alltid at fondet er feilplassert, +# ikke at forvalteren er dyktig -- rapporten flagger det som mistenkelig +# i stedet for å belønne det. +MISKLASSIFISERING_PP = 3.0 + +# Perioder brukt til etterslep/konsistens, korteste først. Korte +# perioder er støy; lange er mest informative, men mangler for unge fond. +PERIODER = [ + ("avk_3m_pct", "3m", 0.10), + ("avk_1y_pct", "1år", 0.25), + ("avk_3y_ann_pct", "3år", 0.35), + ("avk_5y_ann_pct", "5år", 0.30), +] + + +# --------------------------------------------------------------------------- +# Innlesing +# --------------------------------------------------------------------------- + + +def load_panel(path: Path) -> list[dict]: + if not path.exists(): + raise SystemExit(f"finner ikke {path} -- kjør fond/ingest.py først") + with path.open(encoding="utf-8", newline="") as fh: + rows = list(csv.DictReader(fh)) + if not rows: + raise SystemExit(f"{path} er tom") + for r in rows: + for k in list(r): + if k.endswith(("_pct", "_ann_pct")) or k in {"nav", "antall_eiere", "rangering", "risiko", "min_kjoep", "belaaningsgrad_pct"}: + r[k] = _f(r[k]) + for k in ("valutasikret", "smallcap", "tilt", "giret"): + r[k] = str(r.get(k, "")).lower() == "true" + return rows + + +def _f(v) -> float | None: + if v is None or v == "": + return None + try: + return float(v) + except (TypeError, ValueError): + return None + + +# --------------------------------------------------------------------------- +# NAV-panel: det de månedlige filene gir deg som én fil ikke kan +# --------------------------------------------------------------------------- + + +def nav_series(rows: list[dict]) -> dict[str, list[tuple[date, float]]]: + """ISIN -> [(dato, NAV)] sortert kronologisk. + + NAV for norske og nordiske verdipapirfond er totalavkastning + (utbytte reinvestert), så prisendringen *er* avkastningen. + """ + series: dict[str, dict[date, float]] = defaultdict(dict) + for r in rows: + nav = r.get("nav") + if nav is None or nav <= 0: + continue + stamp = r.get("nav_dato") or r["snapshot_dato"] + try: + d = date.fromisoformat(str(stamp)) + except ValueError: + continue + series[r["isin"]][d] = nav + return {isin: sorted(pts.items()) for isin, pts in series.items()} + + +def returns_from_nav(series: list[tuple[date, float]]) -> list[tuple[date, float]]: + """[(dato, NAV)] -> [(dato, periodeavkastning)] som desimaltall.""" + out = [] + for (_, prev), (d, cur) in zip(series, series[1:]): + if prev > 0: + out.append((d, cur / prev - 1.0)) + return out + + +def annualised_vol(rets: list[float], perioder_pr_aar: float) -> float | None: + if len(rets) < 3: + return None + return st.stdev(rets) * math.sqrt(perioder_pr_aar) * 100.0 + + +def max_drawdown(series: list[tuple[date, float]]) -> float | None: + """Største fall fra topp til bunn i panelet, i prosent.""" + if len(series) < 2: + return None + peak, worst = series[0][1], 0.0 + for _, nav in series: + peak = max(peak, nav) + worst = min(worst, nav / peak - 1.0) + return worst * 100.0 + + +def correlation(a: list[float], b: list[float]) -> float | None: + if len(a) != len(b) or len(a) < 3: + return None + try: + return st.correlation(a, b) + except st.StatisticsError: + return None + + +def nav_metrics(rows: list[dict]) -> dict[str, dict]: + """ISIN -> NAV-avledede mål. Tomt før panelet er langt nok.""" + series = nav_series(rows) + stamps = sorted({r["snapshot_dato"] for r in rows}) + if len(stamps) < MIN_SNAPSHOTS_FOR_NAV: + return {} + + # Snapshots per år, målt på faktisk kadens i stedet for å anta + # månedlig -- laster du ned annenhver uke skal vol skaleres deretter. + d0, d1 = date.fromisoformat(stamps[0]), date.fromisoformat(stamps[-1]) + spenn = max((d1 - d0).days, 1) + pr_aar = 365.25 / (spenn / max(len(stamps) - 1, 1)) + + out: dict[str, dict] = {} + for isin, pts in series.items(): + if len(pts) < 2: + continue + rets = returns_from_nav(pts) + verdier = [r for _, r in rets] + aar = max((pts[-1][0] - pts[0][0]).days, 1) / 365.25 + total = pts[-1][1] / pts[0][1] - 1.0 + ann = ((1.0 + total) ** (1.0 / aar) - 1.0) * 100.0 if aar > 0 else None + vol = annualised_vol(verdier, pr_aar) + out[isin] = { + "nav_n": len(pts), + "nav_ann_pct": ann, + "nav_vol_pct": vol, + "nav_mdd_pct": max_drawdown(pts), + "nav_avk_vol": (ann / vol) if (ann is not None and vol) else None, + "_rets": dict(rets), + } + return out + + +# --------------------------------------------------------------------------- +# Rangering innenfor eksponeringsgruppe +# --------------------------------------------------------------------------- + + +def latest_snapshot(rows: list[dict]) -> tuple[str, list[dict]]: + stamp = max(r["snapshot_dato"] for r in rows) + return stamp, [r for r in rows if r["snapshot_dato"] == stamp] + + +def _z(value: float, verdier: list[float]) -> float: + """Z-score, med 0 når gruppa er for liten eller helt flat.""" + if value is None or len(verdier) < 2: + return 0.0 + spread = st.pstdev(verdier) + if spread == 0: + return 0.0 + return (value - st.mean(verdier)) / spread + + +def score_group(gruppe: list[dict], navm: dict[str, dict]) -> list[dict]: + """Gi hvert fond i gruppa en score og en begrunnelse.""" + kostnader = [r["kostnad_pct"] for r in gruppe if r["kostnad_pct"] is not None] + + # Etterslep: fondets avkastning minus gruppemedianen, per periode. + # To indeksfond på samme marked skal ligge likt; avviket er + # forvaltningskvalitet og kostnader som ikke står i prisfeltet. + medianer: dict[str, float] = {} + for felt, _, _ in PERIODER: + verdier = [r[felt] for r in gruppe if r.get(felt) is not None] + if len(verdier) >= 3: + medianer[felt] = st.median(verdier) + + resultater = [] + for r in gruppe: + diffs: list[tuple[str, float, float]] = [] + for felt, navn, vekt in PERIODER: + if felt in medianer and r.get(felt) is not None: + diffs.append((navn, r[felt] - medianer[felt], vekt)) + + vektsum = sum(v for _, _, v in diffs) + etterslep = sum(d * v for _, d, v in diffs) / vektsum if vektsum else None + # Konsistens: andel målte perioder der fondet slår medianen. + konsistens = (sum(1 for _, d, _ in diffs if d > 0) / len(diffs)) if diffs else None + + resultater.append({ + **r, + "etterslep_pp": etterslep, + "konsistens": konsistens, + "perioder_maalt": len(diffs), + **{k: v for k, v in navm.get(r["isin"], {}).items() if not k.startswith("_")}, + }) + + # Z-score innenfor gruppa. Kostnad snus (lavere er bedre). + e_verdier = [x["etterslep_pp"] for x in resultater if x["etterslep_pp"] is not None] + k_verdier = [x["konsistens"] for x in resultater if x["konsistens"] is not None] + for x in resultater: + z_kost = -_z(x["kostnad_pct"], kostnader) if x["kostnad_pct"] is not None else 0.0 + z_ett = _z(x["etterslep_pp"], e_verdier) if x["etterslep_pp"] is not None else 0.0 + z_kon = _z(x["konsistens"], k_verdier) if x["konsistens"] is not None else 0.0 + score = ( + WEIGHTS["kostnad"] * z_kost + + WEIGHTS["etterslep"] * z_ett + + WEIGHTS["konsistens"] * z_kon + ) + # Fond uten målt historikk får ikke score på historikk-leddene. + # Straff dem lett i stedet for å la 0.0 se ut som "gjennomsnittlig". + if x["perioder_maalt"] == 0: + score -= 0.25 + x["merknad"] = "for kort historikk" + elif abs(x["etterslep_pp"] or 0) >= MISKLASSIFISERING_PP and len(resultater) >= 3: + # Et fond vi sannsynligvis ikke kan sammenligne skal ikke + # rangeres som om vi kunne: det sorteres sist med sin advarsel, + # i stedet for å toppe gruppa på det som trolig er en + # klassifiseringsfeil. + x["mistenkt"] = True + x["merknad"] = "avvik mot gruppa – sjekk om fondet hører hjemme her" + x.setdefault("mistenkt", False) + x["z_kostnad"] = z_kost + x["z_etterslep"] = z_ett + x["z_konsistens"] = z_kon + x["score"] = score + + resultater.sort(key=lambda x: (x["mistenkt"], -x["score"])) + for i, x in enumerate(resultater, 1): + x["plass"] = i + return resultater + + +def rank(rows: list[dict], min_gruppe: int = 2) -> tuple[str, list[list[dict]]]: + """Rangér siste snapshot, gruppe for gruppe.""" + stamp, siste = latest_snapshot(rows) + navm = nav_metrics(rows) + + grupper: dict[str, list[dict]] = defaultdict(list) + for r in siste: + grupper[r["eksponering"]].append(r) + + ut = [score_group(g, navm) for g in grupper.values() if len(g) >= min_gruppe] + enslige = [score_group(g, navm) for g in grupper.values() if len(g) < min_gruppe] + # Store grupper først -- der er valget reelt. + ut.sort(key=lambda g: (-len(g), g[0]["eksponering"])) + ut.extend(sorted(enslige, key=lambda g: g[0]["eksponering"])) + return stamp, ut + + +# --------------------------------------------------------------------------- +# Overlapp +# --------------------------------------------------------------------------- + + +def overlap_pairs(rows: list[dict], terskel: float = 0.98) -> list[tuple[str, str, float]]: + """Fondspar som beveger seg tilnærmet likt -- kandidater for kutt. + + Krever NAV-panel. To fond med korrelasjon over terskelen gir samme + eksponering, og da er det billigste av dem det åpenbare valget. + """ + navm = nav_metrics(rows) + if not navm: + return [] + _, siste = latest_snapshot(rows) + navn = {r["isin"]: r["navn"] for r in siste} + kost = {r["isin"]: r["kostnad_pct"] for r in siste} + + par = [] + isins = [i for i in navm if i in navn] + for a_i, a in enumerate(isins): + for b in isins[a_i + 1:]: + ra, rb = navm[a]["_rets"], navm[b]["_rets"] + felles = sorted(set(ra) & set(rb)) + if len(felles) < 3: + continue + c = correlation([ra[d] for d in felles], [rb[d] for d in felles]) + if c is not None and c >= terskel: + billigst = a if (kost.get(a) or 9) <= (kost.get(b) or 9) else b + dyrest = b if billigst == a else a + par.append((navn[billigst], navn[dyrest], c)) + par.sort(key=lambda p: -p[2]) + return par + + +# --------------------------------------------------------------------------- +# Endringer mellom snapshots +# --------------------------------------------------------------------------- + + +def changes(rows: list[dict]) -> dict: + """Hva som endret seg mellom de to siste snapshotene.""" + stamps = sorted({r["snapshot_dato"] for r in rows}) + if len(stamps) < 2: + return {} + forrige, siste = stamps[-2], stamps[-1] + f = {r["isin"]: r for r in rows if r["snapshot_dato"] == forrige} + s = {r["isin"]: r for r in rows if r["snapshot_dato"] == siste} + + kostnadsendring = [] + for isin in set(f) & set(s): + a, b = f[isin].get("kostnad_pct"), s[isin].get("kostnad_pct") + if a is not None and b is not None and abs(b - a) >= 0.005: + kostnadsendring.append((s[isin]["navn"], a, b)) + return { + "fra": forrige, + "til": siste, + "nye": sorted(s[i]["navn"] for i in set(s) - set(f)), + "borte": sorted(f[i]["navn"] for i in set(f) - set(s)), + "kostnad": sorted(kostnadsendring, key=lambda x: x[2] - x[1]), + } + + +# --------------------------------------------------------------------------- +# Rapport +# --------------------------------------------------------------------------- + + +def _fmt(v, bredde=6, desimaler=2, tom=" – "): + if v is None: + return tom.rjust(bredde) + return f"{v:{bredde}.{desimaler}f}" + + +def suspects(grupper: list[list[dict]]) -> list[dict]: + """Fond som skiller seg for mye fra gruppa til at gruppa kan stemme.""" + return [ + x for g in grupper for x in g + if x.get("mistenkt") + ] + + +def report(stamp: str, grupper: list[list[dict]], rows: list[dict], topp: int = 0) -> None: + n_snapshots = len({r["snapshot_dato"] for r in rows}) + print(f"\nFondsrangering per {stamp} ({len(rows)} rader, {n_snapshots} snapshot(s))") + print("Rangert innenfor eksponeringsgruppe -- aldri på tvers.\n") + + har_nav = bool(nav_metrics(rows)) + for gruppe in grupper: + navn = gruppe[0]["eksponering"] + if len(gruppe) < 2: + r = gruppe[0] + print(f"── {navn} (bare {r['navn']}, ingen å sammenligne mot)") + continue + print(f"── {navn} (n={len(gruppe)})") + hodet = f" {'#':>2} {'score':>6} {'pris':>5} {'ettersl':>7} {'kons':>5} {'1år':>6} {'3år':>6}" + if har_nav: + hodet += f" {'vol':>6} {'MDD':>6}" + print(hodet + " fond") + vist = gruppe[:topp] if topp else gruppe + for x in vist: + kons = f"{x['konsistens']*100:4.0f}%" if x["konsistens"] is not None else " –" + linje = ( + f" {x['plass']:2} {x['score']:6.2f} {_fmt(x['kostnad_pct'], 5)} " + f"{_fmt(x['etterslep_pp'], 7)} {kons:>5} " + f"{_fmt(x.get('avk_1y_pct'))} {_fmt(x.get('avk_3y_ann_pct'))}" + ) + if har_nav: + linje += f" {_fmt(x.get('nav_vol_pct'))} {_fmt(x.get('nav_mdd_pct'))}" + merk = f" [{x['merknad']}]" if x.get("merknad") else "" + print(f"{linje} {x['navn'][:44]}{merk}") + if topp and len(gruppe) > topp: + print(f" … {len(gruppe) - topp} til") + print() + + mistenkte = suspects(grupper) + if mistenkte: + print("Mulig feilklassifisering -- avviket mot gruppa er for stort til å være") + print("forvaltningskvalitet. Sjekk hva fondet følger, og legg eventuelt en") + print("linje i fond/overrides.csv:") + for x in sorted(mistenkte, key=lambda x: -abs(x["etterslep_pp"])): + print(f" {x['etterslep_pp']:+6.1f} pp mot «{x['eksponering']}» {x['navn']} ({x['isin']})") + print() + + if not har_nav: + print( + f"NAV-baserte mål (volatilitet, maks fall, overlapp) krever " + f"{MIN_SNAPSHOTS_FOR_NAV} snapshots -- du har {n_snapshots}.\n" + "Til da hviler rangeringen på Nordnets egne avkastningstall, som er\n" + "de samme uansett hvor mange filer du har lastet ned." + ) + + +def write_csv(path: Path, grupper: list[list[dict]]) -> None: + felter = [ + "eksponering", "plass", "score", "navn", "isin", "valuta", "kostnad_pct", + "etterslep_pp", "konsistens", "perioder_maalt", "avk_1y_pct", + "avk_3y_ann_pct", "avk_5y_ann_pct", "nav_ann_pct", "nav_vol_pct", + "nav_mdd_pct", "nav_avk_vol", "rangering", "risiko", "antall_eiere", + "kategori", "forvalter", + ] + def rundet(rad: dict) -> dict: + # Fire desimaler holder; rå float-haler gjør CSV-en uleselig. + return { + k: (round(v, 4) if isinstance(v, float) else v) + for k, v in rad.items() if k in felter + } + + with path.open("w", encoding="utf-8", newline="") as fh: + w = csv.DictWriter(fh, fieldnames=felter, extrasaction="ignore") + w.writeheader() + for g in grupper: + w.writerows(rundet(x) for x in g) + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--panel", type=Path, default=DEFAULT_PANEL) + ap.add_argument("--csv", type=Path, help="skriv rangeringen til CSV") + ap.add_argument("--topp", type=int, default=0, help="vis bare N per gruppe (0 = alle)") + ap.add_argument("--endringer", action="store_true", help="vis endringer siden forrige snapshot") + ap.add_argument("--overlapp", action="store_true", help="vis fondspar som gir samme eksponering") + args = ap.parse_args(argv) + + rows = load_panel(args.panel) + stamp, grupper = rank(rows) + + if args.endringer: + ch = changes(rows) + if not ch: + print("Trenger minst 2 snapshots for å vise endringer.") + return 0 + print(f"\nEndringer {ch['fra']} → {ch['til']}") + print(f" nye i lista: {', '.join(ch['nye']) or 'ingen'}") + print(f" falt ut: {', '.join(ch['borte']) or 'ingen'}") + for navn, a, b in ch["kostnad"]: + print(f" pris {a:.2f} → {b:.2f} {navn}") + return 0 + + if args.overlapp: + par = overlap_pairs(rows) + if not par: + print(f"Overlappsjekk krever {MIN_SNAPSHOTS_FOR_NAV} snapshots med NAV-historikk.") + return 0 + print("\nFondspar med tilnærmet identisk bevegelse (behold den første):") + for billig, dyr, c in par: + print(f" r={c:.4f} {billig} ⟷ {dyr}") + return 0 + + report(stamp, grupper, rows, topp=args.topp) + if args.csv: + write_csv(args.csv, grupper) + print(f"Skrev {args.csv}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/fond/data/fond_2026-08-19.csv b/fond/data/fond_2026-08-19.csv new file mode 100644 index 0000000000000000000000000000000000000000..a69108b098abd4a3a14b5b90aafd66e92f53a61f GIT binary patch literal 42288 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 list[dict]: + if not path.exists(): + return [] + with path.open(encoding="utf-8", newline="") as fh: + return list(csv.DictReader(fh)) + + +def write_panel(path: Path, rows: list[dict]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + # Sorter kronologisk, så navn -- gjør diffene i git lesbare. + rows.sort(key=lambda r: (r["snapshot_dato"], r.get("navn", ""), r.get("isin", ""))) + tmp = path.with_suffix(".csv.tmp") + with tmp.open("w", encoding="utf-8", newline="") as fh: + writer = csv.DictWriter(fh, fieldnames=FIELDNAMES, extrasaction="ignore") + writer.writeheader() + writer.writerows(rows) + tmp.replace(path) + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("filer", nargs="+", type=Path, help="Nordnet-CSV-eksport(er)") + ap.add_argument("--panel", type=Path, default=DEFAULT_PANEL, help=f"panelfil (standard: {DEFAULT_PANEL})") + ap.add_argument("--overrides", type=Path, default=DEFAULT_OVERRIDES, help="manuell omklassifisering") + ap.add_argument("--tørrkjør", "--dry-run", dest="tørrkjør", action="store_true", help="vis hva som ville skjedd") + args = ap.parse_args(argv) + + overrides = load_overrides(args.overrides) + if overrides: + print(f" bruker {len(overrides)} overstyring(er) fra {args.overrides.name}") + + panel = load_panel(args.panel) + by_date: dict[str, list[dict]] = {} + for row in panel: + by_date.setdefault(row["snapshot_dato"], []).append(row) + before = set(by_date) + + for path in args.filer: + try: + snap = parse_file(path, overrides) + except (OSError, ValueError) as exc: + print(f" ! hopper over {path.name}: {exc}", file=sys.stderr) + continue + records = snapshot_records(snap) + stamp = snap.dato.isoformat() + verb = "erstatter" if stamp in by_date else "legger til" + by_date[stamp] = records + print(f" {verb} {stamp}: {len(records)} fond ({path.name})") + + rows = [r for stamp in sorted(by_date) for r in by_date[stamp]] + nye = sorted(set(by_date) - before) + + if args.tørrkjør: + print(f"\n[tørrkjør] panelet ville hatt {len(rows)} rader over {len(by_date)} snapshots") + return 0 + + write_panel(args.panel, rows) + print(f"\n{args.panel}: {len(rows)} rader, {len(by_date)} snapshots ({min(by_date)} .. {max(by_date)})") + if nye: + print(f"nye snapshot-datoer: {', '.join(nye)}") + if len(by_date) < 4: + mangler = 4 - len(by_date) + print( + f"\nMerk: NAV-baserte mål (volatilitet, korrelasjon, tracking) slås på " + f"ved 4 snapshots -- {mangler} måned(er) igjen." + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/fond/nordnet.py b/fond/nordnet.py new file mode 100644 index 0000000..548da20 --- /dev/null +++ b/fond/nordnet.py @@ -0,0 +1,422 @@ +"""Lesing og normalisering av Nordnets fondslister (CSV-eksport). + +Nordnet eksporterer fondslisten som UTF-16LE, tabulatorseparert, med +norsk tallformat: desimalkomma, tusenskille som mellomrom, og unicode +MINUS SIGN (U+2212) i stedet for bindestrek for negative tall. Modulen +oversetter dette til et flatt, maskinlesbart format ("snapshot rows") +der alle tall er float og alle datoer er ISO-8601. +""" + +from __future__ import annotations + +import csv +import io +import re +from collections import Counter +from dataclasses import asdict, dataclass, field +from datetime import date +from pathlib import Path + +# --------------------------------------------------------------------------- +# Lavnivå-parsing +# --------------------------------------------------------------------------- + +# Nordnet bruker U+2212 MINUS SIGN, ikke ASCII '-'. Tusenskille kan være +# vanlig mellomrom eller NBSP/NNBSP. +_MINUS = "−–—" +_SPACE = "   " + + +def parse_number(raw: str | None) -> float | None: + """'−1 234,56' -> -1234.56. Tom/ugyldig streng -> None.""" + if raw is None: + return None + s = raw.strip().strip('"') + for ch in _MINUS: + s = s.replace(ch, "-") + for ch in _SPACE: + s = s.replace(ch, "") + if not s: + return None + try: + return float(s.replace(",", ".")) + except ValueError: + return None + + +def parse_date(raw: str | None) -> date | None: + """'18.8.2026' -> date(2026, 8, 18). Tom/ugyldig streng -> None.""" + if raw is None: + return None + s = raw.strip().strip('"') + m = re.fullmatch(r"(\d{1,2})\.(\d{1,2})\.(\d{4})", s) + if not m: + return None + d, mo, y = (int(g) for g in m.groups()) + try: + return date(y, mo, d) + except ValueError: + return None + + +def read_text(path: Path) -> str: + """Les fila uansett om Nordnet ga oss UTF-16 eller UTF-8.""" + blob = path.read_bytes() + if blob[:2] in (b"\xff\xfe", b"\xfe\xff"): + return blob.decode("utf-16") + return blob.decode("utf-8-sig") + + +# --------------------------------------------------------------------------- +# Kolonner +# --------------------------------------------------------------------------- + +# Nordnet-overskrift -> feltnavn i snapshot-tabellen. Kolonnenavnene har +# variert litt mellom eksporter (norsk/engelsk blanding i periodefeltene), +# så vi slår opp normalisert og godtar flere varianter per felt. +_COLUMNS: dict[str, tuple[str, ...]] = { + "navn": ("navn", "name"), + "isin": ("isin",), + "valuta": ("valuta", "currency"), + "kategori": ("kategori", "category"), + "kostnad_pct": ("total pris %", "totalpris %", "total cost %", "årlig kostnad %"), + "rangering": ("rangering", "rating"), + "risiko": ("risiko", "risk"), + "baerekraft": ("bærekraft", "barekraft", "sustainability"), + "belaaningsgrad_pct": ("belåningsgrad %", "belaningsgrad %"), + "antall_eiere": ("antall eiere", "owners"), + "avk_1d_pct": ("1 d %", "1d %"), + "avk_1u_pct": ("1 uke %", "1 week %"), + "avk_1m_pct": ("1 måned %", "1 maned %", "1 month %"), + "avk_3m_pct": ("3 måneder %", "3 maneder %", "3 months %"), + "avk_iar_pct": ("i år %", "i ar %", "ytd %"), + "avk_1y_pct": ("1 år %", "1 ar %", "1 year %"), + "avk_3y_ann_pct": ("3 yrs. ann.", "3 år ann.", "3 years ann."), + "avk_5y_ann_pct": ("5 yrs. ann.", "5 år ann.", "5 years ann."), + "avk_10y_ann_pct": ("10 yrs. ann.", "10 år ann.", "10 years ann."), + "forvalter": ("ansvarlig forvalter", "manager"), + "nav": ("siste", "last"), + "nav_dato": ("tid", "time", "date"), + "startdato": ("startdato", "inception"), + "min_kjoep": ("min. kjøp", "min. kjop", "min. purchase"), + "handles": ("handles", "trading"), +} + +_NUMERIC = { + "kostnad_pct", "rangering", "risiko", "belaaningsgrad_pct", "antall_eiere", + "avk_1d_pct", "avk_1u_pct", "avk_1m_pct", "avk_3m_pct", "avk_iar_pct", + "avk_1y_pct", "avk_3y_ann_pct", "avk_5y_ann_pct", "avk_10y_ann_pct", + "nav", "min_kjoep", +} +_DATES = {"nav_dato", "startdato"} + +# Felter utledet av derive_exposure(), lagret i panelet slik at analysen +# ikke må gjette på nytt -- og slik at en git-diff viser når en +# omklassifisering endret seg. +DERIVED = [ + "aktivaklasse", "marked", "sektor", "valutasikret", + "smallcap", "tilt", "giret", "eksponering", +] + +# Rekkefølgen radene skrives ut i. 'snapshot_dato' først slik at panelet +# sorterer kronologisk uten ekstra nøkler. +FIELDNAMES = ["snapshot_dato", *_COLUMNS.keys(), *DERIVED, "kilde"] + + +def _header_index(header: list[str]) -> dict[str, int]: + """Map feltnavn -> kolonneindeks for denne fila.""" + seen = {h.strip().strip('"').lower(): i for i, h in enumerate(header)} + idx: dict[str, int] = {} + for field_name, aliases in _COLUMNS.items(): + for alias in aliases: + if alias in seen: + idx[field_name] = seen[alias] + break + missing = {"navn", "isin", "nav"} - idx.keys() + if missing: + raise ValueError(f"mangler påkrevde kolonner: {sorted(missing)}") + return idx + + +# --------------------------------------------------------------------------- +# Avledede felter: eksponering +# --------------------------------------------------------------------------- + +# Morningstar-kategorien er for grov til å sammenligne innenfor: både +# Norge-, Sverige-, Danmark-, Norden- og bred Europa-indeks havner i +# "Europe Equity Large Cap". Vi utleder derfor et eget marked fra navnet. +# Mer spesifikke mønstre først -- første treff vinner. +# Marked utledes fra navn + kategori. Merk fraværet av \b foran de fleste +# tokenene: norske fondsnavn er sammensatte ord ("KLP AksjeNorge Indeks", +# "AksjeUSA", "AksjeVerden"), så et ordgrense-anker ville aldri truffet og +# fondet hadde falt tilbake på kategorien -- som legger Norge, Sverige, +# Danmark og bred Europa i samme sekk. Korte/tvetydige tokener beholder +# ankeret. Mest spesifikke mønster først: første treff vinner. +_MARKETS: tuple[tuple[str, str], ...] = ( + (r"norge|norsk|norway", "Norge"), + (r"sverige|svensk|sweden", "Sverige"), + (r"danmark|denmark", "Danmark"), + (r"suomi|finland", "Finland"), + (r"norden|nordic", "Norden"), + (r"tyskland|germany|\bdax\b", "Tyskland"), + # "Asia ex-Japan" må sjekkes før "japan", ellers fanger Japan-regelen + # kategorien til fond som eksplisitt holder Japan utenfor. + (r"ex-?\s?japan", "Asia ex-Japan"), + (r"pacific|stillehav", "Stillehavet"), + (r"japan", "Japan"), + (r"kina|china", "Kina"), + (r"\bindia", "India"), + (r"fremvoksende|emerging|nye markeder|\bem\b", "Fremvoksende"), + (r"asia", "Asia"), + (r"usa|\bus\b|amerika|\bs&p|nasdaq", "USA"), + (r"europa|europe", "Europa"), + (r"global|verden|world|all countries|alle markeder|acwi", "Global"), +) + +# Sektor leses fra Morningstar-kategorien, ikke fra navnet: navnematching +# gjør "Handelsbanken"/"SpareBank 1" til finanssektor. Kategorien sier +# " Sector Equity" og er entydig. +_SECTOR_FROM_CATEGORY = re.compile(r"^(.*?)\s+Sector Equity$", re.IGNORECASE) +_SECTOR_NO = { + "technology": "Teknologi", + "real estate": "Eiendom", + "energy": "Energi", + "industrials": "Industri", + "healthcare": "Helse", + "financials": "Finans", + "consumer goods & services": "Forbruk", + "utilities": "Forsyning", +} + +# Aktivaklasse -- et rentefond skal aldri rangeres mot et aksjefond. +_ASSET_CLASSES: tuple[tuple[str, str], ...] = ( + (r"fixed income|bond|obligasjon|rente|money market", "Renter"), + (r"allocation|kombinasjon|balanced", "Kombinasjon"), + (r"equity|aksje", "Aksjer"), +) + +_HEDGED = r"valutasikret|valutasikr|hedged|currency hedge|sikret" +_SMALLCAP = r"småbolag|smabolag|small cap|smallcap|mid/small|småselskap" +# Fond med bærekrafts- eller faktortilt følger ikke den brede indeksen og +# skal ikke rangeres mot den. +# Uten ordgrenser av samme grunn som markedene: Amundi forkorter til +# "EurSRIClmtPrsAlgd" i ett ord. "plus" krever derimot at ordet ikke er +# først i navnet, ellers blir forvalteren PLUS Fonder feilmerket. +_TILTED = ( + r"sri|clmt|climate|paris|mer samfunnsansvar|samf\b|klima|sustainable|" + r"framtid|\besg\b|enhanced|flerfaktor|multifactor|\bfactor|" + r"(? bool: + return re.search(pattern, text, re.IGNORECASE) is not None + + +def derive_exposure(navn: str, kategori: str, valuta: str) -> dict[str, object]: + """Utled markeds-/stileksponering fra fondsnavn og kategori. + + Dette er nøkkelen sammenligningen bygger på: to fond er bare + sammenlignbare hvis de har samme aktivaklasse, følger samme marked, + samme størrelsessegment, samme valutasikring og har samme (fravær av) + tilt. + """ + text = f"{navn} {kategori}" + asset = next( + (label for pat, label in _ASSET_CLASSES if _flag(pat, kategori)), "Annet" + ) + + m = _SECTOR_FROM_CATEGORY.match(kategori.strip()) + if m: + key = m.group(1).strip().lower() + sector = _SECTOR_NO.get(key, m.group(1).strip()) + else: + sector = "" + + # Navnet slår kategorien: "KLP AksjeUSA Indeks N" ligger i Morningstars + # "US Equity Large Cap Blend", men "KLP AksjeVerden Indeks N" kan ligge + # i en kategori som nevner Europe. Fondets eget navn er alltid mer + # spesifikt enn bøtta Morningstar la det i. + market = next( + (label for pat, label in _MARKETS if _flag(pat, navn)), + next((label for pat, label in _MARKETS if _flag(pat, kategori)), "Ukjent"), + ) + hedged = _flag(_HEDGED, navn) + smallcap = _flag(_SMALLCAP, text) or "mid/small" in kategori.lower() + tilted = _flag(_TILTED, navn) + leveraged = _flag(_LEVERAGED, navn) + + # Eksponeringsnøkkelen er sammenligningsgruppa. Valutasikring holdes + # som eget ledd fordi den endrer NOK-avkastningen fullstendig -- et + # sikret og et usikret USA-indeksfond følger samme indeks, men gir + # helt ulikt resultat for en norsk sparer. + parts = [sector or market] + if asset != "Aksjer": + parts.insert(0, asset) + if smallcap: + parts.append("SmallCap") + if hedged: + parts.append("NOK-sikret") + if tilted: + parts.append("tilt") + if leveraged: + parts.append("giret") + return { + "aktivaklasse": asset, + "marked": market, + "sektor": sector, + "valutasikret": hedged, + "smallcap": smallcap, + "tilt": tilted, + "giret": leveraged, + "eksponering": " / ".join(parts), + } + + +# --------------------------------------------------------------------------- +# Snapshot +# --------------------------------------------------------------------------- + + +# --------------------------------------------------------------------------- +# Manuell overstyring +# --------------------------------------------------------------------------- + +# Navnebasert klassifisering treffer ikke alltid. "Alfred Berg Indeks R +# (NOK)" følger Oslo Børs, men navnet nevner ikke Norge og Morningstar +# kaller den "Europe Equity Large Cap" -- uten overstyring rangeres den +# mot brede Europa-fond og ser ut til å slå dem med seks prosentpoeng. +OVERRIDE_FIELDS = {"marked", "sektor", "eksponering", "tilt", "smallcap", "valutasikret"} + + +def load_overrides(path: Path) -> dict[str, dict[str, object]]: + """Les ISIN -> overstyrte felter fra overrides.csv. Mangler fila: {}.""" + path = Path(path) + if not path.exists(): + return {} + # Hopp over ledende '#'-kommentarer så fila kan dokumentere seg selv + # uten at kommentaren blir lest som overskriftsrad. + linjer = [ln for ln in path.read_text(encoding="utf-8").splitlines() if not ln.lstrip().startswith("#")] + out: dict[str, dict[str, object]] = {} + with io.StringIO("\n".join(linjer)) as fh: + for row in csv.DictReader(fh): + isin = (row.get("isin") or "").strip() + if not isin or isin.startswith("#"): + continue + felter = {} + for k, v in row.items(): + if k in OVERRIDE_FIELDS and (v or "").strip(): + felter[k] = v.strip().lower() == "true" if v.strip().lower() in ("true", "false") else v.strip() + if felter: + out[isin] = felter + return out + + +def apply_overrides(row: dict, overrides: dict[str, dict[str, object]]) -> dict: + """Legg overstyringer over en rad og bygg eksponeringsnøkkelen på nytt.""" + over = overrides.get(str(row.get("isin", ""))) + if not over: + return row + row.update(over) + if "eksponering" not in over: + # Bygg nøkkelen på nytt fra de (nå overstyrte) delfeltene. + parts = [row.get("sektor") or row.get("marked")] + if row.get("aktivaklasse") not in (None, "", "Aksjer"): + parts.insert(0, row["aktivaklasse"]) + for flagg, etikett in (("smallcap", "SmallCap"), ("valutasikret", "NOK-sikret"), + ("tilt", "tilt"), ("giret", "giret")): + if row.get(flagg): + parts.append(etikett) + row["eksponering"] = " / ".join(str(p) for p in parts if p) + return row + + +@dataclass +class Snapshot: + """Én Nordnet-eksport: dato + radene i den.""" + + dato: date + rader: list[dict] = field(default_factory=list) + kilde: str = "" + + +def parse_file(path: Path, overrides: dict[str, dict[str, object]] | None = None) -> Snapshot: + """Les én Nordnet-CSV til en Snapshot med normaliserte rader.""" + text = read_text(Path(path)) + reader = csv.reader(io.StringIO(text), delimiter="\t", quotechar='"') + try: + header = next(reader) + except StopIteration as exc: + raise ValueError(f"{path}: tom fil") from exc + idx = _header_index(header) + + rader: list[dict] = [] + for raw in reader: + if not any(cell.strip() for cell in raw): + continue + row: dict[str, object] = {} + for field_name, i in idx.items(): + value = raw[i].strip().strip('"') if i < len(raw) else "" + if field_name in _NUMERIC: + row[field_name] = parse_number(value) + elif field_name in _DATES: + d = parse_date(value) + row[field_name] = d.isoformat() if d else "" + else: + row[field_name] = value + for field_name in _COLUMNS: + row.setdefault(field_name, None if field_name in _NUMERIC else "") + row.update(derive_exposure(str(row["navn"]), str(row["kategori"]), str(row["valuta"]))) + if overrides: + apply_overrides(row, overrides) + rader.append(row) + + if not rader: + raise ValueError(f"{path}: ingen datarader") + + # Snapshot-datoen er NAV-datoen radene faktisk gjelder for, ikke + # nedlastingstidspunktet i filnavnet -- laster du ned lørdag er + # kursene fra fredag. + navdatoer = [str(r["nav_dato"]) for r in rader if r["nav_dato"]] + if navdatoer: + # Hyppigste, ikke seneste: noen få fond prises et døgn etter + # resten, og de skal ikke flytte hele snapshotets dato. + stamp = date.fromisoformat(Counter(navdatoer).most_common(1)[0][0]) + else: + stamp = _date_from_filename(Path(path)) or date.today() + return Snapshot(dato=stamp, rader=rader, kilde=Path(path).name) + + +def _date_from_filename(path: Path) -> date | None: + """Reservenøkkel: 'fond_19.8.2026_2120.csv' -> date(2026, 8, 19).""" + m = re.search(r"(\d{1,2})\.(\d{1,2})\.(\d{4})", path.name) + if not m: + return None + d, mo, y = (int(g) for g in m.groups()) + try: + return date(y, mo, d) + except ValueError: + return None + + +def snapshot_records(snap: Snapshot) -> list[dict]: + """Snapshot -> rader klare for panel-CSV.""" + out = [] + for r in snap.rader: + rec = {"snapshot_dato": snap.dato.isoformat(), "kilde": snap.kilde} + rec.update(r) + out.append(rec) + return out + + +__all__ = [ + "Snapshot", + "apply_overrides", + "derive_exposure", + "parse_date", + "parse_file", + "parse_number", + "load_overrides", + "snapshot_records", +] diff --git a/fond/overrides.csv b/fond/overrides.csv new file mode 100644 index 0000000..805ead7 --- /dev/null +++ b/fond/overrides.csv @@ -0,0 +1,5 @@ +# Manuell omklassifisering der navn + Morningstar-kategori gir feil gruppe. +# Fyll bare ut kolonnene du vil overstyre; resten utledes som før. +# Kolonner: isin,marked,sektor,eksponering,tilt,smallcap,valutasikret,notat +isin,marked,sektor,eksponering,tilt,smallcap,valutasikret,notat +NO0010700891,Norge,,,,,,"Alfred Berg Indeks R følger Oslo Børs; navnet sier ikke Norge og Morningstar klassifiserer den som Europe Equity Large Cap" diff --git a/fond/test_fond.py b/fond/test_fond.py new file mode 100644 index 0000000..4b80983 --- /dev/null +++ b/fond/test_fond.py @@ -0,0 +1,322 @@ +#!/usr/bin/env python3 +"""Tester for fondsverktøyet. + + python3 fond/test_fond.py # stdlib, ingen avhengigheter + python3 -m pytest fond/test_fond.py + +Ligger under fond/ og ikke tests/ fordi pyproject setter testpaths til +tests/ -- petrolib-suiten skal ikke plukke opp disse. +""" + +from __future__ import annotations + +import csv +import sys +import tempfile +import unittest +from datetime import date, timedelta +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import analyze # noqa: E402 +import nordnet # noqa: E402 + +HEADER = [ + "Navn", "ISIN", "Valuta", "Kategori", "Total pris %", "Rangering", "Risiko", + "Bærekraft", "Belåningsgrad %", "Antall eiere", "1 d %", "1 uke %", "1 måned %", + "3 måneder %", "i år %", "1 år %", "3 yrs. ann.", "5 yrs. ann.", "10 yrs. ann.", + "Ansvarlig forvalter", "Siste", "Tid", "Startdato", "Min. kjøp", "Handles", +] + + +def nordnet_csv(path: Path, rader: list[dict], navdato: str) -> Path: + """Skriv en fil i akkurat det formatet Nordnet eksporterer.""" + linjer = ["\t".join(HEADER)] + for r in rader: + felt = [ + f'"{r["navn"]}"', f'"{r["isin"]}"', f'"{r.get("valuta", "NOK")}"', + f'"{r.get("kategori", "Global Equity Large Cap")}"', + f'"{r.get("pris", "0,25")}"', f'"{r.get("rangering", "4")}"', '"4"', + '"Artikkel 8"', '"85"', '"1000"', '"−0,10"', '"1,00"', '"2,00"', + f'"{r.get("m3", "5,00")}"', '"10,00"', f'"{r.get("y1", "12,00")}"', + f'"{r.get("y3", "16,00")}"', f'"{r.get("y5", "13,00")}"', '"11,00"', + '"Forvalter AS"', f'"{r["nav"]}"', navdato, "1.1.2015", '"100"', '"Daglig"', + ] + linjer.append("\t".join(felt)) + # UTF-16LE med BOM og CRLF, slik Nordnet faktisk leverer den. + path.write_bytes("\r\n".join(linjer).encode("utf-16")) + return path + + +class TestTallOgDato(unittest.TestCase): + def test_unicode_minus(self): + """Nordnet bruker U+2212, ikke ASCII-bindestrek.""" + self.assertEqual(nordnet.parse_number("−1,16"), -1.16) + self.assertEqual(nordnet.parse_number("-1,16"), -1.16) + + def test_desimalkomma_og_tusenskille(self): + self.assertEqual(nordnet.parse_number("2098,49"), 2098.49) + self.assertEqual(nordnet.parse_number("1 234,50"), 1234.50) + self.assertEqual(nordnet.parse_number("1 234,50"), 1234.50) + + def test_tomme_felt(self): + for tom in ("", " ", '""', None): + self.assertIsNone(nordnet.parse_number(tom)) + + def test_dato(self): + self.assertEqual(nordnet.parse_date("18.8.2026"), date(2026, 8, 18)) + self.assertEqual(nordnet.parse_date("1.10.2024"), date(2024, 10, 1)) + self.assertIsNone(nordnet.parse_date("32.1.2026")) + self.assertIsNone(nordnet.parse_date("")) + + +class TestEksponering(unittest.TestCase): + def test_sammensatte_norske_navn(self): + """'AksjeNorge' har ingen ordgrense foran 'Norge' -- må likevel treffe.""" + for navn, ventet in [ + ("KLP AksjeNorge Indeks N", "Norge"), + ("KLP AksjeUSA Indeks N", "USA"), + ("KLP AksjeVerden Indeks N", "Global"), + ("KLP AksjeEuropa Indeks N", "Europa"), + ("KLP AksjeFremvoksende Markeder Indeks N", "Fremvoksende"), + ]: + with self.subTest(navn=navn): + self.assertEqual( + nordnet.derive_exposure(navn, "Europe Equity Large Cap", "NOK")["marked"], + ventet, + ) + + def test_kategori_skiller_ikke_norden(self): + """Morningstar legger Norge, Sverige og Europa i samme kategori.""" + kat = "Europe Equity Large Cap" + marked = { + nordnet.derive_exposure(n, kat, "NOK")["marked"] + for n in ("Nordnet Norge Indeks", "Nordnet Sverige Index", + "Nordnet Danmark Indeks B", "DNB Norden Indeks A", + "Nordnet Europa Indeks") + } + self.assertEqual(marked, {"Norge", "Sverige", "Danmark", "Norden", "Europa"}) + + def test_valutasikring_er_egen_gruppe(self): + sikret = nordnet.derive_exposure("KLP AksjeUSA Indeks Valutasikret N", "US Equity Large Cap Blend", "NOK") + usikret = nordnet.derive_exposure("KLP AksjeUSA Indeks N", "US Equity Large Cap Blend", "NOK") + self.assertTrue(sikret["valutasikret"]) + self.assertFalse(usikret["valutasikret"]) + self.assertNotEqual(sikret["eksponering"], usikret["eksponering"]) + + def test_sektor_kommer_fra_kategori_ikke_navn(self): + """'Handelsbanken' og 'SpareBank 1' er ikke finanssektor-fond.""" + for navn in ("Handelsbanken Global Index (A1 NOK)", "SpareBank 1 Indeks Global N"): + with self.subTest(navn=navn): + e = nordnet.derive_exposure(navn, "Global Equity Large Cap", "NOK") + self.assertEqual(e["sektor"], "") + self.assertEqual(e["eksponering"], "Global") + tek = nordnet.derive_exposure("KLP AksjeTeknologi Indeks N", "Technology Sector Equity", "NOK") + self.assertEqual(tek["sektor"], "Teknologi") + + def test_ex_japan_gaar_ikke_i_japan_gruppa(self): + e = nordnet.derive_exposure("Lannebo Marknad Pacific A", "Asia ex-Japan Equity", "SEK") + self.assertNotEqual(e["marked"], "Japan") + + def test_tilt_i_sammentrukne_navn(self): + """Amundi skriver 'EurSRIClmtPrsAlgd' i ett ord.""" + self.assertTrue(nordnet.derive_exposure("Amundi MSCI EurSRIClmtPrsAlgd AE C", "Europe Equity Large Cap", "EUR")["tilt"]) + self.assertTrue(nordnet.derive_exposure("KLP AksjeGlobal Mer Samfunnsansvar N", "Global Equity Large Cap", "NOK")["tilt"]) + + def test_plus_som_forvalternavn_er_ikke_tilt(self): + self.assertFalse(nordnet.derive_exposure("PLUS Fastigheter Sverige Index", "Real Estate Sector Equity", "SEK")["tilt"]) + self.assertTrue(nordnet.derive_exposure("Storebrand Sverige Småbolag Plus C NOK", "Europe Equity Mid/Small Cap", "NOK")["tilt"]) + + def test_giring(self): + self.assertTrue(nordnet.derive_exposure("Nordnet Global Indeks 125", "Global Equity Large Cap", "NOK")["giret"]) + + def test_renter_skilles_fra_aksjer(self): + e = nordnet.derive_exposure("KLP Obligasjon Global 3 år N", "Global Fixed Income", "NOK") + self.assertEqual(e["aktivaklasse"], "Renter") + self.assertTrue(e["eksponering"].startswith("Renter")) + + +class TestFillesing(unittest.TestCase): + def setUp(self): + self.tmp = Path(tempfile.mkdtemp()) + + def test_leser_utf16_med_bom(self): + f = nordnet_csv(self.tmp / "a.csv", [ + {"navn": "Fond A", "isin": "NO1", "nav": "100,00"}, + ], "18.8.2026") + snap = nordnet.parse_file(f) + self.assertEqual(snap.dato, date(2026, 8, 18)) + self.assertEqual(snap.rader[0]["nav"], 100.0) + + def test_snapshot_dato_er_hyppigste_navdato(self): + """Noen få fond prises et døgn senere; de skal ikke flytte datoen.""" + rader = [{"navn": f"Fond {i}", "isin": f"NO{i}", "nav": "100,00"} for i in range(5)] + f = nordnet_csv(self.tmp / "b.csv", rader, "18.8.2026") + tekst = f.read_bytes().decode("utf-16").replace("18.8.2026", "19.8.2026", 1) + f.write_bytes(tekst.encode("utf-16")) + self.assertEqual(nordnet.parse_file(f).dato, date(2026, 8, 18)) + + def test_overstyring(self): + ov = self.tmp / "ov.csv" + ov.write_text("# kommentar\nisin,marked,notat\nNO1,Norge,fordi\n", encoding="utf-8") + f = nordnet_csv(self.tmp / "c.csv", [ + {"navn": "Ukjent Indeks R", "isin": "NO1", "nav": "100,00", + "kategori": "Europe Equity Large Cap"}, + ], "18.8.2026") + snap = nordnet.parse_file(f, nordnet.load_overrides(ov)) + self.assertEqual(snap.rader[0]["marked"], "Norge") + self.assertEqual(snap.rader[0]["eksponering"], "Norge") + + def test_tom_fil_gir_feil(self): + tom = self.tmp / "tom.csv" + tom.write_bytes("".encode("utf-16")) + with self.assertRaises(ValueError): + nordnet.parse_file(tom) + + +class TestPanelOgRangering(unittest.TestCase): + """Bygger et flermåneders panel og sjekker NAV-avledede mål.""" + + def setUp(self): + self.tmp = Path(tempfile.mkdtemp()) + self.panel = self.tmp / "snapshots.csv" + start = date(2026, 1, 15) + # Tre globale indeksfond: A og B følger hverandre tett (A billigst), + # C er dyrere og sakker etter -- akkurat mønsteret rangeringen skal se. + baner = { + "A": ("Billig Global Indeks", "0,19", [100, 102, 101, 104, 106, 108]), + "B": ("Dyr Global Indeks", "0,45", [100, 102, 101, 104, 106, 108]), + "C": ("Etternøler Global Indeks", "0,35", [100, 101, 99, 100, 101, 101]), + } + for m in range(6): + d = start + timedelta(days=30 * m) + rader = [ + {"navn": navn, "isin": f"NO{k}", "nav": f"{navs[m]:.2f}".replace(".", ","), + "pris": pris, "y1": "12,00" if k != "C" else "6,00", + "y3": "16,00" if k != "C" else "9,00"} + for k, (navn, pris, navs) in baner.items() + ] + f = nordnet_csv(self.tmp / f"s{m}.csv", rader, f"{d.day}.{d.month}.{d.year}") + snap = nordnet.parse_file(f) + self._append(snap) + + def _append(self, snap): + rows = list(csv.DictReader(self.panel.open(encoding="utf-8"))) if self.panel.exists() else [] + rows = [r for r in rows if r["snapshot_dato"] != snap.dato.isoformat()] + rows.extend(nordnet.snapshot_records(snap)) + with self.panel.open("w", encoding="utf-8", newline="") as fh: + w = csv.DictWriter(fh, fieldnames=nordnet.FIELDNAMES, extrasaction="ignore") + w.writeheader() + w.writerows(rows) + + def test_nav_maal_beregnes(self): + rows = analyze.load_panel(self.panel) + m = analyze.nav_metrics(rows) + self.assertEqual(len(m), 3) + a = m["NOA"] + self.assertEqual(a["nav_n"], 6) + self.assertGreater(a["nav_ann_pct"], 0) # 100 -> 108 er positivt + self.assertGreater(a["nav_vol_pct"], 0) + self.assertLess(a["nav_mdd_pct"], 0) # hadde et fall underveis + + def test_maks_fall(self): + """102 -> 101 er det største fallet i banen: -0,98 %.""" + rows = analyze.load_panel(self.panel) + self.assertAlmostEqual(analyze.nav_metrics(rows)["NOA"]["nav_mdd_pct"], -100 / 102, places=6) + + def test_billigste_av_to_like_vinner(self): + rows = analyze.load_panel(self.panel) + _, grupper = analyze.rank(rows) + gruppe = next(g for g in grupper if len(g) == 3) + rangert = [x["navn"] for x in gruppe] + self.assertEqual(rangert[0], "Billig Global Indeks") + self.assertEqual(rangert[-1], "Etternøler Global Indeks") + + def test_overlapp_finner_duplikatet(self): + """A og B har identiske NAV-baner -- paret skal flagges, billigst først.""" + par = analyze.overlap_pairs(analyze.load_panel(self.panel)) + self.assertTrue(any( + b == "Billig Global Indeks" and d == "Dyr Global Indeks" for b, d, _ in par + ), par) + + def test_endringer_mellom_snapshots(self): + ch = analyze.changes(analyze.load_panel(self.panel)) + self.assertEqual(ch["nye"], []) + self.assertEqual(ch["borte"], []) + + +class TestScoring(unittest.TestCase): + def _fond(self, **kw): + base = {"isin": "X", "navn": "F", "kostnad_pct": 0.25, "eksponering": "Global", + "avk_3m_pct": 5.0, "avk_1y_pct": 12.0, "avk_3y_ann_pct": 16.0, + "avk_5y_ann_pct": 13.0} + base.update(kw) + return base + + def test_lavere_pris_gir_hoyere_score_alt_annet_likt(self): + gruppe = [ + self._fond(isin="A", navn="Billig", kostnad_pct=0.15), + self._fond(isin="B", navn="Middels", kostnad_pct=0.30), + self._fond(isin="C", navn="Dyr", kostnad_pct=0.60), + ] + rangert = analyze.score_group(gruppe, {}) + self.assertEqual([x["navn"] for x in rangert], ["Billig", "Middels", "Dyr"]) + + def test_stort_avvik_flagges_som_feilklassifisering(self): + gruppe = [ + self._fond(isin="A", navn="Normal 1"), + self._fond(isin="B", navn="Normal 2", avk_1y_pct=12.5, avk_3y_ann_pct=16.2), + self._fond(isin="C", navn="Normal 3", avk_1y_pct=11.5, avk_3y_ann_pct=15.8), + self._fond(isin="D", navn="Feilplassert", avk_1y_pct=30.0, + avk_3y_ann_pct=25.0, avk_3m_pct=15.0, avk_5y_ann_pct=24.0), + ] + rangert = analyze.score_group(gruppe, {}) + avvik = next(x for x in rangert if x["navn"] == "Feilplassert") + self.assertIn("avvik", avvik["merknad"]) + # Skal ikke toppe lista på grunn av avviket. + self.assertNotEqual(rangert[0]["navn"], "Feilplassert") + self.assertTrue(analyze.suspects([rangert])) + + def test_manglende_historikk_straffes(self): + gruppe = [ + self._fond(isin="A", navn="Med historikk"), + self._fond(isin="B", navn="Uten historikk", avk_3m_pct=None, + avk_1y_pct=None, avk_3y_ann_pct=None, avk_5y_ann_pct=None), + self._fond(isin="C", navn="Med historikk 2", avk_1y_pct=12.2), + self._fond(isin="D", navn="Med historikk 3", avk_1y_pct=11.8), + ] + rangert = analyze.score_group(gruppe, {}) + ung = next(x for x in rangert if x["navn"] == "Uten historikk") + self.assertEqual(ung["merknad"], "for kort historikk") + self.assertEqual(ung["perioder_maalt"], 0) + + +class TestEkteFil(unittest.TestCase): + """Kjører mot den innsjekkede eksportfila hvis den finnes.""" + + def setUp(self): + self.panel = Path(__file__).resolve().parent / "data" / "snapshots.csv" + if not self.panel.exists(): + self.skipTest("ingen data/snapshots.csv") + + def test_panelet_lar_seg_rangere(self): + rows = analyze.load_panel(self.panel) + stamp, grupper = analyze.rank(rows) + self.assertTrue(stamp) + self.assertTrue(grupper) + for g in grupper: + plasser = [x["plass"] for x in g] + self.assertEqual(plasser, sorted(plasser)) + + def test_hvert_fond_faar_enten_marked_eller_sektor(self): + """Sektorfond har ingen geografi, men ingen fond skal mangle begge.""" + rows = analyze.load_panel(self.panel) + uklassifiserte = sorted( + {r["navn"] for r in rows if r["marked"] == "Ukjent" and not r["sektor"]} + ) + self.assertEqual(uklassifiserte, [], f"uklassifiserte fond: {uklassifiserte}") + + +if __name__ == "__main__": + unittest.main(verbosity=2) From 19d86f37a8ab038cf6fb51097d27e595b93f4feb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 20:20:02 +0000 Subject: [PATCH 2/2] Run the fond/ tests in CI fond/test_fond.py is stdlib-only and lives outside pyproject's testpaths, so neither the lint job (which checks petrolib, tests and tools) nor the petrolib pytest lane ever executed it. Own job, own runner, pinned to 3.10 -- the oldest version fond/ claims to support. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01L8cF2pbLZuncatMezhAV63 --- .github/workflows/ci.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 40e4003..2098908 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,18 @@ jobs: - name: mypy (strict, petrolib only) run: mypy + # fond/ is stdlib-only and sits outside pyproject's testpaths, so the + # petrolib lane never sees it. Own job, own runner, no shared setup. + fond-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.10" # oldest version fond/ claims to support + - name: Fund tooling unit tests + run: python fond/test_fond.py + petrolib-tests: runs-on: ubuntu-latest strategy: