Compare commits
10 Commits
feat/overs
...
feature/is
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b484f0daff | ||
|
|
1288181e39 | ||
|
|
b625f41621 | ||
| 77d3ba967c | |||
|
|
aeed881d85 | ||
|
|
d0bbdb5dc1 | ||
| 44339c52d7 | |||
|
|
22ffdafacc | ||
|
|
c49765e951 | ||
| 64000b9967 |
@@ -165,3 +165,22 @@
|
||||
**효과:**
|
||||
- 국내/해외 스캐너 기준이 변동성 중심으로 일관화
|
||||
- 고변동 구간에서 자동 익스포저 축소, 저변동 구간에서 과소진입 완화
|
||||
|
||||
## 2026-02-18
|
||||
|
||||
### KIS 해외 랭킹 API 404 에러 수정
|
||||
|
||||
**배경:**
|
||||
- KIS 해외주식 랭킹 API(`fetch_overseas_rankings`)가 모든 거래소에서 HTTP 404를 반환
|
||||
- Smart Scanner가 해외 시장 후보 종목을 찾지 못해 거래가 전혀 실행되지 않음
|
||||
|
||||
**근본 원인:**
|
||||
- TR_ID, API 경로, 거래소 코드가 모두 KIS 공식 문서와 불일치
|
||||
|
||||
**구현 결과:**
|
||||
- `src/config.py`: TR_ID/Path 기본값을 KIS 공식 스펙으로 수정
|
||||
- `src/broker/overseas.py`: 랭킹 API 전용 거래소 코드 매핑 추가 (NASD→NAS, NYSE→NYS, AMEX→AMS), 올바른 API 파라미터 사용
|
||||
- `tests/test_overseas_broker.py`: 19개 단위 테스트 추가
|
||||
|
||||
**효과:**
|
||||
- 해외 시장 랭킹 스캔이 정상 동작하여 Smart Scanner가 후보 종목 탐지 가능
|
||||
|
||||
54
scripts/morning_report.sh
Executable file
54
scripts/morning_report.sh
Executable file
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env bash
|
||||
# Morning summary for overnight run logs.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
LOG_DIR="${LOG_DIR:-data/overnight}"
|
||||
|
||||
if [ ! -d "$LOG_DIR" ]; then
|
||||
echo "로그 디렉터리가 없습니다: $LOG_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
latest_run="$(ls -1t "$LOG_DIR"/run_*.log 2>/dev/null | head -n 1 || true)"
|
||||
latest_watchdog="$(ls -1t "$LOG_DIR"/watchdog_*.log 2>/dev/null | head -n 1 || true)"
|
||||
|
||||
if [ -z "$latest_run" ]; then
|
||||
echo "run 로그가 없습니다: $LOG_DIR/run_*.log"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Overnight report"
|
||||
echo "- run log: $latest_run"
|
||||
if [ -n "$latest_watchdog" ]; then
|
||||
echo "- watchdog log: $latest_watchdog"
|
||||
fi
|
||||
|
||||
start_line="$(head -n 1 "$latest_run" || true)"
|
||||
end_line="$(tail -n 1 "$latest_run" || true)"
|
||||
|
||||
info_count="$(rg -c '"level": "INFO"' "$latest_run" || true)"
|
||||
warn_count="$(rg -c '"level": "WARNING"' "$latest_run" || true)"
|
||||
error_count="$(rg -c '"level": "ERROR"' "$latest_run" || true)"
|
||||
critical_count="$(rg -c '"level": "CRITICAL"' "$latest_run" || true)"
|
||||
traceback_count="$(rg -c 'Traceback' "$latest_run" || true)"
|
||||
|
||||
echo "- start: ${start_line:-N/A}"
|
||||
echo "- end: ${end_line:-N/A}"
|
||||
echo "- INFO: ${info_count:-0}"
|
||||
echo "- WARNING: ${warn_count:-0}"
|
||||
echo "- ERROR: ${error_count:-0}"
|
||||
echo "- CRITICAL: ${critical_count:-0}"
|
||||
echo "- Traceback: ${traceback_count:-0}"
|
||||
|
||||
if [ -n "$latest_watchdog" ]; then
|
||||
watchdog_errors="$(rg -c '\[ERROR\]' "$latest_watchdog" || true)"
|
||||
echo "- watchdog ERROR: ${watchdog_errors:-0}"
|
||||
echo ""
|
||||
echo "최근 watchdog 로그:"
|
||||
tail -n 5 "$latest_watchdog" || true
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "최근 앱 로그:"
|
||||
tail -n 20 "$latest_run" || true
|
||||
87
scripts/run_overnight.sh
Executable file
87
scripts/run_overnight.sh
Executable file
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env bash
|
||||
# Start The Ouroboros overnight with logs and watchdog.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
LOG_DIR="${LOG_DIR:-data/overnight}"
|
||||
CHECK_INTERVAL="${CHECK_INTERVAL:-30}"
|
||||
TMUX_AUTO="${TMUX_AUTO:-true}"
|
||||
TMUX_ATTACH="${TMUX_ATTACH:-true}"
|
||||
TMUX_SESSION_PREFIX="${TMUX_SESSION_PREFIX:-ouroboros_overnight}"
|
||||
|
||||
if [ -z "${APP_CMD:-}" ]; then
|
||||
if [ -x ".venv/bin/python" ]; then
|
||||
PYTHON_BIN=".venv/bin/python"
|
||||
elif command -v python3 >/dev/null 2>&1; then
|
||||
PYTHON_BIN="python3"
|
||||
elif command -v python >/dev/null 2>&1; then
|
||||
PYTHON_BIN="python"
|
||||
else
|
||||
echo ".venv/bin/python 또는 python3/python 실행 파일을 찾을 수 없습니다."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
dashboard_port="${DASHBOARD_PORT:-8080}"
|
||||
|
||||
APP_CMD="DASHBOARD_PORT=$dashboard_port $PYTHON_BIN -m src.main --mode=paper --dashboard"
|
||||
fi
|
||||
|
||||
mkdir -p "$LOG_DIR"
|
||||
|
||||
timestamp="$(date +"%Y%m%d_%H%M%S")"
|
||||
RUN_LOG="$LOG_DIR/run_${timestamp}.log"
|
||||
WATCHDOG_LOG="$LOG_DIR/watchdog_${timestamp}.log"
|
||||
PID_FILE="$LOG_DIR/app.pid"
|
||||
WATCHDOG_PID_FILE="$LOG_DIR/watchdog.pid"
|
||||
|
||||
if [ -f "$PID_FILE" ]; then
|
||||
old_pid="$(cat "$PID_FILE" || true)"
|
||||
if [ -n "$old_pid" ] && kill -0 "$old_pid" 2>/dev/null; then
|
||||
echo "앱이 이미 실행 중입니다. pid=$old_pid"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "[$(date -u +"%Y-%m-%dT%H:%M:%SZ")] starting: $APP_CMD" | tee -a "$RUN_LOG"
|
||||
nohup bash -lc "$APP_CMD" >>"$RUN_LOG" 2>&1 &
|
||||
app_pid=$!
|
||||
echo "$app_pid" > "$PID_FILE"
|
||||
|
||||
echo "[$(date -u +"%Y-%m-%dT%H:%M:%SZ")] app pid=$app_pid" | tee -a "$RUN_LOG"
|
||||
|
||||
nohup env PID_FILE="$PID_FILE" LOG_FILE="$WATCHDOG_LOG" CHECK_INTERVAL="$CHECK_INTERVAL" \
|
||||
bash scripts/watchdog.sh >/dev/null 2>&1 &
|
||||
watchdog_pid=$!
|
||||
echo "$watchdog_pid" > "$WATCHDOG_PID_FILE"
|
||||
|
||||
cat <<EOF
|
||||
시작 완료
|
||||
- app pid: $app_pid
|
||||
- watchdog pid: $watchdog_pid
|
||||
- app log: $RUN_LOG
|
||||
- watchdog log: $WATCHDOG_LOG
|
||||
|
||||
실시간 확인:
|
||||
tail -f "$RUN_LOG"
|
||||
tail -f "$WATCHDOG_LOG"
|
||||
EOF
|
||||
|
||||
if [ "$TMUX_AUTO" = "true" ]; then
|
||||
if ! command -v tmux >/dev/null 2>&1; then
|
||||
echo "tmux를 찾지 못해 자동 세션 생성은 건너뜁니다."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
session_name="${TMUX_SESSION_PREFIX}_${timestamp}"
|
||||
window_name="overnight"
|
||||
tmux new-session -d -s "$session_name" -n "$window_name" "tail -f '$RUN_LOG'"
|
||||
tmux split-window -t "${session_name}:${window_name}" -v "tail -f '$WATCHDOG_LOG'"
|
||||
tmux select-layout -t "${session_name}:${window_name}" even-vertical
|
||||
|
||||
echo "tmux session 생성: $session_name"
|
||||
echo "수동 접속: tmux attach -t $session_name"
|
||||
|
||||
if [ -z "${TMUX:-}" ] && [ "$TMUX_ATTACH" = "true" ]; then
|
||||
tmux attach -t "$session_name"
|
||||
fi
|
||||
fi
|
||||
76
scripts/stop_overnight.sh
Executable file
76
scripts/stop_overnight.sh
Executable file
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env bash
|
||||
# Stop The Ouroboros overnight app/watchdog/tmux session.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
LOG_DIR="${LOG_DIR:-data/overnight}"
|
||||
PID_FILE="$LOG_DIR/app.pid"
|
||||
WATCHDOG_PID_FILE="$LOG_DIR/watchdog.pid"
|
||||
TMUX_SESSION_PREFIX="${TMUX_SESSION_PREFIX:-ouroboros_overnight}"
|
||||
KILL_TIMEOUT="${KILL_TIMEOUT:-5}"
|
||||
|
||||
stop_pid() {
|
||||
local name="$1"
|
||||
local pid="$2"
|
||||
|
||||
if [ -z "$pid" ]; then
|
||||
echo "$name PID가 비어 있습니다."
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! kill -0 "$pid" 2>/dev/null; then
|
||||
echo "$name 프로세스가 이미 종료됨 (pid=$pid)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
kill "$pid" 2>/dev/null || true
|
||||
for _ in $(seq 1 "$KILL_TIMEOUT"); do
|
||||
if ! kill -0 "$pid" 2>/dev/null; then
|
||||
echo "$name 종료됨 (pid=$pid)"
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
kill -9 "$pid" 2>/dev/null || true
|
||||
if ! kill -0 "$pid" 2>/dev/null; then
|
||||
echo "$name 강제 종료됨 (pid=$pid)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "$name 종료 실패 (pid=$pid)"
|
||||
return 1
|
||||
}
|
||||
|
||||
status=0
|
||||
|
||||
if [ -f "$WATCHDOG_PID_FILE" ]; then
|
||||
watchdog_pid="$(cat "$WATCHDOG_PID_FILE" || true)"
|
||||
stop_pid "watchdog" "$watchdog_pid" || status=1
|
||||
rm -f "$WATCHDOG_PID_FILE"
|
||||
else
|
||||
echo "watchdog pid 파일 없음: $WATCHDOG_PID_FILE"
|
||||
fi
|
||||
|
||||
if [ -f "$PID_FILE" ]; then
|
||||
app_pid="$(cat "$PID_FILE" || true)"
|
||||
stop_pid "app" "$app_pid" || status=1
|
||||
rm -f "$PID_FILE"
|
||||
else
|
||||
echo "app pid 파일 없음: $PID_FILE"
|
||||
fi
|
||||
|
||||
if command -v tmux >/dev/null 2>&1; then
|
||||
sessions="$(tmux ls 2>/dev/null | awk -F: -v p="$TMUX_SESSION_PREFIX" '$1 ~ "^" p "_" {print $1}')"
|
||||
if [ -n "$sessions" ]; then
|
||||
while IFS= read -r s; do
|
||||
[ -z "$s" ] && continue
|
||||
tmux kill-session -t "$s" 2>/dev/null || true
|
||||
echo "tmux 세션 종료: $s"
|
||||
done <<< "$sessions"
|
||||
else
|
||||
echo "종료할 tmux 세션 없음 (prefix=${TMUX_SESSION_PREFIX}_)"
|
||||
fi
|
||||
fi
|
||||
|
||||
exit "$status"
|
||||
42
scripts/watchdog.sh
Executable file
42
scripts/watchdog.sh
Executable file
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env bash
|
||||
# Simple watchdog for The Ouroboros process.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
PID_FILE="${PID_FILE:-data/overnight/app.pid}"
|
||||
LOG_FILE="${LOG_FILE:-data/overnight/watchdog.log}"
|
||||
CHECK_INTERVAL="${CHECK_INTERVAL:-30}"
|
||||
STATUS_EVERY="${STATUS_EVERY:-10}"
|
||||
|
||||
mkdir -p "$(dirname "$LOG_FILE")"
|
||||
|
||||
log() {
|
||||
printf '%s %s\n' "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" "$1" | tee -a "$LOG_FILE"
|
||||
}
|
||||
|
||||
if [ ! -f "$PID_FILE" ]; then
|
||||
log "[ERROR] pid file not found: $PID_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PID="$(cat "$PID_FILE")"
|
||||
if [ -z "$PID" ]; then
|
||||
log "[ERROR] pid file is empty: $PID_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "[INFO] watchdog started (pid=$PID, interval=${CHECK_INTERVAL}s)"
|
||||
|
||||
count=0
|
||||
while true; do
|
||||
if kill -0 "$PID" 2>/dev/null; then
|
||||
count=$((count + 1))
|
||||
if [ $((count % STATUS_EVERY)) -eq 0 ]; then
|
||||
log "[INFO] process alive (pid=$PID)"
|
||||
fi
|
||||
else
|
||||
log "[ERROR] process stopped (pid=$PID)"
|
||||
exit 1
|
||||
fi
|
||||
sleep "$CHECK_INTERVAL"
|
||||
done
|
||||
@@ -315,6 +315,11 @@ class SmartVolatilityScanner:
|
||||
logger.info("Overseas scanner: no symbol universe for %s", market.name)
|
||||
return []
|
||||
|
||||
logger.info(
|
||||
"Overseas scanner: scanning %d fallback symbols for %s",
|
||||
len(symbols),
|
||||
market.name,
|
||||
)
|
||||
candidates: list[ScanCandidate] = []
|
||||
for stock_code in symbols:
|
||||
try:
|
||||
@@ -350,6 +355,11 @@ class SmartVolatilityScanner:
|
||||
logger.warning("Failed to analyze overseas %s: %s", stock_code, exc)
|
||||
except Exception as exc:
|
||||
logger.error("Unexpected error analyzing overseas %s: %s", stock_code, exc)
|
||||
logger.info(
|
||||
"Overseas symbol fallback scan found %d candidates for %s",
|
||||
len(candidates),
|
||||
market.name,
|
||||
)
|
||||
return candidates
|
||||
|
||||
def get_stock_codes(self, candidates: list[ScanCandidate]) -> list[str]:
|
||||
|
||||
@@ -104,12 +104,14 @@ class KISBroker:
|
||||
time_since_last_attempt = now - self._last_refresh_attempt
|
||||
if time_since_last_attempt < self._refresh_cooldown:
|
||||
remaining = self._refresh_cooldown - time_since_last_attempt
|
||||
error_msg = (
|
||||
f"Token refresh on cooldown. "
|
||||
f"Retry in {remaining:.1f}s (KIS allows 1/minute)"
|
||||
# Do not fail fast here. If token is unavailable, upstream calls
|
||||
# will all fail for up to a minute and scanning returns no trades.
|
||||
logger.warning(
|
||||
"Token refresh on cooldown. Waiting %.1fs before retry (KIS allows 1/minute)",
|
||||
remaining,
|
||||
)
|
||||
logger.warning(error_msg)
|
||||
raise ConnectionError(error_msg)
|
||||
await asyncio.sleep(remaining)
|
||||
now = asyncio.get_event_loop().time()
|
||||
|
||||
logger.info("Refreshing KIS access token")
|
||||
self._last_refresh_attempt = now
|
||||
|
||||
@@ -12,6 +12,20 @@ from src.broker.kis_api import KISBroker
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Ranking API uses different exchange codes than order/quote APIs.
|
||||
_RANKING_EXCHANGE_MAP: dict[str, str] = {
|
||||
"NASD": "NAS",
|
||||
"NYSE": "NYS",
|
||||
"AMEX": "AMS",
|
||||
"SEHK": "HKS",
|
||||
"SHAA": "SHS",
|
||||
"SZAA": "SZS",
|
||||
"HSX": "HSX",
|
||||
"HNX": "HNX",
|
||||
"TSE": "TSE",
|
||||
}
|
||||
|
||||
|
||||
class OverseasBroker:
|
||||
"""KIS Overseas Stock API wrapper that reuses KISBroker infrastructure."""
|
||||
|
||||
@@ -70,7 +84,7 @@ class OverseasBroker:
|
||||
ranking_type: str = "fluctuation",
|
||||
limit: int = 30,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Fetch overseas rankings (price change or volume amount).
|
||||
"""Fetch overseas rankings (price change or volume surge).
|
||||
|
||||
Ranking API specs may differ by account/product. Endpoint paths and
|
||||
TR_IDs are configurable via settings and can be overridden in .env.
|
||||
@@ -81,47 +95,63 @@ class OverseasBroker:
|
||||
await self._broker._rate_limiter.acquire()
|
||||
session = self._broker._get_session()
|
||||
|
||||
ranking_excd = _RANKING_EXCHANGE_MAP.get(exchange_code, exchange_code)
|
||||
|
||||
if ranking_type == "volume":
|
||||
tr_id = self._broker._settings.OVERSEAS_RANKING_VOLUME_TR_ID
|
||||
path = self._broker._settings.OVERSEAS_RANKING_VOLUME_PATH
|
||||
params: dict[str, str] = {
|
||||
"AUTH": "",
|
||||
"EXCD": ranking_excd,
|
||||
"MIXN": "0",
|
||||
"VOL_RANG": "0",
|
||||
}
|
||||
else:
|
||||
tr_id = self._broker._settings.OVERSEAS_RANKING_FLUCT_TR_ID
|
||||
path = self._broker._settings.OVERSEAS_RANKING_FLUCT_PATH
|
||||
params = {
|
||||
"AUTH": "",
|
||||
"EXCD": ranking_excd,
|
||||
"NDAY": "0",
|
||||
"GUBN": "1",
|
||||
"VOL_RANG": "0",
|
||||
}
|
||||
|
||||
headers = await self._broker._auth_headers(tr_id)
|
||||
url = f"{self._broker._base_url}{path}"
|
||||
|
||||
# Try common param variants used by KIS overseas quotation APIs.
|
||||
param_variants = [
|
||||
{"AUTH": "", "EXCD": exchange_code, "NREC": str(max(limit, 30))},
|
||||
{"AUTH": "", "OVRS_EXCG_CD": exchange_code, "NREC": str(max(limit, 30))},
|
||||
{"AUTH": "", "EXCD": exchange_code},
|
||||
{"AUTH": "", "OVRS_EXCG_CD": exchange_code},
|
||||
]
|
||||
|
||||
last_error: str | None = None
|
||||
for params in param_variants:
|
||||
try:
|
||||
async with session.get(url, headers=headers, params=params) as resp:
|
||||
try:
|
||||
async with session.get(url, headers=headers, params=params) as resp:
|
||||
if resp.status != 200:
|
||||
text = await resp.text()
|
||||
if resp.status != 200:
|
||||
last_error = f"HTTP {resp.status}: {text}"
|
||||
continue
|
||||
if resp.status == 404:
|
||||
logger.warning(
|
||||
"Overseas ranking endpoint unavailable (404) for %s/%s; "
|
||||
"using symbol fallback scan",
|
||||
exchange_code,
|
||||
ranking_type,
|
||||
)
|
||||
return []
|
||||
raise ConnectionError(
|
||||
f"fetch_overseas_rankings failed ({resp.status}): {text}"
|
||||
)
|
||||
|
||||
data = await resp.json()
|
||||
rows = self._extract_ranking_rows(data)
|
||||
if rows:
|
||||
return rows[:limit]
|
||||
data = await resp.json()
|
||||
rows = self._extract_ranking_rows(data)
|
||||
if rows:
|
||||
return rows[:limit]
|
||||
|
||||
# keep trying another param variant if response has no usable rows
|
||||
last_error = f"empty output (keys={list(data.keys())})"
|
||||
except (TimeoutError, aiohttp.ClientError) as exc:
|
||||
last_error = str(exc)
|
||||
continue
|
||||
|
||||
raise ConnectionError(
|
||||
f"fetch_overseas_rankings failed for {exchange_code}/{ranking_type}: {last_error}"
|
||||
)
|
||||
logger.debug(
|
||||
"Overseas ranking returned empty for %s/%s (keys=%s)",
|
||||
exchange_code,
|
||||
ranking_type,
|
||||
list(data.keys()),
|
||||
)
|
||||
return []
|
||||
except (TimeoutError, aiohttp.ClientError) as exc:
|
||||
raise ConnectionError(
|
||||
f"Network error fetching overseas rankings: {exc}"
|
||||
) from exc
|
||||
|
||||
async def get_overseas_balance(self, exchange_code: str) -> dict[str, Any]:
|
||||
"""
|
||||
|
||||
@@ -91,13 +91,13 @@ class Settings(BaseSettings):
|
||||
# Overseas ranking API (KIS endpoint/TR_ID may vary by account/product)
|
||||
# Override these from .env if your account uses different specs.
|
||||
OVERSEAS_RANKING_ENABLED: bool = True
|
||||
OVERSEAS_RANKING_FLUCT_TR_ID: str = "HHDFS76200100"
|
||||
OVERSEAS_RANKING_VOLUME_TR_ID: str = "HHDFS76200200"
|
||||
OVERSEAS_RANKING_FLUCT_TR_ID: str = "HHDFS76290000"
|
||||
OVERSEAS_RANKING_VOLUME_TR_ID: str = "HHDFS76270000"
|
||||
OVERSEAS_RANKING_FLUCT_PATH: str = (
|
||||
"/uapi/overseas-price/v1/quotations/inquire-updown-rank"
|
||||
"/uapi/overseas-stock/v1/ranking/updown-rate"
|
||||
)
|
||||
OVERSEAS_RANKING_VOLUME_PATH: str = (
|
||||
"/uapi/overseas-price/v1/quotations/inquire-volume-rank"
|
||||
"/uapi/overseas-stock/v1/ranking/volume-surge"
|
||||
)
|
||||
|
||||
# Dashboard (optional)
|
||||
|
||||
@@ -90,12 +90,12 @@ class TestTokenManagement:
|
||||
await broker.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_token_refresh_cooldown_prevents_rapid_retries(self, settings):
|
||||
"""Token refresh should enforce cooldown after failure (issue #54)."""
|
||||
async def test_token_refresh_cooldown_waits_then_retries(self, settings):
|
||||
"""Token refresh should wait out cooldown then retry (issue #54)."""
|
||||
broker = KISBroker(settings)
|
||||
broker._refresh_cooldown = 2.0 # Short cooldown for testing
|
||||
broker._refresh_cooldown = 0.1 # Short cooldown for testing
|
||||
|
||||
# First refresh attempt fails with 403 (EGW00133)
|
||||
# All attempts fail with 403 (EGW00133)
|
||||
mock_resp_403 = AsyncMock()
|
||||
mock_resp_403.status = 403
|
||||
mock_resp_403.text = AsyncMock(
|
||||
@@ -109,8 +109,8 @@ class TestTokenManagement:
|
||||
with pytest.raises(ConnectionError, match="Token refresh failed"):
|
||||
await broker._ensure_token()
|
||||
|
||||
# Second attempt within cooldown should fail with cooldown error
|
||||
with pytest.raises(ConnectionError, match="Token refresh on cooldown"):
|
||||
# Second attempt within cooldown should wait then retry (and still get 403)
|
||||
with pytest.raises(ConnectionError, match="Token refresh failed"):
|
||||
await broker._ensure_token()
|
||||
|
||||
await broker.close()
|
||||
|
||||
521
tests/test_overseas_broker.py
Normal file
521
tests/test_overseas_broker.py
Normal file
@@ -0,0 +1,521 @@
|
||||
"""Tests for OverseasBroker — rankings, price, balance, order, and helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import aiohttp
|
||||
import pytest
|
||||
|
||||
from src.broker.kis_api import KISBroker
|
||||
from src.broker.overseas import OverseasBroker, _RANKING_EXCHANGE_MAP
|
||||
from src.config import Settings
|
||||
|
||||
|
||||
def _make_async_cm(mock_resp: AsyncMock) -> MagicMock:
|
||||
"""Create an async context manager that returns mock_resp on __aenter__."""
|
||||
cm = MagicMock()
|
||||
cm.__aenter__ = AsyncMock(return_value=mock_resp)
|
||||
cm.__aexit__ = AsyncMock(return_value=False)
|
||||
return cm
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_settings() -> Settings:
|
||||
"""Provide mock settings with correct default TR_IDs/paths."""
|
||||
return Settings(
|
||||
KIS_APP_KEY="test_key",
|
||||
KIS_APP_SECRET="test_secret",
|
||||
KIS_ACCOUNT_NO="12345678-01",
|
||||
GEMINI_API_KEY="test_gemini_key",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_broker(mock_settings: Settings) -> KISBroker:
|
||||
"""Provide a mock KIS broker."""
|
||||
broker = KISBroker(mock_settings)
|
||||
broker.get_orderbook = AsyncMock() # type: ignore[method-assign]
|
||||
return broker
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def overseas_broker(mock_broker: KISBroker) -> OverseasBroker:
|
||||
"""Provide an OverseasBroker wrapping a mock KISBroker."""
|
||||
return OverseasBroker(mock_broker)
|
||||
|
||||
|
||||
def _setup_broker_mocks(overseas_broker: OverseasBroker, mock_session: MagicMock) -> None:
|
||||
"""Wire up common broker mocks."""
|
||||
overseas_broker._broker._rate_limiter.acquire = AsyncMock()
|
||||
overseas_broker._broker._get_session = MagicMock(return_value=mock_session)
|
||||
overseas_broker._broker._auth_headers = AsyncMock(return_value={})
|
||||
|
||||
|
||||
class TestRankingExchangeMap:
|
||||
"""Test exchange code mapping for ranking API."""
|
||||
|
||||
def test_nasd_maps_to_nas(self) -> None:
|
||||
assert _RANKING_EXCHANGE_MAP["NASD"] == "NAS"
|
||||
|
||||
def test_nyse_maps_to_nys(self) -> None:
|
||||
assert _RANKING_EXCHANGE_MAP["NYSE"] == "NYS"
|
||||
|
||||
def test_amex_maps_to_ams(self) -> None:
|
||||
assert _RANKING_EXCHANGE_MAP["AMEX"] == "AMS"
|
||||
|
||||
def test_sehk_maps_to_hks(self) -> None:
|
||||
assert _RANKING_EXCHANGE_MAP["SEHK"] == "HKS"
|
||||
|
||||
def test_unmapped_exchange_passes_through(self) -> None:
|
||||
assert _RANKING_EXCHANGE_MAP.get("UNKNOWN", "UNKNOWN") == "UNKNOWN"
|
||||
|
||||
def test_tse_unchanged(self) -> None:
|
||||
assert _RANKING_EXCHANGE_MAP["TSE"] == "TSE"
|
||||
|
||||
|
||||
class TestConfigDefaults:
|
||||
"""Test that config defaults match KIS official API specs."""
|
||||
|
||||
def test_fluct_tr_id(self, mock_settings: Settings) -> None:
|
||||
assert mock_settings.OVERSEAS_RANKING_FLUCT_TR_ID == "HHDFS76290000"
|
||||
|
||||
def test_volume_tr_id(self, mock_settings: Settings) -> None:
|
||||
assert mock_settings.OVERSEAS_RANKING_VOLUME_TR_ID == "HHDFS76270000"
|
||||
|
||||
def test_fluct_path(self, mock_settings: Settings) -> None:
|
||||
assert mock_settings.OVERSEAS_RANKING_FLUCT_PATH == "/uapi/overseas-stock/v1/ranking/updown-rate"
|
||||
|
||||
def test_volume_path(self, mock_settings: Settings) -> None:
|
||||
assert mock_settings.OVERSEAS_RANKING_VOLUME_PATH == "/uapi/overseas-stock/v1/ranking/volume-surge"
|
||||
|
||||
|
||||
class TestFetchOverseasRankings:
|
||||
"""Test fetch_overseas_rankings method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fluctuation_uses_correct_params(
|
||||
self, overseas_broker: OverseasBroker
|
||||
) -> None:
|
||||
"""Fluctuation ranking should use HHDFS76290000, updown-rate path, and correct params."""
|
||||
mock_resp = AsyncMock()
|
||||
mock_resp.status = 200
|
||||
mock_resp.json = AsyncMock(
|
||||
return_value={"output": [{"symb": "AAPL", "name": "Apple"}]}
|
||||
)
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_session.get = MagicMock(return_value=_make_async_cm(mock_resp))
|
||||
|
||||
_setup_broker_mocks(overseas_broker, mock_session)
|
||||
overseas_broker._broker._auth_headers = AsyncMock(
|
||||
return_value={"authorization": "Bearer test"}
|
||||
)
|
||||
|
||||
result = await overseas_broker.fetch_overseas_rankings("NASD", "fluctuation")
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0]["symb"] == "AAPL"
|
||||
|
||||
call_args = mock_session.get.call_args
|
||||
url = call_args[0][0]
|
||||
params = call_args[1]["params"]
|
||||
|
||||
assert "/uapi/overseas-stock/v1/ranking/updown-rate" in url
|
||||
assert params["EXCD"] == "NAS"
|
||||
assert params["NDAY"] == "0"
|
||||
assert params["GUBN"] == "1"
|
||||
assert params["VOL_RANG"] == "0"
|
||||
|
||||
overseas_broker._broker._auth_headers.assert_called_with("HHDFS76290000")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_volume_uses_correct_params(
|
||||
self, overseas_broker: OverseasBroker
|
||||
) -> None:
|
||||
"""Volume ranking should use HHDFS76270000, volume-surge path, and correct params."""
|
||||
mock_resp = AsyncMock()
|
||||
mock_resp.status = 200
|
||||
mock_resp.json = AsyncMock(
|
||||
return_value={"output": [{"symb": "TSLA", "name": "Tesla"}]}
|
||||
)
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_session.get = MagicMock(return_value=_make_async_cm(mock_resp))
|
||||
|
||||
_setup_broker_mocks(overseas_broker, mock_session)
|
||||
overseas_broker._broker._auth_headers = AsyncMock(
|
||||
return_value={"authorization": "Bearer test"}
|
||||
)
|
||||
|
||||
result = await overseas_broker.fetch_overseas_rankings("NYSE", "volume")
|
||||
|
||||
assert len(result) == 1
|
||||
|
||||
call_args = mock_session.get.call_args
|
||||
url = call_args[0][0]
|
||||
params = call_args[1]["params"]
|
||||
|
||||
assert "/uapi/overseas-stock/v1/ranking/volume-surge" in url
|
||||
assert params["EXCD"] == "NYS"
|
||||
assert params["MIXN"] == "0"
|
||||
assert params["VOL_RANG"] == "0"
|
||||
assert "NDAY" not in params
|
||||
assert "GUBN" not in params
|
||||
|
||||
overseas_broker._broker._auth_headers.assert_called_with("HHDFS76270000")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_404_returns_empty_list(
|
||||
self, overseas_broker: OverseasBroker
|
||||
) -> None:
|
||||
"""HTTP 404 should return empty list (fallback) instead of raising."""
|
||||
mock_resp = AsyncMock()
|
||||
mock_resp.status = 404
|
||||
mock_resp.text = AsyncMock(return_value="Not Found")
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_session.get = MagicMock(return_value=_make_async_cm(mock_resp))
|
||||
|
||||
_setup_broker_mocks(overseas_broker, mock_session)
|
||||
|
||||
result = await overseas_broker.fetch_overseas_rankings("AMEX", "fluctuation")
|
||||
assert result == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_404_error_raises(
|
||||
self, overseas_broker: OverseasBroker
|
||||
) -> None:
|
||||
"""Non-404 HTTP errors should raise ConnectionError."""
|
||||
mock_resp = AsyncMock()
|
||||
mock_resp.status = 500
|
||||
mock_resp.text = AsyncMock(return_value="Internal Server Error")
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_session.get = MagicMock(return_value=_make_async_cm(mock_resp))
|
||||
|
||||
_setup_broker_mocks(overseas_broker, mock_session)
|
||||
|
||||
with pytest.raises(ConnectionError, match="500"):
|
||||
await overseas_broker.fetch_overseas_rankings("NASD")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_response_returns_empty(
|
||||
self, overseas_broker: OverseasBroker
|
||||
) -> None:
|
||||
"""Empty output in response should return empty list."""
|
||||
mock_resp = AsyncMock()
|
||||
mock_resp.status = 200
|
||||
mock_resp.json = AsyncMock(return_value={"output": []})
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_session.get = MagicMock(return_value=_make_async_cm(mock_resp))
|
||||
|
||||
_setup_broker_mocks(overseas_broker, mock_session)
|
||||
|
||||
result = await overseas_broker.fetch_overseas_rankings("NASD")
|
||||
assert result == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ranking_disabled_returns_empty(
|
||||
self, overseas_broker: OverseasBroker
|
||||
) -> None:
|
||||
"""When OVERSEAS_RANKING_ENABLED=False, should return empty immediately."""
|
||||
overseas_broker._broker._settings.OVERSEAS_RANKING_ENABLED = False
|
||||
result = await overseas_broker.fetch_overseas_rankings("NASD")
|
||||
assert result == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_limit_truncates_results(
|
||||
self, overseas_broker: OverseasBroker
|
||||
) -> None:
|
||||
"""Results should be truncated to the specified limit."""
|
||||
rows = [{"symb": f"SYM{i}"} for i in range(20)]
|
||||
mock_resp = AsyncMock()
|
||||
mock_resp.status = 200
|
||||
mock_resp.json = AsyncMock(return_value={"output": rows})
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_session.get = MagicMock(return_value=_make_async_cm(mock_resp))
|
||||
|
||||
_setup_broker_mocks(overseas_broker, mock_session)
|
||||
|
||||
result = await overseas_broker.fetch_overseas_rankings("NASD", limit=5)
|
||||
assert len(result) == 5
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_network_error_raises(
|
||||
self, overseas_broker: OverseasBroker
|
||||
) -> None:
|
||||
"""Network errors should raise ConnectionError."""
|
||||
cm = MagicMock()
|
||||
cm.__aenter__ = AsyncMock(side_effect=aiohttp.ClientError("timeout"))
|
||||
cm.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_session.get = MagicMock(return_value=cm)
|
||||
|
||||
_setup_broker_mocks(overseas_broker, mock_session)
|
||||
|
||||
with pytest.raises(ConnectionError, match="Network error"):
|
||||
await overseas_broker.fetch_overseas_rankings("NASD")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exchange_code_mapping_applied(
|
||||
self, overseas_broker: OverseasBroker
|
||||
) -> None:
|
||||
"""All major exchanges should use mapped codes in API params."""
|
||||
for original, mapped in [("NASD", "NAS"), ("NYSE", "NYS"), ("AMEX", "AMS")]:
|
||||
mock_resp = AsyncMock()
|
||||
mock_resp.status = 200
|
||||
mock_resp.json = AsyncMock(return_value={"output": [{"symb": "X"}]})
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_session.get = MagicMock(return_value=_make_async_cm(mock_resp))
|
||||
|
||||
_setup_broker_mocks(overseas_broker, mock_session)
|
||||
|
||||
await overseas_broker.fetch_overseas_rankings(original)
|
||||
|
||||
call_params = mock_session.get.call_args[1]["params"]
|
||||
assert call_params["EXCD"] == mapped, f"{original} should map to {mapped}"
|
||||
|
||||
|
||||
class TestGetOverseasPrice:
|
||||
"""Test get_overseas_price method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_success(self, overseas_broker: OverseasBroker) -> None:
|
||||
"""Successful price fetch returns JSON data."""
|
||||
mock_resp = AsyncMock()
|
||||
mock_resp.status = 200
|
||||
mock_resp.json = AsyncMock(return_value={"output": {"last": "150.00"}})
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_session.get = MagicMock(return_value=_make_async_cm(mock_resp))
|
||||
|
||||
_setup_broker_mocks(overseas_broker, mock_session)
|
||||
overseas_broker._broker._auth_headers = AsyncMock(return_value={"authorization": "Bearer t"})
|
||||
|
||||
result = await overseas_broker.get_overseas_price("NASD", "AAPL")
|
||||
assert result["output"]["last"] == "150.00"
|
||||
|
||||
call_args = mock_session.get.call_args
|
||||
params = call_args[1]["params"]
|
||||
assert params["EXCD"] == "NASD"
|
||||
assert params["SYMB"] == "AAPL"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_error_raises(self, overseas_broker: OverseasBroker) -> None:
|
||||
"""Non-200 response should raise ConnectionError."""
|
||||
mock_resp = AsyncMock()
|
||||
mock_resp.status = 400
|
||||
mock_resp.text = AsyncMock(return_value="Bad Request")
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_session.get = MagicMock(return_value=_make_async_cm(mock_resp))
|
||||
|
||||
_setup_broker_mocks(overseas_broker, mock_session)
|
||||
|
||||
with pytest.raises(ConnectionError, match="get_overseas_price failed"):
|
||||
await overseas_broker.get_overseas_price("NASD", "AAPL")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_network_error_raises(self, overseas_broker: OverseasBroker) -> None:
|
||||
"""Network error should raise ConnectionError."""
|
||||
cm = MagicMock()
|
||||
cm.__aenter__ = AsyncMock(side_effect=aiohttp.ClientError("conn refused"))
|
||||
cm.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_session.get = MagicMock(return_value=cm)
|
||||
|
||||
_setup_broker_mocks(overseas_broker, mock_session)
|
||||
|
||||
with pytest.raises(ConnectionError, match="Network error"):
|
||||
await overseas_broker.get_overseas_price("NASD", "AAPL")
|
||||
|
||||
|
||||
class TestGetOverseasBalance:
|
||||
"""Test get_overseas_balance method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_success(self, overseas_broker: OverseasBroker) -> None:
|
||||
"""Successful balance fetch returns JSON data."""
|
||||
mock_resp = AsyncMock()
|
||||
mock_resp.status = 200
|
||||
mock_resp.json = AsyncMock(return_value={"output1": [{"pdno": "AAPL"}]})
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_session.get = MagicMock(return_value=_make_async_cm(mock_resp))
|
||||
|
||||
_setup_broker_mocks(overseas_broker, mock_session)
|
||||
|
||||
result = await overseas_broker.get_overseas_balance("NASD")
|
||||
assert result["output1"][0]["pdno"] == "AAPL"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_error_raises(self, overseas_broker: OverseasBroker) -> None:
|
||||
"""Non-200 should raise ConnectionError."""
|
||||
mock_resp = AsyncMock()
|
||||
mock_resp.status = 500
|
||||
mock_resp.text = AsyncMock(return_value="Server Error")
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_session.get = MagicMock(return_value=_make_async_cm(mock_resp))
|
||||
|
||||
_setup_broker_mocks(overseas_broker, mock_session)
|
||||
|
||||
with pytest.raises(ConnectionError, match="get_overseas_balance failed"):
|
||||
await overseas_broker.get_overseas_balance("NASD")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_network_error_raises(self, overseas_broker: OverseasBroker) -> None:
|
||||
"""Network error should raise ConnectionError."""
|
||||
cm = MagicMock()
|
||||
cm.__aenter__ = AsyncMock(side_effect=TimeoutError("timeout"))
|
||||
cm.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_session.get = MagicMock(return_value=cm)
|
||||
|
||||
_setup_broker_mocks(overseas_broker, mock_session)
|
||||
|
||||
with pytest.raises(ConnectionError, match="Network error"):
|
||||
await overseas_broker.get_overseas_balance("NYSE")
|
||||
|
||||
|
||||
class TestSendOverseasOrder:
|
||||
"""Test send_overseas_order method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_buy_market_order(self, overseas_broker: OverseasBroker) -> None:
|
||||
"""Market buy order should use VTTT1002U and ORD_DVSN=01."""
|
||||
mock_resp = AsyncMock()
|
||||
mock_resp.status = 200
|
||||
mock_resp.json = AsyncMock(return_value={"rt_cd": "0"})
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_session.post = MagicMock(return_value=_make_async_cm(mock_resp))
|
||||
|
||||
_setup_broker_mocks(overseas_broker, mock_session)
|
||||
overseas_broker._broker._get_hash_key = AsyncMock(return_value="hashval")
|
||||
|
||||
result = await overseas_broker.send_overseas_order("NASD", "AAPL", "BUY", 10)
|
||||
assert result["rt_cd"] == "0"
|
||||
|
||||
# Verify BUY TR_ID
|
||||
overseas_broker._broker._auth_headers.assert_called_with("VTTT1002U")
|
||||
|
||||
call_args = mock_session.post.call_args
|
||||
body = call_args[1]["json"]
|
||||
assert body["ORD_DVSN"] == "01" # market order
|
||||
assert body["OVRS_ORD_UNPR"] == "0"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sell_limit_order(self, overseas_broker: OverseasBroker) -> None:
|
||||
"""Limit sell order should use VTTT1006U and ORD_DVSN=00."""
|
||||
mock_resp = AsyncMock()
|
||||
mock_resp.status = 200
|
||||
mock_resp.json = AsyncMock(return_value={"rt_cd": "0"})
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_session.post = MagicMock(return_value=_make_async_cm(mock_resp))
|
||||
|
||||
_setup_broker_mocks(overseas_broker, mock_session)
|
||||
overseas_broker._broker._get_hash_key = AsyncMock(return_value="hashval")
|
||||
|
||||
result = await overseas_broker.send_overseas_order("NYSE", "MSFT", "SELL", 5, price=350.0)
|
||||
assert result["rt_cd"] == "0"
|
||||
|
||||
overseas_broker._broker._auth_headers.assert_called_with("VTTT1006U")
|
||||
|
||||
call_args = mock_session.post.call_args
|
||||
body = call_args[1]["json"]
|
||||
assert body["ORD_DVSN"] == "00" # limit order
|
||||
assert body["OVRS_ORD_UNPR"] == "350.0"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_order_http_error_raises(self, overseas_broker: OverseasBroker) -> None:
|
||||
"""Non-200 should raise ConnectionError."""
|
||||
mock_resp = AsyncMock()
|
||||
mock_resp.status = 400
|
||||
mock_resp.text = AsyncMock(return_value="Bad Request")
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_session.post = MagicMock(return_value=_make_async_cm(mock_resp))
|
||||
|
||||
_setup_broker_mocks(overseas_broker, mock_session)
|
||||
overseas_broker._broker._get_hash_key = AsyncMock(return_value="hashval")
|
||||
|
||||
with pytest.raises(ConnectionError, match="send_overseas_order failed"):
|
||||
await overseas_broker.send_overseas_order("NASD", "AAPL", "BUY", 1)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_order_network_error_raises(self, overseas_broker: OverseasBroker) -> None:
|
||||
"""Network error should raise ConnectionError."""
|
||||
cm = MagicMock()
|
||||
cm.__aenter__ = AsyncMock(side_effect=aiohttp.ClientError("conn reset"))
|
||||
cm.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_session.post = MagicMock(return_value=cm)
|
||||
|
||||
_setup_broker_mocks(overseas_broker, mock_session)
|
||||
overseas_broker._broker._get_hash_key = AsyncMock(return_value="hashval")
|
||||
|
||||
with pytest.raises(ConnectionError, match="Network error"):
|
||||
await overseas_broker.send_overseas_order("NASD", "TSLA", "SELL", 2)
|
||||
|
||||
|
||||
class TestGetCurrencyCode:
|
||||
"""Test _get_currency_code mapping."""
|
||||
|
||||
def test_us_exchanges(self, overseas_broker: OverseasBroker) -> None:
|
||||
assert overseas_broker._get_currency_code("NASD") == "USD"
|
||||
assert overseas_broker._get_currency_code("NYSE") == "USD"
|
||||
assert overseas_broker._get_currency_code("AMEX") == "USD"
|
||||
|
||||
def test_japan(self, overseas_broker: OverseasBroker) -> None:
|
||||
assert overseas_broker._get_currency_code("TSE") == "JPY"
|
||||
|
||||
def test_hong_kong(self, overseas_broker: OverseasBroker) -> None:
|
||||
assert overseas_broker._get_currency_code("SEHK") == "HKD"
|
||||
|
||||
def test_china(self, overseas_broker: OverseasBroker) -> None:
|
||||
assert overseas_broker._get_currency_code("SHAA") == "CNY"
|
||||
assert overseas_broker._get_currency_code("SZAA") == "CNY"
|
||||
|
||||
def test_vietnam(self, overseas_broker: OverseasBroker) -> None:
|
||||
assert overseas_broker._get_currency_code("HNX") == "VND"
|
||||
assert overseas_broker._get_currency_code("HSX") == "VND"
|
||||
|
||||
def test_unknown_defaults_usd(self, overseas_broker: OverseasBroker) -> None:
|
||||
assert overseas_broker._get_currency_code("UNKNOWN") == "USD"
|
||||
|
||||
|
||||
class TestExtractRankingRows:
|
||||
"""Test _extract_ranking_rows helper."""
|
||||
|
||||
def test_output_key(self, overseas_broker: OverseasBroker) -> None:
|
||||
data = {"output": [{"a": 1}, {"b": 2}]}
|
||||
assert overseas_broker._extract_ranking_rows(data) == [{"a": 1}, {"b": 2}]
|
||||
|
||||
def test_output1_key(self, overseas_broker: OverseasBroker) -> None:
|
||||
data = {"output1": [{"c": 3}]}
|
||||
assert overseas_broker._extract_ranking_rows(data) == [{"c": 3}]
|
||||
|
||||
def test_output2_key(self, overseas_broker: OverseasBroker) -> None:
|
||||
data = {"output2": [{"d": 4}]}
|
||||
assert overseas_broker._extract_ranking_rows(data) == [{"d": 4}]
|
||||
|
||||
def test_no_list_returns_empty(self, overseas_broker: OverseasBroker) -> None:
|
||||
data = {"output": "not a list"}
|
||||
assert overseas_broker._extract_ranking_rows(data) == []
|
||||
|
||||
def test_empty_data(self, overseas_broker: OverseasBroker) -> None:
|
||||
assert overseas_broker._extract_ranking_rows({}) == []
|
||||
|
||||
def test_filters_non_dict_rows(self, overseas_broker: OverseasBroker) -> None:
|
||||
data = {"output": [{"a": 1}, "invalid", {"b": 2}]}
|
||||
assert overseas_broker._extract_ranking_rows(data) == [{"a": 1}, {"b": 2}]
|
||||
Reference in New Issue
Block a user