File size: 7,129 Bytes
2e658e7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
"""Focused tests for the DS4 merge-nesting fix in dual_datasource_client.py.

Verifies: (1) DS4-owned fields are read from ds4["data"], not ds4 top level;
(2) DS2 fallback still fires only when a DS4 data field is genuinely
missing; (3) DS2 still cannot override a present/valid DS4 field once read
from the correct nested path; (4) top-level metadata (dataState,
noTradeGuard) is unaffected by the fix, since it was never part of the bug.
Mocked HTTP only -- no live network calls.
"""
import asyncio
import os
import sys
from datetime import datetime, timezone
import pytest

sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))  # hermes_overlay/

import trading.dual_datasource_client as ddc


@pytest.fixture(autouse=True)
def _disable_live_binance(monkeypatch):
    async def _unavailable(symbol, needed):
        return {}, []
    monkeypatch.setattr(ddc.binance_public, "get_binance_public_snapshot", _unavailable)


class _FakeResponse:
    def __init__(self, payload, status_code=200):
        self._payload = payload
        self.status_code = status_code

    def raise_for_status(self):
        pass

    def json(self):
        return self._payload


def _make_fake_client(ds4_payload, ds2_payloads=None, ds4_raises=False):
    ds2_payloads = ds2_payloads or {}

    class _FakeClient:
        async def __aenter__(self):
            return self

        async def __aexit__(self, *a):
            return False

        async def get(self, url, timeout=None, **_kwargs):
            if "short-hunter/snapshot" in url:
                if ds4_raises:
                    raise RuntimeError("boom")
                return _FakeResponse(ds4_payload)
            for key, payload in ds2_payloads.items():
                if key in url:
                    return _FakeResponse(payload)
            return _FakeResponse({})

    return _FakeClient()

def _nested_ds4_payload(**overrides):
    """A realistic DS4 envelope: metadata top-level, market fields under data."""
    base = {
        "success": True,
        "symbol": "BTCUSDT",
        "dataState": "live",
        "noTradeGuard": False,
        "timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
        "warnings": [],
        "errors": [],
        "data": {
            # contractType/status let _contract_verification() mark this a
            # verified Futures instrument (see docs/DATA_PIPELINE_AND_CONTRACTS.md
            # and docs/DEVELOPER_GUIDE.md: DS4 owns Futures verification, and a
            # bare {"symbol": ...} is deliberately treated as unverified).
            "contract": {"symbol": "XBTUSDTM", "contractType": "PERPETUAL", "status": "TRADING"},
            "ticker": {"lastPrice": 64364.4, "change24h": None},
            "ohlcv": [
                {"timestamp": i, "open": 64360, "high": 64370, "low": 64350,
                 "close": 64364.4, "volume": 5}
                for i in range(4)
            ],
            "orderbook": {"bids": [[64364.4, 1]], "asks": [[64364.5, 1]]},
            "funding": {"currentFundingRate": -8.2e-05},
            "openInterest": {"openInterest": 27000928.0, "history": []},
            "indicators": {"rsi14": 49.3},
            "sentiment": None,
        },
    }
    base.update(overrides)
    return base


def test_merge_reads_futures_fields_from_nested_data(monkeypatch):
    payload = _nested_ds4_payload()
    monkeypatch.setattr(ddc.httpx, "AsyncClient", lambda *a, **kw: _make_fake_client(payload))
    result = asyncio.run(ddc.get_market_context("BTCUSDT"))
    merged = result["merged"]

    assert merged["ticker"] == {"lastPrice": 64364.4, "change24h": None}
    assert merged["funding"] == {"currentFundingRate": -8.2e-05}
    assert merged["openInterest"] == {"openInterest": 27000928.0, "history": []}
    assert merged["orderbook"] == {"bids": [[64364.4, 1]], "asks": [[64364.5, 1]]}
    assert merged["contract"] == {
        "symbol": "XBTUSDTM",
        "contractType": "PERPETUAL",
        "status": "TRADING",
    }
    assert result["sources"]["ticker"] == "datasource4"
    assert result["sources"]["funding"] == "datasource4"
    assert result["noTradeGuard"] is False


def test_ds2_fallback_fires_only_for_genuinely_missing_nested_field(monkeypatch):
    payload = _nested_ds4_payload()
    payload["data"]["orderbook"] = None  # DS4 genuinely missing this field
    ds2_payloads = {"trading/orderbook": {"bids": [[1, 1]], "asks": [[1, 1]]}}
    monkeypatch.setattr(
        ddc.httpx, "AsyncClient", lambda *a, **kw: _make_fake_client(payload, ds2_payloads)
    )
    result = asyncio.run(ddc.get_market_context("BTCUSDT"))
    merged = result["merged"]

    assert merged["orderbook"] == {"bids": [[1, 1]], "asks": [[1, 1]]}
    assert result["sources"]["orderbook"] == "datasource2"
    assert any("orderbook" in w and "Datasource 2 fallback" in w for w in result["warnings"])
    # untouched DS4-owned fields still come from DS4, not DS2
    assert result["sources"]["ticker"] == "datasource4"


def test_ds2_cannot_override_valid_nested_ds4_orderbook(monkeypatch):
    payload = _nested_ds4_payload()  # DS4 orderbook present and valid
    ds2_payloads = {"trading/orderbook": {"bids": [[999, 1]], "asks": [[999, 1]]}}
    monkeypatch.setattr(
        ddc.httpx, "AsyncClient", lambda *a, **kw: _make_fake_client(payload, ds2_payloads)
    )
    result = asyncio.run(ddc.get_market_context("BTCUSDT"))
    merged = result["merged"]

    assert merged["orderbook"] == {"bids": [[64364.4, 1]], "asks": [[64364.5, 1]]}
    assert result["sources"]["orderbook"] == "datasource4"


def test_ds4_data_key_missing_entirely_is_treated_as_no_data(monkeypatch):
    payload = {
        "success": False, "symbol": "BTCUSDT", "dataState": "PARTIAL",
        "noTradeGuard": False, "timestamp": "x", "warnings": [], "errors": [],
        # no "data" key at all -- malformed response
    }
    monkeypatch.setattr(ddc.httpx, "AsyncClient", lambda *a, **kw: _make_fake_client(payload))
    result = asyncio.run(ddc.get_market_context("BTCUSDT"))

    assert result["sources"]["ticker"] == "unavailable"
    assert result["merged"]["ticker"] is None
    assert result["noTradeGuard"] is True  # missing critical fields after merge
    assert any("Missing required Futures fields" in r for r in result["noTradeReasons"])


def test_top_level_metadata_unaffected_by_nesting_fix(monkeypatch):
    """dataState/noTradeGuard were never part of the bug -- must stay top-level reads."""
    payload = _nested_ds4_payload(dataState="PARTIAL")
    monkeypatch.setattr(ddc.httpx, "AsyncClient", lambda *a, **kw: _make_fake_client(payload))
    result = asyncio.run(ddc.get_market_context("BTCUSDT"))

    assert result["noTradeGuard"] is True
    assert any("dataState='PARTIAL'" in r for r in result["noTradeReasons"])
    # Stale DS4 data remains visible in primary for diagnostics, but is not
    # silently promoted into the merged current-market view.
    assert result["primary"]["data"]["ticker"] == {"lastPrice": 64364.4, "change24h": None}
    assert result["merged"]["ticker"] is None
    assert result["sources"]["ticker"] == "unavailable"