diff --git a/README.md b/README.md index 0c4469c..ff54a5c 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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. @@ -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 @@ -140,6 +142,7 @@ 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 @@ -147,7 +150,8 @@ 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 diff --git a/src/config.py b/src/config.py index bd09ed3..70d8188 100644 --- a/src/config.py +++ b/src/config.py @@ -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 +} diff --git a/src/dcf_inputs.py b/src/dcf_inputs.py new file mode 100644 index 0000000..63b2032 --- /dev/null +++ b/src/dcf_inputs.py @@ -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()) diff --git a/tests/test_dcf_inputs.py b/tests/test_dcf_inputs.py new file mode 100644 index 0000000..06076a2 --- /dev/null +++ b/tests/test_dcf_inputs.py @@ -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