370 lines
14 KiB
Python
370 lines
14 KiB
Python
"""
|
|
portfolio.py — Position tracker + ordre-forslag
|
|
|
|
Kommandoer:
|
|
python portfolio.py orders → dagens køb/sælg/hold forslag
|
|
python portfolio.py status → åbne positioner + stop/take
|
|
python portfolio.py buy TICKER N PRIS → registrer et køb
|
|
python portfolio.py sell TICKER PRIS → registrer et salg
|
|
|
|
Eksempel:
|
|
python portfolio.py buy VWS 11 195.00
|
|
python portfolio.py sell VWS 244.00
|
|
"""
|
|
|
|
import sys
|
|
import json
|
|
import time
|
|
import sqlite3
|
|
from pathlib import Path
|
|
from datetime import datetime, timezone
|
|
|
|
import yfinance as yf
|
|
from db import get_conn, DBConn
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Config — justér disse
|
|
# ---------------------------------------------------------------------------
|
|
|
|
CAPITAL = 10_000 # DKK total kapital
|
|
MAX_POSITIONS = 3 # maks åbne positioner ad gangen
|
|
STOP_LOSS_PCT = 0.08 # sælg hvis -8% fra indgang
|
|
TAKE_PROFIT_PCT = 0.20 # sælg hvis +20% fra indgang
|
|
MIN_SIGNAL = 0.35 # minimum signal_score for KØB
|
|
BUY_ANALYST = {"stærk køb", "køb"} # dansk label fra analyst_rec()
|
|
|
|
C25_PATH = Path(__file__).parent / "c25.json"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# DB setup
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def get_db() -> DBConn:
|
|
"""Return a DBConn wrapper. Schema is managed by db.py."""
|
|
return get_conn()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Hjælpere
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _c25_map() -> dict:
|
|
data = json.loads(C25_PATH.read_text())
|
|
# c25.json er en dict med ticker som nøgle (plus _meta)
|
|
return {k: v for k, v in data.items() if k != "_meta"}
|
|
|
|
|
|
def _current_price(ticker_yahoo: str) -> float | None:
|
|
try:
|
|
info = yf.Ticker(ticker_yahoo).fast_info
|
|
return round(float(info.last_price), 2)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _analyst_label(ticker_yahoo: str) -> tuple[str, int]:
|
|
"""Returnerer (dansk label, antal analytikere)."""
|
|
try:
|
|
info = yf.Ticker(ticker_yahoo).info
|
|
rec = (info.get("recommendationKey") or "").lower()
|
|
n = info.get("numberOfAnalystOpinions") or 0
|
|
label_map = {
|
|
"strong_buy": "stærk køb", "buy": "køb",
|
|
"hold": "hold", "sell": "sælg", "strong_sell": "stærk sælg",
|
|
}
|
|
return label_map.get(rec, rec or "ukendt"), int(n)
|
|
except Exception:
|
|
return "ukendt", 0
|
|
|
|
|
|
def _open_positions(db: DBConn) -> list:
|
|
return db.execute("SELECT * FROM positions ORDER BY entry_date").fetchall()
|
|
|
|
|
|
def _open_count(db: DBConn) -> int:
|
|
return db.execute("SELECT COUNT(*) AS cnt FROM positions").fetchone()["cnt"]
|
|
|
|
|
|
def _best_signals(db: DBConn) -> list:
|
|
"""Hent de bedste aktuelle signal-rækker (senest 7 dage)."""
|
|
cutoff = int(time.time()) - 7 * 86400
|
|
return db.execute("""
|
|
SELECT ticker, MAX(company_name) AS company_name,
|
|
MAX(signal_score) AS signal_score,
|
|
AVG(sentiment_score) AS sentiment_score,
|
|
SUM(CASE WHEN sentiment = 'positive' THEN 1 ELSE 0 END) AS pos_count,
|
|
SUM(CASE WHEN sentiment = 'negative' THEN 1 ELSE 0 END) AS neg_count,
|
|
COUNT(*) AS article_count,
|
|
MAX(momentum_dir) AS momentum_dir,
|
|
MAX(claude_magnitude) AS magnitude
|
|
FROM article_signals
|
|
WHERE analyzed_at > ?
|
|
GROUP BY ticker
|
|
ORDER BY signal_score DESC
|
|
""", (cutoff,)).fetchall()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Ordre-logik
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _per_position(db: DBConn) -> int:
|
|
open_n = _open_count(db)
|
|
slots = MAX_POSITIONS - open_n
|
|
if slots <= 0:
|
|
return 0
|
|
# fordel restkapi tal ligeligt
|
|
used = sum(
|
|
p["shares"] * p["entry_price"]
|
|
for p in _open_positions(db)
|
|
)
|
|
remaining = max(0.0, CAPITAL - used)
|
|
return int(remaining // slots)
|
|
|
|
|
|
def evaluate_orders(db: DBConn) -> None:
|
|
c25 = _c25_map()
|
|
signals = _best_signals(db)
|
|
open_pos = {p["ticker"]: p for p in _open_positions(db)}
|
|
open_n = len(open_pos)
|
|
per_pos = _per_position(db)
|
|
used = sum(p["shares"] * p["entry_price"] for p in open_pos.values())
|
|
|
|
print()
|
|
print("═" * 66)
|
|
print(f" MONEYMAKER ORDRE-FORSLAG · {datetime.now(timezone.utc).strftime('%d %b %Y %H:%M')} UTC")
|
|
print(f" Kapital: {CAPITAL:,.0f} kr │ Brugt: {used:,.0f} kr │ "
|
|
f"Åbne: {open_n}/{MAX_POSITIONS} │ Pr. pos: {per_pos:,.0f} kr")
|
|
print("═" * 66)
|
|
|
|
buy_suggestions = []
|
|
hold_suggestions = []
|
|
watch_list = []
|
|
|
|
for row in signals:
|
|
ticker = row["ticker"]
|
|
meta = c25.get(ticker, {})
|
|
yahoo = meta.get("ticker_yahoo", f"{ticker}.CO")
|
|
price = _current_price(yahoo)
|
|
if price is None:
|
|
continue
|
|
|
|
analyst_lbl, analyst_n = _analyst_label(yahoo)
|
|
score = row["signal_score"] or 0.0
|
|
pos_ct = row["pos_count"] or 0
|
|
neg_ct = row["neg_count"] or 0
|
|
net_sentiment = pos_ct - neg_ct
|
|
|
|
# Er vi allerede inde?
|
|
if ticker in open_pos:
|
|
hold_suggestions.append((ticker, row, meta, price, analyst_lbl, analyst_n))
|
|
continue
|
|
|
|
# KØB-betingelser
|
|
analyst_ok = analyst_lbl in BUY_ANALYST
|
|
signal_ok = score >= MIN_SIGNAL
|
|
enige = (net_sentiment > 0 and analyst_ok) or (net_sentiment < 0 and analyst_lbl in {"sælg", "stærk sælg"})
|
|
|
|
if signal_ok and analyst_ok and enige:
|
|
buy_suggestions.append((ticker, row, meta, price, analyst_lbl, analyst_n, score))
|
|
elif signal_ok or analyst_ok:
|
|
watch_list.append((ticker, row, meta, price, analyst_lbl, analyst_n, score))
|
|
|
|
# --- KØB ---
|
|
if buy_suggestions and open_n < MAX_POSITIONS and per_pos > 0:
|
|
print()
|
|
print(" 🟢 KØB-SIGNALER")
|
|
print(" " + "─" * 62)
|
|
slots_left = MAX_POSITIONS - open_n
|
|
for (ticker, row, meta, price, analyst_lbl, analyst_n, score) in buy_suggestions[:slots_left]:
|
|
shares = int(per_pos // price)
|
|
if shares == 0:
|
|
etps = meta.get("leveraged", [])
|
|
etp_buy = next((e["ticker"] for e in etps if e.get("direction") == "long"), "")
|
|
etp_txt = f" → brug ETP {etp_buy}" if etp_buy else " → pris for høj til direkte køb"
|
|
print(f" {ticker:<10} {price:>7,.0f} kr/stk{etp_txt}")
|
|
continue
|
|
total = shares * price
|
|
stop = round(price * (1 - STOP_LOSS_PCT), 2)
|
|
take = round(price * (1 + TAKE_PROFIT_PCT), 2)
|
|
max_loss = round(shares * (price - stop), 0)
|
|
max_gain = round(shares * (take - price), 0)
|
|
print(f" {ticker:<10} KØB {shares} stk à {price:,.0f} kr = {total:,.0f} kr")
|
|
print(f" Stop-loss: {stop:,.0f} kr (max tab: {max_loss:,.0f} kr)")
|
|
print(f" Take-profit: {take:,.0f} kr (max gevinst: {max_gain:,.0f} kr)")
|
|
print(f" Signal: {score:.2f} │ Analytikere ({analyst_n}): {analyst_lbl.upper()}")
|
|
print(f" Registrer: python portfolio.py buy {ticker} {shares} {price}")
|
|
print()
|
|
elif not buy_suggestions:
|
|
print()
|
|
print(" Ingen klare KØB-signaler lige nu.")
|
|
|
|
# --- ÅBNE POSITIONER (stop/take check) ---
|
|
if open_pos:
|
|
print()
|
|
print(" 📊 ÅBNE POSITIONER")
|
|
print(" " + "─" * 62)
|
|
for ticker, pos in open_pos.items():
|
|
meta = c25.get(ticker, {})
|
|
yahoo = meta.get("ticker_yahoo", f"{ticker}.CO")
|
|
price = _current_price(yahoo) or pos["entry_price"]
|
|
entry = pos["entry_price"]
|
|
shares = pos["shares"]
|
|
pct_chg = (price - entry) / entry * 100
|
|
value = shares * price
|
|
pnl = shares * (price - entry)
|
|
|
|
stop_hit = price <= pos["stop_loss"]
|
|
take_hit = price >= pos["take_profit"]
|
|
action = "🔴 SÆLG (stop-loss!)" if stop_hit else ("🟡 SÆLG (take-profit!)" if take_hit else "⏳ HOLD")
|
|
|
|
print(f" {ticker:<10} {shares:.0f} stk │ ind: {entry:,.0f} kr │ nu: {price:,.0f} kr │ {pct_chg:+.1f}%")
|
|
print(f" Værdi: {value:,.0f} kr │ P&L: {pnl:+,.0f} kr │ {action}")
|
|
print(f" Stop: {pos['stop_loss']:,.0f} kr │ Take: {pos['take_profit']:,.0f} kr")
|
|
if stop_hit or take_hit:
|
|
print(f" Registrer: python portfolio.py sell {ticker} {price:.2f}")
|
|
print()
|
|
|
|
# --- HOLD/WATCH ---
|
|
if watch_list:
|
|
print()
|
|
print(" 🔍 FØLG MED (signal endnu ikke klart nok)")
|
|
print(" " + "─" * 62)
|
|
for (ticker, row, meta, price, analyst_lbl, analyst_n, score) in watch_list[:4]:
|
|
why = []
|
|
if score < MIN_SIGNAL:
|
|
why.append(f"signal {score:.2f} < {MIN_SIGNAL}")
|
|
if analyst_lbl not in BUY_ANALYST:
|
|
why.append(f"analytikere: {analyst_lbl.upper()}")
|
|
print(f" {ticker:<10} {price:>7,.0f} kr │ {', '.join(why)}")
|
|
|
|
print()
|
|
print(" Forklaring:")
|
|
print(" Stop-loss = sælg automatisk hvis kurs falder 10% fra dit køb")
|
|
print(" Take-profit = sælg hvis kurs stiger 25% — tag gevinsten hjem")
|
|
print(" KØB-krav = signal≥0.25 + analytikere siger KØB + begge peger samme vej")
|
|
print()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Portfolio CRUD
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def cmd_buy(db: DBConn, args: list[str]) -> None:
|
|
if len(args) < 3:
|
|
print("Brug: python portfolio.py buy TICKER ANTAL PRIS")
|
|
print("Eks: python portfolio.py buy VWS 11 195.00")
|
|
return
|
|
ticker = args[0].upper()
|
|
shares = float(args[1])
|
|
price = float(args[2])
|
|
stop = round(price * (1 - STOP_LOSS_PCT), 2)
|
|
take = round(price * (1 + TAKE_PROFIT_PCT), 2)
|
|
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
total = shares * price
|
|
|
|
db.upsert(
|
|
"positions", "ticker",
|
|
["ticker", "shares", "entry_price", "entry_date", "stop_loss", "take_profit"],
|
|
(ticker, shares, price, today, stop, take),
|
|
)
|
|
db.execute("""
|
|
INSERT INTO position_events
|
|
(ticker, action, shares, price, total_dkk, event_date)
|
|
VALUES (?, 'buy', ?, ?, ?, ?)
|
|
""", (ticker, shares, price, total, today))
|
|
db.commit()
|
|
print(f"✅ KØB registreret: {shares:.0f} stk {ticker} à {price:.2f} kr = {total:.2f} kr")
|
|
print(f" Stop-loss: {stop:.2f} kr")
|
|
print(f" Take-profit: {take:.2f} kr")
|
|
|
|
|
|
def cmd_sell(db: DBConn, args: list[str]) -> None:
|
|
if len(args) < 2:
|
|
print("Brug: python portfolio.py sell TICKER PRIS")
|
|
print("Eks: python portfolio.py sell VWS 244.00")
|
|
return
|
|
ticker = args[0].upper()
|
|
price = float(args[1])
|
|
pos = db.execute("SELECT * FROM positions WHERE ticker = ?", (ticker,)).fetchone()
|
|
if not pos:
|
|
print(f"❌ {ticker} — ingen åben position fundet")
|
|
return
|
|
shares = pos["shares"]
|
|
entry = pos["entry_price"]
|
|
pnl = shares * (price - entry)
|
|
total = shares * price
|
|
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
|
|
signal_correct = 1 if pnl > 0 else 0
|
|
db.execute("DELETE FROM positions WHERE ticker = ?", (ticker,))
|
|
db.execute("""
|
|
INSERT INTO position_events
|
|
(ticker, action, shares, price, total_dkk, pnl_dkk, signal_correct, event_date)
|
|
VALUES (?, 'sell', ?, ?, ?, ?, ?, ?)
|
|
""", (ticker, shares, price, total, pnl, signal_correct, today))
|
|
db.commit()
|
|
emoji = "🟢" if pnl >= 0 else "🔴"
|
|
print(f"{emoji} SALG registreret: {shares:.0f} stk {ticker} à {price:.2f} kr = {total:.2f} kr")
|
|
print(f" Købt til: {entry:.2f} kr │ P&L: {pnl:+.2f} kr │ Signal: {'✅ korrekt' if signal_correct else '❌ forkert'}")
|
|
|
|
|
|
def cmd_status(db: DBConn) -> None:
|
|
positions = _open_positions(db)
|
|
history = db.execute("""
|
|
SELECT * FROM position_events ORDER BY event_date DESC LIMIT 10
|
|
""").fetchall()
|
|
|
|
if not positions:
|
|
print("\n Ingen åbne positioner.\n")
|
|
else:
|
|
print("\n ÅBNE POSITIONER:")
|
|
for p in positions:
|
|
meta = _c25_map().get(p["ticker"], {})
|
|
yahoo = meta.get("ticker_yahoo", f"{p['ticker']}.CO")
|
|
price = _current_price(yahoo) or p["entry_price"]
|
|
pnl = p["shares"] * (price - p["entry_price"])
|
|
print(f" {p['ticker']:<10} {p['shares']:.0f} stk ind {p['entry_price']:.2f} nu {price:.2f} P&L {pnl:+.2f} kr")
|
|
print(f" Stop {p['stop_loss']:.2f} Take {p['take_profit']:.2f} (købt {p['entry_date']})")
|
|
|
|
if history:
|
|
print("\n SENESTE HANDLER:")
|
|
total_pnl = 0.0
|
|
for e in history:
|
|
pnl_txt = f" P&L {e['pnl_dkk']:+.2f} kr" if e["pnl_dkk"] is not None else ""
|
|
print(f" {e['event_date']} {e['action'].upper():<5} {e['ticker']:<10} "
|
|
f"{e['shares']:.0f} stk à {e['price']:.2f} kr{pnl_txt}")
|
|
if e["pnl_dkk"] is not None:
|
|
total_pnl += e["pnl_dkk"]
|
|
sold = [e for e in history if e["action"] == "sell"]
|
|
if sold:
|
|
print(f"\n Samlet realiseret P&L: {total_pnl:+.2f} kr")
|
|
print()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# CLI
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def main() -> None:
|
|
db = get_db()
|
|
args = sys.argv[1:]
|
|
cmd = args[0] if args else "orders"
|
|
|
|
if cmd == "orders":
|
|
evaluate_orders(db)
|
|
elif cmd == "status":
|
|
cmd_status(db)
|
|
elif cmd == "buy" and len(args) >= 4:
|
|
cmd_buy(db, args[1:])
|
|
elif cmd == "sell" and len(args) >= 3:
|
|
cmd_sell(db, args[1:])
|
|
else:
|
|
print(__doc__)
|
|
|
|
db.close()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|