242 lines
8.8 KiB
Python
242 lines
8.8 KiB
Python
"""
|
|
report.py — Daily P&L report for MoneyMaker dry run.
|
|
|
|
Usage:
|
|
python report.py # full P&L report
|
|
python report.py --fees # fee drag analysis only
|
|
python report.py --signals # signal accuracy summary only
|
|
"""
|
|
import sys
|
|
import time
|
|
import json
|
|
import argparse
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
import yfinance as yf
|
|
|
|
from db import get_conn, DB_TYPE
|
|
|
|
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("_")}
|
|
|
|
CAPITAL = 10_000 # DKK — must match portfolio.py
|
|
|
|
|
|
def _c25_day_return() -> float | None:
|
|
"""Fetch today's return for OMX C25 CAP index from Yahoo Finance."""
|
|
try:
|
|
info = yf.Ticker("^OMXC25").fast_info
|
|
prev = info.get("previousClose") or info.get("regularMarketPreviousClose")
|
|
last = info.get("lastPrice") or info.get("regularMarketPrice")
|
|
if prev and last and prev > 0:
|
|
return (last - prev) / prev * 100
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
def _unrealised_pnl(db) -> tuple[float, list[dict]]:
|
|
"""
|
|
Calculate unrealised P&L for all open positions.
|
|
Returns (total_unrealised_dkk, details_list).
|
|
"""
|
|
positions = db.execute("SELECT * FROM positions").fetchall()
|
|
details = []
|
|
total_unreal = 0.0
|
|
|
|
for p in positions:
|
|
ticker = p["ticker"]
|
|
shares = float(p["shares"])
|
|
entry = float(p["entry_price"])
|
|
entry_date = p["entry_date"]
|
|
|
|
yf_ticker = C25.get(ticker, {}).get("ticker_yahoo", ticker + ".CO")
|
|
try:
|
|
last = yf.Ticker(yf_ticker).fast_info.get("lastPrice") or entry
|
|
except Exception:
|
|
last = entry
|
|
|
|
cost = shares * entry
|
|
value = shares * last
|
|
unreal = value - cost
|
|
pct = (unreal / cost * 100) if cost else 0
|
|
total_unreal += unreal
|
|
|
|
details.append({
|
|
"ticker": ticker,
|
|
"shares": shares,
|
|
"entry": entry,
|
|
"last": last,
|
|
"cost": cost,
|
|
"value": value,
|
|
"unreal": unreal,
|
|
"pct": pct,
|
|
"entry_date": entry_date,
|
|
})
|
|
|
|
return total_unreal, details
|
|
|
|
|
|
def _realised_pnl(db) -> float:
|
|
"""Sum all realised P&L from position_events."""
|
|
row = db.execute(
|
|
"SELECT COALESCE(SUM(pnl_dkk),0) AS total FROM position_events WHERE action='sell'"
|
|
).fetchone()
|
|
return float(row["total"] or 0)
|
|
|
|
|
|
def _total_fees(db) -> float:
|
|
"""Sum all fees from saxo_orders."""
|
|
try:
|
|
row = db.execute(
|
|
"SELECT COALESCE(SUM(fee_dkk),0) AS total FROM saxo_orders"
|
|
).fetchone()
|
|
return float(row["total"] or 0)
|
|
except Exception:
|
|
return 0.0
|
|
|
|
|
|
def _invested(positions_details: list[dict]) -> float:
|
|
return sum(p["cost"] for p in positions_details)
|
|
|
|
|
|
def _signal_accuracy(db) -> dict:
|
|
"""
|
|
Calculate signal accuracy from completed trades (position_events).
|
|
A signal is 'correct' if the trade had P&L > 0 (signal_correct=1).
|
|
Also returns overall signal scan stats from article_signals.
|
|
"""
|
|
# Accuracy from closed trades
|
|
try:
|
|
row = db.execute("""
|
|
SELECT
|
|
COUNT(*) AS total_trades,
|
|
SUM(CASE WHEN signal_correct=1 THEN 1 ELSE 0 END) AS correct,
|
|
SUM(CASE WHEN signal_correct=0 THEN 1 ELSE 0 END) AS wrong,
|
|
COALESCE(AVG(CASE WHEN signal_correct IS NOT NULL
|
|
THEN signal_correct END), -1) AS accuracy
|
|
FROM position_events
|
|
WHERE action = 'sell' AND signal_correct IS NOT NULL
|
|
""").fetchone()
|
|
total_trades = int(row["total_trades"] or 0)
|
|
correct = int(row["correct"] or 0)
|
|
wrong = int(row["wrong"] or 0)
|
|
accuracy_pct = (float(row["accuracy"]) * 100) if total_trades > 0 else None
|
|
except Exception:
|
|
total_trades = correct = wrong = 0
|
|
accuracy_pct = None
|
|
|
|
# NLP scan stats
|
|
try:
|
|
srow = db.execute("""
|
|
SELECT
|
|
COUNT(*) AS total_signals,
|
|
SUM(CASE WHEN alert=1 THEN 1 ELSE 0 END) AS alerts,
|
|
AVG(signal_score) AS avg_score
|
|
FROM article_signals
|
|
WHERE signal_score IS NOT NULL
|
|
""").fetchone()
|
|
return {
|
|
"total": int(srow["total_signals"] or 0),
|
|
"alerts": int(srow["alerts"] or 0),
|
|
"avg_score": round(float(srow["avg_score"] or 0), 3),
|
|
"total_trades": total_trades,
|
|
"correct": correct,
|
|
"wrong": wrong,
|
|
"accuracy_pct": accuracy_pct,
|
|
}
|
|
except Exception:
|
|
return {
|
|
"total": 0, "alerts": 0, "avg_score": 0,
|
|
"total_trades": total_trades, "correct": correct,
|
|
"wrong": wrong, "accuracy_pct": accuracy_pct,
|
|
}
|
|
|
|
|
|
def print_report() -> None:
|
|
db = get_conn()
|
|
now = datetime.now(timezone.utc).strftime("%d %b %Y %H:%M UTC")
|
|
|
|
unreal_total, positions = _unrealised_pnl(db)
|
|
realised = _realised_pnl(db)
|
|
fees = _total_fees(db)
|
|
invested = _invested(positions)
|
|
cash = CAPITAL - invested
|
|
net_pnl = realised + unreal_total - fees
|
|
net_pct = (net_pnl / CAPITAL * 100)
|
|
c25_ret = _c25_day_return()
|
|
vs_bench = (net_pct - c25_ret) if c25_ret is not None else None
|
|
sig_stats = _signal_accuracy(db)
|
|
|
|
# ── Header ──────────────────────────────────────────────────────────────
|
|
print()
|
|
print("═" * 48)
|
|
print(f" MONEYMAKER P&L · {now}")
|
|
print(f" DB: {DB_TYPE}")
|
|
print("═" * 48)
|
|
print(f" Kapital: {CAPITAL:>10,.0f} DKK")
|
|
print(f" Investeret: {invested:>10,.0f} DKK ({len(positions)} pos.)")
|
|
print(f" Kontant: {cash:>10,.0f} DKK")
|
|
print()
|
|
print(f" Urealiseret: {unreal_total:>+10,.0f} DKK")
|
|
print(f" Realiseret: {realised:>+10,.0f} DKK")
|
|
print(f" Total gebyrer: {-fees:>+8,.0f} DKK")
|
|
print(f" Net P&L: {net_pnl:>+10,.0f} DKK ({net_pct:+.2f}%)")
|
|
print()
|
|
|
|
# ── Benchmark ────────────────────────────────────────────────────────────
|
|
if c25_ret is not None:
|
|
bench_icon = "✅" if vs_bench and vs_bench >= 0 else "❌"
|
|
print(f" C25 index: {c25_ret:+.2f}% (i dag)")
|
|
print(f" vs benchmark: {vs_bench:+.2f}% {bench_icon}")
|
|
else:
|
|
print(" C25 index: N/A (marked lukket?)")
|
|
print()
|
|
|
|
# ── Open positions ────────────────────────────────────────────────────────
|
|
if positions:
|
|
print(" ┌─ Åbne positioner ─────────────────────────────┐")
|
|
for p in positions:
|
|
icon = "📈" if p["unreal"] >= 0 else "📉"
|
|
print(
|
|
f" │ {icon} {p['ticker']:<8} {p['shares']:.0f} stk "
|
|
f"købt {p['entry']:.0f} nu {p['last']:.0f} "
|
|
f"P&L {p['unreal']:+.0f} ({p['pct']:+.1f}%)"
|
|
)
|
|
print(" └────────────────────────────────────────────────┘")
|
|
print()
|
|
|
|
# ── Signal stats ─────────────────────────────────────────────────────────
|
|
print(f" Signaler: {sig_stats['total']:>5} analyseret")
|
|
print(f" Alerts: {sig_stats['alerts']:>5} ⚡ triggers")
|
|
print(f" Gns score: {sig_stats['avg_score']:>5.3f}")
|
|
if sig_stats["total_trades"] > 0:
|
|
acc = sig_stats["accuracy_pct"]
|
|
icon = "✅" if acc and acc >= 50 else "❌"
|
|
print(f" Lukkede handler: {sig_stats['total_trades']} "
|
|
f"({sig_stats['correct']} korrekte / {sig_stats['wrong']} forkerte) "
|
|
f"→ {acc:.0f}% {icon}")
|
|
else:
|
|
print(" Lukkede handler: 0 (ingen salg endnu)")
|
|
if fees > 0:
|
|
drag_pct = (fees / max(abs(net_pnl + fees), 1)) * 100
|
|
print(f" Gebyr-drag: {drag_pct:.1f}% af brutto P&L")
|
|
print("═" * 48)
|
|
print()
|
|
|
|
db.close()
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="MoneyMaker P&L rapport")
|
|
parser.add_argument("--fees", action="store_true")
|
|
parser.add_argument("--signals", action="store_true")
|
|
args = parser.parse_args()
|
|
print_report()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|