From 62ac92eaaf4718e7915338c58efe7d14f241e8a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=A8=B8=EB=8B=88=ED=8E=98=EB=8B=88?= Date: Wed, 18 Mar 2026 20:57:43 +0900 Subject: [PATCH] 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 --- backend/app/api/portfolio.py | 1 + backend/app/schemas/portfolio.py | 1 + backend/app/services/rebalance.py | 23 +++++++++--- backend/tests/e2e/test_rebalance_flow.py | 45 ++++++++++++++++++++++++ 4 files changed, 65 insertions(+), 5 deletions(-) diff --git a/backend/app/api/portfolio.py b/backend/app/api/portfolio.py index f9f7fed..3d1ee3a 100644 --- a/backend/app/api/portfolio.py +++ b/backend/app/api/portfolio.py @@ -366,6 +366,7 @@ async def calculate_rebalance_manual( strategy=data.strategy, manual_prices=data.prices, additional_amount=data.additional_amount, + min_trade_amount=data.min_trade_amount, ) diff --git a/backend/app/schemas/portfolio.py b/backend/app/schemas/portfolio.py index 8760f81..b96df2d 100644 --- a/backend/app/schemas/portfolio.py +++ b/backend/app/schemas/portfolio.py @@ -208,6 +208,7 @@ class RebalanceCalculateRequest(BaseModel): strategy: str = Field(..., pattern="^(full_rebalance|additional_buy)$") prices: Optional[dict[str, Decimal]] = None additional_amount: Optional[Decimal] = Field(None, ge=0) + min_trade_amount: Optional[Decimal] = Field(default=Decimal("10000"), ge=0) class RebalanceCalculateItem(BaseModel): diff --git a/backend/app/services/rebalance.py b/backend/app/services/rebalance.py index ae2bc53..1a9c47c 100644 --- a/backend/app/services/rebalance.py +++ b/backend/app/services/rebalance.py @@ -193,6 +193,7 @@ class RebalanceService: strategy: str, manual_prices: Optional[Dict[str, Decimal]] = None, additional_amount: Optional[Decimal] = None, + min_trade_amount: Optional[Decimal] = None, ): """Calculate rebalance with optional manual prices and strategy selection.""" from app.schemas.portfolio import RebalanceCalculateItem, RebalanceCalculateResponse @@ -228,17 +229,29 @@ class RebalanceService: current_values, total_assets, stock_names, prev_prices, start_prices, ) - return RebalanceCalculateResponse( - portfolio_id=portfolio.id, - total_assets=total_assets, - items=items, - ) else: # additional_buy items = self._calc_additional_buy( all_tickers, targets, holdings, current_prices, current_values, total_assets, additional_amount, 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( portfolio_id=portfolio.id, total_assets=total_assets, diff --git a/backend/tests/e2e/test_rebalance_flow.py b/backend/tests/e2e/test_rebalance_flow.py index f795104..f663db8 100644 --- a/backend/tests/e2e/test_rebalance_flow.py +++ b/backend/tests/e2e/test_rebalance_flow.py @@ -158,3 +158,48 @@ def test_apply_rebalance_insufficient_quantity(client: TestClient, auth_headers) 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