Compare commits
1 Commits
feature/is
...
feature/is
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d19e5b0de6 |
22
src/db.py
22
src/db.py
@@ -237,6 +237,28 @@ 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]:
|
||||||
|
|||||||
224
src/main.py
224
src/main.py
@@ -32,6 +32,7 @@ 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,
|
||||||
@@ -42,7 +43,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 +107,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 +114,16 @@ 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,
|
|
||||||
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."""
|
||||||
|
if action != "BUY":
|
||||||
Priority:
|
return 1
|
||||||
1. playbook_allocation_pct (AI-specified) scaled by scenario_confidence
|
|
||||||
2. Fallback: volatility-score-based allocation from scanner candidate
|
|
||||||
"""
|
|
||||||
if action == "SELL":
|
|
||||||
return broker_held_qty
|
|
||||||
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 +381,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 +388,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 +407,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 +467,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"):
|
||||||
broker_held_qty = (
|
|
||||||
_extract_held_qty_from_balance(
|
|
||||||
balance_data, stock_code, is_domestic=market.is_domestic
|
|
||||||
)
|
|
||||||
if decision.action == "SELL"
|
|
||||||
else 0
|
|
||||||
)
|
|
||||||
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,
|
|
||||||
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,20 +892,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_broker_held_qty = (
|
|
||||||
_extract_held_qty_from_balance(
|
|
||||||
balance_data, stock_code, is_domestic=market.is_domestic
|
|
||||||
)
|
|
||||||
if decision.action == "SELL"
|
|
||||||
else 0
|
|
||||||
)
|
|
||||||
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,
|
||||||
broker_held_qty=daily_broker_held_qty,
|
|
||||||
)
|
)
|
||||||
if quantity <= 0:
|
if quantity <= 0:
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -1300,18 +1136,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 +1150,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 +1864,22 @@ 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 /
|
# Also include current holdings so stop-loss / take-profit
|
||||||
# take-profit can fire even when a holding drops off the
|
# can trigger even when a position drops off the scanner.
|
||||||
# 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, [])
|
scanner_codes = active_stocks.get(market.code, [])
|
||||||
try:
|
held_codes = get_open_positions_by_market(db_conn, market.code)
|
||||||
if market.is_domestic:
|
# Union: scanner candidates first, then holdings not already present.
|
||||||
held_balance = await broker.get_balance()
|
# dict.fromkeys preserves insertion order and removes duplicates.
|
||||||
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))
|
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 held_codes:
|
||||||
if extra_held:
|
new_held = [c for c in held_codes if c not in set(scanner_codes)]
|
||||||
|
if new_held:
|
||||||
logger.info(
|
logger.info(
|
||||||
"Holdings added to loop for %s (not in scanner): %s",
|
"Holdings added to loop for %s (not in scanner): %s",
|
||||||
market.name, extra_held,
|
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
|
||||||
|
|||||||
@@ -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,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Tests for database helper functions."""
|
"""Tests for database helper functions."""
|
||||||
|
|
||||||
from src.db import get_open_position, init_db, log_trade
|
from src.db import get_open_position, get_open_positions_by_market, init_db, log_trade
|
||||||
|
|
||||||
|
|
||||||
def test_get_open_position_returns_latest_buy() -> None:
|
def test_get_open_position_returns_latest_buy() -> None:
|
||||||
@@ -58,3 +58,87 @@ 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") == []
|
||||||
|
|||||||
@@ -14,9 +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,
|
|
||||||
_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,219 +68,6 @@ 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:
|
|
||||||
"""Test _determine_order_quantity() — SELL uses broker_held_qty."""
|
|
||||||
|
|
||||||
def test_sell_returns_broker_held_qty(self) -> None:
|
|
||||||
result = _determine_order_quantity(
|
|
||||||
action="SELL",
|
|
||||||
current_price=105.0,
|
|
||||||
total_cash=50000.0,
|
|
||||||
candidate=None,
|
|
||||||
settings=None,
|
|
||||||
broker_held_qty=7,
|
|
||||||
)
|
|
||||||
assert result == 7
|
|
||||||
|
|
||||||
def test_sell_returns_zero_when_broker_qty_zero(self) -> None:
|
|
||||||
result = _determine_order_quantity(
|
|
||||||
action="SELL",
|
|
||||||
current_price=105.0,
|
|
||||||
total_cash=50000.0,
|
|
||||||
candidate=None,
|
|
||||||
settings=None,
|
|
||||||
broker_held_qty=0,
|
|
||||||
)
|
|
||||||
assert result == 0
|
|
||||||
|
|
||||||
def test_buy_without_position_sizing_returns_one(self) -> None:
|
|
||||||
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:
|
|
||||||
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:
|
|
||||||
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
|
|
||||||
# 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:
|
|
||||||
"""playbook_allocation_pct should take priority over volatility-based sizing."""
|
|
||||||
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(
|
|
||||||
action="BUY",
|
|
||||||
current_price=50000.0,
|
|
||||||
total_cash=1000000.0,
|
|
||||||
candidate=None,
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
class TestSafeFloat:
|
class TestSafeFloat:
|
||||||
"""Test safe_float() helper function."""
|
"""Test safe_float() helper function."""
|
||||||
|
|
||||||
@@ -1456,14 +1240,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"})
|
||||||
@@ -1545,209 +1328,6 @@ async def test_hold_overridden_to_sell_when_stop_loss_triggered() -> None:
|
|||||||
|
|
||||||
broker = MagicMock()
|
broker = MagicMock()
|
||||||
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(
|
|
||||||
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,
|
|
||||||
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()
|
|
||||||
assert broker.send_order.call_args.kwargs["order_type"] == "SELL"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
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)
|
|
||||||
|
|
||||||
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 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={
|
|
||||||
"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 +1341,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,
|
||||||
@@ -1920,10 +1393,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
|
assert broker.send_order.call_args.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
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -2192,307 +1662,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"
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
Reference in New Issue
Block a user