mirror of
https://github.com/OpenByteInc/QuantDinger.git
synced 2026-09-28 15:22:56 +08:00
164 lines
6.0 KiB
Python
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,
|
|
)
|