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
7 changed files with 55 additions and 1296 deletions

View File

@@ -42,7 +42,7 @@ from src.logging.decision_logger import DecisionLogger
from src.logging_config import setup_logging from src.logging_config import setup_logging
from src.markets.schedule import MarketInfo, get_next_market_open, get_open_markets from src.markets.schedule import MarketInfo, get_next_market_open, get_open_markets
from src.notifications.telegram_client import NotificationFilter, TelegramClient, TelegramCommandHandler from src.notifications.telegram_client import NotificationFilter, TelegramClient, TelegramCommandHandler
from src.strategy.models import DayPlaybook, MarketOutlook from src.strategy.models import DayPlaybook
from src.strategy.playbook_store import PlaybookStore from src.strategy.playbook_store import PlaybookStore
from src.strategy.pre_market_planner import PreMarketPlanner from src.strategy.pre_market_planner import PreMarketPlanner
from src.strategy.scenario_engine import ScenarioEngine from src.strategy.scenario_engine import ScenarioEngine
@@ -106,82 +106,6 @@ def _extract_symbol_from_holding(item: dict[str, Any]) -> str:
return "" return ""
def _extract_held_codes_from_balance(
balance_data: dict[str, Any],
*,
is_domestic: bool,
) -> list[str]:
"""Return stock codes with a positive orderable quantity from a balance response.
Uses the broker's live output1 as the source of truth so that partial fills
and manual external trades are always reflected correctly.
"""
output1 = balance_data.get("output1", [])
if isinstance(output1, dict):
output1 = [output1]
if not isinstance(output1, list):
return []
codes: list[str] = []
for holding in output1:
if not isinstance(holding, dict):
continue
code_key = "pdno" if is_domestic else "ovrs_pdno"
code = str(holding.get(code_key, "")).strip().upper()
if not code:
continue
if is_domestic:
qty = int(holding.get("ord_psbl_qty") or holding.get("hldg_qty") or 0)
else:
qty = int(holding.get("ovrs_cblc_qty") or holding.get("hldg_qty") or 0)
if qty > 0:
codes.append(code)
return codes
def _extract_held_qty_from_balance(
balance_data: dict[str, Any],
stock_code: str,
*,
is_domestic: bool,
) -> int:
"""Extract the broker-confirmed orderable quantity for a stock.
Uses the broker's live balance response (output1) as the source of truth
rather than the local DB, because DB records reflect order quantity which
may differ from actual fill quantity due to partial fills.
Domestic fields (VTTC8434R output1):
pdno — 종목코드
ord_psbl_qty — 주문가능수량 (preferred: excludes unsettled)
hldg_qty — 보유수량 (fallback)
Overseas fields (output1):
ovrs_pdno — 종목코드
ovrs_cblc_qty — 해외잔고수량 (preferred)
hldg_qty — 보유수량 (fallback)
"""
output1 = balance_data.get("output1", [])
if isinstance(output1, dict):
output1 = [output1]
if not isinstance(output1, list):
return 0
for holding in output1:
if not isinstance(holding, dict):
continue
code_key = "pdno" if is_domestic else "ovrs_pdno"
held_code = str(holding.get(code_key, "")).strip().upper()
if held_code != stock_code.strip().upper():
continue
if is_domestic:
qty = int(holding.get("ord_psbl_qty") or holding.get("hldg_qty") or 0)
else:
qty = int(holding.get("ovrs_cblc_qty") or holding.get("hldg_qty") or 0)
return qty
return 0
def _determine_order_quantity( def _determine_order_quantity(
*, *,
action: str, action: str,
@@ -189,40 +113,19 @@ def _determine_order_quantity(
total_cash: float, total_cash: float,
candidate: ScanCandidate | None, candidate: ScanCandidate | None,
settings: Settings | None, settings: Settings | None,
broker_held_qty: int = 0, open_position: dict[str, Any] | None = None,
playbook_allocation_pct: float | None = None,
scenario_confidence: int = 80,
) -> int: ) -> int:
"""Determine order quantity using volatility-aware position sizing. """Determine order quantity using volatility-aware position sizing."""
Priority:
1. playbook_allocation_pct (AI-specified) scaled by scenario_confidence
2. Fallback: volatility-score-based allocation from scanner candidate
"""
if action == "SELL": if action == "SELL":
return broker_held_qty 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
if settings is None or not settings.POSITION_SIZING_ENABLED: if settings is None or not settings.POSITION_SIZING_ENABLED:
return 1 return 1
# Use AI-specified allocation_pct if available
if playbook_allocation_pct is not None:
# Confidence scaling: confidence 80 → 1.0x, confidence 95 → 1.19x
confidence_scale = scenario_confidence / 80.0
effective_pct = min(
settings.POSITION_MAX_ALLOCATION_PCT,
max(
settings.POSITION_MIN_ALLOCATION_PCT,
playbook_allocation_pct * confidence_scale,
),
)
budget = total_cash * (effective_pct / 100.0)
quantity = int(budget // current_price)
return max(0, quantity)
# Fallback: volatility-score-based allocation
target_score = max(1.0, settings.POSITION_VOLATILITY_TARGET_SCORE) target_score = max(1.0, settings.POSITION_VOLATILITY_TARGET_SCORE)
observed_score = candidate.score if candidate else target_score observed_score = candidate.score if candidate else target_score
observed_score = max(1.0, min(100.0, observed_score)) observed_score = max(1.0, min(100.0, observed_score))
@@ -480,34 +383,6 @@ async def trading_cycle(
) )
stock_playbook = playbook.get_stock_playbook(stock_code) stock_playbook = playbook.get_stock_playbook(stock_code)
# 2.1. Apply market_outlook-based BUY confidence threshold
if decision.action == "BUY":
base_threshold = (settings.CONFIDENCE_THRESHOLD if settings else 80)
outlook = playbook.market_outlook
if outlook == MarketOutlook.BEARISH:
min_confidence = 90
elif outlook == MarketOutlook.BULLISH:
min_confidence = 75
else:
min_confidence = base_threshold
if match.confidence < min_confidence:
logger.info(
"BUY suppressed for %s (%s): confidence %d < %d (market_outlook=%s)",
stock_code,
market.name,
match.confidence,
min_confidence,
outlook.value,
)
decision = TradeDecision(
action="HOLD",
confidence=match.confidence,
rationale=(
f"BUY confidence {match.confidence} < {min_confidence} "
f"(market_outlook={outlook.value})"
),
)
if decision.action == "HOLD": if decision.action == "HOLD":
open_position = get_open_position(db_conn, stock_code, market.code) open_position = get_open_position(db_conn, stock_code, market.code)
if open_position: if open_position:
@@ -515,10 +390,8 @@ 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(
@@ -536,22 +409,6 @@ 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,
@@ -612,23 +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"):
broker_held_qty = ( sell_position = (
_extract_held_qty_from_balance( get_open_position(db_conn, stock_code, market.code)
balance_data, stock_code, is_domestic=market.is_domestic
)
if decision.action == "SELL" if decision.action == "SELL"
else 0 else None
) )
matched_scenario = match.matched_scenario
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,
broker_held_qty=broker_held_qty, open_position=sell_position,
playbook_allocation_pct=matched_scenario.allocation_pct if matched_scenario else None,
scenario_confidence=match.confidence,
) )
if quantity <= 0: if quantity <= 0:
logger.info( logger.info(
@@ -1048,12 +900,10 @@ 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_broker_held_qty = ( daily_sell_position = (
_extract_held_qty_from_balance( get_open_position(db_conn, stock_code, market.code)
balance_data, stock_code, is_domestic=market.is_domestic
)
if decision.action == "SELL" if decision.action == "SELL"
else 0 else None
) )
quantity = _determine_order_quantity( quantity = _determine_order_quantity(
action=decision.action, action=decision.action,
@@ -1061,7 +911,7 @@ async def run_daily_session(
total_cash=total_cash, total_cash=total_cash,
candidate=candidate_map.get(stock_code), candidate=candidate_map.get(stock_code),
settings=settings, settings=settings,
broker_held_qty=daily_broker_held_qty, open_position=daily_sell_position,
) )
if quantity <= 0: if quantity <= 0:
logger.info( logger.info(
@@ -1300,18 +1150,10 @@ def _start_dashboard_server(settings: Settings) -> threading.Thread | None:
if not settings.DASHBOARD_ENABLED: if not settings.DASHBOARD_ENABLED:
return None return None
# Validate dependencies before spawning the thread so startup failures are
# reported synchronously (avoids the misleading "started" → "failed" log pair).
try:
import uvicorn # noqa: F401
from src.dashboard import create_dashboard_app # noqa: F401
except ImportError as exc:
logger.warning("Dashboard server unavailable (missing dependency): %s", exc)
return None
def _serve() -> None: def _serve() -> None:
try: try:
import uvicorn import uvicorn
from src.dashboard import create_dashboard_app from src.dashboard import create_dashboard_app
app = create_dashboard_app(settings.DB_PATH) app = create_dashboard_app(settings.DB_PATH)
@@ -1322,7 +1164,7 @@ def _start_dashboard_server(settings: Settings) -> threading.Thread | None:
log_level="info", log_level="info",
) )
except Exception as exc: except Exception as exc:
logger.warning("Dashboard server stopped unexpectedly: %s", exc) logger.warning("Dashboard server failed to start: %s", exc)
thread = threading.Thread( thread = threading.Thread(
target=_serve, target=_serve,
@@ -2036,38 +1878,8 @@ async def run(settings: Settings) -> None:
except Exception as exc: except Exception as exc:
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 currently-held positions so stop-loss / stock_codes = active_stocks.get(market.code, [])
# take-profit can fire even when a holding drops off the
# scanner. Broker balance is the source of truth here —
# unlike the local DB it reflects actual fills and any
# manual trades done outside the bot.
scanner_codes = active_stocks.get(market.code, [])
try:
if market.is_domestic:
held_balance = await broker.get_balance()
else:
held_balance = await overseas_broker.get_overseas_balance(
market.exchange_code
)
held_codes = _extract_held_codes_from_balance(
held_balance, is_domestic=market.is_domestic
)
except Exception as exc:
logger.warning(
"Failed to fetch holdings for %s: %s — skipping holdings merge",
market.name, exc,
)
held_codes = []
stock_codes = list(dict.fromkeys(scanner_codes + held_codes))
extra_held = [c for c in held_codes if c not in set(scanner_codes)]
if extra_held:
logger.info(
"Holdings added to loop for %s (not in scanner): %s",
market.name, extra_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

@@ -46,18 +46,6 @@ class StockCondition(BaseModel):
The ScenarioEngine evaluates all non-None fields as AND conditions. The ScenarioEngine evaluates all non-None fields as AND conditions.
A condition matches only if ALL specified fields are satisfied. A condition matches only if ALL specified fields are satisfied.
Technical indicator fields:
rsi_below / rsi_above — RSI threshold
volume_ratio_above / volume_ratio_below — volume vs previous day
price_above / price_below — absolute price level
price_change_pct_above / price_change_pct_below — intraday % change
Position-aware fields (require market_data enrichment from open position):
unrealized_pnl_pct_above — matches if unrealized P&L > threshold (e.g. 3.0 → +3%)
unrealized_pnl_pct_below — matches if unrealized P&L < threshold (e.g. -2.0 → -2%)
holding_days_above — matches if position held for more than N days
holding_days_below — matches if position held for fewer than N days
""" """
rsi_below: float | None = None rsi_below: float | None = None
@@ -68,10 +56,6 @@ class StockCondition(BaseModel):
price_below: float | None = None price_below: float | None = None
price_change_pct_above: float | None = None price_change_pct_above: float | None = None
price_change_pct_below: float | None = None price_change_pct_below: float | None = None
unrealized_pnl_pct_above: float | None = None
unrealized_pnl_pct_below: float | None = None
holding_days_above: int | None = None
holding_days_below: int | None = None
def has_any_condition(self) -> bool: def has_any_condition(self) -> bool:
"""Check if at least one condition field is set.""" """Check if at least one condition field is set."""
@@ -86,10 +70,6 @@ class StockCondition(BaseModel):
self.price_below, self.price_below,
self.price_change_pct_above, self.price_change_pct_above,
self.price_change_pct_below, self.price_change_pct_below,
self.unrealized_pnl_pct_above,
self.unrealized_pnl_pct_below,
self.holding_days_above,
self.holding_days_below,
) )
) )

View File

@@ -75,7 +75,6 @@ class PreMarketPlanner:
market: str, market: str,
candidates: list[ScanCandidate], candidates: list[ScanCandidate],
today: date | None = None, today: date | None = None,
current_holdings: list[dict] | None = None,
) -> DayPlaybook: ) -> DayPlaybook:
"""Generate a DayPlaybook for a market using Gemini. """Generate a DayPlaybook for a market using Gemini.
@@ -83,10 +82,6 @@ class PreMarketPlanner:
market: Market code ("KR" or "US") market: Market code ("KR" or "US")
candidates: Stock candidates from SmartVolatilityScanner candidates: Stock candidates from SmartVolatilityScanner
today: Override date (defaults to date.today()). Use market-local date. today: Override date (defaults to date.today()). Use market-local date.
current_holdings: Currently held positions with entry_price and unrealized_pnl_pct.
Each dict: {"stock_code": str, "name": str, "qty": int,
"entry_price": float, "unrealized_pnl_pct": float,
"holding_days": int}
Returns: Returns:
DayPlaybook with scenarios. Empty/defensive if no candidates or failure. DayPlaybook with scenarios. Empty/defensive if no candidates or failure.
@@ -111,7 +106,6 @@ class PreMarketPlanner:
context_data, context_data,
self_market_scorecard, self_market_scorecard,
cross_market, cross_market,
current_holdings=current_holdings,
) )
# 3. Call Gemini # 3. Call Gemini
@@ -124,8 +118,7 @@ class PreMarketPlanner:
# 4. Parse response # 4. Parse response
playbook = self._parse_response( playbook = self._parse_response(
decision.rationale, today, market, candidates, cross_market, decision.rationale, today, market, candidates, cross_market
current_holdings=current_holdings,
) )
playbook_with_tokens = playbook.model_copy( playbook_with_tokens = playbook.model_copy(
update={"token_count": decision.token_count} update={"token_count": decision.token_count}
@@ -237,7 +230,6 @@ class PreMarketPlanner:
context_data: dict[str, Any], context_data: dict[str, Any],
self_market_scorecard: dict[str, Any] | None, self_market_scorecard: dict[str, Any] | None,
cross_market: CrossMarketContext | None, cross_market: CrossMarketContext | None,
current_holdings: list[dict] | None = None,
) -> str: ) -> str:
"""Build a structured prompt for Gemini to generate scenario JSON.""" """Build a structured prompt for Gemini to generate scenario JSON."""
max_scenarios = self._settings.MAX_SCENARIOS_PER_STOCK max_scenarios = self._settings.MAX_SCENARIOS_PER_STOCK
@@ -249,26 +241,6 @@ class PreMarketPlanner:
for c in candidates for c in candidates
) )
holdings_text = ""
if current_holdings:
lines = []
for h in current_holdings:
code = h.get("stock_code", "")
name = h.get("name", "")
qty = h.get("qty", 0)
entry_price = h.get("entry_price", 0.0)
pnl_pct = h.get("unrealized_pnl_pct", 0.0)
holding_days = h.get("holding_days", 0)
lines.append(
f" - {code} ({name}): {qty}주 @ {entry_price:,.0f}, "
f"미실현손익 {pnl_pct:+.2f}%, 보유 {holding_days}"
)
holdings_text = (
"\n## Current Holdings (보유 중 — SELL/HOLD 전략 고려 필요)\n"
+ "\n".join(lines)
+ "\n"
)
cross_market_text = "" cross_market_text = ""
if cross_market: if cross_market:
cross_market_text = ( cross_market_text = (
@@ -301,20 +273,10 @@ class PreMarketPlanner:
for key, value in list(layer_data.items())[:5]: for key, value in list(layer_data.items())[:5]:
context_text += f" - {key}: {value}\n" context_text += f" - {key}: {value}\n"
holdings_instruction = ""
if current_holdings:
holding_codes = [h.get("stock_code", "") for h in current_holdings]
holdings_instruction = (
f"- Also include SELL/HOLD scenarios for held stocks: "
f"{', '.join(holding_codes)} "
f"(even if not in candidates list)\n"
)
return ( return (
f"You are a pre-market trading strategist for the {market} market.\n" f"You are a pre-market trading strategist for the {market} market.\n"
f"Generate structured trading scenarios for today.\n\n" f"Generate structured trading scenarios for today.\n\n"
f"## Candidates (from volatility scanner)\n{candidates_text}\n" f"## Candidates (from volatility scanner)\n{candidates_text}\n"
f"{holdings_text}"
f"{self_market_text}" f"{self_market_text}"
f"{cross_market_text}" f"{cross_market_text}"
f"{context_text}\n" f"{context_text}\n"
@@ -332,8 +294,7 @@ class PreMarketPlanner:
f' "stock_code": "...",\n' f' "stock_code": "...",\n'
f' "scenarios": [\n' f' "scenarios": [\n'
f' {{\n' f' {{\n'
f' "condition": {{"rsi_below": 30, "volume_ratio_above": 2.0,' f' "condition": {{"rsi_below": 30, "volume_ratio_above": 2.0}},\n'
f' "unrealized_pnl_pct_above": 3.0, "holding_days_above": 5}},\n'
f' "action": "BUY|SELL|HOLD",\n' f' "action": "BUY|SELL|HOLD",\n'
f' "confidence": 85,\n' f' "confidence": 85,\n'
f' "allocation_pct": 10.0,\n' f' "allocation_pct": 10.0,\n'
@@ -347,8 +308,7 @@ class PreMarketPlanner:
f'}}\n\n' f'}}\n\n'
f"Rules:\n" f"Rules:\n"
f"- Max {max_scenarios} scenarios per stock\n" f"- Max {max_scenarios} scenarios per stock\n"
f"- Candidates list is the primary source for BUY candidates\n" f"- Only use stocks from the candidates list\n"
f"{holdings_instruction}"
f"- Confidence 0-100 (80+ for actionable trades)\n" f"- Confidence 0-100 (80+ for actionable trades)\n"
f"- stop_loss_pct must be <= 0, take_profit_pct must be >= 0\n" f"- stop_loss_pct must be <= 0, take_profit_pct must be >= 0\n"
f"- Return ONLY the JSON, no markdown fences or explanation\n" f"- Return ONLY the JSON, no markdown fences or explanation\n"
@@ -361,19 +321,12 @@ class PreMarketPlanner:
market: str, market: str,
candidates: list[ScanCandidate], candidates: list[ScanCandidate],
cross_market: CrossMarketContext | None, cross_market: CrossMarketContext | None,
current_holdings: list[dict] | None = None,
) -> DayPlaybook: ) -> DayPlaybook:
"""Parse Gemini's JSON response into a validated DayPlaybook.""" """Parse Gemini's JSON response into a validated DayPlaybook."""
cleaned = self._extract_json(response_text) cleaned = self._extract_json(response_text)
data = json.loads(cleaned) data = json.loads(cleaned)
valid_codes = {c.stock_code for c in candidates} valid_codes = {c.stock_code for c in candidates}
# Holdings are also valid — AI may generate SELL/HOLD scenarios for them
if current_holdings:
for h in current_holdings:
code = h.get("stock_code", "")
if code:
valid_codes.add(code)
# Parse market outlook # Parse market outlook
outlook_str = data.get("market_outlook", "neutral") outlook_str = data.get("market_outlook", "neutral")
@@ -437,10 +390,6 @@ class PreMarketPlanner:
price_below=cond_data.get("price_below"), price_below=cond_data.get("price_below"),
price_change_pct_above=cond_data.get("price_change_pct_above"), price_change_pct_above=cond_data.get("price_change_pct_above"),
price_change_pct_below=cond_data.get("price_change_pct_below"), price_change_pct_below=cond_data.get("price_change_pct_below"),
unrealized_pnl_pct_above=cond_data.get("unrealized_pnl_pct_above"),
unrealized_pnl_pct_below=cond_data.get("unrealized_pnl_pct_below"),
holding_days_above=cond_data.get("holding_days_above"),
holding_days_below=cond_data.get("holding_days_below"),
) )
if not condition.has_any_condition(): if not condition.has_any_condition():

View File

@@ -206,37 +206,6 @@ class ScenarioEngine:
if condition.price_change_pct_below is not None: if condition.price_change_pct_below is not None:
checks.append(price_change_pct is not None and price_change_pct < condition.price_change_pct_below) checks.append(price_change_pct is not None and price_change_pct < condition.price_change_pct_below)
# Position-aware conditions
unrealized_pnl_pct = self._safe_float(market_data.get("unrealized_pnl_pct"))
if condition.unrealized_pnl_pct_above is not None or condition.unrealized_pnl_pct_below is not None:
if "unrealized_pnl_pct" not in market_data:
self._warn_missing_key("unrealized_pnl_pct")
if condition.unrealized_pnl_pct_above is not None:
checks.append(
unrealized_pnl_pct is not None
and unrealized_pnl_pct > condition.unrealized_pnl_pct_above
)
if condition.unrealized_pnl_pct_below is not None:
checks.append(
unrealized_pnl_pct is not None
and unrealized_pnl_pct < condition.unrealized_pnl_pct_below
)
holding_days = self._safe_float(market_data.get("holding_days"))
if condition.holding_days_above is not None or condition.holding_days_below is not None:
if "holding_days" not in market_data:
self._warn_missing_key("holding_days")
if condition.holding_days_above is not None:
checks.append(
holding_days is not None
and holding_days > condition.holding_days_above
)
if condition.holding_days_below is not None:
checks.append(
holding_days is not None
and holding_days < condition.holding_days_below
)
return len(checks) > 0 and all(checks) return len(checks) > 0 and all(checks)
def _evaluate_global_condition( def _evaluate_global_condition(
@@ -297,9 +266,5 @@ class ScenarioEngine:
details["current_price"] = self._safe_float(market_data.get("current_price")) details["current_price"] = self._safe_float(market_data.get("current_price"))
if condition.price_change_pct_above is not None or condition.price_change_pct_below is not None: if condition.price_change_pct_above is not None or condition.price_change_pct_below is not None:
details["price_change_pct"] = self._safe_float(market_data.get("price_change_pct")) details["price_change_pct"] = self._safe_float(market_data.get("price_change_pct"))
if condition.unrealized_pnl_pct_above is not None or condition.unrealized_pnl_pct_below is not None:
details["unrealized_pnl_pct"] = self._safe_float(market_data.get("unrealized_pnl_pct"))
if condition.holding_days_above is not None or condition.holding_days_below is not None:
details["holding_days"] = self._safe_float(market_data.get("holding_days"))
return details return details

View File

@@ -15,8 +15,6 @@ from src.logging.decision_logger import DecisionLogger
from src.main import ( from src.main import (
_apply_dashboard_flag, _apply_dashboard_flag,
_determine_order_quantity, _determine_order_quantity,
_extract_held_codes_from_balance,
_extract_held_qty_from_balance,
_handle_market_close, _handle_market_close,
_run_context_scheduler, _run_context_scheduler,
_run_evolution_loop, _run_evolution_loop,
@@ -71,104 +69,49 @@ def _make_sell_match(stock_code: str = "005930") -> ScenarioMatch:
) )
class TestExtractHeldQtyFromBalance:
"""Tests for _extract_held_qty_from_balance()."""
def _domestic_balance(self, stock_code: str, ord_psbl_qty: int) -> dict:
return {
"output1": [{"pdno": stock_code, "ord_psbl_qty": str(ord_psbl_qty)}],
"output2": [{"dnca_tot_amt": "1000000"}],
}
def test_domestic_returns_ord_psbl_qty(self) -> None:
balance = self._domestic_balance("005930", 7)
assert _extract_held_qty_from_balance(balance, "005930", is_domestic=True) == 7
def test_domestic_fallback_to_hldg_qty(self) -> None:
balance = {"output1": [{"pdno": "005930", "hldg_qty": "3"}]}
assert _extract_held_qty_from_balance(balance, "005930", is_domestic=True) == 3
def test_domestic_returns_zero_when_not_found(self) -> None:
balance = self._domestic_balance("005930", 5)
assert _extract_held_qty_from_balance(balance, "000660", is_domestic=True) == 0
def test_domestic_returns_zero_when_output1_empty(self) -> None:
balance = {"output1": [], "output2": [{}]}
assert _extract_held_qty_from_balance(balance, "005930", is_domestic=True) == 0
def test_overseas_returns_ovrs_cblc_qty(self) -> None:
balance = {"output1": [{"ovrs_pdno": "AAPL", "ovrs_cblc_qty": "10"}]}
assert _extract_held_qty_from_balance(balance, "AAPL", is_domestic=False) == 10
def test_overseas_fallback_to_hldg_qty(self) -> None:
balance = {"output1": [{"ovrs_pdno": "AAPL", "hldg_qty": "4"}]}
assert _extract_held_qty_from_balance(balance, "AAPL", is_domestic=False) == 4
def test_case_insensitive_match(self) -> None:
balance = {"output1": [{"pdno": "005930", "ord_psbl_qty": "2"}]}
assert _extract_held_qty_from_balance(balance, "005930", is_domestic=True) == 2
class TestExtractHeldCodesFromBalance:
"""Tests for _extract_held_codes_from_balance()."""
def test_returns_codes_with_positive_qty(self) -> None:
balance = {
"output1": [
{"pdno": "005930", "ord_psbl_qty": "5"},
{"pdno": "000660", "ord_psbl_qty": "3"},
]
}
result = _extract_held_codes_from_balance(balance, is_domestic=True)
assert set(result) == {"005930", "000660"}
def test_excludes_zero_qty_holdings(self) -> None:
balance = {
"output1": [
{"pdno": "005930", "ord_psbl_qty": "0"},
{"pdno": "000660", "ord_psbl_qty": "2"},
]
}
result = _extract_held_codes_from_balance(balance, is_domestic=True)
assert "005930" not in result
assert "000660" in result
def test_returns_empty_when_output1_missing(self) -> None:
balance: dict = {}
assert _extract_held_codes_from_balance(balance, is_domestic=True) == []
def test_overseas_uses_ovrs_pdno(self) -> None:
balance = {"output1": [{"ovrs_pdno": "AAPL", "ovrs_cblc_qty": "3"}]}
result = _extract_held_codes_from_balance(balance, is_domestic=False)
assert result == ["AAPL"]
class TestDetermineOrderQuantity: class TestDetermineOrderQuantity:
"""Test _determine_order_quantity() — SELL uses broker_held_qty.""" """Test _determine_order_quantity() helper function."""
def test_sell_returns_broker_held_qty(self) -> None: 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( result = _determine_order_quantity(
action="SELL", action="SELL",
current_price=105.0, current_price=105.0,
total_cash=50000.0, total_cash=50000.0,
candidate=None, candidate=None,
settings=None, settings=None,
broker_held_qty=7, open_position=open_pos,
) )
assert result == 7 assert result == 7
def test_sell_returns_zero_when_broker_qty_zero(self) -> None: 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( result = _determine_order_quantity(
action="SELL", action="SELL",
current_price=105.0, current_price=105.0,
total_cash=50000.0, total_cash=50000.0,
candidate=None, candidate=None,
settings=None, settings=None,
broker_held_qty=0, 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 assert result == 0
def test_buy_without_position_sizing_returns_one(self) -> None: def test_buy_without_position_sizing_returns_one(self) -> None:
"""BUY with no settings should return 1 (default)."""
result = _determine_order_quantity( result = _determine_order_quantity(
action="BUY", action="BUY",
current_price=50000.0, current_price=50000.0,
@@ -179,6 +122,7 @@ class TestDetermineOrderQuantity:
assert result == 1 assert result == 1
def test_buy_with_zero_cash_returns_zero(self) -> None: def test_buy_with_zero_cash_returns_zero(self) -> None:
"""BUY with no cash should return 0."""
result = _determine_order_quantity( result = _determine_order_quantity(
action="BUY", action="BUY",
current_price=50000.0, current_price=50000.0,
@@ -189,97 +133,22 @@ class TestDetermineOrderQuantity:
assert result == 0 assert result == 0
def test_buy_with_position_sizing_calculates_correctly(self) -> None: def test_buy_with_position_sizing_calculates_correctly(self) -> None:
"""BUY with position sizing should calculate quantity from budget."""
settings = MagicMock(spec=Settings) settings = MagicMock(spec=Settings)
settings.POSITION_SIZING_ENABLED = True settings.POSITION_SIZING_ENABLED = True
settings.POSITION_VOLATILITY_TARGET_SCORE = 50.0 settings.POSITION_VOLATILITY_TARGET_SCORE = 50.0
settings.POSITION_BASE_ALLOCATION_PCT = 10.0 settings.POSITION_BASE_ALLOCATION_PCT = 10.0
settings.POSITION_MAX_ALLOCATION_PCT = 30.0 settings.POSITION_MAX_ALLOCATION_PCT = 30.0
settings.POSITION_MIN_ALLOCATION_PCT = 1.0 settings.POSITION_MIN_ALLOCATION_PCT = 1.0
# 1,000,000 * 10% = 100,000 budget // 50,000 price = 2 shares
result = _determine_order_quantity(
action="BUY",
current_price=50000.0,
total_cash=1000000.0,
candidate=None,
settings=settings,
)
assert result == 2
def test_determine_order_quantity_uses_playbook_allocation_pct(self) -> None: # total_cash=1,000,000 * 10% = 100,000 budget
"""playbook_allocation_pct should take priority over volatility-based sizing.""" # 100,000 // 50,000 = 2 shares
settings = MagicMock(spec=Settings)
settings.POSITION_SIZING_ENABLED = True
settings.POSITION_MAX_ALLOCATION_PCT = 30.0
settings.POSITION_MIN_ALLOCATION_PCT = 1.0
# playbook says 20%, confidence 80 → scale=1.0 → 20%
# 1,000,000 * 20% = 200,000 // 50,000 price = 4 shares
result = _determine_order_quantity( result = _determine_order_quantity(
action="BUY", action="BUY",
current_price=50000.0, current_price=50000.0,
total_cash=1000000.0, total_cash=1000000.0,
candidate=None, candidate=None,
settings=settings, settings=settings,
playbook_allocation_pct=20.0,
scenario_confidence=80,
)
assert result == 4
def test_determine_order_quantity_confidence_scales_allocation(self) -> None:
"""Higher confidence should produce a larger allocation (up to max)."""
settings = MagicMock(spec=Settings)
settings.POSITION_SIZING_ENABLED = True
settings.POSITION_MAX_ALLOCATION_PCT = 30.0
settings.POSITION_MIN_ALLOCATION_PCT = 1.0
# confidence 96 → scale=1.2 → 10% * 1.2 = 12%
# 1,000,000 * 12% = 120,000 // 50,000 price = 2 shares
result = _determine_order_quantity(
action="BUY",
current_price=50000.0,
total_cash=1000000.0,
candidate=None,
settings=settings,
playbook_allocation_pct=10.0,
scenario_confidence=96,
)
# scale = 96/80 = 1.2 → effective_pct = 12.0
# budget = 1_000_000 * 0.12 = 120_000 → qty = 120_000 // 50_000 = 2
assert result == 2
def test_determine_order_quantity_confidence_clamped_to_max(self) -> None:
"""Confidence scaling should not exceed POSITION_MAX_ALLOCATION_PCT."""
settings = MagicMock(spec=Settings)
settings.POSITION_SIZING_ENABLED = True
settings.POSITION_MAX_ALLOCATION_PCT = 15.0
settings.POSITION_MIN_ALLOCATION_PCT = 1.0
# playbook 20% * scale 1.5 = 30% → clamped to 15%
# 1,000,000 * 15% = 150,000 // 50,000 price = 3 shares
result = _determine_order_quantity(
action="BUY",
current_price=50000.0,
total_cash=1000000.0,
candidate=None,
settings=settings,
playbook_allocation_pct=20.0,
scenario_confidence=120, # extreme → scale = 1.5
)
assert result == 3
def test_determine_order_quantity_fallback_when_no_playbook(self) -> None:
"""Without playbook_allocation_pct, falls back to volatility-based sizing."""
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
# Same as test_buy_with_position_sizing_calculates_correctly (no playbook)
result = _determine_order_quantity(
action="BUY",
current_price=50000.0,
total_cash=1000000.0,
candidate=None,
settings=settings,
playbook_allocation_pct=None, # explicit None → fallback
) )
assert result == 2 assert result == 2
@@ -1456,14 +1325,13 @@ async def test_sell_updates_original_buy_decision_outcome() -> None:
broker.get_current_price = AsyncMock(return_value=(120.0, 0.0, 0.0)) broker.get_current_price = AsyncMock(return_value=(120.0, 0.0, 0.0))
broker.get_balance = AsyncMock( broker.get_balance = AsyncMock(
return_value={ return_value={
"output1": [{"pdno": "005930", "ord_psbl_qty": "1"}],
"output2": [ "output2": [
{ {
"tot_evlu_amt": "100000", "tot_evlu_amt": "100000",
"dnca_tot_amt": "10000", "dnca_tot_amt": "10000",
"pchs_amt_smtl_amt": "90000", "pchs_amt_smtl_amt": "90000",
} }
], ]
} }
) )
broker.send_order = AsyncMock(return_value={"msg1": "OK"}) broker.send_order = AsyncMock(return_value={"msg1": "OK"})
@@ -1547,14 +1415,13 @@ async def test_hold_overridden_to_sell_when_stop_loss_triggered() -> None:
broker.get_current_price = AsyncMock(return_value=(95.0, -5.0, 0.0)) broker.get_current_price = AsyncMock(return_value=(95.0, -5.0, 0.0))
broker.get_balance = AsyncMock( broker.get_balance = AsyncMock(
return_value={ return_value={
"output1": [{"pdno": "005930", "ord_psbl_qty": "1"}],
"output2": [ "output2": [
{ {
"tot_evlu_amt": "100000", "tot_evlu_amt": "100000",
"dnca_tot_amt": "10000", "dnca_tot_amt": "10000",
"pchs_amt_smtl_amt": "90000", "pchs_amt_smtl_amt": "90000",
} }
], ]
} }
) )
broker.send_order = AsyncMock(return_value={"msg1": "OK"}) broker.send_order = AsyncMock(return_value={"msg1": "OK"})
@@ -1615,8 +1482,8 @@ async def test_hold_overridden_to_sell_when_stop_loss_triggered() -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_hold_overridden_to_sell_when_take_profit_triggered() -> None: async def test_sell_order_uses_actual_held_quantity() -> None:
"""HOLD decision should be overridden to SELL when take-profit threshold is reached.""" """SELL order should use the actual quantity held, not hardcoded 1."""
db_conn = init_db(":memory:") db_conn = init_db(":memory:")
decision_logger = DecisionLogger(db_conn) decision_logger = DecisionLogger(db_conn)
@@ -1630,13 +1497,14 @@ async def test_hold_overridden_to_sell_when_take_profit_triggered() -> 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=1, quantity=5,
price=100.0, price=100.0,
market="KR", market="KR",
exchange_code="KRX", exchange_code="KRX",
@@ -1644,110 +1512,7 @@ async def test_hold_overridden_to_sell_when_take_profit_triggered() -> None:
) )
broker = MagicMock() broker = MagicMock()
# Current price 106.0 → +6% gain, above take_profit_pct=3.0 broker.get_current_price = AsyncMock(return_value=(95.0, -5.0, 0.0))
broker.get_current_price = AsyncMock(return_value=(106.0, 6.0, 0.0))
broker.get_balance = AsyncMock(
return_value={
"output1": [{"pdno": "005930", "ord_psbl_qty": "1"}],
"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="take profit 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()
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( broker.get_balance = AsyncMock(
return_value={ return_value={
"output2": [ "output2": [
@@ -1761,113 +1526,6 @@ async def test_hold_not_overridden_when_between_stop_loss_and_take_profit() -> N
) )
broker.send_order = AsyncMock(return_value={"msg1": "OK"}) 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
async def test_sell_order_uses_broker_balance_qty_not_db() -> None:
"""SELL quantity must come from broker balance output1, not DB.
The DB records order quantity which may differ from actual fill quantity.
This test verifies that we use the broker-confirmed orderable quantity.
"""
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={},
)
# DB records 10 shares ordered — but only 5 actually filled (partial fill scenario)
log_trade(
conn=db_conn,
stock_code="005930",
action="BUY",
confidence=90,
rationale="entry",
quantity=10, # ordered quantity (may differ from fill)
price=100.0,
market="KR",
exchange_code="KRX",
decision_id=buy_decision_id,
)
broker = MagicMock()
# Stop-loss triggers (price dropped below -2%)
broker.get_current_price = AsyncMock(return_value=(95.0, -5.0, 0.0))
broker.get_balance = AsyncMock(
return_value={
# Broker confirms only 5 shares are actually orderable (partial fill)
"output1": [{"pdno": "005930", "ord_psbl_qty": "5"}],
"output2": [
{
"tot_evlu_amt": "100000",
"dnca_tot_amt": "10000",
"pchs_amt_smtl_amt": "90000",
}
],
}
)
broker.send_order = AsyncMock(return_value={"msg1": "OK"})
scenario = StockScenario( scenario = StockScenario(
condition=StockCondition(rsi_below=30), condition=StockCondition(rsi_below=30),
action=ScenarioAction.BUY, action=ScenarioAction.BUY,
@@ -1922,8 +1580,7 @@ async def test_sell_order_uses_broker_balance_qty_not_db() -> None:
broker.send_order.assert_called_once() broker.send_order.assert_called_once()
call_kwargs = broker.send_order.call_args.kwargs call_kwargs = broker.send_order.call_args.kwargs
assert call_kwargs["order_type"] == "SELL" assert call_kwargs["order_type"] == "SELL"
# Must use broker-confirmed qty (5), NOT DB-recorded ordered qty (10) assert call_kwargs["quantity"] == 5 # actual held quantity, not 1
assert call_kwargs["quantity"] == 5
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -2192,307 +1849,3 @@ def test_start_dashboard_server_enabled_starts_thread() -> None:
assert thread == mock_thread assert thread == mock_thread
mock_thread_cls.assert_called_once() mock_thread_cls.assert_called_once()
mock_thread.start.assert_called_once() mock_thread.start.assert_called_once()
def test_start_dashboard_server_returns_none_when_uvicorn_missing() -> None:
"""Returns None (no thread) and logs a warning when uvicorn is not installed."""
settings = Settings(
KIS_APP_KEY="test_key",
KIS_APP_SECRET="test_secret",
KIS_ACCOUNT_NO="12345678-01",
GEMINI_API_KEY="test_gemini_key",
DASHBOARD_ENABLED=True,
)
import builtins
real_import = builtins.__import__
def mock_import(name: str, *args: object, **kwargs: object) -> object:
if name == "uvicorn":
raise ImportError("No module named 'uvicorn'")
return real_import(name, *args, **kwargs)
with patch("builtins.__import__", side_effect=mock_import):
thread = _start_dashboard_server(settings)
assert thread is None
# ---------------------------------------------------------------------------
# market_outlook BUY confidence threshold tests (#173)
# ---------------------------------------------------------------------------
class TestMarketOutlookConfidenceThreshold:
"""Tests for market_outlook-based BUY confidence suppression in trading_cycle."""
@pytest.fixture
def mock_broker(self) -> MagicMock:
broker = MagicMock()
broker.get_current_price = AsyncMock(return_value=(50000.0, 1.0, 0.0))
broker.get_balance = AsyncMock(
return_value={
"output2": [
{
"tot_evlu_amt": "10000000",
"dnca_tot_amt": "5000000",
"pchs_amt_smtl_amt": "9500000",
}
]
}
)
broker.send_order = AsyncMock(return_value={"msg1": "OK"})
return broker
@pytest.fixture
def mock_market(self) -> MagicMock:
market = MagicMock()
market.name = "Korea"
market.code = "KR"
market.exchange_code = "KRX"
market.is_domestic = True
return market
@pytest.fixture
def mock_telegram(self) -> MagicMock:
telegram = MagicMock()
telegram.notify_trade_execution = AsyncMock()
telegram.notify_scenario_matched = AsyncMock()
telegram.notify_fat_finger = AsyncMock()
return telegram
def _make_buy_match_with_confidence(
self, confidence: int, stock_code: str = "005930"
) -> ScenarioMatch:
from src.strategy.models import StockScenario
scenario = StockScenario(
condition=StockCondition(rsi_below=30),
action=ScenarioAction.BUY,
confidence=confidence,
allocation_pct=10.0,
)
return ScenarioMatch(
stock_code=stock_code,
matched_scenario=scenario,
action=ScenarioAction.BUY,
confidence=confidence,
rationale="Test buy",
)
def _make_playbook_with_outlook(
self, outlook_str: str, market: str = "KR"
) -> DayPlaybook:
from src.strategy.models import MarketOutlook
outlook_map = {
"bearish": MarketOutlook.BEARISH,
"bullish": MarketOutlook.BULLISH,
"neutral": MarketOutlook.NEUTRAL,
"neutral_to_bullish": MarketOutlook.NEUTRAL_TO_BULLISH,
"neutral_to_bearish": MarketOutlook.NEUTRAL_TO_BEARISH,
}
return DayPlaybook(
date=date(2026, 2, 20),
market=market,
market_outlook=outlook_map[outlook_str],
)
@pytest.mark.asyncio
async def test_bearish_outlook_raises_buy_confidence_threshold(
self,
mock_broker: MagicMock,
mock_market: MagicMock,
mock_telegram: MagicMock,
) -> None:
"""BUY with confidence 85 should be suppressed to HOLD in bearish market."""
engine = MagicMock(spec=ScenarioEngine)
engine.evaluate = MagicMock(return_value=self._make_buy_match_with_confidence(85))
playbook = self._make_playbook_with_outlook("bearish")
decision_logger = MagicMock()
decision_logger.log_decision = MagicMock(return_value="decision-id")
with patch("src.main.log_trade"):
await trading_cycle(
broker=mock_broker,
overseas_broker=MagicMock(),
scenario_engine=engine,
playbook=playbook,
risk=MagicMock(),
db_conn=MagicMock(),
decision_logger=decision_logger,
context_store=MagicMock(get_latest_timeframe=MagicMock(return_value=None)),
criticality_assessor=MagicMock(
assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")),
get_timeout=MagicMock(return_value=5.0),
),
telegram=mock_telegram,
market=mock_market,
stock_code="005930",
scan_candidates={},
)
# HOLD should be logged (not BUY) — check decision_logger was called with HOLD
call_args = decision_logger.log_decision.call_args
assert call_args is not None
assert call_args.kwargs["action"] == "HOLD"
@pytest.mark.asyncio
async def test_bearish_outlook_allows_high_confidence_buy(
self,
mock_broker: MagicMock,
mock_market: MagicMock,
mock_telegram: MagicMock,
) -> None:
"""BUY with confidence 92 should proceed in bearish market (threshold=90)."""
engine = MagicMock(spec=ScenarioEngine)
engine.evaluate = MagicMock(return_value=self._make_buy_match_with_confidence(92))
playbook = self._make_playbook_with_outlook("bearish")
risk = MagicMock()
risk.validate_order = MagicMock()
decision_logger = MagicMock()
decision_logger.log_decision = MagicMock(return_value="decision-id")
with patch("src.main.log_trade"):
await trading_cycle(
broker=mock_broker,
overseas_broker=MagicMock(),
scenario_engine=engine,
playbook=playbook,
risk=risk,
db_conn=MagicMock(),
decision_logger=decision_logger,
context_store=MagicMock(get_latest_timeframe=MagicMock(return_value=None)),
criticality_assessor=MagicMock(
assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")),
get_timeout=MagicMock(return_value=5.0),
),
telegram=mock_telegram,
market=mock_market,
stock_code="005930",
scan_candidates={},
)
call_args = decision_logger.log_decision.call_args
assert call_args is not None
assert call_args.kwargs["action"] == "BUY"
@pytest.mark.asyncio
async def test_bullish_outlook_lowers_buy_confidence_threshold(
self,
mock_broker: MagicMock,
mock_market: MagicMock,
mock_telegram: MagicMock,
) -> None:
"""BUY with confidence 77 should proceed in bullish market (threshold=75)."""
engine = MagicMock(spec=ScenarioEngine)
engine.evaluate = MagicMock(return_value=self._make_buy_match_with_confidence(77))
playbook = self._make_playbook_with_outlook("bullish")
risk = MagicMock()
risk.validate_order = MagicMock()
decision_logger = MagicMock()
decision_logger.log_decision = MagicMock(return_value="decision-id")
with patch("src.main.log_trade"):
await trading_cycle(
broker=mock_broker,
overseas_broker=MagicMock(),
scenario_engine=engine,
playbook=playbook,
risk=risk,
db_conn=MagicMock(),
decision_logger=decision_logger,
context_store=MagicMock(get_latest_timeframe=MagicMock(return_value=None)),
criticality_assessor=MagicMock(
assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")),
get_timeout=MagicMock(return_value=5.0),
),
telegram=mock_telegram,
market=mock_market,
stock_code="005930",
scan_candidates={},
)
call_args = decision_logger.log_decision.call_args
assert call_args is not None
assert call_args.kwargs["action"] == "BUY"
@pytest.mark.asyncio
async def test_bullish_outlook_suppresses_very_low_confidence_buy(
self,
mock_broker: MagicMock,
mock_market: MagicMock,
mock_telegram: MagicMock,
) -> None:
"""BUY with confidence 70 should be suppressed even in bullish market (threshold=75)."""
engine = MagicMock(spec=ScenarioEngine)
engine.evaluate = MagicMock(return_value=self._make_buy_match_with_confidence(70))
playbook = self._make_playbook_with_outlook("bullish")
decision_logger = MagicMock()
decision_logger.log_decision = MagicMock(return_value="decision-id")
with patch("src.main.log_trade"):
await trading_cycle(
broker=mock_broker,
overseas_broker=MagicMock(),
scenario_engine=engine,
playbook=playbook,
risk=MagicMock(),
db_conn=MagicMock(),
decision_logger=decision_logger,
context_store=MagicMock(get_latest_timeframe=MagicMock(return_value=None)),
criticality_assessor=MagicMock(
assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")),
get_timeout=MagicMock(return_value=5.0),
),
telegram=mock_telegram,
market=mock_market,
stock_code="005930",
scan_candidates={},
)
call_args = decision_logger.log_decision.call_args
assert call_args is not None
assert call_args.kwargs["action"] == "HOLD"
@pytest.mark.asyncio
async def test_neutral_outlook_uses_default_threshold(
self,
mock_broker: MagicMock,
mock_market: MagicMock,
mock_telegram: MagicMock,
) -> None:
"""BUY with confidence 82 should proceed in neutral market (default=80)."""
engine = MagicMock(spec=ScenarioEngine)
engine.evaluate = MagicMock(return_value=self._make_buy_match_with_confidence(82))
playbook = self._make_playbook_with_outlook("neutral")
risk = MagicMock()
risk.validate_order = MagicMock()
decision_logger = MagicMock()
decision_logger.log_decision = MagicMock(return_value="decision-id")
with patch("src.main.log_trade"):
await trading_cycle(
broker=mock_broker,
overseas_broker=MagicMock(),
scenario_engine=engine,
playbook=playbook,
risk=risk,
db_conn=MagicMock(),
decision_logger=decision_logger,
context_store=MagicMock(get_latest_timeframe=MagicMock(return_value=None)),
criticality_assessor=MagicMock(
assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")),
get_timeout=MagicMock(return_value=5.0),
),
telegram=mock_telegram,
market=mock_market,
stock_code="005930",
scan_candidates={},
)
call_args = decision_logger.log_decision.call_args
assert call_args is not None
assert call_args.kwargs["action"] == "BUY"

View File

@@ -830,171 +830,3 @@ class TestSmartFallbackPlaybook:
] ]
assert len(buy_scenarios) == 1 assert len(buy_scenarios) == 1
assert buy_scenarios[0].condition.volume_ratio_above == 2.0 # VOL_MULTIPLIER default assert buy_scenarios[0].condition.volume_ratio_above == 2.0 # VOL_MULTIPLIER default
# ---------------------------------------------------------------------------
# Holdings in prompt (#170)
# ---------------------------------------------------------------------------
class TestHoldingsInPrompt:
"""Tests for current_holdings parameter in generate_playbook / _build_prompt."""
def _make_holdings(self) -> list[dict]:
return [
{
"stock_code": "005930",
"name": "Samsung",
"qty": 10,
"entry_price": 71000.0,
"unrealized_pnl_pct": 2.3,
"holding_days": 3,
}
]
def test_build_prompt_includes_holdings_section(self) -> None:
"""Prompt should contain a Current Holdings section when holdings are given."""
planner = _make_planner()
candidates = [_candidate()]
holdings = self._make_holdings()
prompt = planner._build_prompt(
"KR",
candidates,
context_data={},
self_market_scorecard=None,
cross_market=None,
current_holdings=holdings,
)
assert "## Current Holdings" in prompt
assert "005930" in prompt
assert "+2.30%" in prompt
assert "보유 3일" in prompt
def test_build_prompt_no_holdings_omits_section(self) -> None:
"""Prompt should NOT contain a Current Holdings section when holdings=None."""
planner = _make_planner()
candidates = [_candidate()]
prompt = planner._build_prompt(
"KR",
candidates,
context_data={},
self_market_scorecard=None,
cross_market=None,
current_holdings=None,
)
assert "## Current Holdings" not in prompt
def test_build_prompt_empty_holdings_omits_section(self) -> None:
"""Empty list should also omit the holdings section."""
planner = _make_planner()
candidates = [_candidate()]
prompt = planner._build_prompt(
"KR",
candidates,
context_data={},
self_market_scorecard=None,
cross_market=None,
current_holdings=[],
)
assert "## Current Holdings" not in prompt
def test_build_prompt_holdings_instruction_included(self) -> None:
"""Prompt should include instruction to generate scenarios for held stocks."""
planner = _make_planner()
candidates = [_candidate()]
holdings = self._make_holdings()
prompt = planner._build_prompt(
"KR",
candidates,
context_data={},
self_market_scorecard=None,
cross_market=None,
current_holdings=holdings,
)
assert "005930" in prompt
assert "SELL/HOLD" in prompt
@pytest.mark.asyncio
async def test_generate_playbook_passes_holdings_to_prompt(self) -> None:
"""generate_playbook should pass current_holdings through to the prompt."""
planner = _make_planner()
candidates = [_candidate()]
holdings = self._make_holdings()
# Capture the actual prompt sent to Gemini
captured_prompts: list[str] = []
original_decide = planner._gemini.decide
async def capture_and_call(data: dict) -> TradeDecision:
captured_prompts.append(data.get("prompt_override", ""))
return await original_decide(data)
planner._gemini.decide = capture_and_call # type: ignore[method-assign]
await planner.generate_playbook(
"KR", candidates, today=date(2026, 2, 8), current_holdings=holdings
)
assert len(captured_prompts) == 1
assert "## Current Holdings" in captured_prompts[0]
assert "005930" in captured_prompts[0]
@pytest.mark.asyncio
async def test_holdings_stock_allowed_in_parse_response(self) -> None:
"""Holdings stocks not in candidates list should be accepted in the response."""
holding_code = "000660" # Not in candidates
stocks = [
{
"stock_code": "005930", # candidate
"scenarios": [
{
"condition": {"rsi_below": 30},
"action": "BUY",
"confidence": 85,
"rationale": "oversold",
}
],
},
{
"stock_code": holding_code, # holding only
"scenarios": [
{
"condition": {"price_change_pct_below": -2.0},
"action": "SELL",
"confidence": 90,
"rationale": "stop-loss",
}
],
},
]
planner = _make_planner(gemini_response=_gemini_response_json(stocks=stocks))
candidates = [_candidate()] # only 005930
holdings = [
{
"stock_code": holding_code,
"name": "SK Hynix",
"qty": 5,
"entry_price": 180000.0,
"unrealized_pnl_pct": -1.5,
"holding_days": 7,
}
]
pb = await planner.generate_playbook(
"KR",
candidates,
today=date(2026, 2, 8),
current_holdings=holdings,
)
codes = [sp.stock_code for sp in pb.stock_playbooks]
assert "005930" in codes
assert holding_code in codes

View File

@@ -440,135 +440,3 @@ class TestEvaluate:
assert result.action == ScenarioAction.BUY assert result.action == ScenarioAction.BUY
assert result.match_details["rsi"] == 25.0 assert result.match_details["rsi"] == 25.0
assert isinstance(result.match_details["rsi"], float) assert isinstance(result.match_details["rsi"], float)
# ---------------------------------------------------------------------------
# Position-aware condition tests (#171)
# ---------------------------------------------------------------------------
class TestPositionAwareConditions:
"""Tests for unrealized_pnl_pct and holding_days condition fields."""
def test_evaluate_condition_unrealized_pnl_above_matches(
self, engine: ScenarioEngine
) -> None:
"""unrealized_pnl_pct_above should match when P&L exceeds threshold."""
condition = StockCondition(unrealized_pnl_pct_above=3.0)
assert engine.evaluate_condition(condition, {"unrealized_pnl_pct": 5.0}) is True
def test_evaluate_condition_unrealized_pnl_above_no_match(
self, engine: ScenarioEngine
) -> None:
"""unrealized_pnl_pct_above should NOT match when P&L is below threshold."""
condition = StockCondition(unrealized_pnl_pct_above=3.0)
assert engine.evaluate_condition(condition, {"unrealized_pnl_pct": 2.0}) is False
def test_evaluate_condition_unrealized_pnl_below_matches(
self, engine: ScenarioEngine
) -> None:
"""unrealized_pnl_pct_below should match when P&L is under threshold."""
condition = StockCondition(unrealized_pnl_pct_below=-2.0)
assert engine.evaluate_condition(condition, {"unrealized_pnl_pct": -3.5}) is True
def test_evaluate_condition_unrealized_pnl_below_no_match(
self, engine: ScenarioEngine
) -> None:
"""unrealized_pnl_pct_below should NOT match when P&L is above threshold."""
condition = StockCondition(unrealized_pnl_pct_below=-2.0)
assert engine.evaluate_condition(condition, {"unrealized_pnl_pct": -1.0}) is False
def test_evaluate_condition_holding_days_above_matches(
self, engine: ScenarioEngine
) -> None:
"""holding_days_above should match when position held longer than threshold."""
condition = StockCondition(holding_days_above=5)
assert engine.evaluate_condition(condition, {"holding_days": 7}) is True
def test_evaluate_condition_holding_days_above_no_match(
self, engine: ScenarioEngine
) -> None:
"""holding_days_above should NOT match when position held shorter."""
condition = StockCondition(holding_days_above=5)
assert engine.evaluate_condition(condition, {"holding_days": 3}) is False
def test_evaluate_condition_holding_days_below_matches(
self, engine: ScenarioEngine
) -> None:
"""holding_days_below should match when position held fewer days."""
condition = StockCondition(holding_days_below=3)
assert engine.evaluate_condition(condition, {"holding_days": 1}) is True
def test_evaluate_condition_holding_days_below_no_match(
self, engine: ScenarioEngine
) -> None:
"""holding_days_below should NOT match when held more days."""
condition = StockCondition(holding_days_below=3)
assert engine.evaluate_condition(condition, {"holding_days": 5}) is False
def test_combined_pnl_and_holding_days(self, engine: ScenarioEngine) -> None:
"""Combined position-aware conditions should AND-evaluate correctly."""
condition = StockCondition(
unrealized_pnl_pct_above=3.0,
holding_days_above=5,
)
# Both met → match
assert engine.evaluate_condition(
condition,
{"unrealized_pnl_pct": 4.5, "holding_days": 7},
) is True
# Only pnl met → no match
assert engine.evaluate_condition(
condition,
{"unrealized_pnl_pct": 4.5, "holding_days": 3},
) is False
def test_missing_unrealized_pnl_does_not_match(
self, engine: ScenarioEngine
) -> None:
"""Missing unrealized_pnl_pct key should not match the condition."""
condition = StockCondition(unrealized_pnl_pct_above=3.0)
assert engine.evaluate_condition(condition, {}) is False
def test_missing_holding_days_does_not_match(
self, engine: ScenarioEngine
) -> None:
"""Missing holding_days key should not match the condition."""
condition = StockCondition(holding_days_above=5)
assert engine.evaluate_condition(condition, {}) is False
def test_match_details_includes_position_fields(
self, engine: ScenarioEngine
) -> None:
"""match_details should include position fields when condition specifies them."""
pb = _playbook(
scenarios=[
StockScenario(
condition=StockCondition(unrealized_pnl_pct_above=3.0),
action=ScenarioAction.SELL,
confidence=90,
rationale="Take profit",
)
]
)
result = engine.evaluate(
pb,
"005930",
{"unrealized_pnl_pct": 5.0},
{},
)
assert result.action == ScenarioAction.SELL
assert "unrealized_pnl_pct" in result.match_details
assert result.match_details["unrealized_pnl_pct"] == 5.0
def test_position_conditions_parse_from_planner(self) -> None:
"""StockCondition should accept and store new fields from JSON parsing."""
condition = StockCondition(
unrealized_pnl_pct_above=3.0,
unrealized_pnl_pct_below=None,
holding_days_above=5,
holding_days_below=None,
)
assert condition.unrealized_pnl_pct_above == 3.0
assert condition.holding_days_above == 5
assert condition.has_any_condition() is True