Compare commits
1 Commits
feature/is
...
dfb418c7b2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dfb418c7b2 |
@@ -201,68 +201,3 @@
|
|||||||
- `tests/test_brain.py`: 3개 테스트 추가 (override 전달, optimization 우회, 미지정 시 기존 동작 유지)
|
- `tests/test_brain.py`: 3개 테스트 추가 (override 전달, optimization 우회, 미지정 시 기존 동작 유지)
|
||||||
|
|
||||||
**이슈/PR:** #143
|
**이슈/PR:** #143
|
||||||
|
|
||||||
### 미국장 거래 미실행 근본 원인 분석 및 수정 (자율 실행 세션)
|
|
||||||
|
|
||||||
**배경:**
|
|
||||||
- 사용자 요청: "미국장 열면 프로그램 돌려서 거래 한 번도 못 한 거 꼭 원인 찾아서 해결해줘"
|
|
||||||
- 프로그램을 미국장 개장(9:30 AM EST) 전부터 실행하여 실시간 로그를 분석
|
|
||||||
|
|
||||||
**발견된 근본 원인 #1: Defensive Playbook — BUY 조건 없음**
|
|
||||||
|
|
||||||
- Gemini free tier (20 RPD) 소진 → `generate_playbook()` 실패 → `_defensive_playbook()` 폴백
|
|
||||||
- Defensive playbook은 `price_change_pct_below: -3.0 → SELL` 조건만 존재, BUY 조건 없음
|
|
||||||
- ScenarioEngine이 항상 HOLD 반환 → 거래 0건
|
|
||||||
|
|
||||||
**수정 #1 (PR #146, Issue #145):**
|
|
||||||
- `src/strategy/pre_market_planner.py`: `_smart_fallback_playbook()` 메서드 추가
|
|
||||||
- 스캐너 signal 기반 BUY 조건 생성: `momentum → volume_ratio_above`, `oversold → rsi_below`
|
|
||||||
- 기존 defensive stop-loss SELL 조건 유지
|
|
||||||
- Gemini 실패 시 defensive → smart fallback으로 전환
|
|
||||||
- 테스트 10개 추가
|
|
||||||
|
|
||||||
**발견된 근본 원인 #2: 가격 API 거래소 코드 불일치 + VTS 잔고 API 오류**
|
|
||||||
|
|
||||||
실제 로그:
|
|
||||||
```
|
|
||||||
Scenario matched for MRNX: BUY (confidence=80) ✓
|
|
||||||
Decision for EWUS (NYSE American): BUY (confidence=80) ✓
|
|
||||||
Skip BUY APLZ (NYSE American): no affordable quantity (cash=0.00, price=0.00) ✗
|
|
||||||
```
|
|
||||||
|
|
||||||
- `get_overseas_price()`: `NASD`/`NYSE`/`AMEX` 전송 → API가 `NAS`/`NYS`/`AMS` 기대 → 빈 응답 → `price=0`
|
|
||||||
- `VTTS3012R` 잔고 API: "ERROR : INPUT INVALID_CHECK_ACNO" → `total_cash=0`
|
|
||||||
- 결과: `_determine_order_quantity()` 가 0 반환 → 주문 건너뜀
|
|
||||||
|
|
||||||
**수정 #2 (PR #148, Issue #147):**
|
|
||||||
- `src/broker/overseas.py`: `_PRICE_EXCHANGE_MAP = _RANKING_EXCHANGE_MAP` 추가, 가격 API에 매핑 적용
|
|
||||||
- `src/config.py`: `PAPER_OVERSEAS_CASH: float = Field(default=50000.0)` — paper 모드 시뮬레이션 잔고
|
|
||||||
- `src/main.py`: 잔고 0일 때 PAPER_OVERSEAS_CASH 폴백, 가격 0일 때 candidate.price 폴백
|
|
||||||
- 테스트 8개 추가
|
|
||||||
|
|
||||||
**효과:**
|
|
||||||
- BUY 결정 → 실제 주문 전송까지의 파이프라인이 완전히 동작
|
|
||||||
- Paper 모드에서 KIS VTS 해외 잔고 API 오류에 관계없이 시뮬레이션 거래 가능
|
|
||||||
|
|
||||||
**이슈/PR:** #145, #146, #147, #148
|
|
||||||
|
|
||||||
### 해외주식 시장가 주문 거부 수정 (Fix #3, 연속 발견)
|
|
||||||
|
|
||||||
**배경:**
|
|
||||||
- Fix #147 적용 후 주문 전송 시작 → KIS VTS가 거부: "지정가만 가능한 상품입니다"
|
|
||||||
|
|
||||||
**근본 원인:**
|
|
||||||
- `trading_cycle()`, `run_daily_session()` 양쪽에서 `send_overseas_order(price=0.0)` 하드코딩
|
|
||||||
- `price=0` → `ORD_DVSN="01"` (시장가) 전송 → KIS VTS 거부
|
|
||||||
- Fix #147에서 이미 `current_price`를 올바르게 계산했으나 주문 시 미사용
|
|
||||||
|
|
||||||
**구현 결과:**
|
|
||||||
- `src/main.py`: 두 곳에서 `price=0.0` → `price=current_price`/`price=stock_data["current_price"]`
|
|
||||||
- `tests/test_main.py`: 회귀 테스트 `test_overseas_buy_order_uses_limit_price` 추가
|
|
||||||
|
|
||||||
**최종 확인 로그:**
|
|
||||||
```
|
|
||||||
Order result: 모의투자 매수주문이 완료 되었습니다. ✓
|
|
||||||
```
|
|
||||||
|
|
||||||
**이슈/PR:** #149, #150
|
|
||||||
|
|||||||
@@ -75,14 +75,6 @@ class Settings(BaseSettings):
|
|||||||
# Market selection (comma-separated market codes)
|
# Market selection (comma-separated market codes)
|
||||||
ENABLED_MARKETS: str = "KR,US"
|
ENABLED_MARKETS: str = "KR,US"
|
||||||
|
|
||||||
# Fallback stock list for KR domestic market when ranking API returns empty
|
|
||||||
# (KIS VTS does not return data from volume-rank API).
|
|
||||||
# Comma-separated 6-digit stock codes. Override in .env if needed.
|
|
||||||
KR_FALLBACK_STOCKS: str = (
|
|
||||||
"005930,000660,035420,005380,068270," # 삼성전자,SK하이닉스,NAVER,현대차,셀트리온
|
|
||||||
"051910,035720,006400,207940,000270" # LG화학,카카오,삼성SDI,삼성바이오로직스,기아
|
|
||||||
)
|
|
||||||
|
|
||||||
# Backup and Disaster Recovery (optional)
|
# Backup and Disaster Recovery (optional)
|
||||||
BACKUP_ENABLED: bool = True
|
BACKUP_ENABLED: bool = True
|
||||||
BACKUP_DIR: str = "data/backups"
|
BACKUP_DIR: str = "data/backups"
|
||||||
|
|||||||
36
src/main.py
36
src/main.py
@@ -263,19 +263,6 @@ async def trading_cycle(
|
|||||||
foreigner_net = 0.0 # Not available for overseas
|
foreigner_net = 0.0 # Not available for overseas
|
||||||
price_change_pct = safe_float(price_data.get("output", {}).get("rate", "0"))
|
price_change_pct = safe_float(price_data.get("output", {}).get("rate", "0"))
|
||||||
|
|
||||||
# Price API may return 0/empty for certain VTS exchange codes.
|
|
||||||
# Fall back to the scanner candidate's price so order sizing still works.
|
|
||||||
if current_price <= 0:
|
|
||||||
market_candidates_lookup = scan_candidates.get(market.code, {})
|
|
||||||
cand_lookup = market_candidates_lookup.get(stock_code)
|
|
||||||
if cand_lookup and cand_lookup.price > 0:
|
|
||||||
current_price = cand_lookup.price
|
|
||||||
logger.debug(
|
|
||||||
"Price API returned 0 for %s; using scanner price %.4f",
|
|
||||||
stock_code,
|
|
||||||
current_price,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Calculate daily P&L %
|
# Calculate daily P&L %
|
||||||
pnl_pct = (
|
pnl_pct = (
|
||||||
((total_eval - purchase_total) / purchase_total * 100)
|
((total_eval - purchase_total) / purchase_total * 100)
|
||||||
@@ -757,16 +744,6 @@ async def run_daily_session(
|
|||||||
price_change_pct = safe_float(
|
price_change_pct = safe_float(
|
||||||
price_data.get("output", {}).get("rate", "0")
|
price_data.get("output", {}).get("rate", "0")
|
||||||
)
|
)
|
||||||
# Fall back to scanner candidate price if API returns 0.
|
|
||||||
if current_price <= 0:
|
|
||||||
cand_lookup = candidate_map.get(stock_code)
|
|
||||||
if cand_lookup and cand_lookup.price > 0:
|
|
||||||
current_price = cand_lookup.price
|
|
||||||
logger.debug(
|
|
||||||
"Price API returned 0 for %s; using scanner price %.4f",
|
|
||||||
stock_code,
|
|
||||||
current_price,
|
|
||||||
)
|
|
||||||
|
|
||||||
stock_data: dict[str, Any] = {
|
stock_data: dict[str, Any] = {
|
||||||
"stock_code": stock_code,
|
"stock_code": stock_code,
|
||||||
@@ -821,10 +798,6 @@ async def run_daily_session(
|
|||||||
if total_cash <= 0 and settings.PAPER_OVERSEAS_CASH > 0:
|
if total_cash <= 0 and settings.PAPER_OVERSEAS_CASH > 0:
|
||||||
total_cash = settings.PAPER_OVERSEAS_CASH
|
total_cash = settings.PAPER_OVERSEAS_CASH
|
||||||
|
|
||||||
# VTS overseas balance API often returns 0; use paper fallback.
|
|
||||||
if total_cash <= 0 and settings.PAPER_OVERSEAS_CASH > 0:
|
|
||||||
total_cash = settings.PAPER_OVERSEAS_CASH
|
|
||||||
|
|
||||||
# Calculate daily P&L %
|
# Calculate daily P&L %
|
||||||
pnl_pct = (
|
pnl_pct = (
|
||||||
((total_eval - purchase_total) / purchase_total * 100)
|
((total_eval - purchase_total) / purchase_total * 100)
|
||||||
@@ -1698,14 +1671,7 @@ async def run(settings: Settings) -> None:
|
|||||||
logger.info("Smart Scanner: Scanning %s market", market.name)
|
logger.info("Smart Scanner: Scanning %s market", market.name)
|
||||||
|
|
||||||
fallback_stocks: list[str] | None = None
|
fallback_stocks: list[str] | None = None
|
||||||
if market.is_domestic:
|
if not market.is_domestic:
|
||||||
# KIS VTS ranking API often returns empty for domestic.
|
|
||||||
# Use configured fallback so scanner can still run.
|
|
||||||
raw = settings.KR_FALLBACK_STOCKS if settings else ""
|
|
||||||
fallback_stocks = [
|
|
||||||
c.strip() for c in raw.split(",") if c.strip()
|
|
||||||
] or None
|
|
||||||
else:
|
|
||||||
fallback_stocks = await build_overseas_symbol_universe(
|
fallback_stocks = await build_overseas_symbol_universe(
|
||||||
db_conn=db_conn,
|
db_conn=db_conn,
|
||||||
overseas_broker=overseas_broker,
|
overseas_broker=overseas_broker,
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
"""Pre-market planner — generates DayPlaybook via Gemini before market open.
|
"""Pre-market planner — generates DayPlaybook via Gemini before market open.
|
||||||
|
|
||||||
One Gemini API call per market per day. Candidates come from SmartVolatilityScanner.
|
One Gemini API call per market per day. Candidates come from SmartVolatilityScanner.
|
||||||
On failure, returns a smart rule-based fallback playbook that uses scanner signals
|
On failure, returns a defensive playbook (all HOLD, no trades).
|
||||||
(momentum/oversold) to generate BUY conditions, avoiding the all-HOLD problem.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -135,7 +134,7 @@ class PreMarketPlanner:
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Playbook generation failed for %s", market)
|
logger.exception("Playbook generation failed for %s", market)
|
||||||
if self._settings.DEFENSIVE_PLAYBOOK_ON_FAILURE:
|
if self._settings.DEFENSIVE_PLAYBOOK_ON_FAILURE:
|
||||||
return self._smart_fallback_playbook(today, market, candidates, self._settings)
|
return self._defensive_playbook(today, market, candidates)
|
||||||
return self._empty_playbook(today, market)
|
return self._empty_playbook(today, market)
|
||||||
|
|
||||||
def build_cross_market_context(
|
def build_cross_market_context(
|
||||||
@@ -471,99 +470,3 @@ class PreMarketPlanner:
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _smart_fallback_playbook(
|
|
||||||
today: date,
|
|
||||||
market: str,
|
|
||||||
candidates: list[ScanCandidate],
|
|
||||||
settings: Settings,
|
|
||||||
) -> DayPlaybook:
|
|
||||||
"""Rule-based fallback playbook when Gemini is unavailable.
|
|
||||||
|
|
||||||
Uses scanner signals (RSI, volume_ratio) to generate meaningful BUY
|
|
||||||
conditions instead of the all-SELL defensive playbook. Candidates are
|
|
||||||
already pre-qualified by SmartVolatilityScanner, so we trust their
|
|
||||||
signals and build actionable scenarios from them.
|
|
||||||
|
|
||||||
Scenario logic per candidate:
|
|
||||||
- momentum signal: BUY when volume_ratio exceeds scanner threshold
|
|
||||||
- oversold signal: BUY when RSI is below oversold threshold
|
|
||||||
- always: SELL stop-loss at -3.0% as guard
|
|
||||||
"""
|
|
||||||
stock_playbooks = []
|
|
||||||
for c in candidates:
|
|
||||||
scenarios: list[StockScenario] = []
|
|
||||||
|
|
||||||
if c.signal == "momentum":
|
|
||||||
scenarios.append(
|
|
||||||
StockScenario(
|
|
||||||
condition=StockCondition(
|
|
||||||
volume_ratio_above=settings.VOL_MULTIPLIER,
|
|
||||||
),
|
|
||||||
action=ScenarioAction.BUY,
|
|
||||||
confidence=80,
|
|
||||||
allocation_pct=10.0,
|
|
||||||
stop_loss_pct=-3.0,
|
|
||||||
take_profit_pct=5.0,
|
|
||||||
rationale=(
|
|
||||||
f"Rule-based BUY: momentum signal, "
|
|
||||||
f"volume={c.volume_ratio:.1f}x (fallback planner)"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
elif c.signal == "oversold":
|
|
||||||
scenarios.append(
|
|
||||||
StockScenario(
|
|
||||||
condition=StockCondition(
|
|
||||||
rsi_below=settings.RSI_OVERSOLD_THRESHOLD,
|
|
||||||
),
|
|
||||||
action=ScenarioAction.BUY,
|
|
||||||
confidence=80,
|
|
||||||
allocation_pct=10.0,
|
|
||||||
stop_loss_pct=-3.0,
|
|
||||||
take_profit_pct=5.0,
|
|
||||||
rationale=(
|
|
||||||
f"Rule-based BUY: oversold signal, "
|
|
||||||
f"RSI={c.rsi:.0f} (fallback planner)"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Always add stop-loss guard
|
|
||||||
scenarios.append(
|
|
||||||
StockScenario(
|
|
||||||
condition=StockCondition(price_change_pct_below=-3.0),
|
|
||||||
action=ScenarioAction.SELL,
|
|
||||||
confidence=90,
|
|
||||||
stop_loss_pct=-3.0,
|
|
||||||
rationale="Rule-based stop-loss (fallback planner)",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
stock_playbooks.append(
|
|
||||||
StockPlaybook(
|
|
||||||
stock_code=c.stock_code,
|
|
||||||
scenarios=scenarios,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"Smart fallback playbook for %s: %d stocks with rule-based BUY/SELL conditions",
|
|
||||||
market,
|
|
||||||
len(stock_playbooks),
|
|
||||||
)
|
|
||||||
return DayPlaybook(
|
|
||||||
date=today,
|
|
||||||
market=market,
|
|
||||||
market_outlook=MarketOutlook.NEUTRAL,
|
|
||||||
default_action=ScenarioAction.HOLD,
|
|
||||||
stock_playbooks=stock_playbooks,
|
|
||||||
global_rules=[
|
|
||||||
GlobalRule(
|
|
||||||
condition="portfolio_pnl_pct < -2.0",
|
|
||||||
action=ScenarioAction.REDUCE_ALL,
|
|
||||||
rationale="Defensive: reduce on loss threshold",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -738,83 +738,6 @@ class TestOverseasBalanceParsing:
|
|||||||
# Verify price API was called
|
# Verify price API was called
|
||||||
mock_overseas_broker_with_empty_price.get_overseas_price.assert_called_once()
|
mock_overseas_broker_with_empty_price.get_overseas_price.assert_called_once()
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_overseas_broker_with_buy_scenario(self) -> MagicMock:
|
|
||||||
"""Create mock overseas broker that returns a valid price for BUY orders."""
|
|
||||||
broker = MagicMock()
|
|
||||||
broker.get_overseas_price = AsyncMock(
|
|
||||||
return_value={"output": {"last": "182.50"}}
|
|
||||||
)
|
|
||||||
broker.get_overseas_balance = AsyncMock(
|
|
||||||
return_value={
|
|
||||||
"output2": [
|
|
||||||
{
|
|
||||||
"frcr_evlu_tota": "100000.00",
|
|
||||||
"frcr_dncl_amt_2": "50000.00",
|
|
||||||
"frcr_buy_amt_smtl": "50000.00",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
)
|
|
||||||
broker.send_overseas_order = AsyncMock(return_value={"msg1": "주문접수"})
|
|
||||||
return broker
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_scenario_engine_buy(self) -> MagicMock:
|
|
||||||
"""Create mock scenario engine that returns BUY."""
|
|
||||||
engine = MagicMock(spec=ScenarioEngine)
|
|
||||||
engine.evaluate = MagicMock(return_value=_make_buy_match("AAPL"))
|
|
||||||
return engine
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_overseas_buy_order_uses_limit_price(
|
|
||||||
self,
|
|
||||||
mock_domestic_broker: MagicMock,
|
|
||||||
mock_overseas_broker_with_buy_scenario: MagicMock,
|
|
||||||
mock_scenario_engine_buy: MagicMock,
|
|
||||||
mock_playbook: DayPlaybook,
|
|
||||||
mock_risk: MagicMock,
|
|
||||||
mock_db: MagicMock,
|
|
||||||
mock_decision_logger: MagicMock,
|
|
||||||
mock_context_store: MagicMock,
|
|
||||||
mock_criticality_assessor: MagicMock,
|
|
||||||
mock_telegram: MagicMock,
|
|
||||||
mock_overseas_market: MagicMock,
|
|
||||||
) -> None:
|
|
||||||
"""Overseas BUY order must use current_price (limit), not 0 (market).
|
|
||||||
|
|
||||||
KIS VTS rejects market orders for overseas paper trading.
|
|
||||||
Regression test for issue #149.
|
|
||||||
"""
|
|
||||||
mock_telegram.notify_trade_execution = AsyncMock()
|
|
||||||
|
|
||||||
with patch("src.main.log_trade"):
|
|
||||||
await trading_cycle(
|
|
||||||
broker=mock_domestic_broker,
|
|
||||||
overseas_broker=mock_overseas_broker_with_buy_scenario,
|
|
||||||
scenario_engine=mock_scenario_engine_buy,
|
|
||||||
playbook=mock_playbook,
|
|
||||||
risk=mock_risk,
|
|
||||||
db_conn=mock_db,
|
|
||||||
decision_logger=mock_decision_logger,
|
|
||||||
context_store=mock_context_store,
|
|
||||||
criticality_assessor=mock_criticality_assessor,
|
|
||||||
telegram=mock_telegram,
|
|
||||||
market=mock_overseas_market,
|
|
||||||
stock_code="AAPL",
|
|
||||||
scan_candidates={},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify limit order was sent with actual price + 0.5% premium (issue #151), not 0.0
|
|
||||||
mock_overseas_broker_with_buy_scenario.send_overseas_order.assert_called_once()
|
|
||||||
call_kwargs = mock_overseas_broker_with_buy_scenario.send_overseas_order.call_args
|
|
||||||
sent_price = call_kwargs[1].get("price") or call_kwargs[0][4]
|
|
||||||
expected_price = round(182.5 * 1.005, 4) # 0.5% premium for BUY limit orders
|
|
||||||
assert sent_price == expected_price, (
|
|
||||||
f"Expected limit price {expected_price} (182.5 * 1.005) but got {sent_price}. "
|
|
||||||
"KIS VTS only accepts limit orders; BUY uses 0.5% premium to improve fill rate."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestScenarioEngineIntegration:
|
class TestScenarioEngineIntegration:
|
||||||
"""Test scenario engine integration in trading_cycle."""
|
"""Test scenario engine integration in trading_cycle."""
|
||||||
@@ -1678,43 +1601,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()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# KR fallback stocks config (issue #153)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class TestKrFallbackStocksConfig:
|
|
||||||
"""Test KR_FALLBACK_STOCKS default value and parsing."""
|
|
||||||
|
|
||||||
def test_default_contains_samsung(self) -> None:
|
|
||||||
settings = Settings(
|
|
||||||
KIS_APP_KEY="k",
|
|
||||||
KIS_APP_SECRET="s",
|
|
||||||
KIS_ACCOUNT_NO="12345678-01",
|
|
||||||
GEMINI_API_KEY="g",
|
|
||||||
)
|
|
||||||
codes = [c.strip() for c in settings.KR_FALLBACK_STOCKS.split(",") if c.strip()]
|
|
||||||
assert "005930" in codes # 삼성전자
|
|
||||||
|
|
||||||
def test_default_has_ten_codes(self) -> None:
|
|
||||||
settings = Settings(
|
|
||||||
KIS_APP_KEY="k",
|
|
||||||
KIS_APP_SECRET="s",
|
|
||||||
KIS_ACCOUNT_NO="12345678-01",
|
|
||||||
GEMINI_API_KEY="g",
|
|
||||||
)
|
|
||||||
codes = [c.strip() for c in settings.KR_FALLBACK_STOCKS.split(",") if c.strip()]
|
|
||||||
assert len(codes) == 10
|
|
||||||
|
|
||||||
def test_env_override(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
monkeypatch.setenv("KR_FALLBACK_STOCKS", "005930,000660")
|
|
||||||
settings = Settings(
|
|
||||||
KIS_APP_KEY="k",
|
|
||||||
KIS_APP_SECRET="s",
|
|
||||||
KIS_ACCOUNT_NO="12345678-01",
|
|
||||||
GEMINI_API_KEY="g",
|
|
||||||
)
|
|
||||||
codes = [c.strip() for c in settings.KR_FALLBACK_STOCKS.split(",") if c.strip()]
|
|
||||||
assert codes == ["005930", "000660"]
|
|
||||||
|
|||||||
@@ -164,23 +164,18 @@ class TestGeneratePlaybook:
|
|||||||
assert pb.market_outlook == MarketOutlook.NEUTRAL
|
assert pb.market_outlook == MarketOutlook.NEUTRAL
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_gemini_failure_returns_smart_fallback(self) -> None:
|
async def test_gemini_failure_returns_defensive(self) -> None:
|
||||||
planner = _make_planner()
|
planner = _make_planner()
|
||||||
planner._gemini.decide = AsyncMock(side_effect=RuntimeError("API timeout"))
|
planner._gemini.decide = AsyncMock(side_effect=RuntimeError("API timeout"))
|
||||||
# oversold candidate (signal="oversold", rsi=28.5)
|
|
||||||
candidates = [_candidate()]
|
candidates = [_candidate()]
|
||||||
|
|
||||||
pb = await planner.generate_playbook("KR", candidates, today=date(2026, 2, 8))
|
pb = await planner.generate_playbook("KR", candidates, today=date(2026, 2, 8))
|
||||||
|
|
||||||
assert pb.default_action == ScenarioAction.HOLD
|
assert pb.default_action == ScenarioAction.HOLD
|
||||||
# Smart fallback uses NEUTRAL outlook (not NEUTRAL_TO_BEARISH)
|
assert pb.market_outlook == MarketOutlook.NEUTRAL_TO_BEARISH
|
||||||
assert pb.market_outlook == MarketOutlook.NEUTRAL
|
|
||||||
assert pb.stock_count == 1
|
assert pb.stock_count == 1
|
||||||
# Oversold candidate → first scenario is BUY, second is SELL stop-loss
|
# Defensive playbook has stop-loss scenarios
|
||||||
scenarios = pb.stock_playbooks[0].scenarios
|
assert pb.stock_playbooks[0].scenarios[0].action == ScenarioAction.SELL
|
||||||
assert scenarios[0].action == ScenarioAction.BUY
|
|
||||||
assert scenarios[0].condition.rsi_below == 30
|
|
||||||
assert scenarios[1].action == ScenarioAction.SELL
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_gemini_failure_empty_when_defensive_disabled(self) -> None:
|
async def test_gemini_failure_empty_when_defensive_disabled(self) -> None:
|
||||||
@@ -662,171 +657,3 @@ class TestDefensivePlaybook:
|
|||||||
assert pb.stock_count == 0
|
assert pb.stock_count == 0
|
||||||
assert pb.market == "US"
|
assert pb.market == "US"
|
||||||
assert pb.market_outlook == MarketOutlook.NEUTRAL
|
assert pb.market_outlook == MarketOutlook.NEUTRAL
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Smart fallback playbook
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class TestSmartFallbackPlaybook:
|
|
||||||
"""Tests for _smart_fallback_playbook — rule-based BUY/SELL on Gemini failure."""
|
|
||||||
|
|
||||||
def _make_settings(self) -> Settings:
|
|
||||||
return Settings(
|
|
||||||
KIS_APP_KEY="test",
|
|
||||||
KIS_APP_SECRET="test",
|
|
||||||
KIS_ACCOUNT_NO="12345678-01",
|
|
||||||
GEMINI_API_KEY="test",
|
|
||||||
RSI_OVERSOLD_THRESHOLD=30,
|
|
||||||
VOL_MULTIPLIER=2.0,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_momentum_candidate_gets_buy_on_volume(self) -> None:
|
|
||||||
candidates = [
|
|
||||||
_candidate(code="CHOW", signal="momentum", volume_ratio=13.64, rsi=100.0)
|
|
||||||
]
|
|
||||||
settings = self._make_settings()
|
|
||||||
|
|
||||||
pb = PreMarketPlanner._smart_fallback_playbook(
|
|
||||||
date(2026, 2, 17), "US_AMEX", candidates, settings
|
|
||||||
)
|
|
||||||
|
|
||||||
assert pb.stock_count == 1
|
|
||||||
sp = pb.stock_playbooks[0]
|
|
||||||
assert sp.stock_code == "CHOW"
|
|
||||||
# First scenario: BUY with volume_ratio_above
|
|
||||||
buy_sc = sp.scenarios[0]
|
|
||||||
assert buy_sc.action == ScenarioAction.BUY
|
|
||||||
assert buy_sc.condition.volume_ratio_above == 2.0
|
|
||||||
assert buy_sc.condition.rsi_below is None
|
|
||||||
assert buy_sc.confidence == 80
|
|
||||||
# Second scenario: stop-loss SELL
|
|
||||||
sell_sc = sp.scenarios[1]
|
|
||||||
assert sell_sc.action == ScenarioAction.SELL
|
|
||||||
assert sell_sc.condition.price_change_pct_below == -3.0
|
|
||||||
|
|
||||||
def test_oversold_candidate_gets_buy_on_rsi(self) -> None:
|
|
||||||
candidates = [
|
|
||||||
_candidate(code="005930", signal="oversold", rsi=22.0, volume_ratio=3.5)
|
|
||||||
]
|
|
||||||
settings = self._make_settings()
|
|
||||||
|
|
||||||
pb = PreMarketPlanner._smart_fallback_playbook(
|
|
||||||
date(2026, 2, 17), "KR", candidates, settings
|
|
||||||
)
|
|
||||||
|
|
||||||
sp = pb.stock_playbooks[0]
|
|
||||||
buy_sc = sp.scenarios[0]
|
|
||||||
assert buy_sc.action == ScenarioAction.BUY
|
|
||||||
assert buy_sc.condition.rsi_below == 30
|
|
||||||
assert buy_sc.condition.volume_ratio_above is None
|
|
||||||
|
|
||||||
def test_all_candidates_have_stop_loss_sell(self) -> None:
|
|
||||||
candidates = [
|
|
||||||
_candidate(code="AAA", signal="momentum", volume_ratio=5.0),
|
|
||||||
_candidate(code="BBB", signal="oversold", rsi=25.0),
|
|
||||||
]
|
|
||||||
settings = self._make_settings()
|
|
||||||
|
|
||||||
pb = PreMarketPlanner._smart_fallback_playbook(
|
|
||||||
date(2026, 2, 17), "US_NASDAQ", candidates, settings
|
|
||||||
)
|
|
||||||
|
|
||||||
assert pb.stock_count == 2
|
|
||||||
for sp in pb.stock_playbooks:
|
|
||||||
sell_scenarios = [s for s in sp.scenarios if s.action == ScenarioAction.SELL]
|
|
||||||
assert len(sell_scenarios) == 1
|
|
||||||
assert sell_scenarios[0].condition.price_change_pct_below == -3.0
|
|
||||||
assert sell_scenarios[0].condition.price_change_pct_below == -3.0
|
|
||||||
|
|
||||||
def test_market_outlook_is_neutral(self) -> None:
|
|
||||||
candidates = [_candidate(signal="momentum", volume_ratio=5.0)]
|
|
||||||
settings = self._make_settings()
|
|
||||||
|
|
||||||
pb = PreMarketPlanner._smart_fallback_playbook(
|
|
||||||
date(2026, 2, 17), "US_AMEX", candidates, settings
|
|
||||||
)
|
|
||||||
|
|
||||||
assert pb.market_outlook == MarketOutlook.NEUTRAL
|
|
||||||
|
|
||||||
def test_default_action_is_hold(self) -> None:
|
|
||||||
candidates = [_candidate(signal="momentum", volume_ratio=5.0)]
|
|
||||||
settings = self._make_settings()
|
|
||||||
|
|
||||||
pb = PreMarketPlanner._smart_fallback_playbook(
|
|
||||||
date(2026, 2, 17), "US_AMEX", candidates, settings
|
|
||||||
)
|
|
||||||
|
|
||||||
assert pb.default_action == ScenarioAction.HOLD
|
|
||||||
|
|
||||||
def test_has_global_reduce_all_rule(self) -> None:
|
|
||||||
candidates = [_candidate(signal="momentum", volume_ratio=5.0)]
|
|
||||||
settings = self._make_settings()
|
|
||||||
|
|
||||||
pb = PreMarketPlanner._smart_fallback_playbook(
|
|
||||||
date(2026, 2, 17), "US_AMEX", candidates, settings
|
|
||||||
)
|
|
||||||
|
|
||||||
assert len(pb.global_rules) == 1
|
|
||||||
rule = pb.global_rules[0]
|
|
||||||
assert rule.action == ScenarioAction.REDUCE_ALL
|
|
||||||
assert "portfolio_pnl_pct" in rule.condition
|
|
||||||
|
|
||||||
def test_empty_candidates_returns_empty_playbook(self) -> None:
|
|
||||||
settings = self._make_settings()
|
|
||||||
|
|
||||||
pb = PreMarketPlanner._smart_fallback_playbook(
|
|
||||||
date(2026, 2, 17), "US_AMEX", [], settings
|
|
||||||
)
|
|
||||||
|
|
||||||
assert pb.stock_count == 0
|
|
||||||
|
|
||||||
def test_vol_multiplier_applied_from_settings(self) -> None:
|
|
||||||
"""VOL_MULTIPLIER=3.0 should set volume_ratio_above=3.0 for momentum."""
|
|
||||||
candidates = [_candidate(signal="momentum", volume_ratio=5.0)]
|
|
||||||
settings = self._make_settings()
|
|
||||||
settings = settings.model_copy(update={"VOL_MULTIPLIER": 3.0})
|
|
||||||
|
|
||||||
pb = PreMarketPlanner._smart_fallback_playbook(
|
|
||||||
date(2026, 2, 17), "US_AMEX", candidates, settings
|
|
||||||
)
|
|
||||||
|
|
||||||
buy_sc = pb.stock_playbooks[0].scenarios[0]
|
|
||||||
assert buy_sc.condition.volume_ratio_above == 3.0
|
|
||||||
|
|
||||||
def test_rsi_oversold_threshold_applied_from_settings(self) -> None:
|
|
||||||
"""RSI_OVERSOLD_THRESHOLD=25 should set rsi_below=25 for oversold."""
|
|
||||||
candidates = [_candidate(signal="oversold", rsi=22.0)]
|
|
||||||
settings = self._make_settings()
|
|
||||||
settings = settings.model_copy(update={"RSI_OVERSOLD_THRESHOLD": 25})
|
|
||||||
|
|
||||||
pb = PreMarketPlanner._smart_fallback_playbook(
|
|
||||||
date(2026, 2, 17), "KR", candidates, settings
|
|
||||||
)
|
|
||||||
|
|
||||||
buy_sc = pb.stock_playbooks[0].scenarios[0]
|
|
||||||
assert buy_sc.condition.rsi_below == 25
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_generate_playbook_uses_smart_fallback_on_gemini_error(self) -> None:
|
|
||||||
"""generate_playbook() should use smart fallback (not defensive) on API failure."""
|
|
||||||
planner = _make_planner()
|
|
||||||
planner._gemini.decide = AsyncMock(side_effect=ConnectionError("429 quota exceeded"))
|
|
||||||
# momentum candidate
|
|
||||||
candidates = [
|
|
||||||
_candidate(code="CHOW", signal="momentum", volume_ratio=13.64, rsi=100.0)
|
|
||||||
]
|
|
||||||
|
|
||||||
pb = await planner.generate_playbook(
|
|
||||||
"US_AMEX", candidates, today=date(2026, 2, 18)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Should NOT be all-SELL defensive; should have BUY for momentum
|
|
||||||
assert pb.stock_count == 1
|
|
||||||
buy_scenarios = [
|
|
||||||
s for s in pb.stock_playbooks[0].scenarios
|
|
||||||
if s.action == ScenarioAction.BUY
|
|
||||||
]
|
|
||||||
assert len(buy_scenarios) == 1
|
|
||||||
assert buy_scenarios[0].condition.volume_ratio_above == 2.0 # VOL_MULTIPLIER default
|
|
||||||
|
|||||||
Reference in New Issue
Block a user