Add min_trade_amount parameter (default 10,000 KRW) to rebalance/calculate endpoint. Trades below this threshold are converted to hold actions to avoid inefficient micro-trades during rebalancing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
206 lines
6.9 KiB
Python
206 lines
6.9 KiB
Python
"""
|
|
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 float(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 float(data["total_assets"]) > 0
|
|
assert float(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
|
|
|
|
|
|
def test_apply_rebalance(client: TestClient, auth_headers):
|
|
"""리밸런싱 결과를 적용하면 거래가 일괄 생성된다."""
|
|
pid = _setup_portfolio_with_holdings(client, auth_headers)
|
|
|
|
response = client.post(
|
|
f"/api/portfolios/{pid}/rebalance/apply",
|
|
json={
|
|
"items": [
|
|
{"ticker": "069500", "action": "buy", "quantity": 5, "price": 50000},
|
|
{"ticker": "148070", "action": "sell", "quantity": 2, "price": 110000},
|
|
]
|
|
},
|
|
headers=auth_headers,
|
|
)
|
|
assert response.status_code == 201
|
|
data = response.json()
|
|
assert len(data["transactions"]) == 2
|
|
assert data["transactions"][0]["tx_type"] == "buy"
|
|
assert data["transactions"][1]["tx_type"] == "sell"
|
|
assert data["holdings_updated"] == 2
|
|
|
|
# Verify holdings were updated
|
|
holdings_resp = client.get(
|
|
f"/api/portfolios/{pid}/holdings",
|
|
headers=auth_headers,
|
|
)
|
|
holdings = {h["ticker"]: h for h in holdings_resp.json()}
|
|
assert holdings["069500"]["quantity"] == 15 # 10 + 5
|
|
assert holdings["148070"]["quantity"] == 3 # 5 - 2
|
|
|
|
|
|
def test_apply_rebalance_insufficient_quantity(client: TestClient, auth_headers):
|
|
"""매도 수량이 보유량을 초과하면 400 에러."""
|
|
pid = _setup_portfolio_with_holdings(client, auth_headers)
|
|
|
|
response = client.post(
|
|
f"/api/portfolios/{pid}/rebalance/apply",
|
|
json={
|
|
"items": [
|
|
{"ticker": "148070", "action": "sell", "quantity": 10, "price": 110000},
|
|
]
|
|
},
|
|
headers=auth_headers,
|
|
)
|
|
assert response.status_code == 400
|
|
|
|
|
|
def test_min_trade_amount_filters_small_trades(client: TestClient, auth_headers):
|
|
"""min_trade_amount 미만 거래는 hold로 변경된다."""
|
|
pid = _setup_portfolio_with_holdings(client, auth_headers)
|
|
|
|
# With very high min_trade_amount, all trades should become hold
|
|
response = client.post(
|
|
f"/api/portfolios/{pid}/rebalance/calculate",
|
|
json={
|
|
"strategy": "full_rebalance",
|
|
"prices": {"069500": 50000, "148070": 110000},
|
|
"min_trade_amount": 99999999,
|
|
},
|
|
headers=auth_headers,
|
|
)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
for item in data["items"]:
|
|
assert item["action"] == "hold"
|
|
assert item["diff_quantity"] == 0
|
|
|
|
|
|
def test_min_trade_amount_allows_large_trades(client: TestClient, auth_headers):
|
|
"""min_trade_amount 이상 거래는 정상 처리된다."""
|
|
pid = _setup_portfolio_with_holdings(client, auth_headers)
|
|
|
|
# Use skewed prices to create a meaningful rebalancing diff
|
|
# 069500: 10 * 30000 = 300,000 / 148070: 5 * 200000 = 1,000,000
|
|
# total = 1,300,000, target each 50% = 650,000
|
|
# 069500 needs buy: (650000-300000)/30000 = 11 shares => 330,000 trade value
|
|
response = client.post(
|
|
f"/api/portfolios/{pid}/rebalance/calculate",
|
|
json={
|
|
"strategy": "full_rebalance",
|
|
"prices": {"069500": 30000, "148070": 200000},
|
|
"min_trade_amount": 1,
|
|
},
|
|
headers=auth_headers,
|
|
)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
# At least one item should have buy or sell action
|
|
actions = [item["action"] for item in data["items"]]
|
|
assert "buy" in actions or "sell" in actions
|