Compare commits

..

1 Commits

Author SHA1 Message Date
agentson
5844ec5ad3 fix: enforce take_profit_pct in HOLD evaluation loop (#163)
Some checks failed
CI / test (pull_request) Has been cancelled
HOLD 판정 후 보유 포지션에 대해 stop_loss와 함께 take_profit도 체크하도록 수정.
AI가 생성한 take_profit_pct가 실제 거래 로직에 반영되지 않던 구조적 결함 수정.

- HOLD 블록에서 loss_pct >= take_profit_threshold 조건 추가
- stop_loss와 상호 배타적으로 동작 (stop_loss 우선 체크)
- take_profit 기본값 3.0% (playbook 없는 경우 적용)
- 테스트 2개 추가:
  - test_hold_overridden_to_sell_when_take_profit_triggered
  - test_hold_not_overridden_when_between_stop_loss_and_take_profit

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 03:00:52 +09:00
2 changed files with 128 additions and 111 deletions

View File

@@ -113,13 +113,10 @@ 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 == "SELL":
if open_position is None:
return 0
return int(open_position.get("quantity") or 0)
if action != "BUY":
return 1
if current_price <= 0 or total_cash <= 0:
return 0
@@ -390,8 +387,10 @@ async def trading_cycle(
if entry_price > 0:
loss_pct = (current_price - entry_price) / entry_price * 100
stop_loss_threshold = -2.0
take_profit_threshold = 3.0
if stock_playbook and stock_playbook.scenarios:
stop_loss_threshold = stock_playbook.scenarios[0].stop_loss_pct
take_profit_threshold = stock_playbook.scenarios[0].take_profit_pct
if loss_pct <= stop_loss_threshold:
decision = TradeDecision(
@@ -409,6 +408,22 @@ async def trading_cycle(
loss_pct,
stop_loss_threshold,
)
elif loss_pct >= take_profit_threshold:
decision = TradeDecision(
action="SELL",
confidence=90,
rationale=(
f"Take-profit triggered ({loss_pct:.2f}% >= "
f"{take_profit_threshold:.2f}%)"
),
)
logger.info(
"Take-profit override for %s (%s): %.2f%% >= %.2f%%",
stock_code,
market.name,
loss_pct,
take_profit_threshold,
)
logger.info(
"Decision for %s (%s): %s (confidence=%d)",
stock_code,
@@ -469,18 +484,12 @@ 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(
@@ -900,18 +909,12 @@ 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(

View File

@@ -14,7 +14,6 @@ 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,
@@ -69,90 +68,6 @@ 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."""
@@ -1482,8 +1397,8 @@ async def test_hold_overridden_to_sell_when_stop_loss_triggered() -> None:
@pytest.mark.asyncio
async def test_sell_order_uses_actual_held_quantity() -> None:
"""SELL order should use the actual quantity held, not hardcoded 1."""
async def test_hold_overridden_to_sell_when_take_profit_triggered() -> None:
"""HOLD decision should be overridden to SELL when take-profit threshold is reached."""
db_conn = init_db(":memory:")
decision_logger = DecisionLogger(db_conn)
@@ -1497,14 +1412,13 @@ async def test_sell_order_uses_actual_held_quantity() -> None:
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,
quantity=1,
price=100.0,
market="KR",
exchange_code="KRX",
@@ -1512,7 +1426,8 @@ async def test_sell_order_uses_actual_held_quantity() -> None:
)
broker = MagicMock()
broker.get_current_price = AsyncMock(return_value=(95.0, -5.0, 0.0))
# Current price 106.0 → +6% gain, above take_profit_pct=3.0
broker.get_current_price = AsyncMock(return_value=(106.0, 6.0, 0.0))
broker.get_balance = AsyncMock(
return_value={
"output2": [
@@ -1531,7 +1446,8 @@ async def test_sell_order_uses_actual_held_quantity() -> None:
action=ScenarioAction.BUY,
confidence=88,
stop_loss_pct=-2.0,
rationale="stop loss policy",
take_profit_pct=3.0,
rationale="take profit policy",
)
playbook = DayPlaybook(
date=date(2026, 2, 8),
@@ -1578,9 +1494,107 @@ async def test_sell_order_uses_actual_held_quantity() -> None:
)
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
assert broker.send_order.call_args.kwargs["order_type"] == "SELL"
@pytest.mark.asyncio
async def test_hold_not_overridden_when_between_stop_loss_and_take_profit() -> None:
"""HOLD should remain HOLD when P&L is within stop-loss and take-profit bounds."""
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={},
)
log_trade(
conn=db_conn,
stock_code="005930",
action="BUY",
confidence=90,
rationale="entry",
quantity=1,
price=100.0,
market="KR",
exchange_code="KRX",
decision_id=buy_decision_id,
)
broker = MagicMock()
# Current price 101.0 → +1% gain, within [-2%, +3%] range
broker.get_current_price = AsyncMock(return_value=(101.0, 1.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,
take_profit_pct=3.0,
rationale="within range 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_not_called()
@pytest.mark.asyncio