Files
links/invest/services.py
T

278 lines
9.2 KiB
Python

"""
Service layer for the invest app.
Real-time prices fetched from Yahoo Finance on demand using yfinance.
"""
import logging
from decimal import Decimal
from datetime import datetime, date
from typing import Optional
from django.db import transaction
from .models import Portfolio, Stock, Transaction
logger = logging.getLogger(__name__)
# In-memory cache for price failures (not persisted in DB)
_price_cache: dict[str, tuple[float, datetime]] = {}
_PRICE_CACHE_TTL_SECONDS = 300 # 5 minutes
# ---------------------------------------------------------------------------
# Price fetching via yfinance
# ---------------------------------------------------------------------------
def _get_yfinance_price(stock_code: str) -> Optional[float]:
"""
Fetch current price from Yahoo Finance using yfinance.
Returns None if fetch fails.
"""
try:
import yfinance as yf
ticker = yf.Ticker(stock_code)
hist = ticker.history(period="1d")
if hist.empty:
return None
return float(hist["Close"].iloc[-1])
except Exception as exc:
logger.warning("yfinance failed for %s: %s", stock_code, exc)
return None
def get_current_price(stock_code: str) -> Optional[float]:
"""
Get current price for a stock, with simple in-memory cache fallback.
"""
now = datetime.now()
cached = _price_cache.get(stock_code)
# Return cached price if fresh enough
if cached:
price, cached_at = cached
if (now - cached_at).total_seconds() < _PRICE_CACHE_TTL_SECONDS:
return price
# Fetch fresh price
price = _get_yfinance_price(stock_code)
if price is not None:
_price_cache[stock_code] = (price, now)
return price
# Fallback to stale cache if fetch failed
if cached:
logger.info("Using stale cached price for %s", stock_code)
return cached[0]
return None
# ---------------------------------------------------------------------------
# Holdings calculation from transactions
# ---------------------------------------------------------------------------
def calculate_holdings_from_transactions(portfolio: Portfolio) -> dict[str, dict]:
"""
Calculate current holdings (stock_code -> {quantity, avg_cost}) from transactions.
"""
holdings: dict[str, dict] = {}
txs = Transaction.objects.filter(portfolio=portfolio).order_by('date', 'created_at')
for tx in txs:
code = tx.stock_code
if code not in holdings:
holdings[code] = {
'quantity': Decimal('0'),
'total_cost': Decimal('0'),
}
if tx.action == Transaction.ACTION_BUY:
holdings[code]['quantity'] += tx.quantity
holdings[code]['total_cost'] += tx.quantity * tx.price_per_share
elif tx.action == Transaction.ACTION_SELL:
# Reduce quantity, proportionally reduce cost basis
if holdings[code]['quantity'] > 0:
sold_ratio = min(tx.quantity / holdings[code]['quantity'], Decimal('1'))
holdings[code]['total_cost'] *= (1 - sold_ratio)
holdings[code]['quantity'] -= tx.quantity
if holdings[code]['quantity'] < 0:
holdings[code]['quantity'] = Decimal('0')
# Calculate avg_cost
for code, data in holdings.items():
if data['quantity'] > 0:
data['avg_cost'] = float(data['total_cost'] / data['quantity'])
else:
data['avg_cost'] = 0.0
data['quantity'] = float(data['quantity'])
data['total_cost'] = float(data['total_cost'])
return holdings
# ---------------------------------------------------------------------------
# Portfolio holdings with real-time prices
# ---------------------------------------------------------------------------
def get_portfolio_holdings(portfolio: Portfolio) -> dict:
"""
Get portfolio holdings with real-time prices from Yahoo Finance.
Combines Stock snapshots with transaction history for avg_cost.
"""
# Get current Stock snapshot
stocks = {s.stock_code: float(s.quantity) for s in portfolio.stocks.all()}
# Get holdings from transactions
tx_holdings = calculate_holdings_from_transactions(portfolio)
# Merge: use Stock quantity as source of truth, tx_holdings for avg_cost
holdings_list = []
total_value = Decimal('0')
total_cost = Decimal('0')
all_codes = set(stocks.keys()) | set(tx_holdings.keys())
for code in all_codes:
quantity = stocks.get(code, tx_holdings.get(code, {}).get('quantity', 0))
if isinstance(quantity, Decimal):
quantity = float(quantity)
if quantity <= 0:
continue
tx_data = tx_holdings.get(code, {})
avg_cost = tx_data.get('avg_cost', 0.0)
cost = Decimal(str(avg_cost)) * Decimal(str(quantity))
current_price = get_current_price(code)
if current_price is None:
current_price = 0.0
value = Decimal(str(current_price)) * Decimal(str(quantity))
pnl = value - cost
pnl_pct = (float(pnl / cost * 100) if cost > 0 else 0.0) if cost != 0 else 0.0
holdings_list.append({
'stock_code': code,
'quantity': quantity,
'avg_cost': avg_cost,
'current_price': current_price,
'current_value': float(value),
'unrealized_pnl': float(pnl),
'unrealized_pnl_pct': round(pnl_pct, 2),
})
total_value += value
total_cost += cost
total_pnl = total_value - total_cost
total_pnl_pct = (float(total_pnl / total_cost * 100) if total_cost > 0 else 0.0) if total_cost != 0 else 0.0
return {
'portfolio_id': portfolio.id,
'portfolio_name': portfolio.name,
'holdings': holdings_list,
'total_value': float(total_value),
'total_cost': float(total_cost),
'total_pnl': float(total_pnl),
'total_pnl_pct': round(total_pnl_pct, 2),
}
# ---------------------------------------------------------------------------
# AI update - sync holdings from AI
# ---------------------------------------------------------------------------
def ai_update_holdings(portfolio: Portfolio, holdings: list[dict], reset: bool = False) -> dict:
"""
AI updates portfolio holdings.
Creates/updates Stock records and corresponding BUY transactions.
Args:
portfolio: Portfolio to update
holdings: List of {"stock_code": str, "quantity": float, "avg_cost": float}
reset: If True, clear existing holdings first
"""
results = []
with transaction.atomic():
if reset:
# Delete existing stocks and transactions
portfolio.stocks.all().delete()
portfolio.transactions.all().delete()
for item in holdings:
stock_code = item['stock_code']
quantity = Decimal(str(item['quantity']))
avg_cost = Decimal(str(item.get('avg_cost', 0)))
# Create or update Stock
stock, created = Stock.objects.update_or_create(
portfolio=portfolio,
stock_code=stock_code,
defaults={'quantity': quantity}
)
# Create a BUY transaction to record the holding
if quantity > 0 and avg_cost > 0:
# Check if transaction already exists for this stock_code with same avg_cost
# (to avoid duplicates on re-runs)
existing_tx = Transaction.objects.filter(
portfolio=portfolio,
stock_code=stock_code,
action=Transaction.ACTION_BUY,
price_per_share=avg_cost,
).first()
if not existing_tx:
Transaction.objects.create(
portfolio=portfolio,
action=Transaction.ACTION_BUY,
stock_code=stock_code,
quantity=quantity,
price_per_share=avg_cost,
date=date.today(),
)
tx_status = 'created'
else:
tx_status = 'skipped_existing'
results.append({
'stock_code': stock_code,
'quantity': float(quantity),
'avg_cost': float(avg_cost),
'stock_created': created,
'tx_status': tx_status if quantity > 0 and avg_cost > 0 else 'skipped',
})
return {
'portfolio_id': portfolio.id,
'portfolio_name': portfolio.name,
'reset': reset,
'results': results,
}
# ---------------------------------------------------------------------------
# Transaction CRUD
# ---------------------------------------------------------------------------
def add_transaction(
portfolio: Portfolio,
action: str,
stock_code: str,
quantity: Decimal,
price_per_share: Decimal,
date: date,
) -> Transaction:
"""Add a buy or sell transaction."""
return Transaction.objects.create(
portfolio=portfolio,
action=action.upper(),
stock_code=stock_code.upper(),
quantity=quantity,
price_per_share=price_per_share,
date=date,
)