Compare commits

...

17 Commits

Author SHA1 Message Date
agentson
4da22b10eb fix: SELL 주문에서 Fat Finger 오탐 수정 — 손절/익절 차단 버그 (#187)
Some checks failed
CI / test (pull_request) Has been cancelled
SELL 주문은 현금을 소비하지 않고 받는 것이므로 Fat Finger 체크 대상이
아님. 포지션 가치가 잔여 현금의 30%를 초과해도 SELL은 정상 실행돼야 함.

- realtime/daily 사이클 두 곳 모두 수정
- SELL: check_circuit_breaker만 호출 (Fat Finger 스킵)
- BUY: 기존대로 validate_order 호출 (Fat Finger + Circuit Breaker)
- 테스트 2개 추가: SELL Fat Finger 스킵, SELL 서킷브레이커 적용 확인

재현 사례 (2026-02-21):
  JELD stop-loss -6.20% → FAT FINGER: 49,548 is 99.1% of cash 50,000
  RXT take-profit +46.13% → FAT FINGER: 88,676 is 177.4% of cash 50,000

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 00:32:11 +09:00
c920b257b6 Merge pull request 'improve: implied_rsi 포화 임계점 개선 12.5%→25% (#181)' (#186) from feature/issue-181-implied-rsi-saturation into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #186
2026-02-20 10:35:10 +09:00
9927bfa13e Merge pull request 'fix: Telegram 409 다중 인스턴스 충돌 시 WARNING + 30초 백오프 (#180)' (#185) from feature/issue-180-telegram-instance-lock into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #185
2026-02-20 09:52:15 +09:00
agentson
aceba86186 fix: Telegram 409 감지 시 백오프 대신 polling 즉시 종료 (#180)
Some checks failed
CI / test (pull_request) Has been cancelled
409 충돌 감지 시 30초 백오프 후 재시도하는 방식에서
_running = False로 polling을 즉시 중단하는 방식으로 변경.

다중 인스턴스가 실행 중인 경우 재시도는 의미 없고 충돌만 반복됨.
이제 409 발생 시 이 프로세스의 Telegram 명령어 polling을 완전히 비활성화.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 09:35:33 +09:00
agentson
b961c53a92 improve: implied_rsi 계수 4.0→2.0으로 완화 — 포화 임계점 12.5%→25% (#181)
Some checks failed
CI / test (pull_request) Has been cancelled
SmartScanner의 implied_rsi 공식에서 계수를 4.0에서 2.0으로 수정.
12.5% 이상 변동률에서 RSI=100으로 포화되던 문제를 개선.

변경 전: 50 + (change_rate * 4.0) → 12.5% 변동 시 RSI=100
변경 후: 50 + (change_rate * 2.0) → 25% 변동 시 RSI=100

이제 10% 상승 → RSI=70, 12.5% 상승 → RSI=75 (의미 있는 구분 가능)
해외 소형주(NYSE American 등)의 RSI=100 집단 현상 완화.

- smart_scanner.py 3곳 동일 공식 모두 수정
- TestImpliedRSIFormula 클래스 5개 테스트 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 09:33:35 +09:00
76a7ee7cdb Merge pull request 'fix: 잔액 부족 주문 실패 후 10분간 BUY 재시도 방지 (#179)' (#183) from feature/issue-179-insufficient-balance-cooldown into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #183
2026-02-20 09:31:08 +09:00
agentson
77577f3f4d fix: Telegram 409 충돌 시 WARNING 로그 + 30초 백오프 적용 (#180)
Some checks failed
CI / test (pull_request) Has been cancelled
다중 인스턴스 실행 시 Telegram getUpdates 409 응답을 ERROR가 아닌 WARNING으로
처리하고, 30초 동안 polling을 일시 중단하여 충돌을 완화.

- _conflict_backoff_until 속성 추가
- 409 감지 시 명확한 "another instance is polling" 메시지 출력
- poll_loop에서 백오프 활성 중 polling 스킵
- TestGetUpdates에 409 관련 테스트 2개 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 09:31:04 +09:00
17112b864a Merge pull request 'fix: uvicorn 미설치 시 dashboard 오해 없는 실패 처리 (#178)' (#184) from feature/issue-178-dashboard-log-order into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #184
2026-02-20 09:30:48 +09:00
agentson
28bcc7acd7 fix: uvicorn 미설치 시 dashboard 실패를 동기적으로 감지하여 오해 없는 로그 출력 (#178)
Some checks failed
CI / test (pull_request) Has been cancelled
스레드 시작 전에 uvicorn import를 검증하도록 _start_dashboard_server 수정.
uvicorn 미설치 시 "started" 로그 없이 즉시 WARNING 출력 후 None 반환.

- 사전 import 검증으로 "started" → "failed" 오해 소지 있는 로그 쌍 제거
- uvicorn 미설치 시 명확한 경고 메시지 출력
- test_start_dashboard_server_returns_none_when_uvicorn_missing 테스트 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 09:28:23 +09:00
agentson
39b9f179f4 fix: 잔액 부족 주문 실패 후 10분간 BUY 재시도 방지 (issue #179)
Some checks failed
CI / test (pull_request) Has been cancelled
잔액 부족(주문가능금액 부족) 에러 발생 시 해당 종목을 10분간 BUY 시도에서
제외하는 cooldown 메커니즘을 realtime/daily 루프 모두에 적용.

- _BUY_COOLDOWN_SECONDS = 600 상수 추가
- trading_cycle()에 buy_cooldown 파라미터 추가
- 잔액 부족 에러(주문가능금액) 감지 후 cooldown 설정
- BUY 실행 전 cooldown 체크 (realtime + daily session 모두)
- TestBuyCooldown 테스트 클래스 4개 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 09:26:09 +09:00
bd2b3241b2 Merge pull request 'feat: use market_outlook to adjust BUY confidence threshold (#173)' (#177) from feature/issue-173-market-outlook-threshold into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #177
2026-02-20 08:38:52 +09:00
561faaaafa Merge pull request 'feat: use playbook allocation_pct in position sizing (#172)' (#176) from feature/issue-172-playbook-allocation-sizing into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #176
2026-02-20 08:37:59 +09:00
a33d6a145f Merge pull request 'feat: add position-aware conditions to StockCondition (#171)' (#175) from feature/issue-171-position-aware-conditions into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #175
2026-02-20 08:36:07 +09:00
7e6c912214 Merge pull request 'feat: include current holdings in pre-market AI prompt (#170)' (#174) from feature/issue-170-holdings-in-prompt into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #174
2026-02-20 08:35:16 +09:00
agentson
c7640a30d7 feat: use playbook allocation_pct in position sizing (#172)
Some checks failed
CI / test (pull_request) Has been cancelled
- Add playbook_allocation_pct and scenario_confidence parameters to
  _determine_order_quantity() with playbook-based sizing taking priority
  over volatility-score fallback when provided
- Confidence scaling: confidence/80 multiplier (confidence 96 → 1.2x)
  clipped to [POSITION_MIN_ALLOCATION_PCT, POSITION_MAX_ALLOCATION_PCT]
- Pass matched_scenario.allocation_pct and match.confidence from
  trading_cycle so AI's allocation decisions reach order execution
- Add 4 new tests: playbook priority, confidence scaling, max clamp,
  and fallback behavior

Closes #172

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 08:29:09 +09:00
agentson
60a22d6cd4 feat: add position-aware conditions to StockCondition (#171)
Some checks failed
CI / test (pull_request) Has been cancelled
- Add unrealized_pnl_pct_above/below and holding_days_above/below fields
  to StockCondition so AI can generate rules like 'P&L > 3% → SELL'
- Evaluate new fields in ScenarioEngine.evaluate_condition() with same
  AND-combining logic as existing technical indicator fields
- Include position fields in _build_match_details() for audit logging
- Parse new fields from AI JSON response in PreMarketPlanner._parse_scenario()
- Update prompt schema example to show new position-aware condition fields
- Add 13 tests covering all new condition combinations and edge cases

Closes #171

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 08:27:44 +09:00
agentson
b1f48d859e feat: include current holdings in pre-market AI prompt (#170)
Some checks failed
CI / test (pull_request) Has been cancelled
- Add current_holdings parameter to generate_playbook() and _build_prompt()
- Inject '## Current Holdings' section into Gemini prompt with qty, entry
  price, unrealized PnL%, and holding days for each held position
- Instruct AI to generate SELL/HOLD scenarios for held stocks even if not
  in scanner candidates list
- Allow held stock codes in _parse_response() valid_codes set so AI-
  generated SELL scenarios for holdings pass validation
- Add 6 tests covering prompt inclusion, omission, and response parsing

Closes #170

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 08:25:38 +09:00
11 changed files with 1084 additions and 25 deletions

View File

@@ -175,7 +175,7 @@ class SmartVolatilityScanner:
liquidity_score = volume_rank_bonus.get(stock_code, 0.0) liquidity_score = volume_rank_bonus.get(stock_code, 0.0)
score = min(100.0, volatility_score + liquidity_score) score = min(100.0, volatility_score + liquidity_score)
signal = "momentum" if change_rate >= 0 else "oversold" signal = "momentum" if change_rate >= 0 else "oversold"
implied_rsi = max(0.0, min(100.0, 50.0 + (change_rate * 4.0))) implied_rsi = max(0.0, min(100.0, 50.0 + (change_rate * 2.0)))
candidates.append( candidates.append(
ScanCandidate( ScanCandidate(
@@ -282,7 +282,7 @@ class SmartVolatilityScanner:
liquidity_score = volume_rank_bonus.get(stock_code, 0.0) liquidity_score = volume_rank_bonus.get(stock_code, 0.0)
score = min(100.0, volatility_score + liquidity_score) score = min(100.0, volatility_score + liquidity_score)
signal = "momentum" if change_rate >= 0 else "oversold" signal = "momentum" if change_rate >= 0 else "oversold"
implied_rsi = max(0.0, min(100.0, 50.0 + (change_rate * 4.0))) implied_rsi = max(0.0, min(100.0, 50.0 + (change_rate * 2.0)))
candidates.append( candidates.append(
ScanCandidate( ScanCandidate(
stock_code=stock_code, stock_code=stock_code,
@@ -338,7 +338,7 @@ class SmartVolatilityScanner:
score = min(volatility_pct / 10.0, 1.0) * 100.0 score = min(volatility_pct / 10.0, 1.0) * 100.0
signal = "momentum" if change_rate >= 0 else "oversold" signal = "momentum" if change_rate >= 0 else "oversold"
implied_rsi = max(0.0, min(100.0, 50.0 + (change_rate * 4.0))) implied_rsi = max(0.0, min(100.0, 50.0 + (change_rate * 2.0)))
candidates.append( candidates.append(
ScanCandidate( ScanCandidate(
stock_code=stock_code, stock_code=stock_code,

View File

@@ -81,6 +81,7 @@ def safe_float(value: str | float | None, default: float = 0.0) -> float:
TRADE_INTERVAL_SECONDS = 60 TRADE_INTERVAL_SECONDS = 60
SCAN_INTERVAL_SECONDS = 60 # Scan markets every 60 seconds SCAN_INTERVAL_SECONDS = 60 # Scan markets every 60 seconds
MAX_CONNECTION_RETRIES = 3 MAX_CONNECTION_RETRIES = 3
_BUY_COOLDOWN_SECONDS = 600 # 10-minute cooldown after insufficient-balance rejection
# Daily trading mode constants (for Free tier API efficiency) # Daily trading mode constants (for Free tier API efficiency)
DAILY_TRADE_SESSIONS = 4 # Number of trading sessions per day DAILY_TRADE_SESSIONS = 4 # Number of trading sessions per day
@@ -190,8 +191,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 +208,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))
@@ -275,6 +299,7 @@ async def trading_cycle(
stock_code: str, stock_code: str,
scan_candidates: dict[str, dict[str, ScanCandidate]], scan_candidates: dict[str, dict[str, ScanCandidate]],
settings: Settings | None = None, settings: Settings | None = None,
buy_cooldown: dict[str, float] | None = None,
) -> None: ) -> None:
"""Execute one trading cycle for a single stock.""" """Execute one trading cycle for a single stock."""
cycle_start_time = asyncio.get_event_loop().time() cycle_start_time = asyncio.get_event_loop().time()
@@ -596,6 +621,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,
@@ -603,6 +629,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(
@@ -616,13 +644,33 @@ async def trading_cycle(
return return
order_amount = current_price * quantity order_amount = current_price * quantity
# 4. Risk check BEFORE order # 4. Check BUY cooldown (set when a prior BUY failed due to insufficient balance)
if decision.action == "BUY" and buy_cooldown is not None:
cooldown_key = f"{market.code}:{stock_code}"
cooldown_until = buy_cooldown.get(cooldown_key, 0.0)
now = asyncio.get_event_loop().time()
if now < cooldown_until:
remaining = int(cooldown_until - now)
logger.info(
"Skip BUY %s (%s): insufficient-balance cooldown active (%ds remaining)",
stock_code,
market.name,
remaining,
)
return
# 5a. Risk check BEFORE order
# SELL orders do not consume cash (they receive it), so fat-finger check
# is skipped for SELLs — only circuit breaker applies.
try: try:
risk.validate_order( if decision.action == "SELL":
current_pnl_pct=pnl_pct, risk.check_circuit_breaker(pnl_pct)
order_amount=order_amount, else:
total_cash=total_cash, risk.validate_order(
) current_pnl_pct=pnl_pct,
order_amount=order_amount,
total_cash=total_cash,
)
except FatFingerRejected as exc: except FatFingerRejected as exc:
try: try:
await telegram.notify_fat_finger( await telegram.notify_fat_finger(
@@ -664,12 +712,24 @@ async def trading_cycle(
# Check if KIS rejected the order (rt_cd != "0") # Check if KIS rejected the order (rt_cd != "0")
if result.get("rt_cd", "") != "0": if result.get("rt_cd", "") != "0":
order_succeeded = False order_succeeded = False
msg1 = result.get("msg1") or ""
logger.warning( logger.warning(
"Overseas order not accepted for %s: rt_cd=%s msg=%s", "Overseas order not accepted for %s: rt_cd=%s msg=%s",
stock_code, stock_code,
result.get("rt_cd"), result.get("rt_cd"),
result.get("msg1"), msg1,
) )
# Set BUY cooldown when the rejection is due to insufficient balance
if decision.action == "BUY" and buy_cooldown is not None and "주문가능금액" in msg1:
cooldown_key = f"{market.code}:{stock_code}"
buy_cooldown[cooldown_key] = (
asyncio.get_event_loop().time() + _BUY_COOLDOWN_SECONDS
)
logger.info(
"BUY cooldown set for %s: %.0fs (insufficient balance)",
stock_code,
_BUY_COOLDOWN_SECONDS,
)
logger.info("Order result: %s", result.get("msg1", "OK")) logger.info("Order result: %s", result.get("msg1", "OK"))
# 5.5. Notify trade execution (only on success) # 5.5. Notify trade execution (only on success)
@@ -777,6 +837,9 @@ async def run_daily_session(
logger.info("Starting daily trading session for %d markets", len(open_markets)) logger.info("Starting daily trading session for %d markets", len(open_markets))
# BUY cooldown: prevents retrying stocks rejected for insufficient balance
daily_buy_cooldown: dict[str, float] = {} # "{market_code}:{stock_code}" -> expiry timestamp
# Process each open market # Process each open market
for market in open_markets: for market in open_markets:
# Use market-local date for playbook keying # Use market-local date for playbook keying
@@ -1049,13 +1112,33 @@ async def run_daily_session(
continue continue
order_amount = stock_data["current_price"] * quantity order_amount = stock_data["current_price"] * quantity
# Check BUY cooldown (insufficient balance)
if decision.action == "BUY":
daily_cooldown_key = f"{market.code}:{stock_code}"
daily_cooldown_until = daily_buy_cooldown.get(daily_cooldown_key, 0.0)
now = asyncio.get_event_loop().time()
if now < daily_cooldown_until:
remaining = int(daily_cooldown_until - now)
logger.info(
"Skip BUY %s (%s): insufficient-balance cooldown active (%ds remaining)",
stock_code,
market.name,
remaining,
)
continue
# Risk check # Risk check
# SELL orders do not consume cash (they receive it), so fat-finger
# check is skipped for SELLs — only circuit breaker applies.
try: try:
risk.validate_order( if decision.action == "SELL":
current_pnl_pct=pnl_pct, risk.check_circuit_breaker(pnl_pct)
order_amount=order_amount, else:
total_cash=total_cash, risk.validate_order(
) current_pnl_pct=pnl_pct,
order_amount=order_amount,
total_cash=total_cash,
)
except FatFingerRejected as exc: except FatFingerRejected as exc:
try: try:
await telegram.notify_fat_finger( await telegram.notify_fat_finger(
@@ -1105,12 +1188,23 @@ async def run_daily_session(
) )
if result.get("rt_cd", "") != "0": if result.get("rt_cd", "") != "0":
order_succeeded = False order_succeeded = False
daily_msg1 = result.get("msg1") or ""
logger.warning( logger.warning(
"Overseas order not accepted for %s: rt_cd=%s msg=%s", "Overseas order not accepted for %s: rt_cd=%s msg=%s",
stock_code, stock_code,
result.get("rt_cd"), result.get("rt_cd"),
result.get("msg1"), daily_msg1,
) )
if decision.action == "BUY" and "주문가능금액" in daily_msg1:
daily_cooldown_key = f"{market.code}:{stock_code}"
daily_buy_cooldown[daily_cooldown_key] = (
asyncio.get_event_loop().time() + _BUY_COOLDOWN_SECONDS
)
logger.info(
"BUY cooldown set for %s: %.0fs (insufficient balance)",
stock_code,
_BUY_COOLDOWN_SECONDS,
)
logger.info("Order result: %s", result.get("msg1", "OK")) logger.info("Order result: %s", result.get("msg1", "OK"))
# Notify trade execution (only on success) # Notify trade execution (only on success)
@@ -1274,10 +1368,18 @@ 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)
@@ -1288,7 +1390,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 failed to start: %s", exc) logger.warning("Dashboard server stopped unexpectedly: %s", exc)
thread = threading.Thread( thread = threading.Thread(
target=_serve, target=_serve,
@@ -1729,6 +1831,9 @@ async def run(settings: Settings) -> None:
# Active stocks per market (dynamically discovered by scanner) # Active stocks per market (dynamically discovered by scanner)
active_stocks: dict[str, list[str]] = {} # market_code -> [stock_codes] active_stocks: dict[str, list[str]] = {} # market_code -> [stock_codes]
# BUY cooldown: prevents retrying a stock rejected for insufficient balance
buy_cooldown: dict[str, float] = {} # "{market_code}:{stock_code}" -> expiry timestamp
# Initialize latency control system # Initialize latency control system
criticality_assessor = CriticalityAssessor( criticality_assessor = CriticalityAssessor(
critical_pnl_threshold=-2.5, # Near circuit breaker at -3.0% critical_pnl_threshold=-2.5, # Near circuit breaker at -3.0%
@@ -2071,6 +2176,7 @@ async def run(settings: Settings) -> None:
stock_code, stock_code,
scan_candidates, scan_candidates,
settings, settings,
buy_cooldown,
) )
break # Success — exit retry loop break # Success — exit retry loop
except CircuitBreakerTripped as exc: except CircuitBreakerTripped as exc:

View File

@@ -604,9 +604,19 @@ class TelegramCommandHandler:
async with session.post(url, json=payload) as resp: async with session.post(url, json=payload) as resp:
if resp.status != 200: if resp.status != 200:
error_text = await resp.text() error_text = await resp.text()
logger.error( if resp.status == 409:
"getUpdates API error (status=%d): %s", resp.status, error_text # Another bot instance is already polling — stop this poller entirely.
) # Retrying would keep conflicting with the other instance.
self._running = False
logger.warning(
"Telegram conflict (409): another instance is already polling. "
"Disabling Telegram commands for this process. "
"Ensure only one instance of The Ouroboros is running at a time.",
)
else:
logger.error(
"getUpdates API error (status=%d): %s", resp.status, error_text
)
return [] return []
data = await resp.json() data = await resp.json()

View File

@@ -46,6 +46,18 @@ 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
@@ -56,6 +68,10 @@ 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."""
@@ -70,6 +86,10 @@ class StockCondition(BaseModel):
self.price_below, self.price_below,
self.price_change_pct_above, self.price_change_pct_above,
self.price_change_pct_below, self.price_change_pct_below,
self.unrealized_pnl_pct_above,
self.unrealized_pnl_pct_below,
self.holding_days_above,
self.holding_days_below,
) )
) )

View File

@@ -75,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"
@@ -294,7 +332,8 @@ 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}},\n' f' "condition": {{"rsi_below": 30, "volume_ratio_above": 2.0,'
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'
@@ -308,7 +347,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 +361,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")
@@ -390,6 +437,10 @@ class PreMarketPlanner:
price_below=cond_data.get("price_below"), price_below=cond_data.get("price_below"),
price_change_pct_above=cond_data.get("price_change_pct_above"), price_change_pct_above=cond_data.get("price_change_pct_above"),
price_change_pct_below=cond_data.get("price_change_pct_below"), price_change_pct_below=cond_data.get("price_change_pct_below"),
unrealized_pnl_pct_above=cond_data.get("unrealized_pnl_pct_above"),
unrealized_pnl_pct_below=cond_data.get("unrealized_pnl_pct_below"),
holding_days_above=cond_data.get("holding_days_above"),
holding_days_below=cond_data.get("holding_days_below"),
) )
if not condition.has_any_condition(): if not condition.has_any_condition():

View File

@@ -206,6 +206,37 @@ 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(
@@ -266,5 +297,9 @@ class ScenarioEngine:
details["current_price"] = self._safe_float(market_data.get("current_price")) details["current_price"] = self._safe_float(market_data.get("current_price"))
if condition.price_change_pct_above is not None or condition.price_change_pct_below is not None: if condition.price_change_pct_above is not None or condition.price_change_pct_below is not None:
details["price_change_pct"] = self._safe_float(market_data.get("price_change_pct")) details["price_change_pct"] = self._safe_float(market_data.get("price_change_pct"))
if condition.unrealized_pnl_pct_above is not None or condition.unrealized_pnl_pct_below is not None:
details["unrealized_pnl_pct"] = self._safe_float(market_data.get("unrealized_pnl_pct"))
if condition.holding_days_above is not None or condition.holding_days_below is not None:
details["holding_days"] = self._safe_float(market_data.get("holding_days"))
return details return details

View File

@@ -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."""
@@ -553,6 +631,119 @@ class TestTradingCycleTelegramIntegration:
# Verify no trade notification sent # Verify no trade notification sent
mock_telegram.notify_trade_execution.assert_not_called() mock_telegram.notify_trade_execution.assert_not_called()
@pytest.mark.asyncio
async def test_sell_skips_fat_finger_check(
self,
mock_broker: MagicMock,
mock_overseas_broker: MagicMock,
mock_scenario_engine: 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_market: MagicMock,
) -> None:
"""SELL orders must not be blocked by fat-finger check.
Even if position value > 30% of cash (e.g. stop-loss on a large holding
with low remaining cash), the SELL should proceed — only circuit breaker
applies to SELLs.
"""
# SELL decision with held qty=100 shares @ 50,000 = 5,000,000
# cash = 5,000,000 → ratio = 100% which would normally trigger fat finger
mock_scenario_engine.evaluate = MagicMock(return_value=_make_sell_match())
mock_broker.get_balance = AsyncMock(
return_value={
"output1": [{"pdno": "005930", "ord_psbl_qty": "100"}],
"output2": [
{
"tot_evlu_amt": "10000000",
"dnca_tot_amt": "5000000",
"pchs_amt_smtl_amt": "5000000",
}
],
}
)
with patch("src.main.log_trade"):
await trading_cycle(
broker=mock_broker,
overseas_broker=mock_overseas_broker,
scenario_engine=mock_scenario_engine,
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_market,
stock_code="005930",
scan_candidates={},
)
# validate_order (which includes fat finger) must NOT be called for SELL
mock_risk.validate_order.assert_not_called()
# check_circuit_breaker MUST be called for SELL
mock_risk.check_circuit_breaker.assert_called_once()
@pytest.mark.asyncio
async def test_sell_circuit_breaker_still_applies(
self,
mock_broker: MagicMock,
mock_overseas_broker: MagicMock,
mock_scenario_engine: 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_market: MagicMock,
) -> None:
"""SELL orders must still respect the circuit breaker."""
mock_scenario_engine.evaluate = MagicMock(return_value=_make_sell_match())
mock_broker.get_balance = AsyncMock(
return_value={
"output1": [{"pdno": "005930", "ord_psbl_qty": "100"}],
"output2": [
{
"tot_evlu_amt": "10000000",
"dnca_tot_amt": "5000000",
"pchs_amt_smtl_amt": "5000000",
}
],
}
)
mock_risk.check_circuit_breaker.side_effect = CircuitBreakerTripped(
pnl_pct=-4.0, threshold=-3.0
)
with patch("src.main.log_trade"):
with pytest.raises(CircuitBreakerTripped):
await trading_cycle(
broker=mock_broker,
overseas_broker=mock_overseas_broker,
scenario_engine=mock_scenario_engine,
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_market,
stock_code="005930",
scan_candidates={},
)
mock_risk.check_circuit_breaker.assert_called_once()
mock_risk.validate_order.assert_not_called()
class TestRunFunctionTelegramIntegration: class TestRunFunctionTelegramIntegration:
"""Test telegram notifications in run function.""" """Test telegram notifications in run function."""
@@ -2116,6 +2307,268 @@ def test_start_dashboard_server_enabled_starts_thread() -> None:
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
# ---------------------------------------------------------------------------
# BUY cooldown tests (#179)
# ---------------------------------------------------------------------------
class TestBuyCooldown:
"""Tests for BUY cooldown after insufficient-balance rejection."""
@pytest.fixture
def mock_broker(self) -> MagicMock:
broker = MagicMock()
broker.get_current_price = AsyncMock(return_value=(100.0, 1.0, 0.0))
broker.get_balance = AsyncMock(
return_value={
"output2": [{"tot_evlu_amt": "1000000", "dnca_tot_amt": "500000",
"pchs_amt_smtl_amt": "500000"}]
}
)
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_overseas_market(self) -> MagicMock:
market = MagicMock()
market.name = "NASDAQ"
market.code = "US_NASDAQ"
market.exchange_code = "NAS"
market.is_domestic = False
return market
@pytest.fixture
def mock_overseas_broker(self) -> MagicMock:
broker = MagicMock()
broker.get_overseas_price = AsyncMock(
return_value={"output": {"last": "1.0", "rate": "0.0",
"high": "1.05", "low": "0.95", "tvol": "1000000"}}
)
broker.get_overseas_balance = AsyncMock(return_value={
"output1": [],
"output2": [{"frcr_dncl_amt_2": "50000", "frcr_evlu_tota": "50000",
"frcr_buy_amt_smtl": "0"}],
})
broker.send_overseas_order = AsyncMock(
return_value={"rt_cd": "1", "msg1": "모의투자 주문가능금액이 부족합니다."}
)
return broker
def _make_buy_match_overseas(self, stock_code: str = "MLECW") -> ScenarioMatch:
return ScenarioMatch(
stock_code=stock_code,
matched_scenario=None,
action=ScenarioAction.BUY,
confidence=85,
rationale="Test buy",
)
@pytest.mark.asyncio
async def test_cooldown_set_on_insufficient_balance(
self, mock_broker: MagicMock, mock_overseas_broker: MagicMock,
mock_overseas_market: MagicMock,
) -> None:
"""BUY cooldown entry is created after 주문가능금액 rejection."""
engine = MagicMock(spec=ScenarioEngine)
engine.evaluate = MagicMock(return_value=self._make_buy_match_overseas("MLECW"))
buy_cooldown: dict[str, float] = {}
with patch("src.main.log_trade"):
await trading_cycle(
broker=mock_broker,
overseas_broker=mock_overseas_broker,
scenario_engine=engine,
playbook=_make_playbook("US_NASDAQ"),
risk=MagicMock(),
db_conn=MagicMock(),
decision_logger=MagicMock(),
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=MagicMock(
notify_trade_execution=AsyncMock(),
notify_fat_finger=AsyncMock(),
notify_circuit_breaker=AsyncMock(),
notify_scenario_matched=AsyncMock(),
),
market=mock_overseas_market,
stock_code="MLECW",
scan_candidates={},
buy_cooldown=buy_cooldown,
)
assert "US_NASDAQ:MLECW" in buy_cooldown
assert buy_cooldown["US_NASDAQ:MLECW"] > 0
@pytest.mark.asyncio
async def test_cooldown_skips_buy(
self, mock_broker: MagicMock, mock_overseas_broker: MagicMock,
mock_overseas_market: MagicMock,
) -> None:
"""BUY is skipped when cooldown is active for the stock."""
engine = MagicMock(spec=ScenarioEngine)
engine.evaluate = MagicMock(return_value=self._make_buy_match_overseas("MLECW"))
import asyncio
# Set an active cooldown (expires far in the future)
buy_cooldown: dict[str, float] = {
"US_NASDAQ:MLECW": asyncio.get_event_loop().time() + 600
}
with patch("src.main.log_trade"):
await trading_cycle(
broker=mock_broker,
overseas_broker=mock_overseas_broker,
scenario_engine=engine,
playbook=_make_playbook("US_NASDAQ"),
risk=MagicMock(),
db_conn=MagicMock(),
decision_logger=MagicMock(),
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=MagicMock(
notify_trade_execution=AsyncMock(),
notify_fat_finger=AsyncMock(),
notify_circuit_breaker=AsyncMock(),
notify_scenario_matched=AsyncMock(),
),
market=mock_overseas_market,
stock_code="MLECW",
scan_candidates={},
buy_cooldown=buy_cooldown,
)
# Order should NOT have been sent
mock_overseas_broker.send_overseas_order.assert_not_called()
@pytest.mark.asyncio
async def test_cooldown_not_set_on_other_errors(
self, mock_broker: MagicMock, mock_overseas_market: MagicMock,
) -> None:
"""Cooldown is NOT set for non-balance-related rejections."""
engine = MagicMock(spec=ScenarioEngine)
engine.evaluate = MagicMock(return_value=self._make_buy_match_overseas("MLECW"))
# Different rejection reason
overseas_broker = MagicMock()
overseas_broker.get_overseas_price = AsyncMock(
return_value={"output": {"last": "1.0", "rate": "0.0",
"high": "1.05", "low": "0.95", "tvol": "1000000"}}
)
overseas_broker.get_overseas_balance = AsyncMock(return_value={
"output1": [],
"output2": [{"frcr_dncl_amt_2": "50000", "frcr_evlu_tota": "50000",
"frcr_buy_amt_smtl": "0"}],
})
overseas_broker.send_overseas_order = AsyncMock(
return_value={"rt_cd": "1", "msg1": "기타 오류 메시지"}
)
buy_cooldown: dict[str, float] = {}
with patch("src.main.log_trade"):
await trading_cycle(
broker=mock_broker,
overseas_broker=overseas_broker,
scenario_engine=engine,
playbook=_make_playbook("US_NASDAQ"),
risk=MagicMock(),
db_conn=MagicMock(),
decision_logger=MagicMock(),
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=MagicMock(
notify_trade_execution=AsyncMock(),
notify_fat_finger=AsyncMock(),
notify_circuit_breaker=AsyncMock(),
notify_scenario_matched=AsyncMock(),
),
market=mock_overseas_market,
stock_code="MLECW",
scan_candidates={},
buy_cooldown=buy_cooldown,
)
# Cooldown should NOT be set for non-balance errors
assert "US_NASDAQ:MLECW" not in buy_cooldown
@pytest.mark.asyncio
async def test_no_cooldown_param_still_works(
self, mock_broker: MagicMock, mock_overseas_broker: MagicMock,
mock_overseas_market: MagicMock,
) -> None:
"""trading_cycle works normally when buy_cooldown is None (default)."""
engine = MagicMock(spec=ScenarioEngine)
engine.evaluate = MagicMock(return_value=self._make_buy_match_overseas("MLECW"))
with patch("src.main.log_trade"):
await trading_cycle(
broker=mock_broker,
overseas_broker=mock_overseas_broker,
scenario_engine=engine,
playbook=_make_playbook("US_NASDAQ"),
risk=MagicMock(),
db_conn=MagicMock(),
decision_logger=MagicMock(),
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=MagicMock(
notify_trade_execution=AsyncMock(),
notify_fat_finger=AsyncMock(),
notify_circuit_breaker=AsyncMock(),
notify_scenario_matched=AsyncMock(),
),
market=mock_overseas_market,
stock_code="MLECW",
scan_candidates={},
# buy_cooldown not passed → defaults to None
)
# Should attempt the order (and fail), but not crash
mock_overseas_broker.send_overseas_order.assert_called_once()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# market_outlook BUY confidence threshold tests (#173) # market_outlook BUY confidence threshold tests (#173)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -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

View File

@@ -440,3 +440,135 @@ 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

View File

@@ -350,6 +350,42 @@ class TestSmartVolatilityScanner:
assert [c.stock_code for c in candidates] == ["ABCD"] assert [c.stock_code for c in candidates] == ["ABCD"]
class TestImpliedRSIFormula:
"""Test the implied_rsi formula in SmartVolatilityScanner (issue #181)."""
def test_neutral_change_gives_neutral_rsi(self) -> None:
"""0% change → implied_rsi = 50 (neutral)."""
# formula: 50 + (change_rate * 2.0)
rsi = max(0.0, min(100.0, 50.0 + (0.0 * 2.0)))
assert rsi == 50.0
def test_10pct_change_gives_rsi_70(self) -> None:
"""10% upward change → implied_rsi = 70 (momentum signal)."""
rsi = max(0.0, min(100.0, 50.0 + (10.0 * 2.0)))
assert rsi == 70.0
def test_minus_10pct_gives_rsi_30(self) -> None:
"""-10% change → implied_rsi = 30 (oversold signal)."""
rsi = max(0.0, min(100.0, 50.0 + (-10.0 * 2.0)))
assert rsi == 30.0
def test_saturation_at_25pct(self) -> None:
"""Saturation occurs at >=25% change (not 12.5% as with old coefficient 4.0)."""
rsi_12pct = max(0.0, min(100.0, 50.0 + (12.5 * 2.0)))
rsi_25pct = max(0.0, min(100.0, 50.0 + (25.0 * 2.0)))
rsi_30pct = max(0.0, min(100.0, 50.0 + (30.0 * 2.0)))
# At 12.5% change: RSI = 75 (not 100, unlike old formula)
assert rsi_12pct == 75.0
# At 25%+ saturation
assert rsi_25pct == 100.0
assert rsi_30pct == 100.0 # Capped
def test_negative_saturation(self) -> None:
"""Saturation at -25% gives RSI = 0."""
rsi = max(0.0, min(100.0, 50.0 + (-25.0 * 2.0)))
assert rsi == 0.0
class TestRSICalculation: class TestRSICalculation:
"""Test RSI calculation in VolatilityAnalyzer.""" """Test RSI calculation in VolatilityAnalyzer."""

View File

@@ -876,6 +876,54 @@ class TestGetUpdates:
assert updates == [] assert updates == []
@pytest.mark.asyncio
async def test_get_updates_409_stops_polling(self) -> None:
"""409 Conflict response stops the poller (_running = False) and returns empty list."""
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
handler = TelegramCommandHandler(client)
handler._running = True # simulate active poller
mock_resp = AsyncMock()
mock_resp.status = 409
mock_resp.text = AsyncMock(
return_value='{"ok":false,"error_code":409,"description":"Conflict"}'
)
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock(return_value=False)
with patch("aiohttp.ClientSession.post", return_value=mock_resp):
updates = await handler._get_updates()
assert updates == []
assert handler._running is False # poller stopped
@pytest.mark.asyncio
async def test_poll_loop_exits_after_409(self) -> None:
"""_poll_loop exits naturally after _running is set to False by a 409 response."""
import asyncio as _asyncio
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
handler = TelegramCommandHandler(client)
call_count = 0
async def mock_get_updates_409() -> list[dict]:
nonlocal call_count
call_count += 1
# Simulate 409 stopping the poller
handler._running = False
return []
handler._get_updates = mock_get_updates_409 # type: ignore[method-assign]
handler._running = True
task = _asyncio.create_task(handler._poll_loop())
await _asyncio.wait_for(task, timeout=2.0)
# _get_updates called exactly once, then loop exited
assert call_count == 1
assert handler._running is False
class TestCommandWithArgs: class TestCommandWithArgs:
"""Test register_command_with_args and argument dispatch.""" """Test register_command_with_args and argument dispatch."""