Files
links/tests/test_invest_cost_basis.py
OpenClaw Sub-agent b67a834def feat(invest): cost basis & P&L, AUD totals, chart ranges, per-ticker page, manual entry, tx search
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)
2026-08-02 08:09:58 +10:00

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