mirror of
https://github.com/wahyd4/links.git
synced 2026-08-09 05:06:16 +10:00
278 lines
9.2 KiB
Python
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,
|
|
)
|