Files
mmd/signals.py

355 lines
14 KiB
Python
Raw Normal View History

2026-05-26 22:21:27 +02:00
"""
signals.py C25 Signal Board & Reporting
Usage:
python3 signals.py # full signal board (last 7 days)
python3 signals.py top [--days 14] # top companies by mentions
python3 signals.py company NOVO-B # detail view for one company
python3 signals.py summary # quick one-liner per company
python3 signals.py buy # only budget/accessible + leveraged ETPs
"""
import sys
import json
import time
import argparse
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
import yfinance as yf
from db import get_conn, DBConn
C25_PATH = Path(__file__).parent / "c25.json"
_c25_raw = json.loads(C25_PATH.read_text())
C25: dict[str, dict] = {k: v for k, v in _c25_raw.items() if not k.startswith("_")}
_analyst_cache: dict[str, dict] = {}
def get_db() -> DBConn:
"""Return a DBConn wrapper. Schema is managed by db.py."""
return get_conn()
def analyst_rec(ticker: str) -> dict:
"""Hent analytikernes konsensus fra Yahoo Finance (cachet pr. kørsel)."""
if ticker in _analyst_cache:
return _analyst_cache[ticker]
yf_ticker = C25.get(ticker, {}).get("ticker_yahoo", ticker + ".CO")
try:
info = yf.Ticker(yf_ticker).info
mean = info.get("recommendationMean")
count = info.get("numberOfAnalystOpinions", 0)
if mean is None:
result = {"label": "Ukendt", "mean": None, "count": 0}
elif mean <= 1.5: result = {"label": "STÆRK KØB 🟢", "mean": mean, "count": count}
elif mean <= 2.5: result = {"label": "KØB 🟢", "mean": mean, "count": count}
elif mean <= 3.5: result = {"label": "HOLD 🟡", "mean": mean, "count": count}
elif mean <= 4.5: result = {"label": "SÆLG 🔴", "mean": mean, "count": count}
else: result = {"label": "STÆRK SÆLG 🔴", "mean": mean, "count": count}
except Exception:
result = {"label": "Ukendt", "mean": None, "count": 0}
_analyst_cache[ticker] = result
return result
SENTIMENT_DA = {"positive": "POSITIV ↑", "negative": "NEGATIV ↓", "neutral": "NEUTRAL →"}
TIER_DA = {
"budget": "under 200 kr — kan købes direkte",
"accessible": "200-500 kr — kan købes direkte",
"expensive": "500-2000 kr — overvej ETP",
"inaccessible": "over 2000 kr — brug ETP",
}
# ---------------------------------------------------------------------------
# Queries
# ---------------------------------------------------------------------------
def company_stats(db: DBConn, days: int = 7) -> list[dict]:
"""Return per-company aggregated signal stats for the last N days."""
cutoff = int(time.time()) - days * 86400
rows = db.execute(
"""SELECT
s.ticker,
COUNT(*) AS mention_articles,
SUM(s.mention_count) AS total_mentions,
AVG(CASE WHEN s.sentiment='positive' THEN s.sentiment_score
WHEN s.sentiment='negative' THEN -s.sentiment_score
ELSE 0 END) AS avg_sentiment,
SUM(CASE WHEN s.sentiment='positive' THEN 1 ELSE 0 END) AS pos_count,
SUM(CASE WHEN s.sentiment='negative' THEN 1 ELSE 0 END) AS neg_count,
SUM(CASE WHEN s.sentiment='neutral' THEN 1 ELSE 0 END) AS neu_count,
AVG(a.source_count) AS avg_sources,
MAX(COALESCE(s.signal_score, 0)) AS max_signal,
SUM(COALESCE(s.alert, 0)) AS alert_count
FROM article_signals s
JOIN articles a ON a.slug = s.article_slug
WHERE s.analyzed_at >= ?
GROUP BY s.ticker
ORDER BY max_signal DESC, mention_articles DESC""",
(cutoff,),
).fetchall()
return [dict(r) for r in rows]
def company_articles(db: DBConn, ticker: str, days: int = 14) -> list[dict]:
"""Return individual articles mentioning a company."""
cutoff = int(time.time()) - days * 86400
rows = db.execute(
"""SELECT
a.title, a.slug, a.start_date, a.source_count,
s.sentiment, s.sentiment_score, s.mention_count,
s.signal_score, s.alert, s.claude_reasoning,
s.momentum_dir, s.momentum_pct_5d, s.coverage_spread
FROM article_signals s
JOIN articles a ON a.slug = s.article_slug
WHERE s.ticker = ? AND s.analyzed_at >= ?
ORDER BY COALESCE(s.signal_score,0) DESC, a.source_count DESC""",
(ticker, cutoff),
).fetchall()
return [dict(r) for r in rows]
# ---------------------------------------------------------------------------
# Display helpers
# ---------------------------------------------------------------------------
def print_signal_board(days: int = 7) -> None:
db = get_db()
rows = company_stats(db, days)
db.close()
if not rows:
print(f"Ingen signaler de seneste {days} dage. Kør: make")
return
now_str = datetime.now(timezone.utc).strftime("%d %b %Y %H:%M")
W = 66
print(f"\n{''*W}")
print(f" MONEYMAKER · Hvad sker der med dine C25 aktier? · {now_str}")
print(f"{''*W}")
print(f" (baseret på de seneste {days} dage med nyheder)\n")
for r in rows:
ticker = r["ticker"]
company = C25.get(ticker, {})
name = company.get("name", ticker)
price = company.get("price_dkk_approx", "?")
tier = company.get("tier", "?")
etps = company.get("leveraged", [])
avg_s = r["avg_sentiment"] or 0
sent_da = SENTIMENT_DA.get(
"positive" if avg_s > 0.1 else ("negative" if avg_s < -0.1 else "neutral"),
"NEUTRAL →"
)
n_art = r["mention_articles"]
n_src = int(r["avg_sources"] or 0)
sig = r.get("max_signal") or 0
alerts = r.get("alert_count") or 0
# Analytiker konsensus
rec = analyst_rec(ticker)
# Er nyheder og analytikere enige?
news_bull = avg_s > 0.1
news_bear = avg_s < -0.1
ana_bull = rec["mean"] is not None and rec["mean"] <= 2.5
ana_bear = rec["mean"] is not None and rec["mean"] >= 3.5
if (news_bull and ana_bull) or (news_bear and ana_bear):
agreement = "✅ Nyheder og eksperter er ENIGE"
elif (news_bull and ana_bear) or (news_bear and ana_bull):
agreement = "⚠️ Nyheder og eksperter er UENIGE"
else:
agreement = "〰️ Intet klart signal endnu"
# Kildedækning advarsel
coverage_warn = " ⚠️ Kun få mediekilder — usikkert" if n_src < 3 else ""
# Signal i ord
if sig >= 0.5: sig_txt = f"🔥 STÆRKT ({sig:.2f})"
elif sig >= 0.2: sig_txt = f"📊 Moderat ({sig:.2f})"
elif sig > 0: sig_txt = f"🔍 Svagt ({sig:.2f})"
else: sig_txt = f"⬜ Ingen ({sig:.2f})"
# ETP info
etp_info = ""
if etps and len(etps) >= 2:
long_e = next((e for e in etps if e["direction"] == "long"), etps[0])
short_e = next((e for e in etps if e["direction"] == "short"), etps[1])
etp_info = f"\n │ ETP (gearing 3x): KØB={long_e['ticker']} SÆLG={short_e['ticker']} ({long_e['exchange']})"
alerts_str = f"\n │ ⚡ {alerts} alert(s) udløst!" if alerts else ""
print(f" ┌─ {name} ({ticker}) {''*max(1, W-6-len(name)-len(ticker))}")
print(f" │ Pris: ca. {price} kr · {TIER_DA.get(tier, tier)}")
print(f" │ Eksperter ({rec['count']:>2}): {rec['label']}")
print(f" │ Nyheder ({n_art} art): {sent_da} · gnsn. {n_src} mediekilder{coverage_warn}")
print(f" │ Samlet vurdering: {agreement}")
print(f" │ Signalstyrke: {sig_txt}{etp_info}{alerts_str}")
print(f"{''*W}")
print()
print(f" Forklaring:")
print(f" Eksperter = finansanalytikere der følger aktien (Yahoo Finance)")
print(f" Nyheder = hvad medierne skriver, analyseret med AI (FinBERT + Claude)")
print(f" Signal = kombineret score: nyhedssentiment × kildedækning × kursmomentum")
print(f" ETP = børshandlet certifikat — du køber 3x gearing uden at eje aktien")
print()
def print_company_detail(ticker: str, days: int = 14) -> None:
ticker = ticker.upper()
if ticker not in C25:
print(f"Ukendt ticker: {ticker}. Tilgængelige: {', '.join(sorted(C25.keys()))}")
return
db = get_db()
company = C25[ticker]
stats = next((r for r in company_stats(db, days) if r["ticker"] == ticker), None)
arts = company_articles(db, ticker, days)
db.close()
rec = analyst_rec(ticker)
price = company.get("price_dkk_approx", "?")
tier = company.get("tier", "?")
etps = company.get("leveraged", [])
W = 66
print(f"\n{''*W}")
print(f" {company['name']} ({ticker})")
print(f" Sektor: {company['sector']} · ca. {price} kr · {TIER_DA.get(tier, tier)}")
if etps:
for e in etps:
label = "KØB (long)" if e["direction"] == "long" else "SÆLG (short)"
print(f" ETP {label}: {e['name']} ({e['ticker']}) — {e['exchange']}")
print(f"{''*W}")
print(f"\n Hvad siger eksperterne?")
print(f" {rec['label']} · {rec['count']} analytikere følger aktien")
print(f"\n Hvad siger nyhederne de seneste {days} dage?")
if not stats:
print(f" Ingen nyheder fundet endnu.")
return
avg_s = stats["avg_sentiment"] or 0
sent_da = SENTIMENT_DA.get(
"positive" if avg_s > 0.1 else ("negative" if avg_s < -0.1 else "neutral"), ""
)
print(f" {sent_da} · {stats['mention_articles']} artikler")
print(f" Positiv: {stats['pos_count']} · Negativ: {stats['neg_count']} · Neutral: {stats['neu_count']}")
print(f"\n Enkeltartikler:")
print(f" {''*W}")
for a in arts[:15]:
sent_da2 = SENTIMENT_DA.get(a["sentiment"], "?")
sig = a.get("signal_score") or 0
n_src = a.get("source_count") or 0
low_cov = " ⚠️ få kilder" if n_src < 3 else ""
sig_txt = f"🔥{sig:.2f}" if sig >= 0.5 else (f"📊{sig:.2f}" if sig >= 0.2 else f"{sig:.2f}")
print(f"\n {a['title'][:62]}")
print(f" Sentiment: {sent_da2:<14} Signal: {sig_txt} Kilder: {n_src}{low_cov}")
reason = a.get("claude_reasoning", "")
if reason and reason not in ("(no API key)", ""):
print(f" Claude: {reason[:80]}")
def print_buy_signals(days: int = 7) -> None:
"""Vis kun aktier med positivt signal — direkte køb og via ETP."""
db = get_db()
rows = company_stats(db, days)
db.close()
W = 66
print(f"\n{''*W}")
print(f" HVAD KAN DET BETALE SIG? · de seneste {days} dage")
print(f"{''*W}\n")
direct = [r for r in rows if C25.get(r["ticker"], {}).get("tier") in ("budget", "accessible")]
via_etp = [r for r in rows if C25.get(r["ticker"], {}).get("leveraged")]
def fmt(r: dict, note: str = "") -> None:
ticker = r["ticker"]
c = C25.get(ticker, {})
avg_s = r["avg_sentiment"] or 0
sent = SENTIMENT_DA.get(
"positive" if avg_s > 0.1 else ("negative" if avg_s < -0.1 else "neutral"), "")
rec = analyst_rec(ticker)
price = c.get("price_dkk_approx", "?")
sig = r.get("max_signal") or 0
print(f" {c.get('name',''):<30} {price:>6} kr")
print(f" Eksperter: {rec['label']}")
print(f" Nyheder: {sent} (signal: {sig:.2f})")
if note:
print(f" {note}")
print()
if direct:
print(" Du kan købe disse DIREKTE (overkommelig pris):")
print(f" {''*W}")
for r in sorted(direct, key=lambda x: abs(x["avg_sentiment"] or 0), reverse=True):
fmt(r)
if via_etp:
print(" Via WisdomTree ETP (3x gearing — højere risiko/gevinst):")
print(f" {''*W}")
for r in sorted(via_etp, key=lambda x: abs(x["avg_sentiment"] or 0), reverse=True):
ticker = r["ticker"]
avg_s = r["avg_sentiment"] or 0
etps = C25.get(ticker, {}).get("leveraged", [])
if etps:
direction = "long" if avg_s >= 0 else "short"
etp = next((e for e in etps if e["direction"] == direction), etps[0])
fmt(r, note=f"ETP: {etp['ticker']} ({direction} 3x) på {etp['exchange']}")
else:
fmt(r)
if not direct and not via_etp:
print(" Ingen signaler endnu. Kør: make")
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main() -> None:
parser = argparse.ArgumentParser(description="C25 signal board")
sub = parser.add_subparsers(dest="cmd")
p_top = sub.add_parser("top", help="Top companies by mention count")
p_top.add_argument("--days", type=int, default=7)
p_co = sub.add_parser("company", help="Detail view for one company")
p_co.add_argument("ticker")
p_co.add_argument("--days", type=int, default=14)
sub.add_parser("summary", help="One-liner per company")
p_buy = sub.add_parser("buy", help="Affordable + leveraged ETP candidates")
p_buy.add_argument("--days", type=int, default=7)
p_board = sub.add_parser("board", help="Full signal board")
p_board.add_argument("--days", type=int, default=7)
args = parser.parse_args()
if args.cmd in (None, "board"):
days = getattr(args, "days", 7)
print_signal_board(days)
elif args.cmd == "top":
print_signal_board(args.days)
elif args.cmd == "company":
print_company_detail(args.ticker, getattr(args, "days", 14))
elif args.cmd == "buy":
print_buy_signals(args.days)
elif args.cmd == "summary":
print_signal_board(7)
if __name__ == "__main__":
main()