Compare commits
1 Commits
feature/is
...
feature/is
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5844ec5ad3 |
37
src/main.py
37
src/main.py
@@ -113,13 +113,10 @@ def _determine_order_quantity(
|
|||||||
total_cash: float,
|
total_cash: float,
|
||||||
candidate: ScanCandidate | None,
|
candidate: ScanCandidate | None,
|
||||||
settings: Settings | None,
|
settings: Settings | None,
|
||||||
open_position: dict[str, Any] | None = None,
|
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Determine order quantity using volatility-aware position sizing."""
|
"""Determine order quantity using volatility-aware position sizing."""
|
||||||
if action == "SELL":
|
if action != "BUY":
|
||||||
if open_position is None:
|
return 1
|
||||||
return 0
|
|
||||||
return int(open_position.get("quantity") or 0)
|
|
||||||
if current_price <= 0 or total_cash <= 0:
|
if current_price <= 0 or total_cash <= 0:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
@@ -390,8 +387,10 @@ async def trading_cycle(
|
|||||||
if entry_price > 0:
|
if entry_price > 0:
|
||||||
loss_pct = (current_price - entry_price) / entry_price * 100
|
loss_pct = (current_price - entry_price) / entry_price * 100
|
||||||
stop_loss_threshold = -2.0
|
stop_loss_threshold = -2.0
|
||||||
|
take_profit_threshold = 3.0
|
||||||
if stock_playbook and stock_playbook.scenarios:
|
if stock_playbook and stock_playbook.scenarios:
|
||||||
stop_loss_threshold = stock_playbook.scenarios[0].stop_loss_pct
|
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:
|
if loss_pct <= stop_loss_threshold:
|
||||||
decision = TradeDecision(
|
decision = TradeDecision(
|
||||||
@@ -409,6 +408,22 @@ async def trading_cycle(
|
|||||||
loss_pct,
|
loss_pct,
|
||||||
stop_loss_threshold,
|
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(
|
logger.info(
|
||||||
"Decision for %s (%s): %s (confidence=%d)",
|
"Decision for %s (%s): %s (confidence=%d)",
|
||||||
stock_code,
|
stock_code,
|
||||||
@@ -469,18 +484,12 @@ async def trading_cycle(
|
|||||||
trade_price = current_price
|
trade_price = current_price
|
||||||
trade_pnl = 0.0
|
trade_pnl = 0.0
|
||||||
if decision.action in ("BUY", "SELL"):
|
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(
|
quantity = _determine_order_quantity(
|
||||||
action=decision.action,
|
action=decision.action,
|
||||||
current_price=current_price,
|
current_price=current_price,
|
||||||
total_cash=total_cash,
|
total_cash=total_cash,
|
||||||
candidate=candidate,
|
candidate=candidate,
|
||||||
settings=settings,
|
settings=settings,
|
||||||
open_position=sell_position,
|
|
||||||
)
|
)
|
||||||
if quantity <= 0:
|
if quantity <= 0:
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -900,18 +909,12 @@ async def run_daily_session(
|
|||||||
trade_pnl = 0.0
|
trade_pnl = 0.0
|
||||||
order_succeeded = True
|
order_succeeded = True
|
||||||
if decision.action in ("BUY", "SELL"):
|
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(
|
quantity = _determine_order_quantity(
|
||||||
action=decision.action,
|
action=decision.action,
|
||||||
current_price=stock_data["current_price"],
|
current_price=stock_data["current_price"],
|
||||||
total_cash=total_cash,
|
total_cash=total_cash,
|
||||||
candidate=candidate_map.get(stock_code),
|
candidate=candidate_map.get(stock_code),
|
||||||
settings=settings,
|
settings=settings,
|
||||||
open_position=daily_sell_position,
|
|
||||||
)
|
)
|
||||||
if quantity <= 0:
|
if quantity <= 0:
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ from src.evolution.scorecard import DailyScorecard
|
|||||||
from src.logging.decision_logger import DecisionLogger
|
from src.logging.decision_logger import DecisionLogger
|
||||||
from src.main import (
|
from src.main import (
|
||||||
_apply_dashboard_flag,
|
_apply_dashboard_flag,
|
||||||
_determine_order_quantity,
|
|
||||||
_handle_market_close,
|
_handle_market_close,
|
||||||
_run_context_scheduler,
|
_run_context_scheduler,
|
||||||
_run_evolution_loop,
|
_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:
|
class TestSafeFloat:
|
||||||
"""Test safe_float() helper function."""
|
"""Test safe_float() helper function."""
|
||||||
|
|
||||||
@@ -1482,8 +1397,8 @@ async def test_hold_overridden_to_sell_when_stop_loss_triggered() -> None:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_sell_order_uses_actual_held_quantity() -> None:
|
async def test_hold_overridden_to_sell_when_take_profit_triggered() -> None:
|
||||||
"""SELL order should use the actual quantity held, not hardcoded 1."""
|
"""HOLD decision should be overridden to SELL when take-profit threshold is reached."""
|
||||||
db_conn = init_db(":memory:")
|
db_conn = init_db(":memory:")
|
||||||
decision_logger = DecisionLogger(db_conn)
|
decision_logger = DecisionLogger(db_conn)
|
||||||
|
|
||||||
@@ -1497,14 +1412,13 @@ async def test_sell_order_uses_actual_held_quantity() -> None:
|
|||||||
context_snapshot={},
|
context_snapshot={},
|
||||||
input_data={},
|
input_data={},
|
||||||
)
|
)
|
||||||
# Bought 5 shares at 100.0
|
|
||||||
log_trade(
|
log_trade(
|
||||||
conn=db_conn,
|
conn=db_conn,
|
||||||
stock_code="005930",
|
stock_code="005930",
|
||||||
action="BUY",
|
action="BUY",
|
||||||
confidence=90,
|
confidence=90,
|
||||||
rationale="entry",
|
rationale="entry",
|
||||||
quantity=5,
|
quantity=1,
|
||||||
price=100.0,
|
price=100.0,
|
||||||
market="KR",
|
market="KR",
|
||||||
exchange_code="KRX",
|
exchange_code="KRX",
|
||||||
@@ -1512,7 +1426,8 @@ async def test_sell_order_uses_actual_held_quantity() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
broker = MagicMock()
|
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(
|
broker.get_balance = AsyncMock(
|
||||||
return_value={
|
return_value={
|
||||||
"output2": [
|
"output2": [
|
||||||
@@ -1531,7 +1446,8 @@ async def test_sell_order_uses_actual_held_quantity() -> None:
|
|||||||
action=ScenarioAction.BUY,
|
action=ScenarioAction.BUY,
|
||||||
confidence=88,
|
confidence=88,
|
||||||
stop_loss_pct=-2.0,
|
stop_loss_pct=-2.0,
|
||||||
rationale="stop loss policy",
|
take_profit_pct=3.0,
|
||||||
|
rationale="take profit policy",
|
||||||
)
|
)
|
||||||
playbook = DayPlaybook(
|
playbook = DayPlaybook(
|
||||||
date=date(2026, 2, 8),
|
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()
|
broker.send_order.assert_called_once()
|
||||||
call_kwargs = broker.send_order.call_args.kwargs
|
assert broker.send_order.call_args.kwargs["order_type"] == "SELL"
|
||||||
assert call_kwargs["order_type"] == "SELL"
|
|
||||||
assert call_kwargs["quantity"] == 5 # actual held quantity, not 1
|
|
||||||
|
@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
|
@pytest.mark.asyncio
|
||||||
|
|||||||
Reference in New Issue
Block a user