Compare commits

..

1 Commits

Author SHA1 Message Date
agentson
1adb85926d fix: use actual held quantity for SELL orders instead of hardcoded 1 (#164)
Some checks failed
CI / test (pull_request) Has been cancelled
_determine_order_quantity()에서 SELL 시 항상 1을 반환하던 버그 수정.
DB에서 실제 보유 수량을 조회해 전량 청산이 가능하도록 변경.

- _determine_order_quantity에 open_position 파라미터 추가
- SELL 시 open_position["quantity"] 반환, 포지션 없으면 0 반환
- trading_cycle 및 run_daily_session 호출 지점 모두 수정
- _determine_order_quantity 임포트 및 유닛 테스트 클래스 추가 (6개)
- SELL 실제 수량 사용 통합 테스트 추가 (quantity=5 검증)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 03:03:50 +09:00
4 changed files with 206 additions and 125 deletions

View File

@@ -237,28 +237,6 @@ def get_open_position(
return {"decision_id": row[1], "price": row[2], "quantity": row[3]} return {"decision_id": row[1], "price": row[2], "quantity": row[3]}
def get_open_positions_by_market(
conn: sqlite3.Connection, market: str
) -> list[str]:
"""Return stock codes with a net positive position in the given market.
Uses net BUY - SELL quantity aggregation to avoid false positives from
the simpler "latest record is BUY" heuristic. A stock is considered
open only when the bot's own recorded trades leave a positive net quantity.
"""
cursor = conn.execute(
"""
SELECT stock_code
FROM trades
WHERE market = ?
GROUP BY stock_code
HAVING SUM(CASE WHEN action = 'BUY' THEN quantity ELSE -quantity END) > 0
""",
(market,),
)
return [row[0] for row in cursor.fetchall()]
def get_recent_symbols( def get_recent_symbols(
conn: sqlite3.Connection, market: str, limit: int = 30 conn: sqlite3.Connection, market: str, limit: int = 30
) -> list[str]: ) -> list[str]:

View File

@@ -32,7 +32,6 @@ from src.core.risk_manager import CircuitBreakerTripped, FatFingerRejected, Risk
from src.db import ( from src.db import (
get_latest_buy_trade, get_latest_buy_trade,
get_open_position, get_open_position,
get_open_positions_by_market,
get_recent_symbols, get_recent_symbols,
init_db, init_db,
log_trade, log_trade,
@@ -114,10 +113,13 @@ 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 != "BUY": if action == "SELL":
return 1 if open_position is None:
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
@@ -467,12 +469,18 @@ 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(
@@ -892,12 +900,18 @@ 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(
@@ -1865,21 +1879,7 @@ async def run(settings: Settings) -> None:
logger.error("Smart Scanner failed for %s: %s", market.name, exc) logger.error("Smart Scanner failed for %s: %s", market.name, exc)
# Get active stocks from scanner (dynamic, no static fallback) # Get active stocks from scanner (dynamic, no static fallback)
# Also include current holdings so stop-loss / take-profit stock_codes = active_stocks.get(market.code, [])
# can trigger even when a position drops off the scanner.
scanner_codes = active_stocks.get(market.code, [])
held_codes = get_open_positions_by_market(db_conn, market.code)
# Union: scanner candidates first, then holdings not already present.
# dict.fromkeys preserves insertion order and removes duplicates.
stock_codes = list(dict.fromkeys(scanner_codes + held_codes))
if held_codes:
new_held = [c for c in held_codes if c not in set(scanner_codes)]
if new_held:
logger.info(
"Holdings added to loop for %s (not in scanner): %s",
market.name,
new_held,
)
if not stock_codes: if not stock_codes:
logger.debug("No active stocks for market %s", market.code) logger.debug("No active stocks for market %s", market.code)
continue continue

View File

@@ -1,6 +1,6 @@
"""Tests for database helper functions.""" """Tests for database helper functions."""
from src.db import get_open_position, get_open_positions_by_market, init_db, log_trade from src.db import get_open_position, init_db, log_trade
def test_get_open_position_returns_latest_buy() -> None: def test_get_open_position_returns_latest_buy() -> None:
@@ -58,87 +58,3 @@ def test_get_open_position_returns_none_when_latest_is_sell() -> None:
def test_get_open_position_returns_none_when_no_trades() -> None: def test_get_open_position_returns_none_when_no_trades() -> None:
conn = init_db(":memory:") conn = init_db(":memory:")
assert get_open_position(conn, "AAPL", "US_NASDAQ") is None assert get_open_position(conn, "AAPL", "US_NASDAQ") is None
# --- get_open_positions_by_market tests ---
def test_get_open_positions_by_market_returns_net_positive_stocks() -> None:
"""Stocks with net BUY quantity > 0 are included."""
conn = init_db(":memory:")
log_trade(
conn=conn, stock_code="005930", action="BUY", confidence=90,
rationale="entry", quantity=5, price=70000.0, market="KR",
exchange_code="KRX", decision_id="d1",
)
log_trade(
conn=conn, stock_code="000660", action="BUY", confidence=85,
rationale="entry", quantity=3, price=100000.0, market="KR",
exchange_code="KRX", decision_id="d2",
)
result = get_open_positions_by_market(conn, "KR")
assert set(result) == {"005930", "000660"}
def test_get_open_positions_by_market_excludes_fully_sold_stocks() -> None:
"""Stocks where BUY qty == SELL qty are excluded (net qty = 0)."""
conn = init_db(":memory:")
log_trade(
conn=conn, stock_code="005930", action="BUY", confidence=90,
rationale="entry", quantity=3, price=70000.0, market="KR",
exchange_code="KRX", decision_id="d1",
)
log_trade(
conn=conn, stock_code="005930", action="SELL", confidence=95,
rationale="exit", quantity=3, price=71000.0, market="KR",
exchange_code="KRX", decision_id="d2",
)
result = get_open_positions_by_market(conn, "KR")
assert "005930" not in result
def test_get_open_positions_by_market_includes_partially_sold_stocks() -> None:
"""Stocks with partial SELL (net qty > 0) are still included."""
conn = init_db(":memory:")
log_trade(
conn=conn, stock_code="005930", action="BUY", confidence=90,
rationale="entry", quantity=5, price=70000.0, market="KR",
exchange_code="KRX", decision_id="d1",
)
log_trade(
conn=conn, stock_code="005930", action="SELL", confidence=95,
rationale="partial exit", quantity=2, price=71000.0, market="KR",
exchange_code="KRX", decision_id="d2",
)
result = get_open_positions_by_market(conn, "KR")
assert "005930" in result
def test_get_open_positions_by_market_is_market_scoped() -> None:
"""Only stocks from the specified market are returned."""
conn = init_db(":memory:")
log_trade(
conn=conn, stock_code="005930", action="BUY", confidence=90,
rationale="entry", quantity=3, price=70000.0, market="KR",
exchange_code="KRX", decision_id="d1",
)
log_trade(
conn=conn, stock_code="AAPL", action="BUY", confidence=85,
rationale="entry", quantity=2, price=200.0, market="NASD",
exchange_code="NAS", decision_id="d2",
)
kr_result = get_open_positions_by_market(conn, "KR")
nasd_result = get_open_positions_by_market(conn, "NASD")
assert kr_result == ["005930"]
assert nasd_result == ["AAPL"]
def test_get_open_positions_by_market_returns_empty_when_no_trades() -> None:
"""Empty list returned when no trades exist for the market."""
conn = init_db(":memory:")
assert get_open_positions_by_market(conn, "KR") == []

View File

@@ -14,6 +14,7 @@ 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,
@@ -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: class TestSafeFloat:
"""Test safe_float() helper function.""" """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" 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 @pytest.mark.asyncio
async def test_handle_market_close_runs_daily_review_flow() -> None: async def test_handle_market_close_runs_daily_review_flow() -> None:
"""Market close should aggregate, create scorecard, lessons, and notify.""" """Market close should aggregate, create scorecard, lessons, and notify."""