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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
94 changes: 83 additions & 11 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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 ----------
Expand Down
69 changes: 69 additions & 0 deletions src/metrics_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 41 additions & 0 deletions tests/test_metrics_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
calculate_cagr,
compute_metrics,
build_metrics_table,
build_historical_trend,
)


Expand Down Expand Up @@ -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