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:
머니페니 2026-03-18 20:57:43 +09:00
parent 01f86298c4
commit 62ac92eaaf
4 changed files with 65 additions and 5 deletions

View File

@ -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,
) )

View File

@ -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):

View File

@ -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,

View File

@ -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