feat: add rebalance calculate schemas and tests
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
6d7cf340ea
commit
de77d5b2aa
@ -186,3 +186,35 @@ class RebalanceSimulationResponse(BaseModel):
|
|||||||
additional_amount: Decimal
|
additional_amount: Decimal
|
||||||
new_total: Decimal
|
new_total: Decimal
|
||||||
items: List[RebalanceItem]
|
items: List[RebalanceItem]
|
||||||
|
|
||||||
|
|
||||||
|
class RebalanceCalculateRequest(BaseModel):
|
||||||
|
"""Request for manual rebalance calculation."""
|
||||||
|
strategy: str = Field(..., pattern="^(full_rebalance|additional_buy)$")
|
||||||
|
prices: Optional[dict[str, Decimal]] = None
|
||||||
|
additional_amount: Optional[Decimal] = Field(None, ge=0)
|
||||||
|
|
||||||
|
|
||||||
|
class RebalanceCalculateItem(BaseModel):
|
||||||
|
"""Extended rebalance item with price change info."""
|
||||||
|
ticker: str
|
||||||
|
name: str | None = None
|
||||||
|
target_ratio: Decimal
|
||||||
|
current_ratio: Decimal
|
||||||
|
current_quantity: int
|
||||||
|
current_value: Decimal
|
||||||
|
current_price: Decimal
|
||||||
|
target_value: Decimal
|
||||||
|
diff_ratio: Decimal
|
||||||
|
diff_quantity: int
|
||||||
|
action: str # "buy", "sell", or "hold"
|
||||||
|
change_vs_prev_month: Decimal | None = None
|
||||||
|
change_vs_start: Decimal | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class RebalanceCalculateResponse(BaseModel):
|
||||||
|
"""Response for manual rebalance calculation."""
|
||||||
|
portfolio_id: int
|
||||||
|
total_assets: Decimal
|
||||||
|
available_to_buy: Decimal | None = None
|
||||||
|
items: List[RebalanceCalculateItem]
|
||||||
|
|||||||
113
backend/tests/e2e/test_rebalance_flow.py
Normal file
113
backend/tests/e2e/test_rebalance_flow.py
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
"""
|
||||||
|
E2E tests for rebalancing calculation flow.
|
||||||
|
"""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
def _setup_portfolio_with_holdings(client: TestClient, auth_headers: dict) -> int:
|
||||||
|
"""Helper: create portfolio with targets and holdings."""
|
||||||
|
# Create portfolio
|
||||||
|
resp = client.post(
|
||||||
|
"/api/portfolios",
|
||||||
|
json={"name": "Rebalance Test", "portfolio_type": "pension"},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
pid = resp.json()["id"]
|
||||||
|
|
||||||
|
# Set targets (sum = 100)
|
||||||
|
client.put(
|
||||||
|
f"/api/portfolios/{pid}/targets",
|
||||||
|
json=[
|
||||||
|
{"ticker": "069500", "target_ratio": 50},
|
||||||
|
{"ticker": "148070", "target_ratio": 50},
|
||||||
|
],
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set holdings
|
||||||
|
client.put(
|
||||||
|
f"/api/portfolios/{pid}/holdings",
|
||||||
|
json=[
|
||||||
|
{"ticker": "069500", "quantity": 10, "avg_price": 40000},
|
||||||
|
{"ticker": "148070", "quantity": 5, "avg_price": 100000},
|
||||||
|
],
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
return pid
|
||||||
|
|
||||||
|
|
||||||
|
def test_calculate_rebalance_with_manual_prices(client: TestClient, auth_headers):
|
||||||
|
"""Test rebalance calculation with manually provided prices."""
|
||||||
|
pid = _setup_portfolio_with_holdings(client, auth_headers)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/portfolios/{pid}/rebalance/calculate",
|
||||||
|
json={
|
||||||
|
"strategy": "full_rebalance",
|
||||||
|
"prices": {"069500": 50000, "148070": 110000},
|
||||||
|
},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["portfolio_id"] == pid
|
||||||
|
assert data["total_assets"] > 0
|
||||||
|
assert len(data["items"]) == 2
|
||||||
|
# Verify items have required fields
|
||||||
|
item = data["items"][0]
|
||||||
|
assert "ticker" in item
|
||||||
|
assert "target_ratio" in item
|
||||||
|
assert "current_ratio" in item
|
||||||
|
assert "diff_quantity" in item
|
||||||
|
assert "action" in item
|
||||||
|
assert "change_vs_prev_month" in item
|
||||||
|
|
||||||
|
|
||||||
|
def test_calculate_additional_buy_strategy(client: TestClient, auth_headers):
|
||||||
|
"""Test additional buy strategy: buy-only, no sells."""
|
||||||
|
pid = _setup_portfolio_with_holdings(client, auth_headers)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/portfolios/{pid}/rebalance/calculate",
|
||||||
|
json={
|
||||||
|
"strategy": "additional_buy",
|
||||||
|
"prices": {"069500": 50000, "148070": 110000},
|
||||||
|
"additional_amount": 1000000,
|
||||||
|
},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["total_assets"] > 0
|
||||||
|
assert data["available_to_buy"] == 1000000
|
||||||
|
# Additional buy should never have "sell" actions
|
||||||
|
for item in data["items"]:
|
||||||
|
assert item["action"] in ("buy", "hold")
|
||||||
|
|
||||||
|
|
||||||
|
def test_additional_buy_requires_amount(client: TestClient, auth_headers):
|
||||||
|
"""Test that additional_buy strategy requires additional_amount."""
|
||||||
|
pid = _setup_portfolio_with_holdings(client, auth_headers)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/portfolios/{pid}/rebalance/calculate",
|
||||||
|
json={
|
||||||
|
"strategy": "additional_buy",
|
||||||
|
"prices": {"069500": 50000, "148070": 110000},
|
||||||
|
},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_calculate_rebalance_without_prices_fallback(client: TestClient, auth_headers):
|
||||||
|
"""Test rebalance calculation without manual prices falls back to DB."""
|
||||||
|
pid = _setup_portfolio_with_holdings(client, auth_headers)
|
||||||
|
|
||||||
|
# Without prices, should still work (may have 0 prices from DB in test env)
|
||||||
|
response = client.post(
|
||||||
|
f"/api/portfolios/{pid}/rebalance/calculate",
|
||||||
|
json={"strategy": "full_rebalance"},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
Loading…
x
Reference in New Issue
Block a user