feat: implement Smart Volatility Scanner with RSI/volume filters (issue #76)
Some checks failed
CI / test (pull_request) Has been cancelled

Add Python-first scanning pipeline that reduces Gemini API calls by filtering
stocks before AI analysis: KIS rankings API -> RSI/volume filter -> AI judgment.

## Implementation
- Add RSI calculation (Wilder's smoothing method) to VolatilityAnalyzer
- Add KIS API methods: fetch_market_rankings() and get_daily_prices()
- Create SmartVolatilityScanner with configurable thresholds
- Integrate scanner into main.py realtime mode
- Add selection_context logging to trades table for Evolution system

## Configuration
- RSI_OVERSOLD_THRESHOLD: 30 (configurable 0-50)
- RSI_MOMENTUM_THRESHOLD: 70 (configurable 50-100)
- VOL_MULTIPLIER: 2.0 (minimum volume ratio, configurable 1-10)
- SCANNER_TOP_N: 3 (max candidates per scan, configurable 1-10)

## Benefits
- Reduces Gemini API calls (process 1-3 qualified stocks vs 20-30 ranked)
- Python-based technical filtering before expensive AI judgment
- Tracks selection criteria (RSI, volume_ratio, signal, score) for strategy optimization
- Graceful fallback to static watchlist if ranking API fails

## Tests
- 13 new tests for SmartVolatilityScanner and RSI calculation
- All existing tests updated and passing
- Coverage maintained at 73%

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
agentson
2026-02-06 00:48:23 +09:00
parent 27f581f17d
commit f0ae25c533
9 changed files with 861 additions and 24 deletions

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
from src.analysis.scanner import MarketScanner
from src.analysis.smart_scanner import ScanCandidate, SmartVolatilityScanner
from src.analysis.volatility import VolatilityAnalyzer
__all__ = ["VolatilityAnalyzer", "MarketScanner"]
__all__ = ["VolatilityAnalyzer", "MarketScanner", "SmartVolatilityScanner", "ScanCandidate"]

View File

@@ -0,0 +1,192 @@
"""Smart Volatility Scanner with RSI and volume filters.
Fetches market rankings from KIS API and applies technical filters
to identify high-probability trading candidates.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Any
from src.analysis.volatility import VolatilityAnalyzer
from src.broker.kis_api import KISBroker
from src.config import Settings
logger = logging.getLogger(__name__)
@dataclass
class ScanCandidate:
"""A qualified candidate from the smart scanner."""
stock_code: str
name: str
price: float
volume: float
volume_ratio: float # Current volume / previous day volume
rsi: float
signal: str # "oversold" or "momentum"
score: float # Composite score for ranking
class SmartVolatilityScanner:
"""Scans market rankings and applies RSI/volume filters.
Flow:
1. Fetch volume rankings from KIS API
2. For each ranked stock, fetch daily prices
3. Calculate RSI and volume ratio
4. Apply filters: volume > VOL_MULTIPLIER AND (RSI < 30 OR RSI > 70)
5. Return top N qualified candidates
"""
def __init__(
self,
broker: KISBroker,
volatility_analyzer: VolatilityAnalyzer,
settings: Settings,
) -> None:
"""Initialize the smart scanner.
Args:
broker: KIS broker for API calls
volatility_analyzer: Analyzer for RSI calculation
settings: Application settings
"""
self.broker = broker
self.analyzer = volatility_analyzer
self.settings = settings
# Extract scanner settings
self.rsi_oversold = settings.RSI_OVERSOLD_THRESHOLD
self.rsi_momentum = settings.RSI_MOMENTUM_THRESHOLD
self.vol_multiplier = settings.VOL_MULTIPLIER
self.top_n = settings.SCANNER_TOP_N
async def scan(
self,
fallback_stocks: list[str] | None = None,
) -> list[ScanCandidate]:
"""Execute smart scan and return qualified candidates.
Args:
fallback_stocks: Stock codes to use if ranking API fails
Returns:
List of ScanCandidate, sorted by score, up to top_n items
"""
# Step 1: Fetch rankings
try:
rankings = await self.broker.fetch_market_rankings(
ranking_type="volume",
limit=30, # Fetch more than needed for filtering
)
logger.info("Fetched %d stocks from volume rankings", len(rankings))
except ConnectionError as exc:
logger.warning("Ranking API failed, using fallback: %s", exc)
if fallback_stocks:
# Create minimal ranking data for fallback
rankings = [
{
"stock_code": code,
"name": code,
"price": 0,
"volume": 0,
"change_rate": 0,
"volume_increase_rate": 0,
}
for code in fallback_stocks
]
else:
return []
# Step 2: Analyze each stock
candidates: list[ScanCandidate] = []
for stock in rankings:
stock_code = stock["stock_code"]
if not stock_code:
continue
try:
# Fetch daily prices for RSI calculation
daily_prices = await self.broker.get_daily_prices(stock_code, days=20)
if len(daily_prices) < 15: # Need at least 14+1 for RSI
logger.debug("Insufficient price history for %s", stock_code)
continue
# Calculate RSI
close_prices = [p["close"] for p in daily_prices]
rsi = self.analyzer.calculate_rsi(close_prices, period=14)
# Calculate volume ratio (today vs previous day avg)
if len(daily_prices) >= 2:
prev_day_volume = daily_prices[-2]["volume"]
current_volume = stock.get("volume", 0) or daily_prices[-1]["volume"]
volume_ratio = (
current_volume / prev_day_volume if prev_day_volume > 0 else 1.0
)
else:
volume_ratio = stock.get("volume_increase_rate", 0) / 100 + 1 # Fallback
# Apply filters
volume_qualified = volume_ratio >= self.vol_multiplier
rsi_oversold = rsi < self.rsi_oversold
rsi_momentum = rsi > self.rsi_momentum
if volume_qualified and (rsi_oversold or rsi_momentum):
signal = "oversold" if rsi_oversold else "momentum"
# Calculate composite score
# Higher score for: extreme RSI + high volume
rsi_extremity = abs(rsi - 50) / 50 # 0-1 scale
volume_score = min(volume_ratio / 5, 1.0) # Cap at 5x
score = (rsi_extremity * 0.6 + volume_score * 0.4) * 100
candidates.append(
ScanCandidate(
stock_code=stock_code,
name=stock.get("name", stock_code),
price=stock.get("price", daily_prices[-1]["close"]),
volume=current_volume,
volume_ratio=volume_ratio,
rsi=rsi,
signal=signal,
score=score,
)
)
logger.info(
"Qualified: %s (%s) RSI=%.1f vol=%.1fx signal=%s score=%.1f",
stock_code,
stock.get("name", ""),
rsi,
volume_ratio,
signal,
score,
)
except ConnectionError as exc:
logger.warning("Failed to analyze %s: %s", stock_code, exc)
continue
except Exception as exc:
logger.error("Unexpected error analyzing %s: %s", stock_code, exc)
continue
# Sort by score and return top N
candidates.sort(key=lambda c: c.score, reverse=True)
return candidates[: self.top_n]
def get_stock_codes(self, candidates: list[ScanCandidate]) -> list[str]:
"""Extract stock codes from candidates for watchlist update.
Args:
candidates: List of scan candidates
Returns:
List of stock codes
"""
return [c.stock_code for c in candidates]

View File

@@ -124,6 +124,54 @@ class VolatilityAnalyzer:
return 1.0
return current_volume / avg_volume
def calculate_rsi(
self,
close_prices: list[float],
period: int = 14,
) -> float:
"""Calculate Relative Strength Index (RSI) using Wilder's smoothing.
Args:
close_prices: List of closing prices (oldest to newest, minimum period+1 values)
period: RSI period (default 14)
Returns:
RSI value between 0 and 100, or 50.0 (neutral) if insufficient data
Examples:
>>> analyzer = VolatilityAnalyzer()
>>> prices = [100 - i * 0.5 for i in range(20)] # Downtrend
>>> rsi = analyzer.calculate_rsi(prices)
>>> assert rsi < 50 # Oversold territory
"""
if len(close_prices) < period + 1:
return 50.0 # Neutral RSI if insufficient data
# Calculate price changes
changes = [close_prices[i] - close_prices[i - 1] for i in range(1, len(close_prices))]
# Separate gains and losses
gains = [max(0.0, change) for change in changes]
losses = [max(0.0, -change) for change in changes]
# Calculate initial average gain/loss (simple average for first period)
avg_gain = sum(gains[:period]) / period
avg_loss = sum(losses[:period]) / period
# Apply Wilder's smoothing for remaining periods
for i in range(period, len(changes)):
avg_gain = (avg_gain * (period - 1) + gains[i]) / period
avg_loss = (avg_loss * (period - 1) + losses[i]) / period
# Calculate RS and RSI
if avg_loss == 0:
return 100.0 # All gains, maximum RSI
rs = avg_gain / avg_loss
rsi = 100 - (100 / (1 + rs))
return rsi
def calculate_pv_divergence(
self,
price_change: float,