diff --git a/README.md b/README.md index f43c97d..0c4469c 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Prosjektet er bygget for å øve på å gå fra rådata til strukturert forretni - **Divergens-basert ranking-tabell** – selskaper fargelegges etter hvor mye de avviker fra bransjesnittet (z-score), ikke bare etter rangeringsplass, med automatisk snudd fargeskala for metrikker der lavere er bedre - **Kvartil-heatmap** – rask oversikt over hvert selskaps relative styrke på tvers av alle nøkkeltall samtidig - **Autogenererte nøkkelfunn** – kortfattede tekstlige innsikter basert på de beregnede tallene -- **Klikkbar selskapsnavigasjon** – klikk på et selskap i listen øverst til høyre, eller på dets prikk i posisjoneringsmatrisen, for å åpne en dedikert side for det selskapet (foreløpig et skjelett — full selskapsinfo og dypere analyse kommer i en senere oppdatering) +- **Dedikert selskapsside (dypdykk)** – klikk på et selskap i listen øverst til høyre, eller på dets prikk i posisjoneringsmatrisen, for å åpne en egen side med flerårig historikk for omsetning, EBITDA, EBIT, nettoresultat og EBITDA-margin (selskapsinfo/guidance, f.eks. bransje og ansatte, kommer i en senere oppdatering) ## Skjermbilder diff --git a/app.py b/app.py index 3f76c1c..af35851 100644 --- a/app.py +++ b/app.py @@ -9,7 +9,7 @@ from src.config import INDUSTRIES from src.data_fetcher import fetch_industry -from src.metrics_engine import build_metrics_table +from src.metrics_engine import build_metrics_table, build_historical_trend from src.analysis import ( rank_companies, build_quartile_heatmap_data, @@ -51,13 +51,17 @@ metrics_df = build_metrics_table(raw_data) -def render_company_page(ticker: str, metrics_df: pd.DataFrame) -> None: +def render_company_page(ticker: str, metrics_df: pd.DataFrame, raw_data: dict) -> None: """ - Dedicated single-company page, reached by clicking a company in the - top-right list or clicking its dot in the positioning matrix. - - Intentionally minimal for now — full company info, guidance and - deeper analysis will be added in a follow-up (see issue #5). + Dedicated single-company page (dypdykk-side), reached by clicking a + company in the top-right list or clicking its dot in the positioning + matrix. + + Viser flerårig historikk (omsetning, EBITDA, EBIT, nettoresultat og + EBITDA-margin) i tillegg til navn/ticker. Bruker rådata som allerede + er hentet og cachet av fetch_industry — ingen nye nettverkskall. + Selskapsinfo/guidance (f.eks. bransje, nettside, ansatte) er fortsatt + ikke med her — det er et eget, senere steg. """ if st.button("← Tilbake til oversikt"): st.session_state.pop("selected_ticker", None) @@ -68,15 +72,83 @@ def render_company_page(ticker: str, metrics_df: pd.DataFrame) -> None: st.title(f"🏢 {company_name}") st.caption(ticker) - st.info( - "Dette er den dedikerte siden for dette selskapet. Selskapsinfo, " - "guidance og dypere analyse kommer her i en senere oppdatering." + + company_raw_data = raw_data.get(ticker, {}) + trend_df = build_historical_trend(company_raw_data) + + if trend_df.empty: + st.warning("Fant ikke nok historiske regnskapstall for dette selskapet.") + return + + # "period" fra yfinance er som regel en hel dato (f.eks. 2023-12-31). + # Vi lager en enkel år-tekst av den, til bruk både i grafen og + # tabellen under, i stedet for å vise hele datoen. + year_labels = [] + for period_value in trend_df["period"]: + if hasattr(period_value, "strftime"): + year_labels.append(period_value.strftime("%Y")) + else: + year_labels.append(str(period_value)) + trend_df["year_label"] = year_labels + + st.subheader("Historisk utvikling") + + trend_fig = px.line( + trend_df, + x="year_label", + y=["revenue", "ebitda", "ebit", "net_income"], + markers=True, + labels={ + "year_label": "År", + "value": "Beløp", + "variable": "Regnskapspost", + }, ) + # Vis hvert år som egen kategori i stedet for en kontinuerlig + # datoakse, slik at vi ikke får unødvendige månedsmerker mellom årene. + trend_fig.update_xaxes(type="category") + trend_fig.update_layout(height=450) + st.plotly_chart(trend_fig, use_container_width=True) + st.caption("Omsetning, EBITDA, EBIT og nettoresultat per rapportert år.") + + st.subheader("Tallene i tabellform") + + # Bygger tabellen rad for rad med ferdigformaterte tekststrenger, i + # stedet for å stole på at Streamlit viser pandas Styler sin + # na_rep-formatering riktig for manglende tall (det gjorde den ikke). + table_rows = [] + for i in range(len(trend_df)): + row = trend_df.iloc[i] + table_rows.append({ + "År": row["year_label"], + "Omsetning": _format_amount(row["revenue"]), + "EBITDA": _format_amount(row["ebitda"]), + "EBIT": _format_amount(row["ebit"]), + "Nettoresultat": _format_amount(row["net_income"]), + "EBITDA-margin": _format_percent(row["ebitda_margin"]), + }) + + display_trend_df = pd.DataFrame(table_rows) + st.dataframe(display_trend_df, use_container_width=True, hide_index=True) + + +def _format_amount(value) -> str: + """Formaterer et kronebeløp med tusenskille, eller '–' hvis det mangler.""" + if pd.isna(value): + return "–" + return f"{value:,.0f}" + + +def _format_percent(value) -> str: + """Formaterer en andel som prosent, eller '–' hvis den mangler.""" + if pd.isna(value): + return "–" + return f"{value:.1%}" selected_ticker = st.session_state.get("selected_ticker") if selected_ticker: - render_company_page(selected_ticker, metrics_df) + render_company_page(selected_ticker, metrics_df, raw_data) st.stop() # ---------- Header + selskapsliste ---------- diff --git a/src/metrics_engine.py b/src/metrics_engine.py index c332f0c..b9e9537 100644 --- a/src/metrics_engine.py +++ b/src/metrics_engine.py @@ -171,6 +171,75 @@ def build_metrics_table(industry_data: dict[str, dict]) -> pd.DataFrame: return df +def build_historical_trend(raw_data: dict) -> pd.DataFrame: + """ + Bygger en tabell med omsetning, EBITDA, EBIT og nettoresultat for + hvert år selskapet har rapportert, til bruk på selskapets + dypdykk-side (se app.py). + + Bruker den samme rådataen som allerede er hentet og cachet av + data_fetcher — ingen nye nettverkskall her. + """ + try: + financials_text = io.StringIO(raw_data["financials"]) + financials = pd.read_json(financials_text) + except Exception: + financials = pd.DataFrame() + + revenue = _get_row(financials, "revenue") + ebitda = _get_row(financials, "ebitda") + ebit = _get_row(financials, "ebit") + net_income = _get_row(financials, "net_income") + + # Finn ut hvilke perioder (år) vi faktisk har tall for. Bruker + # omsetningslinjen som utgangspunkt siden den sjeldnest mangler. + periods = [] + if revenue is not None: + periods = list(revenue.index) + elif ebitda is not None: + periods = list(ebitda.index) + + rows = [] + for period in periods: + row = { + "period": period, + "revenue": _value_for_period(revenue, period), + "ebitda": _value_for_period(ebitda, period), + "ebit": _value_for_period(ebit, period), + "net_income": _value_for_period(net_income, period), + } + rows.append(row) + + trend_df = pd.DataFrame(rows) + + if trend_df.empty: + return trend_df + + # yfinance sorterer kolonner nyest -> eldst. Snu rekkefølgen slik at + # en tidslinje-graf leses fra venstre (eldst) til høyre (nyest). + trend_df = trend_df.iloc[::-1].reset_index(drop=True) + + # Regn ut EBITDA-margin per år også, i tillegg til kronebeløpene. + ebitda_margins = [] + for i in range(len(trend_df)): + revenue_value = trend_df.loc[i, "revenue"] + ebitda_value = trend_df.loc[i, "ebitda"] + margin = _safe_div(ebitda_value, revenue_value) + ebitda_margins.append(margin) + trend_df["ebitda_margin"] = ebitda_margins + + return trend_df + + +def _value_for_period(series: pd.Series | None, period) -> float | None: + """Henter verdien for én bestemt periode fra en regnskapslinje, trygt.""" + if series is None: + return None + if period not in series.index: + return None + return series[period] + + if __name__ == "__main__": # Rask manuell test: python -m src.metrics_engine from src.config import INDUSTRIES diff --git a/tests/test_metrics_engine.py b/tests/test_metrics_engine.py index c65f71e..fcbe0ed 100644 --- a/tests/test_metrics_engine.py +++ b/tests/test_metrics_engine.py @@ -8,6 +8,7 @@ calculate_cagr, compute_metrics, build_metrics_table, + build_historical_trend, ) @@ -188,3 +189,43 @@ def test_build_metrics_table_one_row_per_company(): assert len(df) == 2 assert set(df["ticker"]) == {"AAA.OL", "BBB.OL"} + + +# --- build_historical_trend ------------------------------------------------- + +def test_build_historical_trend_oldest_first(): + # yfinance sorterer kolonner nyest -> eldst; build_historical_trend + # skal snu rekkefølgen slik at eldste år kommer først. + financials = { + "2023": {"Total Revenue": 1000, "EBITDA": 200, "EBIT": 150, "Net Income": 80}, + "2022": {"Total Revenue": 900, "EBITDA": 180, "EBIT": 130, "Net Income": 60}, + "2021": {"Total Revenue": 800, "EBITDA": 150, "EBIT": 100, "Net Income": 40}, + } + raw = _raw(financials=financials) + + trend = build_historical_trend(raw) + + assert len(trend) == 3 + assert trend.iloc[0]["revenue"] == 800 # eldste år først + assert trend.iloc[-1]["revenue"] == 1000 # nyeste år sist + + +def test_build_historical_trend_computes_ebitda_margin_per_year(): + financials = { + "2023": {"Total Revenue": 1000, "EBITDA": 200, "EBIT": 150, "Net Income": 80}, + "2022": {"Total Revenue": 900, "EBITDA": 180, "EBIT": 130, "Net Income": 60}, + } + raw = _raw(financials=financials) + + trend = build_historical_trend(raw) + + # Rad 0 = eldste år (2022), rad 1 = nyeste år (2023) etter snuingen. + row_2023 = trend.iloc[1] + assert row_2023["revenue"] == 1000 + assert row_2023["ebitda_margin"] == 200 / 1000 + + +def test_build_historical_trend_missing_financials_returns_empty(): + raw = _raw(financials=None) + trend = build_historical_trend(raw) + assert trend.empty