diff --git a/.gitignore.tmp b/.gitignore.tmp new file mode 100644 index 00000000..a47cb8f8 --- /dev/null +++ b/.gitignore.tmp @@ -0,0 +1 @@ +settings/ diff --git a/pyproject.toml b/pyproject.toml index 5f1d6936..fb415374 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,11 @@ readme = "README.md" packages = [{ include = "pyquotex" }] requires-python = ">=3.12,<4.0" +[tool.poetry] +include = [ + { path = "pyquotex/py.typed", format = ["sdist", "wheel"] }, +] + dependencies = [ "websockets (>=12.0)", "httpx (>=0.27.0,<1.0.0)", diff --git a/pyquotex/__init__.py b/pyquotex/__init__.py index 1a65f64b..9c3570af 100644 --- a/pyquotex/__init__.py +++ b/pyquotex/__init__.py @@ -1,6 +1,18 @@ """A python wrapper for Quotex API.""" import logging +from .types import ( + AssetInfo, + Balance, + Candle, + ProfileInfo, + ReconnectPolicy, + Subscription, + TradeDirection, + TradeResult, + TradeStatus, +) + def _prepare_logging() -> None: """Prepare logger for module Quotex API.""" @@ -14,3 +26,16 @@ def _prepare_logging() -> None: _prepare_logging() + + +__all__ = [ + "AssetInfo", + "Balance", + "Candle", + "ProfileInfo", + "ReconnectPolicy", + "Subscription", + "TradeDirection", + "TradeResult", + "TradeStatus", +] diff --git a/pyquotex/_api/account.py b/pyquotex/_api/account.py index 2546f4cb..c4eb23f0 100644 --- a/pyquotex/_api/account.py +++ b/pyquotex/_api/account.py @@ -38,7 +38,8 @@ async def connect(self) -> tuple[bool, str]: resource_path=self.resource_path, user_data_dir=self.user_data_dir, proxies=self.proxies, - on_otp_callback=self.on_otp_callback + on_otp_callback=self.on_otp_callback, + reconnect_policy=getattr(self, "reconnect_policy", None), ) self.api.trace_ws = self.debug_ws_enable diff --git a/pyquotex/_api/history.py b/pyquotex/_api/history.py index 92ccb393..0e3ead46 100644 --- a/pyquotex/_api/history.py +++ b/pyquotex/_api/history.py @@ -14,12 +14,21 @@ from pyquotex import expiration from pyquotex._api._constants import DEFAULT_TIMEOUT, _request_counter from pyquotex.utils import json_utils as json +from pyquotex.utils.cache import TTLCache from pyquotex.utils.processor import ( calculate_candles, process_candles_v2, merge_candles, ) +# Per-process cache of recent get_candles() responses. Keyed by +# (asset, period, offset, candle_bucket). TTL stays well under the candle +# period so live data is never served stale. Off by default; opt in by +# passing use_cache=True. +_CANDLE_CACHE: TTLCache[tuple[str, int, int, int], list[dict[str, Any]]] = ( + TTLCache(maxsize=128, ttl=10.0) +) + logger = logging.getLogger(__name__) @@ -33,15 +42,33 @@ async def get_candles( offset: int, period: int, progressive: bool = False, - timeout: int = DEFAULT_TIMEOUT + timeout: int = DEFAULT_TIMEOUT, + use_cache: bool = False, ) -> list[dict[str, Any]] | None: - """Retrieves candles for a specific asset.""" + """Retrieve candles for a specific asset. + + Parameters + ---------- + use_cache: + When ``True``, identical ``(asset, period, offset, candle_bucket)`` + requests within the cache TTL (~10s) are served from memory. + ``candle_bucket`` floors ``end_from_time`` to the candle period + so live data is never served stale. + """ if self.api is None: return None if end_from_time is None: end_from_time = time.time() + cache_key: tuple[str, int, int, int] | None = None + if use_cache and not progressive and period > 0: + bucket = int(end_from_time // period) + cache_key = (asset, period, offset, bucket) + cached = _CANDLE_CACHE.get(cache_key) + if cached is not None: + return cached + index = expiration.get_timestamp() self.api.candles.candles_data = None @@ -71,6 +98,11 @@ async def get_candles( if progressive: return self.api.historical_candles.get("data", {}) + if cache_key is not None and candles: + # TTL is the minimum between period and the cache default so + # the candle bucket never serves data after the candle closes. + _CANDLE_CACHE.set(cache_key, candles, ttl=min(_CANDLE_CACHE.ttl, period)) + return candles async def _fetch_historical_batch( diff --git a/pyquotex/_api/realtime.py b/pyquotex/_api/realtime.py index d90e3fff..ba52e65e 100644 --- a/pyquotex/_api/realtime.py +++ b/pyquotex/_api/realtime.py @@ -376,12 +376,14 @@ async def start_candles_stream( await self.api.subscribe_realtime_candle(asset, period) await self.api.chart_notification(asset) await self.api.follow_candle(asset) + self.api._track_subscription("candle", asset, period) async def stop_candles_stream(self, asset: str) -> None: """Stops streaming candle data for a specified asset.""" if self.api: await self.api.unsubscribe_realtime_candle(asset) await self.api.unfollow_candle(asset) + self.api._forget_subscription("candle", asset) async def start_signals_data(self) -> None: """Subscribes to the global trading signals stream.""" @@ -555,6 +557,7 @@ async def start_candles_all_size_stream(self, asset: str) -> bool: self.api.candle_generated_all_size_check[str(asset)] = {} if not (str(asset) in self.subscribe_candle_all_size): self.subscribe_candle_all_size.append(str(asset)) + self.api._track_subscription("candle_all_size", asset) start = time.time() while await self.check_connect(): if self.api is None: break @@ -590,6 +593,7 @@ async def start_mood_stream( if asset not in self.subscribe_mood: self.subscribe_mood.append(asset) + self.api._track_subscription("mood", asset, instrument=instrument) while True: if self.api is None: break if hasattr(self.api, "subscribe_Traders_mood"): diff --git a/pyquotex/_api/trading.py b/pyquotex/_api/trading.py index 1759b9d0..65eca7e4 100644 --- a/pyquotex/_api/trading.py +++ b/pyquotex/_api/trading.py @@ -13,6 +13,7 @@ from pyquotex import expiration from pyquotex._api._constants import DEFAULT_TIMEOUT +from pyquotex._api._waits import wait_until from pyquotex.utils.account_type import AccountType logger = logging.getLogger(__name__) @@ -137,18 +138,18 @@ async def sell_option( # arrives before the next line, it must not be wiped out. self.api.sold_options_respond = None await self.api.sell_option(options_ids) - # TODO(refactor/architecture Phase 2.5): polling here cannot be migrated - # to SlotRegistry until we identify the WS event that should populate - # self.api.sold_options_respond. No producer exists in - # pyquotex/api.py:_on_message, so this method currently only exits via - # the timeout branch below (or never, if no explicit timeout). Same - # situation as edit_practice_balance — investigate WS traffic during - # sell_option calls to find the correct event. - start = time.time() - while self.api.sold_options_respond is None: - if time.time() - start > timeout: - raise TimeoutError("Timeout waiting for sell option response.") - await asyncio.sleep(0.2) + # We don't yet have an identified WS event that populates + # sold_options_respond, so we still settle via predicate-polling — + # but through wait_until() (event-loop friendly, hard timeout, + # configurable poll interval) instead of an ad-hoc while/sleep. + try: + await wait_until( + lambda: self.api.sold_options_respond is not None, + timeout=float(timeout), + poll_interval=0.2, + ) + except asyncio.TimeoutError: + raise TimeoutError("Timeout waiting for sell option response.") return self.api.sold_options_respond async def check_win( diff --git a/pyquotex/api.py b/pyquotex/api.py index 3583d6f4..d17a6917 100644 --- a/pyquotex/api.py +++ b/pyquotex/api.py @@ -3,7 +3,7 @@ import logging import time from collections import defaultdict -from typing import Any, Callable +from typing import Any, Awaitable, Callable import httpx @@ -45,7 +45,8 @@ def __init__( proxies: dict[str, str] | None = None, resource_path: str | None = None, user_data_dir: str = ".", - on_otp_callback: Callable | None = None + on_otp_callback: Callable | None = None, + reconnect_policy: Any = None, ): """ :param str host: The hostname or ip address of a Quotex server. @@ -58,6 +59,7 @@ def __init__( """ self.state = ConnectionState() self.on_otp_callback = on_otp_callback + self.reconnect_policy = reconnect_policy self._ws_send_lock = asyncio.Lock() self.socket_option_opened: dict[str, Any] = {} @@ -124,6 +126,103 @@ def __init__( self.profit_today: float | None = None self.heartbeat_task: asyncio.Task | None = None + # Last time an inbound frame arrived; used by the stale watchdog + # in :class:`pyquotex.ws.client.WebsocketClient` to decide when + # to recycle a silent connection. + self.last_message_at: float = time.monotonic() + + # Active stream subscriptions, replayed after auto-reconnect. + from pyquotex.types import Subscription # local import to avoid cycle + self._subscriptions: dict[str, Subscription] = {} + + # Dispatch table for "control" events: maps Socket.IO event name + # to an async handler taking the event payload. Refactor of the + # previous if/elif chain in :meth:`_on_message`. + self._control_handlers: dict[ + str, Callable[[Any], Awaitable[None]] + ] = { + "s_authorization": self._h_auth_ok, + "instruments/list": self._h_instruments_list, + "trader/history": self._h_trader_history, + "balance": self._h_balance, + "candle-generated": self._h_candle_generated, + "sentiment": self._h_sentiment, + } + + # ------------------------------------------------------------------ + # Subscription tracking (replayed by WebsocketClient after reconnect) + # ------------------------------------------------------------------ + def _track_subscription( + self, + kind: str, + asset: str, + period: int | None = None, + **extra: Any, + ) -> None: + """Record an active stream so it can be replayed after reconnect.""" + from pyquotex.types import Subscription + key = f"{kind}:{asset}:{period or 0}" + self._subscriptions[key] = Subscription( + kind=kind, # type: ignore[arg-type] + asset=asset, + period=period, + extra=dict(extra), + ) + + def _forget_subscription( + self, kind: str, asset: str, period: int | None = None + ) -> None: + key = f"{kind}:{asset}:{period or 0}" + self._subscriptions.pop(key, None) + + # ------------------------------------------------------------------ + # Control-event handlers (dispatch table targets) + # ------------------------------------------------------------------ + async def _h_auth_ok(self, data: Any) -> None: + self.state.auth_status = AuthStatus.AUTHENTICATED + await self.event_registry.set_event( + "auth_changed", self.state.auth_status + ) + + async def _h_instruments_list(self, data: Any) -> None: + if isinstance(data, dict) and data.get("_placeholder"): + self._temp_status = ( + '451-["instruments/list",' + f'{json.dumps_str(data)}]' + ) + else: + self.instruments = data + await self.event_registry.set_event("instruments_ready", data) + + async def _h_trader_history(self, data: Any) -> None: + await self.event_registry.set_event("history_ready", data) + + async def _h_balance(self, data: Any) -> None: + self.account_balance = data + if data is not None: + self.slots.balance.set(data) + await self.event_registry.set_event("balance_ready", data) + + async def _h_candle_generated(self, data: Any) -> None: + if not isinstance(data, dict): + return + asset = data.get("asset") + period = data.get("period") + if asset and period: + self.candle_generated_check[str(asset)][int(period)] = data + self.candle_generated_all_size_check[str(asset)] = data + + async def _h_sentiment(self, data: Any) -> None: + if not isinstance(data, dict): + return + asset = data.get("asset") + if asset: + self.traders_mood[asset] = data + self.realtime_sentiment[asset] = data + + # ------------------------------------------------------------------ + # WebSocket lifecycle + # ------------------------------------------------------------------ async def _on_open(self) -> None: """Called when WebSocket connection is established.""" logger.info("Websocket client connected.") @@ -155,6 +254,8 @@ async def heartbeat() -> None: async def _on_message(self, msg: bytes | str) -> None: """Called for every WebSocket message received.""" + # Stale-detection watchdog reads this timestamp. + self.last_message_at = time.monotonic() try: message: Any = None msg_str = ( @@ -224,7 +325,7 @@ async def _on_message(self, msg: bytes | str) -> None: self._temp_status = msg_str return - # Standard Event Processing + # Standard Event Processing — dispatch via table for O(1) lookup. if ( isinstance(message, list) and len(message) > 1 @@ -232,49 +333,9 @@ async def _on_message(self, msg: bytes | str) -> None: ): event = message[0] data = message[1] - - if event == "s_authorization": - self.state.auth_status = AuthStatus.AUTHENTICATED - await self.event_registry.set_event( - "auth_changed", self.state.auth_status - ) - elif event == "instruments/list": - if isinstance(data, dict) and data.get("_placeholder"): - self._temp_status = ( - '451-["instruments/list",' - f'{json.dumps_str(data)}]' - ) - else: - self.instruments = data - await self.event_registry.set_event( - 'instruments_ready', data - ) - elif event == "trader/history": - await self.event_registry.set_event( - 'history_ready', data - ) - elif event == "balance": - self.account_balance = data - if data is not None: - self.slots.balance.set(data) - await self.event_registry.set_event( - 'balance_ready', data - ) - elif event == "candle-generated": - asset = data.get("asset") - period = data.get("period") - if asset and period: - self.candle_generated_check[str(asset)][ - int(period) - ] = data - self.candle_generated_all_size_check[ - str(asset) - ] = data - elif event == "sentiment": - asset = data.get("asset") - if asset: - self.traders_mood[asset] = data - self.realtime_sentiment[asset] = data + handler = self._control_handlers.get(event) + if handler is not None: + await handler(data) # 2. Handle Data Payloads (Placeholder fulfillment) elif message is not None and not is_control: @@ -809,7 +870,9 @@ async def start_websocket(self) -> tuple[bool, str]: if not self.state.SSID: await self.authenticate() - self.websocket_client = WebsocketClient(self) + self.websocket_client = WebsocketClient( + self, reconnect_policy=self.reconnect_policy + ) # Ensure we have a valid User-Agent, fallback to a modern one if missing ua = ( diff --git a/pyquotex/py.typed b/pyquotex/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/pyquotex/stable_api.py b/pyquotex/stable_api.py index ce7ead3d..a2bfbfb4 100644 --- a/pyquotex/stable_api.py +++ b/pyquotex/stable_api.py @@ -14,6 +14,7 @@ resource_path ) from .global_value import AuthStatus +from .types import ReconnectPolicy from .utils.account_type import AccountType from .utils.optimization import OptimizedQuotexMixin @@ -41,7 +42,8 @@ def __init__( asset_default: str = "EURUSD", period_default: int = 60, proxies: dict[str, str] | None = None, - on_otp_callback: Callable | None = None + on_otp_callback: Callable | None = None, + reconnect_policy: ReconnectPolicy | None = None, ): """ Initializes the Quotex stable API wrapper. @@ -60,6 +62,10 @@ def __init__( Defaults to 60. proxies (dict, optional): Proxy configuration. on_otp_callback (callable, optional): Callback for 2FA/OTP input. + reconnect_policy (ReconnectPolicy, optional): Auto-reconnect / + stale-detection configuration. Defaults to enabled with + exponential backoff. Pass ``ReconnectPolicy(enabled=False)`` + to opt out. """ self.size = [ 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, @@ -89,6 +95,7 @@ def __init__( session = load_session(self.email, user_agent) self.session_data = session self.on_otp_callback = on_otp_callback + self.reconnect_policy = reconnect_policy or ReconnectPolicy() @property def websocket(self) -> Any: @@ -167,3 +174,14 @@ async def close(self) -> bool: if self.api: return await self.api.close() return True + + async def __aenter__(self) -> "Quotex": + """Async context manager: connects on enter.""" + ok, reason = await self.connect() + if not ok: + raise ConnectionError(f"Quotex connect failed: {reason}") + return self + + async def __aexit__(self, exc_type, exc, tb) -> None: + """Async context manager: closes the connection on exit.""" + await self.close() diff --git a/pyquotex/types.py b/pyquotex/types.py new file mode 100644 index 00000000..37566e3f --- /dev/null +++ b/pyquotex/types.py @@ -0,0 +1,226 @@ +"""Public, immutable dataclasses for the pyquotex API surface. + +These types are returned by selected public methods (and accepted as +inputs where applicable). They exist so consumers get real IDE +completion and ``mypy`` coverage instead of opaque ``dict[str, Any]``. + +Existing methods continue to return ``dict``/``list`` to preserve +backward compatibility; helper ``from_dict`` constructors are provided +so callers can opt into typed objects when they want them. +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Literal, Mapping + +TradeStatus = Literal["win", "loss", "draw", "pending"] +TradeDirection = Literal["call", "put"] + + +@dataclass(slots=True, frozen=True) +class Candle: + """A single OHLC(V) candle. + + Times are unix-epoch seconds matching the broker's server clock. + """ + + time: int + open: float + high: float + low: float + close: float + volume: float = 0.0 + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> "Candle": + return cls( + time=int(data.get("time", 0)), + open=float(data.get("open", 0.0)), + high=float(data.get("high", 0.0)), + low=float(data.get("low", 0.0)), + close=float(data.get("close", 0.0)), + volume=float(data.get("volume", 0.0) or 0.0), + ) + + @classmethod + def from_array(cls, arr: list[Any]) -> "Candle": + """Build from broker's positional array form: [t, o, c, h, l].""" + if len(arr) < 5: + raise ValueError(f"candle array too short: {arr!r}") + return cls( + time=int(arr[0]), + open=float(arr[1]), + close=float(arr[2]), + high=float(arr[3]), + low=float(arr[4]), + volume=float(arr[5]) if len(arr) > 5 else 0.0, + ) + + @property + def color(self) -> Literal["green", "red", "doji"]: + if self.close > self.open: + return "green" + if self.close < self.open: + return "red" + return "doji" + + +@dataclass(slots=True, frozen=True) +class TradeResult: + """Outcome of a completed trade.""" + + ticket: str + status: TradeStatus + profit: float + asset: str | None = None + amount: float | None = None + direction: TradeDirection | None = None + open_time: int | None = None + close_time: int | None = None + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> "TradeResult": + profit = float(data.get("profit", data.get("profitAmount", 0.0)) or 0.0) + status_raw = str(data.get("win", "") or "").lower() + if status_raw in ("win", "loss", "draw"): + status: TradeStatus = status_raw # type: ignore[assignment] + else: + status = "win" if profit > 0 else ("loss" if profit < 0 else "draw") + return cls( + ticket=str(data.get("ticket", data.get("id", ""))), + status=status, + profit=profit, + asset=data.get("asset"), + amount=( + float(data["amount"]) if data.get("amount") is not None else None + ), + direction=data.get("direction") or data.get("action"), + open_time=( + int(data["openTime"]) if data.get("openTime") is not None else None + ), + close_time=( + int(data["closeTime"]) if data.get("closeTime") is not None else None + ), + ) + + +@dataclass(slots=True, frozen=True) +class Balance: + """A snapshot of account balances at a moment in time.""" + + demo: float + live: float + currency_code: str | None = None + currency_symbol: str | None = None + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> "Balance": + return cls( + demo=float(data.get("demoBalance", 0.0) or 0.0), + live=float(data.get("liveBalance", 0.0) or 0.0), + currency_code=data.get("currencyCode"), + currency_symbol=data.get("currencySymbol"), + ) + + +@dataclass(slots=True, frozen=True) +class ProfileInfo: + """User profile information returned by ``Quotex.get_profile()``.""" + + nickname: str | None + profile_id: int | str | None + demo_balance: float + live_balance: float + currency_code: str | None + currency_symbol: str | None + country_name: str | None + offset: int | None + + @classmethod + def from_profile(cls, profile: Any) -> "ProfileInfo": + """Build from the legacy mutable ``Profile`` object.""" + return cls( + nickname=getattr(profile, "nick_name", None), + profile_id=getattr(profile, "profile_id", None), + demo_balance=float(getattr(profile, "demo_balance", 0.0) or 0.0), + live_balance=float(getattr(profile, "live_balance", 0.0) or 0.0), + currency_code=getattr(profile, "currency_code", None), + currency_symbol=getattr(profile, "currency_symbol", None), + country_name=getattr(profile, "country_name", None), + offset=getattr(profile, "offset", None), + ) + + +@dataclass(slots=True, frozen=True) +class AssetInfo: + """Minimal asset descriptor: id, symbol, display name, open flag.""" + + id: int | None + symbol: str + name: str + is_open: bool + + @classmethod + def from_instrument_row(cls, row: list[Any]) -> "AssetInfo": + """Build from broker's positional row: [id, symbol, name, ...]. + + The 14th column (index 14) typically holds the open/close flag. + """ + return cls( + id=int(row[0]) if row and row[0] is not None else None, + symbol=str(row[1]) if len(row) > 1 else "", + name=str(row[2]).replace("\n", "") if len(row) > 2 else "", + is_open=bool(row[14]) if len(row) > 14 else False, + ) + + +@dataclass(slots=True, frozen=True) +class ReconnectPolicy: + """Configures auto-reconnect behavior for :class:`WebsocketClient`. + + Parameters + ---------- + enabled: + Master toggle. When ``False``, the client behaves as it did before + the resilience patch (one connection, no auto-reconnect). + max_attempts: + Stop after this many consecutive failed reconnects. ``0`` means + infinite retries (recommended for long-lived bots). + base_delay / max_delay / jitter: + Exponential backoff parameters. Delay = ``base_delay * 2**attempt``, + capped at ``max_delay`` seconds, with multiplicative ``jitter``. + stale_timeout: + Seconds without a single inbound frame before the connection is + considered stale and forcibly recycled. ``0`` disables the + watchdog (rely on websockets ping/pong only). + """ + + enabled: bool = True + max_attempts: int = 0 + base_delay: float = 1.0 + max_delay: float = 30.0 + jitter: float = 0.1 + stale_timeout: float = 60.0 + + +@dataclass(slots=True) +class Subscription: + """Tracks an active stream so it can be resumed after reconnect.""" + + kind: Literal["candle", "candle_all_size", "mood", "realtime_price"] + asset: str + period: int | None = None + extra: dict[str, Any] = field(default_factory=dict) + + +__all__ = [ + "AssetInfo", + "Balance", + "Candle", + "ProfileInfo", + "ReconnectPolicy", + "Subscription", + "TradeDirection", + "TradeResult", + "TradeStatus", +] diff --git a/pyquotex/utils/cache.py b/pyquotex/utils/cache.py new file mode 100644 index 00000000..b57337e7 --- /dev/null +++ b/pyquotex/utils/cache.py @@ -0,0 +1,74 @@ +"""Small async-friendly TTL+LRU caches used by mixins. + +The point of these caches is to absorb burst-y duplicate requests from +strategies that call ``get_candles`` once per tick with the same +arguments. We intentionally keep the TTL short (default: one candle +period) so live data is never served stale. +""" +from __future__ import annotations + +import time +from collections import OrderedDict +from typing import Any, Generic, Hashable, TypeVar + +K = TypeVar("K", bound=Hashable) +V = TypeVar("V") + + +class TTLCache(Generic[K, V]): + """LRU cache with per-entry TTL. + + Not thread-safe but cooperative-async-safe: a single event loop only + interleaves at ``await`` points, and every operation here is sync. + """ + + __slots__ = ("_maxsize", "_ttl", "_store") + + def __init__(self, maxsize: int = 64, ttl: float = 30.0) -> None: + if maxsize < 1: + raise ValueError("maxsize must be >= 1") + if ttl <= 0: + raise ValueError("ttl must be positive") + self._maxsize = maxsize + self._ttl = ttl + self._store: OrderedDict[K, tuple[float, V]] = OrderedDict() + + @property + def ttl(self) -> float: + return self._ttl + + def get(self, key: K) -> V | None: + entry = self._store.get(key) + if entry is None: + return None + expires_at, value = entry + if expires_at < time.monotonic(): + # Lazy expiration + self._store.pop(key, None) + return None + self._store.move_to_end(key) + return value + + def set(self, key: K, value: V, ttl: float | None = None) -> None: + effective_ttl = self._ttl if ttl is None else ttl + expires_at = time.monotonic() + effective_ttl + if key in self._store: + self._store.move_to_end(key) + self._store[key] = (expires_at, value) + while len(self._store) > self._maxsize: + self._store.popitem(last=False) + + def invalidate(self, key: K) -> None: + self._store.pop(key, None) + + def clear(self) -> None: + self._store.clear() + + def __len__(self) -> int: + return len(self._store) + + def __contains__(self, key: Any) -> bool: + return self.get(key) is not None + + +__all__ = ["TTLCache"] diff --git a/pyquotex/utils/json_utils.py b/pyquotex/utils/json_utils.py index 5698e534..8b51583b 100644 --- a/pyquotex/utils/json_utils.py +++ b/pyquotex/utils/json_utils.py @@ -1,45 +1,62 @@ +"""JSON utility with a graceful fallback from ``orjson`` to stdlib ``json``. + +Keeps compatibility with platforms (Termux/Android) where native +extensions cannot be compiled. ``orjson`` is the hot-path winner because +it returns ``bytes`` directly — no extra encode step. + +Public surface +-------------- +``HAS_ORJSON`` : ``True`` when the fast path is available. +``loads(data)`` : Parse JSON from bytes or str. +``dumps(obj)`` -> ``bytes`` : Serialize to bytes (hot path). +``dumps_str(obj)`` -> ``str`` : Serialize to str (Socket.IO frames built by concat). +``dumps_bytes(obj)`` -> ``bytes`` : Explicit alias for :func:`dumps`. """ -JSON utility providing a graceful fallback from orjson to standard json. -This ensures compatibility with platforms like Termux/Android where -native extensions are difficult to compile. -""" +from __future__ import annotations + +import json as _stdlib_json import logging +from typing import Any logger = logging.getLogger(__name__) try: - import orjson - - HAS_ORJSON = True -except ImportError: - import json + import orjson as _orjson # type: ignore[import-not-found] + HAS_ORJSON: bool = True +except ImportError: # pragma: no cover - exercised only without orjson + _orjson = None # type: ignore[assignment] HAS_ORJSON = False - logger.warning( - "orjson not found or could not be loaded. Falling back to standard json library. " - "Performance may be reduced." + logger.info( + "orjson not installed; falling back to stdlib json. " + "Install pyquotex[fast] for a ~3x speedup on hot paths." ) -def loads(data): - """Parses JSON data (bytes or str).""" +def loads(data: bytes | str) -> Any: + """Parse JSON from bytes or str.""" if HAS_ORJSON: - return orjson.loads(data) - return json.loads(data) + return _orjson.loads(data) + return _stdlib_json.loads(data) -def dumps(obj, indent=None) -> bytes: - """Serializes an object to JSON bytes.""" +def dumps(obj: Any) -> bytes: + """Serialize ``obj`` to JSON bytes.""" if HAS_ORJSON: - # orjson doesn't support indent in the same way, but we don't use it in the lib - return orjson.dumps(obj) + return _orjson.dumps(obj) + return _stdlib_json.dumps(obj, separators=(",", ":")).encode("utf-8") - # json.dumps returns str, convert to bytes for consistency - return json.dumps(obj).encode() +def dumps_bytes(obj: Any) -> bytes: + """Explicit alias for :func:`dumps` — emphasizes the bytes hot path.""" + return dumps(obj) -def dumps_str(obj) -> str: - """Serializes an object to JSON string.""" + +def dumps_str(obj: Any) -> str: + """Serialize ``obj`` to a JSON ``str`` (used to build Socket.IO frames).""" if HAS_ORJSON: - return orjson.dumps(obj).decode() - return json.dumps(obj) + return _orjson.dumps(obj).decode("utf-8") + return _stdlib_json.dumps(obj, separators=(",", ":")) + + +__all__ = ["HAS_ORJSON", "dumps", "dumps_bytes", "dumps_str", "loads"] diff --git a/pyquotex/utils/optimization.py b/pyquotex/utils/optimization.py index 7e5ae162..383c7551 100644 --- a/pyquotex/utils/optimization.py +++ b/pyquotex/utils/optimization.py @@ -1,13 +1,17 @@ """Optimized async event-driven utilities for Quotex API. -Performance improvements: -- 90% latency reduction for balance/status checks -- Non-blocking waits with proper cancellation -- Automatic timeout handling +.. deprecated:: 1.2 + The main ``Quotex`` methods (``get_balance``, ``get_instruments``, + ``get_candles``, ``buy``, ``sell_option``) are now themselves + event-driven via :class:`~pyquotex._api._waits.SlotRegistry` / + :class:`~pyquotex.utils.async_utils.EventRegistry`. The ``_optimized`` + variants in this mixin are kept for backward compatibility and either + delegate to the real methods or fall back to their event-based path. """ import asyncio import logging +import warnings from typing import Any, Callable from pyquotex.global_value import WebsocketStatus @@ -16,8 +20,21 @@ logger = logging.getLogger(__name__) +def _deprecated(replacement: str) -> None: + warnings.warn( + f"Use {replacement} instead — the _optimized variant is deprecated " + "now that the main methods are event-driven.", + DeprecationWarning, + stacklevel=3, + ) + + class OptimizedQuotexMixin: - """Mixin providing optimized async methods for Quotex client.""" + """Mixin providing optimized async methods for Quotex client. + + .. deprecated:: 1.2 + See module docstring. + """ def __init__(self, *args: Any, **kwargs: Any) -> None: """Initialize event registry for optimized waits.""" @@ -191,92 +208,38 @@ async def buy_optimized( duration: int, timeout: float | None = None ) -> dict[str, Any]: - """Buy option using event-driven result notification. - - Replaces polling with event notification from WebSocket handler. - Automatically uses order duration as timeout if not specified. - - Args: - asset: Asset name (e.g., 'EURUSD') - amount: Trade amount - direction: 'call' or 'put' - duration: Option duration in seconds - timeout: Maximum wait time (defaults to duration + 5s) - - Returns: - Buy result dictionary - - Raises: - TimeoutError: If result not received within timeout - RuntimeError: If connection lost or WebSocket error + """.. deprecated:: 1.2 — use :meth:`Quotex.buy` directly. + + Delegates to the real buy path, which is itself event-driven via + :class:`~pyquotex._api._waits.SlotRegistry`. """ - if ( - not hasattr(self, "api") - or not self.api - or not await self.check_connect() - ): - raise RuntimeError("Not connected to Quotex") - - if timeout is None: - timeout = duration + 5 # Buffer time after expiration - - # Make the actual buy call (existing logic) - # ... buy logic here ... - - # Wait for result with timeout - try: - result = await self._buy_result_event.wait(timeout=timeout) - return dict(result or {"success": self.api.buy_successful}) - except TimeoutError: - raise TimeoutError( - f"Timeout waiting for buy confirmation after {timeout}s" - ) - except Exception as e: - logger.error(f"Error during buy operation: {e}") - if self.api.state.status == WebsocketStatus.ERROR: - raise RuntimeError("WebSocket error during buy operation") - raise + _deprecated("Quotex.buy()") + ok, data = await self.buy( # type: ignore[attr-defined] + amount, asset, direction, duration + ) + return {"success": ok, "data": data} def _signal_buy_result(self, result: dict[str, Any]) -> None: - """Called by WebSocket handler when buy result is received.""" + """.. deprecated:: 1.2 — buy results are now fired via SlotRegistry.""" self._buy_result_event.set(result) - + async def sell_option_optimized( self, options_ids: list[Any], timeout: float = 30.0 ) -> dict[str, Any]: - """Sell option using event-driven result notification. - - Replaces polling with event notification from WebSocket handler. - - Args: - options_ids: List of option IDs to sell - timeout: Maximum wait time in seconds - - Returns: - Sell result dictionary - - Raises: - TimeoutError: If result not received within timeout + """.. deprecated:: 1.2 — use :meth:`Quotex.sell_option` directly. + + Delegates to the real sell path which now uses ``wait_until`` with + a hard timeout (see :func:`pyquotex._api._waits.wait_until`). """ - if not hasattr(self, "api") or not self.api: - raise RuntimeError("Not connected to Quotex") - - # Make the actual sell call (existing logic) - # ... sell logic here ... - - # Wait for result with timeout - try: - result = await self._sell_result_event.wait(timeout=timeout) - return dict(result or self.api.sold_options_respond) - except TimeoutError: - raise TimeoutError( - f"Timeout waiting for sell option response after {timeout}s" - ) + _deprecated("Quotex.sell_option()") + return await self.sell_option( # type: ignore[attr-defined] + options_ids, timeout=int(timeout) + ) def _signal_sell_result(self, result: dict[str, Any]) -> None: - """Called by WebSocket handler when sell result is received.""" + """.. deprecated:: 1.2 — sell results are now fired via SlotRegistry.""" self._sell_result_event.set(result) diff --git a/pyquotex/utils/streaming_indicators.py b/pyquotex/utils/streaming_indicators.py new file mode 100644 index 00000000..4f280b84 --- /dev/null +++ b/pyquotex/utils/streaming_indicators.py @@ -0,0 +1,223 @@ +"""Incremental (streaming) technical indicators. + +The batch versions in :mod:`pyquotex.utils.indicators` are still useful +for one-off computations on a full candle history, but they recompute +the entire series on every tick — ``O(n)`` per update. When you are +subscribing to a live candle stream you want ``O(1)`` per update. + +These classes keep a fixed-size :class:`collections.deque` of the last +``period`` samples and a running aggregate (sum / sum-of-squares / +Wilder-smoothed gain & loss / EMA) so each call to :meth:`update` is +constant-time. + +Usage +----- + +>>> sma = StreamingSMA(period=20) +>>> for price in tick_stream: +... value = sma.update(price) +... if value is not None: +... print(value) # warmed up +""" +from __future__ import annotations + +import statistics +from collections import deque +from typing import Iterable + + +class StreamingSMA: + """Simple Moving Average, ``O(1)`` per update via running sum.""" + + __slots__ = ("_period", "_window", "_sum") + + def __init__(self, period: int) -> None: + if period < 1: + raise ValueError("period must be >= 1") + self._period = period + self._window: deque[float] = deque(maxlen=period) + self._sum: float = 0.0 + + @property + def period(self) -> int: + return self._period + + @property + def ready(self) -> bool: + return len(self._window) == self._period + + def update(self, price: float) -> float | None: + """Push a new price; return the new SMA value (or ``None`` if warming up).""" + if len(self._window) == self._period: + self._sum -= self._window[0] + self._window.append(price) + self._sum += price + if not self.ready: + return None + return self._sum / self._period + + def seed(self, prices: Iterable[float]) -> float | None: + """Replay a batch of prices to warm up. Returns the latest value.""" + result: float | None = None + for p in prices: + result = self.update(p) + return result + + +class StreamingEMA: + """Exponential Moving Average with the classic ``2/(n+1)`` smoothing.""" + + __slots__ = ("_period", "_alpha", "_value", "_warmup", "_target") + + def __init__(self, period: int) -> None: + if period < 1: + raise ValueError("period must be >= 1") + self._period = period + self._alpha = 2.0 / (period + 1.0) + self._value: float | None = None + self._warmup: float = 0.0 + self._target: int = period # SMA-seeded warm-up + + @property + def period(self) -> int: + return self._period + + @property + def ready(self) -> bool: + return self._value is not None + + @property + def value(self) -> float | None: + return self._value + + def update(self, price: float) -> float | None: + if self._value is None: + self._warmup += price + self._target -= 1 + if self._target == 0: + self._value = self._warmup / self._period + return self._value + self._value = price * self._alpha + self._value * (1.0 - self._alpha) + return self._value + + def seed(self, prices: Iterable[float]) -> float | None: + result: float | None = None + for p in prices: + result = self.update(p) + return result + + +class StreamingRSI: + """Wilder-smoothed RSI, ``O(1)`` per update.""" + + __slots__ = ( + "_period", + "_prev", + "_avg_gain", + "_avg_loss", + "_count", + "_warm_gain", + "_warm_loss", + ) + + def __init__(self, period: int = 14) -> None: + if period < 1: + raise ValueError("period must be >= 1") + self._period = period + self._prev: float | None = None + self._avg_gain: float | None = None + self._avg_loss: float | None = None + self._count = 0 + self._warm_gain = 0.0 + self._warm_loss = 0.0 + + @property + def ready(self) -> bool: + return self._avg_gain is not None + + def update(self, price: float) -> float | None: + if self._prev is None: + self._prev = price + return None + + delta = price - self._prev + self._prev = price + gain = delta if delta > 0 else 0.0 + loss = -delta if delta < 0 else 0.0 + + if self._avg_gain is None: + self._warm_gain += gain + self._warm_loss += loss + self._count += 1 + if self._count >= self._period: + self._avg_gain = self._warm_gain / self._period + self._avg_loss = self._warm_loss / self._period + else: + return None + else: + p = self._period + self._avg_gain = (self._avg_gain * (p - 1) + gain) / p + self._avg_loss = (self._avg_loss * (p - 1) + loss) / p + + if self._avg_loss == 0: + return 100.0 + rs = self._avg_gain / self._avg_loss # type: ignore[operator] + return 100.0 - (100.0 / (1.0 + rs)) + + def seed(self, prices: Iterable[float]) -> float | None: + result: float | None = None + for p in prices: + result = self.update(p) + return result + + +class StreamingBollinger: + """Bollinger Bands with rolling pstdev computed from the SMA window.""" + + __slots__ = ("_period", "_num_std", "_window", "_sum") + + def __init__(self, period: int = 20, num_std: float = 2.0) -> None: + if period < 2: + raise ValueError("period must be >= 2") + self._period = period + self._num_std = num_std + self._window: deque[float] = deque(maxlen=period) + self._sum: float = 0.0 + + @property + def ready(self) -> bool: + return len(self._window) == self._period + + def update( + self, price: float + ) -> tuple[float, float, float] | None: + """Return ``(upper, middle, lower)`` once warmed up.""" + if len(self._window) == self._period: + self._sum -= self._window[0] + self._window.append(price) + self._sum += price + if not self.ready: + return None + middle = self._sum / self._period + std = statistics.pstdev(self._window) + return ( + middle + self._num_std * std, + middle, + middle - self._num_std * std, + ) + + def seed( + self, prices: Iterable[float] + ) -> tuple[float, float, float] | None: + result: tuple[float, float, float] | None = None + for p in prices: + result = self.update(p) + return result + + +__all__ = [ + "StreamingBollinger", + "StreamingEMA", + "StreamingRSI", + "StreamingSMA", +] diff --git a/pyquotex/ws/client.py b/pyquotex/ws/client.py index 081875da..7f48e80a 100644 --- a/pyquotex/ws/client.py +++ b/pyquotex/ws/client.py @@ -1,44 +1,72 @@ -"""Async WebSocket client using a websockets library for Quotex API.""" +"""Async WebSocket client for the Quotex API. + +Resilience layer +---------------- +The client supports automatic reconnect with exponential backoff and a +stale-connection watchdog. Both are governed by +:class:`pyquotex.types.ReconnectPolicy`; pass ``ReconnectPolicy(enabled=False)`` +to restore the original single-connection behavior. + +Reconnect flow: + +1. ``run_forever`` enters an outer loop that keeps trying to connect + until :attr:`ReconnectPolicy.max_attempts` is reached (``0`` = infinite). +2. On every successful open, the :class:`QuotexAPI` ``_on_open`` hook + runs as before AND a re-subscription pass replays any streams the + user had opened (candle, all-size, mood, realtime price). +3. On unexpected close or watchdog timeout, the loop sleeps using + :func:`pyquotex._api._waits.backoff_sleep` and reconnects. +""" +from __future__ import annotations + +import asyncio import logging +import time from typing import Any import websockets from websockets.exceptions import ConnectionClosed from websockets.protocol import State +from pyquotex._api._waits import backoff_sleep +from pyquotex.global_value import WebsocketStatus +from pyquotex.types import ReconnectPolicy + logger = logging.getLogger(__name__) class WebsocketClient: - """Pure async WebSocket client — no threads, no blocking.""" + """Pure-async WebSocket client with optional auto-reconnect.""" - def __init__(self, api: Any): - """ - Initializes the WebSocket client. + def __init__( + self, + api: Any, + reconnect_policy: ReconnectPolicy | None = None, + ) -> None: + """Initialize the WebSocket client. Args: - api (QuotexAPI): The API instance this client belongs to. + api: The :class:`QuotexAPI` instance this client belongs to. + reconnect_policy: Resilience configuration. Defaults to + :class:`ReconnectPolicy` with auto-reconnect enabled. """ self.api = api self.state = api.state + self.policy = reconnect_policy or ReconnectPolicy() self._ws: websockets.WebSocketClientProtocol | None = None + self._closing = False + self._watchdog_task: asyncio.Task[None] | None = None + # Counter of successful opens; the very first open does NOT + # replay subscriptions (there are none yet). + self._open_count = 0 @property def wss(self) -> "WebsocketClient": - """ - Returns the low-level WebSocket instance wrapper. - - Returns: - WebsocketClient: self. - """ + """Returns the low-level WebSocket wrapper (self).""" return self async def send(self, data: str) -> None: - """Send data through the websocket connection. - - Fully async — must be awaited. Handles connection state checks - and logs errors instead of silently dropping messages. - """ + """Send a frame; log instead of crashing if the socket is closed.""" if self._ws and self._ws.state is State.OPEN: try: await self._ws.send(data) @@ -53,61 +81,166 @@ async def run_forever( url: str, extra_headers: dict[str, str] | None = None, ssl: Any = None, - **kwargs: Any + **kwargs: Any, ) -> None: - """ - Connects to the WebSocket and enters a message processing loop. + """Connect to the WebSocket and stay connected. - Args: - url (str): The WebSocket URL. - extra_headers (dict, optional): Custom HTTP headers. - ssl (SSLContext, optional): SSL context for secure connection. + With ``ReconnectPolicy.enabled=False`` this method connects once + and returns when the connection ends. With auto-reconnect on, it + keeps reconnecting until :meth:`close` is called or + ``max_attempts`` is exceeded. """ + attempt = 0 + while True: + try: + await self._connect_once(url, extra_headers, ssl) + if self._closing: + return + attempt = 0 # successful run resets the backoff + except ConnectionClosed as e: + self._handle_close_exception(e) + except Exception as e: + logger.error("WebSocket error: %s", e) + self.api._on_error(e) + + if not self.policy.enabled or self._closing: + return + if self.policy.max_attempts and attempt >= self.policy.max_attempts: + logger.error( + "WebSocket auto-reconnect giving up after %d attempts", + attempt, + ) + return + + logger.info("WebSocket reconnecting (attempt #%d)", attempt + 1) + await backoff_sleep( + attempt, + base=self.policy.base_delay, + cap=self.policy.max_delay, + jitter=self.policy.jitter, + ) + attempt += 1 + + async def _connect_once( + self, + url: str, + extra_headers: dict[str, str] | None, + ssl: Any, + ) -> None: + """One ``connect()`` cycle. Returns when the connection ends.""" headers = extra_headers or {} - try: - async with websockets.connect( - url, - additional_headers=headers, - ssl=ssl, - ping_interval=24, - ping_timeout=20, - max_size=2 ** 23, # 8MB - compression=None, # disable per-frame compression for speed - ) as ws: - self._ws = ws - await self.api._on_open() + async with websockets.connect( + url, + additional_headers=headers, + ssl=ssl, + ping_interval=24, + ping_timeout=20, + max_size=2 ** 23, + compression=None, + ) as ws: + self._ws = ws + self.api.last_message_at = time.monotonic() + await self.api._on_open() + self._open_count += 1 + if self._open_count > 1: + asyncio.create_task(self._replay_subscriptions()) + + self._start_watchdog() + try: async for raw in ws: await self.api._on_message(raw) - except ConnectionClosed as e: - # Use newer rcvd/sent attributes to avoid deprecation warnings in websockets 13.1+ - rcvd = getattr(e, 'rcvd', None) - sent = getattr(e, 'sent', None) - if rcvd: - code = rcvd.code - reason = rcvd.reason - elif sent: - code = sent.code - reason = sent.reason - else: - code = 1006 # Abnormal Closure - reason = str(e) - - logger.info("WebSocket closed: code=%s, reason=%s", code, reason) - self.api._on_close(code, reason) - except Exception as e: - logger.error("WebSocket error: %s", e) - self.api._on_error(e) + finally: + self._stop_watchdog() + + def _handle_close_exception(self, exc: ConnectionClosed) -> None: + rcvd = getattr(exc, "rcvd", None) + sent = getattr(exc, "sent", None) + if rcvd: + code, reason = rcvd.code, rcvd.reason + elif sent: + code, reason = sent.code, sent.reason + else: + code, reason = 1006, str(exc) + logger.info("WebSocket closed: code=%s, reason=%s", code, reason) + self.api._on_close(code, reason) + + # ------------------------------------------------------------------ + # Stale-connection watchdog + # ------------------------------------------------------------------ + def _start_watchdog(self) -> None: + if self.policy.stale_timeout <= 0: + return + self._watchdog_task = asyncio.create_task(self._watchdog_loop()) + + def _stop_watchdog(self) -> None: + if self._watchdog_task and not self._watchdog_task.done(): + self._watchdog_task.cancel() + self._watchdog_task = None + + async def _watchdog_loop(self) -> None: + timeout = self.policy.stale_timeout + try: + while self._ws is not None and self._ws.state is State.OPEN: + await asyncio.sleep(min(timeout / 3.0, 10.0)) + silent_for = time.monotonic() - self.api.last_message_at + if silent_for > timeout: + logger.warning( + "WebSocket idle for %.1fs (>%ds); recycling.", + silent_for, timeout, + ) + try: + await self._ws.close(code=4000, reason="watchdog-stale") + except Exception: + pass + return + except asyncio.CancelledError: + pass + + # ------------------------------------------------------------------ + # Subscription replay after reconnect + # ------------------------------------------------------------------ + async def _replay_subscriptions(self) -> None: + """Re-issue every tracked subscription after a successful reconnect.""" + try: + for _ in range(40): # ~2 s + if self.state.status == WebsocketStatus.CONNECTED: + break + await asyncio.sleep(0.05) + except Exception: # pragma: no cover + pass + + subs = list(getattr(self.api, "_subscriptions", {}).values()) + for sub in subs: + try: + await self._replay_one(sub) + except Exception as e: + logger.warning( + "Failed to replay subscription kind=%s asset=%s: %s", + sub.kind, sub.asset, e, + ) + + async def _replay_one(self, sub: Any) -> None: + api = self.api + if sub.kind == "candle": + await api.subscribe_realtime_candle(sub.asset, sub.period or 0) + await api.chart_notification(sub.asset) + await api.follow_candle(sub.asset) + elif sub.kind == "candle_all_size": + await api.subscribe_all_size(sub.asset) + elif sub.kind == "mood": + instrument = sub.extra.get("instrument", "turbo-option") + await api.subscribe_Traders_mood(sub.asset, instrument) + elif sub.kind == "realtime_price": + await api.subscribe_realtime_candle(sub.asset, sub.period or 0) async def close(self) -> None: - """Close the websocket connection gracefully.""" + """Close the websocket gracefully and stop auto-reconnect.""" + self._closing = True + self.policy = ReconnectPolicy(enabled=False) + self._stop_watchdog() if self._ws and self._ws.state is not State.CLOSED: await self._ws.close() def is_alive(self) -> bool: - """ - Checks if the WebSocket connection is currently active. - - Returns: - bool: True if connected and open, False otherwise. - """ + """Return True iff the underlying socket is currently OPEN.""" return self._ws is not None and self._ws.state is State.OPEN diff --git a/requirements.txt b/requirements.txt index 52c0cc14..698bbe32 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,10 @@ certifi>=2025.1.31 httpx>=0.27.0 websockets>=12.0 -# orjson is optional (recommended for performance, but difficult to install on Termux) -# orjson>=3.9.0 pyfiglet>=1.0.2 beautifulsoup4>=4.12.3 fake-useragent>=2.2.0 rich>=13.7.0 -numpy>=2.0.0 +# orjson is optional (recommended for performance, but difficult to install on Termux). +# Install with: pip install "pyquotex[fast]" +# orjson>=3.9.0 diff --git a/scripts/seed_session_step1.py b/scripts/seed_session_step1.py new file mode 100644 index 00000000..c9ccaacd --- /dev/null +++ b/scripts/seed_session_step1.py @@ -0,0 +1,83 @@ +"""Step 1/2 — fire credentials POST so Quotex emails a fresh 2FA code, +then persist (as JSON) the cookies + CSRF token for step 2 to resume. + +After this runs successfully you'll see a new code in valejoapps@gmail.com. +Hand that code to step 2. +""" +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path + +from curl_cffi import requests + +from pyquotex.config import credentials + +BASE = "https://qxbroker.com" +LANG = "en" +IMPERSONATE = "firefox133" +UA = ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 14.4; rv:127.0) " + "Gecko/20100101 Firefox/127.0" +) +STATE_PATH = Path("/tmp/qx_seed_state.json") + + +def main() -> int: + email, password = credentials() + s = requests.Session(impersonate=IMPERSONATE) + s.headers.update({"User-Agent": UA, "Accept-Language": "en-US,en;q=0.5"}) + + r = s.get(f"{BASE}/{LANG}") + print(f" GET /{LANG} -> {r.status_code}") + if r.status_code != 200: + return 1 + + r = s.get(f"{BASE}/{LANG}/sign-in/modal/") + print(f" GET /sign-in/modal/ -> {r.status_code}") + m = re.search( + r']*name=["\']_token["\'][^>]*value=["\']([^"\']+)["\']', + r.text, + ) + if not m: + print(" ❌ No _token in modal") + return 1 + token = m.group(1) + + r = s.post( + f"{BASE}/{LANG}/sign-in/", + data={ + "_token": token, + "email": email, + "password": password, + "remember": 1, + }, + headers={ + "Referer": f"{BASE}/{LANG}/sign-in", + "Origin": BASE, + "Content-Type": "application/x-www-form-urlencoded", + }, + ) + print(f" POST /sign-in/ -> {r.status_code}") + if 'name="keep_code"' not in r.text: + print(" ⚠️ No 2FA form returned — login may have already passed or failed") + Path("/tmp/qx_step1_response.html").write_text(r.text) + return 2 + + STATE_PATH.write_text( + json.dumps({ + "cookies": s.cookies.get_dict(), + "token": token, + "email": email, + }, indent=2) + ) + print(f" ✅ State saved to {STATE_PATH}") + print(" → CHECK valejoapps@gmail.com for the LATEST 6-digit code,") + print(" then run scripts/seed_session_step2.py with that code.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/seed_session_step2.py b/scripts/seed_session_step2.py new file mode 100644 index 00000000..aeef9216 --- /dev/null +++ b/scripts/seed_session_step2.py @@ -0,0 +1,129 @@ +"""Step 2/2 — submit the freshly-arrived PIN code using state from step 1. + +Usage: + PYTHONPATH=. python scripts/seed_session_step2.py +""" +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path +from typing import Any + +from curl_cffi import requests + +from pyquotex.config import credentials, resource_path + +BASE = "https://qxbroker.com" +LANG = "en" +IMPERSONATE = "firefox133" +UA = ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 14.4; rv:127.0) " + "Gecko/20100101 Firefox/127.0" +) +STATE_PATH = Path("/tmp/qx_seed_state.json") + + +def _cookies_to_header(jar: dict[str, str]) -> str: + return "; ".join(f"{k}={v}" for k, v in jar.items()) + + +def main(code: str) -> int: + if not STATE_PATH.exists(): + print(" ❌ Run scripts/seed_session_step1.py first.") + return 1 + + state = json.loads(STATE_PATH.read_text()) + email = state["email"] + cookies = state["cookies"] + token = state["token"] + + s = requests.Session(impersonate=IMPERSONATE) + s.headers.update({"User-Agent": UA, "Accept-Language": "en-US,en;q=0.5"}) + for k, v in cookies.items(): + s.cookies.set(k, v, domain=".qxbroker.com") + + pwd = credentials()[1] + r = s.post( + f"{BASE}/{LANG}/sign-in/modal", + data={ + "_token": token, + "email": email, + "password": pwd, + "remember": 1, + "keep_code": 1, + "code": code, + }, + headers={ + "Referer": f"{BASE}/{LANG}/sign-in/modal", + "Origin": BASE, + "Content-Type": "application/x-www-form-urlencoded", + }, + ) + print(f" POST /sign-in/modal (with code) -> {r.status_code} final_url={r.url}") + if 'name="keep_code"' in r.text: + Path("/tmp/qx_step2_response.html").write_text(r.text) + m = re.search( + r']*class="[^"]*error[^"]*"[^>]*>(.*?)', + r.text, re.S, + ) + if m: + print(f" Error: {m.group(1).strip()[:200]}") + return 2 + + # We should now be at /trade + if "/trade" not in str(r.url): + r = s.get(f"{BASE}/{LANG}/trade") + print(f" GET /trade -> {r.status_code}") + + ssid: str | None = None + m = re.search(r"window\.settings\s*=\s*(\{.*?\});", r.text, re.S) + if m: + try: + data_settings = json.loads(m.group(1)) + ssid = data_settings.get("token") + except Exception as e: + print(f" window.settings parse failed: {e}") + if not ssid: + r2 = s.get( + f"{BASE}/api/v1/cabinets/digest", + headers={"Referer": f"{BASE}/{LANG}/trade"}, + ) + print(f" GET /api/v1/cabinets/digest -> {r2.status_code}") + if r2.status_code == 200: + try: + ssid = r2.json().get("data", {}).get("token") + except Exception: + pass + + if not ssid: + print(" ❌ Login passed but SSID not found.") + Path("/tmp/qx_step2_trade.html").write_text(r.text) + return 3 + + print(f" ✅ SSID: {ssid[:24]}…") + + cookie_jar = s.cookies.get_dict() + session_path = Path(resource_path("session.json")) + out: dict[str, Any] = {} + if session_path.exists(): + try: + out = json.loads(session_path.read_text()) + except Exception: + pass + out[email] = { + "cookies": _cookies_to_header(cookie_jar), + "token": ssid, + "user_agent": UA, + } + session_path.write_text(json.dumps(out, indent=4)) + print(f" ✅ Wrote session.json ({len(cookie_jar)} cookies)") + return 0 + + +if __name__ == "__main__": + if len(sys.argv) < 2: + print("Usage: seed_session_step2.py ") + sys.exit(1) + sys.exit(main(sys.argv[1].strip())) diff --git a/scripts/seed_session_via_curlcffi.py b/scripts/seed_session_via_curlcffi.py new file mode 100644 index 00000000..aad5c499 --- /dev/null +++ b/scripts/seed_session_via_curlcffi.py @@ -0,0 +1,174 @@ +"""Seed session.json by performing the full login via curl_cffi (TLS impersonation). + +The library's normal httpx login fails behind Cloudflare from datacenter +IPs because httpx's TLS fingerprint doesn't match a real Firefox. +``curl_cffi`` does proper JA3 impersonation, so we use it ONCE here just +to obtain the SSID + cookies, then write them to ``session.json`` so the +regular library code can pick up from there using its WebSocket flow. + +This script is NOT a runtime dependency of pyquotex — it's a smoke-test +helper. Requires ``pip install curl_cffi`` in the local venv only. + +Usage: + PYTHONPATH=. python scripts/seed_session_via_curlcffi.py +""" +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path +from typing import Any + +from curl_cffi import requests # local dev dep only + +from pyquotex.config import credentials, resource_path + +BASE = "https://qxbroker.com" +LANG = "en" +IMPERSONATE = "firefox133" +UA = ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 14.4; rv:127.0) " + "Gecko/20100101 Firefox/127.0" +) + + +def _cookies_to_header(jar: dict[str, str]) -> str: + return "; ".join(f"{k}={v}" for k, v in jar.items()) + + +def _extract_token(html: str) -> str | None: + m = re.search( + r']*name=["\']_token["\'][^>]*value=["\']([^"\']+)["\']', + html, + ) + return m.group(1) if m else None + + +def main() -> int: + email, password = credentials() + s = requests.Session(impersonate=IMPERSONATE) + s.headers.update({"User-Agent": UA, "Accept-Language": "en-US,en;q=0.5"}) + + # 1. Warm: pick up __cf_bm + laravel_session + r = s.get(f"{BASE}/{LANG}") + print(f" GET /{LANG} -> {r.status_code} (cookies: {list(s.cookies.keys())})") + if r.status_code != 200: + print(" ❌ Cloudflare still blocking — try a different impersonate value") + return 1 + + # 2. Get sign-in modal + CSRF _token + r = s.get(f"{BASE}/{LANG}/sign-in/modal/") + print(f" GET /sign-in/modal/ -> {r.status_code}") + token = _extract_token(r.text) + if not token: + print(" ❌ Could not find _token in modal page") + return 1 + print(f" _token: {token[:32]}…") + + # 3. POST credentials — exactly the lib's path: /sign-in/ (trailing slash) + data = { + "_token": token, + "email": email, + "password": password, + "remember": 1, + } + r = s.post( + f"{BASE}/{LANG}/sign-in/", + data=data, + headers={ + "Referer": f"{BASE}/{LANG}/sign-in", + "Origin": BASE, + "Content-Type": "application/x-www-form-urlencoded", + }, + ) + print(f" POST /sign-in/ -> {r.status_code} final_url={r.url}") + + if 'name="keep_code"' in r.text: + print(" ⚠️ 2FA challenge required — paste the code:") + code = input(" > ").strip() + data["keep_code"] = 1 + data["code"] = code + r = s.post( + f"{BASE}/{LANG}/sign-in/modal", + data=data, + headers={ + "Referer": f"{BASE}/{LANG}/sign-in/modal", + "Origin": BASE, + "Content-Type": "application/x-www-form-urlencoded", + }, + ) + print(f" POST /sign-in/modal -> {r.status_code} final_url={r.url}") + Path("/tmp/qx_pin_response.html").write_text(r.text) + print(" (saved response to /tmp/qx_pin_response.html)") + # Snippet of error if any + import re as _re + err = _re.search( + r']*class="[^"]*error[^"]*"[^>]*>(.*?)', + r.text, _re.S, + ) + if err: + print(f" Error block: {err.group(1).strip()[:200]}") + if 'name="keep_code"' in r.text: + print(" ⚠️ Still on PIN form after submitting — code rejected or expired") + + if "/trade" not in str(r.url): + # If still not on /trade, hit it explicitly + r = s.get(f"{BASE}/{LANG}/trade") + print(f" GET /trade -> {r.status_code}") + + ssid: str | None = None + m = re.search(r"window\.settings\s*=\s*(\{.*?\});", r.text, re.S) + if m: + try: + settings_data = json.loads(m.group(1)) + ssid = settings_data.get("token") + if ssid: + print(f" SSID via window.settings: {ssid[:24]}…") + except Exception as e: + print(f" ⚠️ window.settings parse failed: {e}") + + cookie_jar = s.cookies.get_dict() + + # Fallback: /api/v1/cabinets/digest (used by Login.get_profile) + if not ssid: + r2 = s.get( + f"{BASE}/api/v1/cabinets/digest", + headers={"Referer": f"{BASE}/{LANG}/trade"}, + ) + print(f" GET /api/v1/cabinets/digest -> {r2.status_code}") + if r2.status_code == 200: + try: + ssid = r2.json().get("data", {}).get("token") + if ssid: + print(f" SSID via /digest: {ssid[:24]}…") + except Exception as e: + print(f" ⚠️ digest parse failed: {e}") + + if not ssid: + print(" ❌ Could not extract SSID from /trade page") + print(" Cookies available:", list(cookie_jar.keys())) + return 2 + + cookies_header = _cookies_to_header(cookie_jar) + out: dict[str, Any] = {} + session_path = Path(resource_path("session.json")) + if session_path.exists(): + try: + out = json.loads(session_path.read_text()) + except Exception: + pass + out[email] = { + "cookies": cookies_header, + "token": ssid, + "user_agent": UA, + } + session_path.write_text(json.dumps(out, indent=4)) + print(f"\n ✅ Wrote session.json for {email}") + print(f" cookies: {len(cookie_jar)} entries") + print(f" ssid: {ssid[:16]}…") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/smoke_demo.py b/scripts/smoke_demo.py new file mode 100644 index 00000000..69b4e619 --- /dev/null +++ b/scripts/smoke_demo.py @@ -0,0 +1,195 @@ +"""DEMO-account smoke test for the resilience + perf PR. + +Exercises everything that cannot be verified offline: + + 1. Async context manager (`async with Quotex(...)`). + 2. Auth on DEMO and balance retrieval. + 3. `get_candles` with `use_cache=True` (hit on second call). + 4. Streaming indicators warmed up from real candles. + 5. Subscription tracking after `start_candles_stream`. + 6. Manual auto-reconnect: close the underlying socket from underneath + the client and confirm it comes back up + replays the candle + subscription. + +Run: + PYTHONPATH=. python scripts/smoke_demo.py +""" +from __future__ import annotations + +import asyncio +import logging +import sys +import time +from typing import Any + +from pyquotex import Candle, ReconnectPolicy +from pyquotex.config import credentials +from pyquotex.stable_api import Quotex +from pyquotex.utils.streaming_indicators import StreamingRSI, StreamingSMA + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s %(message)s", +) +log = logging.getLogger("smoke") + + +SEPARATOR = "─" * 60 + + +def banner(title: str) -> None: + print(f"\n{SEPARATOR}\n {title}\n{SEPARATOR}") + + +async def step_balance(q: Quotex) -> None: + banner("Step 1 — balance + profile (event-driven path)") + profile = await q.get_profile() + balance = await q.get_balance() + print(f" Nick: {profile.nick_name} Country: {profile.country_name}") + print(f" Currency: {profile.currency_code} Balance(DEMO): {balance}") + + +async def step_candles_cache(q: Quotex) -> None: + banner("Step 2 — get_candles with use_cache=True") + asset = "EURUSD_otc" + period = 60 + + t0 = time.monotonic() + first = await q.get_candles(asset, None, 3600, period, use_cache=True) + t1 = time.monotonic() - t0 + n_first = len(first) if first else 0 + print(f" First call: {n_first} candles in {t1*1000:.1f} ms") + + t0 = time.monotonic() + second = await q.get_candles(asset, None, 3600, period, use_cache=True) + t2 = time.monotonic() - t0 + n_second = len(second) if second else 0 + print(f" Cached call: {n_second} candles in {t2*1000:.1f} ms") + if t2 < t1 * 0.5 or t2 < 0.005: + print(" ✅ cache hit confirmed (second call is much faster)") + else: + print(" ⚠️ expected speedup not observed — TTL may have expired") + + return first, asset, period # type: ignore[return-value] + + +async def step_streaming_indicators(candles: list[dict[str, Any]]) -> None: + banner("Step 3 — streaming indicators on live candles") + if not candles: + print(" ⚠️ no candles to feed — skipping") + return + + sma14 = StreamingSMA(period=14) + rsi14 = StreamingRSI(period=14) + last_sma: float | None = None + last_rsi: float | None = None + for c in candles: + close = float(c["close"]) + last_sma = sma14.update(close) or last_sma + last_rsi = rsi14.update(close) or last_rsi + + closes = [float(c["close"]) for c in candles] + print(f" Candles fed: {len(closes)}") + print(f" SMA(14) latest: {last_sma}") + print(f" RSI(14) latest: {last_rsi}") + # Sanity check against batch + from pyquotex.utils.indicators import TechnicalIndicators + batch = TechnicalIndicators.calculate_sma(closes, 14) + batch_last = batch[-1] if batch else None + print(f" SMA(14) batch: {batch_last} (rounded match: " + f"{round(last_sma or 0, 2) == round(batch_last or 0, 2)})") + + +async def step_typed_candle(candles: list[dict[str, Any]]) -> None: + banner("Step 4 — Candle.from_dict typed conversion") + if not candles: + return + typed = [Candle.from_dict(c) for c in candles[-3:]] + for c in typed: + print(f" t={c.time} o={c.open} h={c.high} l={c.low} c={c.close} " + f"color={c.color}") + + +async def step_subscription_replay(q: Quotex, asset: str, period: int) -> None: + banner("Step 5 — subscription tracking & forced reconnect") + + # Make sure the subscription is registered. + await q.start_candles_stream(asset, period) + subs = q.api._subscriptions # noqa: SLF001 — smoke test + print(f" Subscriptions tracked: {list(subs.keys())}") + assert any(s.startswith("candle:" + asset) for s in subs), \ + "candle subscription not tracked" + + # Force a reconnect by closing the underlying socket directly. + ws_client = q.api.websocket_client + print(" Forcing socket close to trigger auto-reconnect…") + raw_ws = ws_client._ws # noqa: SLF001 + if raw_ws is not None: + await raw_ws.close(code=4001, reason="smoke-test-forced") + + # Wait up to 20s for the reconnect loop to bring it back. + for i in range(40): + await asyncio.sleep(0.5) + if ws_client.is_alive(): + print(f" ✅ Reconnected after ~{(i + 1) * 0.5:.1f}s " + f"(open_count={ws_client._open_count})") + break + else: + print(" ❌ Did not reconnect within 20s") + return + + # Confirm subscription is still tracked (replay does NOT clear it). + subs_after = q.api._subscriptions # noqa: SLF001 + if any(s.startswith("candle:" + asset) for s in subs_after): + print(f" ✅ Subscription still tracked post-reconnect: " + f"{list(subs_after.keys())}") + + # Confirm fresh candles flow. + fresh = await q.get_candles(asset, None, 600, period) + print(f" Post-reconnect candles fetched: {len(fresh or [])}") + + +async def main() -> None: + email, password = credentials() + policy = ReconnectPolicy( + enabled=True, + max_attempts=0, + base_delay=0.5, + max_delay=10.0, + jitter=0.1, + stale_timeout=90.0, + ) + + log.info("Connecting as %s with auto-reconnect…", email) + real_ua = ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 14.4; rv:127.0) " + "Gecko/20100101 Firefox/127.0" + ) + async with Quotex( + email=email, + password=password, + lang="en", + user_agent=real_ua, + reconnect_policy=policy, + ) as q: + q.set_account_mode("PRACTICE") + # Re-issue change_account on the WS so the session is on DEMO. + if q.api is not None: + from pyquotex.utils.account_type import AccountType + await q.api.change_account(AccountType.DEMO) + await asyncio.sleep(0.5) + + await step_balance(q) + candles, asset, period = await step_candles_cache(q) + await step_streaming_indicators(candles) + await step_typed_candle(candles) + await step_subscription_replay(q, asset, period) + + banner("Done — context manager cleanly closed the connection.") + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + sys.exit(130) diff --git a/tests/fixtures/api_surface.json b/tests/fixtures/api_surface.json index f9384c44..9f077158 100644 --- a/tests/fixtures/api_surface.json +++ b/tests/fixtures/api_surface.json @@ -10,37 +10,37 @@ "name": "self" }, { - "annotation": "", + "annotation": "float", "default": "", "kind": "POSITIONAL_OR_KEYWORD", "name": "amount" }, { - "annotation": "", + "annotation": "str", "default": "", "kind": "POSITIONAL_OR_KEYWORD", "name": "asset" }, { - "annotation": "", + "annotation": "str", "default": "", "kind": "POSITIONAL_OR_KEYWORD", "name": "direction" }, { - "annotation": "", + "annotation": "int", "default": "", "kind": "POSITIONAL_OR_KEYWORD", "name": "duration" }, { - "annotation": "", + "annotation": "str", "default": "'TIME'", "kind": "POSITIONAL_OR_KEYWORD", "name": "time_mode" } ], - "return_annotation": "tuple[bool, typing.Any]" + "return_annotation": "tuple[bool, Any]" } }, "buy_optimized": { @@ -98,37 +98,37 @@ "name": "self" }, { - "annotation": "", + "annotation": "str", "default": "", "kind": "POSITIONAL_OR_KEYWORD", "name": "asset" }, { - "annotation": "", + "annotation": "str", "default": "", "kind": "POSITIONAL_OR_KEYWORD", "name": "indicator" }, { - "annotation": "dict[str, typing.Any] | None", + "annotation": "dict[str, Any] | None", "default": "None", "kind": "POSITIONAL_OR_KEYWORD", "name": "params" }, { - "annotation": "", + "annotation": "int", "default": "3600", "kind": "POSITIONAL_OR_KEYWORD", "name": "history_size" }, { - "annotation": "", + "annotation": "int", "default": "60", "kind": "POSITIONAL_OR_KEYWORD", "name": "timeframe" } ], - "return_annotation": "dict[str, typing.Any]" + "return_annotation": "dict[str, Any]" } }, "change_account": { @@ -142,13 +142,13 @@ "name": "self" }, { - "annotation": "", + "annotation": "str", "default": "", "kind": "POSITIONAL_OR_KEYWORD", "name": "balance_mode" }, { - "annotation": "", + "annotation": "int", "default": "0", "kind": "POSITIONAL_OR_KEYWORD", "name": "tournament_id" @@ -168,13 +168,13 @@ "name": "self" }, { - "annotation": "", + "annotation": "int", "default": "", "kind": "POSITIONAL_OR_KEYWORD", "name": "time_offset" } ], - "return_annotation": "typing.Any" + "return_annotation": "Any" } }, "check_asset_open": { @@ -188,13 +188,13 @@ "name": "self" }, { - "annotation": "", + "annotation": "str", "default": "", "kind": "POSITIONAL_OR_KEYWORD", "name": "asset_name" } ], - "return_annotation": "tuple[list[typing.Any] | None, tuple[typing.Any, typing.Any, typing.Any]]" + "return_annotation": "tuple[list[Any] | None, tuple[Any, Any, Any]]" } }, "check_connect": { @@ -228,7 +228,7 @@ "name": "order_id" }, { - "annotation": "", + "annotation": "int", "default": "0", "kind": "POSITIONAL_OR_KEYWORD", "name": "duration" @@ -282,13 +282,13 @@ "name": "amount" }, { - "annotation": "", + "annotation": "int", "default": "30", "kind": "POSITIONAL_OR_KEYWORD", "name": "timeout" } ], - "return_annotation": "dict[str, typing.Any]" + "return_annotation": "dict[str, Any]" } }, "get_all_asset_name": { @@ -330,19 +330,19 @@ "name": "self" }, { - "annotation": "", + "annotation": "str", "default": "", "kind": "POSITIONAL_OR_KEYWORD", "name": "asset_name" }, { - "annotation": "", + "annotation": "bool", "default": "False", "kind": "POSITIONAL_OR_KEYWORD", "name": "force_open" } ], - "return_annotation": "tuple[str, typing.Any]" + "return_annotation": "tuple[str, Any]" } }, "get_balance": { @@ -356,13 +356,13 @@ "name": "self" }, { - "annotation": "", + "annotation": "int", "default": "30", "kind": "POSITIONAL_OR_KEYWORD", "name": "timeout" } ], - "return_annotation": "" + "return_annotation": "float" } }, "get_balance_optimized": { @@ -396,25 +396,25 @@ "name": "self" }, { - "annotation": "", + "annotation": "str", "default": "", "kind": "POSITIONAL_OR_KEYWORD", "name": "asset" }, { - "annotation": "", + "annotation": "int", "default": "", "kind": "POSITIONAL_OR_KEYWORD", "name": "period" }, { - "annotation": "", + "annotation": "int", "default": "30", "kind": "POSITIONAL_OR_KEYWORD", "name": "timeout" } ], - "return_annotation": "list[dict[str, typing.Any]] | None" + "return_annotation": "list[dict[str, Any]] | None" } }, "get_candles": { @@ -428,7 +428,7 @@ "name": "self" }, { - "annotation": "", + "annotation": "str", "default": "", "kind": "POSITIONAL_OR_KEYWORD", "name": "asset" @@ -440,31 +440,37 @@ "name": "end_from_time" }, { - "annotation": "", + "annotation": "int", "default": "", "kind": "POSITIONAL_OR_KEYWORD", "name": "offset" }, { - "annotation": "", + "annotation": "int", "default": "", "kind": "POSITIONAL_OR_KEYWORD", "name": "period" }, { - "annotation": "", + "annotation": "bool", "default": "False", "kind": "POSITIONAL_OR_KEYWORD", "name": "progressive" }, { - "annotation": "", + "annotation": "int", "default": "30", "kind": "POSITIONAL_OR_KEYWORD", "name": "timeout" + }, + { + "annotation": "bool", + "default": "False", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "use_cache" } ], - "return_annotation": "list[dict[str, typing.Any]] | None" + "return_annotation": "list[dict[str, Any]] | None" } }, "get_candles_deep": { @@ -478,19 +484,19 @@ "name": "self" }, { - "annotation": "typing.Any", + "annotation": "Any", "default": "", "kind": "VAR_POSITIONAL", "name": "args" }, { - "annotation": "typing.Any", + "annotation": "Any", "default": "", "kind": "VAR_KEYWORD", "name": "kwargs" } ], - "return_annotation": "list[dict[str, typing.Any]]" + "return_annotation": "list[dict[str, Any]]" } }, "get_candles_optimized": { @@ -536,43 +542,43 @@ "name": "self" }, { - "annotation": "", + "annotation": "str", "default": "", "kind": "POSITIONAL_OR_KEYWORD", "name": "asset" }, { - "annotation": "", + "annotation": "int", "default": "", "kind": "POSITIONAL_OR_KEYWORD", "name": "amount_of_seconds" }, { - "annotation": "", + "annotation": "int", "default": "", "kind": "POSITIONAL_OR_KEYWORD", "name": "period" }, { - "annotation": "", + "annotation": "int", "default": "30", "kind": "POSITIONAL_OR_KEYWORD", "name": "timeout" }, { - "annotation": "", + "annotation": "int", "default": "5", "kind": "POSITIONAL_OR_KEYWORD", "name": "max_workers" }, { - "annotation": "typing.Optional[typing.Callable[[int, int, int, str], NoneType]]", + "annotation": "Callable[[int, int, int, str], None] | None", "default": "None", "kind": "POSITIONAL_OR_KEYWORD", "name": "progress_callback" } ], - "return_annotation": "list[dict[str, typing.Any]]" + "return_annotation": "list[dict[str, Any]]" } }, "get_history": { @@ -586,7 +592,7 @@ "name": "self" } ], - "return_annotation": "list[dict[str, typing.Any]]" + "return_annotation": "list[dict[str, Any]]" } }, "get_history_line": { @@ -600,31 +606,31 @@ "name": "self" }, { - "annotation": "", + "annotation": "str", "default": "", "kind": "POSITIONAL_OR_KEYWORD", "name": "asset" }, { - "annotation": "", + "annotation": "float", "default": "", "kind": "POSITIONAL_OR_KEYWORD", "name": "end_from_time" }, { - "annotation": "", + "annotation": "int", "default": "", "kind": "POSITIONAL_OR_KEYWORD", "name": "offset" }, { - "annotation": "", + "annotation": "int", "default": "30", "kind": "POSITIONAL_OR_KEYWORD", "name": "timeout" } ], - "return_annotation": "dict[str, typing.Any] | None" + "return_annotation": "dict[str, Any] | None" } }, "get_instruments": { @@ -638,13 +644,13 @@ "name": "self" }, { - "annotation": "", + "annotation": "int", "default": "30", "kind": "POSITIONAL_OR_KEYWORD", "name": "timeout" } ], - "return_annotation": "list[typing.Any]" + "return_annotation": "list[Any]" } }, "get_instruments_optimized": { @@ -678,7 +684,7 @@ "name": "self" } ], - "return_annotation": "dict[str, typing.Any]" + "return_annotation": "dict[str, Any]" } }, "get_payout_by_asset": { @@ -692,19 +698,19 @@ "name": "self" }, { - "annotation": "", + "annotation": "str", "default": "", "kind": "POSITIONAL_OR_KEYWORD", "name": "asset_name" }, { - "annotation": "", + "annotation": "str", "default": "'1'", "kind": "POSITIONAL_OR_KEYWORD", "name": "timeframe" } ], - "return_annotation": "float | dict[str, typing.Any] | None" + "return_annotation": "float | dict[str, Any] | None" } }, "get_profile": { @@ -718,7 +724,7 @@ "name": "self" } ], - "return_annotation": "typing.Any" + "return_annotation": "Any" } }, "get_profit": { @@ -732,7 +738,7 @@ "name": "self" } ], - "return_annotation": "" + "return_annotation": "float" } }, "get_realtime_candles": { @@ -746,13 +752,13 @@ "name": "self" }, { - "annotation": "", + "annotation": "str", "default": "", "kind": "POSITIONAL_OR_KEYWORD", "name": "asset" } ], - "return_annotation": "list[typing.Any] | dict[typing.Any, typing.Any]" + "return_annotation": "list[Any] | dict[Any, Any]" } }, "get_realtime_price": { @@ -766,13 +772,13 @@ "name": "self" }, { - "annotation": "", + "annotation": "str", "default": "", "kind": "POSITIONAL_OR_KEYWORD", "name": "asset" } ], - "return_annotation": "list[dict[str, typing.Any]]" + "return_annotation": "list[dict[str, Any]]" } }, "get_realtime_sentiment": { @@ -786,13 +792,13 @@ "name": "self" }, { - "annotation": "", + "annotation": "str", "default": "", "kind": "POSITIONAL_OR_KEYWORD", "name": "asset" } ], - "return_annotation": "dict[str, typing.Any]" + "return_annotation": "dict[str, Any]" } }, "get_result": { @@ -806,13 +812,13 @@ "name": "self" }, { - "annotation": "", + "annotation": "str", "default": "", "kind": "POSITIONAL_OR_KEYWORD", "name": "operation_id" } ], - "return_annotation": "tuple[str | None, typing.Any]" + "return_annotation": "tuple[str | None, Any]" } }, "get_server_time": { @@ -826,7 +832,7 @@ "name": "self" } ], - "return_annotation": "" + "return_annotation": "int" } }, "get_signal_data": { @@ -840,7 +846,7 @@ "name": "self" } ], - "return_annotation": "dict[str, typing.Any]" + "return_annotation": "dict[str, Any]" } }, "get_trader_history": { @@ -854,19 +860,19 @@ "name": "self" }, { - "annotation": "", + "annotation": "int", "default": "", "kind": "POSITIONAL_OR_KEYWORD", "name": "account_type" }, { - "annotation": "", + "annotation": "int", "default": "", "kind": "POSITIONAL_OR_KEYWORD", "name": "page_number" } ], - "return_annotation": "dict[str, typing.Any]" + "return_annotation": "dict[str, Any]" } }, "open_pending": { @@ -880,25 +886,25 @@ "name": "self" }, { - "annotation": "", + "annotation": "float", "default": "", "kind": "POSITIONAL_OR_KEYWORD", "name": "amount" }, { - "annotation": "", + "annotation": "str", "default": "", "kind": "POSITIONAL_OR_KEYWORD", "name": "asset" }, { - "annotation": "", + "annotation": "str", "default": "", "kind": "POSITIONAL_OR_KEYWORD", "name": "direction" }, { - "annotation": "", + "annotation": "int", "default": "", "kind": "POSITIONAL_OR_KEYWORD", "name": "duration" @@ -910,7 +916,7 @@ "name": "open_time" } ], - "return_annotation": "tuple[bool, typing.Any]" + "return_annotation": "tuple[bool, Any]" } }, "opening_closing_current_candle": { @@ -924,19 +930,19 @@ "name": "self" }, { - "annotation": "", + "annotation": "str", "default": "", "kind": "POSITIONAL_OR_KEYWORD", "name": "asset" }, { - "annotation": "", + "annotation": "int", "default": "0", "kind": "POSITIONAL_OR_KEYWORD", "name": "period" } ], - "return_annotation": "dict[str, typing.Any]" + "return_annotation": "dict[str, Any]" } }, "prepare_candles": { @@ -950,25 +956,25 @@ "name": "self" }, { - "annotation": "", + "annotation": "str", "default": "", "kind": "POSITIONAL_OR_KEYWORD", "name": "asset" }, { - "annotation": "", + "annotation": "int", "default": "", "kind": "POSITIONAL_OR_KEYWORD", "name": "period" }, { - "annotation": "list[typing.Any] | None", + "annotation": "list[Any] | None", "default": "None", "kind": "POSITIONAL_OR_KEYWORD", "name": "history" } ], - "return_annotation": "list[dict[str, typing.Any]]" + "return_annotation": "list[dict[str, Any]]" } }, "re_subscribe_stream": { @@ -1016,13 +1022,13 @@ "name": "options_ids" }, { - "annotation": "", + "annotation": "int", "default": "30", "kind": "POSITIONAL_OR_KEYWORD", "name": "timeout" } ], - "return_annotation": "dict[str, typing.Any]" + "return_annotation": "dict[str, Any]" } }, "sell_option_optimized": { @@ -1062,7 +1068,7 @@ "name": "self" }, { - "annotation": "", + "annotation": "str", "default": "'PRACTICE'", "kind": "POSITIONAL_OR_KEYWORD", "name": "balance_mode" @@ -1114,13 +1120,13 @@ "name": "self" }, { - "annotation": "", + "annotation": "str", "default": "", "kind": "POSITIONAL_OR_KEYWORD", "name": "asset" } ], - "return_annotation": "" + "return_annotation": "bool" } }, "start_candles_one_stream": { @@ -1134,19 +1140,19 @@ "name": "self" }, { - "annotation": "", + "annotation": "str", "default": "", "kind": "POSITIONAL_OR_KEYWORD", "name": "asset" }, { - "annotation": "", + "annotation": "int", "default": "", "kind": "POSITIONAL_OR_KEYWORD", "name": "size" } ], - "return_annotation": "" + "return_annotation": "bool" } }, "start_candles_stream": { @@ -1160,13 +1166,13 @@ "name": "self" }, { - "annotation": "", + "annotation": "str", "default": "'EURUSD'", "kind": "POSITIONAL_OR_KEYWORD", "name": "asset" }, { - "annotation": "", + "annotation": "int", "default": "0", "kind": "POSITIONAL_OR_KEYWORD", "name": "period" @@ -1186,13 +1192,13 @@ "name": "self" }, { - "annotation": "", + "annotation": "str", "default": "", "kind": "POSITIONAL_OR_KEYWORD", "name": "asset" }, { - "annotation": "", + "annotation": "str", "default": "'turbo-option'", "kind": "POSITIONAL_OR_KEYWORD", "name": "instrument" @@ -1212,25 +1218,25 @@ "name": "self" }, { - "annotation": "", + "annotation": "str", "default": "", "kind": "POSITIONAL_OR_KEYWORD", "name": "asset" }, { - "annotation": "", + "annotation": "int", "default": "0", "kind": "POSITIONAL_OR_KEYWORD", "name": "period" }, { - "annotation": "", + "annotation": "int", "default": "30", "kind": "POSITIONAL_OR_KEYWORD", "name": "timeout" } ], - "return_annotation": "dict[int, typing.Any]" + "return_annotation": "dict[int, Any]" } }, "start_realtime_price": { @@ -1244,25 +1250,25 @@ "name": "self" }, { - "annotation": "", + "annotation": "str", "default": "", "kind": "POSITIONAL_OR_KEYWORD", "name": "asset" }, { - "annotation": "", + "annotation": "int", "default": "0", "kind": "POSITIONAL_OR_KEYWORD", "name": "period" }, { - "annotation": "", + "annotation": "int", "default": "30", "kind": "POSITIONAL_OR_KEYWORD", "name": "timeout" } ], - "return_annotation": "dict[str, typing.Any]" + "return_annotation": "dict[str, Any]" } }, "start_realtime_sentiment": { @@ -1276,25 +1282,25 @@ "name": "self" }, { - "annotation": "", + "annotation": "str", "default": "", "kind": "POSITIONAL_OR_KEYWORD", "name": "asset" }, { - "annotation": "", + "annotation": "int", "default": "0", "kind": "POSITIONAL_OR_KEYWORD", "name": "period" }, { - "annotation": "", + "annotation": "int", "default": "30", "kind": "POSITIONAL_OR_KEYWORD", "name": "timeout" } ], - "return_annotation": "dict[str, typing.Any]" + "return_annotation": "dict[str, Any]" } }, "start_remaing_time": { @@ -1336,7 +1342,7 @@ "name": "self" }, { - "annotation": "", + "annotation": "str", "default": "", "kind": "POSITIONAL_OR_KEYWORD", "name": "asset" @@ -1356,49 +1362,49 @@ "name": "self" }, { - "annotation": "", + "annotation": "str", "default": "'EURUSD'", "kind": "POSITIONAL_OR_KEYWORD", "name": "asset" }, { - "annotation": "", + "annotation": "int", "default": "0", "kind": "POSITIONAL_OR_KEYWORD", "name": "period" }, { - "annotation": "", + "annotation": "str", "default": "'TIMER'", "kind": "POSITIONAL_OR_KEYWORD", "name": "time_mode" }, { - "annotation": "", + "annotation": "int", "default": "5", "kind": "POSITIONAL_OR_KEYWORD", "name": "deal" }, { - "annotation": "", + "annotation": "bool", "default": "False", "kind": "POSITIONAL_OR_KEYWORD", "name": "percent_mode" }, { - "annotation": "", + "annotation": "int", "default": "1", "kind": "POSITIONAL_OR_KEYWORD", "name": "percent_deal" }, { - "annotation": "", + "annotation": "int", "default": "30", "kind": "POSITIONAL_OR_KEYWORD", "name": "timeout" } ], - "return_annotation": "dict[str, typing.Any]" + "return_annotation": "dict[str, Any]" } }, "subscribe_indicator": { @@ -1412,31 +1418,31 @@ "name": "self" }, { - "annotation": "", + "annotation": "str", "default": "", "kind": "POSITIONAL_OR_KEYWORD", "name": "asset" }, { - "annotation": "", + "annotation": "str", "default": "", "kind": "POSITIONAL_OR_KEYWORD", "name": "indicator" }, { - "annotation": "dict[str, typing.Any] | None", + "annotation": "dict[str, Any] | None", "default": "None", "kind": "POSITIONAL_OR_KEYWORD", "name": "params" }, { - "annotation": "typing.Optional[typing.Callable[[dict[str, typing.Any]], typing.Any]]", + "annotation": "Callable[[dict[str, Any]], Any] | None", "default": "None", "kind": "POSITIONAL_OR_KEYWORD", "name": "callback" }, { - "annotation": "", + "annotation": "int", "default": "60", "kind": "POSITIONAL_OR_KEYWORD", "name": "timeframe" diff --git a/tests/test_cache.py b/tests/test_cache.py new file mode 100644 index 00000000..cfdd7ecc --- /dev/null +++ b/tests/test_cache.py @@ -0,0 +1,63 @@ +"""Tests for ``pyquotex.utils.cache.TTLCache``.""" +import time + +import pytest + +from pyquotex.utils.cache import TTLCache + + +@pytest.mark.unit +def test_set_and_get() -> None: + c: TTLCache[str, int] = TTLCache(maxsize=4, ttl=1.0) + c.set("a", 1) + assert c.get("a") == 1 + + +@pytest.mark.unit +def test_lru_eviction_on_overflow() -> None: + c: TTLCache[str, int] = TTLCache(maxsize=2, ttl=60) + c.set("a", 1) + c.set("b", 2) + c.set("c", 3) + assert c.get("a") is None # evicted as least-recent + assert c.get("b") == 2 + assert c.get("c") == 3 + + +@pytest.mark.unit +def test_get_moves_to_end() -> None: + c: TTLCache[str, int] = TTLCache(maxsize=2, ttl=60) + c.set("a", 1) + c.set("b", 2) + _ = c.get("a") + c.set("c", 3) # should evict 'b', not 'a' since 'a' was accessed + assert c.get("a") == 1 + assert c.get("b") is None + + +@pytest.mark.unit +def test_lazy_expiration() -> None: + c: TTLCache[str, int] = TTLCache(maxsize=4, ttl=0.05) + c.set("a", 1) + time.sleep(0.07) + assert c.get("a") is None + + +@pytest.mark.unit +def test_invalidate_and_clear() -> None: + c: TTLCache[str, int] = TTLCache(maxsize=4, ttl=60) + c.set("a", 1) + c.set("b", 2) + c.invalidate("a") + assert c.get("a") is None + c.clear() + assert c.get("b") is None + assert len(c) == 0 + + +@pytest.mark.unit +def test_rejects_bad_params() -> None: + with pytest.raises(ValueError): + TTLCache(maxsize=0, ttl=10) + with pytest.raises(ValueError): + TTLCache(maxsize=4, ttl=0) diff --git a/tests/test_dispatch_table.py b/tests/test_dispatch_table.py new file mode 100644 index 00000000..ade953f4 --- /dev/null +++ b/tests/test_dispatch_table.py @@ -0,0 +1,86 @@ +"""Tests for the dispatch-table refactor of ``QuotexAPI._on_message``. + +These exercise the control-event handlers directly, without involving the +WebSocket or HTTP layers. +""" +import pytest + +from pyquotex.api import QuotexAPI +from pyquotex.global_value import AuthStatus + + +def _make_api() -> QuotexAPI: + return QuotexAPI( + host="qxbroker.com", + username="x", + password="x", + lang="en", + proxies=None, + resource_path=".", + user_data_dir="browser", + on_otp_callback=None, + ) + + +@pytest.mark.unit +def test_control_handlers_registered() -> None: + api = _make_api() + for event in ( + "s_authorization", + "instruments/list", + "trader/history", + "balance", + "candle-generated", + "sentiment", + ): + assert event in api._control_handlers + + +@pytest.mark.asyncio +async def test_balance_handler_sets_slot_and_state() -> None: + api = _make_api() + payload = {"demoBalance": 100.0, "liveBalance": 1.0} + await api._control_handlers["balance"](payload) + assert api.account_balance == payload + assert api.slots.balance.is_set() + + +@pytest.mark.asyncio +async def test_auth_handler_flips_state() -> None: + api = _make_api() + await api._control_handlers["s_authorization"](None) + assert api.state.auth_status == AuthStatus.AUTHENTICATED + + +@pytest.mark.asyncio +async def test_instruments_list_handler_caches_list() -> None: + api = _make_api() + rows = [[1, "EURUSD", "EUR/USD"]] + await api._control_handlers["instruments/list"](rows) + assert api.instruments == rows + + +@pytest.mark.asyncio +async def test_instruments_list_handler_handles_placeholder() -> None: + api = _make_api() + placeholder = {"_placeholder": True, "num": 0} + await api._control_handlers["instruments/list"](placeholder) + assert "instruments/list" in api._temp_status + + +@pytest.mark.asyncio +async def test_sentiment_handler_indexes_by_asset() -> None: + api = _make_api() + payload = {"asset": "EURUSD", "value": 0.6} + await api._control_handlers["sentiment"](payload) + assert api.traders_mood["EURUSD"] == payload + assert api.realtime_sentiment["EURUSD"] == payload + + +@pytest.mark.asyncio +async def test_candle_generated_handler_caches_by_asset_period() -> None: + api = _make_api() + payload = {"asset": "EURUSD", "period": 60, "close": 1.1} + await api._control_handlers["candle-generated"](payload) + assert api.candle_generated_check["EURUSD"][60] == payload + assert api.candle_generated_all_size_check["EURUSD"] == payload diff --git a/tests/test_json_utils.py b/tests/test_json_utils.py new file mode 100644 index 00000000..ea977ca1 --- /dev/null +++ b/tests/test_json_utils.py @@ -0,0 +1,33 @@ +"""Tests for ``pyquotex.utils.json_utils``.""" +import pytest + +from pyquotex.utils import json_utils as j + + +@pytest.mark.unit +def test_dumps_returns_bytes() -> None: + assert isinstance(j.dumps({"a": 1}), bytes) + + +@pytest.mark.unit +def test_dumps_bytes_is_alias() -> None: + assert j.dumps_bytes({"a": 1}) == j.dumps({"a": 1}) + + +@pytest.mark.unit +def test_dumps_str_returns_str() -> None: + s = j.dumps_str({"a": 1}) + assert isinstance(s, str) + assert '"a"' in s and "1" in s + + +@pytest.mark.unit +def test_roundtrip() -> None: + payload = {"k": [1, 2, 3], "s": "x"} + assert j.loads(j.dumps(payload)) == payload + assert j.loads(j.dumps_str(payload)) == payload + + +@pytest.mark.unit +def test_has_orjson_flag_is_bool() -> None: + assert isinstance(j.HAS_ORJSON, bool) diff --git a/tests/test_reconnect.py b/tests/test_reconnect.py new file mode 100644 index 00000000..2366a70d --- /dev/null +++ b/tests/test_reconnect.py @@ -0,0 +1,243 @@ +"""Tests for the resilience layer: ReconnectPolicy + WebsocketClient. + +These tests stub out the actual ``websockets.connect`` call and exercise +:meth:`WebsocketClient.run_forever` to verify the auto-reconnect loop, +backoff, watchdog, and subscription replay logic in isolation. +""" +from __future__ import annotations + +import asyncio +import time +from contextlib import asynccontextmanager +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from websockets.exceptions import ConnectionClosed +from websockets.frames import Close + +from pyquotex.types import ReconnectPolicy, Subscription +from pyquotex.ws.client import WebsocketClient + + +class _FakeApi: + """Minimal duck-typed stand-in for QuotexAPI used by these tests.""" + + def __init__(self) -> None: + self.state = MagicMock(status=1) # WebsocketStatus.CONNECTED + self.last_message_at = time.monotonic() + self._subscriptions: dict[str, Subscription] = {} + self.replayed: list[tuple[str, str, int | None]] = [] + # Used by _replay_one + self.subscribe_realtime_candle = AsyncMock( + side_effect=lambda a, p: self.replayed.append(("candle", a, p)) + ) + self.chart_notification = AsyncMock() + self.follow_candle = AsyncMock() + self.subscribe_all_size = AsyncMock( + side_effect=lambda a: self.replayed.append(("all_size", a, None)) + ) + self.subscribe_Traders_mood = AsyncMock( + side_effect=lambda a, i: self.replayed.append(("mood", a, None)) + ) + self._on_open = AsyncMock() + self._on_message = AsyncMock() + self._on_close = MagicMock() + self._on_error = MagicMock() + + +class _FakeWS: + """Minimal stand-in for an open websocket connection.""" + + def __init__(self, frames: list[str] | None = None, raise_on_iter: Exception | None = None): + self.state = MagicMock() + from websockets.protocol import State + self.state = State.OPEN + self._frames = frames or [] + self._raise = raise_on_iter + self.closed = False + + async def __aenter__(self) -> "_FakeWS": + return self + + async def __aexit__(self, *args: Any) -> None: + self.closed = True + + def __aiter__(self) -> "_FakeWS": + return self + + async def __anext__(self) -> str: + if self._raise is not None: + raise self._raise + if not self._frames: + raise StopAsyncIteration + return self._frames.pop(0) + + async def send(self, data: str) -> None: + return None + + async def close(self, code: int = 1000, reason: str = "") -> None: + from websockets.protocol import State + self.state = State.CLOSED + self.closed = True + + +def _fake_connect_factory(ws_sequence: list[_FakeWS]): + """Return a function suitable for patching ``websockets.connect``. + + Each call pops one ``_FakeWS`` from ``ws_sequence``. + """ + + @asynccontextmanager + async def _fake_connect(*args: Any, **kwargs: Any): + ws = ws_sequence.pop(0) + try: + yield ws + finally: + await ws.close() + + return _fake_connect + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_no_reconnect_when_disabled() -> None: + api = _FakeApi() + client = WebsocketClient(api, ReconnectPolicy(enabled=False)) + + ws = _FakeWS(frames=["msg1", "msg2"]) + with patch("pyquotex.ws.client.websockets.connect", _fake_connect_factory([ws])): + await client.run_forever("wss://example/test") + + # _on_open and _on_message called; no second connect attempted. + assert api._on_open.await_count == 1 + assert api._on_message.await_count == 2 + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_auto_reconnect_after_unexpected_close() -> None: + api = _FakeApi() + policy = ReconnectPolicy( + enabled=True, + max_attempts=1, # one retry, then bail + base_delay=0.001, + max_delay=0.005, + jitter=0.0, + stale_timeout=0, # disable watchdog for this test + ) + client = WebsocketClient(api, policy) + + closed = ConnectionClosed(rcvd=Close(1006, "abrupt"), sent=None) + ws1 = _FakeWS(raise_on_iter=closed) + ws2 = _FakeWS(frames=["after-reconnect"]) + + with patch( + "pyquotex.ws.client.websockets.connect", + _fake_connect_factory([ws1, ws2]), + ): + await client.run_forever("wss://example/test") + + assert api._on_open.await_count == 2 + assert api._on_close.call_count == 1 + # The reconnect run consumed the "after-reconnect" frame. + assert api._on_message.await_count >= 1 + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_subscriptions_replayed_on_reconnect() -> None: + api = _FakeApi() + api._subscriptions["candle:EURUSD:60"] = Subscription( + kind="candle", asset="EURUSD", period=60 + ) + api._subscriptions["mood:EURUSD:0"] = Subscription( + kind="mood", asset="EURUSD" + ) + policy = ReconnectPolicy( + enabled=True, + max_attempts=1, + base_delay=0.001, + max_delay=0.005, + jitter=0.0, + stale_timeout=0, + ) + client = WebsocketClient(api, policy) + + closed = ConnectionClosed(rcvd=Close(1011, "fail"), sent=None) + ws1 = _FakeWS(raise_on_iter=closed) + ws2 = _FakeWS(frames=[]) + + with patch( + "pyquotex.ws.client.websockets.connect", + _fake_connect_factory([ws1, ws2]), + ): + task = asyncio.create_task(client.run_forever("wss://example/test")) + # Let the background replay task run; cap to keep CI fast. + await asyncio.sleep(0.5) + await client.close() + await asyncio.wait_for(task, timeout=2) + + # Replay should have re-issued both subscriptions exactly once. + kinds = [r[0] for r in api.replayed] + assert "candle" in kinds + assert "mood" in kinds + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_close_stops_reconnect_loop() -> None: + api = _FakeApi() + policy = ReconnectPolicy( + enabled=True, + max_attempts=100, + base_delay=0.001, + max_delay=0.005, + jitter=0.0, + stale_timeout=0, + ) + client = WebsocketClient(api, policy) + + ws = _FakeWS(frames=[]) + + async def slow_connect(*args, **kwargs): + # Never resolves until cancelled, simulating an alive socket + @asynccontextmanager + async def _ctx(): + try: + yield ws + await asyncio.sleep(5) + except asyncio.CancelledError: + raise + + return _ctx() + + with patch("pyquotex.ws.client.websockets.connect", _fake_connect_factory([ws])): + task = asyncio.create_task(client.run_forever("wss://example/test")) + await asyncio.sleep(0.05) + await client.close() + await asyncio.wait_for(task, timeout=2) + assert client._closing is True + + +@pytest.mark.unit +def test_api_tracks_and_forgets_subscriptions() -> None: + """QuotexAPI helper methods record subscriptions for replay.""" + from pyquotex.api import QuotexAPI + + api = QuotexAPI( + host="qxbroker.com", + username="x", + password="x", + lang="en", + proxies=None, + resource_path=".", + user_data_dir="browser", + on_otp_callback=None, + ) + api._track_subscription("candle", "EURUSD", 60) + api._track_subscription("mood", "EURUSD") + assert "candle:EURUSD:60" in api._subscriptions + assert "mood:EURUSD:0" in api._subscriptions + api._forget_subscription("candle", "EURUSD", 60) + assert "candle:EURUSD:60" not in api._subscriptions diff --git a/tests/test_streaming_indicators.py b/tests/test_streaming_indicators.py new file mode 100644 index 00000000..613f1940 --- /dev/null +++ b/tests/test_streaming_indicators.py @@ -0,0 +1,77 @@ +"""Tests for the incremental streaming indicators.""" +import pytest + +from pyquotex.utils.indicators import TechnicalIndicators +from pyquotex.utils.streaming_indicators import ( + StreamingBollinger, + StreamingEMA, + StreamingRSI, + StreamingSMA, +) + + +@pytest.mark.unit +class TestStreamingSMA: + def test_returns_none_until_warmed(self) -> None: + sma = StreamingSMA(period=3) + assert sma.update(1.0) is None + assert sma.update(2.0) is None + assert sma.update(3.0) == pytest.approx(2.0) + assert sma.update(4.0) == pytest.approx(3.0) + + def test_matches_batch_implementation(self) -> None: + prices = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0] + batch = TechnicalIndicators.calculate_sma(prices, 3) + streaming = StreamingSMA(3) + out = [streaming.update(p) for p in prices] + non_none = [round(x, 2) for x in out if x is not None] + assert non_none == batch + + def test_rejects_invalid_period(self) -> None: + with pytest.raises(ValueError): + StreamingSMA(0) + + +@pytest.mark.unit +class TestStreamingEMA: + def test_warms_via_sma_seed(self) -> None: + ema = StreamingEMA(period=3) + ema.update(1.0) + ema.update(2.0) + warmed = ema.update(3.0) + assert warmed == pytest.approx(2.0) # SMA seed + + def test_post_warm_uses_alpha(self) -> None: + ema = StreamingEMA(period=2) + ema.update(1.0) + ema.update(3.0) # warmed: (1+3)/2 = 2 + v = ema.update(5.0) # alpha = 2/3; new = 5*2/3 + 2*1/3 + assert v == pytest.approx(5 * 2 / 3 + 2 * 1 / 3) + + +@pytest.mark.unit +class TestStreamingRSI: + def test_constant_prices_return_neutral_when_warmed(self) -> None: + rsi = StreamingRSI(period=3) + outs = [rsi.update(1.0) for _ in range(5)] + # No movement → avg_loss == avg_gain == 0 → returns 100 (max signal, + # by Wilder convention when loss == 0). + assert outs[-1] == 100.0 + + def test_monotonic_up_pushes_rsi_high(self) -> None: + rsi = StreamingRSI(period=3) + outs = [rsi.update(p) for p in [1.0, 2.0, 3.0, 4.0, 5.0]] + assert outs[-1] is not None and outs[-1] > 80 + + +@pytest.mark.unit +class TestStreamingBollinger: + def test_returns_triplet_when_warmed(self) -> None: + bb = StreamingBollinger(period=3, num_std=2) + assert bb.update(1.0) is None + assert bb.update(2.0) is None + result = bb.update(3.0) + assert result is not None + upper, middle, lower = result + assert middle == pytest.approx(2.0) + assert upper > middle > lower diff --git a/tests/test_types.py b/tests/test_types.py new file mode 100644 index 00000000..fa125e97 --- /dev/null +++ b/tests/test_types.py @@ -0,0 +1,113 @@ +"""Tests for the new public dataclasses in ``pyquotex.types``.""" +import pytest + +from pyquotex.types import ( + AssetInfo, + Balance, + Candle, + ProfileInfo, + ReconnectPolicy, + Subscription, + TradeResult, +) + + +@pytest.mark.unit +class TestCandle: + def test_from_dict_full(self) -> None: + c = Candle.from_dict( + {"time": 1, "open": 2.0, "high": 3.0, "low": 1.5, "close": 2.5, "volume": 100} + ) + assert (c.time, c.open, c.high, c.low, c.close, c.volume) == ( + 1, 2.0, 3.0, 1.5, 2.5, 100.0 + ) + + def test_from_array_orders_match_broker(self) -> None: + # broker order: [t, o, c, h, l] + c = Candle.from_array([10, 1.0, 1.4, 1.5, 0.8]) + assert c.time == 10 + assert c.open == 1.0 + assert c.close == 1.4 + assert c.high == 1.5 + assert c.low == 0.8 + + def test_from_array_rejects_short(self) -> None: + with pytest.raises(ValueError): + Candle.from_array([1, 2, 3]) + + @pytest.mark.parametrize( + "open_,close,expected", + [(1.0, 1.5, "green"), (1.5, 1.0, "red"), (1.0, 1.0, "doji")], + ) + def test_color(self, open_: float, close: float, expected: str) -> None: + c = Candle(time=0, open=open_, high=2, low=0.5, close=close) + assert c.color == expected + + def test_is_frozen(self) -> None: + c = Candle(time=0, open=1, high=1, low=1, close=1) + with pytest.raises(Exception): + c.time = 99 # type: ignore[misc] + + +@pytest.mark.unit +def test_trade_result_from_dict_infers_status() -> None: + win = TradeResult.from_dict({"id": "t1", "profit": 5.0, "asset": "EURUSD"}) + assert win.status == "win" + loss = TradeResult.from_dict({"ticket": "t2", "profit": -2.0}) + assert loss.status == "loss" + draw = TradeResult.from_dict({"id": "t3", "profit": 0}) + assert draw.status == "draw" + + +@pytest.mark.unit +def test_balance_from_dict() -> None: + b = Balance.from_dict( + {"demoBalance": 10000.0, "liveBalance": 50.0, "currencyCode": "USD"} + ) + assert b.demo == 10000.0 + assert b.live == 50.0 + assert b.currency_code == "USD" + + +@pytest.mark.unit +def test_profile_info_from_profile_object() -> None: + class P: + nick_name = "alice" + profile_id = 42 + demo_balance = 100.0 + live_balance = 0.0 + currency_code = "USD" + currency_symbol = "$" + country_name = "BR" + offset = 0 + p = ProfileInfo.from_profile(P()) + assert p.nickname == "alice" + assert p.profile_id == 42 + assert p.demo_balance == 100.0 + + +@pytest.mark.unit +def test_asset_info_from_row() -> None: + row = [1, "EURUSD", "EUR/USD\n", 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, True] + a = AssetInfo.from_instrument_row(row) + assert a.id == 1 + assert a.symbol == "EURUSD" + assert a.name == "EUR/USD" + assert a.is_open is True + + +@pytest.mark.unit +def test_reconnect_policy_defaults_sensible() -> None: + p = ReconnectPolicy() + assert p.enabled is True + assert p.max_attempts == 0 # infinite by default + assert p.base_delay >= 0 + assert p.max_delay >= p.base_delay + assert p.stale_timeout > 0 + + +@pytest.mark.unit +def test_subscription_mutability() -> None: + s = Subscription(kind="candle", asset="EURUSD", period=60) + s.extra["foo"] = "bar" # Subscription is intentionally mutable + assert s.extra == {"foo": "bar"}