diff --git a/src/main.py b/src/main.py index ca7c11e..f8ecf89 100644 --- a/src/main.py +++ b/src/main.py @@ -113,10 +113,13 @@ def _determine_order_quantity( total_cash: float, candidate: ScanCandidate | None, settings: Settings | None, + open_position: dict[str, Any] | None = None, ) -> int: """Determine order quantity using volatility-aware position sizing.""" - if action != "BUY": - return 1 + if action == "SELL": + if open_position is None: + return 0 + return int(open_position.get("quantity") or 0) if current_price <= 0 or total_cash <= 0: return 0 @@ -466,12 +469,18 @@ async def trading_cycle( trade_price = current_price trade_pnl = 0.0 if decision.action in ("BUY", "SELL"): + sell_position = ( + get_open_position(db_conn, stock_code, market.code) + if decision.action == "SELL" + else None + ) quantity = _determine_order_quantity( action=decision.action, current_price=current_price, total_cash=total_cash, candidate=candidate, settings=settings, + open_position=sell_position, ) if quantity <= 0: logger.info( @@ -891,12 +900,18 @@ async def run_daily_session( trade_pnl = 0.0 order_succeeded = True if decision.action in ("BUY", "SELL"): + daily_sell_position = ( + get_open_position(db_conn, stock_code, market.code) + if decision.action == "SELL" + else None + ) quantity = _determine_order_quantity( action=decision.action, current_price=stock_data["current_price"], total_cash=total_cash, candidate=candidate_map.get(stock_code), settings=settings, + open_position=daily_sell_position, ) if quantity <= 0: logger.info( diff --git a/tests/test_main.py b/tests/test_main.py index 7f4518a..cd42d38 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -14,6 +14,7 @@ from src.evolution.scorecard import DailyScorecard from src.logging.decision_logger import DecisionLogger from src.main import ( _apply_dashboard_flag, + _determine_order_quantity, _handle_market_close, _run_context_scheduler, _run_evolution_loop, @@ -68,6 +69,90 @@ def _make_sell_match(stock_code: str = "005930") -> ScenarioMatch: ) +class TestDetermineOrderQuantity: + """Test _determine_order_quantity() helper function.""" + + def test_sell_returns_position_quantity(self) -> None: + """SELL action should return actual held quantity from open_position.""" + open_pos = {"decision_id": "abc", "price": 100.0, "quantity": 7} + result = _determine_order_quantity( + action="SELL", + current_price=105.0, + total_cash=50000.0, + candidate=None, + settings=None, + open_position=open_pos, + ) + assert result == 7 + + def test_sell_without_position_returns_zero(self) -> None: + """SELL with no open_position should return 0 (no shares to sell).""" + result = _determine_order_quantity( + action="SELL", + current_price=105.0, + total_cash=50000.0, + candidate=None, + settings=None, + open_position=None, + ) + assert result == 0 + + def test_sell_with_zero_quantity_returns_zero(self) -> None: + """SELL with position quantity=0 should return 0.""" + open_pos = {"decision_id": "abc", "price": 100.0, "quantity": 0} + result = _determine_order_quantity( + action="SELL", + current_price=105.0, + total_cash=50000.0, + candidate=None, + settings=None, + open_position=open_pos, + ) + assert result == 0 + + def test_buy_without_position_sizing_returns_one(self) -> None: + """BUY with no settings should return 1 (default).""" + result = _determine_order_quantity( + action="BUY", + current_price=50000.0, + total_cash=1000000.0, + candidate=None, + settings=None, + ) + assert result == 1 + + def test_buy_with_zero_cash_returns_zero(self) -> None: + """BUY with no cash should return 0.""" + result = _determine_order_quantity( + action="BUY", + current_price=50000.0, + total_cash=0.0, + candidate=None, + settings=None, + ) + assert result == 0 + + def test_buy_with_position_sizing_calculates_correctly(self) -> None: + """BUY with position sizing should calculate quantity from budget.""" + settings = MagicMock(spec=Settings) + settings.POSITION_SIZING_ENABLED = True + settings.POSITION_VOLATILITY_TARGET_SCORE = 50.0 + settings.POSITION_BASE_ALLOCATION_PCT = 10.0 + settings.POSITION_MAX_ALLOCATION_PCT = 30.0 + settings.POSITION_MIN_ALLOCATION_PCT = 1.0 + + # total_cash=1,000,000 * 10% = 100,000 budget + # 100,000 // 50,000 = 2 shares + result = _determine_order_quantity( + action="BUY", + current_price=50000.0, + total_cash=1000000.0, + candidate=None, + settings=settings, + ) + assert result == 2 + + class TestSafeFloat: """Test safe_float() helper function.""" @@ -1396,6 +1481,108 @@ async def test_hold_overridden_to_sell_when_stop_loss_triggered() -> None: assert broker.send_order.call_args.kwargs["order_type"] == "SELL" +@pytest.mark.asyncio +async def test_sell_order_uses_actual_held_quantity() -> None: + """SELL order should use the actual quantity held, not hardcoded 1.""" + db_conn = init_db(":memory:") + decision_logger = DecisionLogger(db_conn) + + buy_decision_id = decision_logger.log_decision( + stock_code="005930", + market="KR", + exchange_code="KRX", + action="BUY", + confidence=90, + rationale="entry", + context_snapshot={}, + input_data={}, + ) + # Bought 5 shares at 100.0 + log_trade( + conn=db_conn, + stock_code="005930", + action="BUY", + confidence=90, + rationale="entry", + quantity=5, + price=100.0, + market="KR", + exchange_code="KRX", + decision_id=buy_decision_id, + ) + + broker = MagicMock() + broker.get_current_price = AsyncMock(return_value=(95.0, -5.0, 0.0)) + broker.get_balance = AsyncMock( + return_value={ + "output2": [ + { + "tot_evlu_amt": "100000", + "dnca_tot_amt": "10000", + "pchs_amt_smtl_amt": "90000", + } + ] + } + ) + broker.send_order = AsyncMock(return_value={"msg1": "OK"}) + + scenario = StockScenario( + condition=StockCondition(rsi_below=30), + action=ScenarioAction.BUY, + confidence=88, + stop_loss_pct=-2.0, + rationale="stop loss policy", + ) + playbook = DayPlaybook( + date=date(2026, 2, 8), + market="KR", + stock_playbooks=[ + {"stock_code": "005930", "stock_name": "Samsung", "scenarios": [scenario]} + ], + ) + engine = MagicMock(spec=ScenarioEngine) + engine.evaluate = MagicMock(return_value=_make_hold_match()) + + market = MagicMock() + market.name = "Korea" + market.code = "KR" + market.exchange_code = "KRX" + market.is_domestic = True + + telegram = MagicMock() + telegram.notify_trade_execution = AsyncMock() + telegram.notify_fat_finger = AsyncMock() + telegram.notify_circuit_breaker = AsyncMock() + telegram.notify_scenario_matched = AsyncMock() + + await trading_cycle( + broker=broker, + overseas_broker=MagicMock(), + scenario_engine=engine, + playbook=playbook, + risk=MagicMock(), + db_conn=db_conn, + decision_logger=decision_logger, + context_store=MagicMock( + get_latest_timeframe=MagicMock(return_value=None), + set_context=MagicMock(), + ), + criticality_assessor=MagicMock( + assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")), + get_timeout=MagicMock(return_value=5.0), + ), + telegram=telegram, + market=market, + stock_code="005930", + scan_candidates={}, + ) + + broker.send_order.assert_called_once() + call_kwargs = broker.send_order.call_args.kwargs + assert call_kwargs["order_type"] == "SELL" + assert call_kwargs["quantity"] == 5 # actual held quantity, not 1 + + @pytest.mark.asyncio async def test_handle_market_close_runs_daily_review_flow() -> None: """Market close should aggregate, create scorecard, lessons, and notify."""