feat: prioritize overseas volatility scoring over raw rankings
This commit is contained in:
@@ -220,7 +220,7 @@ class SmartVolatilityScanner:
|
|||||||
self,
|
self,
|
||||||
market: MarketInfo,
|
market: MarketInfo,
|
||||||
) -> list[ScanCandidate]:
|
) -> list[ScanCandidate]:
|
||||||
"""Build overseas candidates from ranking APIs."""
|
"""Build overseas candidates from ranking APIs using volatility-first scoring."""
|
||||||
assert self.overseas_broker is not None
|
assert self.overseas_broker is not None
|
||||||
try:
|
try:
|
||||||
fluct_rows = await self.overseas_broker.fetch_overseas_rankings(
|
fluct_rows = await self.overseas_broker.fetch_overseas_rankings(
|
||||||
@@ -237,39 +237,45 @@ class SmartVolatilityScanner:
|
|||||||
if not fluct_rows:
|
if not fluct_rows:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
volume_rank_bonus: dict[str, float] = {}
|
||||||
|
try:
|
||||||
|
volume_rows = await self.overseas_broker.fetch_overseas_rankings(
|
||||||
|
exchange_code=market.exchange_code,
|
||||||
|
ranking_type="volume",
|
||||||
|
limit=50,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"Overseas volume ranking failed for %s: %s", market.code, exc
|
||||||
|
)
|
||||||
|
volume_rows = []
|
||||||
|
|
||||||
|
for idx, row in enumerate(volume_rows):
|
||||||
|
code = _extract_stock_code(row)
|
||||||
|
if not code:
|
||||||
|
continue
|
||||||
|
# Top-ranked by traded value/volume gets higher liquidity bonus.
|
||||||
|
volume_rank_bonus[code] = max(0.0, 15.0 - idx * 0.3)
|
||||||
|
|
||||||
candidates: list[ScanCandidate] = []
|
candidates: list[ScanCandidate] = []
|
||||||
for row in fluct_rows:
|
for row in fluct_rows:
|
||||||
stock_code = (
|
stock_code = _extract_stock_code(row)
|
||||||
str(
|
|
||||||
row.get("symb")
|
|
||||||
or row.get("ovrs_pdno")
|
|
||||||
or row.get("stock_code")
|
|
||||||
or row.get("pdno")
|
|
||||||
or ""
|
|
||||||
)
|
|
||||||
.strip()
|
|
||||||
.upper()
|
|
||||||
)
|
|
||||||
if not stock_code:
|
if not stock_code:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
price = _safe_float(
|
price = _extract_last_price(row)
|
||||||
row.get("last")
|
change_rate = _extract_change_rate_pct(row)
|
||||||
or row.get("ovrs_nmix_prpr")
|
volume = _extract_volume(row)
|
||||||
or row.get("stck_prpr")
|
intraday_range_pct = _extract_intraday_range_pct(row, price)
|
||||||
)
|
volatility_pct = max(abs(change_rate), intraday_range_pct)
|
||||||
change_rate = _safe_float(
|
|
||||||
row.get("rate")
|
|
||||||
or row.get("prdy_ctrt")
|
|
||||||
or row.get("evlu_pfls_rt")
|
|
||||||
or row.get("chg_rt")
|
|
||||||
)
|
|
||||||
volume = _safe_float(row.get("tvol") or row.get("acml_vol") or row.get("vol"))
|
|
||||||
|
|
||||||
if price <= 0 or abs(change_rate) < 0.8:
|
# Volatility-first filter (not simple gainers/value ranking).
|
||||||
|
if price <= 0 or volatility_pct < 0.8:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
score = min(abs(change_rate) / 8.0, 1.0) * 100.0
|
volatility_score = min(volatility_pct / 10.0, 1.0) * 85.0
|
||||||
|
liquidity_score = volume_rank_bonus.get(stock_code, 0.0)
|
||||||
|
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 * 4.0)))
|
||||||
candidates.append(
|
candidates.append(
|
||||||
@@ -278,7 +284,7 @@ class SmartVolatilityScanner:
|
|||||||
name=str(row.get("name") or row.get("ovrs_item_name") or stock_code),
|
name=str(row.get("name") or row.get("ovrs_item_name") or stock_code),
|
||||||
price=price,
|
price=price,
|
||||||
volume=volume,
|
volume=volume,
|
||||||
volume_ratio=max(1.0, abs(change_rate) / 2.0),
|
volume_ratio=max(1.0, volatility_pct / 2.0),
|
||||||
rsi=implied_rsi,
|
rsi=implied_rsi,
|
||||||
signal=signal,
|
signal=signal,
|
||||||
score=score,
|
score=score,
|
||||||
@@ -311,22 +317,16 @@ class SmartVolatilityScanner:
|
|||||||
market.exchange_code, stock_code
|
market.exchange_code, stock_code
|
||||||
)
|
)
|
||||||
output = price_data.get("output", {})
|
output = price_data.get("output", {})
|
||||||
price = _safe_float(
|
price = _extract_last_price(output)
|
||||||
output.get("last")
|
change_rate = _extract_change_rate_pct(output)
|
||||||
or output.get("ovrs_nmix_prpr")
|
volume = _extract_volume(output)
|
||||||
or output.get("stck_prpr")
|
intraday_range_pct = _extract_intraday_range_pct(output, price)
|
||||||
)
|
volatility_pct = max(abs(change_rate), intraday_range_pct)
|
||||||
change_rate = _safe_float(
|
|
||||||
output.get("rate")
|
|
||||||
or output.get("prdy_ctrt")
|
|
||||||
or output.get("evlu_pfls_rt")
|
|
||||||
)
|
|
||||||
volume = _safe_float(output.get("tvol") or output.get("acml_vol"))
|
|
||||||
|
|
||||||
if price <= 0 or abs(change_rate) < 0.8:
|
if price <= 0 or volatility_pct < 0.8:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
score = min(abs(change_rate) / 8.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 * 4.0)))
|
||||||
candidates.append(
|
candidates.append(
|
||||||
@@ -335,7 +335,7 @@ class SmartVolatilityScanner:
|
|||||||
name=stock_code,
|
name=stock_code,
|
||||||
price=price,
|
price=price,
|
||||||
volume=volume,
|
volume=volume,
|
||||||
volume_ratio=max(1.0, abs(change_rate) / 2.0),
|
volume_ratio=max(1.0, volatility_pct / 2.0),
|
||||||
rsi=implied_rsi,
|
rsi=implied_rsi,
|
||||||
signal=signal,
|
signal=signal,
|
||||||
score=score,
|
score=score,
|
||||||
@@ -367,3 +367,64 @@ def _safe_float(value: Any, default: float = 0.0) -> float:
|
|||||||
return float(value)
|
return float(value)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return default
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_stock_code(row: dict[str, Any]) -> str:
|
||||||
|
"""Extract normalized stock code from various API schemas."""
|
||||||
|
return (
|
||||||
|
str(
|
||||||
|
row.get("symb")
|
||||||
|
or row.get("ovrs_pdno")
|
||||||
|
or row.get("stock_code")
|
||||||
|
or row.get("pdno")
|
||||||
|
or ""
|
||||||
|
)
|
||||||
|
.strip()
|
||||||
|
.upper()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_last_price(row: dict[str, Any]) -> float:
|
||||||
|
"""Extract last/close-like price from API schema variants."""
|
||||||
|
return _safe_float(
|
||||||
|
row.get("last")
|
||||||
|
or row.get("ovrs_nmix_prpr")
|
||||||
|
or row.get("stck_prpr")
|
||||||
|
or row.get("close")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_change_rate_pct(row: dict[str, Any]) -> float:
|
||||||
|
"""Extract daily change rate (%) from API schema variants."""
|
||||||
|
return _safe_float(
|
||||||
|
row.get("rate")
|
||||||
|
or row.get("prdy_ctrt")
|
||||||
|
or row.get("evlu_pfls_rt")
|
||||||
|
or row.get("chg_rt")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_volume(row: dict[str, Any]) -> float:
|
||||||
|
"""Extract volume/traded-amount proxy from schema variants."""
|
||||||
|
return _safe_float(row.get("tvol") or row.get("acml_vol") or row.get("vol"))
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_intraday_range_pct(row: dict[str, Any], price: float) -> float:
|
||||||
|
"""Estimate intraday range percentage from high/low fields."""
|
||||||
|
if price <= 0:
|
||||||
|
return 0.0
|
||||||
|
high = _safe_float(
|
||||||
|
row.get("high")
|
||||||
|
or row.get("ovrs_hgpr")
|
||||||
|
or row.get("stck_hgpr")
|
||||||
|
or row.get("day_hgpr")
|
||||||
|
)
|
||||||
|
low = _safe_float(
|
||||||
|
row.get("low")
|
||||||
|
or row.get("ovrs_lwpr")
|
||||||
|
or row.get("stck_lwpr")
|
||||||
|
or row.get("day_lwpr")
|
||||||
|
)
|
||||||
|
if high <= 0 or low <= 0 or high < low:
|
||||||
|
return 0.0
|
||||||
|
return (high - low) / price * 100.0
|
||||||
|
|||||||
@@ -392,7 +392,7 @@ class TestSmartVolatilityScanner:
|
|||||||
|
|
||||||
candidates = await scanner.scan(market=market, fallback_stocks=["AAPL", "TSLA"])
|
candidates = await scanner.scan(market=market, fallback_stocks=["AAPL", "TSLA"])
|
||||||
|
|
||||||
mock_overseas_broker.fetch_overseas_rankings.assert_called_once()
|
assert mock_overseas_broker.fetch_overseas_rankings.call_count >= 1
|
||||||
mock_overseas_broker.get_overseas_price.assert_not_called()
|
mock_overseas_broker.get_overseas_price.assert_not_called()
|
||||||
assert [c.stock_code for c in candidates] == ["NVDA"]
|
assert [c.stock_code for c in candidates] == ["NVDA"]
|
||||||
|
|
||||||
@@ -418,6 +418,40 @@ class TestSmartVolatilityScanner:
|
|||||||
|
|
||||||
assert candidates == []
|
assert candidates == []
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_scan_overseas_picks_high_intraday_range_even_with_low_change(
|
||||||
|
self, mock_broker: MagicMock, mock_overseas_broker: MagicMock, mock_settings: Settings
|
||||||
|
) -> None:
|
||||||
|
"""Volatility selection should consider intraday range, not only change rate."""
|
||||||
|
analyzer = VolatilityAnalyzer()
|
||||||
|
scanner = SmartVolatilityScanner(
|
||||||
|
broker=mock_broker,
|
||||||
|
overseas_broker=mock_overseas_broker,
|
||||||
|
volatility_analyzer=analyzer,
|
||||||
|
settings=mock_settings,
|
||||||
|
)
|
||||||
|
market = MagicMock()
|
||||||
|
market.name = "NASDAQ"
|
||||||
|
market.code = "US_NASDAQ"
|
||||||
|
market.exchange_code = "NASD"
|
||||||
|
market.is_domestic = False
|
||||||
|
|
||||||
|
# change rate is tiny, but high-low range is large (15%).
|
||||||
|
mock_overseas_broker.fetch_overseas_rankings.return_value = [
|
||||||
|
{
|
||||||
|
"symb": "ABCD",
|
||||||
|
"last": "100",
|
||||||
|
"rate": "0.2",
|
||||||
|
"high": "110",
|
||||||
|
"low": "95",
|
||||||
|
"tvol": "800000",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
candidates = await scanner.scan(market=market, fallback_stocks=[])
|
||||||
|
|
||||||
|
assert [c.stock_code for c in candidates] == ["ABCD"]
|
||||||
|
|
||||||
|
|
||||||
class TestRSICalculation:
|
class TestRSICalculation:
|
||||||
"""Test RSI calculation in VolatilityAnalyzer."""
|
"""Test RSI calculation in VolatilityAnalyzer."""
|
||||||
|
|||||||
Reference in New Issue
Block a user