feat: add minimum trade amount filter to rebalancing calculation
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>
This commit is contained in:
parent
01f86298c4
commit
62ac92eaaf
@ -366,6 +366,7 @@ async def calculate_rebalance_manual(
|
|||||||
strategy=data.strategy,
|
strategy=data.strategy,
|
||||||
manual_prices=data.prices,
|
manual_prices=data.prices,
|
||||||
additional_amount=data.additional_amount,
|
additional_amount=data.additional_amount,
|
||||||
|
min_trade_amount=data.min_trade_amount,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -208,6 +208,7 @@ class RebalanceCalculateRequest(BaseModel):
|
|||||||
strategy: str = Field(..., pattern="^(full_rebalance|additional_buy)$")
|
strategy: str = Field(..., pattern="^(full_rebalance|additional_buy)$")
|
||||||
prices: Optional[dict[str, Decimal]] = None
|
prices: Optional[dict[str, Decimal]] = None
|
||||||
additional_amount: Optional[Decimal] = Field(None, ge=0)
|
additional_amount: Optional[Decimal] = Field(None, ge=0)
|
||||||
|
min_trade_amount: Optional[Decimal] = Field(default=Decimal("10000"), ge=0)
|
||||||
|
|
||||||
|
|
||||||
class RebalanceCalculateItem(BaseModel):
|
class RebalanceCalculateItem(BaseModel):
|
||||||
|
|||||||
@ -193,6 +193,7 @@ class RebalanceService:
|
|||||||
strategy: str,
|
strategy: str,
|
||||||
manual_prices: Optional[Dict[str, Decimal]] = None,
|
manual_prices: Optional[Dict[str, Decimal]] = None,
|
||||||
additional_amount: Optional[Decimal] = None,
|
additional_amount: Optional[Decimal] = None,
|
||||||
|
min_trade_amount: Optional[Decimal] = None,
|
||||||
):
|
):
|
||||||
"""Calculate rebalance with optional manual prices and strategy selection."""
|
"""Calculate rebalance with optional manual prices and strategy selection."""
|
||||||
from app.schemas.portfolio import RebalanceCalculateItem, RebalanceCalculateResponse
|
from app.schemas.portfolio import RebalanceCalculateItem, RebalanceCalculateResponse
|
||||||
@ -228,17 +229,29 @@ class RebalanceService:
|
|||||||
current_values, total_assets, stock_names,
|
current_values, total_assets, stock_names,
|
||||||
prev_prices, start_prices,
|
prev_prices, start_prices,
|
||||||
)
|
)
|
||||||
return RebalanceCalculateResponse(
|
|
||||||
portfolio_id=portfolio.id,
|
|
||||||
total_assets=total_assets,
|
|
||||||
items=items,
|
|
||||||
)
|
|
||||||
else: # additional_buy
|
else: # additional_buy
|
||||||
items = self._calc_additional_buy(
|
items = self._calc_additional_buy(
|
||||||
all_tickers, targets, holdings, current_prices,
|
all_tickers, targets, holdings, current_prices,
|
||||||
current_values, total_assets, additional_amount,
|
current_values, total_assets, additional_amount,
|
||||||
stock_names, prev_prices, start_prices,
|
stock_names, prev_prices, start_prices,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Filter out trades below min_trade_amount
|
||||||
|
if min_trade_amount and min_trade_amount > 0:
|
||||||
|
for item in items:
|
||||||
|
if item.action != "hold":
|
||||||
|
trade_value = abs(item.diff_quantity) * item.current_price
|
||||||
|
if trade_value < min_trade_amount:
|
||||||
|
item.diff_quantity = 0
|
||||||
|
item.action = "hold"
|
||||||
|
|
||||||
|
if strategy == "full_rebalance":
|
||||||
|
return RebalanceCalculateResponse(
|
||||||
|
portfolio_id=portfolio.id,
|
||||||
|
total_assets=total_assets,
|
||||||
|
items=items,
|
||||||
|
)
|
||||||
|
else:
|
||||||
return RebalanceCalculateResponse(
|
return RebalanceCalculateResponse(
|
||||||
portfolio_id=portfolio.id,
|
portfolio_id=portfolio.id,
|
||||||
total_assets=total_assets,
|
total_assets=total_assets,
|
||||||
|
|||||||
@ -158,3 +158,48 @@ def test_apply_rebalance_insufficient_quantity(client: TestClient, auth_headers)
|
|||||||
headers=auth_headers,
|
headers=auth_headers,
|
||||||
)
|
)
|
||||||
assert response.status_code == 400
|
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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user