Files
TIANHE 79b196bcf4 v5.0.1
Signed-off-by: TIANHE <TIANHE@GMAIL.COM>
2026-07-18 17:43:02 +08:00

164 lines
6.0 KiB
Python

"""Unit tests for the MOEX (Moscow Exchange) data source.
These tests do not hit the real ISS API — they mock the HTTP layer.
A separate verification script (scripts/verify_moex.py) exercises the live API.
"""
from __future__ import annotations
from unittest.mock import patch
import pytest
from app.data_sources.factory import DataSourceFactory
from app.data_sources.moex import DEFAULT_BOARD, INTERVAL_MAP, MOEXDataSource
def _candle_payload(rows):
"""Build a minimal MOEX ISS /candles.json payload."""
return {
"candles": {
"columns": ["open", "close", "high", "low", "value", "volume", "begin", "end"],
"data": rows,
}
}
def test_factory_recognizes_moex():
assert DataSourceFactory.normalize_market("moex") == "MOEX"
assert DataSourceFactory.normalize_market("MOEX") == "MOEX"
assert DataSourceFactory.normalize_market("RuStocks") == "MOEX"
src = DataSourceFactory.get_source("MOEX")
assert isinstance(src, MOEXDataSource)
def test_normalize_symbol_strips_suffixes():
assert MOEXDataSource._normalize_symbol("sber") == "SBER"
assert MOEXDataSource._normalize_symbol("SBER.ME") == "SBER"
assert MOEXDataSource._normalize_symbol("gazp.MOEX") == "GAZP"
assert MOEXDataSource._normalize_symbol("LKOH:MOEX") == "LKOH"
assert MOEXDataSource._normalize_symbol(" SBER ") == "SBER"
def test_normalize_symbol_rejects_path_injection():
assert MOEXDataSource._normalize_symbol("../etc") == ""
assert MOEXDataSource._normalize_symbol("SBER/GMKN") == ""
assert MOEXDataSource._normalize_symbol("SB%ER") == ""
assert MOEXDataSource._normalize_symbol("a" * 40) == ""
def test_invalid_board_falls_back_to_default():
src = MOEXDataSource(board="bad!!!")
assert src.board == DEFAULT_BOARD
def test_interval_map_covers_all_quantdinger_timeframes():
expected = {"1m", "5m", "15m", "30m", "1H", "4H", "1D", "1W"}
assert expected.issubset(set(INTERVAL_MAP.keys()))
# Native ISS intervals
assert INTERVAL_MAP["1m"] == 1
assert INTERVAL_MAP["1H"] == 60
assert INTERVAL_MAP["1D"] == 24
assert INTERVAL_MAP["1W"] == 7
def test_moex_dt_to_unix_treats_naive_as_moscow():
# MSK is UTC+3 (year-round, no DST since 2014).
# 2025-01-10 12:00:00 MSK == 2025-01-10 09:00:00 UTC == 1736499600
ts = MOEXDataSource._moex_dt_to_unix("2025-01-10 12:00:00")
assert ts == 1736499600
def test_get_kline_native_daily_parses_payload():
src = MOEXDataSource()
rows = [
# open, close, high, low, value, volume, begin, end
[100.0, 101.0, 102.0, 99.0, 1000.0, 500.0, "2025-01-10 00:00:00", "2025-01-10 23:59:59"],
[101.0, 103.0, 104.0, 100.5, 1500.0, 700.0, "2025-01-13 00:00:00", "2025-01-13 23:59:59"],
[103.0, 102.0, 103.5, 101.0, 1100.0, 400.0, "2025-01-14 00:00:00", "2025-01-14 23:59:59"],
]
with patch.object(src, "_http_get", return_value=_candle_payload(rows)):
out = src.get_kline("SBER", "1D", limit=10)
assert len(out) == 3
assert all({"time", "open", "high", "low", "close", "volume"} <= set(k.keys()) for k in out)
# Sorted ascending by time
assert out[0]["time"] < out[1]["time"] < out[2]["time"]
# First candle: 2025-01-10 00:00:00 MSK == 2025-01-09 21:00:00 UTC
assert out[0]["open"] == 100.0 and out[0]["close"] == 101.0
def test_get_kline_resamples_15m_from_1m():
src = MOEXDataSource()
# Build 30 contiguous 1-minute candles starting at 2025-01-10 10:00:00 MSK
base = MOEXDataSource._moex_dt_to_unix("2025-01-10 10:00:00")
rows = []
for i in range(30):
ts = base + i * 60
# Construct begin string in MSK local time
from datetime import datetime, timedelta, timezone
msk = timezone(timedelta(hours=3))
begin = datetime.fromtimestamp(ts, tz=msk).strftime("%Y-%m-%d %H:%M:%S")
end = datetime.fromtimestamp(ts + 59, tz=msk).strftime("%Y-%m-%d %H:%M:%S")
rows.append([
100.0 + i, 100.5 + i, 101.0 + i, 99.5 + i, 10.0, 5.0, begin, end
])
with patch.object(src, "_http_get", return_value=_candle_payload(rows)):
out = src.get_kline("SBER", "15m", limit=10)
# 30 minutes resampled into 15m buckets should yield exactly 2 bars
assert len(out) == 2
# Each 15m bar aggregates 15 1m bars
assert out[0]["volume"] == round(5.0 * 15, 2)
# Open of first bucket == open of first 1m candle; close == close of 15th
assert out[0]["open"] == 100.0
assert out[0]["close"] == 100.5 + 14
def test_get_kline_returns_empty_on_http_failure():
src = MOEXDataSource()
with patch.object(src, "_http_get", return_value=None):
out = src.get_kline("UNKNOWN", "1D", limit=5)
assert out == []
def test_get_kline_unsupported_timeframe_falls_back_to_daily():
src = MOEXDataSource()
rows = [
[100.0, 101.0, 102.0, 99.0, 1000.0, 500.0, "2025-01-10 00:00:00", "2025-01-10 23:59:59"],
]
with patch.object(src, "_http_get", return_value=_candle_payload(rows)):
out = src.get_kline("SBER", "2D", limit=5)
assert len(out) == 1
def test_resample_handles_empty():
assert MOEXDataSource._resample([], 900) == []
def test_get_ticker_parses_marketdata():
src = MOEXDataSource()
payload = {
"marketdata": {
"columns": ["LAST", "LCLOSEPRICE", "OPEN", "HIGH", "LOW"],
"data": [[310.5, 305.0, 306.0, 312.0, 304.0]],
}
}
with patch.object(src, "_http_get", return_value=payload):
t = src.get_ticker("SBER")
assert t["last"] == 310.5
assert t["previousClose"] == 305.0
assert t["change"] == 5.5
# 5.5 / 305 * 100 ≈ 1.8
assert abs(t["changePercent"] - 1.8) < 0.05
def test_live_trading_blocked_for_moex():
"""The canonical broker policy must reject MOEX for live trading."""
from app.services.broker_market_policy import validate_strategy_config
with pytest.raises(ValueError, match="MOEX.*analysis-only"):
validate_strategy_config(
exchange_id="",
market_category="MOEX",
require_exchange=False,
)