Qubx 0.6.71__cp312-cp312-manylinux_2_39_x86_64.whl → 0.6.72__cp312-cp312-manylinux_2_39_x86_64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of Qubx might be problematic. Click here for more details.
- qubx/backtester/runner.py +6 -4
- qubx/connectors/ccxt/adapters/polling_adapter.py +154 -343
- qubx/connectors/ccxt/exchanges/__init__.py +26 -0
- qubx/connectors/ccxt/exchanges/binance/exchange.py +80 -65
- qubx/connectors/ccxt/exchanges/hyperliquid/__init__.py +3 -1
- qubx/connectors/ccxt/exchanges/hyperliquid/broker.py +69 -0
- qubx/connectors/ccxt/exchanges/hyperliquid/hyperliquid.py +385 -26
- qubx/connectors/ccxt/factory.py +4 -0
- qubx/connectors/ccxt/handlers/base.py +2 -1
- qubx/connectors/ccxt/reader.py +181 -69
- qubx/connectors/ccxt/utils.py +50 -26
- qubx/core/basics.py +6 -0
- qubx/core/series.cpython-312-x86_64-linux-gnu.so +0 -0
- qubx/core/utils.cpython-312-x86_64-linux-gnu.so +0 -0
- qubx/ta/indicators.cpython-312-x86_64-linux-gnu.so +0 -0
- qubx/trackers/advanced.py +41 -4
- {qubx-0.6.71.dist-info → qubx-0.6.72.dist-info}/METADATA +1 -1
- {qubx-0.6.71.dist-info → qubx-0.6.72.dist-info}/RECORD +21 -20
- {qubx-0.6.71.dist-info → qubx-0.6.72.dist-info}/LICENSE +0 -0
- {qubx-0.6.71.dist-info → qubx-0.6.72.dist-info}/WHEEL +0 -0
- {qubx-0.6.71.dist-info → qubx-0.6.72.dist-info}/entry_points.txt +0 -0
qubx/backtester/runner.py
CHANGED
|
@@ -4,10 +4,9 @@ import numpy as np
|
|
|
4
4
|
import pandas as pd
|
|
5
5
|
from tqdm.auto import tqdm
|
|
6
6
|
|
|
7
|
-
from qubx import logger
|
|
7
|
+
from qubx import QubxLogConfig, logger
|
|
8
8
|
from qubx.backtester.sentinels import NoDataContinue
|
|
9
9
|
from qubx.backtester.simulated_data import IterableSimulationData
|
|
10
|
-
from qubx.backtester.utils import SimulationDataConfig, TimeGuardedWrapper
|
|
11
10
|
from qubx.core.account import CompositeAccountProcessor
|
|
12
11
|
from qubx.core.basics import SW, DataType, Instrument, TransactionCostsCalculator
|
|
13
12
|
from qubx.core.context import StrategyContext
|
|
@@ -41,6 +40,7 @@ from .utils import (
|
|
|
41
40
|
SimulatedTimeProvider,
|
|
42
41
|
SimulationDataConfig,
|
|
43
42
|
SimulationSetup,
|
|
43
|
+
TimeGuardedWrapper,
|
|
44
44
|
)
|
|
45
45
|
|
|
46
46
|
|
|
@@ -356,8 +356,9 @@ class SimulationRunner:
|
|
|
356
356
|
return False # No scheduled events, stop simulation
|
|
357
357
|
|
|
358
358
|
def print_latency_report(self) -> None:
|
|
359
|
-
_l_r
|
|
360
|
-
|
|
359
|
+
if (_l_r := SW.latency_report()) is not None:
|
|
360
|
+
_llvl = QubxLogConfig.get_log_level()
|
|
361
|
+
QubxLogConfig.set_log_level("INFO")
|
|
361
362
|
logger.info(
|
|
362
363
|
"<BLUE> Time spent in simulation report </BLUE>\n<r>"
|
|
363
364
|
+ _frame_to_str(
|
|
@@ -365,6 +366,7 @@ class SimulationRunner:
|
|
|
365
366
|
)
|
|
366
367
|
+ "</r>"
|
|
367
368
|
)
|
|
369
|
+
QubxLogConfig.set_log_level(_llvl)
|
|
368
370
|
|
|
369
371
|
def _create_backtest_context(self) -> IStrategyContext:
|
|
370
372
|
logger.debug(
|
|
@@ -1,224 +1,206 @@
|
|
|
1
1
|
"""
|
|
2
|
-
|
|
2
|
+
Simplified polling adapter to convert CCXT fetch_* methods into watch_* behavior.
|
|
3
3
|
|
|
4
|
-
This adapter
|
|
5
|
-
|
|
4
|
+
This adapter provides a much simpler approach:
|
|
5
|
+
- No background tasks or queues
|
|
6
|
+
- get_next_data() waits until it's time to poll, then polls synchronously
|
|
7
|
+
- Time-aligned polling (e.g., 11:30, 11:35, 11:40 for 5-minute intervals)
|
|
8
|
+
- Immediate polling when symbols change
|
|
6
9
|
"""
|
|
7
10
|
|
|
8
11
|
import asyncio
|
|
9
|
-
import
|
|
12
|
+
import math
|
|
10
13
|
import time
|
|
11
|
-
from
|
|
14
|
+
from dataclasses import dataclass
|
|
12
15
|
from typing import Any, Callable, Dict, List, Optional, Set
|
|
13
16
|
|
|
14
17
|
from qubx import logger
|
|
15
|
-
|
|
18
|
+
|
|
19
|
+
# Constants
|
|
20
|
+
DEFAULT_POLL_INTERVAL = 300 # 5 minutes
|
|
21
|
+
MIN_POLL_INTERVAL = 1 # 1 second minimum
|
|
22
|
+
MAX_POLL_INTERVAL = 3600 # 1 hour maximum
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class PollingConfig:
|
|
27
|
+
"""Configuration for polling adapter."""
|
|
28
|
+
|
|
29
|
+
poll_interval_seconds: float = DEFAULT_POLL_INTERVAL
|
|
30
|
+
|
|
31
|
+
def __post_init__(self):
|
|
32
|
+
"""Validate configuration after initialization."""
|
|
33
|
+
if not MIN_POLL_INTERVAL <= self.poll_interval_seconds <= MAX_POLL_INTERVAL:
|
|
34
|
+
raise ValueError(
|
|
35
|
+
f"poll_interval_seconds must be between {MIN_POLL_INTERVAL} and {MAX_POLL_INTERVAL}, "
|
|
36
|
+
f"got {self.poll_interval_seconds}"
|
|
37
|
+
)
|
|
16
38
|
|
|
17
39
|
|
|
18
40
|
class PollingToWebSocketAdapter:
|
|
19
41
|
"""
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
-
|
|
24
|
-
-
|
|
25
|
-
-
|
|
26
|
-
- Thread-safe
|
|
27
|
-
- Configurable polling intervals
|
|
42
|
+
Simplified polling adapter that polls synchronously when data is requested.
|
|
43
|
+
|
|
44
|
+
Key features:
|
|
45
|
+
- No background tasks or queues
|
|
46
|
+
- Time-aligned polling (respects clock boundaries)
|
|
47
|
+
- Immediate polling when symbols change
|
|
48
|
+
- Thread-safe symbol management
|
|
28
49
|
"""
|
|
29
50
|
|
|
30
51
|
def __init__(
|
|
31
52
|
self,
|
|
32
53
|
fetch_method: Callable,
|
|
33
|
-
poll_interval_seconds: int = 300, # 5 minutes default
|
|
34
54
|
symbols: Optional[List[str]] = None,
|
|
35
55
|
params: Optional[Dict[str, Any]] = None,
|
|
36
|
-
|
|
37
|
-
boundary_tolerance_seconds: int = 30,
|
|
38
|
-
event_loop: Optional[asyncio.AbstractEventLoop] = None, # Optional explicit loop
|
|
56
|
+
config: Optional[PollingConfig] = None,
|
|
39
57
|
):
|
|
40
58
|
"""
|
|
41
|
-
Initialize the polling adapter.
|
|
59
|
+
Initialize the simplified polling adapter.
|
|
42
60
|
|
|
43
61
|
Args:
|
|
44
|
-
fetch_method: The CCXT fetch_* method to call
|
|
45
|
-
poll_interval_seconds: How often to poll in seconds (default: 300 = 5 minutes)
|
|
62
|
+
fetch_method: The CCXT fetch_* method to call
|
|
46
63
|
symbols: Initial list of symbols to watch
|
|
47
64
|
params: Additional parameters for fetch_method
|
|
48
|
-
|
|
49
|
-
boundary_tolerance_seconds: Tolerance for boundary alignment (only used if use_time_boundaries=True)
|
|
50
|
-
event_loop: Optional explicit asyncio event loop to use for background tasks
|
|
65
|
+
config: PollingConfig instance (uses default if None)
|
|
51
66
|
"""
|
|
67
|
+
self.config = config if config is not None else PollingConfig()
|
|
52
68
|
self.fetch_method = fetch_method
|
|
53
|
-
self.poll_interval_seconds = poll_interval_seconds
|
|
54
69
|
self.params = params or {}
|
|
55
|
-
self.adapter_id = f"polling_adapter_{id(self)}"
|
|
56
|
-
self.use_time_boundaries = use_time_boundaries
|
|
57
|
-
self.boundary_tolerance_seconds = boundary_tolerance_seconds
|
|
58
|
-
self.event_loop = event_loop # Store explicit loop if provided
|
|
70
|
+
self.adapter_id = f"polling_adapter_{id(self)}"
|
|
59
71
|
|
|
60
72
|
# Thread-safe symbol management
|
|
61
73
|
self._symbols_lock = asyncio.Lock()
|
|
62
74
|
self._symbols: Set[str] = set(symbols or [])
|
|
63
75
|
|
|
64
|
-
# Polling state
|
|
65
|
-
self.
|
|
66
|
-
self.
|
|
67
|
-
self._is_running = False
|
|
76
|
+
# Polling state
|
|
77
|
+
self._last_poll_time: Optional[float] = None
|
|
78
|
+
self._symbols_changed = False # Flag to trigger immediate poll
|
|
68
79
|
|
|
69
|
-
# Statistics
|
|
80
|
+
# Statistics
|
|
70
81
|
self._poll_count = 0
|
|
71
82
|
self._error_count = 0
|
|
72
|
-
self._last_poll_time: Optional[float] = None
|
|
73
83
|
|
|
74
|
-
|
|
75
|
-
self._data_queue: asyncio.Queue = asyncio.Queue()
|
|
76
|
-
self._latest_data: Optional[Dict[str, Any]] = None
|
|
77
|
-
self._data_condition = asyncio.Condition()
|
|
78
|
-
|
|
79
|
-
async def start_watching(self) -> None:
|
|
84
|
+
async def get_next_data(self) -> Dict[str, Any]:
|
|
80
85
|
"""
|
|
81
|
-
|
|
86
|
+
Get the next available data by waiting until it's time to poll, then polling.
|
|
82
87
|
|
|
83
|
-
This method
|
|
84
|
-
|
|
88
|
+
This method:
|
|
89
|
+
1. Checks if symbols changed (immediate poll)
|
|
90
|
+
2. Calculates when next poll should happen based on time alignment
|
|
91
|
+
3. Waits until that time
|
|
92
|
+
4. Polls and returns fresh data
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
Dictionary containing fetched data for symbols
|
|
85
96
|
"""
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
97
|
+
async with self._symbols_lock:
|
|
98
|
+
current_symbols = list(self._symbols)
|
|
99
|
+
symbols_changed = self._symbols_changed
|
|
89
100
|
|
|
90
|
-
|
|
91
|
-
|
|
101
|
+
if not current_symbols:
|
|
102
|
+
raise ValueError(f"No symbols configured for adapter {self.adapter_id}")
|
|
92
103
|
|
|
93
|
-
|
|
104
|
+
# If symbols changed, poll immediately
|
|
105
|
+
if symbols_changed:
|
|
106
|
+
logger.debug(f"Symbols changed, polling immediately for adapter {self.adapter_id}")
|
|
107
|
+
async with self._symbols_lock:
|
|
108
|
+
self._symbols_changed = False
|
|
109
|
+
return await self._poll_now(current_symbols)
|
|
94
110
|
|
|
95
|
-
#
|
|
96
|
-
|
|
97
|
-
current_symbols = list(self._symbols)
|
|
98
|
-
|
|
99
|
-
if current_symbols:
|
|
100
|
-
try:
|
|
101
|
-
await self._poll_once(current_symbols)
|
|
102
|
-
except Exception as e:
|
|
103
|
-
logger.error(f"Initial poll failed for adapter {self.adapter_id}: {e}")
|
|
104
|
-
|
|
105
|
-
# Start the polling task for subsequent polls
|
|
106
|
-
if self.event_loop is not None:
|
|
107
|
-
# Use explicit loop if provided (for testing environments)
|
|
108
|
-
|
|
109
|
-
# Always use AsyncThreadLoop for cross-thread task submission
|
|
110
|
-
# This handles both same-thread and cross-thread cases properly
|
|
111
|
-
async_loop = AsyncThreadLoop(self.event_loop)
|
|
112
|
-
self._polling_task = async_loop.submit(self._polling_loop())
|
|
113
|
-
else:
|
|
114
|
-
# Use current event loop (normal operation)
|
|
115
|
-
self._polling_task = asyncio.create_task(self._polling_loop())
|
|
111
|
+
# Calculate wait time for next aligned poll
|
|
112
|
+
wait_time = self._calculate_wait_time()
|
|
116
113
|
|
|
117
|
-
|
|
114
|
+
if wait_time > 0:
|
|
115
|
+
logger.debug(f"Waiting {wait_time:.1f}s for next poll cycle for adapter {self.adapter_id}")
|
|
116
|
+
await asyncio.sleep(wait_time)
|
|
117
|
+
|
|
118
|
+
# Time to poll
|
|
119
|
+
logger.debug(f"Polling now for adapter {self.adapter_id}")
|
|
120
|
+
return await self._poll_now(current_symbols)
|
|
121
|
+
|
|
122
|
+
def _calculate_wait_time(self) -> float:
|
|
118
123
|
"""
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
+
Calculate how long to wait until the next aligned poll time.
|
|
125
|
+
|
|
126
|
+
For intervals >= 1 minute: aligns to clock boundaries (11:30, 11:35, 11:40)
|
|
127
|
+
For intervals < 1 minute: uses simple interval-based timing
|
|
128
|
+
|
|
124
129
|
Returns:
|
|
125
|
-
|
|
130
|
+
Number of seconds to wait (0 if should poll now)
|
|
126
131
|
"""
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
await self.start_watching()
|
|
130
|
-
# Give the background task a chance to start and do initial poll
|
|
131
|
-
await asyncio.sleep(0.5)
|
|
132
|
-
|
|
133
|
-
# Wait for new data from polling task
|
|
134
|
-
try:
|
|
135
|
-
# First, check if we have data immediately available
|
|
136
|
-
try:
|
|
137
|
-
data = self._data_queue.get_nowait()
|
|
138
|
-
return data
|
|
139
|
-
except asyncio.QueueEmpty:
|
|
140
|
-
pass
|
|
141
|
-
|
|
142
|
-
# If no immediate data, wait with timeout
|
|
143
|
-
data = await asyncio.wait_for(self._data_queue.get(), timeout=30.0)
|
|
144
|
-
return data
|
|
145
|
-
except asyncio.TimeoutError:
|
|
146
|
-
logger.debug(f"Timeout waiting for data from adapter {self.adapter_id}, falling back to cached data")
|
|
147
|
-
|
|
148
|
-
# If no new data, return latest data if available
|
|
149
|
-
if self._latest_data is not None:
|
|
150
|
-
return self._latest_data
|
|
151
|
-
else:
|
|
152
|
-
# Try a manual poll as fallback - this handles pytest environment issues
|
|
153
|
-
async with self._symbols_lock:
|
|
154
|
-
current_symbols = list(self._symbols)
|
|
155
|
-
if current_symbols:
|
|
156
|
-
try:
|
|
157
|
-
await self._poll_once(current_symbols)
|
|
158
|
-
if self._latest_data is not None:
|
|
159
|
-
return self._latest_data
|
|
160
|
-
except Exception as e:
|
|
161
|
-
logger.error(f"Manual poll failed for adapter {self.adapter_id}: {e}")
|
|
162
|
-
|
|
163
|
-
raise TimeoutError(f"No data available from polling adapter {self.adapter_id}")
|
|
132
|
+
current_time = time.time()
|
|
133
|
+
interval_seconds = self.config.poll_interval_seconds
|
|
164
134
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
135
|
+
# First poll is always immediate
|
|
136
|
+
if self._last_poll_time is None:
|
|
137
|
+
return 0
|
|
138
|
+
|
|
139
|
+
if interval_seconds >= 60:
|
|
140
|
+
# Time-aligned polling for intervals >= 1 minute using UTC
|
|
141
|
+
# Calculate next boundary based on seconds since epoch
|
|
142
|
+
next_boundary = math.ceil(current_time / interval_seconds) * interval_seconds
|
|
143
|
+
wait_time = next_boundary - current_time
|
|
144
|
+
return max(0, wait_time)
|
|
145
|
+
else:
|
|
146
|
+
# Simple interval-based polling for sub-minute intervals
|
|
147
|
+
next_poll_time = self._last_poll_time + interval_seconds
|
|
148
|
+
wait_time = next_poll_time - current_time
|
|
149
|
+
return max(0, wait_time)
|
|
150
|
+
|
|
151
|
+
async def _poll_now(self, symbols: List[str]) -> Dict[str, Any]:
|
|
168
152
|
"""
|
|
169
|
-
|
|
170
|
-
return
|
|
153
|
+
Perform a poll operation immediately.
|
|
171
154
|
|
|
172
|
-
|
|
155
|
+
Args:
|
|
156
|
+
symbols: List of symbols to poll for
|
|
173
157
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
158
|
+
Returns:
|
|
159
|
+
Dictionary containing fetched data for symbols
|
|
160
|
+
"""
|
|
161
|
+
self._poll_count += 1
|
|
162
|
+
self._last_poll_time = time.time()
|
|
177
163
|
|
|
178
|
-
|
|
179
|
-
while not self._data_queue.empty():
|
|
180
|
-
try:
|
|
181
|
-
self._data_queue.get_nowait()
|
|
182
|
-
except asyncio.QueueEmpty:
|
|
183
|
-
break
|
|
164
|
+
logger.debug(f"Polling {len(symbols)} symbols for adapter {self.adapter_id}")
|
|
184
165
|
|
|
185
|
-
|
|
166
|
+
try:
|
|
167
|
+
# Filter out adapter-specific parameters
|
|
168
|
+
adapter_params = {"pollInterval", "interval", "updateInterval", "poll_interval_minutes"}
|
|
169
|
+
fetch_params = {k: v for k, v in self.params.items() if k not in adapter_params}
|
|
186
170
|
|
|
187
|
-
|
|
171
|
+
# Call the fetch method
|
|
172
|
+
result = await self.fetch_method(symbols, **fetch_params)
|
|
188
173
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
if self._polling_task and not self._polling_task.done():
|
|
192
|
-
try:
|
|
193
|
-
# Handle both Task (normal operation) and Future (explicit event loop) objects
|
|
194
|
-
if hasattr(self._polling_task, 'result') and not hasattr(self._polling_task, '__await__'):
|
|
195
|
-
# This is a Future from AsyncThreadLoop.submit()
|
|
196
|
-
self._polling_task.result(timeout=5.0)
|
|
197
|
-
else:
|
|
198
|
-
# This is a regular Task
|
|
199
|
-
await asyncio.wait_for(self._polling_task, timeout=5.0)
|
|
200
|
-
except (asyncio.TimeoutError, concurrent.futures.TimeoutError):
|
|
201
|
-
logger.debug(f"Polling task for adapter {self.adapter_id} didn't stop gracefully, cancelling")
|
|
202
|
-
self._polling_task.cancel()
|
|
203
|
-
try:
|
|
204
|
-
if hasattr(self._polling_task, 'result') and not hasattr(self._polling_task, '__await__'):
|
|
205
|
-
# Future cancellation is handled by the future itself
|
|
206
|
-
pass
|
|
207
|
-
else:
|
|
208
|
-
# Task cancellation
|
|
209
|
-
await self._polling_task
|
|
210
|
-
except (CancelledError, concurrent.futures.CancelledError):
|
|
211
|
-
pass
|
|
212
|
-
|
|
213
|
-
self._polling_task = None
|
|
174
|
+
logger.debug(f"Poll completed successfully for adapter {self.adapter_id}")
|
|
175
|
+
return result
|
|
214
176
|
|
|
215
|
-
|
|
177
|
+
except Exception as e:
|
|
178
|
+
self._error_count += 1
|
|
179
|
+
logger.error(f"Poll failed for adapter {self.adapter_id}: {e}")
|
|
180
|
+
raise
|
|
181
|
+
|
|
182
|
+
async def update_symbols(self, new_symbols: List[str]) -> None:
|
|
216
183
|
"""
|
|
217
|
-
|
|
184
|
+
Update the symbol list.
|
|
185
|
+
|
|
186
|
+
If symbols changed, the next call to get_next_data() will poll immediately.
|
|
218
187
|
|
|
219
188
|
Args:
|
|
220
|
-
new_symbols:
|
|
189
|
+
new_symbols: New complete list of symbols to watch
|
|
221
190
|
"""
|
|
191
|
+
async with self._symbols_lock:
|
|
192
|
+
old_symbols = self._symbols.copy()
|
|
193
|
+
self._symbols = set(new_symbols or [])
|
|
194
|
+
symbols_changed = old_symbols != self._symbols
|
|
195
|
+
|
|
196
|
+
if symbols_changed:
|
|
197
|
+
self._symbols_changed = True
|
|
198
|
+
logger.debug(
|
|
199
|
+
f"Symbols updated for adapter {self.adapter_id}: {len(old_symbols)} -> {len(self._symbols)}"
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
async def add_symbols(self, new_symbols: List[str]) -> None:
|
|
203
|
+
"""Add symbols to the existing watch list."""
|
|
222
204
|
if not new_symbols:
|
|
223
205
|
return
|
|
224
206
|
|
|
@@ -226,18 +208,13 @@ class PollingToWebSocketAdapter:
|
|
|
226
208
|
before_count = len(self._symbols)
|
|
227
209
|
self._symbols.update(new_symbols)
|
|
228
210
|
after_count = len(self._symbols)
|
|
229
|
-
added_count = after_count - before_count
|
|
230
211
|
|
|
231
|
-
|
|
232
|
-
|
|
212
|
+
if after_count > before_count:
|
|
213
|
+
self._symbols_changed = True
|
|
214
|
+
logger.debug(f"Added {after_count - before_count} symbols to adapter {self.adapter_id}")
|
|
233
215
|
|
|
234
216
|
async def remove_symbols(self, symbols_to_remove: List[str]) -> None:
|
|
235
|
-
"""
|
|
236
|
-
Remove specific symbols from the watch list.
|
|
237
|
-
|
|
238
|
-
Args:
|
|
239
|
-
symbols_to_remove: List of symbols to remove
|
|
240
|
-
"""
|
|
217
|
+
"""Remove symbols from the watch list."""
|
|
241
218
|
if not symbols_to_remove:
|
|
242
219
|
return
|
|
243
220
|
|
|
@@ -245,195 +222,29 @@ class PollingToWebSocketAdapter:
|
|
|
245
222
|
before_count = len(self._symbols)
|
|
246
223
|
self._symbols.difference_update(symbols_to_remove)
|
|
247
224
|
after_count = len(self._symbols)
|
|
248
|
-
removed_count = before_count - after_count
|
|
249
225
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
# If no symbols left, we could optionally stop polling
|
|
254
|
-
# For now, we'll keep polling in case symbols are added back
|
|
255
|
-
|
|
256
|
-
async def update_symbols(self, new_symbols: List[str]) -> None:
|
|
257
|
-
"""
|
|
258
|
-
Replace entire symbol list (atomic operation).
|
|
259
|
-
|
|
260
|
-
Args:
|
|
261
|
-
new_symbols: New complete list of symbols to watch
|
|
262
|
-
"""
|
|
263
|
-
async with self._symbols_lock:
|
|
264
|
-
old_symbols = self._symbols.copy()
|
|
265
|
-
self._symbols = set(new_symbols or [])
|
|
266
|
-
|
|
267
|
-
logger.debug(f"Updated symbols for adapter {self.adapter_id}: {len(old_symbols)} -> {len(self._symbols)}")
|
|
226
|
+
if after_count < before_count:
|
|
227
|
+
self._symbols_changed = True
|
|
228
|
+
logger.debug(f"Removed {before_count - after_count} symbols from adapter {self.adapter_id}")
|
|
268
229
|
|
|
269
230
|
def is_watching(self, symbol: Optional[str] = None) -> bool:
|
|
270
|
-
"""
|
|
271
|
-
Check if adapter has symbols configured to watch.
|
|
272
|
-
|
|
273
|
-
Args:
|
|
274
|
-
symbol: Optional specific symbol to check. If None, checks if has any symbols.
|
|
275
|
-
|
|
276
|
-
Returns:
|
|
277
|
-
True if has the specified symbol (or any symbols if symbol=None)
|
|
278
|
-
"""
|
|
231
|
+
"""Check if adapter has symbols configured to watch."""
|
|
279
232
|
if symbol is None:
|
|
280
233
|
return len(self._symbols) > 0
|
|
281
234
|
else:
|
|
282
235
|
return symbol in self._symbols
|
|
283
236
|
|
|
284
|
-
def is_running(self) -> bool:
|
|
285
|
-
"""
|
|
286
|
-
Check if adapter is actively running (polling).
|
|
287
|
-
|
|
288
|
-
Returns:
|
|
289
|
-
True if adapter is currently running
|
|
290
|
-
"""
|
|
291
|
-
return self._is_running
|
|
292
|
-
|
|
293
237
|
def get_statistics(self) -> Dict[str, Any]:
|
|
294
238
|
"""Get adapter statistics for monitoring."""
|
|
295
239
|
return {
|
|
296
240
|
"adapter_id": self.adapter_id,
|
|
297
|
-
"is_running": self._is_running,
|
|
298
241
|
"symbol_count": len(self._symbols),
|
|
299
242
|
"poll_count": self._poll_count,
|
|
300
243
|
"error_count": self._error_count,
|
|
301
244
|
"last_poll_time": self._last_poll_time,
|
|
302
|
-
"poll_interval_seconds": self.poll_interval_seconds,
|
|
245
|
+
"poll_interval_seconds": self.config.poll_interval_seconds,
|
|
303
246
|
}
|
|
304
247
|
|
|
305
|
-
async def
|
|
306
|
-
"""
|
|
307
|
-
|
|
308
|
-
"""
|
|
309
|
-
# Always do an initial poll immediately
|
|
310
|
-
first_poll = True
|
|
311
|
-
|
|
312
|
-
# Ensure we yield control to allow other tasks to run
|
|
313
|
-
await asyncio.sleep(0)
|
|
314
|
-
|
|
315
|
-
try:
|
|
316
|
-
while not self._stop_event.is_set():
|
|
317
|
-
try:
|
|
318
|
-
# Get current symbols (thread-safe)
|
|
319
|
-
async with self._symbols_lock:
|
|
320
|
-
current_symbols = list(self._symbols)
|
|
321
|
-
|
|
322
|
-
# Skip polling if no symbols
|
|
323
|
-
if not current_symbols:
|
|
324
|
-
await self._cancellable_sleep(10) # Short sleep when no symbols
|
|
325
|
-
continue
|
|
326
|
-
|
|
327
|
-
# Determine if we should poll now
|
|
328
|
-
should_poll = first_poll or self._should_poll_now()
|
|
329
|
-
|
|
330
|
-
if should_poll:
|
|
331
|
-
await self._poll_once(current_symbols)
|
|
332
|
-
first_poll = False
|
|
333
|
-
|
|
334
|
-
# Sleep longer after successful poll
|
|
335
|
-
sleep_time = self.poll_interval_seconds if not self.use_time_boundaries else 60
|
|
336
|
-
await self._cancellable_sleep(sleep_time)
|
|
337
|
-
else:
|
|
338
|
-
# Not time to poll yet, sleep briefly and check again
|
|
339
|
-
await self._cancellable_sleep(5)
|
|
340
|
-
|
|
341
|
-
except CancelledError:
|
|
342
|
-
break
|
|
343
|
-
except Exception as e:
|
|
344
|
-
self._error_count += 1
|
|
345
|
-
logger.error(f"Polling error in adapter {self.adapter_id}: {e}")
|
|
346
|
-
|
|
347
|
-
# Sleep before retry, but not too long
|
|
348
|
-
# Ensure minimum 1 second sleep to avoid tight retry loops
|
|
349
|
-
sleep_time = max(1, min(30, self.poll_interval_seconds // 10))
|
|
350
|
-
await self._cancellable_sleep(sleep_time)
|
|
351
|
-
|
|
352
|
-
except CancelledError:
|
|
353
|
-
pass
|
|
354
|
-
finally:
|
|
355
|
-
logger.debug(f"Polling loop stopped for adapter {self.adapter_id}")
|
|
356
|
-
|
|
357
|
-
def _should_poll_now(self) -> bool:
|
|
358
|
-
"""
|
|
359
|
-
Determine if we should poll now based on timing logic.
|
|
360
|
-
|
|
361
|
-
Returns:
|
|
362
|
-
True if we should poll now
|
|
363
|
-
"""
|
|
364
|
-
if not self.use_time_boundaries:
|
|
365
|
-
# Simple interval-based polling
|
|
366
|
-
if self._last_poll_time is None:
|
|
367
|
-
return True
|
|
368
|
-
return (time.time() - self._last_poll_time) >= self.poll_interval_seconds
|
|
369
|
-
else:
|
|
370
|
-
# Boundary-based polling (like the open_interest.py logic)
|
|
371
|
-
# This would need access to data provider time, which we don't have here
|
|
372
|
-
# For now, fall back to simple interval polling
|
|
373
|
-
# TODO: Implement boundary logic if needed when data provider is available
|
|
374
|
-
if self._last_poll_time is None:
|
|
375
|
-
return True
|
|
376
|
-
return (time.time() - self._last_poll_time) >= self.poll_interval_seconds
|
|
377
|
-
|
|
378
|
-
async def _poll_once(self, symbols: List[str]) -> None:
|
|
379
|
-
"""
|
|
380
|
-
Perform a single polling operation.
|
|
381
|
-
|
|
382
|
-
Args:
|
|
383
|
-
symbols: List of symbols to poll for
|
|
384
|
-
"""
|
|
385
|
-
self._poll_count += 1
|
|
386
|
-
self._last_poll_time = time.time()
|
|
387
|
-
|
|
388
|
-
logger.debug(f"Polling {len(symbols)} symbols for adapter {self.adapter_id}")
|
|
389
|
-
|
|
390
|
-
try:
|
|
391
|
-
# Filter out adapter-specific parameters before calling fetch method
|
|
392
|
-
# These parameters are for the adapter, not the underlying fetch method
|
|
393
|
-
adapter_params = {'pollInterval', 'interval', 'updateInterval'}
|
|
394
|
-
fetch_params = {k: v for k, v in self.params.items() if k not in adapter_params}
|
|
395
|
-
|
|
396
|
-
# Call the fetch method with symbols and filtered params
|
|
397
|
-
result = await self.fetch_method(symbols, **fetch_params)
|
|
398
|
-
|
|
399
|
-
# Check if we've been stopped while the fetch was happening
|
|
400
|
-
if self._stop_event.is_set():
|
|
401
|
-
return
|
|
402
|
-
|
|
403
|
-
# Store result and put it in queue for get_next_data to return
|
|
404
|
-
self._latest_data = result
|
|
405
|
-
|
|
406
|
-
# Put data in queue for get_next_data to return
|
|
407
|
-
try:
|
|
408
|
-
self._data_queue.put_nowait(result)
|
|
409
|
-
except asyncio.QueueFull:
|
|
410
|
-
# Clear old data and add new
|
|
411
|
-
try:
|
|
412
|
-
self._data_queue.get_nowait()
|
|
413
|
-
except asyncio.QueueEmpty:
|
|
414
|
-
pass
|
|
415
|
-
self._data_queue.put_nowait(result)
|
|
416
|
-
|
|
417
|
-
except Exception as e:
|
|
418
|
-
# Check if we've been stopped during the error
|
|
419
|
-
if self._stop_event.is_set():
|
|
420
|
-
return
|
|
421
|
-
# Re-raise to be handled by polling loop
|
|
422
|
-
logger.error(f"Fetch failed for adapter {self.adapter_id}: {e}")
|
|
423
|
-
raise
|
|
424
|
-
|
|
425
|
-
async def _cancellable_sleep(self, seconds: float) -> None:
|
|
426
|
-
"""
|
|
427
|
-
Sleep that can be interrupted by the stop event.
|
|
428
|
-
|
|
429
|
-
Args:
|
|
430
|
-
seconds: Number of seconds to sleep
|
|
431
|
-
"""
|
|
432
|
-
if seconds <= 0:
|
|
433
|
-
return
|
|
434
|
-
|
|
435
|
-
try:
|
|
436
|
-
await asyncio.wait_for(self._stop_event.wait(), timeout=seconds)
|
|
437
|
-
except asyncio.TimeoutError:
|
|
438
|
-
# Timeout is expected - means we slept for the full duration
|
|
439
|
-
pass
|
|
248
|
+
async def stop(self) -> None:
|
|
249
|
+
"""Stop the adapter (cleanup method for compatibility)."""
|
|
250
|
+
logger.debug(f"Adapter {self.adapter_id} stopped (polled {self._poll_count} times, {self._error_count} errors)")
|