From c0d1e1bddc177d6abded91b6a8d2e0f589781f44 Mon Sep 17 00:00:00 2001 From: Victalejo Date: Fri, 15 May 2026 12:05:44 -0500 Subject: [PATCH 1/3] Refactor and clean up code across multiple files - Removed unnecessary blank lines in various files for cleaner code. - Reformatted import statements for consistency and readability. - Improved comments for clarity in the expiration.py and processor.py files. - Added shared pytest fixtures and configuration for better test organization. - Introduced a minimal replay server for offline integration tests. - Implemented end-to-end integration tests to validate the Quotex client against the replay server. - Enhanced test coverage for various functionalities including connection handling, balance events, and candle retrieval. --- .coverage | Bin 0 -> 69632 bytes .github/workflows/ci.yml | 79 ++++++++ pyproject.toml | 25 +++ pyquotex/_api/account.py | 2 +- pyquotex/_api/history.py | 2 +- pyquotex/_api/realtime.py | 2 +- pyquotex/api.py | 34 +++- pyquotex/cli/commands/__init__.py | 1 - pyquotex/cli/commands/candles.py | 6 +- pyquotex/config.py | 4 +- pyquotex/expiration.py | 7 +- pyquotex/stable_api.py | 8 +- pyquotex/utils/account_type.py | 2 +- pyquotex/utils/async_utils.py | 39 ++-- pyquotex/utils/indicators.py | 2 +- pyquotex/utils/processor.py | 8 +- pyquotex/utils/strategy.py | 2 +- pyquotex/ws/channels/buy.py | 4 +- pyquotex/ws/channels/candles.py | 1 - pyquotex/ws/channels/sell_option.py | 1 - pyquotex/ws/channels/ssid.py | 1 - pyquotex/ws/objects/timesync.py | 2 +- tests/conftest.py | 111 ++++++++++ tests/fakes/__init__.py | 0 tests/fakes/ws_replay_server.py | 259 ++++++++++++++++++++++++ tests/test_api.py | 2 +- tests/test_expiration.py | 17 +- tests/test_offline_integration.py | 300 ++++++++++++++++++++++++++++ tests/test_processor.py | 19 +- tests/test_tournament.py | 2 +- tests/test_waits.py | 3 + 31 files changed, 864 insertions(+), 81 deletions(-) create mode 100644 .coverage create mode 100644 .github/workflows/ci.yml create mode 100644 tests/conftest.py create mode 100644 tests/fakes/__init__.py create mode 100644 tests/fakes/ws_replay_server.py create mode 100644 tests/test_offline_integration.py diff --git a/.coverage b/.coverage new file mode 100644 index 0000000000000000000000000000000000000000..affa82639d14c1ac62abeb4dea8c78f058544ac5 GIT binary patch literal 69632 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 QuJD-g*0p6S=~|fkKNXa^4gdfE literal 0 HcmV?d00001 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..b8c53a9c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,79 @@ +name: CI + +on: + push: + branches: [master, main] + pull_request: + branches: [master, main] + +# This workflow only uses matrix variables and github.{workflow,ref} (safe +# values that cannot be controlled by untrusted users) — no untrusted +# event inputs are interpolated into run blocks. + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + name: Lint (ruff) + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + cache: pip + - run: pip install ruff + - name: ruff check + run: ruff check pyquotex tests scripts + # Format check is opt-in until the codebase is migrated to ruff + # format in a follow-up PR (would touch ~80 files in a single diff). + # Enable by running: ruff format pyquotex tests scripts + + type-check: + name: Type check (mypy) + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + cache: pip + - run: pip install -r requirements.txt mypy + - name: mypy (public surface only) + run: | + mypy --ignore-missing-imports --no-strict-optional \ + pyquotex/types.py \ + pyquotex/utils/streaming_indicators.py \ + pyquotex/utils/cache.py \ + pyquotex/utils/json_utils.py \ + pyquotex/_api/_waits.py + + test: + name: Test (py${{ matrix.python }} / ${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, macos-14] + python: ["3.12", "3.13"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + cache: pip + - run: pip install -r requirements.txt pytest pytest-asyncio pytest-mock pytest-cov + - name: pytest (offline only) + env: + PYQUOTEX_LIVE: "0" + run: | + pytest -q --cov=pyquotex --cov-report=term-missing --cov-report=xml --junitxml=junit.xml + - name: Upload coverage XML + if: matrix.os == 'ubuntu-24.04' && matrix.python == '3.13' + uses: actions/upload-artifact@v4 + with: + name: coverage-xml + path: coverage.xml + if-no-files-found: ignore diff --git a/pyproject.toml b/pyproject.toml index fb415374..2390af4c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,31 @@ pytest-mock = "^3.12.0" asyncio_mode = "auto" testpaths = ["tests"] +[tool.ruff] +line-length = 100 +target-version = "py312" +extend-exclude = [ + "examples", + # Legacy network/optimization code uses idioms ruff flags but rewriting + # them is out of scope for the resilience / typing PR. Tighten over time. + "pyquotex/network/login.py", + "pyquotex/network/navigator.py", + "pyquotex/utils/optimization.py", +] + +[tool.ruff.lint] +select = ["E", "F", "W", "I"] +ignore = [ + "E501", # line-too-long — handled by ruff format + "E701", # one-liner `if x: return` is fine in this codebase + "E712", # `== True` comparisons in legacy ConnectionState properties + "E713", # `not (x in y)` style appears a few times in legacy code +] + +[tool.ruff.lint.per-file-ignores] +"tests/*" = ["F401", "F811", "F541"] # tests intentionally import-and-discard +"scripts/*" = ["F401", "F541"] + [build-system] requires = ["poetry-core>=2.0.0"] build-backend = "poetry.core.masonry.api" diff --git a/pyquotex/_api/account.py b/pyquotex/_api/account.py index c4eb23f0..ff525f14 100644 --- a/pyquotex/_api/account.py +++ b/pyquotex/_api/account.py @@ -15,7 +15,6 @@ from pyquotex import expiration from pyquotex._api._constants import DEFAULT_TIMEOUT from pyquotex.api import QuotexAPI -from pyquotex.config import resource_path from pyquotex.exceptions import QuotexTimeoutError from pyquotex.utils.account_type import AccountType from pyquotex.utils.services import truncate @@ -40,6 +39,7 @@ async def connect(self) -> tuple[bool, str]: proxies=self.proxies, on_otp_callback=self.on_otp_callback, reconnect_policy=getattr(self, "reconnect_policy", None), + wss_url_override=getattr(self, "wss_url_override", None), ) self.api.trace_ws = self.debug_ws_enable diff --git a/pyquotex/_api/history.py b/pyquotex/_api/history.py index 0e3ead46..abb981c3 100644 --- a/pyquotex/_api/history.py +++ b/pyquotex/_api/history.py @@ -17,8 +17,8 @@ from pyquotex.utils.cache import TTLCache from pyquotex.utils.processor import ( calculate_candles, - process_candles_v2, merge_candles, + process_candles_v2, ) # Per-process cache of recent get_candles() responses. Keyed by diff --git a/pyquotex/_api/realtime.py b/pyquotex/_api/realtime.py index ba52e65e..ba2ba3e8 100644 --- a/pyquotex/_api/realtime.py +++ b/pyquotex/_api/realtime.py @@ -14,8 +14,8 @@ from pyquotex._api._constants import DEFAULT_TIMEOUT from pyquotex.utils.indicators import TechnicalIndicators from pyquotex.utils.processor import ( - process_tick, aggregate_candle, + process_tick, ) logger = logging.getLogger(__name__) diff --git a/pyquotex/api.py b/pyquotex/api.py index d17a6917..e96066b2 100644 --- a/pyquotex/api.py +++ b/pyquotex/api.py @@ -7,11 +7,7 @@ import httpx -from .global_value import ( - ConnectionState, - WebsocketStatus, - AuthStatus -) +from .global_value import AuthStatus, ConnectionState, WebsocketStatus from .network.history import GetHistory from .network.login import Login from .network.logout import Logout @@ -47,6 +43,7 @@ def __init__( user_data_dir: str = ".", on_otp_callback: Callable | None = None, reconnect_policy: Any = None, + wss_url_override: str | None = None, ): """ :param str host: The hostname or ip address of a Quotex server. @@ -56,6 +53,10 @@ def __init__( :param proxies: The proxies of a Quotex server. :param user_data_dir: The path browser user data dir. :param on_otp_callback: Callback function for OTP (2FA) input. + :param wss_url_override: Replace the computed ``wss://ws2.{host}`` + URL with this value. Primarily a test hook so the offline + integration tests can point at a local replay server, but + also useful for routing through a custom proxy. """ self.state = ConnectionState() self.on_otp_callback = on_otp_callback @@ -86,7 +87,11 @@ def __init__( self.host = host self.https_url = f"https://{host}" - self.wss_url = f"wss://ws2.{host}/socket.io/?EIO=3&transport=websocket" + self.wss_url = ( + wss_url_override + if wss_url_override + else f"wss://ws2.{host}/socket.io/?EIO=3&transport=websocket" + ) self.wss_message: str | None = None self.websocket_client: WebsocketClient | None = None self._websocket_task: asyncio.Task | None = None @@ -406,7 +411,7 @@ async def _on_message(self, msg: bytes | str) -> None: if order_id: profit = order.get("profit", 0) win = "win" if profit > 0 else "loss" - # Check if it's in a closed list or has a + # Check if it's in a closed list or has a # close status is_closed = ( any( @@ -546,7 +551,7 @@ async def _on_message(self, msg: bytes | str) -> None: ) self.timesync.server_timestamp = ts # Sync server clock - # Limit realtime_price history to 1000 entries + # Limit realtime_price history to 1000 entries # to prevent memory bloat price_list = self.realtime_price[asset] price_list.append({"time": ts, "price": price}) @@ -621,7 +626,7 @@ async def authenticate(self) -> tuple[bool, str]: """ Authenticates the user using the provided credentials. - Performs HTTP login, retrieves cookies and SSID token, + Performs HTTP login, retrieves cookies and SSID token, and updates the browser session. Returns: @@ -892,11 +897,18 @@ async def start_websocket(self) -> tuple[bool, str]: "Pragma": "no-cache", "Sec-WebSocket-Extensions": "permessage-deflate; client_max_window_bits", } + # Skip SSL for plain ws:// (test / proxy / dev) — the websockets + # library expects no SSLContext on a non-TLS URL. + ssl_ctx = ( + self.browser._ssl_context + if self.wss_url.startswith("wss://") + else None + ) self._websocket_task = asyncio.create_task( self.websocket_client.run_forever( url=self.wss_url, extra_headers=extra_headers, - ssl=self.browser._ssl_context + ssl=ssl_ctx, ) ) for _ in range(100): @@ -980,7 +992,7 @@ async def get_profile(self) -> Profile: """ Retrieves and parses the user profile data. - Updates the internal profile object with nickname, balances, + Updates the internal profile object with nickname, balances, country, and timezone. Returns: diff --git a/pyquotex/cli/commands/__init__.py b/pyquotex/cli/commands/__init__.py index 67452dfe..d184ba69 100644 --- a/pyquotex/cli/commands/__init__.py +++ b/pyquotex/cli/commands/__init__.py @@ -39,7 +39,6 @@ cmd_sell, ) - COMMAND_REGISTRY = { "login": cmd_login, "balance": cmd_balance, diff --git a/pyquotex/cli/commands/candles.py b/pyquotex/cli/commands/candles.py index dd3d7e57..7cdcd305 100644 --- a/pyquotex/cli/commands/candles.py +++ b/pyquotex/cli/commands/candles.py @@ -8,7 +8,11 @@ from rich.console import Console from rich.panel import Panel from rich.progress import ( - BarColumn, Progress, SpinnerColumn, TaskProgressColumn, TextColumn, + BarColumn, + Progress, + SpinnerColumn, + TaskProgressColumn, + TextColumn, ) from pyquotex.cli.formatters import _print_candles_table, _save_candles_csv diff --git a/pyquotex/config.py b/pyquotex/config.py index 02b12843..654e8366 100644 --- a/pyquotex/config.py +++ b/pyquotex/config.py @@ -55,7 +55,7 @@ def load_session(email: str, user_agent: str | None = None) -> dict[str, Any]: """Load session data for a specific email.""" if user_agent is None: user_agent = UserAgent().random - + output_file = Path(resource_path("session.json")) with session_lock: all_sessions = {} @@ -74,7 +74,7 @@ def load_session(email: str, user_agent: str | None = None) -> dict[str, Any]: "user_agent": user_agent } output_file.write_text(json.dumps(all_sessions, indent=4)) - + return all_sessions.get(email) diff --git a/pyquotex/expiration.py b/pyquotex/expiration.py index 2e0619b4..0da182dc 100644 --- a/pyquotex/expiration.py +++ b/pyquotex/expiration.py @@ -1,9 +1,6 @@ import calendar import time -from datetime import ( - datetime, - timedelta -) +from datetime import datetime, timedelta def get_timestamp() -> int: @@ -167,7 +164,7 @@ def get_server_timer(time_offset_seconds: int) -> int: """ Returns the server (UTC) timestamp based on local time and offset. - :param time_offset_seconds: The offset in seconds between local time + :param time_offset_seconds: The offset in seconds between local time and UTC. Example: -10800 for UTC-3. :return: An integer representing the server time as a Unix timestamp (UTC). """ diff --git a/pyquotex/stable_api.py b/pyquotex/stable_api.py index a2bfbfb4..225646af 100644 --- a/pyquotex/stable_api.py +++ b/pyquotex/stable_api.py @@ -8,11 +8,7 @@ from ._api.realtime import RealtimeMixin from ._api.trading import TradingMixin from .api import QuotexAPI -from .config import ( - load_session, - update_session, - resource_path -) +from .config import load_session, resource_path, update_session from .global_value import AuthStatus from .types import ReconnectPolicy from .utils.account_type import AccountType @@ -44,6 +40,7 @@ def __init__( proxies: dict[str, str] | None = None, on_otp_callback: Callable | None = None, reconnect_policy: ReconnectPolicy | None = None, + wss_url_override: str | None = None, ): """ Initializes the Quotex stable API wrapper. @@ -96,6 +93,7 @@ def __init__( self.session_data = session self.on_otp_callback = on_otp_callback self.reconnect_policy = reconnect_policy or ReconnectPolicy() + self.wss_url_override = wss_url_override @property def websocket(self) -> Any: diff --git a/pyquotex/utils/account_type.py b/pyquotex/utils/account_type.py index 2f16410b..7d587990 100644 --- a/pyquotex/utils/account_type.py +++ b/pyquotex/utils/account_type.py @@ -5,7 +5,7 @@ class AccountType(IntEnum): """ Enum for account types (REAL and DEMO). - + Inherits from IntEnum to ensure compatibility with integer-based API payloads (0 for REAL, 1 for DEMO). """ diff --git a/pyquotex/utils/async_utils.py b/pyquotex/utils/async_utils.py index af9a3278..5fad4dad 100644 --- a/pyquotex/utils/async_utils.py +++ b/pyquotex/utils/async_utils.py @@ -2,9 +2,10 @@ import asyncio import time import uuid -from typing import Any, Dict, Optional, Callable +from typing import Any, Callable, Dict, Optional from ..global_value import AuthStatus +from . import json_utils # used by FastJSONParser below class AsyncEvent: @@ -108,11 +109,11 @@ def is_complete(self) -> bool: class EventRegistry: """Registry for managing multiple events by key.""" - + def __init__(self): self._events: Dict[str, AsyncEvent] = {} self._lock = asyncio.Lock() - + async def get_event(self, key: str, auto_reset: bool = False) -> AsyncEvent: """Get or create an event by key. @@ -123,17 +124,17 @@ async def get_event(self, key: str, auto_reset: bool = False) -> AsyncEvent: if key not in self._events: self._events[key] = AsyncEvent(auto_reset=auto_reset) return self._events[key] - + async def set_event(self, key: str, data: Optional[Any] = None): """Set event data by key.""" event = await self.get_event(key) event.set(data) - + async def wait_event(self, key: str, timeout: Optional[float] = None): """Wait for event by key.""" event = await self.get_event(key) return await event.wait(timeout=timeout) - + async def clear_event(self, key: str): """Clear event by key.""" async with self._lock: @@ -141,11 +142,9 @@ async def clear_event(self, key: str): self._events[key].reset() -from pyquotex.utils import json_utils - class FastJSONParser: """Fast JSON parsing with graceful fallback.""" - + @staticmethod async def parse_async(data: bytes, skip_header: int = 0) -> Any: """Parse JSON data asynchronously, optionally skipping header bytes.""" @@ -168,7 +167,7 @@ async def dumps_async(obj: Any) -> bytes: """Serialize object to JSON bytes asynchronously.""" loop = asyncio.get_running_loop() return await loop.run_in_executor(None, json_utils.dumps, obj) - + @staticmethod def dumps_sync(obj: Any) -> bytes: """Serialize object to JSON bytes synchronously.""" @@ -214,29 +213,29 @@ async def wait_for_condition( error_message: str = "Condition wait timeout" ) -> bool: """Wait for a condition to become true with timeout. - + Args: condition_func: Callable that returns bool timeout: Maximum wait time in seconds check_interval: Time between checks in seconds error_message: Error message if timeout occurs - + Returns: True if condition met - + Raises: TimeoutError: If condition not met within timeout """ start_time = asyncio.get_event_loop().time() - + while True: if condition_func(): return True - + elapsed = asyncio.get_event_loop().time() - start_time if elapsed > timeout: raise TimeoutError(error_message) - + await asyncio.sleep(min(check_interval, timeout - elapsed)) @@ -246,21 +245,21 @@ async def gather_with_limit( return_exceptions: bool = False ) -> list: """Run coroutines with concurrency limit. - + Args: coros: List of coroutines limit: Maximum concurrent tasks return_exceptions: If True, return exceptions instead of raising - + Returns: List of results """ semaphore = asyncio.Semaphore(limit) - + async def sem_coro(coro): async with semaphore: return await coro - + return await asyncio.gather( *[sem_coro(coro) for coro in coros], return_exceptions=return_exceptions diff --git a/pyquotex/utils/indicators.py b/pyquotex/utils/indicators.py index 9c451105..81de3824 100644 --- a/pyquotex/utils/indicators.py +++ b/pyquotex/utils/indicators.py @@ -341,4 +341,4 @@ def donchian( "senkou_b": senkou_b[-1] if senkou_b else None, "chikou": chikou[-1] if chikou else None } - } \ No newline at end of file + } diff --git a/pyquotex/utils/processor.py b/pyquotex/utils/processor.py index d9a9deaf..febd0d6c 100644 --- a/pyquotex/utils/processor.py +++ b/pyquotex/utils/processor.py @@ -51,7 +51,7 @@ def get_last_n_candles( return [] sorted_periods = sorted(candles[pair].keys(), reverse=True) - + # Pre-format all timestamps instead of formatting in loop last_n_candles = [] for period in sorted_periods[:n]: @@ -163,7 +163,7 @@ def process_candles_v2( # Combine candles and realtime data combined = candles + (data if data else []) - # Deduplicate by time to prevent same candle from being added + # Deduplicate by time to prevent same candle from being added # multiple times if combined: candle_dict = { @@ -216,7 +216,7 @@ def merge_candles(candles_data: list[dict[str, Any]]) -> list[dict[str, Any]]: if not candles_data: return [] - # Use dict to eliminate duplicates by time, then convert back to + # Use dict to eliminate duplicates by time, then convert back to # sorted list candle_dict = { c['time']: c for c in candles_data @@ -261,4 +261,4 @@ def aggregate_candle( candle['high'] = max(candle['high'], data['high']) candle['low'] = min(candle['low'], data['low']) - return candles \ No newline at end of file + return candles diff --git a/pyquotex/utils/strategy.py b/pyquotex/utils/strategy.py index 62af8f4f..fb05d320 100644 --- a/pyquotex/utils/strategy.py +++ b/pyquotex/utils/strategy.py @@ -197,7 +197,7 @@ async def run( signal.upper(), float(candles[-1]["close"]), len(candles), ) print( - f" 🟢 CALL" if signal == "call" else " 🔴 PUT", + " 🟢 CALL" if signal == "call" else " 🔴 PUT", f" {asset} @{candles[-1]['close']}", ) diff --git a/pyquotex/ws/channels/buy.py b/pyquotex/ws/channels/buy.py index 96894f97..09c1ca0e 100644 --- a/pyquotex/ws/channels/buy.py +++ b/pyquotex/ws/channels/buy.py @@ -1,11 +1,11 @@ import logging import time +from pyquotex.expiration import get_expiration_time_quotex from pyquotex.utils import json_utils as json from pyquotex.ws.channels.base import Base logger = logging.getLogger(__name__) -from pyquotex.expiration import get_expiration_time_quotex class Buy(Base): @@ -65,7 +65,7 @@ async def __call__( "optionType": option_type } - data = f'42["tick"]' + data = '42["tick"]' await self.send_websocket_request(data) data = f'42["orders/open",{json.dumps_str(payload)}]' diff --git a/pyquotex/ws/channels/candles.py b/pyquotex/ws/channels/candles.py index ddbd61a6..258f1ae8 100644 --- a/pyquotex/ws/channels/candles.py +++ b/pyquotex/ws/channels/candles.py @@ -1,5 +1,4 @@ from pyquotex.utils import json_utils as json - from pyquotex.ws.channels.base import Base diff --git a/pyquotex/ws/channels/sell_option.py b/pyquotex/ws/channels/sell_option.py index c93d00d7..0151202b 100644 --- a/pyquotex/ws/channels/sell_option.py +++ b/pyquotex/ws/channels/sell_option.py @@ -1,5 +1,4 @@ from pyquotex.utils import json_utils as json - from pyquotex.ws.channels.base import Base diff --git a/pyquotex/ws/channels/ssid.py b/pyquotex/ws/channels/ssid.py index 75bf1089..d332f473 100644 --- a/pyquotex/ws/channels/ssid.py +++ b/pyquotex/ws/channels/ssid.py @@ -1,5 +1,4 @@ from pyquotex.utils import json_utils as json - from pyquotex.ws.channels.base import Base diff --git a/pyquotex/ws/objects/timesync.py b/pyquotex/ws/objects/timesync.py index fc4f78a2..e7260845 100644 --- a/pyquotex/ws/objects/timesync.py +++ b/pyquotex/ws/objects/timesync.py @@ -59,7 +59,7 @@ def expiration_time(self, minutes: float | int) -> None: @property def expiration_datetime(self) -> datetime.datetime: - """Get the expiration date and time based on the expiration time + """Get the expiration date and time based on the expiration time and server timestamp. :returns: The expiration date and time. diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..01fa6cb2 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,111 @@ +"""Shared pytest fixtures and pytest config. + +Four pieces live here: + +1. ``pytest_configure`` — registers ``unit`` / ``integration`` / ``live`` + markers used to filter tests in CI. +2. ``pytest_collection_modifyitems`` — auto-skips ``@pytest.mark.live`` + tests unless the ``PYQUOTEX_LIVE=1`` environment variable is set. + Heuristically tags legacy tests that depend on ``credentials()`` / + real broker access as live so CI doesn't try to run them. +3. ``replay_server`` — spins up a :class:`WSReplayServer` per test, lets + the test script its responses, tears it down on exit. +4. ``offline_quotex`` — factory that builds a :class:`Quotex` pre-seeded + with a fake SSID and pointed at the replay server. +""" +from __future__ import annotations + +import os +from typing import AsyncIterator, Callable + +import pytest + +from pyquotex.stable_api import Quotex +from pyquotex.types import ReconnectPolicy +from pyquotex.utils.account_type import AccountType +from tests.fakes.ws_replay_server import WSReplayServer + +# Test modules that depend on a real broker session — auto-tagged ``live``. +# Add to this set when introducing a new credentials-using test file. +_LIVE_TEST_MODULES: frozenset[str] = frozenset({ + "test_basic", + "test_buy", + "test_login", + "test_subscribe_indicator", + "test_deep_history", + "test_infinite_history", + "test_win", + "test_tournament", + "test_user", +}) + + +def pytest_configure(config: pytest.Config) -> None: + """Register custom markers used across the test suite.""" + for marker, description in ( + ("unit", "Pure-Python unit test, no I/O"), + ("integration", "Uses the WSReplayServer or fakes, but stays offline"), + ("live", "Hits qxbroker.com; requires credentials and PYQUOTEX_LIVE=1"), + ): + config.addinivalue_line("markers", f"{marker}: {description}") + + +def pytest_collection_modifyitems( + config: pytest.Config, items: list[pytest.Item] +) -> None: + """Auto-mark legacy live tests and skip them unless ``PYQUOTEX_LIVE=1``.""" + live_enabled = os.environ.get("PYQUOTEX_LIVE", "").lower() in {"1", "true", "yes"} + skip_live = pytest.mark.skip( + reason="live test (set PYQUOTEX_LIVE=1 to enable)" + ) + for item in items: + module_name = item.module.__name__.rsplit(".", 1)[-1] + if module_name in _LIVE_TEST_MODULES: + item.add_marker(pytest.mark.live) + if "live" in item.keywords and not live_enabled: + item.add_marker(skip_live) + + +@pytest.fixture +async def replay_server() -> AsyncIterator[WSReplayServer]: + """Spin up a per-test :class:`WSReplayServer`.""" + server = WSReplayServer() + await server.start() + try: + yield server + finally: + await server.stop() + + +@pytest.fixture +def offline_quotex( + replay_server: WSReplayServer, +) -> Callable[..., Quotex]: + """Build a :class:`Quotex` whose WS layer points at the replay server. + + The factory pre-seeds session data so the HTTP login is skipped, and + forwards any kwargs to :class:`Quotex` for per-test overrides. + """ + + def _make(**overrides: object) -> Quotex: + reconnect = overrides.pop( + "reconnect_policy", + ReconnectPolicy(enabled=False, stale_timeout=0), + ) + client = Quotex( + email="offline@test", + password="x", + lang="en", + reconnect_policy=reconnect, # type: ignore[arg-type] + wss_url_override=replay_server.url, + **overrides, # type: ignore[arg-type] + ) + client.session_data = { + "cookies": "session=fake", + "token": "fake-ssid", + "user_agent": "test-agent/1.0", + } + client.account_is_demo = AccountType.DEMO + return client + + return _make diff --git a/tests/fakes/__init__.py b/tests/fakes/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/fakes/ws_replay_server.py b/tests/fakes/ws_replay_server.py new file mode 100644 index 00000000..6a4a90ec --- /dev/null +++ b/tests/fakes/ws_replay_server.py @@ -0,0 +1,259 @@ +"""Minimal replay server for Quotex's engine.io v3 + socket.io 2 protocol. + +The real ``ws2.qxbroker.com`` speaks engine.io v3 framing on top of WebSocket: + +* Server sends ``0{"sid":"...","upgrades":[],"pingInterval":25000,"pingTimeout":5000}`` + immediately on connect (engine.io OPEN packet). +* Client replies ``40`` (engine.io MESSAGE + socket.io CONNECT for the + default namespace). +* Server replies ``40``. +* From then on, both sides exchange ``42["event-name", payload]`` frames + (socket.io EVENT) and engine.io ``2``/``3`` ping/pong. +* The broker uses placeholder pattern ``451-["event",{"_placeholder":true,"num":0}]`` + followed by the raw binary payload (we skip binary in tests since the + client tolerates JSON-only frames for the events we exercise). + +This class implements a script-driven version of that protocol so +``QuotexAPI`` / ``Quotex`` can be exercised offline without touching the +broker. Test authors register canned responses against an event name and +optionally on every connect (greeting frames). + +Usage +----- + +>>> server = WSReplayServer() +>>> await server.start() +>>> server.on_event("instruments/get", reply=[ +... '42["instruments/list",[[1,"EURUSD","EUR/USD","forex",4,84,60,30,3,1,0,0,[],1,true]]]', +... ]) +>>> # ... point the client at ``server.url`` ... +>>> await server.stop() + +If many tests use the same canned protocol, use :func:`default_handlers`. +""" +from __future__ import annotations + +import asyncio +import json +import logging +import socket +from contextlib import asynccontextmanager +from typing import Any, AsyncIterator, Awaitable, Callable + +import websockets +from websockets.asyncio.server import ServerConnection, serve + +logger = logging.getLogger(__name__) + +Reply = str | bytes +Handler = Callable[[ServerConnection, str], Awaitable[None]] + + +def _free_port() -> int: + """Allocate an ephemeral TCP port and release it back to the OS. + + Reasonable for tests; race window with ``serve()`` is negligible. + """ + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +class WSReplayServer: + """Scriptable engine.io/socket.io WebSocket server for tests. + + Parameters + ---------- + greeting: + Frames the server pushes to every new connection, in order, before + any client message. Defaults to a minimal engine.io handshake + + ``s_authorization`` ack + an empty balance + an empty instruments + list, which is enough to satisfy :meth:`QuotexAPI._on_open`. + """ + + def __init__(self, greeting: list[Reply] | None = None) -> None: + self.port = _free_port() + self.host = "127.0.0.1" + self._server: websockets.Server | None = None + self._handlers: dict[str, list[Reply]] = {} + self._dyn_handlers: dict[str, Handler] = {} + self.received: list[str] = [] + self.connections: list[ServerConnection] = [] + self.greeting: list[Reply] = ( + greeting + if greeting is not None + else default_greeting_frames() + ) + + @property + def url(self) -> str: + """The ``ws://...`` URL clients should connect to.""" + return f"ws://{self.host}:{self.port}/socket.io/?EIO=3&transport=websocket" + + # ------------------------------------------------------------------ + # Scripting + # ------------------------------------------------------------------ + def on_event(self, event_name: str, *, reply: list[Reply]) -> None: + """Reply with these frames when the client sends ``42["event_name",...]``.""" + self._handlers[event_name] = list(reply) + + def on_event_dynamic(self, event_name: str, handler: Handler) -> None: + """Register a coroutine handler that gets the raw message verbatim.""" + self._dyn_handlers[event_name] = handler + + async def push(self, frame: Reply) -> None: + """Broadcast a frame to all currently-connected clients.""" + for conn in list(self.connections): + try: + await conn.send(frame) + except Exception as e: # pragma: no cover - test transient + logger.debug("push() failed for %s: %s", conn, e) + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + async def start(self) -> None: + # ``compression=None`` matches the client (WebsocketClient passes + # the same), otherwise the server would accept ``permessage-deflate`` + # offered in headers and the client would reject the response. + self._server = await serve( + self._connection_handler, + self.host, + self.port, + compression=None, + ) + + async def stop(self) -> None: + if self._server is not None: + self._server.close() + await self._server.wait_closed() + self._server = None + + @asynccontextmanager + async def running(self) -> AsyncIterator["WSReplayServer"]: + await self.start() + try: + yield self + finally: + await self.stop() + + # ------------------------------------------------------------------ + # Connection lifecycle + # ------------------------------------------------------------------ + async def _connection_handler(self, ws: ServerConnection) -> None: + self.connections.append(ws) + try: + for frame in self.greeting: + await ws.send(frame) + async for raw in ws: + msg = raw if isinstance(raw, str) else raw.decode("utf-8", "ignore") + self.received.append(msg) + await self._dispatch(ws, msg) + except websockets.ConnectionClosed: + pass + finally: + try: + self.connections.remove(ws) + except ValueError: + pass + + async def _dispatch(self, ws: ServerConnection, msg: str) -> None: + # engine.io PING (`2`) -> PONG (`3`) + if msg == "2": + await ws.send("3") + return + # socket.io CONNECT (`40`) + if msg == "40": + await ws.send("40") + return + # Standard socket.io EVENT: `42["event-name", payload]` + event = _extract_event_name(msg) + if event is None: + return + if event in self._dyn_handlers: + await self._dyn_handlers[event](ws, msg) + return + for frame in self._handlers.get(event, []): + await ws.send(frame) + + +def _extract_event_name(msg: str) -> str | None: + """Return the socket.io event name from ``42["name", ...]`` style frames.""" + if not msg.startswith("42"): + return None + body = msg[2:] + try: + payload = json.loads(body) + except Exception: + return None + if isinstance(payload, list) and payload and isinstance(payload[0], str): + return payload[0] + return None + + +# ---------------------------------------------------------------------- +# Canned default frames +# ---------------------------------------------------------------------- +def default_greeting_frames() -> list[Reply]: + """Frames sent on every new connection. + + Mirrors what the real broker sends right after a successful + SSID-authorized handshake: engine.io OPEN, an ``s_authorization`` + ACK, an empty balance event, and an empty instruments/list. + """ + return [ + '0{"sid":"replay-sid","upgrades":[],"pingInterval":25000,"pingTimeout":5000}', + '40', + '42["s_authorization"]', + '42["balance",{"demoBalance":10000.0,"liveBalance":0.0,"currencyCode":"USD"}]', + '451-["instruments/list",{"_placeholder":true,"num":0}]', + # Followed by the data payload (list of instrument rows). The + # client's unwrap step would collapse a single-row message into + # a flat row, so we ship two rows to keep the shape intact. + '[' + '[1,"EURUSD","EUR/USD","forex",4,84,60,30,3,1,0,0,[],1,true],' + '[2,"GBPUSD","GBP/USD","forex",4,84,60,30,3,1,0,0,[],1,true]' + ']', + ] + + +def candle_history_frames( + asset: str = "EURUSD", + period: int = 60, + n: int = 60, + base_price: float = 1.10, +) -> list[Reply]: + """Generate a deterministic ``history/load`` reply. + + The broker's ``history/load`` payload contains positional ticks + ``[timestamp, price, direction]`` under the ``candles`` key. The + client's :func:`calculate_candles` groups those ticks by period + into dict candles. We emit several ticks per period bucket so + each one yields a usable OHLC candle (with high != low). + """ + ticks: list[list[Any]] = [] + ticks_per_period = 4 + for i in range(n): + bucket_start = 1_700_000_000 + i * period + # Tiny deterministic oscillation so indicators have something to chew on. + for j in range(ticks_per_period): + ts = bucket_start + j * (period // ticks_per_period) + price = base_price + ((i + j) % 5) * 0.0001 + ticks.append([ts, price, 0]) + payload = { + "asset": asset, + "index": 1, + "period": period, + "candles": ticks, + } + return [ + '451-["history/load",{"_placeholder":true,"num":0}]', + json.dumps(payload), + ] + + +__all__ = [ + "WSReplayServer", + "candle_history_frames", + "default_greeting_frames", +] diff --git a/tests/test_api.py b/tests/test_api.py index ede29f3d..5e5859d7 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -41,7 +41,7 @@ def test_multiple_quotex_instances(): def test_connection_state_enums(): """Test that Enums are correctly initialized and legacy properties work.""" - from pyquotex.global_value import WebsocketStatus, AuthStatus + from pyquotex.global_value import AuthStatus, WebsocketStatus state = ConnectionState() assert state.status == WebsocketStatus.DISCONNECTED diff --git a/tests/test_expiration.py b/tests/test_expiration.py index d091be30..af2c6240 100644 --- a/tests/test_expiration.py +++ b/tests/test_expiration.py @@ -1,10 +1,9 @@ -import pytest -from datetime import datetime, timezone import time -from pyquotex.expiration import ( - timestamp_to_date, - get_timestamp_days_ago -) +from datetime import datetime, timezone + +import pytest + +from pyquotex.expiration import get_timestamp_days_ago, timestamp_to_date def test_timestamp_to_date(): @@ -12,7 +11,7 @@ def test_timestamp_to_date(): # Create an arbitrary UTC timestamp dt = datetime(2030, 1, 1, 12, 0, 0, tzinfo=timezone.utc) ts = dt.timestamp() - + formatted = timestamp_to_date(ts) assert isinstance(formatted, datetime) assert formatted.year == 2030 @@ -22,10 +21,10 @@ def test_get_timestamp_days_ago(): """Test getting timestamp N days in the past.""" now = time.time() days_ago = get_timestamp_days_ago(5) - + # Difference should be ~5 days (5 * 24 * 3600) expected_diff = 5 * 24 * 3600 actual_diff = now - days_ago - + # Allow for small execution delays assert abs(actual_diff - expected_diff) < 2 diff --git a/tests/test_offline_integration.py b/tests/test_offline_integration.py new file mode 100644 index 00000000..107a59ef --- /dev/null +++ b/tests/test_offline_integration.py @@ -0,0 +1,300 @@ +"""End-to-end integration tests that exercise the real ``Quotex`` client +against a local :class:`WSReplayServer`. + +These tests prove the WS stack works (socket.io framing, dispatch table, +slot registry, subscription tracking, reconnect loop) without ever +touching the broker. They run on CI. +""" +from __future__ import annotations + +import asyncio +import json +import time +from typing import Any, Callable + +import pytest + +from pyquotex.global_value import AuthStatus, WebsocketStatus +from pyquotex.stable_api import Quotex +from pyquotex.types import ReconnectPolicy +from tests.fakes.ws_replay_server import ( + WSReplayServer, + candle_history_frames, +) + +# Default replay greeting handles auth + an empty instruments/list, so a +# bare connect() finishes within a few hundred ms. + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_connect_completes_against_replay( + offline_quotex: Callable[..., Quotex], +) -> None: + client = offline_quotex() + ok, reason = await client.connect() + try: + assert ok, reason + assert client.api is not None + assert client.api.state.status == WebsocketStatus.CONNECTED + assert client.api.state.auth_status == AuthStatus.AUTHENTICATED + finally: + await client.close() + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_dispatch_handles_balance_event( + offline_quotex: Callable[..., Quotex], +) -> None: + """The default greeting includes a balance frame — assert it landed.""" + client = offline_quotex() + ok, _ = await client.connect() + assert ok + try: + # Wait briefly for the greeting frames to be processed. + for _ in range(50): + if client.api and client.api.account_balance is not None: + break + await asyncio.sleep(0.02) + assert client.api is not None + assert client.api.account_balance == { + "demoBalance": 10000.0, + "liveBalance": 0.0, + "currencyCode": "USD", + } + assert client.api.slots.balance.is_set() + finally: + await client.close() + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_get_balance_returns_event_driven_value( + offline_quotex: Callable[..., Quotex], +) -> None: + client = offline_quotex() + await client.connect() + try: + balance = await client.get_balance(timeout=2) + # demoBalance + profit_in_operation (None → 0). truncate to 2dp. + assert balance == 10000.0 + finally: + await client.close() + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_dispatch_handles_instruments_placeholder( + offline_quotex: Callable[..., Quotex], +) -> None: + """Greeting ends with the placeholder pattern + payload — instruments fill.""" + client = offline_quotex() + await client.connect() + try: + for _ in range(100): + if client.api and client.api.instruments: + break + await asyncio.sleep(0.02) + assert client.api is not None + assert client.api.instruments + assert client.api.instruments[0][1] == "EURUSD" + finally: + await client.close() + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_history_load_returns_candles( + offline_quotex: Callable[..., Quotex], + replay_server: WSReplayServer, +) -> None: + """Script a candle response and verify ``get_candles`` parses it.""" + replay_server.on_event( + "history/load", + reply=candle_history_frames(asset="EURUSD", period=60, n=30), + ) + # Stubs for subscribe / chart / follow — just ACK silently. + for event in ( + "instruments/update", + "chart_notification/get", + "depth/follow", + ): + replay_server.on_event(event, reply=[]) + + client = offline_quotex() + await client.connect() + try: + candles = await client.get_candles("EURUSD", None, 1800, 60, timeout=5) + assert candles is not None + assert len(candles) > 0 + assert "close" in candles[0] + finally: + await client.close() + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_get_candles_use_cache_hits_on_second_call( + offline_quotex: Callable[..., Quotex], + replay_server: WSReplayServer, +) -> None: + """Second call with use_cache=True should not round-trip to the WS.""" + replay_server.on_event( + "history/load", + reply=candle_history_frames(asset="EURUSD", period=60, n=30), + ) + for event in ( + "instruments/update", + "chart_notification/get", + "depth/follow", + ): + replay_server.on_event(event, reply=[]) + + client = offline_quotex() + await client.connect() + try: + # Pick an end_from_time that falls in a stable bucket. + end_time = 1_700_000_000.0 + + t0 = time.monotonic() + first = await client.get_candles( + "EURUSD", end_time, 1800, 60, use_cache=True, timeout=5 + ) + elapsed_first = time.monotonic() - t0 + assert first is not None and len(first) > 0 + + baseline_history_loads = sum( + 1 for m in replay_server.received if '"history/load"' in m + ) + + t0 = time.monotonic() + second = await client.get_candles( + "EURUSD", end_time, 1800, 60, use_cache=True, timeout=5 + ) + elapsed_second = time.monotonic() - t0 + assert second == first + # No second history/load went over the wire. + new_history_loads = sum( + 1 for m in replay_server.received if '"history/load"' in m + ) + assert new_history_loads == baseline_history_loads + # And the cached call is at least ~10x faster. + assert elapsed_second < elapsed_first / 5 + finally: + await client.close() + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_subscription_tracked_after_start_candles_stream( + offline_quotex: Callable[..., Quotex], + replay_server: WSReplayServer, +) -> None: + for event in ( + "instruments/update", + "chart_notification/get", + "depth/follow", + ): + replay_server.on_event(event, reply=[]) + + client = offline_quotex() + await client.connect() + try: + await client.start_candles_stream("EURUSD", 60) + assert client.api is not None + assert "candle:EURUSD:60" in client.api._subscriptions + sub = client.api._subscriptions["candle:EURUSD:60"] + assert sub.kind == "candle" + assert sub.asset == "EURUSD" + assert sub.period == 60 + finally: + await client.close() + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_reconnect_replays_subscriptions_against_replay( + offline_quotex: Callable[..., Quotex], + replay_server: WSReplayServer, +) -> None: + """End-to-end: open candle stream, force the WS down, watch replay happen.""" + for event in ( + "instruments/update", + "chart_notification/get", + "depth/follow", + ): + replay_server.on_event(event, reply=[]) + + client = offline_quotex( + reconnect_policy=ReconnectPolicy( + enabled=True, + max_attempts=2, + base_delay=0.05, + max_delay=0.2, + jitter=0.0, + stale_timeout=0, + ), + ) + await client.connect() + try: + await client.start_candles_stream("EURUSD", 60) + assert client.api is not None + api = client.api + ws_client = api.websocket_client + assert ws_client is not None + + baseline_subs = sum( + 1 for m in replay_server.received if '"instruments/update"' in m + ) + assert len(replay_server.connections) >= 1, "no server-side connection" + for conn in list(replay_server.connections): + await conn.close(code=1011, reason="forced") + + # Wait for the second open. Be generous; CI is slow. + for _ in range(200): + await asyncio.sleep(0.05) + if ws_client._open_count >= 2: # type: ignore[attr-defined] + break + + assert ws_client._open_count >= 2, ( # type: ignore[attr-defined] + f"Reconnect did not open a second WS (open_count=" + f"{ws_client._open_count})" # type: ignore[attr-defined] + ) + # Now wait for replay to fire the subscribe. + for _ in range(100): + await asyncio.sleep(0.05) + new_subs = sum( + 1 for m in replay_server.received if '"instruments/update"' in m + ) + if new_subs > baseline_subs: + break + + new_subs = sum( + 1 for m in replay_server.received if '"instruments/update"' in m + ) + assert new_subs > baseline_subs, ( + f"Replay did not re-issue subscribe (saw {new_subs}, " + f"baseline {baseline_subs})" + ) + finally: + await client.close() + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_async_context_manager_closes_cleanly( + offline_quotex: Callable[..., Quotex], +) -> None: + client = offline_quotex() + async with client as q: + assert q.api is not None + assert q.api.state.status == WebsocketStatus.CONNECTED + ws_client = q.api.websocket_client + assert ws_client is not None and ws_client.is_alive() + # After exit, the WS layer should be closed even if the cached + # state enum hasn't been fully drained from the dispatch loop. + assert client.api is not None + assert client.api.websocket_client is not None + assert not client.api.websocket_client.is_alive() + assert client.api.websocket_client._closing is True # type: ignore[attr-defined] diff --git a/tests/test_processor.py b/tests/test_processor.py index 2090daba..a4953073 100644 --- a/tests/test_processor.py +++ b/tests/test_processor.py @@ -1,4 +1,5 @@ import pytest + from pyquotex.utils.processor import get_color, process_candles, process_candles_v2 @@ -23,46 +24,46 @@ def test_get_color_invalid(): candle1 = {"open": 1.1000} candle2 = {"close": 1.1050} candle3 = {} - + with pytest.raises(KeyError): get_color(candle1) - + with pytest.raises(KeyError): get_color(candle2) - + with pytest.raises(KeyError): get_color(candle3) def test_process_candles_grouping(): """Test that process_candles can group smaller period candles.""" - + # A list of 1-second ticks raw_candles = [ {"time": 1000000, "price": 1.0, "amount": 1}, {"time": 1000001, "price": 1.5, "amount": 1}, {"time": 1000002, "price": 0.5, "amount": 1}, {"time": 1000003, "price": 1.2, "amount": 1}, - + {"time": 1000004, "price": 2.0, "amount": 1}, {"time": 1000005, "price": 2.5, "amount": 1}, {"time": 1000006, "price": 1.5, "amount": 1}, {"time": 1000007, "price": 2.2, "amount": 1}, {"time": 1000008, "price": 2.0, "amount": 1}, # Forces the previous candle to close ] - + # Process into 4-second period candles processed = process_candles(raw_candles, 4) - + assert len(processed) == 2 - + # First candle assert processed[0]['open'] == 1.0 assert processed[0]['close'] == 1.2 assert processed[0]['high'] == 1.5 assert processed[0]['low'] == 0.5 assert processed[0]['ticks'] == 4 - + # Second candle assert processed[1]['open'] == 2.0 assert processed[1]['close'] == 2.2 diff --git a/tests/test_tournament.py b/tests/test_tournament.py index c14014b9..a50fb2f2 100644 --- a/tests/test_tournament.py +++ b/tests/test_tournament.py @@ -8,7 +8,7 @@ async def test_tournament_account_example(): """ Example of how to use a tournament account in demo mode. - + To use a tournament, you must provide the tournament_id. Tournament accounts are always considered 'DEMO' (practice) type. """ diff --git a/tests/test_waits.py b/tests/test_waits.py index feabfb50..464c66a4 100644 --- a/tests/test_waits.py +++ b/tests/test_waits.py @@ -1,5 +1,6 @@ """Unit tests for WaitableSlot and wait_until.""" import asyncio + import pytest from pyquotex._api._waits import SlotRegistry, WaitableSlot, wait_until @@ -181,6 +182,7 @@ def test_slot_registry_candle_v2_release(): async def test_backoff_sleep_respects_base(): """attempt=0 with base=0.01 should sleep ~0.01s (within jitter).""" import time + from pyquotex._api._waits import backoff_sleep start = time.monotonic() await backoff_sleep(0, base=0.01, cap=0.1, jitter=0) @@ -192,6 +194,7 @@ async def test_backoff_sleep_respects_base(): async def test_backoff_sleep_caps_at_max(): """A large attempt should not exceed cap (within jitter).""" import time + from pyquotex._api._waits import backoff_sleep start = time.monotonic() await backoff_sleep(5, base=0.01, cap=0.05, jitter=0) From 130d5a1a51e78796bf0dc1fc9ecf307a14b70632 Mon Sep 17 00:00:00 2001 From: Victalejo Date: Fri, 15 May 2026 12:32:52 -0500 Subject: [PATCH 2/3] feat: add buy and check_win step to smoke demo for end-to-end trade testing --- scripts/smoke_demo.py | 54 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/scripts/smoke_demo.py b/scripts/smoke_demo.py index 69b4e619..72f19ece 100644 --- a/scripts/smoke_demo.py +++ b/scripts/smoke_demo.py @@ -149,6 +149,57 @@ async def step_subscription_replay(q: Quotex, asset: str, period: int) -> None: print(f" Post-reconnect candles fetched: {len(fresh or [])}") +async def step_buy_and_check_win(q: Quotex) -> None: + """Place a real DEMO buy and resolve it via the event-driven check_win. + + Exercises the end-to-end trade path against the broker: + * settings_apply / orders/open frames + * slots.buy_confirm wakes up when broker echoes the order id + * slots.win_result(order_id) wakes up when the deal closes + """ + banner("Step 7 — buy + check_win (DEMO trade)") + # Try a few candidates; first one the broker accepts wins. + candidates = ["EURUSD_otc", "EURUSD", "AUDCAD_otc", "AUDCAD"] + amount = 1.0 + duration = 60 + + sym = None + buy_info: Any = None + ok = False + for candidate in candidates: + print(f" Trying buy on {candidate}…") + try: + ok, buy_info = await asyncio.wait_for( + q.buy(amount, candidate, "call", duration), + timeout=45, + ) + except (TimeoutError, asyncio.TimeoutError): + print(" realtime stream timed out — try next") + continue + except Exception as e: + print(f" exception: {e!r}") + continue + if ok and isinstance(buy_info, dict) and buy_info.get("id"): + sym = candidate + break + print(f" rejected: ok={ok!r} info={buy_info!r}") + if not ok or not sym: + print(" ⚠️ No tradeable asset right now; skipping trade step") + return + if not ok: + print(f" ❌ Buy failed: {buy_info}") + return + order_id = buy_info.get("id") if isinstance(buy_info, dict) else None + print(f" ✅ Buy accepted: order_id={order_id}") + if not order_id: + print(" ⚠️ No order_id returned — can't check_win") + return + + print(f" Waiting for trade to close (~{duration}s)…") + win, profit = await q.check_win(order_id, duration=duration) + print(f" ✅ check_win → status={win!r} profit={profit}") + + async def main() -> None: email, password = credentials() policy = ReconnectPolicy( @@ -183,6 +234,9 @@ async def main() -> None: candles, asset, period = await step_candles_cache(q) await step_streaming_indicators(candles) await step_typed_candle(candles) + # Buy BEFORE the forced reconnect — it needs realtime price ticks + # flowing for the asset, which is most reliable on a fresh socket. + await step_buy_and_check_win(q) await step_subscription_replay(q, asset, period) banner("Done — context manager cleanly closed the connection.") From 483b9dae0b278fce60154a317decd575a6c9f15f Mon Sep 17 00:00:00 2001 From: Victalejo Date: Fri, 15 May 2026 12:37:54 -0500 Subject: [PATCH 3/3] feat: update CI configuration and enhance test collection logic --- .coverage | Bin 69632 -> 69632 bytes .github/workflows/ci.yml | 4 +-- pyproject.toml | 62 +++++++++++++++++++++++---------------- tests/conftest.py | 24 +++++++++++++-- 4 files changed, 61 insertions(+), 29 deletions(-) diff --git a/.coverage b/.coverage index affa82639d14c1ac62abeb4dea8c78f058544ac5..0fd7ef65a2ae8448deaa2f810433c353d9dc38ce 100644 GIT binary patch delta 18 ZcmZozz|ydQWkYBmGXsOe=7_!(jQ~H+2H^kz delta 18 ZcmZozz|ydQWkYBmGt(B2%@KVo8UaN52cG}{ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b8c53a9c..f9bd0076 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,7 +40,7 @@ jobs: with: python-version: "3.13" cache: pip - - run: pip install -r requirements.txt mypy + - run: pip install -e ".[test]" mypy - name: mypy (public surface only) run: | mypy --ignore-missing-imports --no-strict-optional \ @@ -64,7 +64,7 @@ jobs: with: python-version: ${{ matrix.python }} cache: pip - - run: pip install -r requirements.txt pytest pytest-asyncio pytest-mock pytest-cov + - run: pip install -e ".[test]" - name: pytest (offline only) env: PYQUOTEX_LIVE: "0" diff --git a/pyproject.toml b/pyproject.toml index 2390af4c..779bedfb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,39 +2,55 @@ name = "pyquotex" version = "1.1.0" description = "Quotex API Client written in Python." -authors = [ - { name = "cleiton", email = "cleiton.leonel@gmail.com"}] +authors = [{ name = "cleiton", email = "cleiton.leonel@gmail.com" }] license = "MIT" 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)", - "pyfiglet (>=1.0.2,<2.0.0)", - "beautifulsoup4 (>=4.12.3,<5.0.0)", - "fake-useragent (==2.2.0)", - "certifi (>=2025.1.31)", - "rich (>=13.7.0,<14.0.0)", + "websockets>=12.0", + "httpx>=0.27.0,<1.0.0", + "pyfiglet>=1.0.2,<2.0.0", + "beautifulsoup4>=4.12.3,<5.0.0", + "fake-useragent==2.2.0", + "certifi>=2025.1.31", + "rich>=13.7.0,<14.0.0", # numpy removed: the 7 calls in indicators.py have been replaced with # pure-Python / stdlib equivalents (statistics.pstdev, list comprehensions). # This saves ~20 MB and unblocks installation on Termux / slim images. ] [project.optional-dependencies] -fast = ["orjson (>=3.9.0,<4.0.0)"] +fast = ["orjson>=3.9.0,<4.0.0"] +# `dev` and `test` extras are convenience for local setup; CI installs +# them explicitly via pip so changes here don't change CI behavior. +test = [ + "pytest>=8.0.0", + "pytest-asyncio>=0.23.5", + "pytest-mock>=3.12.0", + "pytest-cov>=4.1.0", +] +dev = [ + "pyquotex[test]", + "ruff>=0.5.0", + "mypy>=1.10.0", +] + +[build-system] +requires = ["hatchling>=1.20"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["pyquotex"] +# py.typed marker (PEP 561) must ship in the wheel. +include = ["pyquotex/py.typed"] -[tool.poetry.group.dev.dependencies] -python = ">=3.12,<4.0" -pytest = "^8.0.0" -pytest-asyncio = "^0.23.5" -pytest-mock = "^3.12.0" +[tool.hatch.build.targets.sdist] +include = [ + "pyquotex", + "README.md", + "LICENSE", +] [tool.pytest.ini_options] asyncio_mode = "auto" @@ -64,7 +80,3 @@ ignore = [ [tool.ruff.lint.per-file-ignores] "tests/*" = ["F401", "F811", "F541"] # tests intentionally import-and-discard "scripts/*" = ["F401", "F541"] - -[build-system] -requires = ["poetry-core>=2.0.0"] -build-backend = "poetry.core.masonry.api" diff --git a/tests/conftest.py b/tests/conftest.py index 01fa6cb2..af107e7c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -40,6 +40,10 @@ }) +def _live_enabled() -> bool: + return os.environ.get("PYQUOTEX_LIVE", "").lower() in {"1", "true", "yes"} + + def pytest_configure(config: pytest.Config) -> None: """Register custom markers used across the test suite.""" for marker, description in ( @@ -50,11 +54,27 @@ def pytest_configure(config: pytest.Config) -> None: config.addinivalue_line("markers", f"{marker}: {description}") +def pytest_ignore_collect(collection_path, config: pytest.Config) -> bool | None: + """Skip *collection* of legacy live modules when ``PYQUOTEX_LIVE`` is off. + + Several legacy ``test_*.py`` files call :func:`pyquotex.config.credentials` + at module import time, which blocks on ``input()`` when ``settings/config.ini`` + is missing (as is the case on CI). Collecting them at all would crash the + run before any test executes, so we drop them entirely outside live mode. + """ + if _live_enabled(): + return None + stem = collection_path.stem # filename without extension + if stem in _LIVE_TEST_MODULES: + return True + return None + + def pytest_collection_modifyitems( config: pytest.Config, items: list[pytest.Item] ) -> None: - """Auto-mark legacy live tests and skip them unless ``PYQUOTEX_LIVE=1``.""" - live_enabled = os.environ.get("PYQUOTEX_LIVE", "").lower() in {"1", "true", "yes"} + """Auto-mark live tests; skip them at runtime unless ``PYQUOTEX_LIVE=1``.""" + live_enabled = _live_enabled() skip_live = pytest.mark.skip( reason="live test (set PYQUOTEX_LIVE=1 to enable)" )