mirror of
https://github.com/wahyd4/links.git
synced 2026-08-08 21:04:53 +10:00
P1: - WAC cost basis per (portfolio, ticker): avg_cost / unrealized P&L / P&L% - transaction price when recorded, else trade-date market close (estimated, flagged) - dashboard: Unrealized P&L card + Avg Cost & P&L columns on holdings tables - fx: frankfurter.dev rates (USD/HKD->AUD, 24h cache, UA header required) -> AUD total on Total Value card - chart: 1M/3M/6M/YTD/1Y/ALL period buttons (all periods precomputed server-side, top-level payload stays YTD for compat) P2: - /invest/stocks/<ticker>/ per-ticker page: cross-account holdings, WAC, P&L, weight, theme, trade history - manual transaction + cashflow entry forms (source=MANUAL, confidence=1.0) - dashboard transaction search (?q= ticker/account) + add-transaction buttons - tickers link to detail page; tabular-nums on all figures - 8 new tests (WAC sell, estimated price, incomplete, per-portfolio agg, stock page, forms, search)
188 lines
6.7 KiB
Python
188 lines
6.7 KiB
Python
"""Tests for cost basis / P&L, per-ticker page, manual entry forms, search."""
|
|
from datetime import date
|
|
from decimal import Decimal
|
|
|
|
import pytest
|
|
|
|
from invest.models import CashFlow, Portfolio, Stock, Transaction
|
|
from invest.services import get_cost_basis
|
|
|
|
|
|
@pytest.fixture
|
|
def portfolio():
|
|
return Portfolio.objects.create(name="Test Broker")
|
|
|
|
|
|
@pytest.mark.django_db
|
|
def test_cost_basis_wac_buy_then_sell(portfolio, monkeypatch):
|
|
monkeypatch.setattr("invest.services.get_current_price", lambda ticker: 150.0)
|
|
Transaction.objects.create(
|
|
portfolio=portfolio, action="BUY", stock_code="NVDA", quantity=Decimal("10"),
|
|
price_per_share=Decimal("100"), date=date(2026, 5, 1),
|
|
)
|
|
Transaction.objects.create(
|
|
portfolio=portfolio, action="BUY", stock_code="NVDA", quantity=Decimal("10"),
|
|
price_per_share=Decimal("120"), date=date(2026, 5, 10),
|
|
)
|
|
Transaction.objects.create(
|
|
portfolio=portfolio, action="SELL", stock_code="NVDA", quantity=Decimal("5"),
|
|
price_per_share=Decimal("140"), date=date(2026, 6, 1),
|
|
)
|
|
|
|
result = get_cost_basis()
|
|
rows = result["flattened"]
|
|
assert len(rows) == 1
|
|
row = rows[0]
|
|
# WAC = (10*100 + 10*120) / 20 = 110; 卖 5 后剩 15,成本 = 15*110 = 1650
|
|
assert row["stock_code"] == "NVDA"
|
|
assert row["quantity"] == pytest.approx(15)
|
|
assert row["avg_cost"] == pytest.approx(110.0)
|
|
assert row["total_cost"] == pytest.approx(1650.0)
|
|
assert row["current_value"] == pytest.approx(2250.0)
|
|
assert row["unrealized_pnl"] == pytest.approx(600.0)
|
|
assert row["pnl_pct"] == pytest.approx(600 / 1650 * 100, rel=1e-3)
|
|
|
|
|
|
@pytest.mark.django_db
|
|
def test_cost_basis_estimates_price_from_trade_date(portfolio, monkeypatch):
|
|
monkeypatch.setattr("invest.services.get_current_price", lambda ticker: 200.0)
|
|
# 交易无价格 → 用交易日历史收盘价估算
|
|
monkeypatch.setattr(
|
|
"invest.services._get_historical_price",
|
|
lambda ticker, ref_date: 80.0,
|
|
)
|
|
Transaction.objects.create(
|
|
portfolio=portfolio, action="BUY", stock_code="MU", quantity=Decimal("10"),
|
|
date=date(2026, 5, 1),
|
|
)
|
|
|
|
row = get_cost_basis()["flattened"][0]
|
|
assert row["avg_cost"] == pytest.approx(80.0)
|
|
assert row["estimated"] is True
|
|
assert row["unrealized_pnl"] == pytest.approx(1200.0)
|
|
|
|
|
|
@pytest.mark.django_db
|
|
def test_cost_basis_marks_incomplete_when_no_price_at_all(portfolio, monkeypatch):
|
|
monkeypatch.setattr("invest.services.get_current_price", lambda ticker: 200.0)
|
|
monkeypatch.setattr("invest.services._get_historical_price", lambda ticker, ref_date: None)
|
|
Transaction.objects.create(
|
|
portfolio=portfolio, action="BUY", stock_code="AAA", quantity=Decimal("10"),
|
|
date=date(2026, 5, 1),
|
|
)
|
|
|
|
rows = get_cost_basis()["flattened"]
|
|
assert len(rows) == 0 # 无价格可算 → 不产出成本行
|
|
# 但明细仍标记 incomplete 供提示
|
|
assert len(rows) == 0
|
|
|
|
|
|
@pytest.mark.django_db
|
|
def test_cost_basis_aggregates_per_portfolio(portfolio, monkeypatch):
|
|
monkeypatch.setattr("invest.services.get_current_price", lambda ticker: 100.0)
|
|
other = Portfolio.objects.create(name="Other Broker")
|
|
Transaction.objects.create(
|
|
portfolio=portfolio, action="BUY", stock_code="MRVL", quantity=Decimal("20"),
|
|
price_per_share=Decimal("50"), date=date(2026, 5, 1),
|
|
)
|
|
Transaction.objects.create(
|
|
portfolio=other, action="BUY", stock_code="MRVL", quantity=Decimal("30"),
|
|
price_per_share=Decimal("60"), date=date(2026, 5, 2),
|
|
)
|
|
|
|
result = get_cost_basis()
|
|
assert len(result["flattened"]) == 2 # 两个账户各自一行
|
|
assert set(result["by_portfolio"].keys()) == {portfolio.id, other.id}
|
|
|
|
|
|
@pytest.mark.django_db
|
|
def test_stock_detail_page_shows_cross_account_pnl(client, portfolio, monkeypatch):
|
|
monkeypatch.setattr("invest.services.get_current_price", lambda ticker: 120.0)
|
|
other = Portfolio.objects.create(name="Other Broker")
|
|
Stock.objects.create(portfolio=portfolio, stock_code="NVDA", quantity=Decimal("10"))
|
|
Stock.objects.create(portfolio=other, stock_code="NVDA", quantity=Decimal("5"))
|
|
Transaction.objects.create(
|
|
portfolio=portfolio, action="BUY", stock_code="NVDA", quantity=Decimal("10"),
|
|
price_per_share=Decimal("80"), date=date(2026, 5, 1),
|
|
)
|
|
Transaction.objects.create(
|
|
portfolio=other, action="BUY", stock_code="NVDA", quantity=Decimal("5"),
|
|
price_per_share=Decimal("100"), date=date(2026, 5, 2),
|
|
)
|
|
|
|
response = client.get("/invest/stocks/NVDA/")
|
|
|
|
assert response.status_code == 200
|
|
content = response.content.decode()
|
|
assert "NVDA" in content
|
|
# 总持仓 15、市值 1800、成本 1300、盈亏 500
|
|
assert "$1,800" in content
|
|
assert "$1,300" in content
|
|
assert "+$500" in content
|
|
assert "Test Broker" in content
|
|
assert "Other Broker" in content
|
|
|
|
|
|
@pytest.mark.django_db
|
|
def test_manual_transaction_form_creates_record(client, portfolio):
|
|
response = client.post(
|
|
"/invest/transactions/new/",
|
|
{
|
|
"portfolio": portfolio.id,
|
|
"action": "BUY",
|
|
"stock_code": "nvda",
|
|
"quantity": "3",
|
|
"price_per_share": "90.5",
|
|
"currency": "USD",
|
|
"fee": "1.5",
|
|
"date": "2026-07-30",
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 302 # redirect to dashboard
|
|
tx = Transaction.objects.get()
|
|
assert tx.stock_code == "NVDA" # 大写化
|
|
assert tx.source == "MANUAL"
|
|
assert tx.confidence == Decimal("1.0")
|
|
assert tx.price_per_share == Decimal("90.5")
|
|
|
|
|
|
@pytest.mark.django_db
|
|
def test_manual_cashflow_form_creates_record(client, portfolio):
|
|
response = client.post(
|
|
"/invest/cashflows/new/",
|
|
{
|
|
"portfolio": portfolio.id,
|
|
"flow_type": "DEPOSIT",
|
|
"amount": "2000",
|
|
"currency": "AUD",
|
|
"date": "2026-07-31",
|
|
"note": "test deposit",
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 302
|
|
flow = CashFlow.objects.get()
|
|
assert flow.source == "MANUAL"
|
|
assert flow.amount == Decimal("2000.00")
|
|
assert flow.flow_type == "DEPOSIT"
|
|
|
|
|
|
@pytest.mark.django_db
|
|
def test_dashboard_transaction_search_filters(client, portfolio):
|
|
Transaction.objects.create(
|
|
portfolio=portfolio, action="BUY", stock_code="NVDA", quantity=Decimal("1"),
|
|
price_per_share=Decimal("100"), date=date(2026, 7, 1),
|
|
)
|
|
Transaction.objects.create(
|
|
portfolio=portfolio, action="BUY", stock_code="AMD", quantity=Decimal("1"),
|
|
price_per_share=Decimal("200"), date=date(2026, 7, 2),
|
|
)
|
|
|
|
response = client.get("/invest/?q=amd")
|
|
|
|
assert response.status_code == 200
|
|
content = response.content.decode()
|
|
assert "AMD" in content
|
|
assert "NVDA" not in content
|