summaryrefslogtreecommitdiff
path: root/src/teradata/sec.py
diff options
context:
space:
mode:
authorMikka Petterson <m.petterson@student.maastrichtuniversity.nl>2026-10-14 07:45:20 +0200
committerMikka Petterson <m.petterson@student.maastrichtuniversity.nl>2026-10-14 07:45:20 +0200
commit0bcc5be35b6a99109bd04925c22890347c9d6e88 (patch)
treefdd88728ad1747268b0a02aba0de08a3af6a52b2 /src/teradata/sec.py
Initial commitHEADmain
Diffstat (limited to 'src/teradata/sec.py')
-rw-r--r--src/teradata/sec.py233
1 files changed, 233 insertions, 0 deletions
diff --git a/src/teradata/sec.py b/src/teradata/sec.py
new file mode 100644
index 0000000..fbba073
--- /dev/null
+++ b/src/teradata/sec.py
@@ -0,0 +1,233 @@
+"""
+This is by no means a complete implementation of on-disk caching.
+It however serves its purpose for my specific need.
+"""
+
+lazy import re
+
+lazy import pandas as pd
+lazy from bs4 import BeautifulSoup
+lazy from edgar import Company
+
+lazy from .cache import persistent
+
+CONCEPTS = {
+ "revenue": [
+ "RevenueFromContractWithCustomerExcludingAssessedTax",
+ "SalesRevenueNet",
+ "Revenues",
+ ],
+ "net_income": [
+ "NetIncomeLoss",
+ "ProfitLoss",
+ "NetIncomeLossAvailableToCommonStockholdersBasic",
+ ],
+ "eps_basic": ["EarningsPerShareBasic"],
+ "eps_diluted": ["EarningsPerShareDiluted"],
+ "total_assets": ["Assets"],
+}
+
+MONEY = r"\$\s?(\d[\d,]*\.?\d*)\s*(million|billion|M|B)\b"
+ARR_RE = [
+ re.compile(rf"Cloud\s+ARR[^$.]{{0,150}}?{MONEY}", re.I),
+ re.compile(rf"{MONEY}\s+(?:of|in)\s+Cloud\s+ARR", re.I),
+]
+
+
+def clean_facts(sub: pd.DataFrame) -> pd.DataFrame:
+ df = sub.dropna(subset=["period_end", "numeric_value"]).copy()
+ for dim_col in ["dimensions", "segments", "dimension"]:
+ if dim_col in df.columns:
+ df = df[df[dim_col].isna() | (df[dim_col] == "") | (df[dim_col] == "{}")]
+ return df.sort_values("filing_date")
+
+
+def parse_flow_concept(sub: pd.DataFrame) -> pd.Series:
+ sub = clean_facts(sub)
+ if sub.empty:
+ return pd.Series(dtype=float)
+
+ sub = sub.dropna(subset=["period_start"]).copy()
+ sub["days"] = (sub["period_end"] - sub["period_start"]).dt.days
+
+ sub = sub[sub["days"].between(70, 380)]
+
+ sub = sub.sort_values("filing_date").drop_duplicates(
+ ["period_start", "period_end"], keep="last"
+ )
+
+ q_discrete = (
+ sub[sub["days"].between(75, 105)]
+ .set_index("period_end")["numeric_value"]
+ .to_dict()
+ )
+
+ ytd_6m = sub[sub["days"].between(165, 200)]
+ for _, r in ytd_6m.iterrows():
+ pend = r["period_end"]
+ if pend not in q_discrete:
+ q1_match = [
+ v
+ for k, v in q_discrete.items()
+ if 70 <= (pend - k).days <= 105 and k > r["period_start"]
+ ]
+ if q1_match:
+ q_discrete[pend] = r["numeric_value"] - q1_match[0]
+
+ ytd_9m = sub[sub["days"].between(255, 290)]
+ for _, r in ytd_9m.iterrows():
+ pend = r["period_end"]
+ if pend not in q_discrete:
+ in_range_q = [
+ v for k, v in q_discrete.items() if r["period_start"] < k < pend
+ ]
+ if len(in_range_q) >= 2:
+ q_discrete[pend] = r["numeric_value"] - sum(in_range_q[-2:])
+
+ fy_12m = sub[sub["days"].between(350, 380)]
+ for _, r in fy_12m.iterrows():
+ pend = r["period_end"]
+ if pend not in q_discrete:
+ inner_q = [v for k, v in q_discrete.items() if r["period_start"] < k < pend]
+ if len(inner_q) == 3:
+ q_discrete[pend] = r["numeric_value"] - sum(inner_q)
+
+ res = pd.Series(q_discrete).sort_index()
+ res.index = pd.to_datetime(res.index)
+ return res
+
+
+def parse_instant_concept(sub: pd.DataFrame) -> pd.Series:
+ sub = clean_facts(sub)
+ if sub.empty:
+ return pd.Series(dtype=float)
+
+ sub = sub.sort_values(["period_end", "numeric_value"])
+ series = sub.drop_duplicates("period_end", keep="last").set_index("period_end")[
+ "numeric_value"
+ ]
+ series.index = pd.to_datetime(series.index)
+
+ full_idx = pd.date_range(
+ start=series.index.min(), end=series.index.max(), freq="QE"
+ )
+ series = series.reindex(series.index.union(full_idx)).sort_index()
+ return series.ffill().loc[full_idx]
+
+
+def extract_revenue(facts: pd.DataFrame) -> pd.Series:
+ rev_series = pd.Series(dtype=float)
+ for tag in [
+ "RevenueFromContractWithCustomerExcludingAssessedTax",
+ "SalesRevenueNet",
+ "Revenues",
+ ]:
+ sub = facts[facts["concept"].astype(str).str.endswith(tag)]
+ if not sub.empty:
+ parsed = parse_flow_concept(sub)
+ valid = parsed[parsed > 300e6]
+ rev_series = rev_series.combine_first(valid)
+
+ prod_sub = facts[facts["concept"].astype(str).str.endswith("SalesRevenueGoodsNet")]
+ serv_sub = facts[
+ facts["concept"].astype(str).str.endswith("SalesRevenueServicesNet")
+ ]
+
+ if not prod_sub.empty and not serv_sub.empty:
+ p_rev = parse_flow_concept(prod_sub)
+ s_rev = parse_flow_concept(serv_sub)
+ combined_ps = (p_rev + s_rev).dropna()
+ rev_series = rev_series.combine_first(combined_ps)
+
+ return rev_series.sort_index()
+
+
+def cloud_arr(ticker: str, since: str) -> pd.DataFrame:
+ rows = {}
+ for f in sorted(
+ Company(ticker).get_filings(form="8-K", filing_date=f"{since}:"),
+ key=lambda f: f.filing_date,
+ ):
+ if "2.02" not in str(f.items or ""):
+ continue
+ text = " ".join(
+ re.sub(
+ r"\s+", " ", BeautifulSoup(a.download(), "html.parser").get_text(" ")
+ )
+ for a in f.attachments
+ if str(a.document_type).upper().startswith("EX-99")
+ )
+ for pat in ARR_RE:
+ if m := pat.search(text):
+ num, unit = m.groups()[-2:]
+ multiplier = 1e9 if unit[0].lower() == "b" else 1e6
+ val = float(num.replace(",", "")) * multiplier
+ rows[pd.Timestamp(f.filing_date) - pd.offsets.QuarterEnd(1)] = {
+ "cloud_arr": val,
+ "filing_date": f.filing_date,
+ "snippet": text[max(0, m.start() - 40) : m.end() + 60],
+ }
+ break
+ return pd.DataFrame.from_dict(rows, orient="index").sort_index()
+
+
+@persistent
+def get_financials(ticker: str = "TDC", start: str = "2011-01-01") -> pd.DataFrame:
+ facts = Company(ticker).get_facts().to_dataframe(pit_mode=True)
+ for c in ("period_start", "period_end", "filing_date"):
+ if c in facts.columns:
+ facts[c] = pd.to_datetime(facts[c], errors="coerce")
+
+ series = {}
+
+ asset_facts = facts[facts["concept"].astype(str).str.endswith("Assets")]
+ series["total_assets"] = parse_instant_concept(asset_facts)
+
+ series["revenue"] = extract_revenue(facts)
+
+ for name in ["net_income", "eps_basic", "eps_diluted"]:
+ concept_candidates = CONCEPTS[name]
+ combined = pd.Series(dtype=float)
+ for tag in concept_candidates:
+ sub = facts[facts["concept"].astype(str).str.endswith(tag)]
+ if not sub.empty:
+ combined = combined.combine_first(parse_flow_concept(sub))
+ series[name] = combined
+
+ out = pd.DataFrame(series).sort_index()
+
+ out = out.resample("QE").last()
+
+ out["total_assets"] = out["total_assets"].ffill()
+ out["avg_assets"] = out["total_assets"].rolling(2).mean()
+ out["roa_quarterly"] = out["net_income"] / out["avg_assets"]
+ out["roa_annualized"] = out["roa_quarterly"] * 4
+
+ arr = cloud_arr(ticker, since="2020-01-01")
+ if not arr.empty:
+ arr.index = pd.to_datetime(arr.index).to_period("Q").to_timestamp("Q")
+ out["cloud_arr"] = arr["cloud_arr"]
+ out["cloud_arr_yoy"] = out["cloud_arr"].pct_change(4)
+
+ df = out[out.index >= start].copy()
+ df.index.name = "quarter_end"
+ return df
+
+
+@persistent
+def get_periodic_filing_dates(
+ ticker: str = "TDC", start: str = "2011-01-01"
+) -> pd.DataFrame:
+ company = Company(ticker)
+ filings = company.get_filings(
+ form=["10-K", "10-Q"],
+ filing_date=f"{start}:",
+ amendments=False,
+ )
+
+ df = filings.to_pandas()
+ df.columns = [c.lower() for c in df.columns]
+
+ df["filing_date"] = pd.to_datetime(df["filing_date"])
+ df = df.sort_values("filing_date").reset_index(drop=True)
+ return df[["filing_date", "form"]]