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: 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 0000000..a69108b Binary files /dev/null and b/fond/data/fond_2026-08-19.csv differ diff --git a/fond/data/snapshots.csv b/fond/data/snapshots.csv new file mode 100644 index 0000000..12a84e0 --- /dev/null +++ b/fond/data/snapshots.csv @@ -0,0 +1,85 @@ +snapshot_dato,navn,isin,valuta,kategori,kostnad_pct,rangering,risiko,baerekraft,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,forvalter,nav,nav_dato,startdato,min_kjoep,handles,aktivaklasse,marked,sektor,valutasikret,smallcap,tilt,giret,eksponering,kilde +2026-08-18,Alfred Berg Indeks R (NOK),NO0010700891,NOK,Europe Equity Large Cap,0.37,3.0,4.0,Artikkel 8,85.0,3864.0,-0.41,1.37,6.32,1.85,23.18,27.29,18.57,12.28,12.86,Alfred Berg Kapitalforvaltning AS,905.74,2026-08-19,2014-02-17,100.0,Daglig,Aksjer,Norge,,False,False,False,False,Norge,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,Alfred Berg Nordic Index R (NOK),NO0012477738,NOK,Europe Equity Large Cap,0.25,3.0,4.0,Artikkel 6,0.0,166.0,-0.17,-0.01,1.06,5.13,3.13,12.66,11.46,,,Alfred Berg Kapitalforvaltning AS,149.71,2026-08-18,2022-05-02,100.0,Daglig,Aksjer,Norden,,False,False,False,False,Norden,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,Amundi MSCI EmMktsSRIClmtPrsAlgd AE C,LU1861138375,EUR,Global Emerging Markets Equity,0.51,2.0,4.0,Artikkel 8,70.0,428.0,0.25,0.42,2.93,5.41,8.54,14.56,11.59,5.62,,Amundi Luxembourg S.A.,151.69,2026-08-18,2019-01-16,100.0,Daglig,Aksjer,Fremvoksende,,False,False,True,False,Fremvoksende / tilt,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,Amundi MSCI EurSRIClmtPrsAlgd AE C,LU1861136833,EUR,Europe Equity Large Cap,0.42,2.0,4.0,Artikkel 8,80.0,499.0,-1.06,-1.88,-0.45,6.98,-0.15,1.73,6.92,4.85,,Amundi Luxembourg S.A.,187.04,2026-08-18,2018-09-11,100.0,Daglig,Aksjer,Europa,,False,False,True,False,Europa / tilt,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,Amundi MSCI USASRIClmtParisAligned-AE(C),LU1861135512,EUR,US Equity Large Cap Blend,0.42,1.0,4.0,Artikkel 8,80.0,975.0,-0.88,-1.39,2.87,10.13,6.99,6.52,7.84,8.85,,Amundi Luxembourg S.A.,247.08,2026-08-18,2018-09-11,100.0,Daglig,Aksjer,USA,,False,False,True,False,USA / tilt,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,Amundi MSCI World SRI Clmt Prs AlgdAEC,LU1861133657,EUR,Global Equity Large Cap,0.42,3.0,4.0,Artikkel 8,80.0,986.0,-1.36,-1.44,0.83,9.84,8.44,12.96,11.86,9.88,,Amundi Luxembourg S.A.,241.57,2026-08-18,2018-09-10,100.0,Daglig,Aksjer,Global,,False,False,True,False,Global / tilt,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,DNB Barnefond A,NO0010336977,NOK,Europe Equity Mid/Small Cap,0.25,4.0,4.0,Artikkel 8,80.0,1315.0,-0.28,-0.48,0.66,4.92,1.59,10.64,12.42,6.64,12.42,DNB Asset Management AS,1104.39,2026-08-18,1997-02-17,100.0,Daglig,Aksjer,Europa,,False,True,True,False,Europa / SmallCap / tilt,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,DNB Critical Materials A,NO0010820509,NOK,Industrials Sector Equity,0.25,2.0,4.0,Artikkel 8,0.0,276.0,1.98,-0.49,18.23,2.76,6.82,14.08,10.04,7.82,,DNB Asset Management AS,224.56,2026-08-18,2018-10-09,100.0,Daglig,Aksjer,Ukjent,Industri,False,False,False,False,Industri,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,DNB Europa Indeks A,NO0008001872,NOK,Europe Equity Large Cap,0.25,5.0,4.0,Artikkel 8,85.0,8101.0,-0.81,-1.73,0.46,8.64,3.78,10.71,13.37,10.25,11.36,DNB Asset Management AS,335.32,2026-08-18,1998-07-14,100.0,Daglig,Aksjer,Europa,,False,False,False,False,Europa,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,DNB Global Emerging Markets Indeks A,NO0010815855,NOK,Global Emerging Markets Equity,0.35,3.0,4.0,Artikkel 8,75.0,4265.0,-1.03,0.8,2.04,4.42,13.7,23.97,18.22,9.45,,DNB Asset Management AS,199.17,2026-08-18,2018-04-18,100.0,Daglig,Aksjer,Fremvoksende,,False,False,False,False,Fremvoksende,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,DNB Global Enhanced Index A,NO0012923277,NOK,Global Equity Large Cap,0.35,,4.0,Artikkel 8,80.0,908.0,-0.9,-1.38,0.79,6.41,5.72,11.3,,,,DNB Asset Management AS,159.34,2026-08-18,2023-09-22,100.0,Daglig,Aksjer,Global,,False,False,True,False,Global / tilt,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,DNB Global Enhanced Small Cap A,NO0013466037,NOK,Global Equity Mid/Small Cap,0.6,,4.0,Artikkel 8,0.0,615.0,-1.65,-0.88,1.12,8.31,10.71,16.91,,,,DNB Asset Management AS,116.34,2026-08-18,2025-02-12,100.0,Daglig,Aksjer,Global,,False,True,True,False,Global / SmallCap / tilt,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,DNB Global Indeks A,NO0010582984,NOK,Global Equity Large Cap,0.25,4.0,4.0,Artikkel 8,85.0,46654.0,-0.86,-1.43,0.84,6.79,5.59,10.99,16.23,12.77,14.37,DNB Asset Management AS,865.71,2026-08-18,2010-09-24,100.0,Daglig,Aksjer,Global,,False,False,False,False,Global,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,DNB Global Industrisektor Indeks A,NO0010820525,NOK,Industrials Sector Equity,0.25,4.0,4.0,Artikkel 8,85.0,2813.0,-1.73,-1.98,1.25,7.16,8.44,10.09,16.41,12.78,,DNB Asset Management AS,289.73,2026-08-18,2018-10-09,100.0,Daglig,Aksjer,Global,Industri,False,False,False,False,Industri,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,DNB Global Marked Valutasikret A,NO0012778507,NOK,Global Equity Large Cap,0.27,,4.0,Artikkel 8,85.0,767.0,-0.41,0.04,3.94,6.1,14.29,21.91,20.62,,,DNB Asset Management AS,187.83,2026-08-18,2022-12-13,100.0,Daglig,Aksjer,Global,,True,False,False,False,Global / NOK-sikret,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,DNB Klima Indeks A,NO0010969181,NOK,Global Equity Large Cap,0.25,3.0,4.0,Artikkel 9,80.0,1803.0,-0.8,-1.75,0.67,6.1,1.78,6.8,14.63,10.96,,DNB Asset Management AS,191.84,2026-08-18,2021-05-06,100.0,Daglig,Aksjer,Global,,False,False,True,False,Global / tilt,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,DNB Norden Indeks A,NO0010815871,NOK,Europe Equity Large Cap,0.25,4.0,4.0,Artikkel 8,80.0,5327.0,-0.99,-0.99,0.07,4.24,2.56,11.76,11.53,5.39,,DNB Asset Management AS,247.91,2026-08-18,2018-04-12,100.0,Daglig,Aksjer,Norden,,False,False,False,False,Norden,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,DNB Norge Indeks A,NO0010582976,NOK,Europe Equity Large Cap,0.25,3.0,4.0,Artikkel 8,85.0,5310.0,0.2,2.43,6.77,3.56,23.75,27.72,18.3,12.44,12.96,DNB Asset Management AS,579.31,2026-08-18,2010-08-25,100.0,Daglig,Aksjer,Norge,,False,False,False,False,Norge,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,DNB Nuclear Energy A,NO0013413831,NOK,Energy Sector Equity,0.35,,4.0,Artikkel 8,80.0,20363.0,-0.39,1.71,4.42,2.16,4.95,14.02,,,,DNB Asset Management AS,136.4,2026-08-18,2024-12-18,100.0,Daglig,Aksjer,Ukjent,Energi,False,False,False,False,Energi,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,DNB USA Indeks A,NO0010337959,NOK,US Equity Large Cap Blend,0.25,4.0,4.0,Artikkel 8,85.0,4177.0,-0.82,-1.47,0.56,6.04,5.36,10.44,17.06,13.77,16.35,DNB Asset Management AS,839.0,2026-08-18,1998-10-21,100.0,Daglig,Aksjer,USA,,False,False,False,False,USA,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,Handelsbanken Emerging M. Index (A1 NOK),SE0011338425,NOK,Global Emerging Markets Equity,0.77,4.0,4.0,Artikkel 9,70.0,1115.0,-0.96,0.42,2.22,4.48,17.0,27.15,19.1,10.8,10.24,Handelsbanken Fonder AB,301.13,2026-08-19,2018-12-14,100.0,Daglig,Aksjer,Fremvoksende,,False,False,False,False,Fremvoksende,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,Handelsbanken Europa Index (A1 NOK),SE0006789723,NOK,Europe Equity Large Cap,0.35,4.0,4.0,Artikkel 9,85.0,2271.0,-0.82,-2.26,0.13,9.1,2.52,10.27,13.78,9.89,11.05,Handelsbanken Fonder AB,260.24,2026-08-19,2015-02-17,100.0,Daglig,Aksjer,Europa,,False,False,False,False,Europa,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,Handelsbanken Global Index (A1 NOK),SE0011309699,NOK,Global Equity Large Cap,0.56,4.0,4.0,Artikkel 9,80.0,1783.0,-0.87,-1.99,0.21,5.92,4.72,11.06,15.88,11.25,13.81,Handelsbanken Fonder AB,691.8,2026-08-19,2018-12-14,100.0,Daglig,Aksjer,Global,,False,False,False,False,Global,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,Handelsbanken USA Index (A1 NOK),SE0006800157,NOK,US Equity Large Cap Blend,0.35,4.0,4.0,Artikkel 9,85.0,3655.0,-1.15,-1.99,0.72,5.83,4.79,11.7,17.33,13.18,16.84,Handelsbanken Fonder AB,1156.58,2026-08-19,2015-02-12,100.0,Daglig,Aksjer,USA,,False,False,False,False,USA,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,KLP Aksje Verden All Cap Indeks N,NO0013751651,NOK,Global Equity Large Cap,0.28,,4.0,Artikkel 8,70.0,741.0,-0.91,-1.06,1.09,,,,,,,KLP Kapitalforvaltning AS,1034.22,2026-08-18,2026-06-09,100.0,Daglig,Aksjer,Global,,False,False,False,False,Global,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,KLP AksjeAsia Indeks N,NO0012445362,NOK,Asia Equity,0.25,4.0,4.0,Artikkel 8,75.0,4241.0,-1.2,-0.61,3.96,9.11,10.93,13.08,15.0,9.72,9.96,KLP Kapitalforvaltning AS,1707.47,2026-08-18,2022-06-08,100.0,Daglig,Aksjer,Asia,,False,False,False,False,Asia,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,KLP AksjeAsia Indeks Valutasikret N,NO0012445370,NOK,Equity Miscellaneous,0.28,,4.0,Artikkel 8,70.0,3230.0,-0.06,1.32,6.13,8.59,21.35,31.05,24.33,16.36,12.78,KLP Kapitalforvaltning AS,2098.49,2026-08-18,2022-06-08,100.0,Daglig,Aksjer,Asia,,True,False,False,False,Asia / NOK-sikret,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,KLP AksjeEuropa Indeks N,NO0012445388,NOK,Europe Equity Large Cap,0.25,4.0,4.0,Artikkel 8,85.0,8783.0,-0.31,-0.93,1.02,9.42,4.39,10.84,14.22,10.75,11.46,KLP Kapitalforvaltning AS,1777.85,2026-08-18,2022-06-08,100.0,Daglig,Aksjer,Europa,,False,False,False,False,Europa,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,KLP AksjeEuropa Indeks Valutasikret N,NO0012445396,NOK,Equity Miscellaneous,0.28,,4.0,Artikkel 8,85.0,1371.0,-0.53,-0.99,2.21,8.17,13.53,22.0,17.12,10.0,9.87,KLP Kapitalforvaltning AS,1676.54,2026-08-18,2022-06-08,100.0,Daglig,Aksjer,Europa,,True,False,False,False,Europa / NOK-sikret,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,KLP AksjeFremvoksende Markeder Indeks N,NO0012443664,NOK,Global Emerging Markets Equity,0.33,4.0,4.0,Artikkel 8,75.0,16196.0,0.76,1.59,3.3,5.42,15.21,25.9,18.47,10.09,10.4,KLP Kapitalforvaltning AS,1729.93,2026-08-18,2022-06-08,100.0,Daglig,Aksjer,Fremvoksende,,False,False,False,False,Fremvoksende,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,KLP AksjeFremvoksende MarkederMer Samf N,NO0012445339,NOK,Global Emerging Markets Equity,0.33,2.0,4.0,Artikkel 9,80.0,1014.0,-1.42,0.23,0.37,-0.25,9.55,18.01,14.91,,,KLP Kapitalforvaltning AS,1556.95,2026-08-18,2022-06-08,100.0,Daglig,Aksjer,Fremvoksende,,False,False,True,False,Fremvoksende / tilt,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,KLP AksjeGlobal Flerfaktor N,NO0012444514,NOK,Global Equity Large Cap,0.3,4.0,4.0,Artikkel 8,85.0,1146.0,0.0,-0.87,0.8,8.81,6.46,12.01,14.33,11.8,11.18,KLP Kapitalforvaltning AS,1735.44,2026-08-18,2022-06-08,100.0,Daglig,Aksjer,Global,,False,False,True,False,Global / tilt,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,KLP AksjeGlobal Flerfaktor Valutasikr N,NO0012444530,NOK,Equity Miscellaneous,0.32,,3.0,Artikkel 8,85.0,621.0,-0.58,-0.29,3.02,8.07,14.68,22.45,18.84,10.46,9.43,KLP Kapitalforvaltning AS,1704.31,2026-08-18,2022-06-08,100.0,Daglig,Aksjer,Global,,True,False,True,False,Global / NOK-sikret / tilt,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,KLP AksjeGlobal Indeks N,NO0012445404,NOK,Global Equity Large Cap,0.25,4.0,4.0,Artikkel 8,80.0,47231.0,-0.55,-0.67,1.73,7.21,6.45,12.29,17.01,12.98,14.6,KLP Kapitalforvaltning AS,1894.81,2026-08-18,2022-06-08,100.0,Daglig,Aksjer,Global,,False,False,False,False,Global,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,KLP AksjeGlobal Indeks Valutasikret N,NO0012445529,NOK,Equity Miscellaneous,0.28,,4.0,Artikkel 8,85.0,6146.0,-0.4,0.01,3.98,6.2,14.33,22.1,20.76,11.31,12.22,KLP Kapitalforvaltning AS,1830.12,2026-08-18,2022-06-08,100.0,Daglig,Aksjer,Global,,True,False,False,False,Global / NOK-sikret,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,KLP AksjeGlobal Mer Samf Valutasikret N,NO0012445305,NOK,Global Equity Large Cap,0.28,,4.0,Artikkel 9,80.0,597.0,-0.49,0.21,4.17,8.68,15.44,25.09,20.24,10.18,,KLP Kapitalforvaltning AS,1778.45,2026-08-18,2022-06-08,100.0,Daglig,Aksjer,Global,,True,False,True,False,Global / NOK-sikret / tilt,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,KLP AksjeGlobal Mer Samfunnsansvar N,NO0012445289,NOK,Global Equity Large Cap,0.25,3.0,4.0,Artikkel 9,85.0,6435.0,-0.65,-0.46,1.98,9.85,7.48,14.99,16.52,11.71,,KLP Kapitalforvaltning AS,1836.91,2026-08-18,2022-06-08,100.0,Daglig,Aksjer,Global,,False,False,True,False,Global / tilt,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,KLP AksjeGlobal Small Cap Indeks N,NO0012445347,NOK,Global Equity Mid/Small Cap,0.4,3.0,4.0,Artikkel 8,80.0,3696.0,-0.34,1.07,2.7,9.3,12.16,19.22,15.02,9.63,,KLP Kapitalforvaltning AS,1663.33,2026-08-18,2022-06-08,100.0,Daglig,Aksjer,Global,,False,True,False,False,Global / SmallCap,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,KLP AksjeNorden Indeks N,NO0012445230,NOK,Europe Equity Large Cap,0.25,4.0,4.0,Artikkel 8,85.0,8545.0,-0.33,0.03,1.32,6.13,1.96,9.19,8.99,5.04,10.99,KLP Kapitalforvaltning AS,1447.15,2026-08-18,2022-06-08,100.0,Daglig,Aksjer,Norden,,False,False,False,False,Norden,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,KLP AksjeNorden Mer Samfunnsansvar N,NO0012445263,NOK,Europe Equity Large Cap,0.25,3.0,4.0,Artikkel 9,80.0,825.0,-0.3,0.49,1.4,4.72,1.9,9.85,9.22,3.57,,KLP Kapitalforvaltning AS,1359.25,2026-08-18,2022-06-08,100.0,Daglig,Aksjer,Norden,,False,False,True,False,Norden / tilt,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,KLP AksjeNorge Indeks N,NO0012445214,NOK,Europe Equity Large Cap,0.25,3.0,4.0,Artikkel 8,85.0,5991.0,0.2,2.43,6.78,3.56,23.8,27.8,18.41,12.54,13.12,KLP Kapitalforvaltning AS,1631.9,2026-08-18,2022-06-08,100.0,Daglig,Aksjer,Norge,,False,False,False,False,Norge,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,KLP AksjeTeknologi Indeks N,NO0013296780,NOK,Technology Sector Equity,0.28,,5.0,Artikkel 8,80.0,1873.0,-2.03,-0.85,3.28,7.87,19.95,29.2,,,,KLP Kapitalforvaltning AS,1447.44,2026-08-18,2024-10-09,100.0,Daglig,Aksjer,Ukjent,Teknologi,False,False,False,False,Teknologi,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,KLP AksjeUSA Indeks N,NO0012445511,NOK,US Equity Large Cap Blend,0.25,4.0,4.0,Artikkel 8,85.0,2537.0,-0.69,-0.83,1.4,6.34,6.0,11.73,17.67,13.77,,KLP Kapitalforvaltning AS,1919.57,2026-08-18,2022-10-26,100.0,Daglig,Aksjer,USA,,False,False,False,False,USA,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,KLP AksjeUSA Indeks Valutasikret N,NO0012445354,NOK,US Equity Large Cap Blend,0.28,,4.0,Artikkel 8,85.0,739.0,-0.53,-0.11,3.94,5.07,13.27,20.26,21.0,10.65,12.82,KLP Kapitalforvaltning AS,1827.84,2026-08-18,2022-06-08,100.0,Daglig,Aksjer,USA,,True,False,False,False,USA / NOK-sikret,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,KLP AksjeVerden Indeks N,NO0012444498,NOK,Global Equity Large Cap,0.26,4.0,4.0,Artikkel 8,85.0,6773.0,-0.4,-0.41,1.91,7.0,7.43,13.75,17.19,12.62,13.98,KLP Kapitalforvaltning AS,1875.26,2026-08-18,2022-06-08,100.0,Daglig,Aksjer,Global,,False,False,False,False,Global,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,KLP Framtid N,NO0012442328,NOK,Global Equity Large Cap,0.3,4.0,3.0,Artikkel 8,85.0,850.0,-0.27,0.29,3.22,6.48,12.2,18.76,18.23,12.25,,KLP Kapitalforvaltning AS,1805.29,2026-08-18,2022-06-08,100.0,Daglig,Aksjer,Global,,False,False,True,False,Global / tilt,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,KLP Obligasjon Global 3 år N,NO0012861741,NOK,Global Fixed Income,0.31,5.0,2.0,Artikkel 8,0.0,55.0,0.07,0.05,0.39,1.08,1.39,3.15,5.01,,,KLP Kapitalforvaltning AS,1033.29,2026-08-18,2023-04-20,100.0,Daglig,Renter,Global,,False,False,False,False,Renter / Global,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,Lannebo Emerging Markets A,SE0004578615,SEK,Global Emerging Markets Equity,0.64,4.0,4.0,Artikkel 8,75.0,3702.0,-1.16,0.84,2.11,5.38,17.56,29.77,19.21,9.25,11.39,Lannebo Kapitalförvaltning AB,387.14,2026-08-18,2012-07-11,100.0,Daglig,Aksjer,Fremvoksende,,False,False,False,False,Fremvoksende,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,Lannebo Marknad Europa A,SE0000577975,SEK,Europe Equity Large Cap,0.52,4.0,4.0,Artikkel 8,85.0,1290.0,-0.31,-0.87,0.96,10.13,4.21,11.09,13.55,9.77,11.23,Lannebo Kapitalförvaltning AB,328.94,2026-08-18,1999-10-18,100.0,Daglig,Aksjer,Europa,,False,False,False,False,Europa,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,Lannebo Marknad Global A,SE0010049197,SEK,Global Equity Large Cap,0.37,4.0,4.0,Artikkel 8,85.0,650.0,-1.07,-1.62,1.03,7.54,5.36,11.37,16.59,12.05,,Lannebo Kapitalförvaltning AB,331.03,2026-08-18,2017-10-11,100.0,Daglig,Aksjer,Global,,False,False,False,False,Global,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,Lannebo Marknad Japan A,SE0000577959,SEK,Japan Equity,0.52,3.0,5.0,Artikkel 8,85.0,568.0,-1.85,-0.3,4.71,11.98,13.5,15.84,17.01,10.77,10.54,Lannebo Kapitalförvaltning AB,235.32,2026-08-18,1999-10-20,100.0,Daglig,Aksjer,Japan,,False,False,False,False,Japan,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,Lannebo Marknad Pacific A,SE0000577983,SEK,Asia ex-Japan Equity,0.5,2.0,4.0,Artikkel 8,75.0,647.0,-0.56,-2.22,0.6,7.53,2.16,0.38,9.82,6.05,8.37,Lannebo Kapitalförvaltning AB,444.33,2026-08-18,1999-10-20,100.0,Daglig,Aksjer,Stillehavet,,False,False,False,False,Stillehavet,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,Lannebo Marknad Sverige Bred A,SE0001463449,SEK,Europe Equity Large Cap,0.4,3.0,4.0,Artikkel 8,85.0,418.0,-1.0,-2.58,1.24,5.85,-0.5,10.57,15.85,5.03,10.59,Lannebo Kapitalförvaltning AB,549.29,2026-08-18,2005-08-24,100.0,Daglig,Aksjer,Sverige,,False,False,False,False,Sverige,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,Lannebo Marknad USA A,SE0000577967,SEK,US Equity Large Cap Blend,0.48,3.0,4.0,Artikkel 8,85.0,920.0,-1.01,-1.72,0.96,6.83,5.34,10.72,17.08,12.81,15.78,Lannebo Kapitalförvaltning AB,695.95,2026-08-18,1999-10-20,100.0,Daglig,Aksjer,USA,,False,False,False,False,USA,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,Nordnet Danmark Indeks B,SE0014956371,DKK,Europe Equity Large Cap,0.19,2.0,4.0,Artikkel 8,85.0,24581.0,-0.84,2.22,-1.76,8.51,-2.52,4.84,3.48,1.9,,Nordnet Fonder AB,294.35,2026-08-18,2020-10-01,100.0,Daglig,Aksjer,Danmark,,False,False,False,False,Danmark,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,Nordnet Emerging Markets Indeks,IE00BMTD2S51,NOK,Global Emerging Markets Equity,0.29,3.0,4.0,Artikkel 8,85.0,43046.0,-0.56,1.32,1.84,3.3,11.12,19.29,17.76,7.99,,J.P. Morgan Mansart Management Ltd,168.42,2026-08-18,2020-09-02,100.0,Daglig,Aksjer,Fremvoksende,,False,False,False,False,Fremvoksende,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,Nordnet Europa Indeks,IE00BMTD2N07,NOK,Europe Equity Large Cap,0.19,3.0,4.0,Artikkel 8,85.0,15687.0,-0.74,-1.97,0.18,8.26,0.84,6.69,9.7,7.76,,J.P. Morgan Mansart Management Ltd,193.65,2026-08-18,2020-09-01,100.0,Daglig,Aksjer,Europa,,False,False,False,False,Europa,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,Nordnet Global Indeks,IE00BMTD2J60,NOK,Global Equity Large Cap,0.19,4.0,4.0,Artikkel 8,85.0,79486.0,-1.01,-1.69,0.65,6.29,3.98,10.41,15.6,12.42,,J.P. Morgan Mansart Management Ltd,228.03,2026-08-18,2020-09-02,100.0,Daglig,Aksjer,Global,,False,False,False,False,Global,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,Nordnet Global Indeks 125,IE000480NS87,NOK,Global Equity Large Cap,0.39,,4.0,Artikkel 8,75.0,18326.0,-1.22,-1.89,1.31,7.1,5.5,13.44,,,,J.P. Morgan Mansart Management Ltd,169.87,2026-08-18,2023-09-22,100.0,Daglig,Aksjer,Global,,False,False,False,True,Global / giret,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,Nordnet Norge Indeks,SE0005993110,NOK,Europe Equity Large Cap,0.19,4.0,4.0,Artikkel 6,85.0,220351.0,-0.18,1.06,6.16,1.29,25.92,29.58,20.78,14.61,13.99,Nordnet Fonder AB,347.49,2026-08-18,2014-06-24,100.0,Daglig,Aksjer,Norge,,False,False,False,False,Norge,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,Nordnet Småbolag Sverige Index C,SE0024245492,NOK,Europe Equity Mid/Small Cap,0.35,,5.0,Artikkel 8,80.0,1286.0,-0.73,-1.52,0.49,4.31,-5.65,,,,,Nordnet Fonder AB,96.75,2026-08-18,2025-12-09,100.0,Daglig,Aksjer,Sverige,,False,True,False,False,Sverige / SmallCap,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,Nordnet Suomi Indeksi,SE0005993102,EUR,Europe Equity Mid/Small Cap,0.19,5.0,4.0,Artikkel 8,85.0,347725.0,-1.72,-0.53,1.33,0.56,1.91,17.24,17.5,8.26,12.85,Nordnet Fonder AB,351.95,2026-08-18,2014-06-17,100.0,Daglig,Aksjer,Finland,,False,True,False,False,Finland / SmallCap,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,Nordnet Sverige Index,SE0002756973,SEK,Europe Equity Large Cap,0.19,4.0,4.0,Artikkel 8,85.0,233636.0,-1.08,-2.81,0.82,5.43,1.78,13.72,16.91,5.65,11.59,Nordnet Fonder AB,840.17,2026-08-18,2009-03-10,100.0,Daglig,Aksjer,Sverige,,False,False,False,False,Sverige,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,Nordnet Teknologi Indeks,IE00BNNLSM87,NOK,Technology Sector Equity,0.29,5.0,5.0,Artikkel 6,80.0,61612.0,-2.42,-2.18,1.85,6.11,13.53,24.57,28.35,20.56,,J.P. Morgan Mansart Management Ltd,298.38,2026-08-18,2021-05-26,100.0,Daglig,Aksjer,Ukjent,Teknologi,False,False,False,False,Teknologi,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,Nordnet Tyskland C,SE0028778456,EUR,Europe Equity Large Cap,0.15,,4.0,Artikkel 8,80.0,1752.0,-0.91,-1.63,3.06,,,,,,,Nordnet Fonder AB,10.48,2026-08-18,2026-06-03,100.0,Daglig,Aksjer,Tyskland,,False,False,False,False,Tyskland,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,Nordnet USA Indeks,IE00BMTD2X05,NOK,US Equity Large Cap Blend,0.19,4.0,4.0,Artikkel 8,85.0,15627.0,-1.02,-1.7,0.24,5.47,4.2,11.65,16.35,14.61,,J.P. Morgan Mansart Management Ltd,254.28,2026-08-18,2020-09-01,100.0,Daglig,Aksjer,USA,,False,False,False,False,USA,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,PLUS Fastigheter Sverige Index,SE0016798805,SEK,Real Estate Sector Equity,0.25,,6.0,Artikkel 6,80.0,2372.0,-0.08,-1.7,-0.6,-0.87,-15.17,-13.19,3.39,,,ISEC Services AB,62.24,2026-08-18,2021-09-23,100.0,Daglig,Aksjer,Sverige,Eiendom,False,False,False,False,Eiendom,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,SEB Emerging Markets Exposure C,SE0021147386,EUR,Global Emerging Markets Equity,0.38,,4.0,Artikkel 8,75.0,1554.0,-1.05,1.17,2.26,6.05,17.73,28.84,,,,SEB Funds AB,170.67,2026-08-18,2024-02-22,100.0,Daglig,Aksjer,Fremvoksende,,False,False,False,False,Fremvoksende,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,SEB Global All Countries Exposure D USD,LU0047324644,USD,Global Equity Large Cap,0.4,4.0,4.0,Artikkel 8,80.0,38.0,-1.03,-1.29,0.69,6.53,5.93,12.11,16.77,11.77,13.67,SEB Funds AB,2.37,2026-08-18,1993-10-25,100.0,Daglig,Aksjer,Global,,False,False,False,False,Global,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,SEB Global Exposure A (SEK),SE0027597592,SEK,Global Equity Large Cap,0.24,,4.0,Artikkel 8,80.0,384.0,-1.01,-1.57,0.49,6.67,,,,,,SEB Funds AB,117.05,2026-08-18,2026-02-19,100.0,Daglig,Aksjer,Global,,False,False,False,False,Global,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,SEB Global Sustainable Companies A (SEK),SE0022574349,SEK,Global Equity Large Cap,0.4,,4.0,Artikkel 9,80.0,75.0,-1.05,-1.64,1.62,9.46,7.93,11.59,,,,SEB Funds AB,124.58,2026-08-18,2024-08-21,100.0,Daglig,Aksjer,Global,,False,False,True,False,Global / tilt,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,SpareBank 1 Indeks Global N,NO0012619040,NOK,Global Equity Large Cap,0.25,4.0,4.0,Artikkel 8,0.0,398.0,-0.82,-1.28,0.85,6.75,5.92,11.08,16.33,,,ODIN Forvaltning AS,189.61,2026-08-18,2022-09-01,100.0,Daglig,Aksjer,Global,,False,False,False,False,Global,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,Spiltan Europafond Investmentbolag,SE0028000067,SEK,Europe Equity Large Cap,0.4,,4.0,Artikkel 8,80.0,819.0,-0.55,-2.41,-0.27,7.79,,,,,,Spiltan Fonder AB,104.45,2026-08-18,2026-05-06,100.0,Daglig,Aksjer,Europa,,False,False,True,False,Europa / tilt,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,Storebrand Europa C NOK,SE0017911100,NOK,Europe Equity Large Cap,0.26,4.0,4.0,Artikkel 8,0.0,839.0,-0.87,-1.65,0.06,8.27,2.68,9.4,14.44,,,Storebrand Asset Management AS,181.4,2026-08-18,2022-11-10,100.0,Daglig,Aksjer,Europa,,False,False,False,False,Europa,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,Storebrand Europe Index N,NO0013711242,NOK,Europe Equity Large Cap,0.39,,4.0,,80.0,30.0,-0.65,-1.59,0.43,8.21,,,,,,Storebrand Asset Management AS,107.71,2026-08-18,2026-03-12,100.0,Daglig,Aksjer,Europa,,False,False,False,False,Europa,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,Storebrand Global Indeks N,NO0010817851,NOK,Global Equity Large Cap,0.25,4.0,4.0,Artikkel 8,80.0,2568.0,-0.55,-0.67,1.6,7.07,6.48,12.18,16.59,12.84,,Storebrand Asset Management AS,2344.38,2026-08-18,2020-08-28,100.0,Daglig,Aksjer,Global,,False,False,False,False,Global,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,Storebrand Global Indeks Valutasikret N,NO0010817877,NOK,Equity Miscellaneous,0.27,,4.0,Artikkel 8,80.0,432.0,-0.7,-0.52,3.05,5.31,13.39,20.85,20.04,11.21,,Storebrand Asset Management AS,2522.54,2026-08-18,2020-05-08,100.0,Daglig,Aksjer,Global,,True,False,False,False,Global / NOK-sikret,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,Storebrand Indeks - Alle Markeder N,NO0010817893,NOK,Global Equity Large Cap,0.25,4.0,4.0,Artikkel 8,85.0,5781.0,-0.82,-1.17,0.89,6.39,6.41,12.35,16.28,12.58,14.07,Storebrand Asset Management AS,3004.88,2026-08-18,2018-07-10,100.0,Daglig,Aksjer,Global,,False,False,False,False,Global,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,Storebrand Indeks - Norden N,NO0010947153,NOK,Europe Equity Large Cap,0.25,4.0,4.0,Artikkel 8,80.0,428.0,-0.17,-0.01,1.09,5.47,2.26,12.86,11.48,5.39,,Storebrand Asset Management AS,1498.86,2026-08-18,2021-04-15,100.0,Daglig,Aksjer,Norden,,False,False,False,False,Norden,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,Storebrand Indeks - Nye Markeder N,NO0010817919,NOK,Global Emerging Markets Equity,0.35,4.0,4.0,Artikkel 8,75.0,3484.0,-1.06,0.75,1.95,4.57,13.76,24.18,18.71,10.52,10.4,Storebrand Asset Management AS,2440.82,2026-08-18,2018-10-10,100.0,Daglig,Aksjer,Fremvoksende,,False,False,False,False,Fremvoksende,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,Storebrand Indeks Norge N,NO0010817778,NOK,Europe Equity Large Cap,0.25,3.0,4.0,Artikkel 8,80.0,2491.0,0.2,2.42,6.76,3.53,23.75,27.88,18.3,12.5,13.12,Storebrand Asset Management AS,2395.44,2026-08-18,2020-08-28,100.0,Daglig,Aksjer,Norge,,False,False,False,False,Norge,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,Storebrand Japan C NOK,SE0017911126,NOK,Japan Equity,0.26,3.0,5.0,Artikkel 8,0.0,758.0,-1.76,-0.37,4.44,11.39,14.62,17.75,16.57,,,Storebrand Asset Management AS,188.24,2026-08-18,2022-11-10,100.0,Daglig,Aksjer,Japan,,False,False,False,False,Japan,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,Storebrand Sverige Småbolag Plus C NOK,SE0017911209,NOK,Europe Equity Mid/Small Cap,0.56,3.0,5.0,Artikkel 8,0.0,45.0,-0.8,-2.04,-0.46,3.54,-8.51,-6.13,8.99,,,Storebrand Asset Management AS,123.48,2026-08-18,2022-11-10,100.0,Daglig,Aksjer,Sverige,,False,True,True,False,Sverige / SmallCap / tilt,2ac4a720-fond_19.8.2026_2120.csv +2026-08-18,Storebrand USA C NOK,SE0017911142,NOK,US Equity Large Cap Blend,0.25,3.0,4.0,Artikkel 8,0.0,164.0,-0.83,-1.51,0.12,5.95,5.64,10.31,16.09,,,Storebrand Asset Management AS,181.91,2026-08-18,2022-11-10,100.0,Daglig,Aksjer,USA,,False,False,False,False,USA,2ac4a720-fond_19.8.2026_2120.csv diff --git a/fond/ingest.py b/fond/ingest.py new file mode 100644 index 0000000..09239b7 --- /dev/null +++ b/fond/ingest.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""Legg månedlige Nordnet-eksporter inn i ett voksende panel. + + python3 fond/ingest.py nedlastinger/fond_*.csv + +Panelet (fond/data/snapshots.csv) er én rad per (snapshot-dato, ISIN). +Kjøringen er idempotent: samme fil to ganger gir ingen duplikater, og en +snapshot-dato som allerede finnes blir overskrevet av den nye lesningen +i stedet for lagt til. +""" + +from __future__ import annotations + +import argparse +import csv +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from nordnet import FIELDNAMES, load_overrides, parse_file, snapshot_records # noqa: E402 + +DEFAULT_PANEL = Path(__file__).resolve().parent / "data" / "snapshots.csv" +DEFAULT_OVERRIDES = Path(__file__).resolve().parent / "overrides.csv" + + +def load_panel(path: Path) -> 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)