Compare commits
1 Commits
feature/is
...
feature/is
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1f48d859e |
30
src/main.py
30
src/main.py
@@ -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
|
||||||
@@ -457,34 +457,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:
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ 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.
|
||||||
|
|
||||||
@@ -82,6 +83,10 @@ 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.
|
||||||
@@ -106,6 +111,7 @@ 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
|
||||||
@@ -118,7 +124,8 @@ 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}
|
||||||
@@ -230,6 +237,7 @@ 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
|
||||||
@@ -241,6 +249,26 @@ 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 = (
|
||||||
@@ -273,10 +301,20 @@ 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"
|
||||||
@@ -308,7 +346,8 @@ 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"- Only use stocks from the candidates list\n"
|
f"- Candidates list is the primary source for BUY candidates\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"
|
||||||
@@ -321,12 +360,19 @@ 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")
|
||||||
|
|||||||
@@ -2114,284 +2114,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()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 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,3 +830,171 @@ 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
|
||||||
|
|||||||
Reference in New Issue
Block a user