Compare commits
1 Commits
feature/is
...
feature/is
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7640a30d7 |
28
src/main.py
28
src/main.py
@@ -190,8 +190,15 @@ def _determine_order_quantity(
|
|||||||
candidate: ScanCandidate | None,
|
candidate: ScanCandidate | None,
|
||||||
settings: Settings | None,
|
settings: Settings | None,
|
||||||
broker_held_qty: int = 0,
|
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.
|
||||||
|
|
||||||
|
Priority:
|
||||||
|
1. playbook_allocation_pct (AI-specified) scaled by scenario_confidence
|
||||||
|
2. Fallback: volatility-score-based allocation from scanner candidate
|
||||||
|
"""
|
||||||
if action == "SELL":
|
if action == "SELL":
|
||||||
return broker_held_qty
|
return broker_held_qty
|
||||||
if current_price <= 0 or total_cash <= 0:
|
if current_price <= 0 or total_cash <= 0:
|
||||||
@@ -200,6 +207,22 @@ def _determine_order_quantity(
|
|||||||
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))
|
||||||
@@ -568,6 +591,7 @@ async def trading_cycle(
|
|||||||
if decision.action == "SELL"
|
if decision.action == "SELL"
|
||||||
else 0
|
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,
|
||||||
@@ -575,6 +599,8 @@ async def trading_cycle(
|
|||||||
candidate=candidate,
|
candidate=candidate,
|
||||||
settings=settings,
|
settings=settings,
|
||||||
broker_held_qty=broker_held_qty,
|
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(
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -346,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"
|
||||||
@@ -360,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")
|
||||||
|
|||||||
@@ -205,6 +205,84 @@ class TestDetermineOrderQuantity:
|
|||||||
)
|
)
|
||||||
assert result == 2
|
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."""
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
Reference in New Issue
Block a user