Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@ industry-benchmark/
│ ├── config.py # Bransjedefinisjoner, tickerlister, feltnavn-aliaser
│ ├── data_fetcher.py # Henter og cacher finansdata via yfinance
│ ├── metrics_engine.py # Beregner nøkkeltall per selskap
│ └── analysis.py # Ranking, kvartiler, 2x2-matrise, divergens-styling
│ ├── analysis.py # Ranking, kvartiler, 2x2-matrise, divergens-styling
│ └── dcf_inputs.py # Datainnsamling for DCF-analyse (ikke selve DCF-modellen)
├── tests/ # Enhetstester (pytest) for src/-modulene
├── data/
│ ├── raw/ # Cachet API-respons per selskap (JSON)
Expand All @@ -81,6 +82,7 @@ Applikasjonen er delt i fire lag med tydelig ansvarsfordeling:
- **`data_fetcher.py`** henter rådata (resultatregnskap, balanse, kurshistorikk) for en gitt ticker og cacher resultatet lokalt i 24 timer, slik at gjentatte kjøringer ikke belaster API-et unødig.
- **`metrics_engine.py`** tar imot rådata og regner ut et konsistent sett nøkkeltall per selskap. Siden yfinance bruker ulike feltnavn for samme konsept på tvers av selskaper og markeder, forsøker motoren flere kandidatnavn (`FIELD_ALIASES`) før den gir opp og returnerer `None` for en metrikk fremfor å feile.
- **`analysis.py`** inneholder all sammenlignende logikk: rangering, kvartilinndeling, 2x2-kvadrant-klassifisering og z-score-basert divergensfarging. Dette laget vet ingenting om hvordan tallene skal presenteres — kun hvordan de skal sammenlignes.
- **`dcf_inputs.py`** samler inn og utleder inputverdier til en fremtidig DCF-analyse (kapitalstruktur, effektiv skattesats, historisk fri kontantstrøm), fra den samme cachede rådataen som `metrics_engine.py` bruker. Selve DCF-beregningen (fremskrivning, terminalverdi, nåverdi) er bevisst ikke med ennå — dette er kun datagrunnlaget.
- **`app.py`** binder dataflyten sammen til et interaktivt Streamlit-grensesnitt, og har ikke egen forretningslogikk utover presentasjon.

Denne separasjonen gjør det enkelt å bytte ut presentasjonslaget (f.eks. til et automatisk generert PDF-sammendrag) uten å røre beregningslogikken, eller å bytte datakilde uten å endre hvordan nøkkeltall beregnes.
Expand Down Expand Up @@ -119,7 +121,7 @@ pip install -r requirements-dev.txt
pytest
```

Dette gir en dekningsrapport i terminalen (linjer testet i prosent per fil) og en detaljert HTML-rapport i `htmlcov/index.html`. Ved siste kjøring var dekningen på `src/` **85 %**.
Dette gir en dekningsrapport i terminalen (linjer testet i prosent per fil) og en detaljert HTML-rapport i `htmlcov/index.html`. Ved siste kjøring var dekningen på `src/` **89 %**.

## Legge til en ny bransje

Expand All @@ -140,14 +142,16 @@ Tickere følger Yahoo Finance-konvensjon (`.OL` for Oslo Børs, ingen suffiks fo
- **ROIC-beregningen er forenklet** (EBIT / siste rapporterte investert kapital, ujustert for skatt og periodegjennomsnitt).
- **Divergens-fargingen krever reell spredning** i utvalget — med svært få selskaper (3–4) kan én ekstrem uteligger gjøre fargeskalaen mindre informativ, selv om z-scoren klippes for å begrense effekten.
- **Data er ikke revidert** mot offisielle årsrapporter og bør kun brukes til indikativ screening, ikke som beslutningsgrunnlag.
- **DCF-markedsforutsetninger er hardkodet** (`DCF_MARKET_ASSUMPTIONS` i `config.py`) siden risikofri rente og aksjerisikopremie ikke finnes i yfinance — må oppdateres manuelt.

## Videre arbeid

Mulige utvidelser inkluderer:

- Erstatte de regelbaserte nøkkelfunnene med LLM-genererte innsikter basert på metrics-tabellen
- Automatisk generert «executive summary» som PDF eller PowerPoint
- Historisk trendvisning av margin og vekst over flere år, ikke bare siste rapporterte periode
- Selve DCF-modellen (fremskrivning av kontantstrømmer, WACC, terminalverdi) bygget på toppen av `dcf_inputs.py`
- Redigerbar exit EV/EBITDA-tabell for verdsettelsessensitivitet
- Bytte datakilde til et mer strukturert API (f.eks. Financial Modeling Prep) for bedre feltkonsistens og dypere historikk

## Bidragsytere
Expand Down
18 changes: 18 additions & 0 deletions src/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,24 @@
"Common Stock Equity",
"Total Stockholder Equity",
],
# Lagt til for DCF-datainnsamlingen (src/dcf_inputs.py)
"free_cash_flow": ["Free Cash Flow"],
"operating_cash_flow": ["Operating Cash Flow"],
"capital_expenditures": ["Capital Expenditure", "Capital Expenditures"],
"interest_expense": ["Interest Expense", "Interest Expense Non Operating"],
"pretax_income": ["Pretax Income", "Income Before Tax"],
"tax_provision": ["Tax Provision", "Income Tax Expense"],
"effective_tax_rate": ["Tax Rate For Calcs"],
}

CACHE_MAX_AGE_DAYS = 1 # hvor lenge cachet data regnes som "fersk"

# DCF-forutsetninger som IKKE finnes i yfinance og må settes manuelt.
# Brukes foreløpig ikke av noen kode ennå — dette er bare datagrunnlaget
# for når selve DCF-modellen bygges (egen issue for det senere).
# Tallene under er grove startpunkter for det norske markedet (NOK), ikke
# ferdig fasit — bør oppdateres manuelt når DCF-modellen faktisk tas i bruk.
DCF_MARKET_ASSUMPTIONS = {
"risk_free_rate": 0.035, # ca. norsk 10-årig statsobligasjonsrente
"equity_risk_premium": 0.055, # historisk meravkastning aksjer vs. risikofri rente
}
185 changes: 185 additions & 0 deletions src/dcf_inputs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
from __future__ import annotations

"""
Datainnsamling for DCF-analyse (diskontert kontantstrøm).
VIKTIG: dette er KUN innsamling og enkel utledning av inputverdier —
ikke selve DCF-beregningen (fremskrivning av kontantstrømmer,
terminalverdi, nåverdi av kontantstrømmer osv). Den delen er bevisst
ute av scope her og kommer i en egen, senere issue.
Gjenbruker samme mønster som metrics_engine.py: feltnavn varierer
mellom selskaper i yfinance, så vi prøver flere kandidatnavn via
FIELD_ALIASES (se config.py) før vi gir opp og returnerer None.
"""

import io
from dataclasses import dataclass, asdict

import pandas as pd

from src.metrics_engine import _get_row, _latest_value, _safe_div, _value_for_period


@dataclass
class DcfInputs:
ticker: str
name: str
currency: str | None
market_cap: float | None
total_debt: float | None
net_debt: float | None
beta: float | None
effective_tax_rate: float | None
cost_of_debt: float | None
latest_free_cash_flow: float | None


def compute_dcf_inputs(raw_data: dict) -> DcfInputs:
"""
Samler inn og utleder nøkkelinput en DCF-analyse trenger for ett
selskap: kapitalstruktur (brukes til WACC), effektiv skattesats, og
siste frie kontantstrøm. Bruker rådata som allerede er hentet og
cachet av data_fetcher — ingen nye nettverkskall.
"""
ticker = raw_data["ticker"]
info = raw_data.get("info", {}) or {}

try:
financials_text = io.StringIO(raw_data["financials"])
balance_sheet_text = io.StringIO(raw_data["balance_sheet"])
cashflow_text = io.StringIO(raw_data["cashflow"])

financials = pd.read_json(financials_text)
balance_sheet = pd.read_json(balance_sheet_text)
cashflow = pd.read_json(cashflow_text)
except Exception:
financials = pd.DataFrame()
balance_sheet = pd.DataFrame()
cashflow = pd.DataFrame()

total_debt_row = _get_row(balance_sheet, "total_debt")
cash_row = _get_row(balance_sheet, "cash")
interest_expense_row = _get_row(financials, "interest_expense")
tax_provision_row = _get_row(financials, "tax_provision")
pretax_income_row = _get_row(financials, "pretax_income")
effective_tax_rate_row = _get_row(financials, "effective_tax_rate")
free_cash_flow_row = _get_row(cashflow, "free_cash_flow")
operating_cash_flow_row = _get_row(cashflow, "operating_cash_flow")
capital_expenditures_row = _get_row(cashflow, "capital_expenditures")

latest_total_debt = _latest_value(total_debt_row)
latest_cash = _latest_value(cash_row)
latest_interest_expense = _latest_value(interest_expense_row)
latest_tax_provision = _latest_value(tax_provision_row)
latest_pretax_income = _latest_value(pretax_income_row)
latest_effective_tax_rate = _latest_value(effective_tax_rate_row)
latest_free_cash_flow = _latest_value(free_cash_flow_row)
latest_operating_cash_flow = _latest_value(operating_cash_flow_row)
latest_capital_expenditures = _latest_value(capital_expenditures_row)

net_debt = None
if latest_total_debt is not None and latest_cash is not None:
net_debt = latest_total_debt - latest_cash

cost_of_debt = _safe_div(latest_interest_expense, latest_total_debt)

# Bruk yfinance sin egen effektive skattesats hvis den finnes, ellers
# regn den ut selv fra skatt betalt / resultat før skatt.
effective_tax_rate = latest_effective_tax_rate
if effective_tax_rate is None:
effective_tax_rate = _safe_div(latest_tax_provision, latest_pretax_income)

# yfinance regner som regel ut Free Cash Flow direkte for oss. Finnes
# ikke den linjen, regner vi den ut selv: driftskontantstrøm pluss
# investeringer (Capital Expenditure er som regel negativ i yfinance,
# altså penger ut av selskapet, derfor "pluss" og ikke "minus").
free_cash_flow = latest_free_cash_flow
if free_cash_flow is None:
if latest_operating_cash_flow is not None and latest_capital_expenditures is not None:
free_cash_flow = latest_operating_cash_flow + latest_capital_expenditures

return DcfInputs(
ticker=ticker,
name=info.get("longName", ticker),
currency=info.get("currency"),
market_cap=info.get("marketCap"),
total_debt=latest_total_debt,
net_debt=net_debt,
beta=info.get("beta"),
effective_tax_rate=effective_tax_rate,
cost_of_debt=cost_of_debt,
latest_free_cash_flow=free_cash_flow,
)


def build_dcf_inputs_table(industry_data: dict[str, dict]) -> pd.DataFrame:
"""Bygger en samlet DataFrame med DCF-input for alle selskaper i en bransje."""
rows = []
for data in industry_data.values():
inputs = compute_dcf_inputs(data)
rows.append(asdict(inputs))

df = pd.DataFrame(rows)
return df


def build_fcf_trend(raw_data: dict) -> pd.DataFrame:
"""
Bygger en tabell med fri kontantstrøm (FCF) per år selskapet har
rapportert, eldste år først. Til bruk som grunnlag for å fremskrive
kontantstrømmer når selve DCF-modellen bygges (egen, senere issue).
Bruker rådata som allerede er hentet og cachet — ingen nye
nettverkskall.
"""
try:
cashflow_text = io.StringIO(raw_data["cashflow"])
cashflow = pd.read_json(cashflow_text)
except Exception:
cashflow = pd.DataFrame()

free_cash_flow = _get_row(cashflow, "free_cash_flow")
operating_cash_flow = _get_row(cashflow, "operating_cash_flow")
capital_expenditures = _get_row(cashflow, "capital_expenditures")

periods = []
if free_cash_flow is not None:
periods = list(free_cash_flow.index)
elif operating_cash_flow is not None:
periods = list(operating_cash_flow.index)

rows = []
for period in periods:
fcf_value = _value_for_period(free_cash_flow, period)

if fcf_value is None:
operating_value = _value_for_period(operating_cash_flow, period)
capex_value = _value_for_period(capital_expenditures, period)
if operating_value is not None and capex_value is not None:
fcf_value = operating_value + capex_value

rows.append({"period": period, "free_cash_flow": fcf_value})

trend_df = pd.DataFrame(rows)

if trend_df.empty:
return trend_df

# yfinance sorterer kolonner nyest -> eldst. Snu rekkefølgen slik at
# tabellen leses fra eldste til nyeste år, samme konvensjon som
# build_historical_trend() i metrics_engine.py.
trend_df = trend_df.iloc[::-1].reset_index(drop=True)

return trend_df


if __name__ == "__main__":
# Rask manuell test: python -m src.dcf_inputs
from src.config import INDUSTRIES
from src.data_fetcher import fetch_industry

sample = INDUSTRIES["norsk_sjomat"]["tickers"][:3]
raw = fetch_industry(sample)
df = build_dcf_inputs_table(raw)
print(df.to_string())
129 changes: 129 additions & 0 deletions tests/test_dcf_inputs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import pandas as pd

from src.dcf_inputs import (
DcfInputs,
compute_dcf_inputs,
build_dcf_inputs_table,
build_fcf_trend,
)


def _raw(ticker="MOWI.OL", info=None, financials=None, balance_sheet=None, cashflow=None):
"""Bygger en minimal rådata-dict slik data_fetcher ville produsert den."""
financials_df = pd.DataFrame(financials or {})
balance_df = pd.DataFrame(balance_sheet or {})
cashflow_df = pd.DataFrame(cashflow or {})
return {
"ticker": ticker,
"info": info or {},
"financials": financials_df.to_json(),
"balance_sheet": balance_df.to_json(),
"cashflow": cashflow_df.to_json(),
"history": "{}",
}


# --- compute_dcf_inputs -----------------------------------------------------

def test_compute_dcf_inputs_full_data():
financials = {
"2023": {
"Interest Expense": 50,
"Tax Provision": 20,
"Pretax Income": 100,
},
}
balance_sheet = {
"2023": {"Total Debt": 1000, "Cash": 200},
}
cashflow = {
"2023": {"Free Cash Flow": 300},
}
info = {
"longName": "Mowi ASA",
"currency": "NOK",
"marketCap": 90_000,
"beta": 0.6,
}

raw = _raw(info=info, financials=financials, balance_sheet=balance_sheet, cashflow=cashflow)
inputs = compute_dcf_inputs(raw)

assert isinstance(inputs, DcfInputs)
assert inputs.name == "Mowi ASA"
assert inputs.currency == "NOK"
assert inputs.total_debt == 1000
assert inputs.net_debt == 1000 - 200
assert inputs.beta == 0.6
assert inputs.cost_of_debt == 50 / 1000
assert inputs.effective_tax_rate == 20 / 100 # ingen "Tax Rate For Calcs" i testdataen
assert inputs.latest_free_cash_flow == 300


def test_compute_dcf_inputs_prefers_yfinance_tax_rate_when_present():
financials = {
"2023": {
"Tax Provision": 20,
"Pretax Income": 100,
"Tax Rate For Calcs": 0.25, # skal brukes i stedet for 20/100
},
}
raw = _raw(financials=financials)

inputs = compute_dcf_inputs(raw)
assert inputs.effective_tax_rate == 0.25


def test_compute_dcf_inputs_falls_back_to_operating_cash_flow_minus_capex():
cashflow = {
"2023": {"Operating Cash Flow": 500, "Capital Expenditure": -100},
}
raw = _raw(cashflow=cashflow) # ingen "Free Cash Flow"-linje

inputs = compute_dcf_inputs(raw)
assert inputs.latest_free_cash_flow == 500 + (-100)


def test_compute_dcf_inputs_missing_data_returns_none_gracefully():
raw = _raw(info={"longName": "Tomt Selskap"})
inputs = compute_dcf_inputs(raw)

assert inputs.name == "Tomt Selskap"
assert inputs.total_debt is None
assert inputs.net_debt is None
assert inputs.cost_of_debt is None
assert inputs.effective_tax_rate is None
assert inputs.latest_free_cash_flow is None


def test_build_dcf_inputs_table_one_row_per_company():
raw1 = _raw(ticker="AAA.OL", info={"longName": "A"})
raw2 = _raw(ticker="BBB.OL", info={"longName": "B"})

df = build_dcf_inputs_table({"AAA.OL": raw1, "BBB.OL": raw2})

assert len(df) == 2
assert set(df["ticker"]) == {"AAA.OL", "BBB.OL"}


# --- build_fcf_trend ---------------------------------------------------------

def test_build_fcf_trend_oldest_first():
cashflow = {
"2023": {"Free Cash Flow": 300},
"2022": {"Free Cash Flow": 250},
"2021": {"Free Cash Flow": 200},
}
raw = _raw(cashflow=cashflow)

trend = build_fcf_trend(raw)

assert len(trend) == 3
assert trend.iloc[0]["free_cash_flow"] == 200 # eldste år først
assert trend.iloc[-1]["free_cash_flow"] == 300 # nyeste år sist


def test_build_fcf_trend_missing_cashflow_returns_empty():
raw = _raw(cashflow=None)
trend = build_fcf_trend(raw)
assert trend.empty