diff --git a/README.md b/README.md index 8236ef93..f4e7c8ce 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,16 @@ Prover ferramentas para desenvolvedores integrarem seus sistemas com a plataform https://cleitonleonel.github.io/pyquotex/ +## πŸ— Arquitectura interna + +- `pyquotex.stable_api.Quotex` β€” facade pΓΊblico (la API que usΓ‘s). +- `pyquotex._api/*` β€” mixins por dominio (account, trading, history, realtime, assets). +- `pyquotex.cli/*` β€” entrada de comandos del CLI. +- `pyquotex.api.QuotexAPI` β€” cliente WebSocket subyacente. + +La interfaz pΓΊblica no cambia entre 1.0.x y 1.1.0. + + ## πŸ›  InstalaΓ§Γ£o ### 1. Clone o repositΓ³rio: diff --git a/app.py b/app.py index d98c3b6a..c6f6b045 100644 --- a/app.py +++ b/app.py @@ -1,1435 +1,8 @@ -#!/usr/bin/env python3 -"""PyQuotex CLI β€” Complete Quotex trading API client. +"""Compatibility shim. The CLI now lives in pyquotex.cli. -Every public method exposed by "stable_api.Quotex" is reachable from this -CLI. Use "python app.py --help" or "python app.py --help" for -full usage. - -Commands --------- -Connection & Auth - login Connect and show profile + balance Show current balance - -Account Management - set-demo-balance Refill / set demo (practice) balance - server-time Show synced server timestamp - settings Apply and retrieve trading-UI settings - -Assets & Payouts - assets List all available assets with open/closed status - payout Show payout % for all asset - payout-asset Show payout % for a single asset - -Candle / Market Data - candles Fetch latest candles (up to 199 per request) - candles-v2 Fetch candles via the v2 API path - candles-deep Fetch deep historical data (parallel workers) - history-line Fetch raw historical price-line data - candle-info Opening / closing / remaining time of current candle - realtime-price Live price stream for an asset - realtime-sentiment Live trader-sentiment stream - realtime-candle Live candle tick stream - -Trading - buy Place an immediate binary option trade - sell / close an open position early - pending Place a pending order (executed at a future time) - check a win/loss result of a trade by ID - result Look up a trade result from history by operation ID - signals Fetch current signal data from the signal stream - -History Show recent trade history (paged) - -Indicator Calculate a technical indicator (RSI, MACD, BB, …) - subscribe-indicator Live indicator stream with callback - -Monitoring - monitor Real-time candle price monitor - strategy Run Triple-Confirmation strategy (demo only) +Kept so that documented usage `python app.py ` continues to work. """ -import argparse -import asyncio -import csv -import logging -import sys -import time -from datetime import datetime -from typing import Any - -from rich import box -from rich.console import Console -from rich.panel import Panel -from rich.progress import ( - Progress, SpinnerColumn, TextColumn, BarColumn, TaskProgressColumn -) -from rich.table import Table - -from pyquotex.config import credentials -from pyquotex.stable_api import Quotex -from pyquotex.utils.strategy import TripleConfirmationStrategy - -console = Console() -logger = logging.getLogger(__name__) - -# Global to track current progress for OTP handling -current_progress: Progress | None = None - - -# --------------------------------------------------------------------------- -# OTP callback -# --------------------------------------------------------------------------- - -async def on_otp(message: str) -> str: - """Callback to handle OTP input, pausing progress spinners if active.""" - if current_progress: - current_progress.stop() - try: - pin = console.input(f"[bold yellow]πŸ” {message}[/]") - return pin - finally: - current_progress.start() - else: - return console.input(f"[bold yellow]πŸ” {message}[/]") - - -# --------------------------------------------------------------------------- -# Argument parser -# --------------------------------------------------------------------------- - -def make_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser( - prog="pyquotex", - description="⚑ PyQuotex β€” Complete Quotex trading API CLI", - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=( - "Examples:\n" - " pyquotex login --demo\n" - " pyquotex balance --live\n" - " pyquotex assets\n" - " pyquotex payout\n" - " pyquotex payout-asset --asset EURUSD --timeframe 1\n" - " pyquotex candles --asset EURUSD --period 60 --count 10\n" - " pyquotex candles-v2 --asset EURUSD --period 60\n" - " pyquotex candles-deep --asset EURUSD --seconds 3600 --workers 5\n" - " pyquotex history-line --asset EURUSD --offset 3600\n" - " pyquotex candle-info --asset EURUSD --period 60\n" - " pyquotex realtime-price --asset EURUSD\n" - " pyquotex realtime-sentiment --asset EURUSD\n" - " pyquotex realtime-candle --asset EURUSD --period 60\n" - " pyquotex buy --asset EURUSD --amount 5 --direction call --duration 60 --check-win\n" - " pyquotex sell --id TRADE_ID\n" - " pyquotex pending --asset EURUSD --amount 10 --direction call --duration 60\n" - " pyquotex check --id TRADE_ID\n" - " pyquotex result --id OPERATION_ID\n" - " pyquotex history --pages 2\n" - " pyquotex signals\n" - " pyquotex indicator --asset EURUSD --name RSI --period 14\n" - " pyquotex server-time\n" - " pyquotex set-demo-balance --amount 10000\n" - " pyquotex settings --asset EURUSD --period 60\n" - " pyquotex monitor --asset EURUSD\n" - " pyquotex strategy --asset EURUSD --auto-trade\n" - ), - ) - sub = parser.add_subparsers(dest="command", metavar="COMMAND") - - # ── helpers ───────────────────────────────────────────────────────────── - def _add_account_flags(p: argparse.ArgumentParser) -> None: - g = p.add_mutually_exclusive_group() - g.add_argument("--demo", action="store_true", default=True, - help="Use demo account (default)") - g.add_argument("--live", action="store_true", - help="Use live account") - - def _add_asset_flag(p: argparse.ArgumentParser, - default: str = "EURUSD") -> None: - p.add_argument("--asset", default=default, - help=f"Asset symbol (default: {default})") - - # ── test-all ───────────────────────────────────────────────────────────── - sub.add_parser("test-all", help="Run all tests") - - # ── login ──────────────────────────────────────────────────────────────── - p = sub.add_parser("login", help="Test connection and show profile + balance") - _add_account_flags(p) - - # ── balance ────────────────────────────────────────────────────────────── - p = sub.add_parser("balance", help="Show account balance") - _add_account_flags(p) - - # ── server-time ────────────────────────────────────────────────────────── - sub.add_parser("server-time", - help="Show the current synced server timestamp") - - # ── set-demo-balance ───────────────────────────────────────────────────── - p = sub.add_parser("set-demo-balance", - help="Refill or set demo (practice) account balance") - p.add_argument("--amount", type=float, default=10000.0, - help="Amount to set (default: 10000)") - - # ── settings ───────────────────────────────────────────────────────────── - p = sub.add_parser("settings", - help="Apply trading-UI settings and show result") - _add_asset_flag(p) - p.add_argument("--period", type=int, default=60, - help="Candle period in seconds (default: 60)") - p.add_argument("--mode", choices=["TIMER", "TURBO"], default="TIMER", - help="Time mode (default: TIMER)") - p.add_argument("--deal", type=int, default=5, - help="Default deal amount (default: 5)") - _add_account_flags(p) - - # ── assets ─────────────────────────────────────────────────────────────── - sub.add_parser("assets", help="List all available assets") - - # ── payout ─────────────────────────────────────────────────────────────── - sub.add_parser("payout", help="Show payout % for all assets") - - # ── payout-asset ───────────────────────────────────────────────────────── - p = sub.add_parser("payout-asset", - help="Show payout % for a specific asset") - _add_asset_flag(p) - p.add_argument("--timeframe", default="1", - choices=["1", "5", "24", "all"], - help="Timeframe in minutes, or 'all' (default: 1)") - - # ── candles ────────────────────────────────────────────────────────────── - p = sub.add_parser("candles", help="Fetch latest candle data (≀199)") - _add_asset_flag(p) - p.add_argument("--period", type=int, default=60, - help="Candle period in seconds (default: 60)") - p.add_argument("--count", type=int, default=10, - help="Number of candles to display (default: 10)") - _add_account_flags(p) - - # ── candles-v2 ─────────────────────────────────────────────────────────── - p = sub.add_parser("candles-v2", - help="Fetch candles via the v2 API path") - _add_asset_flag(p) - p.add_argument("--period", type=int, default=60, - help="Candle period in seconds (default: 60)") - _add_account_flags(p) - - # ── candles-deep ───────────────────────────────────────────────────────── - p = sub.add_parser("candles-deep", - help="Fetch deep historical candle data (parallel workers)") - _add_asset_flag(p) - p.add_argument("--seconds", type=int, default=3600, - help="Total history window in seconds (default: 3600)") - p.add_argument("--period", type=int, default=60, - help="Candle period in seconds (default: 60)") - p.add_argument("--workers", type=int, default=5, - help="Parallel workers 2-10 (default: 5). " - "WARNING: >10 may cause a ban.") - p.add_argument("--output", metavar="FILE", - help="Save results to a CSV file") - _add_account_flags(p) - - # ── history-line ───────────────────────────────────────────────────────── - p = sub.add_parser("history-line", - help="Fetch raw historical price-line data") - _add_asset_flag(p) - p.add_argument("--offset", type=int, default=3600, - help="History window in seconds (default: 3600)") - _add_account_flags(p) - - # ── candle-info ────────────────────────────────────────────────────────── - p = sub.add_parser("candle-info", - help="Show opening / closing / remaining time of current candle") - _add_asset_flag(p) - p.add_argument("--period", type=int, default=60, - help="Candle period in seconds (default: 60)") - _add_account_flags(p) - - # ── realtime-price ─────────────────────────────────────────────────────── - p = sub.add_parser("realtime-price", - help="Stream live price data for an asset") - _add_asset_flag(p) - p.add_argument("--period", type=int, default=60, - help="Candle period in seconds (default: 60)") - _add_account_flags(p) - - # ── realtime-sentiment ─────────────────────────────────────────────────── - p = sub.add_parser("realtime-sentiment", - help="Stream live trader-sentiment data") - _add_asset_flag(p) - p.add_argument("--period", type=int, default=60, - help="Candle period in seconds (default: 60)") - _add_account_flags(p) - - # ── realtime-candle ────────────────────────────────────────────────────── - p = sub.add_parser("realtime-candle", - help="Stream live processed candle ticks") - _add_asset_flag(p) - p.add_argument("--period", type=int, default=60, - help="Candle period in seconds (default: 60)") - _add_account_flags(p) - - # ── buy ────────────────────────────────────────────────────────────────── - p = sub.add_parser("buy", help="Place an immediate binary option trade") - _add_asset_flag(p) - p.add_argument("--amount", type=float, default=1.0, - help="Trade amount (default: 1.0)") - p.add_argument("--direction", choices=["call", "put"], default="call", - help="call = UP, put = DOWN (default: call)") - p.add_argument("--duration", type=int, default=60, - help="Duration in seconds (default: 60)") - p.add_argument("--check-win", action="store_true", - help="Wait for the trade to settle and show win/loss") - _add_account_flags(p) - - # ── sell ───────────────────────────────────────────────────────────────── - p = sub.add_parser("sell", help="Sell / close an open position early") - p.add_argument("--id", dest="trade_id", required=True, - help="Trade ID to sell") - _add_account_flags(p) - - # ── pending ────────────────────────────────────────────────────────────── - p = sub.add_parser("pending", - help="Place a pending order (executed at a future time)") - _add_asset_flag(p) - p.add_argument("--amount", type=float, default=1.0, - help="Trade amount (default: 1.0)") - p.add_argument("--direction", choices=["call", "put"], default="call", - help="call = UP, put = DOWN (default: call)") - p.add_argument("--duration", type=int, default=60, - help="Duration in seconds (default: 60)") - p.add_argument("--open-time", dest="open_time", default=None, - help="Exact open time HH:MM (optional, defaults to next candle)") - _add_account_flags(p) - - # ── check ──────────────────────────────────────────────────────────────── - p = sub.add_parser("check", - help="Check win/loss result of a trade by ID") - p.add_argument("--id", dest="trade_id", required=True, - help="Trade ID to check") - _add_account_flags(p) - - # ── result ─────────────────────────────────────────────────────────────── - p = sub.add_parser("result", - help="Look up trade result from history by operation ID") - p.add_argument("--id", dest="operation_id", required=True, - help="Operation ID to look up") - _add_account_flags(p) - - # ── history ────────────────────────────────────────────────────────────── - p = sub.add_parser("history", help="Show recent trade history (paged)") - p.add_argument("--pages", type=int, default=1, - help="Number of history pages (default: 1)") - _add_account_flags(p) - - # ── signals ────────────────────────────────────────────────────────────── - sub.add_parser("signals", - help="Fetch current signal data from the signals stream") - - # ── indicator ──────────────────────────────────────────────────────────── - p = sub.add_parser("indicator", - help="Calculate a technical indicator (RSI, MACD, BB, …)") - _add_asset_flag(p) - p.add_argument("--name", - choices=["RSI", "MACD", "BOLLINGER", - "STOCHASTIC", "ADX", "ATR", "SMA", "EMA", "ICHIMOKU"], - default="RSI", - help="Indicator name (default: RSI)") - p.add_argument("--period", type=int, default=14, - help="Indicator period (default: 14)") - p.add_argument("--timeframe", type=int, default=60, - help="Candle timeframe in seconds (default: 60)") - _add_account_flags(p) - - # ── monitor ────────────────────────────────────────────────────────────── - p = sub.add_parser("monitor", - help="Real-time price monitor for an asset") - _add_asset_flag(p) - p.add_argument("--period", type=int, default=60, - help="Candle period in seconds (default: 60)") - - # ── strategy ───────────────────────────────────────────────────────────── - p = sub.add_parser("strategy", - help="Run Triple-Confirmation strategy (DEMO recommended)") - _add_asset_flag(p) - p.add_argument("--period", type=int, default=60, - help="Candle period in seconds (default: 60)") - p.add_argument("--auto-trade", action="store_true", - help="Automatically place trades on signals (DEMO only)") - - return parser - - -# --------------------------------------------------------------------------- -# Connection helper with exponential backoff -# --------------------------------------------------------------------------- - -async def connect_with_retry( - client: Quotex, - is_demo: bool, - max_attempts: int = 5, -) -> bool: - """Connect to Quotex with exponential backoff on failure.""" - if await client.check_connect(): - return True - - delay = 1.0 - for attempt in range(1, max_attempts + 1): - with Progress( - SpinnerColumn(), - TextColumn( - f"[cyan]Connecting (attempt {attempt}/{max_attempts})…" - ), - transient=True, - console=console, - ) as prog: - global current_progress - current_progress = prog - prog.add_task("connect") - client.account_is_demo = 1 if is_demo else 0 - try: - check, reason = await client.connect() - finally: - current_progress = None - - if check: - console.print(f"[bold green]βœ“[/] Connected β€” {reason}") - return True - - console.print( - f"[yellow]⚠ Connection failed:[/] {reason}. " - f"Retrying in {delay:.0f}s…" - ) - await asyncio.sleep(delay) - delay = min(delay * 2, 30) - - console.print("[bold red]βœ— Could not connect after maximum attempts.[/]") - return False - - -# --------------------------------------------------------------------------- -# Shared helpers -# --------------------------------------------------------------------------- - -def _is_demo(args: argparse.Namespace) -> bool: - if hasattr(args, "live") and args.live: - return False - return True - - -def _balance_table(profile: Any) -> Table: - table = Table( - title="πŸ’° [bold]Account Balance[/]", - show_header=True, - header_style="bold bright_white on magenta", - box=box.ROUNDED, - border_style="magenta", - row_styles=["none", "dim"], - padding=(0, 1), - ) - table.add_column("Account", style="cyan", no_wrap=True) - table.add_column("Balance", justify="right", style="bold green") - table.add_column("Currency", style="bright_white") - table.add_row( - "Demo", f"{profile.demo_balance:,.2f}", profile.currency_symbol or "" - ) - table.add_row( - "Live", f"{profile.live_balance:,.2f}", profile.currency_symbol or "" - ) - return table - - -# --------------------------------------------------------------------------- -# Command implementations β€” Connection & Account -# --------------------------------------------------------------------------- - -async def cmd_login(client: Quotex, args: argparse.Namespace) -> None: - """Connect and display user profile + balance.""" - is_demo = _is_demo(args) - if not await connect_with_retry(client, is_demo): - return - profile = await client.get_profile() - console.print(_balance_table(profile)) - console.print(Panel( - f"[bold blue]Nickname:[/] {profile.nick_name}\n" - f"[bold blue]Country:[/] {profile.country_name}\n" - f"[bold blue]Offset:[/] {profile.offset}", - title="πŸ‘€ [bold]User Profile[/]", - border_style="bright_blue", - box=box.ROUNDED, - padding=(1, 2), - expand=False, - )) - - -async def cmd_balance(client: Quotex, args: argparse.Namespace) -> None: - """Display current balance.""" - is_demo = _is_demo(args) - if not await connect_with_retry(client, is_demo): - return - profile = await client.get_profile() - console.print(_balance_table(profile)) - - -async def cmd_server_time(client: Quotex, args: argparse.Namespace) -> None: - """Show the current synced server timestamp.""" - if not await connect_with_retry(client, True): - return - ts = await client.get_server_time() - dt = datetime.fromtimestamp(ts) - console.print(Panel( - f"[bold cyan]Unix:[/] {ts}\n" - f"[bold cyan]Local:[/] {dt.strftime('%Y-%m-%d %H:%M:%S')}", - title="πŸ•’ [bold]Server Time[/]", - border_style="cyan", - box=box.ROUNDED, - expand=False, - )) - - -async def cmd_set_demo_balance( - client: Quotex, args: argparse.Namespace -) -> None: - """Refill or set the demo (practice) account balance.""" - if not await connect_with_retry(client, True): - return - result = await client.edit_practice_balance(args.amount) - console.print(Panel( - f"[bold green]βœ“ Demo balance updated[/]\n{result}", - title="πŸ’Έ [bold]Set Demo Balance[/]", - border_style="green", - box=box.ROUNDED, - expand=False, - )) - - -async def cmd_settings(client: Quotex, args: argparse.Namespace) -> None: - """Apply trading-UI settings and display the server response.""" - is_demo = _is_demo(args) - if not await connect_with_retry(client, is_demo): - return - result = await client.store_settings_apply( - asset=args.asset, - period=args.period, - time_mode=args.mode, - deal=args.deal, - ) - table = Table( - title="βš™οΈ [bold]Settings Applied[/]", - box=box.ROUNDED, - border_style="cyan", - show_header=True, - header_style="bold cyan", - ) - table.add_column("Key", style="bright_white") - table.add_column("Value", style="yellow") - for k, v in result.items(): - table.add_row(str(k), str(v)) - console.print(table) - - -# --------------------------------------------------------------------------- -# Command implementations β€” Assets & Payouts -# --------------------------------------------------------------------------- - -async def cmd_assets(client: Quotex, args: argparse.Namespace) -> None: - """List all available assets with open/closed status.""" - if not await connect_with_retry(client, True): - return - await client.get_all_assets() - instruments = await client.get_instruments() - if not instruments: - console.print("[red]No instruments received.[/]") - return - - table = Table( - title="πŸ“Š [bold]Available Assets[/]", - box=box.ROUNDED, - border_style="bright_blue", - show_header=True, - header_style="bold bright_white on blue", - row_styles=["none", "dim"], - ) - table.add_column("#", style="dim", width=4) - table.add_column("Asset", style="cyan", no_wrap=True) - table.add_column("Name", style="white") - table.add_column("Status", justify="center") - table.add_column("Payout %", justify="right", style="green") - - for idx, i in enumerate(instruments, 1): - status = "[green]OPEN[/]" if i[14] else "[red]CLOSED[/]" - payout = f"{i[5]}%" if len(i) > 5 else "β€”" - table.add_row(str(idx), i[1], i[2].replace("\n", ""), status, payout) - - console.print(table) - - -async def cmd_payout(client: Quotex, args: argparse.Namespace) -> None: - """Show payout % for all assets.""" - if not await connect_with_retry(client, True): - return - await client.get_all_assets() - data = client.get_payment() - if not data: - console.print("[red]No payout data available.[/]") - return - - table = Table( - title="πŸ’Ή [bold]Asset Payouts[/]", - box=box.ROUNDED, - border_style="green", - show_header=True, - header_style="bold bright_white on green", - row_styles=["none", "dim"], - ) - table.add_column("Asset", style="cyan", no_wrap=True) - table.add_column("Payout %", justify="right") - table.add_column("Turbo %", justify="right") - table.add_column("1M %", justify="right") - table.add_column("5M %", justify="right") - table.add_column("Open", justify="center") - - for asset, info in data.items(): - status = "[green]βœ“[/]" if info.get("open") else "[red]βœ—[/]" - table.add_row( - asset, - str(info.get("payment", "β€”")), - str(info.get("turbo_payment", "β€”")), - str(info.get("profit", {}).get("1M", "β€”")), - str(info.get("profit", {}).get("5M", "β€”")), - status, - ) - console.print(table) - - -async def cmd_payout_asset(client: Quotex, args: argparse.Namespace) -> None: - """Show payout % for a specific asset.""" - if not await connect_with_retry(client, True): - return - await client.get_all_assets() - result = client.get_payout_by_asset(args.asset, args.timeframe) - if result is None: - console.print(f"[red]Asset '{args.asset}' not found.[/]") - return - console.print(Panel( - f"[bold cyan]Asset:[/] {args.asset}\n" - f"[bold cyan]Timeframe:[/] {args.timeframe}M\n" - f"[bold green]Payout:[/] {result}%", - title="πŸ’Ή [bold]Asset Payout[/]", - border_style="green", - box=box.ROUNDED, - expand=False, - )) - - -# --------------------------------------------------------------------------- -# Command implementations β€” Candle / Market Data -# --------------------------------------------------------------------------- - -async def cmd_candles(client: Quotex, args: argparse.Namespace) -> None: - """Fetch latest candles for an asset (up to 199 per call).""" - is_demo = _is_demo(args) - if not await connect_with_retry(client, is_demo): - return - asset, _ = await client.get_available_asset(args.asset, force_open=True) - candles = await client.get_candles( - asset, time.time(), args.period * args.count, args.period - ) - if not candles: - console.print("[red]No candle data received.[/]") - return - _print_candles_table(candles[-args.count:], asset, args.period) - - -async def cmd_candles_v2(client: Quotex, args: argparse.Namespace) -> None: - """Fetch candles via the v2 API path.""" - is_demo = _is_demo(args) - if not await connect_with_retry(client, is_demo): - return - asset, _ = await client.get_available_asset(args.asset, force_open=True) - candles = await client.get_candle_v2(asset, args.period) - if not candles: - console.print("[red]No v2 candle data received.[/]") - return - _print_candles_table(candles, asset, args.period, title="Candles (v2)") - - -async def cmd_candles_deep(client: Quotex, args: argparse.Namespace) -> None: - """Fetch deep historical candle data using parallel workers.""" - is_demo = _is_demo(args) - if not await connect_with_retry(client, is_demo): - return - if args.workers > 10: - console.print( - "[bold red]⚠ WARNING:[/] workers > 10 may cause a ban. " - "Clamping to 10." - ) - args.workers = 10 - - asset, _ = await client.get_available_asset(args.asset, force_open=True) - - def _progress_cb(done: int, total: int, count: int, label: str) -> None: - pct = int(done / total * 100) if total else 0 - console.print( - f" [dim]{label}[/] {pct}% β€” {count} candles collected", - end="\r", - ) - - with Progress( - SpinnerColumn(), - TextColumn("[cyan]Fetching deep history…"), - BarColumn(), - TaskProgressColumn(), - transient=True, - console=console, - ) as prog: - prog.add_task("fetch") - candles = await client.get_historical_candles( - asset, - amount_of_seconds=args.seconds, - period=args.period, - max_workers=args.workers, - progress_callback=_progress_cb, - ) - - console.print(f"\n[green]βœ“[/] {len(candles)} candles fetched.") - _print_candles_table(candles[-20:], asset, args.period, - title=f"Last 20 of {len(candles)} candles (deep)") - - if args.output: - _save_candles_csv(candles, args.output) - console.print(f"[green]βœ“ Saved to {args.output}[/]") - - -async def cmd_history_line(client: Quotex, args: argparse.Namespace) -> None: - """Fetch raw historical price-line data.""" - is_demo = _is_demo(args) - if not await connect_with_retry(client, is_demo): - return - asset, _ = await client.get_available_asset(args.asset, force_open=True) - await client.get_all_assets() - data = await client.get_history_line( - asset, time.time(), args.offset - ) - if not data: - console.print("[red]No history-line data received.[/]") - return - console.print(Panel( - str(data)[:2000], - title=f"πŸ“ˆ [bold]History Line β€” {asset}[/]", - border_style="blue", - box=box.ROUNDED, - )) - - -async def cmd_candle_info(client: Quotex, args: argparse.Namespace) -> None: - """Show opening / closing / remaining time of the current candle.""" - is_demo = _is_demo(args) - if not await connect_with_retry(client, is_demo): - return - asset, _ = await client.get_available_asset(args.asset, force_open=True) - await client.start_candles_stream(asset, args.period) - await asyncio.sleep(1) # let stream warm up - info = await client.opening_closing_current_candle(asset, args.period) - if not info: - console.print("[red]Could not retrieve candle info.[/]") - return - opening = datetime.fromtimestamp(info.get("opening", 0)) - closing = datetime.fromtimestamp(info.get("closing", 0)) - console.print(Panel( - f"[bold cyan]Asset:[/] {asset}\n" - f"[bold cyan]Period:[/] {args.period}s\n" - f"[bold cyan]Opening:[/] {opening.strftime('%H:%M:%S')}\n" - f"[bold cyan]Closing:[/] {closing.strftime('%H:%M:%S')}\n" - f"[bold yellow]Remaining:[/] {info.get('remaining', '?')}s", - title="πŸ•―οΈ [bold]Current Candle Info[/]", - border_style="cyan", - box=box.ROUNDED, - expand=False, - )) - await client.stop_candles_stream(asset) - - -async def cmd_realtime_price(client: Quotex, args: argparse.Namespace) -> None: - """Stream live price data for an asset (Ctrl+C to stop).""" - is_demo = _is_demo(args) - if not await connect_with_retry(client, is_demo): - return - asset, _ = await client.get_available_asset(args.asset, force_open=True) - console.print( - f"[cyan]Streaming live price for[/] [bold]{asset}[/] " - f"[dim](Ctrl+C to stop)[/]" - ) - await client.start_realtime_price(asset, args.period) - try: - while True: - prices = await client.get_realtime_price(asset) - if prices: - latest = prices[-1] - console.print( - f" [dim]{datetime.now().strftime('%H:%M:%S')}[/] " - f"[bold green]{latest.get('price', latest)}[/]", - end="\r", - ) - await asyncio.sleep(0.5) - except KeyboardInterrupt: - console.print("\n[yellow]Stream stopped.[/]") - finally: - await client.stop_candles_stream(asset) - - -async def cmd_realtime_sentiment( - client: Quotex, args: argparse.Namespace -) -> None: - """Stream live trader-sentiment data (Ctrl+C to stop).""" - is_demo = _is_demo(args) - if not await connect_with_retry(client, is_demo): - return - asset, _ = await client.get_available_asset(args.asset, force_open=True) - console.print( - f"[cyan]Streaming sentiment for[/] [bold]{asset}[/] " - f"[dim](Ctrl+C to stop)[/]" - ) - await client.start_realtime_sentiment(asset, args.period) - try: - while True: - sentiment = await client.get_realtime_sentiment(asset) - if sentiment: - bulls = sentiment.get("call", sentiment.get("bulls", "?")) - bears = sentiment.get("put", sentiment.get("bears", "?")) - console.print( - f" [dim]{datetime.now().strftime('%H:%M:%S')}[/] " - f"[green]CALL {bulls}%[/] [red]PUT {bears}%[/]", - end="\r", - ) - await asyncio.sleep(1) - except KeyboardInterrupt: - console.print("\n[yellow]Stream stopped.[/]") - finally: - await client.stop_candles_stream(asset) - - -async def cmd_realtime_candle( - client: Quotex, args: argparse.Namespace -) -> None: - """Stream live processed candle ticks (Ctrl+C to stop).""" - is_demo = _is_demo(args) - if not await connect_with_retry(client, is_demo): - return - asset, _ = await client.get_available_asset(args.asset, force_open=True) - console.print( - f"[cyan]Streaming candle ticks for[/] [bold]{asset}[/] " - f"[dim](Ctrl+C to stop)[/]" - ) - try: - while True: - candle = await client.start_realtime_candle(asset, args.period) - if candle: - console.print( - f" [dim]{datetime.now().strftime('%H:%M:%S')}[/] " - f"{candle}", - end="\r", - ) - await asyncio.sleep(0.5) - except KeyboardInterrupt: - console.print("\n[yellow]Stream stopped.[/]") - finally: - await client.stop_candles_stream(asset) - - -# --------------------------------------------------------------------------- -# Command implementations β€” Trading -# --------------------------------------------------------------------------- - -async def cmd_buy(client: Quotex, args: argparse.Namespace) -> None: - """Place an immediate binary option trade.""" - is_demo = _is_demo(args) - if not await connect_with_retry(client, is_demo): - return - - asset, asset_info = await client.get_available_asset( - args.asset, force_open=True - ) - if not asset_info or not asset_info[0]: - console.print( - f"[bold red]βœ— Asset {args.asset} not found or closed.[/]" - ) - return - - console.print( - f"[cyan]Placing trade:[/] [bold]{args.direction.upper()}[/] " - f"[yellow]{asset}[/] | amount=[bold]{args.amount}[/] | " - f"duration=[bold]{args.duration}s[/]" - ) - - with Progress( - SpinnerColumn(), TextColumn("[cyan]Sending order…"), - transient=True, console=console - ) as prog: - prog.add_task("buy") - status, trade_data = await client.buy( - args.amount, asset, args.direction, args.duration - ) - - if status: - order_data = trade_data if isinstance(trade_data, dict) else {} - trade_id = order_data.get("id") - close_ts = order_data.get("closeTimestamp") - console.print( - f"[bold green]βœ“ Order placed![/] Trade ID: [bold]{trade_id}[/]" - ) - - if getattr(args, "check_win", False): - with Progress( - SpinnerColumn(), - TextColumn("[cyan]{task.description}"), - transient=True, - console=console, - ) as prog: - task_id = prog.add_task("Waiting for trade closure...") - check_task = asyncio.create_task( - client.check_win(trade_id, args.duration) - ) - while not check_task.done(): - server_now = ( - client.api.timesync.server_timestamp - if client.api else None - ) - remaining = ( - int(close_ts - server_now) - if close_ts and server_now else 0 - ) - label = ( - f"Waiting… [bold yellow]{remaining}s[/] remaining" - if remaining > 0 - else "Waiting… [bold yellow]finishing[/]" - ) - prog.update(task_id, description=label) - try: - await asyncio.wait_for( - asyncio.shield(check_task), timeout=1.0 - ) - except asyncio.TimeoutError: - pass - - win, profit = await check_task - color = "green" if win == "win" else "red" - label = "WIN πŸŽ‰" if win == "win" else "LOSS πŸ’Έ" - console.print( - f"[bold {color}]{label}[/] β€” Profit: [bold]{profit:+.2f}[/]" - ) - else: - console.print( - "[dim]Order dispatched. Pass --check-win to wait for result.[/]" - ) - else: - console.print(f"[bold red]βœ— Order failed.[/] Response: {trade_data}") - sys.exit(1) - - -async def cmd_sell(client: Quotex, args: argparse.Namespace) -> None: - """Sell / close an open position early.""" - is_demo = _is_demo(args) - if not await connect_with_retry(client, is_demo): - return - with Progress( - SpinnerColumn(), TextColumn("[cyan]Sending sell request…"), - transient=True, console=console - ) as prog: - prog.add_task("sell") - result = await client.sell_option(args.trade_id) - console.print(Panel( - f"[bold green]βœ“ Sell response received[/]\n{result}", - title="πŸ“€ [bold]Sell Option[/]", - border_style="green", - box=box.ROUNDED, - expand=False, - )) - - -async def cmd_pending(client: Quotex, args: argparse.Namespace) -> None: - """Place a pending order to be executed at a future time.""" - is_demo = _is_demo(args) - if not await connect_with_retry(client, is_demo): - return - - asset, asset_info = await client.get_available_asset( - args.asset, force_open=True - ) - if not asset_info or not asset_info[0]: - console.print( - f"[bold red]βœ— Asset {args.asset} not found or closed.[/]" - ) - return - - console.print( - f"[cyan]Placing pending order:[/] [bold]{args.direction.upper()}[/] " - f"[yellow]{asset}[/] | amount=[bold]{args.amount}[/] | " - f"duration=[bold]{args.duration}s[/]" - + (f" | open_time=[bold]{args.open_time}[/]" if args.open_time else "") - ) - - with Progress( - SpinnerColumn(), TextColumn("[cyan]Sending pending order…"), - transient=True, console=console - ) as prog: - prog.add_task("pending") - status, data = await client.open_pending( - args.amount, asset, args.direction, - args.duration, args.open_time - ) - - if status: - console.print( - f"[bold green]βœ“ Pending order placed![/]\n{data}" - ) - else: - console.print(f"[bold red]βœ— Pending order failed.[/] {data}") - sys.exit(1) - - -async def cmd_check(client: Quotex, args: argparse.Namespace) -> None: - """Check win/loss result of a trade by ID.""" - is_demo = _is_demo(args) - if not await connect_with_retry(client, is_demo): - return - - console.print( - f"[cyan]Checking result for Trade ID:[/] [bold]{args.trade_id}[/]" - ) - with Progress( - SpinnerColumn(), TextColumn("[cyan]{task.description}"), - transient=True, console=console - ) as prog: - task_id = prog.add_task("Waiting…") - check_task = asyncio.create_task( - client.check_win(args.trade_id, timeout=300) - ) - elapsed = 0 - while not check_task.done(): - prog.update( - task_id, - description=f"Waiting… [bold yellow]{elapsed}s[/] elapsed", - ) - try: - await asyncio.wait_for( - asyncio.shield(check_task), timeout=1.0 - ) - except asyncio.TimeoutError: - elapsed += 1 - - win, profit = await check_task - - color = "green" if win == "win" else "red" - label = "WIN πŸŽ‰" if win == "win" else "LOSS πŸ’Έ" - console.print( - f"[bold {color}]{label}[/] β€” Profit: [bold]{profit:+.2f}[/]" - ) - - -async def cmd_result(client: Quotex, args: argparse.Namespace) -> None: - """Look up a trade result from history by operation ID.""" - is_demo = _is_demo(args) - if not await connect_with_retry(client, is_demo): - return - status, data = await client.get_result(args.operation_id) - if status is None: - console.print(f"[red]Operation ID '{args.operation_id}' not found.[/]") - return - color = "green" if status == "win" else "red" - console.print(Panel( - f"[bold {color}]Result: {status.upper()}[/]\n{data}", - title=f"πŸ“‹ [bold]Trade Result β€” {args.operation_id}[/]", - border_style=color, - box=box.ROUNDED, - )) - - -async def cmd_signals(client: Quotex, args: argparse.Namespace) -> None: - """Fetch current signal data from the signals stream.""" - if not await connect_with_retry(client, True): - return - await client.start_signals_data() - await asyncio.sleep(2) # allow signals to arrive - data = client.get_signal_data() - if not data: - console.print("[yellow]No signal data available yet.[/]") - return - table = Table( - title="πŸ“‘ [bold]Signal Data[/]", - box=box.ROUNDED, - border_style="yellow", - show_header=True, - header_style="bold yellow", - ) - table.add_column("Key", style="cyan") - table.add_column("Value", style="white") - for k, v in data.items(): - table.add_row(str(k), str(v)) - console.print(table) - - -# --------------------------------------------------------------------------- -# Command implementations β€” History -# --------------------------------------------------------------------------- - -async def cmd_history(client: Quotex, args: argparse.Namespace) -> None: - """Show recent trade history (paged).""" - is_demo = _is_demo(args) - if not await connect_with_retry(client, is_demo): - return - all_trades: list[dict] = [] - account_type = 1 if is_demo else 0 - for page in range(1, args.pages + 1): - page_data = await client.get_trader_history(account_type, page) - if isinstance(page_data, dict): - trades = page_data.get("data", []) - elif isinstance(page_data, list): - trades = page_data - else: - trades = [] - all_trades.extend(trades) - - if not all_trades: - console.print("[yellow]No trade history found.[/]") - return - - table = Table( - title=f"πŸ“œ [bold]Trade History[/] ({'Demo' if is_demo else 'Live'})", - box=box.ROUNDED, - border_style="bright_blue", - show_header=True, - header_style="bold bright_white on blue", - row_styles=["none", "dim"], - ) - table.add_column("ID", style="dim", no_wrap=True) - table.add_column("Asset", style="cyan") - table.add_column("Direction", justify="center") - table.add_column("Amount", justify="right") - table.add_column("Profit", justify="right") - table.add_column("Result", justify="center") - table.add_column("Time", style="dim") - - for t in all_trades: - profit = float(t.get("profitAmount", 0)) - result_str = ( - "[green]WIN[/]" if profit > 0 - else "[red]LOSS[/]" if profit < 0 - else "[dim]DRAW[/]" - ) - direction = t.get("command", t.get("direction", "?")).upper() - dir_color = "green" if direction in ("CALL", "BUY", "UP") else "red" - ts = t.get("openTimestamp", t.get("createdAt", "")) - try: - ts_str = datetime.fromtimestamp(int(ts)).strftime( - "%m-%d %H:%M" - ) if ts else "β€”" - except Exception: - ts_str = str(ts) - table.add_row( - str(t.get("ticket", t.get("id", "β€”")))[:12], - str(t.get("asset", "?")), - f"[{dir_color}]{direction}[/{dir_color}]", - f"{float(t.get('amount', 0)):,.2f}", - f"{profit:+,.2f}", - result_str, - ts_str, - ) - console.print(table) - - -# --------------------------------------------------------------------------- -# Command implementations β€” Indicators -# --------------------------------------------------------------------------- - -async def cmd_indicator(client: Quotex, args: argparse.Namespace) -> None: - """Calculate a technical indicator and display the result.""" - is_demo = _is_demo(args) - if not await connect_with_retry(client, is_demo): - return - asset, _ = await client.get_available_asset(args.asset, force_open=True) - console.print( - f"[cyan]Calculating[/] [bold]{args.name}[/] for " - f"[yellow]{asset}[/] (period={args.period}, tf={args.timeframe}s)" - ) - with Progress( - SpinnerColumn(), TextColumn("[cyan]Fetching history + computing…"), - transient=True, console=console - ) as prog: - prog.add_task("indicator") - result = await client.calculate_indicator( - asset, - args.name, - params={"period": args.period}, - timeframe=args.timeframe, - ) - if not result: - console.print("[red]No indicator data returned.[/]") - return - table = Table( - title=f"πŸ“ [bold]{args.name} β€” {asset}[/]", - box=box.ROUNDED, - border_style="magenta", - show_header=True, - header_style="bold magenta", - ) - table.add_column("Key", style="cyan") - table.add_column("Value", style="bold yellow") - if isinstance(result, dict): - for k, v in result.items(): - table.add_row(str(k), f"{v:.6f}" if isinstance(v, float) else str(v)) - else: - table.add_row("result", str(result)) - console.print(table) - - -# --------------------------------------------------------------------------- -# Command implementations β€” Monitor & Strategy -# --------------------------------------------------------------------------- - -async def cmd_monitor(client: Quotex, args: argparse.Namespace) -> None: - """Real-time price monitor for an asset (Ctrl+C to stop).""" - if not await connect_with_retry(client, True): - return - asset, _ = await client.get_available_asset(args.asset, force_open=True) - console.print( - f"[cyan]Monitoring[/] [bold]{asset}[/] " - f"[dim](period={args.period}s β€” Ctrl+C to stop)[/]" - ) - await client.start_candles_stream(asset, args.period) - prev_price = None - try: - while True: - prices = await client.get_realtime_price(asset) - if prices: - latest = prices[-1] - price = latest.get("price", latest) - change = "" - if prev_price is not None: - delta = float(price) - float(prev_price) - change = ( - f" [green]+{delta:.5f}[/]" if delta > 0 - else f" [red]{delta:.5f}[/]" if delta < 0 - else " [dim]β€”[/]" - ) - console.print( - f" [dim]{datetime.now().strftime('%H:%M:%S')}[/] " - f"[bold]{price}[/]{change} ", - end="\r", - ) - prev_price = price - await asyncio.sleep(0.5) - except KeyboardInterrupt: - console.print("\n[yellow]Monitor stopped.[/]") - finally: - await client.stop_candles_stream(asset) - - -async def cmd_strategy(client: Quotex, args: argparse.Namespace) -> None: - """Run a Triple-Confirmation strategy.""" - if not await connect_with_retry(client, True): - return - strategy = TripleConfirmationStrategy( - client=client, - asset=args.asset, - period=args.period, - ) - console.print(Panel( - f"[bold cyan]Asset:[/] {args.asset}\n" - f"[bold cyan]Period:[/] {args.period}s\n" - f"[bold cyan]Auto-trade:[/] {'YES ⚠ DEMO ONLY' if args.auto_trade else 'NO (signal only)'}", - title="🧠 [bold]Triple Confirmation Strategy[/]", - border_style="magenta", - box=box.ROUNDED, - expand=False, - )) - await strategy.run(auto_trade=args.auto_trade) - - -# --------------------------------------------------------------------------- -# Utility helpers -# --------------------------------------------------------------------------- - -def _print_candles_table( - candles: list[dict], - asset: str, - period: int, - title: str | None = None, -) -> None: - """Render a Rich table of candle data.""" - tbl_title = title or f"πŸ•―οΈ [bold]Candles β€” {asset} ({period}s)[/]" - table = Table( - title=tbl_title, - box=box.ROUNDED, - border_style="bright_blue", - show_header=True, - header_style="bold bright_white on blue", - row_styles=["none", "dim"], - ) - table.add_column("Time", style="dim", no_wrap=True) - table.add_column("Open", justify="right") - table.add_column("High", justify="right", style="green") - table.add_column("Low", justify="right", style="red") - table.add_column("Close", justify="right", style="bold") - table.add_column("Dir", justify="center") - - for c in candles: - ts = c.get("time", c.get("timestamp", 0)) - try: - ts_str = datetime.fromtimestamp(int(ts)).strftime("%m-%d %H:%M:%S") - except Exception: - ts_str = str(ts) - o = c.get("open", 0) - h = c.get("max", c.get("high", 0)) - lo = c.get("min", c.get("low", 0)) - cl = c.get("close", 0) - direction = ( - "[green]β–²[/]" if float(cl) >= float(o) - else "[red]β–Ό[/]" - ) - table.add_row( - ts_str, - f"{float(o):.5f}", - f"{float(h):.5f}", - f"{float(lo):.5f}", - f"{float(cl):.5f}", - direction, - ) - console.print(table) - - -def _save_candles_csv(candles: list[dict], filepath: str) -> None: - """Save the candles list to a CSV file.""" - if not candles: - return - fieldnames = list(candles[0].keys()) - with open(filepath, "w", newline="") as f: - writer = csv.DictWriter(f, fieldnames=fieldnames) - writer.writeheader() - writer.writerows(candles) - - -# --------------------------------------------------------------------------- -# test-all runner -# --------------------------------------------------------------------------- - -async def cmd_test_all(client: Quotex, args: argparse.Namespace) -> None: - """Run a quick smoke-test of every major API method.""" - console.rule("[bold cyan]PyQuotex β€” test-all[/]") - passed = 0 - failed = 0 - - async def _test(name: str, coro: Any) -> None: - nonlocal passed, failed - try: - result = await coro - console.print(f" [green]βœ“[/] {name}: {str(result)[:80]}") - passed += 1 - except Exception as e: - console.print(f" [red]βœ—[/] {name}: {e}") - failed += 1 - - if not await connect_with_retry(client, True): - return - - await client.get_all_assets() - - await _test("get_profile", client.get_profile()) - await _test("get_balance", client.get_balance()) - await _test("get_server_time", client.get_server_time()) - await _test("get_all_asset_name", asyncio.coroutine( - lambda: client.get_all_asset_name() - )()) - await _test("get_payment (sync)", asyncio.coroutine( - lambda: client.get_payment() - )()) - await _test("get_payout_by_asset EURUSD", - asyncio.coroutine( - lambda: client.get_payout_by_asset("EURUSD") - )()) - await _test("get_candles EURUSD 60s", - client.get_candles("EURUSD", time.time(), 3600, 60)) - await _test("get_candle_v2 EURUSD", - client.get_candle_v2("EURUSD", 60)) - await _test("get_historical_candles EURUSD 1h", - client.get_historical_candles( - "EURUSD", amount_of_seconds=3600, period=60, max_workers=2 - )) - await _test("get_realtime_price EURUSD", - client.start_realtime_price("EURUSD", 60)) - await _test("get_realtime_sentiment EURUSD", - client.start_realtime_sentiment("EURUSD", 60)) - await _test("get_trader_history demo p1", - client.get_trader_history(1, 1)) - await _test("calculate_indicator RSI", - client.calculate_indicator( - "EURUSD", "RSI", {"period": 14}, timeframe=60 - )) - - console.rule() - color = "green" if failed == 0 else "yellow" - console.print( - f"[bold {color}]Results: {passed} passed, {failed} failed[/]" - ) - - -# --------------------------------------------------------------------------- -# Main dispatcher -# --------------------------------------------------------------------------- - -COMMAND_MAP: dict[str, Any] = { - "login": cmd_login, - "balance": cmd_balance, - "server-time": cmd_server_time, - "set-demo-balance": cmd_set_demo_balance, - "settings": cmd_settings, - "assets": cmd_assets, - "payout": cmd_payout, - "payout-asset": cmd_payout_asset, - "candles": cmd_candles, - "candles-v2": cmd_candles_v2, - "candles-deep": cmd_candles_deep, - "history-line": cmd_history_line, - "candle-info": cmd_candle_info, - "realtime-price": cmd_realtime_price, - "realtime-sentiment": cmd_realtime_sentiment, - "realtime-candle": cmd_realtime_candle, - "buy": cmd_buy, - "sell": cmd_sell, - "pending": cmd_pending, - "check": cmd_check, - "result": cmd_result, - "signals": cmd_signals, - "history": cmd_history, - "indicator": cmd_indicator, - "monitor": cmd_monitor, - "strategy": cmd_strategy, - "test-all": cmd_test_all, -} - - -async def main() -> None: - parser = make_parser() - args = parser.parse_args() - - if not args.command: - parser.print_help() - return - - email, password = credentials() - client = Quotex( - email=email, - password=password, - on_otp_callback=on_otp, - ) - - handler = COMMAND_MAP.get(args.command) - if handler is None: - console.print(f"[red]Unknown command: {args.command}[/]") - parser.print_help() - return - - try: - await handler(client, args) - finally: - await client.close() - +from pyquotex.cli.__main__ import cli_main if __name__ == "__main__": - asyncio.run(main()) + cli_main() diff --git a/docs/superpowers/plans/2026-05-11-architecture-maintainability.md b/docs/superpowers/plans/2026-05-11-architecture-maintainability.md new file mode 100644 index 00000000..9e5ab435 --- /dev/null +++ b/docs/superpowers/plans/2026-05-11-architecture-maintainability.md @@ -0,0 +1,2316 @@ +# Architecture & Maintainability Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Split `stable_api.py` (1573 lines) into domain mixins, modularize `app.py` (1435-line CLI) into per-topic files, and replace `asyncio.sleep`-based polling with event-driven waits β€” while preserving 100% public API backwards compatibility. + +**Architecture:** Mixin-based facade in `pyquotex/stable_api.py` composing five domain mixins from a new private `pyquotex/_api/` package. CLI moves to `pyquotex/cli/` with command modules registered in a dict; `app.py` becomes a 5-line shim. Polling loops are replaced by a typed `WaitableSlot` helper whose `.set()` is invoked from the WS message handler in `pyquotex/api.py`. + +**Tech Stack:** Python 3.12+, `asyncio`, `argparse`, `pytest`, `pytest-asyncio`, no new runtime dependencies. + +**Spec:** [docs/superpowers/specs/2026-05-11-architecture-maintainability-design.md](../specs/2026-05-11-architecture-maintainability-design.md) + +**Branch:** All work happens on `refactor/architecture`. Merge to `master` only after Phase 5. + +--- + +## File Structure + +### Files Created + +| Path | Responsibility | +|---|---| +| `pyquotex/exceptions.py` | Custom exception types (`QuotexTimeoutError`) | +| `pyquotex/_api/__init__.py` | Marker for private domain package | +| `pyquotex/_api/_waits.py` | `WaitableSlot[T]`, `wait_until`, `SlotRegistry` | +| `pyquotex/_api/account.py` | `AccountMixin` β€” balance, profile, connection, account mode | +| `pyquotex/_api/trading.py` | `TradingMixin` β€” buy, sell, pending, check_win, results | +| `pyquotex/_api/history.py` | `HistoryMixin` β€” candles (sync and historical), trade history | +| `pyquotex/_api/realtime.py` | `RealtimeMixin` β€” streams, sentiment, indicators | +| `pyquotex/_api/assets.py` | `AssetsMixin` β€” instruments, payouts, asset availability | +| `pyquotex/cli/__init__.py` | Marker | +| `pyquotex/cli/__main__.py` | Entry point with `asyncio.run(main())` | +| `pyquotex/cli/parser.py` | `make_parser()` and all subparser definitions | +| `pyquotex/cli/runtime.py` | `connect_with_retry`, `on_otp`, `_is_demo` | +| `pyquotex/cli/formatters.py` | `_balance_table`, `_print_candles_table`, `_save_candles_csv` | +| `pyquotex/cli/commands/__init__.py` | `COMMAND_REGISTRY` dict | +| `pyquotex/cli/commands/account.py` | login, balance, server-time, set-demo-balance, settings | +| `pyquotex/cli/commands/market.py` | assets, payout, payout-asset | +| `pyquotex/cli/commands/candles.py` | candles, candles-v2, candles-deep, history-line, candle-info | +| `pyquotex/cli/commands/realtime.py` | realtime-price, realtime-sentiment, realtime-candle | +| `pyquotex/cli/commands/trading.py` | buy, sell, pending, check, result | +| `pyquotex/cli/commands/analysis.py` | signals, history, indicator, monitor, strategy | +| `pyquotex/cli/commands/diagnostics.py` | test-all | +| `scripts/snapshot_api_surface.py` | One-shot generator for `tests/fixtures/api_surface.json` | +| `tests/fixtures/api_surface.json` | Baseline snapshot of `Quotex` public surface | +| `tests/test_api_surface.py` | Regression test against the snapshot | +| `tests/test_import_compat.py` | Verifies legacy imports still resolve | +| `tests/test_cli_smoke.py` | `--help` smoke tests for CLI entrypoints | +| `tests/test_waits.py` | Unit tests for `WaitableSlot` and `wait_until` | + +### Files Modified + +| Path | Change | +|---|---| +| `pyquotex/stable_api.py` | Shrinks from 1573 β†’ ~200 lines (facade only) | +| `pyquotex/api.py` | Add `SlotRegistry` attribute and wire `.set()` calls in `_on_message` | +| `app.py` | Shrinks from 1435 β†’ 5 lines (shim to `pyquotex.cli.__main__:main`) | +| `pyproject.toml` | Bump version 1.0.3 β†’ 1.1.0 | + +--- + +## Phase 0 β€” Safety Net + +### Task 0.1: Create refactor branch + +**Files:** repo-wide + +- [ ] **Step 1: Create and switch to the refactor branch** + +```bash +git checkout -b refactor/architecture +git status +``` + +Expected: `On branch refactor/architecture` with clean working tree. + +--- + +### Task 0.2: Generate public API surface snapshot + +**Files:** +- Create: `scripts/snapshot_api_surface.py` +- Create: `tests/fixtures/api_surface.json` + +- [ ] **Step 1: Create the snapshot script** + +Create `scripts/snapshot_api_surface.py`: + +```python +"""Generate a baseline snapshot of Quotex's public API surface. + +Run once before the refactor and commit tests/fixtures/api_surface.json. +The regression test in tests/test_api_surface.py compares the live class +against this snapshot. +""" +import inspect +import json +from pathlib import Path + +from pyquotex.stable_api import Quotex + +OUTPUT = Path(__file__).parent.parent / "tests" / "fixtures" / "api_surface.json" + + +def _serialize_signature(sig: inspect.Signature) -> dict: + params = [] + for name, param in sig.parameters.items(): + params.append( + { + "name": name, + "kind": str(param.kind), + "default": ( + "" + if param.default is inspect.Parameter.empty + else repr(param.default) + ), + "annotation": ( + "" + if param.annotation is inspect.Parameter.empty + else str(param.annotation) + ), + } + ) + return { + "parameters": params, + "return_annotation": ( + "" + if sig.return_annotation is inspect.Signature.empty + else str(sig.return_annotation) + ), + } + + +def main() -> None: + surface: dict[str, dict] = {} + for name in sorted(dir(Quotex)): + if name.startswith("_"): + continue + attr = getattr(Quotex, name) + if callable(attr): + try: + sig = inspect.signature(attr) + except (TypeError, ValueError): + surface[name] = {"kind": "callable", "signature": None} + continue + surface[name] = { + "kind": "method", + "signature": _serialize_signature(sig), + } + else: + surface[name] = {"kind": "attribute", "type": type(attr).__name__} + + OUTPUT.parent.mkdir(parents=True, exist_ok=True) + OUTPUT.write_text(json.dumps(surface, indent=2, sort_keys=True) + "\n") + print(f"Wrote {len(surface)} public symbols to {OUTPUT}") + + +if __name__ == "__main__": + main() +``` + +- [ ] **Step 2: Run the script to generate the fixture** + +```bash +python scripts/snapshot_api_surface.py +``` + +Expected: `Wrote N public symbols to tests/fixtures/api_surface.json` (where N is around 50+). + +- [ ] **Step 3: Verify fixture looks reasonable** + +```bash +python -c "import json; d = json.load(open('tests/fixtures/api_surface.json')); print(len(d), sorted(d)[:10])" +``` + +Expected: A list of public method names like `['buy', 'calculate_indicator', 'change_account', 'change_time_offset', 'check_asset_open', ...]`. + +- [ ] **Step 4: Commit** + +```bash +git add scripts/snapshot_api_surface.py tests/fixtures/api_surface.json +git commit -m "test: snapshot baseline of Quotex public API surface" +``` + +--- + +### Task 0.3: Add surface regression test + +**Files:** +- Create: `tests/test_api_surface.py` + +- [ ] **Step 1: Write the test** + +Create `tests/test_api_surface.py`: + +```python +"""Regression test: Quotex's public surface must not shrink during refactors.""" +import inspect +import json +from pathlib import Path + +from pyquotex.stable_api import Quotex + +FIXTURE = Path(__file__).parent / "fixtures" / "api_surface.json" + + +def _current_surface() -> dict[str, dict]: + surface: dict[str, dict] = {} + for name in sorted(dir(Quotex)): + if name.startswith("_"): + continue + attr = getattr(Quotex, name) + if callable(attr): + try: + sig = inspect.signature(attr) + except (TypeError, ValueError): + surface[name] = {"kind": "callable"} + continue + params = [p.name for p in sig.parameters.values()] + surface[name] = {"kind": "method", "params": params} + else: + surface[name] = {"kind": "attribute"} + return surface + + +def test_public_methods_present(): + """Every public method in the snapshot must still exist on Quotex.""" + baseline = json.loads(FIXTURE.read_text()) + current = _current_surface() + missing = sorted(set(baseline) - set(current)) + assert not missing, f"Public symbols removed: {missing}" + + +def test_public_method_params_unchanged(): + """Parameter names of public methods must not change (order matters).""" + baseline = json.loads(FIXTURE.read_text()) + current = _current_surface() + diffs: list[str] = [] + for name, baseline_entry in baseline.items(): + if baseline_entry.get("kind") != "method": + continue + if name not in current: + continue # caught by previous test + baseline_params = [ + p["name"] for p in baseline_entry["signature"]["parameters"] + ] + current_params = current[name].get("params", []) + if baseline_params != current_params: + diffs.append( + f"{name}: baseline={baseline_params} current={current_params}" + ) + assert not diffs, "Parameter signatures changed:\n" + "\n".join(diffs) +``` + +- [ ] **Step 2: Run the test** + +```bash +pytest tests/test_api_surface.py -v +``` + +Expected: 2 passed. + +- [ ] **Step 3: Commit** + +```bash +git add tests/test_api_surface.py +git commit -m "test: add public API surface regression tests" +``` + +--- + +### Task 0.4: Add import compatibility test + +**Files:** +- Create: `tests/test_import_compat.py` + +- [ ] **Step 1: Write the test** + +Create `tests/test_import_compat.py`: + +```python +"""Verify legacy import paths continue to resolve.""" + +def test_stable_api_quotex_importable(): + from pyquotex.stable_api import Quotex + assert Quotex is not None + assert hasattr(Quotex, "buy") + assert hasattr(Quotex, "get_balance") + assert hasattr(Quotex, "connect") + + +def test_quotex_api_importable(): + from pyquotex.api import QuotexAPI + assert QuotexAPI is not None + + +def test_account_type_importable(): + from pyquotex.utils.account_type import AccountType + assert AccountType.DEMO is not None + assert AccountType.REAL is not None + + +def test_indicators_importable(): + from pyquotex.utils.indicators import TechnicalIndicators + assert TechnicalIndicators is not None +``` + +- [ ] **Step 2: Run the test** + +```bash +pytest tests/test_import_compat.py -v +``` + +Expected: 4 passed. + +- [ ] **Step 3: Commit** + +```bash +git add tests/test_import_compat.py +git commit -m "test: verify legacy imports remain available" +``` + +--- + +### Task 0.5: Add CLI smoke test + +**Files:** +- Create: `tests/test_cli_smoke.py` + +- [ ] **Step 1: Write the test** + +Create `tests/test_cli_smoke.py`: + +```python +"""Smoke tests: CLI entrypoints respond to --help.""" +import subprocess +import sys + + +def test_app_py_help_runs(): + """`python app.py --help` must exit 0 and list commands.""" + result = subprocess.run( + [sys.executable, "app.py", "--help"], + capture_output=True, + text=True, + timeout=15, + ) + assert result.returncode == 0, f"stderr: {result.stderr}" + assert "balance" in result.stdout + assert "buy" in result.stdout + + +def test_module_invocation_help_runs(): + """`python -m pyquotex --help` must exit 0 and list commands.""" + result = subprocess.run( + [sys.executable, "-m", "pyquotex", "--help"], + capture_output=True, + text=True, + timeout=15, + ) + assert result.returncode == 0, f"stderr: {result.stderr}" + assert "balance" in result.stdout +``` + +- [ ] **Step 2: Run the test** + +```bash +pytest tests/test_cli_smoke.py -v +``` + +Expected: 2 passed. If `python -m pyquotex` does not exist yet, the second test will fail β€” adjust by reading `pyquotex/__main__.py` to confirm; if it exists, the test should pass. + +- [ ] **Step 3: Inspect __main__ if needed** + +If `test_module_invocation_help_runs` fails, run: + +```bash +cat pyquotex/__main__.py +``` + +Confirm it routes to `app.py`'s parser. If it does not list commands, mark this test as expected-fail with a comment until Phase 4 wires it up: + +```python +import pytest +@pytest.mark.skip(reason="python -m pyquotex wired up in Phase 4") +def test_module_invocation_help_runs(): + ... +``` + +- [ ] **Step 4: Re-run** + +```bash +pytest tests/test_cli_smoke.py -v +``` + +Expected: all pass (with one skip if applicable). + +- [ ] **Step 5: Commit** + +```bash +git add tests/test_cli_smoke.py +git commit -m "test: add CLI --help smoke tests" +``` + +--- + +## Phase 1 β€” Wait Helpers and Exceptions + +### Task 1.1: Add QuotexTimeoutError + +**Files:** +- Create: `pyquotex/exceptions.py` + +- [ ] **Step 1: Write the failing test** + +Append to `tests/test_import_compat.py`: + +```python +def test_exceptions_importable(): + from pyquotex.exceptions import QuotexTimeoutError + assert issubclass(QuotexTimeoutError, Exception) +``` + +- [ ] **Step 2: Run the test to verify it fails** + +```bash +pytest tests/test_import_compat.py::test_exceptions_importable -v +``` + +Expected: FAIL with `ModuleNotFoundError: No module named 'pyquotex.exceptions'`. + +- [ ] **Step 3: Create the module** + +Create `pyquotex/exceptions.py`: + +```python +"""Custom exception types raised by pyquotex public APIs.""" + + +class QuotexTimeoutError(Exception): + """Raised when a Quotex operation exceeds its allotted timeout. + + Wraps asyncio.TimeoutError so callers do not need to import asyncio. + """ +``` + +- [ ] **Step 4: Run the test to verify it passes** + +```bash +pytest tests/test_import_compat.py::test_exceptions_importable -v +``` + +Expected: 1 passed. + +- [ ] **Step 5: Commit** + +```bash +git add pyquotex/exceptions.py tests/test_import_compat.py +git commit -m "feat(exceptions): add QuotexTimeoutError" +``` + +--- + +### Task 1.2: Scaffold private `_api` package + +**Files:** +- Create: `pyquotex/_api/__init__.py` + +- [ ] **Step 1: Create the package marker** + +Create `pyquotex/_api/__init__.py`: + +```python +"""Private domain submodules for pyquotex. + +Not part of the public API. Re-exports are routed through pyquotex.stable_api. +""" +``` + +- [ ] **Step 2: Verify package imports** + +```bash +python -c "import pyquotex._api; print('ok')" +``` + +Expected: `ok`. + +- [ ] **Step 3: Commit** + +```bash +git add pyquotex/_api/__init__.py +git commit -m "chore: scaffold pyquotex._api private package" +``` + +--- + +### Task 1.3: Implement WaitableSlot + +**Files:** +- Create: `pyquotex/_api/_waits.py` +- Create: `tests/test_waits.py` + +- [ ] **Step 1: Write the failing tests** + +Create `tests/test_waits.py`: + +```python +"""Unit tests for WaitableSlot and wait_until.""" +import asyncio +import pytest + +from pyquotex._api._waits import WaitableSlot, wait_until + + +@pytest.mark.asyncio +async def test_slot_resolves_with_set_value(): + slot: WaitableSlot[int] = WaitableSlot() + + async def setter(): + await asyncio.sleep(0.01) + slot.set(42) + + asyncio.create_task(setter()) + assert await slot.wait(timeout=1.0) == 42 + + +@pytest.mark.asyncio +async def test_slot_times_out_when_never_set(): + slot: WaitableSlot[int] = WaitableSlot() + with pytest.raises(asyncio.TimeoutError): + await slot.wait(timeout=0.05) + + +@pytest.mark.asyncio +async def test_slot_can_be_cleared_and_reused(): + slot: WaitableSlot[str] = WaitableSlot() + slot.set("first") + assert await slot.wait(timeout=0.1) == "first" + slot.clear() + with pytest.raises(asyncio.TimeoutError): + await slot.wait(timeout=0.05) + slot.set("second") + assert await slot.wait(timeout=0.1) == "second" + + +@pytest.mark.asyncio +async def test_slot_set_before_wait_resolves_immediately(): + slot: WaitableSlot[int] = WaitableSlot() + slot.set(7) + assert await slot.wait(timeout=0.1) == 7 + + +@pytest.mark.asyncio +async def test_wait_until_resolves_when_predicate_true(): + counter = {"n": 0} + + async def increment(): + await asyncio.sleep(0.01) + counter["n"] = 5 + + asyncio.create_task(increment()) + await wait_until(lambda: counter["n"] >= 5, timeout=1.0) + assert counter["n"] == 5 + + +@pytest.mark.asyncio +async def test_wait_until_times_out(): + with pytest.raises(asyncio.TimeoutError): + await wait_until(lambda: False, timeout=0.05) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +pytest tests/test_waits.py -v +``` + +Expected: All fail with `ModuleNotFoundError: No module named 'pyquotex._api._waits'`. + +- [ ] **Step 3: Implement the helpers** + +Create `pyquotex/_api/_waits.py`: + +```python +"""Event-driven wait primitives that replace asyncio.sleep polling. + +A WaitableSlot is a typed one-shot (re-armable) signal: a consumer awaits +.wait(), and the producer (typically the WS message handler) calls .set(value). + +wait_until() exists for cases where the desired state cannot be signaled +from the WS handler. It still uses short polling internally but enforces +a hard timeout. +""" +from __future__ import annotations + +import asyncio +from typing import Callable, Generic, TypeVar + +T = TypeVar("T") + +DEFAULT_TIMEOUT: float = 10.0 + + +class WaitableSlot(Generic[T]): + """Typed slot a consumer awaits and the producer fills via .set().""" + + __slots__ = ("_value", "_event") + + def __init__(self) -> None: + self._value: T | None = None + self._event = asyncio.Event() + + def set(self, value: T) -> None: + """Store the value and wake any awaiting consumers.""" + self._value = value + self._event.set() + + def clear(self) -> None: + """Reset the slot so subsequent waits block again.""" + self._value = None + self._event.clear() + + def is_set(self) -> bool: + return self._event.is_set() + + async def wait(self, timeout: float = DEFAULT_TIMEOUT) -> T: + """Block until set or raise asyncio.TimeoutError on timeout.""" + await asyncio.wait_for(self._event.wait(), timeout=timeout) + return self._value # type: ignore[return-value] + + +async def wait_until( + predicate: Callable[[], bool], + *, + timeout: float = DEFAULT_TIMEOUT, + poll_interval: float = 0.05, +) -> None: + """Poll predicate() until truthy or raise asyncio.TimeoutError.""" + async def _loop() -> None: + while not predicate(): + await asyncio.sleep(poll_interval) + + await asyncio.wait_for(_loop(), timeout=timeout) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +pytest tests/test_waits.py -v +``` + +Expected: 6 passed. + +- [ ] **Step 5: Commit** + +```bash +git add pyquotex/_api/_waits.py tests/test_waits.py +git commit -m "feat(_api): add WaitableSlot and wait_until helpers" +``` + +--- + +### Task 1.4: Add SlotRegistry + +**Files:** +- Modify: `pyquotex/_api/_waits.py` +- Modify: `tests/test_waits.py` + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/test_waits.py`: + +```python +from pyquotex._api._waits import SlotRegistry + + +def test_slot_registry_has_named_slots(): + reg = SlotRegistry() + assert reg.balance is not None + assert reg.balance_update is not None + assert reg.candle_v2_ready is not None + assert reg.historical_ready is not None + assert reg.pending_confirm is not None + assert reg.sold_option_confirm is not None + assert reg.training_balance_edit is not None + assert reg.auth_status is not None + + +def test_slot_registry_keyed_slots_create_on_access(): + reg = SlotRegistry() + slot_a = reg.order_confirm("req-1") + slot_b = reg.order_confirm("req-1") + slot_c = reg.order_confirm("req-2") + assert slot_a is slot_b # same key returns same slot + assert slot_a is not slot_c # different key returns different slot + + +def test_slot_registry_keyed_slot_release(): + reg = SlotRegistry() + slot = reg.order_confirm("req-1") + slot.set({"id": 1}) + reg.release_order_confirm("req-1") + new_slot = reg.order_confirm("req-1") + assert new_slot is not slot +``` + +- [ ] **Step 2: Run to verify failure** + +```bash +pytest tests/test_waits.py -k "SlotRegistry or slot_registry" -v +``` + +Expected: All fail with `ImportError`. + +- [ ] **Step 3: Implement SlotRegistry** + +Append to `pyquotex/_api/_waits.py`: + +```python +class SlotRegistry: + """Container of named and keyed WaitableSlots used by QuotexAPI. + + Named slots are pre-created for one-off events (balance update, auth + status change, etc.). Keyed slots are dynamic per-request waits keyed + by request_id / operation_id; they are created lazily and released + once the consumer has read the value. + """ + + def __init__(self) -> None: + # Named slots + self.balance: WaitableSlot[dict] = WaitableSlot() + self.balance_update: WaitableSlot[dict] = WaitableSlot() + self.candle_v2_ready: WaitableSlot[str] = WaitableSlot() + self.historical_ready: WaitableSlot[str] = WaitableSlot() + self.pending_confirm: WaitableSlot[dict] = WaitableSlot() + self.sold_option_confirm: WaitableSlot[dict] = WaitableSlot() + self.training_balance_edit: WaitableSlot[dict] = WaitableSlot() + self.auth_status: WaitableSlot[bool] = WaitableSlot() + + # Keyed slots (created on demand) + self._order_confirm: dict[str, WaitableSlot[dict]] = {} + self._win_result: dict[str, WaitableSlot[dict]] = {} + + def order_confirm(self, request_id: str) -> WaitableSlot[dict]: + slot = self._order_confirm.get(request_id) + if slot is None: + slot = WaitableSlot() + self._order_confirm[request_id] = slot + return slot + + def release_order_confirm(self, request_id: str) -> None: + self._order_confirm.pop(request_id, None) + + def win_result(self, operation_id: str) -> WaitableSlot[dict]: + slot = self._win_result.get(operation_id) + if slot is None: + slot = WaitableSlot() + self._win_result[operation_id] = slot + return slot + + def release_win_result(self, operation_id: str) -> None: + self._win_result.pop(operation_id, None) +``` + +- [ ] **Step 4: Run all tests** + +```bash +pytest tests/test_waits.py -v +``` + +Expected: 9 passed. + +- [ ] **Step 5: Commit** + +```bash +git add pyquotex/_api/_waits.py tests/test_waits.py +git commit -m "feat(_api): add SlotRegistry for named and keyed waitable slots" +``` + +--- + +## Phase 2 β€” Polling β†’ Events + +### Task 2.1: Attach SlotRegistry to QuotexAPI + +**Files:** +- Modify: `pyquotex/api.py:59-123` (the `__init__` method) + +- [ ] **Step 1: Read the current init** + +```bash +grep -n "self.event_registry" pyquotex/api.py +``` + +Locate the line `self.event_registry = EventRegistry()` (around line 121). + +- [ ] **Step 2: Add SlotRegistry next to event_registry** + +In `pyquotex/api.py`, find the line: + +```python + self.event_registry = EventRegistry() +``` + +Replace with: + +```python + self.event_registry = EventRegistry() + from pyquotex._api._waits import SlotRegistry + self.slots = SlotRegistry() +``` + +(Inline import avoids a circular import risk; `_waits.py` does not import from `pyquotex.api`.) + +- [ ] **Step 3: Add a test confirming the registry exists on a QuotexAPI instance** + +Append to `tests/test_waits.py`: + +```python +def test_quotex_api_has_slot_registry(): + """QuotexAPI must expose a SlotRegistry as .slots.""" + from pyquotex.api import QuotexAPI + + api = QuotexAPI( + host="qxbroker.com", + username="x", + password="x", + lang="en", + resource_path=".", + user_data_dir="browser", + proxies=None, + on_otp_callback=None, + ) + assert isinstance(api.slots, SlotRegistry) + assert api.slots.balance is not None +``` + +- [ ] **Step 4: Run the test** + +```bash +pytest tests/test_waits.py::test_quotex_api_has_slot_registry -v +``` + +Expected: pass. If it fails because of constructor signature drift, adjust the kwargs by reading `pyquotex/api.py` lines around 30-58. + +- [ ] **Step 5: Commit** + +```bash +git add pyquotex/api.py tests/test_waits.py +git commit -m "feat(api): attach SlotRegistry to QuotexAPI" +``` + +--- + +### Task 2.2: Fire balance slot from WS handler + +**Files:** +- Modify: `pyquotex/api.py` (lines around 250 and 383) + +- [ ] **Step 1: Locate balance assignments** + +```bash +grep -n "self.account_balance = " pyquotex/api.py +``` + +Expected output: two lines (around 250 and 383). + +- [ ] **Step 2: Add slot firing** + +For each occurrence, change: + +```python + self.account_balance = data +``` + +to: + +```python + self.account_balance = data + self.slots.balance.set(data) +``` + +And: + +```python + self.account_balance = message +``` + +to: + +```python + self.account_balance = message + self.slots.balance.set(message) +``` + +- [ ] **Step 3: Verify imports unchanged** + +```bash +python -c "from pyquotex.api import QuotexAPI; print('ok')" +``` + +Expected: `ok`. + +- [ ] **Step 4: Run surface and import tests** + +```bash +pytest tests/test_api_surface.py tests/test_import_compat.py tests/test_waits.py -v +``` + +Expected: all pass. + +- [ ] **Step 5: Commit** + +```bash +git add pyquotex/api.py +git commit -m "feat(api): fire balance slot from WS handler" +``` + +--- + +### Task 2.3: Migrate get_balance polling + +**Files:** +- Modify: `pyquotex/stable_api.py` (the `get_balance` method around line 666) + +- [ ] **Step 1: Read the current implementation** + +```bash +sed -n '660,695p' pyquotex/stable_api.py +``` + +Note the polling loop pattern `while ... is None: await asyncio.sleep(0.2)`. + +- [ ] **Step 2: Replace polling with slot.wait()** + +In `pyquotex/stable_api.py`, locate the `get_balance` method body. Replace the polling loop: + +```python + while self.api.account_balance is None: + await asyncio.sleep(0.2) +``` + +with: + +```python + from pyquotex.exceptions import QuotexTimeoutError + if self.api.account_balance is None: + try: + await self.api.slots.balance.wait(timeout=timeout) + except asyncio.TimeoutError: + raise QuotexTimeoutError( + f"get_balance timed out after {timeout}s" + ) +``` + +Important: keep the existing `timeout` parameter handling and surrounding logic intact. The change is only inside the polling loop region. + +- [ ] **Step 3: Run surface test** + +```bash +pytest tests/test_api_surface.py -v +``` + +Expected: pass (signature unchanged). + +- [ ] **Step 4: Run all unit tests** + +```bash +pytest tests/test_waits.py tests/test_api_surface.py tests/test_import_compat.py tests/test_cli_smoke.py -v +``` + +Expected: all pass. + +- [ ] **Step 5: Commit** + +```bash +git add pyquotex/stable_api.py +git commit -m "refactor(stable_api): replace get_balance polling with WaitableSlot" +``` + +--- + +### Task 2.4: Migrate edit_practice_balance polling + +**Files:** +- Modify: `pyquotex/api.py` (around line 250 where `training_balance_edit_request` is set β€” find with grep) +- Modify: `pyquotex/stable_api.py` line 658 (`while self.api.training_balance_edit_request is None`) + +- [ ] **Step 1: Find the producer** + +```bash +grep -n "training_balance_edit_request" pyquotex/api.py +``` + +Locate where `self.training_balance_edit_request = ...` is assigned in `_on_message`. + +- [ ] **Step 2: Add slot.set() next to assignment** + +After the assignment, add: + +```python + self.slots.training_balance_edit.set(self.training_balance_edit_request) +``` + +- [ ] **Step 3: Replace consumer polling** + +In `pyquotex/stable_api.py`, locate line 658. Replace: + +```python + while self.api.training_balance_edit_request is None: + await asyncio.sleep(0.2) +``` + +with: + +```python + from pyquotex.exceptions import QuotexTimeoutError + if self.api.training_balance_edit_request is None: + try: + await self.api.slots.training_balance_edit.wait(timeout=DEFAULT_TIMEOUT) + except asyncio.TimeoutError: + raise QuotexTimeoutError( + f"edit_practice_balance timed out after {DEFAULT_TIMEOUT}s" + ) +``` + +- [ ] **Step 4: Run all unit tests** + +```bash +pytest tests/test_waits.py tests/test_api_surface.py tests/test_import_compat.py -v +``` + +Expected: all pass. + +- [ ] **Step 5: Commit** + +```bash +git add pyquotex/api.py pyquotex/stable_api.py +git commit -m "refactor(stable_api): replace edit_practice_balance polling with slot" +``` + +--- + +### Task 2.5: Migrate buy / sell_option / pending confirmation polling + +**Files:** +- Modify: `pyquotex/api.py` (find producer at `self.pending_id = data.get("id")` around line 366; also find `buy_id` and `sold_options_respond` assignments) +- Modify: `pyquotex/stable_api.py` around lines 1149 (pending), 1153 (buy), 1182 (sell), 1185 (sell) + +- [ ] **Step 1: Audit producers** + +```bash +grep -n "self.buy_id\|self.pending_id\|self.sold_options_respond" pyquotex/api.py +``` + +For each assignment in `_on_message`, fire the matching slot: +- `self.buy_id = …` β†’ `self.slots.pending_confirm.set({"id": self.buy_id})` if reused for buy, or use a dedicated `buy_confirm` slot (preferred β€” add to `SlotRegistry`). +- `self.pending_id = …` β†’ `self.slots.pending_confirm.set({"id": self.pending_id})` +- `self.sold_options_respond = …` β†’ `self.slots.sold_option_confirm.set(self.sold_options_respond)` + +- [ ] **Step 2: Add buy_confirm slot to SlotRegistry** + +In `pyquotex/_api/_waits.py`, inside `SlotRegistry.__init__`, after `self.pending_confirm`: + +```python + self.buy_confirm: WaitableSlot[dict] = WaitableSlot() +``` + +- [ ] **Step 3: Update producers in pyquotex/api.py** + +After each producer assignment, add the matching `.set()` call as listed in Step 1. + +- [ ] **Step 4: Replace consumer polling for `buy`** + +In `pyquotex/stable_api.py` `buy()` method, locate the polling loop (around line 1153). Replace: + +```python + while await self.check_connect() and self.api.buy_id is None: + await asyncio.sleep(0.2) +``` + +with: + +```python + from pyquotex.exceptions import QuotexTimeoutError + if self.api.buy_id is None: + try: + await self.api.slots.buy_confirm.wait(timeout=DEFAULT_TIMEOUT) + except asyncio.TimeoutError: + raise QuotexTimeoutError( + f"buy timed out after {DEFAULT_TIMEOUT}s" + ) +``` + +- [ ] **Step 5: Replace consumer polling for `open_pending`** + +Apply the same pattern to the `open_pending` method polling on `self.api.pending_id` (around line 1149). + +- [ ] **Step 6: Replace consumer polling for `sell_option`** + +Apply the same pattern to `sell_option` polling on `self.api.sold_options_respond` (around line 1185). + +- [ ] **Step 7: Run unit tests** + +```bash +pytest tests/test_waits.py tests/test_api_surface.py tests/test_import_compat.py -v +``` + +Expected: all pass. + +- [ ] **Step 8: Commit** + +```bash +git add pyquotex/_api/_waits.py pyquotex/api.py pyquotex/stable_api.py +git commit -m "refactor: replace buy/sell/pending confirmation polling with slots" +``` + +--- + +### Task 2.6: Migrate get_candle_v2 polling + +**Files:** +- Modify: `pyquotex/api.py` around line 295 (`self.candle_v2_data[asset] = data`) +- Modify: `pyquotex/stable_api.py` line 533 + +- [ ] **Step 1: Convert keyed slot for candle_v2** + +In `pyquotex/_api/_waits.py`, add to `SlotRegistry`: + +```python + self._candle_v2: dict[str, WaitableSlot[dict]] = {} + + def candle_v2(self, asset: str) -> WaitableSlot[dict]: + slot = self._candle_v2.get(asset) + if slot is None: + slot = WaitableSlot() + self._candle_v2[asset] = slot + return slot + + def release_candle_v2(self, asset: str) -> None: + self._candle_v2.pop(asset, None) +``` + +- [ ] **Step 2: Fire from producer** + +In `pyquotex/api.py` around line 295, after `self.candle_v2_data[asset] = data`, add: + +```python + self.slots.candle_v2(asset).set(data) +``` + +- [ ] **Step 3: Replace consumer** + +In `pyquotex/stable_api.py` around line 533, replace: + +```python + while self.api.candle_v2_data[asset] is None: + await asyncio.sleep(0.2) +``` + +with: + +```python + from pyquotex.exceptions import QuotexTimeoutError + if self.api.candle_v2_data.get(asset) is None: + try: + await self.api.slots.candle_v2(asset).wait(timeout=DEFAULT_TIMEOUT) + except asyncio.TimeoutError: + raise QuotexTimeoutError( + f"get_candle_v2({asset}) timed out after {DEFAULT_TIMEOUT}s" + ) +``` + +- [ ] **Step 4: Run tests** + +```bash +pytest tests/test_waits.py tests/test_api_surface.py -v +``` + +Expected: all pass. + +- [ ] **Step 5: Commit** + +```bash +git add pyquotex/_api/_waits.py pyquotex/api.py pyquotex/stable_api.py +git commit -m "refactor: replace candle_v2 polling with keyed slot" +``` + +--- + +### Task 2.7: Migrate historical_candles polling + +**Files:** +- Modify: `pyquotex/api.py` around line 105 / wherever `historical_candles` is mutated +- Modify: `pyquotex/stable_api.py` line 509 (`while await self.check_connect() and self.api.historical_candles is None`) + +- [ ] **Step 1: Locate producer** + +```bash +grep -n "self.historical_candles" pyquotex/api.py +``` + +Find the assignment inside `_on_message`. After the assignment add `self.slots.historical_ready.set(self.historical_candles)`. + +- [ ] **Step 2: Replace consumer polling** + +In `pyquotex/stable_api.py` around line 509, replace: + +```python + while await self.check_connect() and self.api.historical_candles is None: + await asyncio.sleep(0.1) +``` + +with: + +```python + from pyquotex.exceptions import QuotexTimeoutError + if self.api.historical_candles is None: + try: + await self.api.slots.historical_ready.wait(timeout=DEFAULT_TIMEOUT) + except asyncio.TimeoutError: + raise QuotexTimeoutError( + f"historical_candles wait timed out after {DEFAULT_TIMEOUT}s" + ) +``` + +- [ ] **Step 3: Run tests** + +```bash +pytest tests/test_waits.py tests/test_api_surface.py -v +``` + +Expected: all pass. + +- [ ] **Step 4: Commit** + +```bash +git add pyquotex/api.py pyquotex/stable_api.py +git commit -m "refactor: replace historical_candles polling with slot" +``` + +--- + +### Task 2.8: Migrate remaining stable_api polling loops + +**Files:** +- Modify: `pyquotex/stable_api.py` lines 114, 183, 663, 934, 939, 1031, 1256 + +- [ ] **Step 1: Audit remaining loops** + +```bash +grep -n "await asyncio.sleep" pyquotex/stable_api.py +``` + +For each remaining loop with a `while … is None` or `while not …` pattern, classify: +- If a clear WS event can fire it β†’ fire a slot from `_on_message` and `await self.api.slots..wait(timeout=…)` at the consumer. +- If the state cannot be signaled cleanly β†’ use `wait_until(predicate, timeout=DEFAULT_TIMEOUT)` from `_waits.py`. + +- [ ] **Step 2: Migrate `check_connect` waits (lines 114, 183)** + +These wait for `self.api.state.status` to become `CONNECTED`. Add to `SlotRegistry` (already present as `auth_status`) and wire from the WS open / auth handler. Then replace the polling with `await self.api.slots.auth_status.wait(timeout=DEFAULT_TIMEOUT)`. + +If wiring requires changes to `_on_open` or auth flow that are out of scope, fall back to `await wait_until(lambda: self.api.state.status == WebsocketStatus.CONNECTED, timeout=DEFAULT_TIMEOUT)` and document. + +- [ ] **Step 3: Migrate `calculate_indicator` waits (lines 934, 939, 1031)** + +Use `wait_until(lambda: , timeout=DEFAULT_TIMEOUT)` because indicator readiness depends on accumulated data, not a single message. Document this as the documented fallback case. + +- [ ] **Step 4: Migrate `check_win` (line 1256)** + +Use the keyed `win_result` slot in `SlotRegistry`. Find the WS handler that updates win/loss state and fire `self.slots.win_result(operation_id).set(result)`. Replace polling with: + +```python + from pyquotex.exceptions import QuotexTimeoutError + try: + result = await self.api.slots.win_result(operation_id).wait(timeout=timeout) + except asyncio.TimeoutError: + raise QuotexTimeoutError( + f"check_win({operation_id}) timed out after {timeout}s" + ) + finally: + self.api.slots.release_win_result(operation_id) +``` + +- [ ] **Step 5: Run all tests** + +```bash +pytest tests/test_waits.py tests/test_api_surface.py tests/test_import_compat.py tests/test_cli_smoke.py -v +``` + +Expected: all pass. + +- [ ] **Step 6: Verify no polling loops remain in stable_api** + +```bash +grep -c "while.*is None.*sleep\|while not.*sleep" pyquotex/stable_api.py +``` + +Expected: 0. If non-zero, audit the remaining lines and decide per the classification rule in Step 1. + +- [ ] **Step 7: Commit** + +```bash +git add pyquotex/api.py pyquotex/stable_api.py pyquotex/_api/_waits.py +git commit -m "refactor: replace remaining stable_api polling with event waits" +``` + +--- + +### Task 2.9: Replace network/login sleeps with exponential backoff + +**Files:** +- Modify: `pyquotex/network/login.py` lines around 98 and 170 +- Modify: `pyquotex/api.py` line 139 (the `sleep(5)` after error) + +- [ ] **Step 1: Read current retry logic** + +```bash +sed -n '90,110p' pyquotex/network/login.py +sed -n '160,180p' pyquotex/network/login.py +sed -n '130,145p' pyquotex/api.py +``` + +- [ ] **Step 2: Add a small backoff helper** + +Append to `pyquotex/_api/_waits.py`: + +```python +import random + + +async def backoff_sleep( + attempt: int, + *, + base: float = 1.0, + cap: float = 30.0, + jitter: float = 0.1, +) -> None: + """Sleep for an exponentially increasing duration with jitter. + + attempt is zero-indexed (0, 1, 2, ...). + """ + delay = min(cap, base * (2 ** attempt)) + delay = delay * (1.0 + random.uniform(-jitter, jitter)) + await asyncio.sleep(max(0.0, delay)) +``` + +- [ ] **Step 3: Add a test** + +Append to `tests/test_waits.py`: + +```python +@pytest.mark.asyncio +async def test_backoff_sleep_grows_and_caps(): + from pyquotex._api._waits import backoff_sleep + import time + + # attempt=0 should sleep ~1s; cap to a small value for test speed + start = time.monotonic() + await backoff_sleep(0, base=0.01, cap=0.1, jitter=0) + assert (time.monotonic() - start) >= 0.009 + + start = time.monotonic() + await backoff_sleep(5, base=0.01, cap=0.05, jitter=0) + elapsed = time.monotonic() - start + assert 0.04 <= elapsed <= 0.15 # cap respected +``` + +- [ ] **Step 4: Run tests** + +```bash +pytest tests/test_waits.py -v +``` + +Expected: all pass. + +- [ ] **Step 5: Use backoff in login retries** + +In `pyquotex/network/login.py`, replace `await asyncio.sleep(1)` calls inside retry loops with `await backoff_sleep(attempt)` where `attempt` is the retry-loop counter. Import: `from pyquotex._api._waits import backoff_sleep`. + +If the surrounding loop does not track an `attempt` index, introduce one: `for attempt in range(MAX_RETRIES): ...`. + +- [ ] **Step 6: Use backoff in api.py reconnect** + +In `pyquotex/api.py` around line 139, replace `await asyncio.sleep(5)` with `await backoff_sleep(retry_count)` where `retry_count` is the surrounding retry counter (introduce if absent). + +- [ ] **Step 7: Run all tests** + +```bash +pytest tests/test_waits.py tests/test_api_surface.py tests/test_import_compat.py tests/test_cli_smoke.py -v +``` + +Expected: all pass. + +- [ ] **Step 8: Commit** + +```bash +git add pyquotex/_api/_waits.py pyquotex/network/login.py pyquotex/api.py tests/test_waits.py +git commit -m "refactor: use exponential backoff for login/reconnect retries" +``` + +--- + +## Phase 3 β€” Extract Mixins + +> **General pattern for each mixin task:** +> 1. Create the mixin file with the method definitions cut from `stable_api.py`. +> 2. Add the mixin to `Quotex`'s base list. +> 3. Remove the same methods from `stable_api.py`. +> 4. Run `pytest tests/test_api_surface.py tests/test_import_compat.py` β€” surface must remain identical. + +### Task 3.1: Extract AccountMixin + +**Files:** +- Create: `pyquotex/_api/account.py` +- Modify: `pyquotex/stable_api.py` + +**Methods to move:** `connect`, `reconnect`, `get_balance`, `get_profile`, `get_server_time`, `change_account`, `change_time_offset`, `set_account_mode`, `edit_practice_balance`, `store_settings_apply`, `start_remaing_time`, `set_session` *(keep set_session in Quotex if it touches init state β€” verify)*. + +- [ ] **Step 1: Create the mixin skeleton** + +Create `pyquotex/_api/account.py`: + +```python +"""Account-related methods extracted from Quotex. + +This mixin is composed into Quotex via multiple inheritance. It uses +self.api, self.session_data, etc. (set up in Quotex.__init__). +""" +from __future__ import annotations + +import asyncio +from typing import Any + +from pyquotex.exceptions import QuotexTimeoutError + + +DEFAULT_TIMEOUT = 30 + + +class AccountMixin: + # Methods are moved here from stable_api.py in Step 2. + pass +``` + +- [ ] **Step 2: Move each method** + +For each method in the list above: +1. Open `pyquotex/stable_api.py`, find the method, copy its full source (def + body, including decorators). +2. Paste into `pyquotex/_api/account.py` under `class AccountMixin`, preserving indentation. +3. Delete the method from `pyquotex/stable_api.py`. + +Imports needed in `account.py` (add as you discover them while moving methods): `from pyquotex.utils.account_type import AccountType`, `from pyquotex.config import update_session`, `from pyquotex.global_value import AuthStatus`, etc. Inspect each moved method to determine which symbols it references and ensure they are imported in `account.py`. + +- [ ] **Step 3: Compose AccountMixin into Quotex** + +In `pyquotex/stable_api.py`, change the class declaration from: + +```python +class Quotex(OptimizedQuotexMixin): +``` + +to: + +```python +from pyquotex._api.account import AccountMixin + +class Quotex(AccountMixin, OptimizedQuotexMixin): +``` + +- [ ] **Step 4: Run surface tests** + +```bash +pytest tests/test_api_surface.py tests/test_import_compat.py -v +``` + +Expected: all pass β€” surface unchanged. + +- [ ] **Step 5: Run CLI smoke** + +```bash +pytest tests/test_cli_smoke.py -v +``` + +Expected: pass. + +- [ ] **Step 6: Commit** + +```bash +git add pyquotex/_api/account.py pyquotex/stable_api.py +git commit -m "refactor(stable_api): extract AccountMixin into _api.account" +``` + +--- + +### Task 3.2: Extract TradingMixin + +**Files:** +- Create: `pyquotex/_api/trading.py` +- Modify: `pyquotex/stable_api.py` + +**Methods to move:** `buy`, `sell_option`, `open_pending`, `check_win`, `get_result`, `get_profit`, `get_history` (trade history, not candles). + +- [ ] **Step 1: Create the mixin** + +Create `pyquotex/_api/trading.py`: + +```python +"""Trading methods (buy, sell, pending, results) extracted from Quotex.""" +from __future__ import annotations + +import asyncio +from typing import Any + +from pyquotex.exceptions import QuotexTimeoutError + + +DEFAULT_TIMEOUT = 30 + + +class TradingMixin: + pass +``` + +- [ ] **Step 2: Move each method** + +Same procedure as Task 3.1 Step 2 for the methods listed above. Watch for usage of `expiration` module and `_request_counter` β€” add those imports if needed: + +```python +from pyquotex import expiration +``` + +The `_request_counter` from `stable_api.py` should stay there (it is module-scoped); the mixin reads it via `from pyquotex.stable_api import _request_counter` only if a method requires it. Prefer moving `_request_counter` to `pyquotex/_api/_waits.py` or a new `pyquotex/_api/_state.py` if it is used by multiple mixins. For this task, leave it in `stable_api.py` and import it from there if needed. + +- [ ] **Step 3: Compose** + +In `pyquotex/stable_api.py`: + +```python +from pyquotex._api.account import AccountMixin +from pyquotex._api.trading import TradingMixin + +class Quotex(AccountMixin, TradingMixin, OptimizedQuotexMixin): +``` + +- [ ] **Step 4: Run surface tests** + +```bash +pytest tests/test_api_surface.py tests/test_import_compat.py tests/test_cli_smoke.py -v +``` + +Expected: all pass. + +- [ ] **Step 5: Commit** + +```bash +git add pyquotex/_api/trading.py pyquotex/stable_api.py +git commit -m "refactor(stable_api): extract TradingMixin into _api.trading" +``` + +--- + +### Task 3.3: Extract HistoryMixin + +**Files:** +- Create: `pyquotex/_api/history.py` +- Modify: `pyquotex/stable_api.py` + +**Methods to move:** `get_candles`, `_fetch_historical_batch`, `_parse_historical_candles`, `get_historical_candles`, `get_candles_deep`, `get_candle_v2`, `get_history_line`, `get_trader_history`, `prepare_candles`. + +- [ ] **Step 1: Create the mixin** + +Create `pyquotex/_api/history.py`: + +```python +"""Candle and historical data methods extracted from Quotex.""" +from __future__ import annotations + +import asyncio +import time +from typing import Any + +from pyquotex.exceptions import QuotexTimeoutError +from pyquotex.utils.processor import ( + calculate_candles, + process_candles_v2, + merge_candles, + aggregate_candle, +) + + +DEFAULT_TIMEOUT = 30 + + +class HistoryMixin: + pass +``` + +- [ ] **Step 2: Move methods** + +Same procedure. The historical methods reference `_request_counter` and `process_candles_v2`; ensure imports cover what each method needs. + +- [ ] **Step 3: Compose** + +```python +from pyquotex._api.history import HistoryMixin + +class Quotex(AccountMixin, TradingMixin, HistoryMixin, OptimizedQuotexMixin): +``` + +- [ ] **Step 4: Run tests** + +```bash +pytest tests/test_api_surface.py tests/test_import_compat.py tests/test_cli_smoke.py -v +``` + +Expected: all pass. + +- [ ] **Step 5: Commit** + +```bash +git add pyquotex/_api/history.py pyquotex/stable_api.py +git commit -m "refactor(stable_api): extract HistoryMixin into _api.history" +``` + +--- + +### Task 3.4: Extract RealtimeMixin + +**Files:** +- Create: `pyquotex/_api/realtime.py` +- Modify: `pyquotex/stable_api.py` + +**Methods to move:** `start_candles_stream`, `stop_candles_stream`, `start_candles_one_stream`, `start_candles_all_size_stream`, `start_signals_data`, `start_realtime_price`, `start_realtime_sentiment`, `start_realtime_candle`, `get_realtime_candles`, `get_realtime_sentiment`, `get_realtime_price`, `subscribe_indicator`, `calculate_indicator`, `start_mood_stream`, `opening_closing_current_candle`, `get_signal_data`. + +- [ ] **Step 1: Create the mixin** + +Create `pyquotex/_api/realtime.py`: + +```python +"""Realtime streaming methods extracted from Quotex.""" +from __future__ import annotations + +import asyncio +from typing import Any + +from pyquotex.exceptions import QuotexTimeoutError +from pyquotex.utils.indicators import TechnicalIndicators +from pyquotex.utils.processor import process_tick + + +DEFAULT_TIMEOUT = 30 + + +class RealtimeMixin: + pass +``` + +- [ ] **Step 2: Move methods** + +Same procedure. + +- [ ] **Step 3: Compose** + +```python +from pyquotex._api.realtime import RealtimeMixin + +class Quotex(AccountMixin, TradingMixin, HistoryMixin, RealtimeMixin, OptimizedQuotexMixin): +``` + +- [ ] **Step 4: Run tests** + +```bash +pytest tests/test_api_surface.py tests/test_import_compat.py tests/test_cli_smoke.py -v +``` + +Expected: all pass. + +- [ ] **Step 5: Commit** + +```bash +git add pyquotex/_api/realtime.py pyquotex/stable_api.py +git commit -m "refactor(stable_api): extract RealtimeMixin into _api.realtime" +``` + +--- + +### Task 3.5: Extract AssetsMixin + +**Files:** +- Create: `pyquotex/_api/assets.py` +- Modify: `pyquotex/stable_api.py` + +**Methods to move:** `get_instruments`, `get_all_asset_name`, `get_available_asset`, `check_asset_open`, `get_all_assets`, `get_payment`, `get_payout_by_asset`, `re_subscribe_stream`. + +- [ ] **Step 1: Create the mixin** + +Create `pyquotex/_api/assets.py`: + +```python +"""Asset metadata and payout methods extracted from Quotex.""" +from __future__ import annotations + +import asyncio +from typing import Any + + +DEFAULT_TIMEOUT = 30 + + +class AssetsMixin: + pass +``` + +- [ ] **Step 2: Move methods** + +Same procedure. + +- [ ] **Step 3: Compose final Quotex** + +```python +from pyquotex._api.account import AccountMixin +from pyquotex._api.trading import TradingMixin +from pyquotex._api.history import HistoryMixin +from pyquotex._api.realtime import RealtimeMixin +from pyquotex._api.assets import AssetsMixin + +class Quotex( + AccountMixin, + TradingMixin, + HistoryMixin, + RealtimeMixin, + AssetsMixin, + OptimizedQuotexMixin, +): +``` + +- [ ] **Step 4: Confirm stable_api.py size** + +```bash +wc -l pyquotex/stable_api.py +``` + +Expected: ~200-300 lines (down from 1573). If significantly more, audit for dead code or methods missed. + +- [ ] **Step 5: Run tests** + +```bash +pytest tests/test_api_surface.py tests/test_import_compat.py tests/test_cli_smoke.py tests/test_waits.py -v +``` + +Expected: all pass. + +- [ ] **Step 6: Commit** + +```bash +git add pyquotex/_api/assets.py pyquotex/stable_api.py +git commit -m "refactor(stable_api): extract AssetsMixin into _api.assets" +``` + +--- + +## Phase 4 β€” CLI Modularization + +### Task 4.1: Scaffold pyquotex/cli/ + +**Files:** +- Create: `pyquotex/cli/__init__.py` +- Create: `pyquotex/cli/parser.py` +- Create: `pyquotex/cli/runtime.py` +- Create: `pyquotex/cli/formatters.py` + +- [ ] **Step 1: Create the package marker** + +Create `pyquotex/cli/__init__.py`: + +```python +"""Command-line interface for pyquotex.""" +``` + +- [ ] **Step 2: Move make_parser into cli/parser.py** + +Read [app.py](../../../app.py) lines 99-360 to confirm the bounds of `make_parser()`. Copy that function (and any module-level constants it uses) into `pyquotex/cli/parser.py` with an appropriate header docstring: + +```python +"""argparse parser construction for pyquotex CLI.""" +import argparse + +# ... copy make_parser() and its helpers verbatim ... +``` + +Do not delete the function from `app.py` yet (Task 4.4 does that). + +- [ ] **Step 3: Move runtime helpers into cli/runtime.py** + +From `app.py`, copy `on_otp` (lines 82-98), `connect_with_retry` (lines 363-409), `_is_demo` (lines 410-415) into `pyquotex/cli/runtime.py`. Add the imports they need (re-read the original file to verify). + +```python +"""CLI runtime helpers: connection retry, OTP prompt, demo detection.""" +# ... copied content with original imports preserved ... +``` + +- [ ] **Step 4: Move formatters into cli/formatters.py** + +From `app.py`, copy `_balance_table` (lines 416-441), `_print_candles_table` (lines 1249-1296), `_save_candles_csv` (lines 1297-1311) into `pyquotex/cli/formatters.py`: + +```python +"""Output formatting helpers shared by CLI commands.""" +# ... copied content ... +``` + +- [ ] **Step 5: Verify imports resolve** + +```bash +python -c "from pyquotex.cli.parser import make_parser; print('parser ok')" +python -c "from pyquotex.cli.runtime import connect_with_retry, on_otp; print('runtime ok')" +python -c "from pyquotex.cli.formatters import _balance_table; print('formatters ok')" +``` + +Expected: three `... ok` lines. + +- [ ] **Step 6: Commit** + +```bash +git add pyquotex/cli/ +git commit -m "feat(cli): scaffold pyquotex.cli package with parser/runtime/formatters" +``` + +--- + +### Task 4.2: Move command functions into cli/commands/ + +**Files:** +- Create: `pyquotex/cli/commands/__init__.py` +- Create: `pyquotex/cli/commands/account.py` +- Create: `pyquotex/cli/commands/market.py` +- Create: `pyquotex/cli/commands/candles.py` +- Create: `pyquotex/cli/commands/realtime.py` +- Create: `pyquotex/cli/commands/trading.py` +- Create: `pyquotex/cli/commands/analysis.py` +- Create: `pyquotex/cli/commands/diagnostics.py` + +**Mapping (line numbers refer to current app.py):** + +| File | Commands (with original app.py line) | +|---|---| +| `account.py` | `cmd_login` (442), `cmd_balance` (461), `cmd_server_time` (470), `cmd_set_demo_balance` (486), `cmd_settings` (502) | +| `market.py` | `cmd_assets` (531), `cmd_payout` (563), `cmd_payout_asset` (601) | +| `candles.py` | `cmd_candles` (625), `cmd_candles_v2` (640), `cmd_candles_deep` (653), `cmd_history_line` (700), `cmd_candle_info` (721) | +| `realtime.py` | `cmd_realtime_price` (749), `cmd_realtime_sentiment` (777), `cmd_realtime_candle` (808) | +| `trading.py` | `cmd_buy` (840), `cmd_sell` (926), `cmd_pending` (946), `cmd_check` (987), `cmd_result` (1026) | +| `analysis.py` | `cmd_signals` (1044), `cmd_history` (1072), `cmd_indicator` (1141), `cmd_monitor` (1186), `cmd_strategy` (1224) | +| `diagnostics.py` | `cmd_test_all` (1312) | + +- [ ] **Step 1: Create the registry skeleton** + +Create `pyquotex/cli/commands/__init__.py`: + +```python +"""Registry mapping CLI command names to async handler functions.""" +from pyquotex.cli.commands.account import ( + cmd_login, + cmd_balance, + cmd_server_time, + cmd_set_demo_balance, + cmd_settings, +) +from pyquotex.cli.commands.market import ( + cmd_assets, + cmd_payout, + cmd_payout_asset, +) +from pyquotex.cli.commands.candles import ( + cmd_candles, + cmd_candles_v2, + cmd_candles_deep, + cmd_history_line, + cmd_candle_info, +) +from pyquotex.cli.commands.realtime import ( + cmd_realtime_price, + cmd_realtime_sentiment, + cmd_realtime_candle, +) +from pyquotex.cli.commands.trading import ( + cmd_buy, + cmd_sell, + cmd_pending, + cmd_check, + cmd_result, +) +from pyquotex.cli.commands.analysis import ( + cmd_signals, + cmd_history, + cmd_indicator, + cmd_monitor, + cmd_strategy, +) +from pyquotex.cli.commands.diagnostics import cmd_test_all + + +COMMAND_REGISTRY = { + "login": cmd_login, + "balance": cmd_balance, + "server-time": cmd_server_time, + "set-demo-balance": cmd_set_demo_balance, + "settings": cmd_settings, + "assets": cmd_assets, + "payout": cmd_payout, + "payout-asset": cmd_payout_asset, + "candles": cmd_candles, + "candles-v2": cmd_candles_v2, + "candles-deep": cmd_candles_deep, + "history-line": cmd_history_line, + "candle-info": cmd_candle_info, + "realtime-price": cmd_realtime_price, + "realtime-sentiment": cmd_realtime_sentiment, + "realtime-candle": cmd_realtime_candle, + "buy": cmd_buy, + "sell": cmd_sell, + "pending": cmd_pending, + "check": cmd_check, + "result": cmd_result, + "signals": cmd_signals, + "history": cmd_history, + "indicator": cmd_indicator, + "monitor": cmd_monitor, + "strategy": cmd_strategy, + "test-all": cmd_test_all, +} +``` + +> Verify the exact command names against `make_parser()` subparsers. If a name differs (e.g. `set_demo_balance` vs `set-demo-balance`), correct the dict. + +- [ ] **Step 2: Move each command group** + +For each row in the mapping table, create the corresponding file (e.g., `pyquotex/cli/commands/account.py`) starting with a docstring and the imports needed. Copy each `cmd_*` function body verbatim from `app.py`. Add at top: + +```python +""" CLI command handlers.""" +import argparse +from rich.console import Console +from rich.table import Table + +from pyquotex.stable_api import Quotex +from pyquotex.cli.formatters import _balance_table # if used +from pyquotex.cli.runtime import _is_demo # if used +# ... add any other imports each cmd uses ... + +console = Console() +``` + +> The exact set of imports for each commands file depends on which symbols the moved functions reference. Inspect each function's body and add the matching imports. + +- [ ] **Step 3: Verify each new module imports cleanly** + +```bash +for m in account market candles realtime trading analysis diagnostics; do + python -c "import pyquotex.cli.commands.$m; print('$m ok')" +done +``` + +Expected: 7 `... ok` lines. + +- [ ] **Step 4: Verify the registry** + +```bash +python -c "from pyquotex.cli.commands import COMMAND_REGISTRY; print(len(COMMAND_REGISTRY), sorted(COMMAND_REGISTRY))" +``` + +Expected: a count of 27 (or matching the actual number of `cmd_*` functions in `app.py`) and the sorted command list. + +- [ ] **Step 5: Commit** + +```bash +git add pyquotex/cli/commands/ +git commit -m "feat(cli): move cmd_* functions into pyquotex.cli.commands" +``` + +--- + +### Task 4.3: Wire pyquotex/cli/__main__.py + +**Files:** +- Create: `pyquotex/cli/__main__.py` + +- [ ] **Step 1: Read the existing main() in app.py** + +```bash +sed -n '1407,1434p' app.py +``` + +Note how it builds the parser, creates the client, calls the command handler, closes. + +- [ ] **Step 2: Write the new entry point** + +Create `pyquotex/cli/__main__.py`: + +```python +"""pyquotex CLI entry point. Run with `python -m pyquotex` or via app.py.""" +import asyncio +import sys + +from pyquotex.cli.commands import COMMAND_REGISTRY +from pyquotex.cli.parser import make_parser +from pyquotex.cli.runtime import connect_with_retry, on_otp +from pyquotex.stable_api import Quotex + + +async def main() -> None: + parser = make_parser() + args = parser.parse_args() + + if not args.command: + parser.print_help() + return + + client = Quotex( + email=args.email, + password=args.password, + lang=args.lang, + on_otp_callback=on_otp, + ) + + await connect_with_retry(client) + try: + handler = COMMAND_REGISTRY.get(args.command) + if handler is None: + print(f"Unknown command: {args.command}", file=sys.stderr) + parser.print_help() + sys.exit(2) + await handler(client, args) + finally: + await client.close() + + +def cli_main() -> None: + asyncio.run(main()) + + +if __name__ == "__main__": + cli_main() +``` + +> **Important:** the constructor arguments must match what `app.py` currently passes. Re-read `app.py` line 1407-1434 and adjust this `Quotex(...)` call to use the same args. + +- [ ] **Step 3: Test the new entry point** + +```bash +python -m pyquotex --help +``` + +Expected: parser help output listing all commands. + +- [ ] **Step 4: Run CLI smoke tests** + +```bash +pytest tests/test_cli_smoke.py -v +``` + +Expected: all pass. If `test_module_invocation_help_runs` was previously skipped, un-skip it now and confirm it passes. + +- [ ] **Step 5: Commit** + +```bash +git add pyquotex/cli/__main__.py +git commit -m "feat(cli): add pyquotex.cli.__main__ entry point" +``` + +--- + +### Task 4.4: Reduce app.py to a shim + +**Files:** +- Modify: `app.py` + +- [ ] **Step 1: Replace app.py content** + +Replace the entire content of `app.py` with: + +```python +"""Compatibility shim. The CLI now lives in pyquotex.cli. + +Kept so that documented usage `python app.py ` continues to work. +""" +from pyquotex.cli.__main__ import cli_main + +if __name__ == "__main__": + cli_main() +``` + +- [ ] **Step 2: Verify app.py still works** + +```bash +python app.py --help +``` + +Expected: parser help output (same as before the refactor). + +- [ ] **Step 3: Verify line count** + +```bash +wc -l app.py +``` + +Expected: ~7 lines. + +- [ ] **Step 4: Run all tests** + +```bash +pytest tests/test_api_surface.py tests/test_import_compat.py tests/test_cli_smoke.py tests/test_waits.py -v +``` + +Expected: all pass. + +- [ ] **Step 5: Commit** + +```bash +git add app.py +git commit -m "refactor(cli): reduce app.py to a 7-line shim to pyquotex.cli" +``` + +--- + +## Phase 5 β€” Cleanup + +### Task 5.1: Remove dead code from stable_api.py + +**Files:** +- Modify: `pyquotex/stable_api.py` + +- [ ] **Step 1: Audit unused imports** + +```bash +python -c " +import ast, sys +tree = ast.parse(open('pyquotex/stable_api.py').read()) +imports = [] +for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom): + for alias in node.names: + imports.append(alias.asname or alias.name) + elif isinstance(node, ast.Import): + for alias in node.names: + imports.append(alias.asname or alias.name.split('.')[0]) +src = open('pyquotex/stable_api.py').read() +for name in imports: + count = src.count(name) + if count <= 1: + print(f'POSSIBLY UNUSED: {name}') +" +``` + +Review the output. For each `POSSIBLY UNUSED` symbol, confirm by searching usage outside its `import` line and remove it from the import block if truly unused. + +- [ ] **Step 2: Remove** + +Edit `pyquotex/stable_api.py` and delete unused imports identified in Step 1. + +- [ ] **Step 3: Re-run all tests** + +```bash +pytest tests/test_api_surface.py tests/test_import_compat.py tests/test_cli_smoke.py tests/test_waits.py -v +``` + +Expected: all pass. + +- [ ] **Step 4: Commit** + +```bash +git add pyquotex/stable_api.py +git commit -m "chore(stable_api): remove dead imports after mixin extraction" +``` + +--- + +### Task 5.2: Bump version + +**Files:** +- Modify: `pyproject.toml` + +- [ ] **Step 1: Bump version** + +In `pyproject.toml`, change: + +```toml +version = "1.0.3" +``` + +to: + +```toml +version = "1.1.0" +``` + +- [ ] **Step 2: Verify** + +```bash +grep '^version' pyproject.toml +``` + +Expected: `version = "1.1.0"`. + +- [ ] **Step 3: Commit** + +```bash +git add pyproject.toml +git commit -m "chore: bump version to 1.1.0" +``` + +--- + +### Task 5.3: Update README (optional) + +**Files:** +- Modify: `README.md` + +- [ ] **Step 1: Add an Architecture note** + +In `README.md`, add a short section after "🎯 Objetivo" with the new layout β€” keep it brief, 5-10 lines. Do not rewrite the README; just acknowledge the new internal structure: + +```markdown +## πŸ— Arquitectura interna + +- `pyquotex.stable_api.Quotex` β€” facade pΓΊblico (la API que usΓ‘s). +- `pyquotex._api/*` β€” mixins por dominio (account, trading, history, realtime, assets). +- `pyquotex.cli/*` β€” entrada de comandos del CLI. +- `pyquotex.api.QuotexAPI` β€” cliente WebSocket subyacente. + +La interfaz pΓΊblica no cambia entre 1.0.x y 1.1.0. +``` + +- [ ] **Step 2: Verify rendering visually if possible** + +```bash +head -60 README.md +``` + +- [ ] **Step 3: Commit** + +```bash +git add README.md +git commit -m "docs: note new internal architecture in README" +``` + +--- + +### Task 5.4: Final verification and merge prep + +**Files:** none + +- [ ] **Step 1: Run the full test suite (excluding live-credential tests)** + +```bash +pytest tests/ -k "not test_buy and not test_win and not test_login and not test_tournament" -v +``` + +Expected: all selected tests pass. If anything fails, debug before merging. + +- [ ] **Step 2: Confirm file sizes** + +```bash +wc -l pyquotex/stable_api.py app.py +``` + +Expected: +- `pyquotex/stable_api.py`: 200-300 lines (was 1573). +- `app.py`: ~7 lines (was 1435). + +- [ ] **Step 3: Confirm no polling loops remain in stable_api** + +```bash +grep -n "while.*is None.*sleep\|while not.*sleep" pyquotex/stable_api.py pyquotex/_api/*.py +``` + +Expected: no output, or only the documented `wait_until`-based fallbacks in `_api/realtime.py` for `calculate_indicator`. + +- [ ] **Step 4: View summary of all commits on the branch** + +```bash +git log --oneline master..refactor/architecture +``` + +Expected: 16-20 commits across phases 0-5. + +- [ ] **Step 5: Prepare merge** + +```bash +git checkout master +git merge --no-ff refactor/architecture -m "refactor: split stable_api into mixins, modularize CLI, event-driven waits" +git log --oneline -5 +``` + +Do **not** push without explicit user confirmation. + +--- + +## Acceptance Criteria + +- [ ] `pytest tests/test_api_surface.py` passes β€” no public methods removed. +- [ ] `pytest tests/test_import_compat.py` passes β€” legacy imports work. +- [ ] `pytest tests/test_cli_smoke.py` passes β€” `app.py --help` and `python -m pyquotex --help` both succeed. +- [ ] `pytest tests/test_waits.py` passes β€” wait primitives behave correctly. +- [ ] `wc -l pyquotex/stable_api.py` shows < 300 lines. +- [ ] `wc -l app.py` shows < 20 lines. +- [ ] No `while … sleep` polling loops in `pyquotex/stable_api.py` (only documented `wait_until` fallbacks allowed in `_api/realtime.py`). +- [ ] `pyproject.toml` version is `1.1.0`. + +## Notes for the Implementer + +- **Move methods, don't rewrite them.** The body of every moved method should be byte-identical to before. Only `self` references and shared imports change. +- **Avoid renaming.** No method names change. No parameter names change. Even `self.api`, `self.session_data`, etc. stay identical. +- **Commit per task.** Do not batch multiple tasks into one commit. Small commits make Phase 2 (the highest-risk phase) easy to bisect. +- **Run the surface test after every mixin extraction.** It is the fastest way to catch an accidental method drop. +- **If a polling migration in Phase 2 cannot find a clean producer signal**, fall back to `wait_until(predicate, timeout=…)`. Document the case in a comment and move on β€” it is still better than naked `sleep`. +- **Integration tests requiring credentials** (`test_buy.py`, `test_win.py`, `test_login.py`, `test_tournament.py`) are not gated by CI. Run them manually if you have credentials; otherwise rely on the surface + smoke tests. diff --git a/docs/superpowers/specs/2026-05-11-architecture-maintainability-design.md b/docs/superpowers/specs/2026-05-11-architecture-maintainability-design.md new file mode 100644 index 00000000..16923099 --- /dev/null +++ b/docs/superpowers/specs/2026-05-11-architecture-maintainability-design.md @@ -0,0 +1,364 @@ +# Architecture & Maintainability Refactor β€” Design Spec + +**Date:** 2026-05-11 +**Scope:** `pyquotex` library + CLI +**Status:** Approved for planning + +## Goals + +Reduce monolithic complexity in `pyquotex` while preserving 100% public API backwards compatibility: + +1. Split [pyquotex/stable_api.py](../../../pyquotex/stable_api.py) (1573 lines) into domain submodules. +2. Split [app.py](../../../app.py) (1435 lines, ~30 CLI commands) into per-topic command modules. +3. Replace all polling `asyncio.sleep` loops (18+ occurrences) with event-driven waits. + +## Non-Goals + +- No migration to typer/click β€” keep `argparse`. +- No new test suite β€” only minimum regression tests. +- No changes to public method signatures or return types. +- No bump to v2.x β€” this is a v1.x maintenance refactor. +- No changes to WebSocket protocol, login flow, or business logic. + +## Constraints + +- `from pyquotex.stable_api import Quotex` must continue to work identically. +- All current public methods on `Quotex` must remain callable as `client.method()`. +- `python app.py ` must keep working (referenced in README). +- No new runtime dependencies (Termux compatibility). +- Each phase must leave the repo in a working state (tests passing, CLI usable). + +## Target Architecture + +### Module Layout + +``` +pyquotex/ +β”œβ”€β”€ stable_api.py # facade (~200 lines, was 1573) +β”œβ”€β”€ _api/ # private domain package (underscore = not part of public API) +β”‚ β”œβ”€β”€ __init__.py +β”‚ β”œβ”€β”€ _waits.py # WaitableSlot, wait_until helpers +β”‚ β”œβ”€β”€ account.py # AccountMixin +β”‚ β”œβ”€β”€ trading.py # TradingMixin +β”‚ β”œβ”€β”€ history.py # HistoryMixin +β”‚ β”œβ”€β”€ realtime.py # RealtimeMixin +β”‚ └── assets.py # AssetsMixin +β”œβ”€β”€ cli/ +β”‚ β”œβ”€β”€ __init__.py +β”‚ β”œβ”€β”€ __main__.py # entry point +β”‚ β”œβ”€β”€ parser.py # make_parser() + subparsers +β”‚ β”œβ”€β”€ runtime.py # connect_with_retry, on_otp, helpers +β”‚ β”œβ”€β”€ formatters.py # table/CSV formatters shared by commands +β”‚ └── commands/ +β”‚ β”œβ”€β”€ __init__.py # COMMAND_REGISTRY dict +β”‚ β”œβ”€β”€ account.py # login, balance, server_time, set_demo_balance, settings +β”‚ β”œβ”€β”€ market.py # assets, payout, payout_asset +β”‚ β”œβ”€β”€ candles.py # candles, candles_v2, candles_deep, history_line, candle_info +β”‚ β”œβ”€β”€ realtime.py # realtime_price, realtime_sentiment, realtime_candle +β”‚ β”œβ”€β”€ trading.py # buy, sell, pending, check, result +β”‚ β”œβ”€β”€ analysis.py # signals, history, indicator, monitor, strategy +β”‚ └── diagnostics.py # test_all +β”œβ”€β”€ api.py # unchanged (WS client QuotexAPI) +└── … # rest of package unchanged +``` + +Notes: +- The private package is named `_api/` (with underscore prefix) to avoid collision with the existing `pyquotex/api.py` (WebSocket client) and to signal it is internal. +- `app.py` at repo root is preserved as a 5-line shim that calls `pyquotex.cli.__main__.main()`. + +### Mixin-Based Composition + +`Quotex` is assembled via multiple inheritance over domain mixins: + +```python +# pyquotex/stable_api.py (post-refactor, ~200 lines) +from pyquotex._api.account import AccountMixin +from pyquotex._api.trading import TradingMixin +from pyquotex._api.history import HistoryMixin +from pyquotex._api.realtime import RealtimeMixin +from pyquotex._api.assets import AssetsMixin + +class Quotex( + AccountMixin, + TradingMixin, + HistoryMixin, + RealtimeMixin, + AssetsMixin, +): + def __init__(self, email, password, lang="pt", ...): + # All current __init__ logic stays here + ... + + # Truly core methods only: websocket property, set_session, + # check_connect, close, _check_connect +``` + +Each mixin uses `self.api`, `self.session_data`, etc. β€” the same shared state attributes that exist today. The mixins never instantiate anything; they only provide methods bound to `Quotex` via the MRO. + +Rationale for mixins over composition (`client.trading.buy()`): +- Mixins preserve 100% call-site backwards compatibility (`client.buy()` keeps working). +- No facade glue methods needed. +- Same `self` state-sharing model as today. + +### Mixin β†’ Method Mapping + +| Mixin | Methods moved from stable_api.py | +|---|---| +| `AccountMixin` | `connect`, `reconnect`, `get_balance`, `get_profile`, `get_server_time`, `change_account`, `change_time_offset`, `set_account_mode`, `edit_practice_balance`, `store_settings_apply`, `get_payment` (account-related), `start_remaing_time` | +| `TradingMixin` | `buy`, `sell_option`, `open_pending`, `check_win`, `get_result`, `get_profit`, `get_history` (trade history) | +| `HistoryMixin` | `get_candles`, `_fetch_historical_batch`, `_parse_historical_candles`, `get_historical_candles`, `get_candles_deep`, `get_candle_v2`, `get_history_line`, `get_trader_history`, `prepare_candles` | +| `RealtimeMixin` | `start_candles_stream`, `stop_candles_stream`, `start_candles_one_stream`, `start_candles_all_size_stream`, `start_signals_data`, `start_realtime_price`, `start_realtime_sentiment`, `start_realtime_candle`, `get_realtime_candles`, `get_realtime_sentiment`, `get_realtime_price`, `subscribe_indicator`, `calculate_indicator`, `start_mood_stream`, `opening_closing_current_candle`, `get_signal_data` | +| `AssetsMixin` | `get_instruments`, `get_all_asset_name`, `get_available_asset`, `check_asset_open`, `get_all_assets`, `get_payout_by_asset`, `re_subscribe_stream` | + +## Event-Driven Waits (Polling Replacement) + +### Helper + +`pyquotex/_api/_waits.py`: + +```python +import asyncio +from typing import TypeVar, Callable + +T = TypeVar("T") +DEFAULT_TIMEOUT = 10.0 + +class WaitableSlot[T]: + """Typed slot a consumer awaits and the WS handler fills.""" + + def __init__(self): + self._value: T | None = None + self._event = asyncio.Event() + + def set(self, value: T) -> None: + self._value = value + self._event.set() + + def clear(self) -> None: + self._value = None + self._event.clear() + + async def wait(self, timeout: float = DEFAULT_TIMEOUT) -> T: + await asyncio.wait_for(self._event.wait(), timeout=timeout) + return self._value # type: ignore[return-value] + + +async def wait_until( + predicate: Callable[[], bool], + *, + timeout: float = DEFAULT_TIMEOUT, + poll_fallback: float = 0.05, +) -> None: + """For states that cannot be signaled from the WS handler. + Short poll with a hard timeout.""" + async def _loop(): + while not predicate(): + await asyncio.sleep(poll_fallback) + await asyncio.wait_for(_loop(), timeout=timeout) +``` + +### Migration Map + +| Caller (current line) | Polling pattern | Replacement | +|---|---|---| +| `get_balance` (stable_api:663) | `while balance is None: sleep(0.2)` | `WaitableSlot[float]` fired by `s_balance` handler | +| `buy` confirm (stable_api:1153) | `while order_id is None: sleep(0.2)` | `WaitableSlot` keyed by `request_id` in order-confirm handler | +| `sell_option` confirm (stable_api:1185) | same pattern | same approach | +| `check_win` (stable_api:1256) | `while result is None: sleep(1)` | `WaitableSlot` indexed by `operation_id` | +| `start_candles_*` init (stable_api:455, 516, 540) | `sleep(0.1)`/`sleep(0.2)` startup wait | `WaitableSlot` fired by first stream message | +| `calculate_indicator` (stable_api:934, 939, 1031) | `sleep(1)` waiting for candle data | `WaitableSlot` per asset/period | +| `connect` retry (api.py:139) | `sleep(5)` after error | Explicit exponential backoff (no library dep) | +| `network/login.py:98, 170` | `sleep(1)` between attempts | Exponential backoff with jitter | +| `check_connect` (stable_api:114, 183) | `sleep(2)` connection wait | `WaitableSlot[bool]` fired by auth-status handler | + +### WS Handler Integration + +The WS message dispatcher in [pyquotex/api.py](../../../pyquotex/api.py) gains slot-filling alongside its current state mutations: + +```python +# inside QuotexAPI.on_message handler (pseudocode) +elif event == "s_balance": + self.account_balance = data + self.slots.balance.set(data) # NEW: wake waiters +elif event == "successupdateBalance": + self.slots.balance_update.set(data) # NEW +elif event == "tradesOpened": + self.slots.order_confirm[request_id].set(data) # NEW: keyed slot +``` + +Slots are stored on `QuotexAPI` as a `SlotRegistry` namespace (simple dataclass holding the named slots plus dicts for keyed slots like `order_confirm[request_id]`). + +### Error Handling + +- All `wait()` calls have a default 10 s timeout; methods that historically waited longer (e.g., `check_win`) get a configurable timeout parameter. +- `asyncio.TimeoutError` is caught at the mixin boundary and re-raised as `QuotexTimeoutError` (new exception class in `pyquotex/exceptions.py` β€” create if absent) so callers don't import from `asyncio`. +- No silent failures: timeouts always raise. + +## CLI Refactor + +### Entrypoint + +```python +# pyquotex/cli/__main__.py (~80 lines) +import asyncio +from pyquotex.stable_api import Quotex +from pyquotex.cli.parser import make_parser +from pyquotex.cli.runtime import connect_with_retry +from pyquotex.cli.commands import COMMAND_REGISTRY + +async def main(): + parser = make_parser() + args = parser.parse_args() + client = Quotex(email=args.email, password=args.password, lang=args.lang) + client.set_session(...) + await connect_with_retry(client) + try: + handler = COMMAND_REGISTRY[args.command] + await handler(client, args) + finally: + await client.close() + +if __name__ == "__main__": + asyncio.run(main()) +``` + +### Command Registry + +`pyquotex/cli/commands/__init__.py` imports each `cmd_*` and registers them in a dict: + +```python +from .account import cmd_login, cmd_balance, cmd_server_time, cmd_set_demo_balance, cmd_settings +from .market import cmd_assets, cmd_payout, cmd_payout_asset +from .candles import cmd_candles, cmd_candles_v2, cmd_candles_deep, cmd_history_line, cmd_candle_info +from .realtime import cmd_realtime_price, cmd_realtime_sentiment, cmd_realtime_candle +from .trading import cmd_buy, cmd_sell, cmd_pending, cmd_check, cmd_result +from .analysis import cmd_signals, cmd_history, cmd_indicator, cmd_monitor, cmd_strategy +from .diagnostics import cmd_test_all + +COMMAND_REGISTRY = { + "login": cmd_login, + "balance": cmd_balance, + "server-time": cmd_server_time, + # … 30 entries +} +``` + +No decorators, no import-time side effects beyond the explicit dict assignment. + +### Compat Shim + +```python +# app.py (root, post-refactor β€” 5 lines) +import asyncio +from pyquotex.cli.__main__ import main + +if __name__ == "__main__": + asyncio.run(main()) +``` + +### Helpers Extraction + +- `_balance_table` β†’ `pyquotex/cli/formatters.py` +- `_print_candles_table` β†’ `pyquotex/cli/formatters.py` +- `_save_candles_csv` β†’ `pyquotex/cli/formatters.py` +- `connect_with_retry` β†’ `pyquotex/cli/runtime.py` +- `on_otp` β†’ `pyquotex/cli/runtime.py` +- `_is_demo` β†’ `pyquotex/cli/runtime.py` + +The `make_parser()` function moves to `pyquotex/cli/parser.py` unchanged. + +## Tests + +### New Tests Added + +1. **`tests/test_api_surface.py`** β€” snapshot check of `Quotex` public methods + signatures (loads `tests/fixtures/api_surface.json`). +2. **`tests/test_import_compat.py`** β€” verifies legacy imports still resolve. +3. **`tests/test_cli_smoke.py`** β€” runs `python -m pyquotex --help` and `python app.py --help` via `subprocess`. +4. **`tests/test_waits.py`** β€” unit tests for `WaitableSlot` and `wait_until` (resolution, timeout, clear). + +### Snapshot Generation + +Before any refactor, a one-shot script `scripts/snapshot_api_surface.py` writes `tests/fixtures/api_surface.json` with: +- All public methods of `Quotex` (`dir(Quotex)` filtered to not start with `_`). +- `inspect.signature()` for each β€” parameter names, defaults, annotations. + +The snapshot file is committed and is the source of truth for the surface test. + +### Acceptance Criteria + +- All 4 new tests pass without credentials. +- `pytest tests/ -k "not test_buy and not test_win and not test_login"` passes locally. +- No method removed from `Quotex` public surface. +- No CLI command removed or renamed. + +### What Is Not Tested + +Existing integration tests (`test_buy.py`, `test_win.py`, `test_login.py`, etc.) that require live Quotex credentials remain unchanged and are out of scope for CI gating. + +## Implementation Phases + +Each phase is independently revertible; the repo stays functional between phases. + +### Phase 0 β€” Safety net (1 commit) + +1. Add `scripts/snapshot_api_surface.py` and run it to generate `tests/fixtures/api_surface.json`. +2. Add `tests/test_api_surface.py`, `tests/test_import_compat.py`, `tests/test_cli_smoke.py`. +3. Confirm new tests pass. + +### Phase 1 β€” Wait helpers (1 commit) + +1. Create `pyquotex/_api/__init__.py` (empty) and `pyquotex/_api/_waits.py` with `WaitableSlot` and `wait_until`. +2. Create `pyquotex/exceptions.py` with `QuotexTimeoutError`. (Verified: file does not currently exist.) +3. Add `tests/test_waits.py`. +4. No changes to `stable_api.py` or `api.py` yet. + +### Phase 2 β€” Polling β†’ events (3–4 commits, one per domain) + +1. Add `SlotRegistry` to `QuotexAPI` in `pyquotex/api.py`. Wire WS message handlers to fill slots in addition to current state mutations. +2. Migrate `get_balance` polling β†’ `WaitableSlot`. Run tests. +3. Migrate `buy`/`sell_option`/`open_pending` confirmation polling. Run tests. +4. Migrate `check_win` and `get_result` polling. Run tests. +5. Migrate `start_*_stream` startup waits. Run tests. +6. Migrate `connect`/login retry sleeps to explicit exponential backoff. Run tests. + +Commit boundaries can be one-per-step or grouped by domain; the planner skill will decide the final granularity. + +### Phase 3 β€” Extract mixins (5 commits) + +1. Create `pyquotex/_api/account.py` with `AccountMixin`; move account-related methods; add to `Quotex` bases. Run surface test. +2. Repeat for `trading.py`, `history.py`, `realtime.py`, `assets.py` (one mixin per commit). +3. After all 5, `stable_api.py` contains only `__init__`, `websocket` property, `set_session`, `check_connect`, `_check_connect`, `close`, plus the class declaration with mixins. + +### Phase 4 β€” CLI modularization (3 commits) + +1. Create `pyquotex/cli/` skeleton (`parser.py`, `runtime.py`, `formatters.py`, `__main__.py`) without removing anything from `app.py` yet. +2. Move `cmd_*` functions into `pyquotex/cli/commands/*.py` by domain group. Update imports. CLI smoke test must pass. +3. Reduce root `app.py` to the 5-line shim. CLI smoke test must still pass. + +### Phase 5 β€” Cleanup (1 commit) + +1. Remove dead code from `stable_api.py` (imports no longer needed, etc.). +2. Update [README.md](../../../README.md) with a brief "Architecture" section pointing to the new layout (optional). +3. Bump version in `pyproject.toml` (e.g., 1.0.3 β†’ 1.1.0) β€” this is a non-breaking refactor that adds internal structure. + +## Risk Mitigation + +- **Branch**: all work on `refactor/architecture`; merge to `master` only after Phase 5. +- **Per-phase reversibility**: each phase is a coherent set of commits; reverting one does not require reverting others. +- **Continuous verification**: Phase 0's surface test is run locally (and in CI if/when added) before each subsequent commit. Any drop in the public surface fails the test immediately. +- **Timing regressions**: Phase 2 (event migration) is the highest-risk phase. If the integration tests (`test_buy.py`) reveal timing issues, the slot model can be tuned (longer default timeouts, configurable per-method) without abandoning the refactor. +- **Rollback escape hatch**: if Phase 2 proves unworkable, Phases 3–5 are independent and can proceed without it. + +## Open Questions + +None β€” all major decisions confirmed during brainstorming: +- All three refactor fronts in one spec. +- 100% backwards compatible via mixin-based facade. +- Keep `argparse`, modularize CLI only. +- Replace ALL polling with events. +- 5 domain mixins. +- Minimum regression tests only. + +## Effort Estimate + +14–16 commits total across 5 phases, 3–5 work sessions depending on pacing. diff --git a/pyproject.toml b/pyproject.toml index 9f123a85..5f1d6936 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pyquotex" -version = "1.0.3" +version = "1.1.0" description = "Quotex API Client written in Python." authors = [ { name = "cleiton", email = "cleiton.leonel@gmail.com"}] diff --git a/pyquotex/__main__.py b/pyquotex/__main__.py index 21277f23..3239f1f7 100644 --- a/pyquotex/__main__.py +++ b/pyquotex/__main__.py @@ -1,4 +1,5 @@ -from app import main -import asyncio +"""Module entry: `python -m pyquotex`.""" +from pyquotex.cli.__main__ import cli_main -asyncio.run(main()) +if __name__ == "__main__": + cli_main() diff --git a/pyquotex/_api/__init__.py b/pyquotex/_api/__init__.py new file mode 100644 index 00000000..e5ee56d5 --- /dev/null +++ b/pyquotex/_api/__init__.py @@ -0,0 +1,4 @@ +"""Private domain submodules for pyquotex. + +Not part of the public API. Re-exports are routed through pyquotex.stable_api. +""" diff --git a/pyquotex/_api/_constants.py b/pyquotex/_api/_constants.py new file mode 100644 index 00000000..454eb4ae --- /dev/null +++ b/pyquotex/_api/_constants.py @@ -0,0 +1,12 @@ +"""Shared module-level constants used by stable_api facade and _api mixins.""" +import itertools +import time + +# Default timeout (seconds) for async polling loops +DEFAULT_TIMEOUT: int = 30 + +# Monotonically increasing counter for WebSocket request indices. +# Seeded from the current millisecond timestamp so indices remain +# browser-style large integers while being globally unique across +# all workers and loop iterations within a process (fixes #85). +_request_counter = itertools.count(int(time.time() * 1000)) diff --git a/pyquotex/_api/_waits.py b/pyquotex/_api/_waits.py new file mode 100644 index 00000000..3ce52a58 --- /dev/null +++ b/pyquotex/_api/_waits.py @@ -0,0 +1,148 @@ +"""Event-driven wait primitives that replace asyncio.sleep polling. + +A WaitableSlot is a typed one-shot (re-armable) signal: a consumer awaits +.wait(), and the producer (typically the WS message handler) calls .set(value). + +wait_until() exists for cases where the desired state cannot be signaled +from the WS handler. It still uses short polling internally but enforces +a hard timeout. +""" +from __future__ import annotations + +import asyncio +import random +from typing import Callable, Generic, TypeVar + +T = TypeVar("T") + +DEFAULT_TIMEOUT: float = 10.0 + + +class WaitableSlot(Generic[T]): + """Typed slot a consumer awaits and the producer fills via .set().""" + + __slots__ = ("_value", "_event") + + def __init__(self) -> None: + self._value: T | None = None + self._event = asyncio.Event() + + def set(self, value: T) -> None: + """Store the value and wake any awaiting consumers. + + Raises ValueError if value is None β€” use clear() to reset the slot. + """ + if value is None: + raise ValueError("WaitableSlot.set() does not accept None; use clear() to reset") + self._value = value + self._event.set() + + def clear(self) -> None: + """Reset the slot so subsequent waits block again.""" + self._value = None + self._event.clear() + + def is_set(self) -> bool: + """Return True if the slot currently holds a value.""" + return self._event.is_set() + + # NOTE: raises asyncio.TimeoutError on timeout. Phase 2 consumers wrap + # this in pyquotex.exceptions.QuotexTimeoutError at their call sites so + # the asyncio coupling stays internal to this module. + async def wait(self, timeout: float = DEFAULT_TIMEOUT) -> T: + """Block until set or raise asyncio.TimeoutError on timeout.""" + await asyncio.wait_for(self._event.wait(), timeout=timeout) + return self._value # type: ignore[return-value] + + +# NOTE: raises asyncio.TimeoutError on timeout. Consumers should wrap it +# in pyquotex.exceptions.QuotexTimeoutError at their call sites. +async def wait_until( + predicate: Callable[[], bool], + *, + timeout: float = DEFAULT_TIMEOUT, + poll_interval: float = 0.05, +) -> None: + """Poll predicate() until truthy or raise asyncio.TimeoutError.""" + async def _loop() -> None: + while not predicate(): + await asyncio.sleep(poll_interval) + + await asyncio.wait_for(_loop(), timeout=timeout) + + +async def backoff_sleep( + attempt: int, + *, + base: float = 1.0, + cap: float = 30.0, + jitter: float = 0.1, +) -> None: + """Sleep for an exponentially increasing duration with jitter. + + Used for retry loops where the previous attempt failed. `attempt` is + zero-indexed (0, 1, 2, ...). Delay grows as base * 2**attempt, capped + at `cap` seconds, with multiplicative jitter of Β±jitter (0.1 = Β±10%). + """ + delay = min(cap, base * (2 ** attempt)) + delay = delay * (1.0 + random.uniform(-jitter, jitter)) + await asyncio.sleep(max(0.0, delay)) + + +class SlotRegistry: + """Container of named and keyed WaitableSlots used by QuotexAPI. + + Named slots are pre-created for one-off events (balance update, auth + status change, etc.). Keyed slots are dynamic per-request waits keyed + by request_id / operation_id; they are created lazily and released + once the consumer has read the value. + """ + + def __init__(self) -> None: + # Named slots + self.balance: WaitableSlot[dict] = WaitableSlot() + self.balance_update: WaitableSlot[dict] = WaitableSlot() + self.candle_v2_ready: WaitableSlot[str] = WaitableSlot() + self.historical_ready: WaitableSlot[str] = WaitableSlot() + self.pending_confirm: WaitableSlot[dict] = WaitableSlot() + self.buy_confirm: WaitableSlot[dict] = WaitableSlot() + self.sold_option_confirm: WaitableSlot[dict] = WaitableSlot() + self.training_balance_edit: WaitableSlot[dict] = WaitableSlot() + self.auth_status: WaitableSlot[bool] = WaitableSlot() + + # Keyed slots (created on demand) + self._order_confirm: dict[str, WaitableSlot[dict]] = {} + self._win_result: dict[str, WaitableSlot[dict]] = {} + self._candle_v2: dict[str, WaitableSlot[dict]] = {} + + def order_confirm(self, request_id: str) -> WaitableSlot[dict]: + slot = self._order_confirm.get(request_id) + if slot is None: + slot = WaitableSlot() + self._order_confirm[request_id] = slot + return slot + + def release_order_confirm(self, request_id: str) -> None: + self._order_confirm.pop(request_id, None) + + def win_result(self, operation_id: str) -> WaitableSlot[dict]: + slot = self._win_result.get(operation_id) + if slot is None: + slot = WaitableSlot() + self._win_result[operation_id] = slot + return slot + + def release_win_result(self, operation_id: str) -> None: + self._win_result.pop(operation_id, None) + + def candle_v2(self, asset: str) -> WaitableSlot[dict]: + """Get or create a per-asset slot fired by the candle_v2_data handler.""" + slot = self._candle_v2.get(asset) + if slot is None: + slot = WaitableSlot() + self._candle_v2[asset] = slot + return slot + + def release_candle_v2(self, asset: str) -> None: + """Release the per-asset candle_v2 slot so a new wait creates a fresh one.""" + self._candle_v2.pop(asset, None) diff --git a/pyquotex/_api/account.py b/pyquotex/_api/account.py new file mode 100644 index 00000000..2546f4cb --- /dev/null +++ b/pyquotex/_api/account.py @@ -0,0 +1,227 @@ +"""Account-related methods extracted from Quotex. + +This mixin is composed into Quotex via multiple inheritance. It uses +self.api, self.session_data, self.account_is_demo, etc. β€” all set up in +Quotex.__init__ inside pyquotex/stable_api.py. +""" +from __future__ import annotations + +import asyncio +import logging +import time +from datetime import datetime +from typing import Any + +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 + +logger = logging.getLogger(__name__) + + +class AccountMixin: + """Methods related to account state, profile, balance, and session.""" + + async def connect(self) -> tuple[bool, str]: + """Establishes a connection to the Quotex API.""" + if self.api and await self.check_connect(): + return True, "Already connected" + self.api = QuotexAPI( + self.host, + self.email, + self.password, + self.lang, + resource_path=self.resource_path, + user_data_dir=self.user_data_dir, + proxies=self.proxies, + on_otp_callback=self.on_otp_callback + ) + + self.api.trace_ws = self.debug_ws_enable + self.api.session_data = self.session_data + self.api.current_asset = self.asset_default + self.api.current_period = self.period_default + self.api.state.SSID = self.session_data.get("token") + + if not self.session_data.get("token"): + check, reason = await self.api.authenticate() + if not check: + return check, reason + + check, reason = await self.api.connect(self.account_is_demo == AccountType.DEMO) + if not await self.check_connect(): + logger.error( + "Websocket failed to connect or connection was rejected." + ) + if "token" in self.session_data: + self.session_data["token"] = None + return False, "Websocket connection rejected." + + return check, reason + + async def reconnect(self) -> None: + """Attempts to re-authenticate and refresh the session.""" + if self.api: + await self.api.authenticate() + + def set_account_mode(self, balance_mode: str = "PRACTICE") -> None: + """Set active account `real` or `practice`""" + if balance_mode.upper() == "REAL": + self.account_is_demo = AccountType.REAL + elif balance_mode.upper() == "PRACTICE": + self.account_is_demo = AccountType.DEMO + else: + raise ValueError( + f"Invalid balance mode '{balance_mode}'. " + "Use 'REAL' or 'PRACTICE'." + ) + + async def change_account(self, balance_mode: str, tournament_id: int = 0) -> None: + """Change active account `real` or `practice` or a specific tournament""" + self.account_is_demo = ( + AccountType.REAL if balance_mode.upper() == "REAL" + else AccountType.DEMO + ) + if self.api: + await self.api.change_account(self.account_is_demo, tournament_id=tournament_id) + + async def change_time_offset(self, time_offset: int) -> Any: + """Updates the timezone/time offset on the server.""" + if self.api: + return await self.api.change_time_offset(time_offset) + return None + + async def edit_practice_balance( + self, + amount: float | int | None = None, + timeout: int = DEFAULT_TIMEOUT + ) -> dict[str, Any]: + """Refills the demo account balance.""" + if self.api is None: + raise RuntimeError("API not initialized") + + self.api.training_balance_edit_request = None + await self.api.edit_training_balance( + amount if amount is not None else 0 + ) + # TODO(refactor/architecture Phase 2.4): polling here cannot be migrated + # to SlotRegistry until we identify the WS event that should populate + # self.api.training_balance_edit_request. No producer exists in + # pyquotex/api.py:_on_message, so this method currently only exits via + # the timeout branch below. Investigate live WS traffic when refilling + # demo balance to find the correct producer event, then either: + # (a) wire that handler to fire self.slots.training_balance_edit, or + # (b) repoint this method to wait on self.slots.balance and return + # the new balance dict (changes return shape β€” would be a + # breaking change for any caller relying on the request payload). + start = time.time() + while self.api.training_balance_edit_request is None: + if time.time() - start > timeout: + raise TimeoutError( + "Timeout waiting for practice balance edit response." + ) + await asyncio.sleep(0.2) + return self.api.training_balance_edit_request + + async def get_balance(self, timeout: int = DEFAULT_TIMEOUT) -> float: + """Get account balance using a true event-driven approach.""" + if not self.api or not await self.check_connect(): + raise RuntimeError("Not connected to Quotex") + + if self.api.account_balance is not None: + if self.api.account_type == AccountType.DEMO: + balance = self.api.account_balance.get("demoBalance", 0) + else: + balance = self.api.account_balance.get("liveBalance", 0) + return float(f"{truncate(balance + self.get_profit(), 2):.2f}") + + if self.api.account_balance is None: + try: + await self.api.slots.balance.wait(timeout=timeout) + except asyncio.TimeoutError: + raise QuotexTimeoutError( + f"get_balance timed out after {timeout}s" + ) + + if self.api.account_balance is None: + return 0.0 + + if self.api.account_type == AccountType.DEMO: + balance = self.api.account_balance.get("demoBalance", 0) + else: + balance = self.api.account_balance.get("liveBalance", 0) + return float(f"{truncate(balance + self.get_profit(), 2):.2f}") + + async def get_profile(self) -> Any: + """Retrieves and parses the user profile data.""" + if self.api: + return await self.api.get_profile() + return None + + async def get_server_time(self) -> int: + """Retrieves and syncs the server time.""" + if self.api is None: + return int(time.time()) + + user_settings = await self.get_profile() + offset_zone = user_settings.offset if user_settings else 0 + self.api.timesync.server_timestamp = ( + expiration.get_server_timer(offset_zone) + ) + return self.api.timesync.server_timestamp + + async def start_remaing_time(self) -> None: + """Debug helper to log the remaining time until the next server + expiration.""" + if self.api is None: + return + + now_stamp = datetime.fromtimestamp(expiration.get_timestamp()) + expiration_stamp = datetime.fromtimestamp( + self.api.timesync.server_timestamp + ) + remaing_time = int((expiration_stamp - now_stamp).total_seconds()) + while remaing_time >= 0: + remaing_time -= 1 + logger.debug("Remaining %d seconds...", max(remaing_time, 0)) + await asyncio.sleep(1) + + async def store_settings_apply( + self, + asset: str = "EURUSD", + period: int = 0, + time_mode: str = "TIMER", + deal: int = 5, + percent_mode: bool = False, + percent_deal: int = 1, + timeout: int = DEFAULT_TIMEOUT + ) -> dict[str, Any]: + """Applies trading settings and retrieves updated settings.""" + if self.api is None: + raise RuntimeError("API not initialized") + + is_fast_option = False if time_mode.upper() == "TIMER" else True + self.api.current_asset = asset + await self.api.settings_apply( + asset, + period, + is_fast_option=is_fast_option, + deal=deal, + percent_mode=percent_mode, + percent_deal=percent_deal + ) + await asyncio.sleep(0.2) + start = time.time() + while True: + if self.api.settings_list: + investments_settings = self.api.settings_list + break + if time.time() - start > timeout: + raise TimeoutError("Timeout waiting for settings response.") + await asyncio.sleep(0.2) + + return investments_settings diff --git a/pyquotex/_api/assets.py b/pyquotex/_api/assets.py new file mode 100644 index 00000000..6af9242b --- /dev/null +++ b/pyquotex/_api/assets.py @@ -0,0 +1,186 @@ +"""Asset metadata, instruments, and payout methods extracted from Quotex. + +This mixin is composed into Quotex via multiple inheritance. It uses +self.api, self.codes_asset, etc. β€” all set up in Quotex.__init__ inside +pyquotex/stable_api.py. +""" +from __future__ import annotations + +import asyncio +import logging +from typing import Any + +from pyquotex._api._constants import DEFAULT_TIMEOUT +from pyquotex._api._waits import wait_until + +logger = logging.getLogger(__name__) + + +class AssetsMixin: + """Methods related to instruments, assets metadata, and payouts.""" + + async def get_instruments( + self, timeout: int = DEFAULT_TIMEOUT + ) -> list[Any]: + """Get instruments using a true event-driven approach.""" + if not self.api or not await self.check_connect(): + return [] + + if self.api.instruments and len(self.api.instruments) > 0: + return self.api.instruments + + try: + # Request instruments explicitly + await self.api.get_instruments() + # Wait for WebSocket event signaling instruments arrival + await self.api.event_registry.wait_event( + 'instruments_ready', timeout=timeout + ) + + if not self.api.instruments: + # Try one last wait if empty β€” event-driven up to 2s + try: + await wait_until( + lambda: bool( + self.api and self.api.instruments + ), + timeout=2, + poll_interval=0.1, + ) + except asyncio.TimeoutError: + pass + + return self.api.instruments or [] + except TimeoutError: + logger.error( + "Timeout waiting for instruments after %ds", timeout + ) + return [] + + def get_all_asset_name(self) -> list[list[str]] | None: + """ + Retrieves names of all available assets. + + Returns: + list: List of assets with ID and display name. + """ + if self.api and self.api.instruments: + return [ + [i[1], i[2].replace("\n", "")] + for i in self.api.instruments + ] + return None + + async def get_available_asset( + self, asset_name: str, force_open: bool = False + ) -> tuple[str, Any]: + """ + Retrieves detailed information for an asset if it is currently open. + + Args: + asset_name (str): Asset name. + force_open (bool, optional): Try to find the OTC version if closed. + Defaults to False. + + Returns: + tuple: (Final asset name, Asset status info). + """ + _, asset_open = await self.check_asset_open(asset_name) + if force_open and (not asset_open or not asset_open[2]): + condition_otc = "otc" not in asset_name + refactor_asset = asset_name.replace("_otc", "") + asset_name = ( + f"{asset_name}_otc" if condition_otc else refactor_asset + ) + _, asset_open = await self.check_asset_open(asset_name) + + return asset_name, asset_open + + async def check_asset_open( + self, asset_name: str + ) -> tuple[list[Any] | None, tuple[Any, Any, Any]]: + """ + Checks if a specific asset is currently available for trading. + + Args: + asset_name (str): The name of the asset. + + Returns: + tuple: (Raw instrument data, Formatted status info). + """ + instruments = await self.get_instruments() + for i in instruments: + if asset_name == i[1]: + if self.api: + self.api.current_asset = asset_name + return i, (i[0], i[2].replace("\n", ""), i[14]) + + return None, (None, None, None) + + async def get_all_assets(self) -> dict[str, str]: + """ + Retrieves a mapping of all asset names to their internal codes. + + Returns: + dict: Mapping of asset names to codes. + """ + instruments = await self.get_instruments() + for i in instruments: + if i[0] != "": + self.codes_asset[i[1]] = i[0] + + return self.codes_asset + + def get_payment(self) -> dict[str, Any]: + """Retrieves the payout/payment percentages for all instruments.""" + if self.api is None: + return {} + + assets_data = {} + for i in self.api.instruments: + assets_data[i[2].replace("\n", "")] = { + "turbo_payment": i[18], + "payment": i[5], + "profit": { + "1M": i[-9], + "5M": i[-8] + }, + "open": i[14] + } + + return assets_data + + def get_payout_by_asset( + self, asset_name: str, timeframe: str = "1" + ) -> float | dict[str, Any] | None: + """Retrieves the payout percentage for a specific asset and + timeframe.""" + if self.api is None: + return None + + assets_data = {} + for i in self.api.instruments: + if asset_name == i[1]: + assets_data[i[1].replace("\n", "")] = { + "turbo_payment": i[18], + "payment": i[5], + "profit": { + "24H": i[-10], + "1M": i[-9], + "5M": i[-8] + }, + "open": i[14] + } + break + + data = assets_data.get(asset_name) + if data is None: + return None + + if timeframe == "all": + return data.get("profit") + + profit = data.get("profit") + if profit: + return profit.get(f"{timeframe}M") + return None diff --git a/pyquotex/_api/history.py b/pyquotex/_api/history.py new file mode 100644 index 00000000..92ccb393 --- /dev/null +++ b/pyquotex/_api/history.py @@ -0,0 +1,340 @@ +"""History/candle-related methods extracted from Quotex. + +This mixin is composed into Quotex via multiple inheritance. It uses +self.api, self.codes_asset, etc. β€” all set up in Quotex.__init__ inside +pyquotex/stable_api.py. +""" +from __future__ import annotations + +import asyncio +import logging +import time +from typing import Any, Callable + +from pyquotex import expiration +from pyquotex._api._constants import DEFAULT_TIMEOUT, _request_counter +from pyquotex.utils import json_utils as json +from pyquotex.utils.processor import ( + calculate_candles, + process_candles_v2, + merge_candles, +) + +logger = logging.getLogger(__name__) + + +class HistoryMixin: + """Methods related to candle data, historical queries, and trade history.""" + + async def get_candles( + self, + asset: str, + end_from_time: float | None, + offset: int, + period: int, + progressive: bool = False, + timeout: int = DEFAULT_TIMEOUT + ) -> list[dict[str, Any]] | None: + """Retrieves candles for a specific asset.""" + if self.api is None: + return None + + if end_from_time is None: + end_from_time = time.time() + + index = expiration.get_timestamp() + self.api.candles.candles_data = None + + # Clear event state before requesting data to prevent + # race with WS response + await self.api.event_registry.clear_event(f'candles_ready_{asset}') + + await self.start_candles_stream(asset, period) + await self.api.get_candles(asset, index, end_from_time, offset, period) + + try: + # Wait for WebSocket event signaling candles' arrival + history_data = await self.api.event_registry.wait_event( + f'candles_ready_{asset}', timeout=timeout + ) + except TimeoutError: + logger.error( + "Timeout waiting for candles for %s after %ds", + asset, timeout + ) + return None + + # Pass the asset-specific history directly to avoid + # multi-asset state races + candles = self.prepare_candles(asset, period, history_data) + + if progressive: + return self.api.historical_candles.get("data", {}) + + return candles + + async def _fetch_historical_batch( + self, + asset: str, + fetch_time: int, + offset: int, + period: int, + index: int, + timeout: int + ) -> dict[str, Any] | None: + """Low-level batch fetcher for a specific time point and index.""" + if self.api is None: + return None + + payload = { + "asset": asset, + "index": index, + "time": fetch_time, + "offset": offset, + "period": period + } + ws_msg = f'42["history/load",{json.dumps_str(payload)}]' + + # Clear specific event to ensure fresh wait + event_name = f'candles_ready_{asset}_{index}' + await self.api.event_registry.clear_event(event_name) + + await self.api.send_websocket_request(ws_msg) + + try: + return await self.api.event_registry.wait_event( + event_name, timeout=timeout + ) + except TimeoutError: + logger.warning( + "Batch fetch timeout at %d (index %d) for %s", + fetch_time, index, asset + ) + return None + + def _parse_historical_candles( + self, raw_data: dict[str, Any] + ) -> list[dict[str, Any]]: + """Standardizes raw candle data into a uniform list of dicts.""" + raw_candles = raw_data.get("data", []) or raw_data.get("candles", []) + if not raw_candles: + return [] + + parsed = [] + for c in raw_candles: + if isinstance(c, list) and len(c) >= 5: + parsed.append({ + "time": int(c[0]), + "open": float(c[1]), + "close": float(c[2]), + "high": float(c[3]), + "low": float(c[4]) + }) + elif isinstance(c, dict) and "time" in c: + parsed.append(c) + return parsed + + # https://t.me/pyquotex/1/16064 + # https://github.com/usmanch96/quotex-historical-data + async def get_historical_candles( + self, + asset: str, + amount_of_seconds: int, + period: int, + timeout: int = DEFAULT_TIMEOUT, + max_workers: int = 5, + progress_callback: Callable[[int, int, int, str], None] | None = None + ) -> list[dict[str, Any]]: + """ + Retrieves extensive historical candle data using a hybrid parallel-sequential approach. + Divides the total time range into blocks assigned to parallel workers. + Each worker fetches its block sequentially to ensure no gaps. + """ + all_candles: dict[int, dict[str, Any]] = {} + current_time = int(time.time()) + target_start_time = current_time - amount_of_seconds + + # Divide total range into large blocks for each worker + block_size = amount_of_seconds // max_workers + chunk_seconds = period * 200 # Request size per batch + semaphore = asyncio.Semaphore(max_workers) + + async def worker(start_t: int, end_t: int, worker_id: int) -> list[dict[str, Any]]: + worker_candles = {} + worker_label = f"Worker-{worker_id}" + async with semaphore: + oldest_t = start_t + while oldest_t > end_t: + # Use a monotonically-increasing counter so that parallel + # workers and back-to-back iterations within the same worker + # never produce the same index β€” prevents event-registry + # key collisions where one worker steals another's response. + index = next(_request_counter) + + batch_data = await self._fetch_historical_batch( + asset, oldest_t, chunk_seconds, period, index, timeout + ) + + if not batch_data: + # Gap or error, jump back to try continuing + oldest_t -= chunk_seconds + continue + + new_batch = self._parse_historical_candles(batch_data) + if not new_batch: + oldest_t -= chunk_seconds + continue + + # Process and find new boundary + batch_times = [] + for c in new_batch: + ts = c['time'] + if ts >= end_t and ts <= start_t: + worker_candles[ts] = c + batch_times.append(ts) + + if not batch_times: + oldest_t -= chunk_seconds + continue + + batch_times.sort() + new_oldest = batch_times[0] + + if progress_callback: + # Report progress based on how much of the block is covered + progress_callback( + start_t - new_oldest, + start_t - end_t, + len(worker_candles), + worker_label + ) + + if new_oldest >= oldest_t: + oldest_t -= chunk_seconds + else: + oldest_t = new_oldest + + # Small throttle + await asyncio.sleep(0.1) + + return list(worker_candles.values()) + + await self.start_candles_stream(asset, period) + + # Launch workers for each block + tasks = [] + for i in range(max_workers): + s = current_time - (i * block_size) + e = max(target_start_time, s - block_size) + tasks.append(worker(s, e, i)) + + results = await asyncio.gather(*tasks) + + # Merge results and deduplicate + for batch in results: + for c in batch: + all_candles[c['time']] = c + + return sorted(all_candles.values(), key=lambda x: x['time']) + + async def get_candles_deep( + self, *args: Any, **kwargs: Any + ) -> list[dict[str, Any]]: + """Deprecated alias for get_historical_candles.""" + logger.warning( + "get_candles_deep is deprecated, " + "use get_historical_candles instead." + ) + return await self.get_historical_candles(*args, **kwargs) + + async def get_history_line( + self, + asset: str, + end_from_time: float, + offset: int, + timeout: int = DEFAULT_TIMEOUT + ) -> dict[str, Any] | None: + """Retrieves historical price line data for an asset.""" + if self.api is None: + return None + + index = expiration.get_timestamp() + self.api.current_asset = asset + # Reset to None (not {}) so the poll loop below can detect arrival. + # An empty dict is a valid response; None is the sentinel for + # "not yet received". + self.api.historical_candles = None + await self.start_candles_stream(asset) + await self.api.get_history_line( + self.codes_asset[asset], index, end_from_time, offset + ) + # TODO(refactor/architecture Phase 2.7): polling here cannot be migrated + # to SlotRegistry until we identify the WS event that should populate + # self.api.historical_candles. No producer exists in + # pyquotex/api.py:_on_message, so this method currently only exits via + # the timeout branch below. Same situation as edit_practice_balance + # and sell_option β€” investigate WS traffic when calling get_history_line + # to find the correct producer event. + start_time = time.time() + while await self.check_connect() and self.api.historical_candles is None: + if time.time() - start_time > timeout: + logger.error( + "Timeout waiting for history line data for %s.", + asset + ) + return None + await asyncio.sleep(0.2) + return self.api.historical_candles + + async def get_candle_v2( + self, asset: str, period: int, timeout: int = DEFAULT_TIMEOUT + ) -> list[dict[str, Any]] | None: + """Retrieves candles using the v2 API path.""" + if self.api is None: + return None + + # Reset the slot AND the data dict β€” both serve as sentinels. + self.api.candle_v2_data[asset] = None + self.api.slots.release_candle_v2(asset) + await self.start_candles_stream(asset, period) + try: + await self.api.slots.candle_v2(asset).wait(timeout=timeout) + except asyncio.TimeoutError: + logger.error( + "Timeout waiting for get_candle_v2 data for %s.", + asset + ) + return None + candles = self.prepare_candles(asset, period) + return candles + + def prepare_candles( + self, + asset: str, + period: int, + history: list[Any] | None = None + ) -> list[dict[str, Any]]: + """Prepare candles data for a specified asset.""" + if self.api is None: + return [] + + # Use provided history if available (from event response), + # otherwise fallback to shared state + history_data = ( + history if history is not None else self.api.candles.candles_data + ) + candles_data = calculate_candles(history_data, period) + candles_v2_data = process_candles_v2( + self.api.candle_v2_data, asset, candles_data + ) + new_candles = merge_candles(candles_v2_data) + + return new_candles + + async def get_trader_history( + self, account_type: int, page_number: int + ) -> dict[str, Any]: + """Retrieves trade history for a specific account and page.""" + if self.api: + return await self.api.get_trader_history(account_type, page_number) + return {} diff --git a/pyquotex/_api/realtime.py b/pyquotex/_api/realtime.py new file mode 100644 index 00000000..d90e3fff --- /dev/null +++ b/pyquotex/_api/realtime.py @@ -0,0 +1,603 @@ +"""Real-time streaming and indicator methods extracted from Quotex. + +This mixin is composed into Quotex via multiple inheritance. It uses +self.api, self.codes_asset, etc. β€” all set up in Quotex.__init__ inside +pyquotex/stable_api.py. +""" +from __future__ import annotations + +import asyncio +import logging +import time +from typing import Any, Callable + +from pyquotex._api._constants import DEFAULT_TIMEOUT +from pyquotex.utils.indicators import TechnicalIndicators +from pyquotex.utils.processor import ( + process_tick, + aggregate_candle, +) + +logger = logging.getLogger(__name__) + + +class RealtimeMixin: + """Real-time streaming and indicator methods.""" + + async def calculate_indicator( + self, + asset: str, + indicator: str, + params: dict[str, Any] | None = None, + history_size: int = 3600, + timeframe: int = 60 + ) -> dict[str, Any]: + """Calcula indicadores tΓ©cnicos para um ativo dado.""" + if params is None: + params = {} + + valid_timeframes = [60, 300, 900, 1800, 3600, 7200, 14400, 86400] + if timeframe not in valid_timeframes: + return { + "error": ( + f"Timeframe invΓ‘lido. " + f"Valores permitidos: {valid_timeframes}" + ) + } + + adjusted_history = max(history_size, timeframe * 50) + + candles = await self.get_candles( + asset, time.time(), adjusted_history, timeframe + ) + + if not candles: + return { + "error": f"NΓ£o hΓ‘ dados disponΓ­veis para o ativo {asset}" + } + + prices = [float(candle["close"]) for candle in candles] + highs = [float(candle["high"]) for candle in candles] + lows = [float(candle["low"]) for candle in candles] + timestamps = [candle["time"] for candle in candles] + + indicators = TechnicalIndicators() + indicator = indicator.upper() + + try: + if indicator == "RSI": + period = params.get("period", 14) + values = indicators.calculate_rsi(prices, period) + return { + "rsi": values, + "current": values[-1] if values else None, + "history_size": len(values), + "timeframe": timeframe, + "timestamps": ( + timestamps[-len(values):] if values else [] + ) + } + + elif indicator == "MACD": + fast_period = params.get("fast_period", 12) + slow_period = params.get("slow_period", 26) + signal_period = params.get("signal_period", 9) + macd_data = indicators.calculate_macd( + prices, fast_period, slow_period, signal_period + ) + macd_data["timeframe"] = timeframe + macd_data["timestamps"] = ( + timestamps[-len(macd_data["macd"]):] + if macd_data["macd"] + else [] + ) + return macd_data + + elif indicator == "SMA": + period = params.get("period", 20) + values = indicators.calculate_sma(prices, period) + return { + "sma": values, + "current": values[-1] if values else None, + "history_size": len(values), + "timeframe": timeframe, + "timestamps": ( + timestamps[-len(values):] if values else [] + ) + } + + elif indicator == "EMA": + period = params.get("period", 20) + values = indicators.calculate_ema(prices, period) + return { + "ema": values, + "current": values[-1] if values else None, + "history_size": len(values), + "timeframe": timeframe, + "timestamps": ( + timestamps[-len(values):] if values else [] + ) + } + + elif indicator == "BOLLINGER": + period = params.get("period", 20) + num_std = params.get("std", 2) + bb_data = indicators.calculate_bollinger_bands( + prices, period, num_std + ) + bb_data["timeframe"] = timeframe + bb_data["timestamps"] = ( + timestamps[-len(bb_data["middle"]):] + if bb_data["middle"] + else [] + ) + return bb_data + + elif indicator == "STOCHASTIC": + k_period = params.get("k_period", 14) + d_period = params.get("d_period", 3) + stoch_data = indicators.calculate_stochastic( + prices, highs, lows, k_period, d_period + ) + stoch_data["timeframe"] = timeframe + stoch_data["timestamps"] = ( + timestamps[-len(stoch_data["k"]):] + if stoch_data["k"] + else [] + ) + return stoch_data + + elif indicator == "ATR": + period = params.get("period", 14) + values = indicators.calculate_atr(highs, lows, prices, period) + return { + "atr": values, + "current": values[-1] if values else None, + "history_size": len(values), + "timeframe": timeframe, + "timestamps": ( + timestamps[-len(values):] if values else [] + ) + } + + elif indicator == "ADX": + period = params.get("period", 14) + adx_data = indicators.calculate_adx( + highs, lows, prices, period + ) + adx_data["timeframe"] = timeframe + adx_data["timestamps"] = ( + timestamps[-len(adx_data["adx"]):] + if adx_data["adx"] + else [] + ) + return adx_data + + elif indicator == "ICHIMOKU": + tenkan_period = params.get("tenkan_period", 9) + kijun_period = params.get("kijun_period", 26) + senkou_b_period = params.get("senkou_b_period", 52) + ichimoku_data = indicators.calculate_ichimoku( + highs, lows, tenkan_period, kijun_period, senkou_b_period + ) + ichimoku_data["timeframe"] = timeframe + ichimoku_data["timestamps"] = ( + timestamps[-len(ichimoku_data["tenkan"]):] + if ichimoku_data["tenkan"] + else [] + ) + return ichimoku_data + + else: + return {"error": f"Indicador '{indicator}' nΓ£o suportado"} + + except Exception as e: + return {"error": f"Erro calculando o indicador: {str(e)}"} + + async def subscribe_indicator( + self, + asset: str, + indicator: str, + params: dict[str, Any] | None = None, + callback: Callable[[dict[str, Any]], Any] | None = None, + timeframe: int = 60 + ) -> None: + """ + Subscribes to real-time indicator updates with high performance. + + Features: + - Event-driven: Recalculates only when a new candle is generated. + - Efficient: Pre-loads history and maintains local data buffers. + - Robust: Properly handles all indicator parameters and edge cases. + """ + if params is None: + params = {} + if not callback: + raise ValueError("Callback function must be provided") + + indicator_upper = indicator.upper() + min_periods = { + "RSI": 14, "MACD": 26, "BOLLINGER": 20, "STOCHASTIC": 14, + "ADX": 14, "ATR": 14, "SMA": 20, "EMA": 20, "ICHIMOKU": 52 + } + required_periods = min_periods.get(indicator_upper, 20) + + try: + await self.start_candles_stream(asset, timeframe) + + # 1. Initial Data Loading + # Fetch history to satisfy the indicator's window + history = await self.get_candles( + asset, + time.time(), + timeframe * (required_periods + 20), + timeframe + ) + + if not history: + logger.warning("No history found for %s, waiting...", asset) + history = [] + + # Maintain local buffers to avoid repeated sorting/conversions + prices = [float(c["close"]) for c in history] + highs = [float(c["high"]) for c in history] + lows = [float(c["low"]) for c in history] + last_ts = history[-1]["time"] if history else 0 + + ti = TechnicalIndicators() + event_name = f"candle_generated_{asset}_{timeframe}" + + while await self.check_connect(): + try: + # 2. Wait for New Candle Event + try: + # Wait for the next candle closure + msg_data = await self.api.event_registry.wait_event( + event_name, timeout=timeframe + 10 + ) + except TimeoutError: + # Check if data arrived but event was missed + msg_data = self.api.candle_generated_check[ + str(asset) + ].get(timeframe) + + if not msg_data: + await asyncio.sleep(1) + continue + + current_ts = msg_data.get("index", 0) + if current_ts <= last_ts: + await asyncio.sleep(1) + continue + + # 3. Update Buffers with New Closed Candle + prices.append(float(msg_data["close"])) + highs.append(float(msg_data["high"])) + lows.append(float(msg_data["low"])) + last_ts = current_ts + + # Cap buffers to prevent memory leaks (e.g., 500 candles) + if len(prices) > 500: + prices = prices[-500:] + highs = highs[-500:] + lows = lows[-500:] + + if len(prices) < required_periods: + continue + + # 4. Calculate Indicator + result: dict[str, Any] = { + "time": last_ts, + "timeframe": timeframe, + "asset": asset, + "indicator": indicator_upper + } + + if indicator_upper == "RSI": + period = params.get("period", 14) + vals = ti.calculate_rsi(prices, period) + result["value"] = vals[-1] if vals else None + result["all_values"] = vals + + elif indicator_upper == "MACD": + fast = params.get("fast_period", 12) + slow = params.get("slow_period", 26) + sig = params.get("signal_period", 9) + result.update(ti.calculate_macd(prices, fast, slow, sig)) + + elif indicator_upper == "BOLLINGER": + period = params.get("period", 20) + std = params.get("std", 2) + result.update( + ti.calculate_bollinger_bands(prices, period, std) + ) + + elif indicator_upper == "STOCHASTIC": + k = params.get("k_period", 14) + d = params.get("d_period", 3) + result.update( + ti.calculate_stochastic(prices, highs, lows, k, d) + ) + + elif indicator_upper == "SMA": + period = params.get("period", 20) + vals = ti.calculate_sma(prices, period) + result["value"] = vals[-1] if vals else None + result["all_values"] = vals + + elif indicator_upper == "EMA": + period = params.get("period", 20) + vals = ti.calculate_ema(prices, period) + result["value"] = vals[-1] if vals else None + result["all_values"] = vals + + elif indicator_upper == "ADX": + period = params.get("period", 14) + result.update( + ti.calculate_adx(highs, lows, prices, period) + ) + + elif indicator_upper == "ATR": + period = params.get("period", 14) + vals = ti.calculate_atr(highs, lows, prices, period) + result["value"] = vals[-1] if vals else None + result["all_values"] = vals + + elif indicator_upper == "ICHIMOKU": + t = params.get("tenkan", 9) + k = params.get("kijun", 26) + s = params.get("senkou", 52) + result.update( + ti.calculate_ichimoku(highs, lows, t, k, s) + ) + + else: + result["error"] = f"Indicator {indicator} not supported" + + # 5. Trigger Callback + await callback(result) + + except Exception as e: + logger.warning("Error in indicator loop: %s", e) + await asyncio.sleep(1) + + finally: + try: + await self.stop_candles_stream(asset) + except Exception: + pass + + async def start_candles_stream( + self, asset: str = "EURUSD", period: int = 0 + ) -> None: + """Start streaming candle data for a specified asset.""" + if self.api: + self.api.current_asset = asset + await self.api.subscribe_realtime_candle(asset, period) + await self.api.chart_notification(asset) + await self.api.follow_candle(asset) + + 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) + + async def start_signals_data(self) -> None: + """Subscribes to the global trading signals stream.""" + if self.api: + await self.api.signals_subscribe() + + async def opening_closing_current_candle( + self, asset: str, period: int = 0 + ) -> dict[str, Any]: + """Calculates the opening, closing, and remaining time for the + current candle.""" + candles_data: dict[int, Any] = {} + candles_tick = await self.get_realtime_candles(asset) + logger.debug("Candles tick data: %s", candles_tick) + # aggregate_candle expects dict[int, Any] for tick + # This part might need adjustment depending on what + # get_realtime_candles returns + aggregate = aggregate_candle( + candles_tick if isinstance(candles_tick, dict) else {}, + candles_data + ) + logger.debug("Aggregated candle: %s", aggregate) + if not aggregate: + return {} + candles_dict = list(aggregate.values())[0] + candles_dict['opening'] = candles_dict.pop('timestamp') + candles_dict['closing'] = candles_dict['opening'] + period + candles_dict['remaining'] = candles_dict['closing'] - int(time.time()) + return candles_dict + + async def start_realtime_price( + self, + asset: str, + period: int = 0, + timeout: int = DEFAULT_TIMEOUT + ) -> dict[str, Any]: + """Starts following real-time price for an asset.""" + if self.api is None: + raise RuntimeError("API not initialized") + + await self.start_candles_stream(asset, period) + start = time.time() + while True: + if self.api.realtime_price.get(asset): + return self.api.realtime_price + if time.time() - start > timeout: + raise TimeoutError( + f"Timeout waiting for realtime price data for {asset}." + ) + await asyncio.sleep(0.2) + + async def start_realtime_sentiment( + self, + asset: str, + period: int = 0, + timeout: int = DEFAULT_TIMEOUT + ) -> dict[str, Any]: + """Starts following real-time trader sentiment for an asset.""" + if self.api is None: + raise RuntimeError("API not initialized") + + await self.start_candles_stream(asset, period) + start = time.time() + while True: + if self.api.realtime_sentiment.get(asset): + return self.api.realtime_sentiment[asset] + if time.time() - start > timeout: + raise TimeoutError( + f"Timeout waiting for realtime sentiment data for {asset}." + ) + await asyncio.sleep(0.2) + + async def start_realtime_candle( + self, + asset: str, + period: int = 0, + timeout: int = DEFAULT_TIMEOUT + ) -> dict[int, Any]: + """Starts following and processing real-time candle ticks for + an asset.""" + if self.api is None: + raise RuntimeError("API not initialized") + + await self.start_candles_stream(asset, period) + data: dict[int, Any] = {} + start = time.time() + while True: + candle_data = self.api.realtime_candles.get(asset) + if candle_data: + if isinstance(candle_data, list) and len(candle_data) >= 4: + return process_tick(candle_data, period, data) + return data + if time.time() - start > timeout: + raise TimeoutError( + f"Timeout waiting for realtime candle data for {asset}." + ) + await asyncio.sleep(0.2) + + async def get_realtime_candles( + self, asset: str + ) -> list[Any] | dict[Any, Any]: + """Retrieves current real-time price history for an asset from + shared state.""" + if self.api: + return self.api.realtime_candles.get(asset, []) + return [] + + async def get_realtime_sentiment(self, asset: str) -> dict[str, Any]: + """Retrieves current sentiment data for an asset from shared state.""" + if self.api: + return self.api.realtime_sentiment.get(asset, {}) + return {} + + async def get_realtime_price(self, asset: str) -> list[dict[str, Any]]: + """Retrieves current real-time price history for an asset from + shared state.""" + if self.api: + # Convert deque to list for compatibility with existing strategies + return list(self.api.realtime_price.get(asset, [])) + return [] + + def get_signal_data(self) -> dict[str, Any]: + """Retrieves the list of active signals received via signals stream.""" + if self.api: + return self.api.signal_data + return {} + + async def start_candles_one_stream(self, asset: str, size: int) -> bool: + """Internal helper to start a single candle stream.""" + if self.api is None: + return False + + if not (str(asset + "," + str(size)) in self.subscribe_candle): + self.subscribe_candle.append((asset + "," + str(size))) + start = time.time() + # This part assumes api has these attributes, might need check + if not hasattr(self.api, "candle_generated_check"): + return False + + self.api.candle_generated_check[str(asset)][int(size)] = {} + # Send the subscribe request exactly once before polling. + # Calling follow_candle() inside the loop would spam the server + # with up to 100 subscribe messages (20 s / 0.2 s) before data + # arrives β€” a ban/rate-limit risk explicitly warned about in README. + try: + await self.api.follow_candle(self.codes_asset[asset]) + except Exception as e: + logger.error('**error** start_candles_stream reconnect: %s', e) + await self.connect() + while True: + if time.time() - start > 20: + logger.error( + '**error** start_candles_one_stream late for 20 sec' + ) + return False + try: + if self.api.candle_generated_check[str(asset)][int(size)]: + return True + except (KeyError, TypeError): + pass + await asyncio.sleep(0.2) + + async def start_candles_all_size_stream(self, asset: str) -> bool: + """Internal helper to subscribe to all candle sizes for an asset.""" + if self.api is None: + return False + + if not hasattr(self.api, "candle_generated_all_size_check"): + return False + + 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)) + start = time.time() + while await self.check_connect(): + if self.api is None: break + if time.time() - start > 20: + logger.error( + f'**error** fail {asset} ' + 'start_candles_all_size_stream late for 10 sec' + ) + return False + try: + if self.api.candle_generated_all_size_check[str(asset)]: + return True + except (KeyError, TypeError): + pass + try: + # Assuming api has subscribe_all_size + if hasattr(self.api, "subscribe_all_size"): + self.api.subscribe_all_size(self.codes_asset[asset]) + except Exception as e: + logger.error( + '**error** start_candles_all_size_stream reconnect: %s', e + ) + await self.connect() + await asyncio.sleep(0.2) + return False + + async def start_mood_stream( + self, asset: str, instrument: str = "turbo-option" + ) -> None: + """Internal helper to start the mood (sentiment) stream.""" + if self.api is None: + return + + if asset not in self.subscribe_mood: + self.subscribe_mood.append(asset) + while True: + if self.api is None: break + if hasattr(self.api, "subscribe_Traders_mood"): + self.api.subscribe_Traders_mood(asset, instrument) + try: + if hasattr(self.api, "traders_mood"): + asset_code = self.codes_asset[asset] + self.api.traders_mood[asset_code] = asset_code + break + finally: + await asyncio.sleep(0.2) diff --git a/pyquotex/_api/trading.py b/pyquotex/_api/trading.py new file mode 100644 index 00000000..1759b9d0 --- /dev/null +++ b/pyquotex/_api/trading.py @@ -0,0 +1,214 @@ +"""Trading-related methods extracted from Quotex. + +This mixin is composed into Quotex via multiple inheritance. It uses +self.api, self.account_is_demo, etc. β€” all set up in Quotex.__init__ +inside pyquotex/stable_api.py. +""" +from __future__ import annotations + +import asyncio +import logging +import time +from typing import Any + +from pyquotex import expiration +from pyquotex._api._constants import DEFAULT_TIMEOUT +from pyquotex.utils.account_type import AccountType + +logger = logging.getLogger(__name__) + + +class TradingMixin: + """Methods related to placing trades and reading their results.""" + + async def buy( + self, + amount: float, + asset: str, + direction: str, + duration: int, + time_mode: str = "TIME" + ) -> tuple[bool, Any]: + """ + Places a buy order for a specified asset, direction, and duration. + Waits for WebSocket confirmation of the buy and returns the result. + """ + if self.api is None: + return False, "API not initialized" + + self.api.buy_id = None + self.api.buy_successful = None + request_id = expiration.get_timestamp() + is_fast_option = time_mode.upper() == "TIME" + + # Clear slot state before requesting buy to prevent + # race with WS response + self.api.slots.buy_confirm.clear() + + # Ensure price data is arriving and server is synced + await self.start_realtime_price(asset, duration) + await self.get_server_time() + await self.api.settings_apply(asset, duration, is_fast_option) + + await self.api.buy( + amount, asset, direction, duration, request_id, is_fast_option, time_mode + ) + + timeout = duration + 5 if duration else DEFAULT_TIMEOUT + + if self.api.buy_id is None: + try: + event_data = await self.api.slots.buy_confirm.wait(timeout=timeout) + except asyncio.TimeoutError: + logger.error("Timeout waiting for buy confirmation.") + return False, "Timeout" + else: + event_data = {"id": self.api.buy_id} + + if self.api.state.check_websocket_if_error: + return False, self.api.state.websocket_error_reason + + if ( + event_data + and isinstance(event_data, dict) + and "error" in event_data + ): + return False, event_data["error"] + + return True, event_data + + async def open_pending( + self, + amount: float, + asset: str, + direction: str, + duration: int, + open_time: str | None = None + ) -> tuple[bool, Any]: + """Places a pending order to be executed at a specific future time.""" + if self.api is None: + return False, "API not initialized" + + self.api.pending_id = None + self.api.slots.pending_confirm.clear() + user_settings = await self.get_profile() + offset_zone = user_settings.offset if user_settings else 0 + open_time_int = int( + expiration.get_next_timeframe( + int(time.time()), + offset_zone, + duration, + open_time + ) + ) + await self.api.open_pending( + amount, asset, direction, duration, open_time_int + ) + if self.api.pending_id is None: + try: + await self.api.slots.pending_confirm.wait(timeout=DEFAULT_TIMEOUT) + except asyncio.TimeoutError: + logger.error("Timeout pending order.") + return False, "Timeout waiting for pending ID" + + if self.api.state.check_websocket_if_error: + return False, self.api.state.websocket_error_reason + + # pending_id was set (success path). + status_buy = False + if self.api.pending_id is not None: + status_buy = True + await self.api.instruments_follow( + amount, asset, direction, duration, open_time_int + ) + + return status_buy, self.api.pending_successful + + async def sell_option( + self, + options_ids: list[str] | str, + timeout: int = DEFAULT_TIMEOUT + ) -> dict[str, Any]: + """Sells active options back to the broker before expiration.""" + if self.api is None: + raise RuntimeError("API not initialized") + + # Reset sentinel BEFORE sending the request β€” if the WS response + # 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) + return self.api.sold_options_respond + + async def check_win( + self, order_id: str | int, duration: int = 0 + ) -> tuple[str, float]: + """Checks if a trade operation resulted in a win based on its ID.""" + if self.api is None: + return "loss", 0.0 + + # Fast path: result may already be cached (e.g. closed deal arrived + # before this method was called). + cached = self.api.listinfodata.get(order_id) + if cached and cached.get("game_state") == 1: + self.api.listinfodata.delete(order_id) + return ( + cached.get("win", "loss"), + float(cached.get("profit", 0)), + ) + + # Event-driven path: wait on the keyed win_result slot fired by + # _on_message when the matching order closes. + key = str(order_id) + slot = self.api.slots.win_result(key) + try: + result = await slot.wait(timeout=300) + except asyncio.TimeoutError: + return "loss", 0.0 + finally: + self.api.slots.release_win_result(key) + + # Clean up the listinfodata cache to match prior behavior. + self.api.listinfodata.delete(order_id) + self.api.listinfodata.delete(key) + + win = result.get("win", "loss") if result else "loss" + profit = float(result.get("profit", 0)) if result else 0.0 + return win, profit + + async def get_result(self, operation_id: str) -> tuple[str | None, Any]: + """Check if the trade is a win based on its ID.""" + data_history = await self.get_history() + for item in data_history: + if str(item.get("ticket")) == operation_id: + profit = float(item.get("profitAmount", 0)) + status = "win" if profit > 0 else "loss" + return status, item + + return None, "OperationID Not Found." + + def get_profit(self) -> float: + """Retrieves the profit amount from the current active operation.""" + if self.api: + return self.api.profit_in_operation or 0.0 + return 0.0 + + async def get_history(self) -> list[dict[str, Any]]: + """Get the trader's history based on account type.""" + if self.api is None: + return [] + + account_type = AccountType.DEMO if self.account_is_demo else AccountType.REAL + history = await self.api.get_trader_history(account_type, page=1) + return list(history) diff --git a/pyquotex/api.py b/pyquotex/api.py index 8a3d9f32..3583d6f4 100644 --- a/pyquotex/api.py +++ b/pyquotex/api.py @@ -119,6 +119,8 @@ def __init__( self.browser.set_headers() self.settings = Settings(self) self.event_registry = EventRegistry() + from pyquotex._api._waits import SlotRegistry + self.slots = SlotRegistry() self.profit_today: float | None = None self.heartbeat_task: asyncio.Task | None = None @@ -135,7 +137,12 @@ async def heartbeat() -> None: await self.websocket.send('42["tick"]') except Exception: break - # Send it every 5 seconds as in legacy version + # Send it every 5 seconds as in legacy version. + # TODO(refactor/architecture Phase 2): this is fixed-interval + # pacing for a heartbeat ping (NOT a retry-on-error sleep), + # so exponential backoff_sleep would be the wrong primitive + # here. Migrating only makes sense if reconnect logic is + # added that retries on transient send failures. await asyncio.sleep(5) self.heartbeat_task = asyncio.create_task(heartbeat()) @@ -248,6 +255,8 @@ async def _on_message(self, msg: bytes | str) -> None: ) 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 ) @@ -293,6 +302,8 @@ async def _on_message(self, msg: bytes | str) -> None: if isinstance(data, dict) and data.get("asset"): asset = data["asset"] self.candle_v2_data[asset] = data + if data is not None: + self.slots.candle_v2(asset).set(data) await self.event_registry.set_event( f'candles_ready_{asset}', data ) @@ -356,6 +367,13 @@ async def _on_message(self, msg: bytes | str) -> None: self.listinfodata.set( win, game_state, str(order_id), profit ) + # Fire keyed win_result slot when the order is + # closed (game_state == 1) so check_win() can + # resolve event-driven instead of polling. + if game_state == 1: + self.slots.win_result(str(order_id)).set( + {"win": win, "profit": profit} + ) # Always set buy_confirmed if it was an open request if ( @@ -365,12 +383,16 @@ async def _on_message(self, msg: bytes | str) -> None: if 'pending' in self._temp_status: self.pending_id = data.get("id") self.pending_successful = True + if self.pending_id is not None: + self.slots.pending_confirm.set({"id": self.pending_id}) await self.event_registry.set_event( 'pending_confirmed', data ) else: self.buy_id = data.get("id") self.buy_successful = True + if self.buy_id is not None: + self.slots.buy_confirm.set({"id": self.buy_id}) await self.event_registry.set_event( 'buy_confirmed', data ) @@ -381,6 +403,8 @@ async def _on_message(self, msg: bytes | str) -> None: if isinstance(message, dict): if message.get("liveBalance") or message.get("demoBalance"): self.account_balance = message + if message is not None: + self.slots.balance.set(message) await self.event_registry.set_event( 'balance_ready', message ) @@ -400,6 +424,10 @@ async def _on_message(self, msg: bytes | str) -> None: self.listinfodata.set( win, 1, str(order_id), profit ) + # Always closed here; fire keyed win_result slot. + self.slots.win_result(str(order_id)).set( + {"win": win, "profit": profit} + ) await self.event_registry.set_event( 'history_ready', message ) @@ -409,6 +437,8 @@ async def _on_message(self, msg: bytes | str) -> None: ): # Potential order confirmation self.buy_id = message.get("id") + if self.buy_id is not None: + self.slots.buy_confirm.set({"id": self.buy_id}) await self.event_registry.set_event( 'buy_confirmed', message ) @@ -422,6 +452,8 @@ async def _on_message(self, msg: bytes | str) -> None: data = message[1] order_id = data.get("id") self.buy_id = order_id + if self.buy_id is not None: + self.slots.buy_confirm.set({"id": self.buy_id}) # Update listinfodata for check_win if "profit" in data and "status" in data: @@ -431,6 +463,11 @@ async def _on_message(self, msg: bytes | str) -> None: self.listinfodata.set( win, game_state, str(order_id), profit ) + # Fire keyed win_result slot when closed. + if game_state == 1 and order_id is not None: + self.slots.win_result(str(order_id)).set( + {"win": win, "profit": profit} + ) await self.event_registry.set_event('buy_confirmed', data) await self.event_registry.set_event( diff --git a/pyquotex/cli/__init__.py b/pyquotex/cli/__init__.py new file mode 100644 index 00000000..6d6e10dc --- /dev/null +++ b/pyquotex/cli/__init__.py @@ -0,0 +1 @@ +"""Command-line interface for pyquotex.""" diff --git a/pyquotex/cli/__main__.py b/pyquotex/cli/__main__.py new file mode 100644 index 00000000..c30fbcec --- /dev/null +++ b/pyquotex/cli/__main__.py @@ -0,0 +1,51 @@ +"""pyquotex CLI entry point. Run with `python -m pyquotex` or via app.py.""" +import asyncio +import sys + +from rich.console import Console + +from pyquotex.cli.commands import COMMAND_REGISTRY +from pyquotex.cli.parser import make_parser +from pyquotex.cli.runtime import on_otp +from pyquotex.config import credentials +from pyquotex.stable_api import Quotex + +console = Console() + + +async def main() -> None: + parser = make_parser() + args = parser.parse_args() + + if not getattr(args, "command", None): + parser.print_help() + return + + email, password = credentials() + client = Quotex( + email=email, + password=password, + on_otp_callback=on_otp, + ) + + handler = COMMAND_REGISTRY.get(args.command) + if handler is None: + console.print(f"[red]Unknown command: {args.command}[/]") + parser.print_help() + sys.exit(2) + + try: + await handler(client, args) + finally: + try: + await client.close() + except Exception: + pass # best-effort cleanup + + +def cli_main() -> None: + asyncio.run(main()) + + +if __name__ == "__main__": + cli_main() diff --git a/pyquotex/cli/commands/__init__.py b/pyquotex/cli/commands/__init__.py new file mode 100644 index 00000000..67452dfe --- /dev/null +++ b/pyquotex/cli/commands/__init__.py @@ -0,0 +1,71 @@ +"""CLI command handlers and the COMMAND_REGISTRY dict.""" +from pyquotex.cli.commands.account import ( + cmd_balance, + cmd_login, + cmd_server_time, + cmd_set_demo_balance, + cmd_settings, +) +from pyquotex.cli.commands.analysis import ( + cmd_history, + cmd_indicator, + cmd_monitor, + cmd_signals, + cmd_strategy, +) +from pyquotex.cli.commands.candles import ( + cmd_candle_info, + cmd_candles, + cmd_candles_deep, + cmd_candles_v2, + cmd_history_line, +) +from pyquotex.cli.commands.diagnostics import cmd_test_all +from pyquotex.cli.commands.market import ( + cmd_assets, + cmd_payout, + cmd_payout_asset, +) +from pyquotex.cli.commands.realtime import ( + cmd_realtime_candle, + cmd_realtime_price, + cmd_realtime_sentiment, +) +from pyquotex.cli.commands.trading import ( + cmd_buy, + cmd_check, + cmd_pending, + cmd_result, + cmd_sell, +) + + +COMMAND_REGISTRY = { + "login": cmd_login, + "balance": cmd_balance, + "server-time": cmd_server_time, + "set-demo-balance": cmd_set_demo_balance, + "settings": cmd_settings, + "assets": cmd_assets, + "payout": cmd_payout, + "payout-asset": cmd_payout_asset, + "candles": cmd_candles, + "candles-v2": cmd_candles_v2, + "candles-deep": cmd_candles_deep, + "history-line": cmd_history_line, + "candle-info": cmd_candle_info, + "realtime-price": cmd_realtime_price, + "realtime-sentiment": cmd_realtime_sentiment, + "realtime-candle": cmd_realtime_candle, + "buy": cmd_buy, + "sell": cmd_sell, + "pending": cmd_pending, + "check": cmd_check, + "result": cmd_result, + "signals": cmd_signals, + "history": cmd_history, + "indicator": cmd_indicator, + "monitor": cmd_monitor, + "strategy": cmd_strategy, + "test-all": cmd_test_all, +} diff --git a/pyquotex/cli/commands/account.py b/pyquotex/cli/commands/account.py new file mode 100644 index 00000000..fb6a29d3 --- /dev/null +++ b/pyquotex/cli/commands/account.py @@ -0,0 +1,99 @@ +"""Account CLI command handlers.""" +import argparse +from datetime import datetime + +from rich import box +from rich.console import Console +from rich.panel import Panel +from rich.table import Table + +from pyquotex.cli.formatters import _balance_table +from pyquotex.cli.runtime import _is_demo, connect_with_retry +from pyquotex.stable_api import Quotex + +console = Console() + + +async def cmd_login(client: Quotex, args: argparse.Namespace) -> None: + """Connect and display user profile + balance.""" + is_demo = _is_demo(args) + if not await connect_with_retry(client, is_demo): + return + profile = await client.get_profile() + console.print(_balance_table(profile)) + console.print(Panel( + f"[bold blue]Nickname:[/] {profile.nick_name}\n" + f"[bold blue]Country:[/] {profile.country_name}\n" + f"[bold blue]Offset:[/] {profile.offset}", + title="πŸ‘€ [bold]User Profile[/]", + border_style="bright_blue", + box=box.ROUNDED, + padding=(1, 2), + expand=False, + )) + + +async def cmd_balance(client: Quotex, args: argparse.Namespace) -> None: + """Display current balance.""" + is_demo = _is_demo(args) + if not await connect_with_retry(client, is_demo): + return + profile = await client.get_profile() + console.print(_balance_table(profile)) + + +async def cmd_server_time(client: Quotex, args: argparse.Namespace) -> None: + """Show the current synced server timestamp.""" + if not await connect_with_retry(client, True): + return + ts = await client.get_server_time() + dt = datetime.fromtimestamp(ts) + console.print(Panel( + f"[bold cyan]Unix:[/] {ts}\n" + f"[bold cyan]Local:[/] {dt.strftime('%Y-%m-%d %H:%M:%S')}", + title="πŸ•’ [bold]Server Time[/]", + border_style="cyan", + box=box.ROUNDED, + expand=False, + )) + + +async def cmd_set_demo_balance( + client: Quotex, args: argparse.Namespace +) -> None: + """Refill or set the demo (practice) account balance.""" + if not await connect_with_retry(client, True): + return + result = await client.edit_practice_balance(args.amount) + console.print(Panel( + f"[bold green]βœ“ Demo balance updated[/]\n{result}", + title="πŸ’Έ [bold]Set Demo Balance[/]", + border_style="green", + box=box.ROUNDED, + expand=False, + )) + + +async def cmd_settings(client: Quotex, args: argparse.Namespace) -> None: + """Apply trading-UI settings and display the server response.""" + is_demo = _is_demo(args) + if not await connect_with_retry(client, is_demo): + return + result = await client.store_settings_apply( + asset=args.asset, + period=args.period, + time_mode=args.mode, + deal=args.deal, + ) + table = Table( + title="βš™οΈ [bold]Settings Applied[/]", + box=box.ROUNDED, + border_style="cyan", + show_header=True, + header_style="bold cyan", + ) + table.add_column("Key", style="bright_white") + table.add_column("Value", style="yellow") + for k, v in result.items(): + table.add_row(str(k), str(v)) + console.print(table) diff --git a/pyquotex/cli/commands/analysis.py b/pyquotex/cli/commands/analysis.py new file mode 100644 index 00000000..e451d74d --- /dev/null +++ b/pyquotex/cli/commands/analysis.py @@ -0,0 +1,205 @@ +"""Analysis CLI command handlers.""" +import argparse +import asyncio +from datetime import datetime + +from rich import box +from rich.console import Console +from rich.panel import Panel +from rich.progress import Progress, SpinnerColumn, TextColumn +from rich.table import Table + +from pyquotex.cli.runtime import _is_demo, connect_with_retry +from pyquotex.stable_api import Quotex +from pyquotex.utils.strategy import TripleConfirmationStrategy + +console = Console() + + +async def cmd_signals(client: Quotex, args: argparse.Namespace) -> None: + """Fetch current signal data from the signals stream.""" + if not await connect_with_retry(client, True): + return + await client.start_signals_data() + await asyncio.sleep(2) # allow signals to arrive + data = client.get_signal_data() + if not data: + console.print("[yellow]No signal data available yet.[/]") + return + table = Table( + title="πŸ“‘ [bold]Signal Data[/]", + box=box.ROUNDED, + border_style="yellow", + show_header=True, + header_style="bold yellow", + ) + table.add_column("Key", style="cyan") + table.add_column("Value", style="white") + for k, v in data.items(): + table.add_row(str(k), str(v)) + console.print(table) + + +async def cmd_history(client: Quotex, args: argparse.Namespace) -> None: + """Show recent trade history (paged).""" + is_demo = _is_demo(args) + if not await connect_with_retry(client, is_demo): + return + all_trades: list[dict] = [] + account_type = 1 if is_demo else 0 + for page in range(1, args.pages + 1): + page_data = await client.get_trader_history(account_type, page) + if isinstance(page_data, dict): + trades = page_data.get("data", []) + elif isinstance(page_data, list): + trades = page_data + else: + trades = [] + all_trades.extend(trades) + + if not all_trades: + console.print("[yellow]No trade history found.[/]") + return + + table = Table( + title=f"πŸ“œ [bold]Trade History[/] ({'Demo' if is_demo else 'Live'})", + box=box.ROUNDED, + border_style="bright_blue", + show_header=True, + header_style="bold bright_white on blue", + row_styles=["none", "dim"], + ) + table.add_column("ID", style="dim", no_wrap=True) + table.add_column("Asset", style="cyan") + table.add_column("Direction", justify="center") + table.add_column("Amount", justify="right") + table.add_column("Profit", justify="right") + table.add_column("Result", justify="center") + table.add_column("Time", style="dim") + + for t in all_trades: + profit = float(t.get("profitAmount", 0)) + result_str = ( + "[green]WIN[/]" if profit > 0 + else "[red]LOSS[/]" if profit < 0 + else "[dim]DRAW[/]" + ) + direction = t.get("command", t.get("direction", "?")).upper() + dir_color = "green" if direction in ("CALL", "BUY", "UP") else "red" + ts = t.get("openTimestamp", t.get("createdAt", "")) + try: + ts_str = datetime.fromtimestamp(int(ts)).strftime( + "%m-%d %H:%M" + ) if ts else "β€”" + except Exception: + ts_str = str(ts) + table.add_row( + str(t.get("ticket", t.get("id", "β€”")))[:12], + str(t.get("asset", "?")), + f"[{dir_color}]{direction}[/{dir_color}]", + f"{float(t.get('amount', 0)):,.2f}", + f"{profit:+,.2f}", + result_str, + ts_str, + ) + console.print(table) + + +async def cmd_indicator(client: Quotex, args: argparse.Namespace) -> None: + """Calculate a technical indicator and display the result.""" + is_demo = _is_demo(args) + if not await connect_with_retry(client, is_demo): + return + asset, _ = await client.get_available_asset(args.asset, force_open=True) + console.print( + f"[cyan]Calculating[/] [bold]{args.name}[/] for " + f"[yellow]{asset}[/] (period={args.period}, tf={args.timeframe}s)" + ) + with Progress( + SpinnerColumn(), TextColumn("[cyan]Fetching history + computing…"), + transient=True, console=console + ) as prog: + prog.add_task("indicator") + result = await client.calculate_indicator( + asset, + args.name, + params={"period": args.period}, + timeframe=args.timeframe, + ) + if not result: + console.print("[red]No indicator data returned.[/]") + return + table = Table( + title=f"πŸ“ [bold]{args.name} β€” {asset}[/]", + box=box.ROUNDED, + border_style="magenta", + show_header=True, + header_style="bold magenta", + ) + table.add_column("Key", style="cyan") + table.add_column("Value", style="bold yellow") + if isinstance(result, dict): + for k, v in result.items(): + table.add_row(str(k), f"{v:.6f}" if isinstance(v, float) else str(v)) + else: + table.add_row("result", str(result)) + console.print(table) + + +async def cmd_monitor(client: Quotex, args: argparse.Namespace) -> None: + """Real-time price monitor for an asset (Ctrl+C to stop).""" + if not await connect_with_retry(client, True): + return + asset, _ = await client.get_available_asset(args.asset, force_open=True) + console.print( + f"[cyan]Monitoring[/] [bold]{asset}[/] " + f"[dim](period={args.period}s β€” Ctrl+C to stop)[/]" + ) + await client.start_candles_stream(asset, args.period) + prev_price = None + try: + while True: + prices = await client.get_realtime_price(asset) + if prices: + latest = prices[-1] + price = latest.get("price", latest) + change = "" + if prev_price is not None: + delta = float(price) - float(prev_price) + change = ( + f" [green]+{delta:.5f}[/]" if delta > 0 + else f" [red]{delta:.5f}[/]" if delta < 0 + else " [dim]β€”[/]" + ) + console.print( + f" [dim]{datetime.now().strftime('%H:%M:%S')}[/] " + f"[bold]{price}[/]{change} ", + end="\r", + ) + prev_price = price + await asyncio.sleep(0.5) + except KeyboardInterrupt: + console.print("\n[yellow]Monitor stopped.[/]") + finally: + await client.stop_candles_stream(asset) + + +async def cmd_strategy(client: Quotex, args: argparse.Namespace) -> None: + """Run a Triple-Confirmation strategy.""" + if not await connect_with_retry(client, True): + return + strategy = TripleConfirmationStrategy( + client=client, + asset=args.asset, + period=args.period, + ) + console.print(Panel( + f"[bold cyan]Asset:[/] {args.asset}\n" + f"[bold cyan]Period:[/] {args.period}s\n" + f"[bold cyan]Auto-trade:[/] {'YES ⚠ DEMO ONLY' if args.auto_trade else 'NO (signal only)'}", + title="🧠 [bold]Triple Confirmation Strategy[/]", + border_style="magenta", + box=box.ROUNDED, + expand=False, + )) + await strategy.run(auto_trade=args.auto_trade) diff --git a/pyquotex/cli/commands/candles.py b/pyquotex/cli/commands/candles.py new file mode 100644 index 00000000..dd3d7e57 --- /dev/null +++ b/pyquotex/cli/commands/candles.py @@ -0,0 +1,142 @@ +"""Candles CLI command handlers.""" +import argparse +import asyncio +import time +from datetime import datetime + +from rich import box +from rich.console import Console +from rich.panel import Panel +from rich.progress import ( + BarColumn, Progress, SpinnerColumn, TaskProgressColumn, TextColumn, +) + +from pyquotex.cli.formatters import _print_candles_table, _save_candles_csv +from pyquotex.cli.runtime import _is_demo, connect_with_retry +from pyquotex.stable_api import Quotex + +console = Console() + + +async def cmd_candles(client: Quotex, args: argparse.Namespace) -> None: + """Fetch latest candles for an asset (up to 199 per call).""" + is_demo = _is_demo(args) + if not await connect_with_retry(client, is_demo): + return + asset, _ = await client.get_available_asset(args.asset, force_open=True) + candles = await client.get_candles( + asset, time.time(), args.period * args.count, args.period + ) + if not candles: + console.print("[red]No candle data received.[/]") + return + _print_candles_table(candles[-args.count:], asset, args.period) + + +async def cmd_candles_v2(client: Quotex, args: argparse.Namespace) -> None: + """Fetch candles via the v2 API path.""" + is_demo = _is_demo(args) + if not await connect_with_retry(client, is_demo): + return + asset, _ = await client.get_available_asset(args.asset, force_open=True) + candles = await client.get_candle_v2(asset, args.period) + if not candles: + console.print("[red]No v2 candle data received.[/]") + return + _print_candles_table(candles, asset, args.period, title="Candles (v2)") + + +async def cmd_candles_deep(client: Quotex, args: argparse.Namespace) -> None: + """Fetch deep historical candle data using parallel workers.""" + is_demo = _is_demo(args) + if not await connect_with_retry(client, is_demo): + return + if args.workers > 10: + console.print( + "[bold red]⚠ WARNING:[/] workers > 10 may cause a ban. " + "Clamping to 10." + ) + args.workers = 10 + + asset, _ = await client.get_available_asset(args.asset, force_open=True) + + def _progress_cb(done: int, total: int, count: int, label: str) -> None: + pct = int(done / total * 100) if total else 0 + console.print( + f" [dim]{label}[/] {pct}% β€” {count} candles collected", + end="\r", + ) + + with Progress( + SpinnerColumn(), + TextColumn("[cyan]Fetching deep history…"), + BarColumn(), + TaskProgressColumn(), + transient=True, + console=console, + ) as prog: + prog.add_task("fetch") + candles = await client.get_historical_candles( + asset, + amount_of_seconds=args.seconds, + period=args.period, + max_workers=args.workers, + progress_callback=_progress_cb, + ) + + console.print(f"\n[green]βœ“[/] {len(candles)} candles fetched.") + _print_candles_table(candles[-20:], asset, args.period, + title=f"Last 20 of {len(candles)} candles (deep)") + + if args.output: + _save_candles_csv(candles, args.output) + console.print(f"[green]βœ“ Saved to {args.output}[/]") + + +async def cmd_history_line(client: Quotex, args: argparse.Namespace) -> None: + """Fetch raw historical price-line data.""" + is_demo = _is_demo(args) + if not await connect_with_retry(client, is_demo): + return + asset, _ = await client.get_available_asset(args.asset, force_open=True) + await client.get_all_assets() + data = await client.get_history_line( + asset, time.time(), args.offset + ) + if not data: + console.print("[red]No history-line data received.[/]") + return + console.print(Panel( + str(data)[:2000], + title=f"πŸ“ˆ [bold]History Line β€” {asset}[/]", + border_style="blue", + box=box.ROUNDED, + )) + + +async def cmd_candle_info(client: Quotex, args: argparse.Namespace) -> None: + """Show opening / closing / remaining time of the current candle.""" + is_demo = _is_demo(args) + if not await connect_with_retry(client, is_demo): + return + asset, _ = await client.get_available_asset(args.asset, force_open=True) + await client.start_candles_stream(asset, args.period) + await asyncio.sleep(1) # let stream warm up + info = await client.opening_closing_current_candle(asset, args.period) + if not info: + console.print("[red]Could not retrieve candle info.[/]") + return + opening = datetime.fromtimestamp(info.get("opening", 0)) + closing = datetime.fromtimestamp(info.get("closing", 0)) + console.print(Panel( + f"[bold cyan]Asset:[/] {asset}\n" + f"[bold cyan]Period:[/] {args.period}s\n" + f"[bold cyan]Opening:[/] {opening.strftime('%H:%M:%S')}\n" + f"[bold cyan]Closing:[/] {closing.strftime('%H:%M:%S')}\n" + f"[bold yellow]Remaining:[/] {info.get('remaining', '?')}s", + title="πŸ•―οΈ [bold]Current Candle Info[/]", + border_style="cyan", + box=box.ROUNDED, + expand=False, + )) + await client.stop_candles_stream(asset) diff --git a/pyquotex/cli/commands/diagnostics.py b/pyquotex/cli/commands/diagnostics.py new file mode 100644 index 00000000..270869b2 --- /dev/null +++ b/pyquotex/cli/commands/diagnostics.py @@ -0,0 +1,72 @@ +"""Diagnostics CLI command handlers.""" +import argparse +import asyncio +import time +from typing import Any + +from rich.console import Console + +from pyquotex.cli.runtime import connect_with_retry +from pyquotex.stable_api import Quotex + +console = Console() + + +async def cmd_test_all(client: Quotex, args: argparse.Namespace) -> None: + """Run a quick smoke-test of every major API method.""" + console.rule("[bold cyan]PyQuotex β€” test-all[/]") + passed = 0 + failed = 0 + + async def _test(name: str, coro: Any) -> None: + nonlocal passed, failed + try: + result = await coro + console.print(f" [green]βœ“[/] {name}: {str(result)[:80]}") + passed += 1 + except Exception as e: + console.print(f" [red]βœ—[/] {name}: {e}") + failed += 1 + + if not await connect_with_retry(client, True): + return + + await client.get_all_assets() + + await _test("get_profile", client.get_profile()) + await _test("get_balance", client.get_balance()) + await _test("get_server_time", client.get_server_time()) + await _test("get_all_asset_name", asyncio.coroutine( + lambda: client.get_all_asset_name() + )()) + await _test("get_payment (sync)", asyncio.coroutine( + lambda: client.get_payment() + )()) + await _test("get_payout_by_asset EURUSD", + asyncio.coroutine( + lambda: client.get_payout_by_asset("EURUSD") + )()) + await _test("get_candles EURUSD 60s", + client.get_candles("EURUSD", time.time(), 3600, 60)) + await _test("get_candle_v2 EURUSD", + client.get_candle_v2("EURUSD", 60)) + await _test("get_historical_candles EURUSD 1h", + client.get_historical_candles( + "EURUSD", amount_of_seconds=3600, period=60, max_workers=2 + )) + await _test("get_realtime_price EURUSD", + client.start_realtime_price("EURUSD", 60)) + await _test("get_realtime_sentiment EURUSD", + client.start_realtime_sentiment("EURUSD", 60)) + await _test("get_trader_history demo p1", + client.get_trader_history(1, 1)) + await _test("calculate_indicator RSI", + client.calculate_indicator( + "EURUSD", "RSI", {"period": 14}, timeframe=60 + )) + + console.rule() + color = "green" if failed == 0 else "yellow" + console.print( + f"[bold {color}]Results: {passed} passed, {failed} failed[/]" + ) diff --git a/pyquotex/cli/commands/market.py b/pyquotex/cli/commands/market.py new file mode 100644 index 00000000..e4188263 --- /dev/null +++ b/pyquotex/cli/commands/market.py @@ -0,0 +1,102 @@ +"""Market CLI command handlers.""" +import argparse + +from rich import box +from rich.console import Console +from rich.panel import Panel +from rich.table import Table + +from pyquotex.cli.runtime import connect_with_retry +from pyquotex.stable_api import Quotex + +console = Console() + + +async def cmd_assets(client: Quotex, args: argparse.Namespace) -> None: + """List all available assets with open/closed status.""" + if not await connect_with_retry(client, True): + return + await client.get_all_assets() + instruments = await client.get_instruments() + if not instruments: + console.print("[red]No instruments received.[/]") + return + + table = Table( + title="πŸ“Š [bold]Available Assets[/]", + box=box.ROUNDED, + border_style="bright_blue", + show_header=True, + header_style="bold bright_white on blue", + row_styles=["none", "dim"], + ) + table.add_column("#", style="dim", width=4) + table.add_column("Asset", style="cyan", no_wrap=True) + table.add_column("Name", style="white") + table.add_column("Status", justify="center") + table.add_column("Payout %", justify="right", style="green") + + for idx, i in enumerate(instruments, 1): + status = "[green]OPEN[/]" if i[14] else "[red]CLOSED[/]" + payout = f"{i[5]}%" if len(i) > 5 else "β€”" + table.add_row(str(idx), i[1], i[2].replace("\n", ""), status, payout) + + console.print(table) + + +async def cmd_payout(client: Quotex, args: argparse.Namespace) -> None: + """Show payout % for all assets.""" + if not await connect_with_retry(client, True): + return + await client.get_all_assets() + data = client.get_payment() + if not data: + console.print("[red]No payout data available.[/]") + return + + table = Table( + title="πŸ’Ή [bold]Asset Payouts[/]", + box=box.ROUNDED, + border_style="green", + show_header=True, + header_style="bold bright_white on green", + row_styles=["none", "dim"], + ) + table.add_column("Asset", style="cyan", no_wrap=True) + table.add_column("Payout %", justify="right") + table.add_column("Turbo %", justify="right") + table.add_column("1M %", justify="right") + table.add_column("5M %", justify="right") + table.add_column("Open", justify="center") + + for asset, info in data.items(): + status = "[green]βœ“[/]" if info.get("open") else "[red]βœ—[/]" + table.add_row( + asset, + str(info.get("payment", "β€”")), + str(info.get("turbo_payment", "β€”")), + str(info.get("profit", {}).get("1M", "β€”")), + str(info.get("profit", {}).get("5M", "β€”")), + status, + ) + console.print(table) + + +async def cmd_payout_asset(client: Quotex, args: argparse.Namespace) -> None: + """Show payout % for a specific asset.""" + if not await connect_with_retry(client, True): + return + await client.get_all_assets() + result = client.get_payout_by_asset(args.asset, args.timeframe) + if result is None: + console.print(f"[red]Asset '{args.asset}' not found.[/]") + return + console.print(Panel( + f"[bold cyan]Asset:[/] {args.asset}\n" + f"[bold cyan]Timeframe:[/] {args.timeframe}M\n" + f"[bold green]Payout:[/] {result}%", + title="πŸ’Ή [bold]Asset Payout[/]", + border_style="green", + box=box.ROUNDED, + expand=False, + )) diff --git a/pyquotex/cli/commands/realtime.py b/pyquotex/cli/commands/realtime.py new file mode 100644 index 00000000..f4f1cf94 --- /dev/null +++ b/pyquotex/cli/commands/realtime.py @@ -0,0 +1,98 @@ +"""Realtime CLI command handlers.""" +import argparse +import asyncio +from datetime import datetime + +from rich.console import Console + +from pyquotex.cli.runtime import _is_demo, connect_with_retry +from pyquotex.stable_api import Quotex + +console = Console() + + +async def cmd_realtime_price(client: Quotex, args: argparse.Namespace) -> None: + """Stream live price data for an asset (Ctrl+C to stop).""" + is_demo = _is_demo(args) + if not await connect_with_retry(client, is_demo): + return + asset, _ = await client.get_available_asset(args.asset, force_open=True) + console.print( + f"[cyan]Streaming live price for[/] [bold]{asset}[/] " + f"[dim](Ctrl+C to stop)[/]" + ) + await client.start_realtime_price(asset, args.period) + try: + while True: + prices = await client.get_realtime_price(asset) + if prices: + latest = prices[-1] + console.print( + f" [dim]{datetime.now().strftime('%H:%M:%S')}[/] " + f"[bold green]{latest.get('price', latest)}[/]", + end="\r", + ) + await asyncio.sleep(0.5) + except KeyboardInterrupt: + console.print("\n[yellow]Stream stopped.[/]") + finally: + await client.stop_candles_stream(asset) + + +async def cmd_realtime_sentiment( + client: Quotex, args: argparse.Namespace +) -> None: + """Stream live trader-sentiment data (Ctrl+C to stop).""" + is_demo = _is_demo(args) + if not await connect_with_retry(client, is_demo): + return + asset, _ = await client.get_available_asset(args.asset, force_open=True) + console.print( + f"[cyan]Streaming sentiment for[/] [bold]{asset}[/] " + f"[dim](Ctrl+C to stop)[/]" + ) + await client.start_realtime_sentiment(asset, args.period) + try: + while True: + sentiment = await client.get_realtime_sentiment(asset) + if sentiment: + bulls = sentiment.get("call", sentiment.get("bulls", "?")) + bears = sentiment.get("put", sentiment.get("bears", "?")) + console.print( + f" [dim]{datetime.now().strftime('%H:%M:%S')}[/] " + f"[green]CALL {bulls}%[/] [red]PUT {bears}%[/]", + end="\r", + ) + await asyncio.sleep(1) + except KeyboardInterrupt: + console.print("\n[yellow]Stream stopped.[/]") + finally: + await client.stop_candles_stream(asset) + + +async def cmd_realtime_candle( + client: Quotex, args: argparse.Namespace +) -> None: + """Stream live processed candle ticks (Ctrl+C to stop).""" + is_demo = _is_demo(args) + if not await connect_with_retry(client, is_demo): + return + asset, _ = await client.get_available_asset(args.asset, force_open=True) + console.print( + f"[cyan]Streaming candle ticks for[/] [bold]{asset}[/] " + f"[dim](Ctrl+C to stop)[/]" + ) + try: + while True: + candle = await client.start_realtime_candle(asset, args.period) + if candle: + console.print( + f" [dim]{datetime.now().strftime('%H:%M:%S')}[/] " + f"{candle}", + end="\r", + ) + await asyncio.sleep(0.5) + except KeyboardInterrupt: + console.print("\n[yellow]Stream stopped.[/]") + finally: + await client.stop_candles_stream(asset) diff --git a/pyquotex/cli/commands/trading.py b/pyquotex/cli/commands/trading.py new file mode 100644 index 00000000..65c940e5 --- /dev/null +++ b/pyquotex/cli/commands/trading.py @@ -0,0 +1,218 @@ +"""Trading CLI command handlers.""" +import argparse +import asyncio +import sys + +from rich import box +from rich.console import Console +from rich.panel import Panel +from rich.progress import Progress, SpinnerColumn, TextColumn + +from pyquotex.cli.runtime import _is_demo, connect_with_retry +from pyquotex.stable_api import Quotex + +console = Console() + + +async def cmd_buy(client: Quotex, args: argparse.Namespace) -> None: + """Place an immediate binary option trade.""" + is_demo = _is_demo(args) + if not await connect_with_retry(client, is_demo): + return + + asset, asset_info = await client.get_available_asset( + args.asset, force_open=True + ) + if not asset_info or not asset_info[0]: + console.print( + f"[bold red]βœ— Asset {args.asset} not found or closed.[/]" + ) + return + + console.print( + f"[cyan]Placing trade:[/] [bold]{args.direction.upper()}[/] " + f"[yellow]{asset}[/] | amount=[bold]{args.amount}[/] | " + f"duration=[bold]{args.duration}s[/]" + ) + + with Progress( + SpinnerColumn(), TextColumn("[cyan]Sending order…"), + transient=True, console=console + ) as prog: + prog.add_task("buy") + status, trade_data = await client.buy( + args.amount, asset, args.direction, args.duration + ) + + if status: + order_data = trade_data if isinstance(trade_data, dict) else {} + trade_id = order_data.get("id") + close_ts = order_data.get("closeTimestamp") + console.print( + f"[bold green]βœ“ Order placed![/] Trade ID: [bold]{trade_id}[/]" + ) + + if getattr(args, "check_win", False): + with Progress( + SpinnerColumn(), + TextColumn("[cyan]{task.description}"), + transient=True, + console=console, + ) as prog: + task_id = prog.add_task("Waiting for trade closure...") + check_task = asyncio.create_task( + client.check_win(trade_id, args.duration) + ) + while not check_task.done(): + server_now = ( + client.api.timesync.server_timestamp + if client.api else None + ) + remaining = ( + int(close_ts - server_now) + if close_ts and server_now else 0 + ) + label = ( + f"Waiting… [bold yellow]{remaining}s[/] remaining" + if remaining > 0 + else "Waiting… [bold yellow]finishing[/]" + ) + prog.update(task_id, description=label) + try: + await asyncio.wait_for( + asyncio.shield(check_task), timeout=1.0 + ) + except asyncio.TimeoutError: + pass + + win, profit = await check_task + color = "green" if win == "win" else "red" + label = "WIN πŸŽ‰" if win == "win" else "LOSS πŸ’Έ" + console.print( + f"[bold {color}]{label}[/] β€” Profit: [bold]{profit:+.2f}[/]" + ) + else: + console.print( + "[dim]Order dispatched. Pass --check-win to wait for result.[/]" + ) + else: + console.print(f"[bold red]βœ— Order failed.[/] Response: {trade_data}") + sys.exit(1) + + +async def cmd_sell(client: Quotex, args: argparse.Namespace) -> None: + """Sell / close an open position early.""" + is_demo = _is_demo(args) + if not await connect_with_retry(client, is_demo): + return + with Progress( + SpinnerColumn(), TextColumn("[cyan]Sending sell request…"), + transient=True, console=console + ) as prog: + prog.add_task("sell") + result = await client.sell_option(args.trade_id) + console.print(Panel( + f"[bold green]βœ“ Sell response received[/]\n{result}", + title="πŸ“€ [bold]Sell Option[/]", + border_style="green", + box=box.ROUNDED, + expand=False, + )) + + +async def cmd_pending(client: Quotex, args: argparse.Namespace) -> None: + """Place a pending order to be executed at a future time.""" + is_demo = _is_demo(args) + if not await connect_with_retry(client, is_demo): + return + + asset, asset_info = await client.get_available_asset( + args.asset, force_open=True + ) + if not asset_info or not asset_info[0]: + console.print( + f"[bold red]βœ— Asset {args.asset} not found or closed.[/]" + ) + return + + console.print( + f"[cyan]Placing pending order:[/] [bold]{args.direction.upper()}[/] " + f"[yellow]{asset}[/] | amount=[bold]{args.amount}[/] | " + f"duration=[bold]{args.duration}s[/]" + + (f" | open_time=[bold]{args.open_time}[/]" if args.open_time else "") + ) + + with Progress( + SpinnerColumn(), TextColumn("[cyan]Sending pending order…"), + transient=True, console=console + ) as prog: + prog.add_task("pending") + status, data = await client.open_pending( + args.amount, asset, args.direction, + args.duration, args.open_time + ) + + if status: + console.print( + f"[bold green]βœ“ Pending order placed![/]\n{data}" + ) + else: + console.print(f"[bold red]βœ— Pending order failed.[/] {data}") + sys.exit(1) + + +async def cmd_check(client: Quotex, args: argparse.Namespace) -> None: + """Check win/loss result of a trade by ID.""" + is_demo = _is_demo(args) + if not await connect_with_retry(client, is_demo): + return + + console.print( + f"[cyan]Checking result for Trade ID:[/] [bold]{args.trade_id}[/]" + ) + with Progress( + SpinnerColumn(), TextColumn("[cyan]{task.description}"), + transient=True, console=console + ) as prog: + task_id = prog.add_task("Waiting…") + check_task = asyncio.create_task( + client.check_win(args.trade_id, timeout=300) + ) + elapsed = 0 + while not check_task.done(): + prog.update( + task_id, + description=f"Waiting… [bold yellow]{elapsed}s[/] elapsed", + ) + try: + await asyncio.wait_for( + asyncio.shield(check_task), timeout=1.0 + ) + except asyncio.TimeoutError: + elapsed += 1 + + win, profit = await check_task + + color = "green" if win == "win" else "red" + label = "WIN πŸŽ‰" if win == "win" else "LOSS πŸ’Έ" + console.print( + f"[bold {color}]{label}[/] β€” Profit: [bold]{profit:+.2f}[/]" + ) + + +async def cmd_result(client: Quotex, args: argparse.Namespace) -> None: + """Look up a trade result from history by operation ID.""" + is_demo = _is_demo(args) + if not await connect_with_retry(client, is_demo): + return + status, data = await client.get_result(args.operation_id) + if status is None: + console.print(f"[red]Operation ID '{args.operation_id}' not found.[/]") + return + color = "green" if status == "win" else "red" + console.print(Panel( + f"[bold {color}]Result: {status.upper()}[/]\n{data}", + title=f"πŸ“‹ [bold]Trade Result β€” {args.operation_id}[/]", + border_style=color, + box=box.ROUNDED, + )) diff --git a/pyquotex/cli/formatters.py b/pyquotex/cli/formatters.py new file mode 100644 index 00000000..8a6bd2d9 --- /dev/null +++ b/pyquotex/cli/formatters.py @@ -0,0 +1,91 @@ +"""Output formatting helpers shared by CLI commands.""" +import csv +from datetime import datetime +from typing import Any + +from rich import box +from rich.console import Console +from rich.table import Table + +console = Console() + + +def _balance_table(profile: Any) -> Table: + table = Table( + title="πŸ’° [bold]Account Balance[/]", + show_header=True, + header_style="bold bright_white on magenta", + box=box.ROUNDED, + border_style="magenta", + row_styles=["none", "dim"], + padding=(0, 1), + ) + table.add_column("Account", style="cyan", no_wrap=True) + table.add_column("Balance", justify="right", style="bold green") + table.add_column("Currency", style="bright_white") + table.add_row( + "Demo", f"{profile.demo_balance:,.2f}", profile.currency_symbol or "" + ) + table.add_row( + "Live", f"{profile.live_balance:,.2f}", profile.currency_symbol or "" + ) + return table + + +def _print_candles_table( + candles: list[dict], + asset: str, + period: int, + title: str | None = None, +) -> None: + """Render a Rich table of candle data.""" + tbl_title = title or f"πŸ•―οΈ [bold]Candles β€” {asset} ({period}s)[/]" + table = Table( + title=tbl_title, + box=box.ROUNDED, + border_style="bright_blue", + show_header=True, + header_style="bold bright_white on blue", + row_styles=["none", "dim"], + ) + table.add_column("Time", style="dim", no_wrap=True) + table.add_column("Open", justify="right") + table.add_column("High", justify="right", style="green") + table.add_column("Low", justify="right", style="red") + table.add_column("Close", justify="right", style="bold") + table.add_column("Dir", justify="center") + + for c in candles: + ts = c.get("time", c.get("timestamp", 0)) + try: + ts_str = datetime.fromtimestamp(int(ts)).strftime("%m-%d %H:%M:%S") + except Exception: + ts_str = str(ts) + o = c.get("open", 0) + h = c.get("max", c.get("high", 0)) + lo = c.get("min", c.get("low", 0)) + cl = c.get("close", 0) + direction = ( + "[green]β–²[/]" if float(cl) >= float(o) + else "[red]β–Ό[/]" + ) + table.add_row( + ts_str, + f"{float(o):.5f}", + f"{float(h):.5f}", + f"{float(lo):.5f}", + f"{float(cl):.5f}", + direction, + ) + console.print(table) + + +def _save_candles_csv(candles: list[dict], filepath: str) -> None: + """Save the candles list to a CSV file.""" + if not candles: + return + fieldnames = list(candles[0].keys()) + with open(filepath, "w", newline="") as f: + writer = csv.DictWriter(f, fieldnames=fieldnames) + writer.writeheader() + writer.writerows(candles) diff --git a/pyquotex/cli/parser.py b/pyquotex/cli/parser.py new file mode 100644 index 00000000..e3c0afe2 --- /dev/null +++ b/pyquotex/cli/parser.py @@ -0,0 +1,262 @@ +"""argparse parser construction for the pyquotex CLI.""" +import argparse + + +def make_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="pyquotex", + description="⚑ PyQuotex β€” Complete Quotex trading API CLI", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=( + "Examples:\n" + " pyquotex login --demo\n" + " pyquotex balance --live\n" + " pyquotex assets\n" + " pyquotex payout\n" + " pyquotex payout-asset --asset EURUSD --timeframe 1\n" + " pyquotex candles --asset EURUSD --period 60 --count 10\n" + " pyquotex candles-v2 --asset EURUSD --period 60\n" + " pyquotex candles-deep --asset EURUSD --seconds 3600 --workers 5\n" + " pyquotex history-line --asset EURUSD --offset 3600\n" + " pyquotex candle-info --asset EURUSD --period 60\n" + " pyquotex realtime-price --asset EURUSD\n" + " pyquotex realtime-sentiment --asset EURUSD\n" + " pyquotex realtime-candle --asset EURUSD --period 60\n" + " pyquotex buy --asset EURUSD --amount 5 --direction call --duration 60 --check-win\n" + " pyquotex sell --id TRADE_ID\n" + " pyquotex pending --asset EURUSD --amount 10 --direction call --duration 60\n" + " pyquotex check --id TRADE_ID\n" + " pyquotex result --id OPERATION_ID\n" + " pyquotex history --pages 2\n" + " pyquotex signals\n" + " pyquotex indicator --asset EURUSD --name RSI --period 14\n" + " pyquotex server-time\n" + " pyquotex set-demo-balance --amount 10000\n" + " pyquotex settings --asset EURUSD --period 60\n" + " pyquotex monitor --asset EURUSD\n" + " pyquotex strategy --asset EURUSD --auto-trade\n" + ), + ) + sub = parser.add_subparsers(dest="command", metavar="COMMAND") + + # ── helpers ───────────────────────────────────────────────────────────── + def _add_account_flags(p: argparse.ArgumentParser) -> None: + g = p.add_mutually_exclusive_group() + g.add_argument("--demo", action="store_true", default=True, + help="Use demo account (default)") + g.add_argument("--live", action="store_true", + help="Use live account") + + def _add_asset_flag(p: argparse.ArgumentParser, + default: str = "EURUSD") -> None: + p.add_argument("--asset", default=default, + help=f"Asset symbol (default: {default})") + + # ── test-all ───────────────────────────────────────────────────────────── + sub.add_parser("test-all", help="Run all tests") + + # ── login ──────────────────────────────────────────────────────────────── + p = sub.add_parser("login", help="Test connection and show profile + balance") + _add_account_flags(p) + + # ── balance ────────────────────────────────────────────────────────────── + p = sub.add_parser("balance", help="Show account balance") + _add_account_flags(p) + + # ── server-time ────────────────────────────────────────────────────────── + sub.add_parser("server-time", + help="Show the current synced server timestamp") + + # ── set-demo-balance ───────────────────────────────────────────────────── + p = sub.add_parser("set-demo-balance", + help="Refill or set demo (practice) account balance") + p.add_argument("--amount", type=float, default=10000.0, + help="Amount to set (default: 10000)") + + # ── settings ───────────────────────────────────────────────────────────── + p = sub.add_parser("settings", + help="Apply trading-UI settings and show result") + _add_asset_flag(p) + p.add_argument("--period", type=int, default=60, + help="Candle period in seconds (default: 60)") + p.add_argument("--mode", choices=["TIMER", "TURBO"], default="TIMER", + help="Time mode (default: TIMER)") + p.add_argument("--deal", type=int, default=5, + help="Default deal amount (default: 5)") + _add_account_flags(p) + + # ── assets ─────────────────────────────────────────────────────────────── + sub.add_parser("assets", help="List all available assets") + + # ── payout ─────────────────────────────────────────────────────────────── + sub.add_parser("payout", help="Show payout %% for all assets") + + # ── payout-asset ───────────────────────────────────────────────────────── + p = sub.add_parser("payout-asset", + help="Show payout %% for a specific asset") + _add_asset_flag(p) + p.add_argument("--timeframe", default="1", + choices=["1", "5", "24", "all"], + help="Timeframe in minutes, or 'all' (default: 1)") + + # ── candles ────────────────────────────────────────────────────────────── + p = sub.add_parser("candles", help="Fetch latest candle data (≀199)") + _add_asset_flag(p) + p.add_argument("--period", type=int, default=60, + help="Candle period in seconds (default: 60)") + p.add_argument("--count", type=int, default=10, + help="Number of candles to display (default: 10)") + _add_account_flags(p) + + # ── candles-v2 ─────────────────────────────────────────────────────────── + p = sub.add_parser("candles-v2", + help="Fetch candles via the v2 API path") + _add_asset_flag(p) + p.add_argument("--period", type=int, default=60, + help="Candle period in seconds (default: 60)") + _add_account_flags(p) + + # ── candles-deep ───────────────────────────────────────────────────────── + p = sub.add_parser("candles-deep", + help="Fetch deep historical candle data (parallel workers)") + _add_asset_flag(p) + p.add_argument("--seconds", type=int, default=3600, + help="Total history window in seconds (default: 3600)") + p.add_argument("--period", type=int, default=60, + help="Candle period in seconds (default: 60)") + p.add_argument("--workers", type=int, default=5, + help="Parallel workers 2-10 (default: 5). " + "WARNING: >10 may cause a ban.") + p.add_argument("--output", metavar="FILE", + help="Save results to a CSV file") + _add_account_flags(p) + + # ── history-line ───────────────────────────────────────────────────────── + p = sub.add_parser("history-line", + help="Fetch raw historical price-line data") + _add_asset_flag(p) + p.add_argument("--offset", type=int, default=3600, + help="History window in seconds (default: 3600)") + _add_account_flags(p) + + # ── candle-info ────────────────────────────────────────────────────────── + p = sub.add_parser("candle-info", + help="Show opening / closing / remaining time of current candle") + _add_asset_flag(p) + p.add_argument("--period", type=int, default=60, + help="Candle period in seconds (default: 60)") + _add_account_flags(p) + + # ── realtime-price ─────────────────────────────────────────────────────── + p = sub.add_parser("realtime-price", + help="Stream live price data for an asset") + _add_asset_flag(p) + p.add_argument("--period", type=int, default=60, + help="Candle period in seconds (default: 60)") + _add_account_flags(p) + + # ── realtime-sentiment ─────────────────────────────────────────────────── + p = sub.add_parser("realtime-sentiment", + help="Stream live trader-sentiment data") + _add_asset_flag(p) + p.add_argument("--period", type=int, default=60, + help="Candle period in seconds (default: 60)") + _add_account_flags(p) + + # ── realtime-candle ────────────────────────────────────────────────────── + p = sub.add_parser("realtime-candle", + help="Stream live processed candle ticks") + _add_asset_flag(p) + p.add_argument("--period", type=int, default=60, + help="Candle period in seconds (default: 60)") + _add_account_flags(p) + + # ── buy ────────────────────────────────────────────────────────────────── + p = sub.add_parser("buy", help="Place an immediate binary option trade") + _add_asset_flag(p) + p.add_argument("--amount", type=float, default=1.0, + help="Trade amount (default: 1.0)") + p.add_argument("--direction", choices=["call", "put"], default="call", + help="call = UP, put = DOWN (default: call)") + p.add_argument("--duration", type=int, default=60, + help="Duration in seconds (default: 60)") + p.add_argument("--check-win", action="store_true", + help="Wait for the trade to settle and show win/loss") + _add_account_flags(p) + + # ── sell ───────────────────────────────────────────────────────────────── + p = sub.add_parser("sell", help="Sell / close an open position early") + p.add_argument("--id", dest="trade_id", required=True, + help="Trade ID to sell") + _add_account_flags(p) + + # ── pending ────────────────────────────────────────────────────────────── + p = sub.add_parser("pending", + help="Place a pending order (executed at a future time)") + _add_asset_flag(p) + p.add_argument("--amount", type=float, default=1.0, + help="Trade amount (default: 1.0)") + p.add_argument("--direction", choices=["call", "put"], default="call", + help="call = UP, put = DOWN (default: call)") + p.add_argument("--duration", type=int, default=60, + help="Duration in seconds (default: 60)") + p.add_argument("--open-time", dest="open_time", default=None, + help="Exact open time HH:MM (optional, defaults to next candle)") + _add_account_flags(p) + + # ── check ──────────────────────────────────────────────────────────────── + p = sub.add_parser("check", + help="Check win/loss result of a trade by ID") + p.add_argument("--id", dest="trade_id", required=True, + help="Trade ID to check") + _add_account_flags(p) + + # ── result ─────────────────────────────────────────────────────────────── + p = sub.add_parser("result", + help="Look up trade result from history by operation ID") + p.add_argument("--id", dest="operation_id", required=True, + help="Operation ID to look up") + _add_account_flags(p) + + # ── history ────────────────────────────────────────────────────────────── + p = sub.add_parser("history", help="Show recent trade history (paged)") + p.add_argument("--pages", type=int, default=1, + help="Number of history pages (default: 1)") + _add_account_flags(p) + + # ── signals ────────────────────────────────────────────────────────────── + sub.add_parser("signals", + help="Fetch current signal data from the signals stream") + + # ── indicator ──────────────────────────────────────────────────────────── + p = sub.add_parser("indicator", + help="Calculate a technical indicator (RSI, MACD, BB, …)") + _add_asset_flag(p) + p.add_argument("--name", + choices=["RSI", "MACD", "BOLLINGER", + "STOCHASTIC", "ADX", "ATR", "SMA", "EMA", "ICHIMOKU"], + default="RSI", + help="Indicator name (default: RSI)") + p.add_argument("--period", type=int, default=14, + help="Indicator period (default: 14)") + p.add_argument("--timeframe", type=int, default=60, + help="Candle timeframe in seconds (default: 60)") + _add_account_flags(p) + + # ── monitor ────────────────────────────────────────────────────────────── + p = sub.add_parser("monitor", + help="Real-time price monitor for an asset") + _add_asset_flag(p) + p.add_argument("--period", type=int, default=60, + help="Candle period in seconds (default: 60)") + + # ── strategy ───────────────────────────────────────────────────────────── + p = sub.add_parser("strategy", + help="Run Triple-Confirmation strategy (DEMO recommended)") + _add_asset_flag(p) + p.add_argument("--period", type=int, default=60, + help="Candle period in seconds (default: 60)") + p.add_argument("--auto-trade", action="store_true", + help="Automatically place trades on signals (DEMO only)") + + return parser diff --git a/pyquotex/cli/runtime.py b/pyquotex/cli/runtime.py new file mode 100644 index 00000000..919d76fb --- /dev/null +++ b/pyquotex/cli/runtime.py @@ -0,0 +1,75 @@ +"""CLI runtime helpers: connection retry, OTP prompt, demo detection.""" +import argparse +import asyncio + +from rich.console import Console +from rich.progress import Progress, SpinnerColumn, TextColumn + +from pyquotex.stable_api import Quotex + +console = Console() + +# Global to track current progress for OTP handling +current_progress: Progress | None = None + + +async def on_otp(message: str) -> str: + """Callback to handle OTP input, pausing progress spinners if active.""" + if current_progress: + current_progress.stop() + try: + pin = console.input(f"[bold yellow]πŸ” {message}[/]") + return pin + finally: + current_progress.start() + else: + return console.input(f"[bold yellow]πŸ” {message}[/]") + + +async def connect_with_retry( + client: Quotex, + is_demo: bool, + max_attempts: int = 5, +) -> bool: + """Connect to Quotex with exponential backoff on failure.""" + if await client.check_connect(): + return True + + delay = 1.0 + for attempt in range(1, max_attempts + 1): + with Progress( + SpinnerColumn(), + TextColumn( + f"[cyan]Connecting (attempt {attempt}/{max_attempts})…" + ), + transient=True, + console=console, + ) as prog: + global current_progress + current_progress = prog + prog.add_task("connect") + client.account_is_demo = 1 if is_demo else 0 + try: + check, reason = await client.connect() + finally: + current_progress = None + + if check: + console.print(f"[bold green]βœ“[/] Connected β€” {reason}") + return True + + console.print( + f"[yellow]⚠ Connection failed:[/] {reason}. " + f"Retrying in {delay:.0f}s…" + ) + await asyncio.sleep(delay) + delay = min(delay * 2, 30) + + console.print("[bold red]βœ— Could not connect after maximum attempts.[/]") + return False + + +def _is_demo(args: argparse.Namespace) -> bool: + if hasattr(args, "live") and args.live: + return False + return True diff --git a/pyquotex/exceptions.py b/pyquotex/exceptions.py new file mode 100644 index 00000000..765cccca --- /dev/null +++ b/pyquotex/exceptions.py @@ -0,0 +1,8 @@ +"""Custom exception types raised by pyquotex public APIs.""" + + +class QuotexTimeoutError(TimeoutError): + """Raised when a Quotex operation exceeds its allotted timeout. + + Wraps asyncio.TimeoutError so callers do not need to import asyncio. + """ diff --git a/pyquotex/network/login.py b/pyquotex/network/login.py index 38cc588e..5d368510 100644 --- a/pyquotex/network/login.py +++ b/pyquotex/network/login.py @@ -6,6 +6,7 @@ from bs4.element import AttributeValueList +from pyquotex._api._waits import backoff_sleep from pyquotex.config import update_session from pyquotex.network.navigator import Browser from pyquotex.utils import json_utils as json @@ -95,7 +96,11 @@ async def awaiting_pin( print("\nClosing program.") sys.exit() - await asyncio.sleep(1) + # TODO: this is a linear settle-delay between OTP entry and POST, + # not a counted retry. backoff_sleep(0) preserves ~1s pacing today; + # a proper retry counter should be introduced when this method is + # refactored to handle transient PIN-submission failures. + await backoff_sleep(0) await self.send_request( method="POST", url=f"{self.full_url}/sign-in/modal", @@ -167,7 +172,11 @@ async def _post(self, data: dict[str, Any]) -> tuple[bool, str]: "de enviar para o seu e-mail: " ) await self.awaiting_pin(data, input_message) - await asyncio.sleep(1) + # TODO: linear settle-delay before reading the post-login redirect, + # not a counted retry. backoff_sleep(0) preserves ~1s pacing today; + # a proper retry counter should be introduced if login form + # submission grows transient-failure handling. + await backoff_sleep(0) success = await self.success_login() return success diff --git a/pyquotex/stable_api.py b/pyquotex/stable_api.py index 0c0935ed..ce7ead3d 100644 --- a/pyquotex/stable_api.py +++ b/pyquotex/stable_api.py @@ -1,12 +1,12 @@ import asyncio -import itertools import logging -import time -from datetime import datetime from typing import Any, Callable -from pyquotex.utils import json_utils as json -from . import expiration +from ._api.account import AccountMixin +from ._api.assets import AssetsMixin +from ._api.history import HistoryMixin +from ._api.realtime import RealtimeMixin +from ._api.trading import TradingMixin from .api import QuotexAPI from .config import ( load_session, @@ -15,30 +15,19 @@ ) from .global_value import AuthStatus from .utils.account_type import AccountType -from .utils.indicators import TechnicalIndicators from .utils.optimization import OptimizedQuotexMixin -from .utils.processor import ( - calculate_candles, - process_candles_v2, - merge_candles, - process_tick, - aggregate_candle -) -from .utils.services import truncate logger = logging.getLogger(__name__) -# Default timeout (seconds) for async polling loops -DEFAULT_TIMEOUT = 30 - -# Monotonically increasing counter for WebSocket request indices. -# Seeded from the current millisecond timestamp, so indices remain -# browser-style large integers while being globally unique across -# all workers and loop iterations within a process (fixes #85). -_request_counter = itertools.count(int(time.time() * 1000)) - -class Quotex(OptimizedQuotexMixin): +class Quotex( + AccountMixin, + TradingMixin, + HistoryMixin, + RealtimeMixin, + AssetsMixin, + OptimizedQuotexMixin, +): def __init__( self, @@ -110,9 +99,22 @@ def websocket(self) -> Any: @staticmethod async def _check_connect(state: Any) -> bool: - """Check connection using the per-instance state object.""" - await asyncio.sleep(2) - return state.auth_status == AuthStatus.AUTHENTICATED + """Check connection using the per-instance state object. + + Waits up to ~2s for the state to settle on AUTHENTICATED; returns + as soon as the predicate is satisfied (event-driven path) or False + on timeout. Replaces an unconditional ``await asyncio.sleep(2)``. + """ + from pyquotex._api._waits import wait_until + try: + await wait_until( + lambda: state.auth_status == AuthStatus.AUTHENTICATED, + timeout=2, + poll_interval=0.05, + ) + return True + except asyncio.TimeoutError: + return state.auth_status == AuthStatus.AUTHENTICATED async def check_connect(self) -> bool: """Check connection using the current API's state.""" @@ -160,1412 +162,6 @@ async def re_subscribe_stream(self) -> None: except Exception as e: logger.warning("Failed to re-subscribe mood stream: %s", e) - async def get_instruments( - self, timeout: int = DEFAULT_TIMEOUT - ) -> list[Any]: - """Get instruments using a true event-driven approach.""" - if not self.api or not await self.check_connect(): - return [] - - if self.api.instruments and len(self.api.instruments) > 0: - return self.api.instruments - - try: - # Request instruments explicitly - await self.api.get_instruments() - # Wait for WebSocket event signaling instruments arrival - await self.api.event_registry.wait_event( - 'instruments_ready', timeout=timeout - ) - - if not self.api.instruments: - # Try one last wait if empty - await asyncio.sleep(2) - - return self.api.instruments or [] - except TimeoutError: - logger.error( - "Timeout waiting for instruments after %ds", timeout - ) - return [] - - def get_all_asset_name(self) -> list[list[str]] | None: - """ - Retrieves names of all available assets. - - Returns: - list: List of assets with ID and display name. - """ - if self.api and self.api.instruments: - return [ - [i[1], i[2].replace("\n", "")] - for i in self.api.instruments - ] - return None - - async def get_available_asset( - self, asset_name: str, force_open: bool = False - ) -> tuple[str, Any]: - """ - Retrieves detailed information for an asset if it is currently open. - - Args: - asset_name (str): Asset name. - force_open (bool, optional): Try to find the OTC version if closed. - Defaults to False. - - Returns: - tuple: (Final asset name, Asset status info). - """ - _, asset_open = await self.check_asset_open(asset_name) - if force_open and (not asset_open or not asset_open[2]): - condition_otc = "otc" not in asset_name - refactor_asset = asset_name.replace("_otc", "") - asset_name = ( - f"{asset_name}_otc" if condition_otc else refactor_asset - ) - _, asset_open = await self.check_asset_open(asset_name) - - return asset_name, asset_open - - async def check_asset_open( - self, asset_name: str - ) -> tuple[list[Any] | None, tuple[Any, Any, Any]]: - """ - Checks if a specific asset is currently available for trading. - - Args: - asset_name (str): The name of the asset. - - Returns: - tuple: (Raw instrument data, Formatted status info). - """ - instruments = await self.get_instruments() - for i in instruments: - if asset_name == i[1]: - if self.api: - self.api.current_asset = asset_name - return i, (i[0], i[2].replace("\n", ""), i[14]) - - return None, (None, None, None) - - async def get_all_assets(self) -> dict[str, str]: - """ - Retrieves a mapping of all asset names to their internal codes. - - Returns: - dict: Mapping of asset names to codes. - """ - instruments = await self.get_instruments() - for i in instruments: - if i[0] != "": - self.codes_asset[i[1]] = i[0] - - return self.codes_asset - - async def get_candles( - self, - asset: str, - end_from_time: float | None, - offset: int, - period: int, - progressive: bool = False, - timeout: int = DEFAULT_TIMEOUT - ) -> list[dict[str, Any]] | None: - """Retrieves candles for a specific asset.""" - if self.api is None: - return None - - if end_from_time is None: - end_from_time = time.time() - - index = expiration.get_timestamp() - self.api.candles.candles_data = None - - # Clear event state before requesting data to prevent - # race with WS response - await self.api.event_registry.clear_event(f'candles_ready_{asset}') - - await self.start_candles_stream(asset, period) - await self.api.get_candles(asset, index, end_from_time, offset, period) - - try: - # Wait for WebSocket event signaling candles' arrival - history_data = await self.api.event_registry.wait_event( - f'candles_ready_{asset}', timeout=timeout - ) - except TimeoutError: - logger.error( - "Timeout waiting for candles for %s after %ds", - asset, timeout - ) - return None - - # Pass the asset-specific history directly to avoid - # multi-asset state races - candles = self.prepare_candles(asset, period, history_data) - - if progressive: - return self.api.historical_candles.get("data", {}) - - return candles - - async def _fetch_historical_batch( - self, - asset: str, - fetch_time: int, - offset: int, - period: int, - index: int, - timeout: int - ) -> dict[str, Any] | None: - """Low-level batch fetcher for a specific time point and index.""" - if self.api is None: - return None - - payload = { - "asset": asset, - "index": index, - "time": fetch_time, - "offset": offset, - "period": period - } - ws_msg = f'42["history/load",{json.dumps_str(payload)}]' - - # Clear specific event to ensure fresh wait - event_name = f'candles_ready_{asset}_{index}' - await self.api.event_registry.clear_event(event_name) - - await self.api.send_websocket_request(ws_msg) - - try: - return await self.api.event_registry.wait_event( - event_name, timeout=timeout - ) - except TimeoutError: - logger.warning( - "Batch fetch timeout at %d (index %d) for %s", - fetch_time, index, asset - ) - return None - - def _parse_historical_candles( - self, raw_data: dict[str, Any] - ) -> list[dict[str, Any]]: - """Standardizes raw candle data into a uniform list of dicts.""" - raw_candles = raw_data.get("data", []) or raw_data.get("candles", []) - if not raw_candles: - return [] - - parsed = [] - for c in raw_candles: - if isinstance(c, list) and len(c) >= 5: - parsed.append({ - "time": int(c[0]), - "open": float(c[1]), - "close": float(c[2]), - "high": float(c[3]), - "low": float(c[4]) - }) - elif isinstance(c, dict) and "time" in c: - parsed.append(c) - return parsed - - # https://t.me/pyquotex/1/16064 - # https://github.com/usmanch96/quotex-historical-data - async def get_historical_candles( - self, - asset: str, - amount_of_seconds: int, - period: int, - timeout: int = DEFAULT_TIMEOUT, - max_workers: int = 5, - progress_callback: Callable[[int, int, int, str], None] | None = None - ) -> list[dict[str, Any]]: - """ - Retrieves extensive historical candle data using a hybrid parallel-sequential approach. - Divides the total time range into blocks assigned to parallel workers. - Each worker fetches its block sequentially to ensure no gaps. - """ - all_candles: dict[int, dict[str, Any]] = {} - current_time = int(time.time()) - target_start_time = current_time - amount_of_seconds - - # Divide total range into large blocks for each worker - block_size = amount_of_seconds // max_workers - chunk_seconds = period * 200 # Request size per batch - semaphore = asyncio.Semaphore(max_workers) - - async def worker(start_t: int, end_t: int, worker_id: int) -> list[dict[str, Any]]: - worker_candles = {} - worker_label = f"Worker-{worker_id}" - async with semaphore: - oldest_t = start_t - while oldest_t > end_t: - # Use a monotonically-increasing counter so that parallel - # workers and back-to-back iterations within the same worker - # never produce the same index β€” prevents event-registry - # key collisions where one worker steals another's response. - index = next(_request_counter) - - batch_data = await self._fetch_historical_batch( - asset, oldest_t, chunk_seconds, period, index, timeout - ) - - if not batch_data: - # Gap or error, jump back to try continuing - oldest_t -= chunk_seconds - continue - - new_batch = self._parse_historical_candles(batch_data) - if not new_batch: - oldest_t -= chunk_seconds - continue - - # Process and find new boundary - batch_times = [] - for c in new_batch: - ts = c['time'] - if ts >= end_t and ts <= start_t: - worker_candles[ts] = c - batch_times.append(ts) - - if not batch_times: - oldest_t -= chunk_seconds - continue - - batch_times.sort() - new_oldest = batch_times[0] - - if progress_callback: - # Report progress based on how much of the block is covered - progress_callback( - start_t - new_oldest, - start_t - end_t, - len(worker_candles), - worker_label - ) - - if new_oldest >= oldest_t: - oldest_t -= chunk_seconds - else: - oldest_t = new_oldest - - # Small throttle - await asyncio.sleep(0.1) - - return list(worker_candles.values()) - - await self.start_candles_stream(asset, period) - - # Launch workers for each block - tasks = [] - for i in range(max_workers): - s = current_time - (i * block_size) - e = max(target_start_time, s - block_size) - tasks.append(worker(s, e, i)) - - results = await asyncio.gather(*tasks) - - # Merge results and deduplicate - for batch in results: - for c in batch: - all_candles[c['time']] = c - - return sorted(all_candles.values(), key=lambda x: x['time']) - - async def get_candles_deep( - self, *args: Any, **kwargs: Any - ) -> list[dict[str, Any]]: - """Deprecated alias for get_historical_candles.""" - logger.warning( - "get_candles_deep is deprecated, " - "use get_historical_candles instead." - ) - return await self.get_historical_candles(*args, **kwargs) - - async def get_history_line( - self, - asset: str, - end_from_time: float, - offset: int, - timeout: int = DEFAULT_TIMEOUT - ) -> dict[str, Any] | None: - """Retrieves historical price line data for an asset.""" - if self.api is None: - return None - - index = expiration.get_timestamp() - self.api.current_asset = asset - # Reset to None (not {}) so the poll loop below can detect arrival. - # An empty dict is a valid response; None is the sentinel for - # "not yet received". - self.api.historical_candles = None - await self.start_candles_stream(asset) - await self.api.get_history_line( - self.codes_asset[asset], index, end_from_time, offset - ) - start_time = time.time() - while await self.check_connect() and self.api.historical_candles is None: - if time.time() - start_time > timeout: - logger.error( - "Timeout waiting for history line data for %s.", - asset - ) - return None - await asyncio.sleep(0.2) - return self.api.historical_candles - - async def get_candle_v2( - self, asset: str, period: int, timeout: int = DEFAULT_TIMEOUT - ) -> list[dict[str, Any]] | None: - """Retrieves candles using the v2 API path.""" - if self.api is None: - return None - - self.api.candle_v2_data[asset] = None - await self.start_candles_stream(asset, period) - start_time = time.time() - # Poll until data arrives or timeout is reached. - # Previous code returned None on every iteration because the - # return statement was inside the while-body instead of the - # timeout branch, so data was never awaited. - while self.api.candle_v2_data[asset] is None: - if time.time() - start_time > timeout: - logger.error( - "Timeout waiting for get_candle_v2 data for %s.", - asset - ) - return None - await asyncio.sleep(0.2) - candles = self.prepare_candles(asset, period) - return candles - - def prepare_candles( - self, - asset: str, - period: int, - history: list[Any] | None = None - ) -> list[dict[str, Any]]: - """Prepare candles data for a specified asset.""" - if self.api is None: - return [] - - # Use provided history if available (from event response), - # otherwise fallback to shared state - history_data = ( - history if history is not None else self.api.candles.candles_data - ) - candles_data = calculate_candles(history_data, period) - candles_v2_data = process_candles_v2( - self.api.candle_v2_data, asset, candles_data - ) - new_candles = merge_candles(candles_v2_data) - - return new_candles - - async def connect(self) -> tuple[bool, str]: - """Establishes a connection to the Quotex API.""" - if self.api and await self.check_connect(): - return True, "Already connected" - self.api = QuotexAPI( - self.host, - self.email, - self.password, - self.lang, - resource_path=self.resource_path, - user_data_dir=self.user_data_dir, - proxies=self.proxies, - on_otp_callback=self.on_otp_callback - ) - - self.api.trace_ws = self.debug_ws_enable - self.api.session_data = self.session_data - self.api.current_asset = self.asset_default - self.api.current_period = self.period_default - self.api.state.SSID = self.session_data.get("token") - - if not self.session_data.get("token"): - check, reason = await self.api.authenticate() - if not check: - return check, reason - - check, reason = await self.api.connect(self.account_is_demo == AccountType.DEMO) - if not await self.check_connect(): - logger.error( - "Websocket failed to connect or connection was rejected." - ) - if "token" in self.session_data: - self.session_data["token"] = None - return False, "Websocket connection rejected." - - return check, reason - - async def reconnect(self) -> None: - """Attempts to re-authenticate and refresh the session.""" - if self.api: - await self.api.authenticate() - - def set_account_mode(self, balance_mode: str = "PRACTICE") -> None: - """Set active account `real` or `practice`""" - if balance_mode.upper() == "REAL": - self.account_is_demo = AccountType.REAL - elif balance_mode.upper() == "PRACTICE": - self.account_is_demo = AccountType.DEMO - else: - raise ValueError( - f"Invalid balance mode '{balance_mode}'. " - "Use 'REAL' or 'PRACTICE'." - ) - - async def change_account(self, balance_mode: str, tournament_id: int = 0) -> None: - """Change active account `real` or `practice` or a specific tournament""" - self.account_is_demo = ( - AccountType.REAL if balance_mode.upper() == "REAL" - else AccountType.DEMO - ) - if self.api: - await self.api.change_account(self.account_is_demo, tournament_id=tournament_id) - - async def change_time_offset(self, time_offset: int) -> Any: - """Updates the timezone/time offset on the server.""" - if self.api: - return await self.api.change_time_offset(time_offset) - return None - - async def get_trader_history( - self, account_type: int, page_number: int - ) -> dict[str, Any]: - """Retrieves trade history for a specific account and page.""" - if self.api: - return await self.api.get_trader_history(account_type, page_number) - return {} - - async def edit_practice_balance( - self, - amount: float | int | None = None, - timeout: int = DEFAULT_TIMEOUT - ) -> dict[str, Any]: - """Refills the demo account balance.""" - if self.api is None: - raise RuntimeError("API not initialized") - - self.api.training_balance_edit_request = None - await self.api.edit_training_balance( - amount if amount is not None else 0 - ) - start = time.time() - while self.api.training_balance_edit_request is None: - if time.time() - start > timeout: - raise TimeoutError( - "Timeout waiting for practice balance edit response." - ) - await asyncio.sleep(0.2) - return self.api.training_balance_edit_request - - async def get_balance(self, timeout: int = DEFAULT_TIMEOUT) -> float: - """Get account balance using a true event-driven approach.""" - if not self.api or not await self.check_connect(): - raise RuntimeError("Not connected to Quotex") - - if self.api.account_balance is not None: - if self.api.account_type == AccountType.DEMO: - balance = self.api.account_balance.get("demoBalance", 0) - else: - balance = self.api.account_balance.get("liveBalance", 0) - return float(f"{truncate(balance + self.get_profit(), 2):.2f}") - - try: - # Wait for WebSocket event signaling balance arrival - await self.api.event_registry.wait_event( - 'balance_ready', timeout=timeout - ) - except TimeoutError: - logger.error(f"Timeout waiting for balance after {timeout}s") - raise - - if self.api.account_balance is None: - return 0.0 - - if self.api.account_type == AccountType.DEMO: - balance = self.api.account_balance.get("demoBalance", 0) - else: - balance = self.api.account_balance.get("liveBalance", 0) - return float(f"{truncate(balance + self.get_profit(), 2):.2f}") - - async def calculate_indicator( - self, - asset: str, - indicator: str, - params: dict[str, Any] | None = None, - history_size: int = 3600, - timeframe: int = 60 - ) -> dict[str, Any]: - """Calcula indicadores tΓ©cnicos para um ativo dado.""" - if params is None: - params = {} - - valid_timeframes = [60, 300, 900, 1800, 3600, 7200, 14400, 86400] - if timeframe not in valid_timeframes: - return { - "error": ( - f"Timeframe invΓ‘lido. " - f"Valores permitidos: {valid_timeframes}" - ) - } - - adjusted_history = max(history_size, timeframe * 50) - - candles = await self.get_candles( - asset, time.time(), adjusted_history, timeframe - ) - - if not candles: - return { - "error": f"NΓ£o hΓ‘ dados disponΓ­veis para o ativo {asset}" - } - - prices = [float(candle["close"]) for candle in candles] - highs = [float(candle["high"]) for candle in candles] - lows = [float(candle["low"]) for candle in candles] - timestamps = [candle["time"] for candle in candles] - - indicators = TechnicalIndicators() - indicator = indicator.upper() - - try: - if indicator == "RSI": - period = params.get("period", 14) - values = indicators.calculate_rsi(prices, period) - return { - "rsi": values, - "current": values[-1] if values else None, - "history_size": len(values), - "timeframe": timeframe, - "timestamps": ( - timestamps[-len(values):] if values else [] - ) - } - - elif indicator == "MACD": - fast_period = params.get("fast_period", 12) - slow_period = params.get("slow_period", 26) - signal_period = params.get("signal_period", 9) - macd_data = indicators.calculate_macd( - prices, fast_period, slow_period, signal_period - ) - macd_data["timeframe"] = timeframe - macd_data["timestamps"] = ( - timestamps[-len(macd_data["macd"]):] - if macd_data["macd"] - else [] - ) - return macd_data - - elif indicator == "SMA": - period = params.get("period", 20) - values = indicators.calculate_sma(prices, period) - return { - "sma": values, - "current": values[-1] if values else None, - "history_size": len(values), - "timeframe": timeframe, - "timestamps": ( - timestamps[-len(values):] if values else [] - ) - } - - elif indicator == "EMA": - period = params.get("period", 20) - values = indicators.calculate_ema(prices, period) - return { - "ema": values, - "current": values[-1] if values else None, - "history_size": len(values), - "timeframe": timeframe, - "timestamps": ( - timestamps[-len(values):] if values else [] - ) - } - - elif indicator == "BOLLINGER": - period = params.get("period", 20) - num_std = params.get("std", 2) - bb_data = indicators.calculate_bollinger_bands( - prices, period, num_std - ) - bb_data["timeframe"] = timeframe - bb_data["timestamps"] = ( - timestamps[-len(bb_data["middle"]):] - if bb_data["middle"] - else [] - ) - return bb_data - - elif indicator == "STOCHASTIC": - k_period = params.get("k_period", 14) - d_period = params.get("d_period", 3) - stoch_data = indicators.calculate_stochastic( - prices, highs, lows, k_period, d_period - ) - stoch_data["timeframe"] = timeframe - stoch_data["timestamps"] = ( - timestamps[-len(stoch_data["k"]):] - if stoch_data["k"] - else [] - ) - return stoch_data - - elif indicator == "ATR": - period = params.get("period", 14) - values = indicators.calculate_atr(highs, lows, prices, period) - return { - "atr": values, - "current": values[-1] if values else None, - "history_size": len(values), - "timeframe": timeframe, - "timestamps": ( - timestamps[-len(values):] if values else [] - ) - } - - elif indicator == "ADX": - period = params.get("period", 14) - adx_data = indicators.calculate_adx( - highs, lows, prices, period - ) - adx_data["timeframe"] = timeframe - adx_data["timestamps"] = ( - timestamps[-len(adx_data["adx"]):] - if adx_data["adx"] - else [] - ) - return adx_data - - elif indicator == "ICHIMOKU": - tenkan_period = params.get("tenkan_period", 9) - kijun_period = params.get("kijun_period", 26) - senkou_b_period = params.get("senkou_b_period", 52) - ichimoku_data = indicators.calculate_ichimoku( - highs, lows, tenkan_period, kijun_period, senkou_b_period - ) - ichimoku_data["timeframe"] = timeframe - ichimoku_data["timestamps"] = ( - timestamps[-len(ichimoku_data["tenkan"]):] - if ichimoku_data["tenkan"] - else [] - ) - return ichimoku_data - - else: - return {"error": f"Indicador '{indicator}' nΓ£o suportado"} - - except Exception as e: - return {"error": f"Erro calculando o indicador: {str(e)}"} - - async def subscribe_indicator( - self, - asset: str, - indicator: str, - params: dict[str, Any] | None = None, - callback: Callable[[dict[str, Any]], Any] | None = None, - timeframe: int = 60 - ) -> None: - """ - Subscribes to real-time indicator updates with high performance. - - Features: - - Event-driven: Recalculates only when a new candle is generated. - - Efficient: Pre-loads history and maintains local data buffers. - - Robust: Properly handles all indicator parameters and edge cases. - """ - if params is None: - params = {} - if not callback: - raise ValueError("Callback function must be provided") - - indicator_upper = indicator.upper() - min_periods = { - "RSI": 14, "MACD": 26, "BOLLINGER": 20, "STOCHASTIC": 14, - "ADX": 14, "ATR": 14, "SMA": 20, "EMA": 20, "ICHIMOKU": 52 - } - required_periods = min_periods.get(indicator_upper, 20) - - try: - await self.start_candles_stream(asset, timeframe) - - # 1. Initial Data Loading - # Fetch history to satisfy the indicator's window - history = await self.get_candles( - asset, - time.time(), - timeframe * (required_periods + 20), - timeframe - ) - - if not history: - logger.warning("No history found for %s, waiting...", asset) - history = [] - - # Maintain local buffers to avoid repeated sorting/conversions - prices = [float(c["close"]) for c in history] - highs = [float(c["high"]) for c in history] - lows = [float(c["low"]) for c in history] - last_ts = history[-1]["time"] if history else 0 - - ti = TechnicalIndicators() - event_name = f"candle_generated_{asset}_{timeframe}" - - while await self.check_connect(): - try: - # 2. Wait for New Candle Event - try: - # Wait for the next candle closure - msg_data = await self.api.event_registry.wait_event( - event_name, timeout=timeframe + 10 - ) - except TimeoutError: - # Check if data arrived but event was missed - msg_data = self.api.candle_generated_check[ - str(asset) - ].get(timeframe) - - if not msg_data: - await asyncio.sleep(1) - continue - - current_ts = msg_data.get("index", 0) - if current_ts <= last_ts: - await asyncio.sleep(1) - continue - - # 3. Update Buffers with New Closed Candle - prices.append(float(msg_data["close"])) - highs.append(float(msg_data["high"])) - lows.append(float(msg_data["low"])) - last_ts = current_ts - - # Cap buffers to prevent memory leaks (e.g., 500 candles) - if len(prices) > 500: - prices = prices[-500:] - highs = highs[-500:] - lows = lows[-500:] - - if len(prices) < required_periods: - continue - - # 4. Calculate Indicator - result: dict[str, Any] = { - "time": last_ts, - "timeframe": timeframe, - "asset": asset, - "indicator": indicator_upper - } - - if indicator_upper == "RSI": - period = params.get("period", 14) - vals = ti.calculate_rsi(prices, period) - result["value"] = vals[-1] if vals else None - result["all_values"] = vals - - elif indicator_upper == "MACD": - fast = params.get("fast_period", 12) - slow = params.get("slow_period", 26) - sig = params.get("signal_period", 9) - result.update(ti.calculate_macd(prices, fast, slow, sig)) - - elif indicator_upper == "BOLLINGER": - period = params.get("period", 20) - std = params.get("std", 2) - result.update( - ti.calculate_bollinger_bands(prices, period, std) - ) - - elif indicator_upper == "STOCHASTIC": - k = params.get("k_period", 14) - d = params.get("d_period", 3) - result.update( - ti.calculate_stochastic(prices, highs, lows, k, d) - ) - - elif indicator_upper == "SMA": - period = params.get("period", 20) - vals = ti.calculate_sma(prices, period) - result["value"] = vals[-1] if vals else None - result["all_values"] = vals - - elif indicator_upper == "EMA": - period = params.get("period", 20) - vals = ti.calculate_ema(prices, period) - result["value"] = vals[-1] if vals else None - result["all_values"] = vals - - elif indicator_upper == "ADX": - period = params.get("period", 14) - result.update( - ti.calculate_adx(highs, lows, prices, period) - ) - - elif indicator_upper == "ATR": - period = params.get("period", 14) - vals = ti.calculate_atr(highs, lows, prices, period) - result["value"] = vals[-1] if vals else None - result["all_values"] = vals - - elif indicator_upper == "ICHIMOKU": - t = params.get("tenkan", 9) - k = params.get("kijun", 26) - s = params.get("senkou", 52) - result.update( - ti.calculate_ichimoku(highs, lows, t, k, s) - ) - - else: - result["error"] = f"Indicator {indicator} not supported" - - # 5. Trigger Callback - await callback(result) - - except Exception as e: - logger.warning("Error in indicator loop: %s", e) - await asyncio.sleep(1) - - finally: - try: - await self.stop_candles_stream(asset) - except Exception: - pass - - async def get_profile(self) -> Any: - """Retrieves and parses the user profile data.""" - if self.api: - return await self.api.get_profile() - return None - - async def get_server_time(self) -> int: - """Retrieves and syncs the server time.""" - if self.api is None: - return int(time.time()) - - user_settings = await self.get_profile() - offset_zone = user_settings.offset if user_settings else 0 - self.api.timesync.server_timestamp = ( - expiration.get_server_timer(offset_zone) - ) - return self.api.timesync.server_timestamp - - async def get_history(self) -> list[dict[str, Any]]: - """Get the trader's history based on account type.""" - if self.api is None: - return [] - - account_type = AccountType.DEMO if self.account_is_demo else AccountType.REAL - history = await self.api.get_trader_history(account_type, page=1) - return list(history) - - async def buy( - self, - amount: float, - asset: str, - direction: str, - duration: int, - time_mode: str = "TIME" - ) -> tuple[bool, Any]: - """ - Places a buy order for a specified asset, direction, and duration. - Waits for WebSocket confirmation of the buy and returns the result. - """ - if self.api is None: - return False, "API not initialized" - - self.api.buy_id = None - self.api.buy_successful = None - request_id = expiration.get_timestamp() - is_fast_option = time_mode.upper() == "TIME" - - # Clear event state before requesting buy to prevent - # race with WS response - await self.api.event_registry.clear_event('buy_confirmed') - - # Ensure price data is arriving and server is synced - await self.start_realtime_price(asset, duration) - await self.get_server_time() - await self.api.settings_apply(asset, duration, is_fast_option) - - await self.api.buy( - amount, asset, direction, duration, request_id, is_fast_option, time_mode - ) - - timeout = duration + 5 if duration else 30 - - try: - # Wait for WebSocket event signaling buy confirmation - event_data = await self.api.event_registry.wait_event( - 'buy_confirmed', timeout=timeout - ) - except TimeoutError as e: - logger.error(str(e)) - return False, "Timeout" - - if self.api.state.check_websocket_if_error: - return False, self.api.state.websocket_error_reason - - if ( - event_data - and isinstance(event_data, dict) - and "error" in event_data - ): - return False, event_data["error"] - - return True, event_data - - async def open_pending( - self, - amount: float, - asset: str, - direction: str, - duration: int, - open_time: str | None = None - ) -> tuple[bool, Any]: - """Places a pending order to be executed at a specific future time.""" - if self.api is None: - return False, "API not initialized" - - self.api.pending_id = None - user_settings = await self.get_profile() - offset_zone = user_settings.offset if user_settings else 0 - open_time_int = int( - expiration.get_next_timeframe( - int(time.time()), - offset_zone, - duration, - open_time - ) - ) - await self.api.open_pending( - amount, asset, direction, duration, open_time_int - ) - start = time.time() - while await self.check_connect() and self.api.pending_id is None: - if time.time() - start > 30: - logger.error("Timeout pending order.") - return False, "Timeout waiting for pending ID" - await asyncio.sleep(0.2) - if self.api.state.check_websocket_if_error: - return False, self.api.state.websocket_error_reason - - # Loop exited normally β€” pending_id was set (success path). - # Only follow up when we actually have an id; if the loop was - # broken by disconnect the early returns above already handled it. - if self.api.pending_id is not None: - status_buy = True - await self.api.instruments_follow( - amount, asset, direction, duration, open_time_int - ) - - return status_buy, self.api.pending_successful - - async def sell_option( - self, - options_ids: list[str] | str, - timeout: int = DEFAULT_TIMEOUT - ) -> dict[str, Any]: - """Sells active options back to the broker before expiration.""" - if self.api is None: - raise RuntimeError("API not initialized") - - # Reset sentinel BEFORE sending the request β€” if the WS response - # arrives before the next line, it must not be wiped out. - self.api.sold_options_respond = None - await self.api.sell_option(options_ids) - 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) - return self.api.sold_options_respond - - def get_payment(self) -> dict[str, Any]: - """Retrieves the payout/payment percentages for all instruments.""" - if self.api is None: - return {} - - assets_data = {} - for i in self.api.instruments: - assets_data[i[2].replace("\n", "")] = { - "turbo_payment": i[18], - "payment": i[5], - "profit": { - "1M": i[-9], - "5M": i[-8] - }, - "open": i[14] - } - - return assets_data - - def get_payout_by_asset( - self, asset_name: str, timeframe: str = "1" - ) -> float | dict[str, Any] | None: - """Retrieves the payout percentage for a specific asset and - timeframe.""" - if self.api is None: - return None - - assets_data = {} - for i in self.api.instruments: - if asset_name == i[1]: - assets_data[i[1].replace("\n", "")] = { - "turbo_payment": i[18], - "payment": i[5], - "profit": { - "24H": i[-10], - "1M": i[-9], - "5M": i[-8] - }, - "open": i[14] - } - break - - data = assets_data.get(asset_name) - if data is None: - return None - - if timeframe == "all": - return data.get("profit") - - profit = data.get("profit") - if profit: - return profit.get(f"{timeframe}M") - return None - - async def start_remaing_time(self) -> None: - """Debug helper to log the remaining time until the next server - expiration.""" - if self.api is None: - return - - now_stamp = datetime.fromtimestamp(expiration.get_timestamp()) - expiration_stamp = datetime.fromtimestamp( - self.api.timesync.server_timestamp - ) - remaing_time = int((expiration_stamp - now_stamp).total_seconds()) - while remaing_time >= 0: - remaing_time -= 1 - logger.debug("Remaining %d seconds...", max(remaing_time, 0)) - await asyncio.sleep(1) - - async def check_win( - self, order_id: str | int, duration: int = 0 - ) -> tuple[str, float]: - """Checks if a trade operation resulted in a win based on its ID.""" - if self.api is None: - return "loss", 0.0 - - start_time = time.time() - while await self.check_connect(): - # Safety timeout after 5 minutes - if time.time() - start_time > 300: - break - - data_dict = self.api.listinfodata.get(order_id) - if data_dict and data_dict.get("game_state") == 1: - self.api.listinfodata.delete(order_id) - win = data_dict.get("win", "loss") - profit = float(data_dict.get("profit", 0)) - return win, profit - await asyncio.sleep(0.2) - - return "loss", 0.0 - - async def start_candles_stream( - self, asset: str = "EURUSD", period: int = 0 - ) -> None: - """Start streaming candle data for a specified asset.""" - if self.api: - self.api.current_asset = asset - await self.api.subscribe_realtime_candle(asset, period) - await self.api.chart_notification(asset) - await self.api.follow_candle(asset) - - async def store_settings_apply( - self, - asset: str = "EURUSD", - period: int = 0, - time_mode: str = "TIMER", - deal: int = 5, - percent_mode: bool = False, - percent_deal: int = 1, - timeout: int = DEFAULT_TIMEOUT - ) -> dict[str, Any]: - """Applies trading settings and retrieves updated settings.""" - if self.api is None: - raise RuntimeError("API not initialized") - - is_fast_option = False if time_mode.upper() == "TIMER" else True - self.api.current_asset = asset - await self.api.settings_apply( - asset, - period, - is_fast_option=is_fast_option, - deal=deal, - percent_mode=percent_mode, - percent_deal=percent_deal - ) - await asyncio.sleep(0.2) - start = time.time() - while True: - if self.api.settings_list: - investments_settings = self.api.settings_list - break - if time.time() - start > timeout: - raise TimeoutError("Timeout waiting for settings response.") - await asyncio.sleep(0.2) - - return investments_settings - - 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) - - async def start_signals_data(self) -> None: - """Subscribes to the global trading signals stream.""" - if self.api: - await self.api.signals_subscribe() - - async def opening_closing_current_candle( - self, asset: str, period: int = 0 - ) -> dict[str, Any]: - """Calculates the opening, closing, and remaining time for the - current candle.""" - candles_data: dict[int, Any] = {} - candles_tick = await self.get_realtime_candles(asset) - logger.debug("Candles tick data: %s", candles_tick) - # aggregate_candle expects dict[int, Any] for tick - # This part might need adjustment depending on what - # get_realtime_candles returns - aggregate = aggregate_candle( - candles_tick if isinstance(candles_tick, dict) else {}, - candles_data - ) - logger.debug("Aggregated candle: %s", aggregate) - if not aggregate: - return {} - candles_dict = list(aggregate.values())[0] - candles_dict['opening'] = candles_dict.pop('timestamp') - candles_dict['closing'] = candles_dict['opening'] + period - candles_dict['remaining'] = candles_dict['closing'] - int(time.time()) - return candles_dict - - async def start_realtime_price( - self, - asset: str, - period: int = 0, - timeout: int = DEFAULT_TIMEOUT - ) -> dict[str, Any]: - """Starts following real-time price for an asset.""" - if self.api is None: - raise RuntimeError("API not initialized") - - await self.start_candles_stream(asset, period) - start = time.time() - while True: - if self.api.realtime_price.get(asset): - return self.api.realtime_price - if time.time() - start > timeout: - raise TimeoutError( - f"Timeout waiting for realtime price data for {asset}." - ) - await asyncio.sleep(0.2) - - async def start_realtime_sentiment( - self, - asset: str, - period: int = 0, - timeout: int = DEFAULT_TIMEOUT - ) -> dict[str, Any]: - """Starts following real-time trader sentiment for an asset.""" - if self.api is None: - raise RuntimeError("API not initialized") - - await self.start_candles_stream(asset, period) - start = time.time() - while True: - if self.api.realtime_sentiment.get(asset): - return self.api.realtime_sentiment[asset] - if time.time() - start > timeout: - raise TimeoutError( - f"Timeout waiting for realtime sentiment data for {asset}." - ) - await asyncio.sleep(0.2) - - async def start_realtime_candle( - self, - asset: str, - period: int = 0, - timeout: int = DEFAULT_TIMEOUT - ) -> dict[int, Any]: - """Starts following and processing real-time candle ticks for - an asset.""" - if self.api is None: - raise RuntimeError("API not initialized") - - await self.start_candles_stream(asset, period) - data: dict[int, Any] = {} - start = time.time() - while True: - candle_data = self.api.realtime_candles.get(asset) - if candle_data: - if isinstance(candle_data, list) and len(candle_data) >= 4: - return process_tick(candle_data, period, data) - return data - if time.time() - start > timeout: - raise TimeoutError( - f"Timeout waiting for realtime candle data for {asset}." - ) - await asyncio.sleep(0.2) - - async def get_realtime_candles( - self, asset: str - ) -> list[Any] | dict[Any, Any]: - """Retrieves current real-time price history for an asset from - shared state.""" - if self.api: - return self.api.realtime_candles.get(asset, []) - return [] - - async def get_realtime_sentiment(self, asset: str) -> dict[str, Any]: - """Retrieves current sentiment data for an asset from shared state.""" - if self.api: - return self.api.realtime_sentiment.get(asset, {}) - return {} - - async def get_realtime_price(self, asset: str) -> list[dict[str, Any]]: - """Retrieves current real-time price history for an asset from - shared state.""" - if self.api: - # Convert deque to list for compatibility with existing strategies - return list(self.api.realtime_price.get(asset, [])) - return [] - - def get_signal_data(self) -> dict[str, Any]: - """Retrieves the list of active signals received via signals stream.""" - if self.api: - return self.api.signal_data - return {} - - def get_profit(self) -> float: - """Retrieves the profit amount from the current active operation.""" - if self.api: - return self.api.profit_in_operation or 0.0 - return 0.0 - - async def get_result(self, operation_id: str) -> tuple[str | None, Any]: - """Check if the trade is a win based on its ID.""" - data_history = await self.get_history() - for item in data_history: - if str(item.get("ticket")) == operation_id: - profit = float(item.get("profitAmount", 0)) - status = "win" if profit > 0 else "loss" - return status, item - - return None, "OperationID Not Found." - - async def start_candles_one_stream(self, asset: str, size: int) -> bool: - """Internal helper to start a single candle stream.""" - if self.api is None: - return False - - if not (str(asset + "," + str(size)) in self.subscribe_candle): - self.subscribe_candle.append((asset + "," + str(size))) - start = time.time() - # This part assumes api has these attributes, might need check - if not hasattr(self.api, "candle_generated_check"): - return False - - self.api.candle_generated_check[str(asset)][int(size)] = {} - # Send the subscribe request exactly once before polling. - # Calling follow_candle() inside the loop would spam the server - # with up to 100 subscribe messages (20 s / 0.2 s) before data - # arrives β€” a ban/rate-limit risk explicitly warned about in README. - try: - await self.api.follow_candle(self.codes_asset[asset]) - except Exception as e: - logger.error('**error** start_candles_stream reconnect: %s', e) - await self.connect() - while True: - if time.time() - start > 20: - logger.error( - '**error** start_candles_one_stream late for 20 sec' - ) - return False - try: - if self.api.candle_generated_check[str(asset)][int(size)]: - return True - except (KeyError, TypeError): - pass - await asyncio.sleep(0.2) - - async def start_candles_all_size_stream(self, asset: str) -> bool: - """Internal helper to subscribe to all candle sizes for an asset.""" - if self.api is None: - return False - - if not hasattr(self.api, "candle_generated_all_size_check"): - return False - - 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)) - start = time.time() - while await self.check_connect(): - if self.api is None: break - if time.time() - start > 20: - logger.error( - f'**error** fail {asset} ' - 'start_candles_all_size_stream late for 10 sec' - ) - return False - try: - if self.api.candle_generated_all_size_check[str(asset)]: - return True - except (KeyError, TypeError): - pass - try: - # Assuming api has subscribe_all_size - if hasattr(self.api, "subscribe_all_size"): - self.api.subscribe_all_size(self.codes_asset[asset]) - except Exception as e: - logger.error( - '**error** start_candles_all_size_stream reconnect: %s', e - ) - await self.connect() - await asyncio.sleep(0.2) - return False - - async def start_mood_stream( - self, asset: str, instrument: str = "turbo-option" - ) -> None: - """Internal helper to start the mood (sentiment) stream.""" - if self.api is None: - return - - if asset not in self.subscribe_mood: - self.subscribe_mood.append(asset) - while True: - if self.api is None: break - if hasattr(self.api, "subscribe_Traders_mood"): - self.api.subscribe_Traders_mood(asset, instrument) - try: - if hasattr(self.api, "traders_mood"): - asset_code = self.codes_asset[asset] - self.api.traders_mood[asset_code] = asset_code - break - finally: - await asyncio.sleep(0.2) - async def close(self) -> bool: """Closes the API connection and stops all tasks.""" if self.api: diff --git a/scripts/snapshot_api_surface.py b/scripts/snapshot_api_surface.py new file mode 100644 index 00000000..bb9178dc --- /dev/null +++ b/scripts/snapshot_api_surface.py @@ -0,0 +1,24 @@ +"""Generate a baseline snapshot of Quotex's public API surface. + +Run once before the refactor and commit tests/fixtures/api_surface.json. +The regression test in tests/test_api_surface.py compares the live class +against this snapshot. +""" +import json +from pathlib import Path + +from pyquotex.stable_api import Quotex +from scripts.surface_utils import extract_surface + +OUTPUT = Path(__file__).parent.parent / "tests" / "fixtures" / "api_surface.json" + + +def main() -> None: + surface = extract_surface(Quotex, full_signatures=True) + OUTPUT.parent.mkdir(parents=True, exist_ok=True) + OUTPUT.write_text(json.dumps(surface, indent=2, sort_keys=True) + "\n") + print(f"Wrote {len(surface)} public symbols to {OUTPUT}") + + +if __name__ == "__main__": + main() diff --git a/scripts/surface_utils.py b/scripts/surface_utils.py new file mode 100644 index 00000000..60b946c6 --- /dev/null +++ b/scripts/surface_utils.py @@ -0,0 +1,73 @@ +"""Shared surface-introspection helpers used by the snapshot script and the regression tests. + +Public-method discovery is intentionally underscore-prefixed: we treat any name +starting with `_` as private and exclude it from the public surface. +""" +import inspect +from typing import Any + + +def serialize_signature(sig: inspect.Signature) -> dict: + """Serialize an inspect.Signature into a JSON-friendly dict.""" + params = [] + for name, param in sig.parameters.items(): + params.append( + { + "name": name, + "kind": str(param.kind), + "default": ( + "" + if param.default is inspect.Parameter.empty + else repr(param.default) + ), + "annotation": ( + "" + if param.annotation is inspect.Parameter.empty + else str(param.annotation) + ), + } + ) + return { + "parameters": params, + "return_annotation": ( + "" + if sig.return_annotation is inspect.Signature.empty + else str(sig.return_annotation) + ), + } + + +def extract_surface(cls: type, *, full_signatures: bool) -> dict[str, dict[str, Any]]: + """Walk dir(cls) and return a public-surface dict. + + full_signatures=True (used by the snapshot script) records each method's full + signature payload. full_signatures=False (used by the regression test) records + only the ordered parameter names β€” enough to detect signature drift without the + test having to depend on Python-version-specific annotation rendering. + """ + surface: dict[str, dict[str, Any]] = {} + for name in sorted(dir(cls)): + if name.startswith("_"): + continue + attr = getattr(cls, name) + if callable(attr): + try: + sig = inspect.signature(attr) + except (TypeError, ValueError): + entry: dict[str, Any] = {"kind": "callable"} + if full_signatures: + entry["signature"] = None + surface[name] = entry + continue + entry = {"kind": "method"} + if full_signatures: + entry["signature"] = serialize_signature(sig) + else: + entry["params"] = [p.name for p in sig.parameters.values()] + surface[name] = entry + else: + entry = {"kind": "attribute"} + if full_signatures: + entry["type"] = type(attr).__name__ + surface[name] = entry + return surface diff --git a/tests/fixtures/api_surface.json b/tests/fixtures/api_surface.json new file mode 100644 index 00000000..f9384c44 --- /dev/null +++ b/tests/fixtures/api_surface.json @@ -0,0 +1,1452 @@ +{ + "buy": { + "kind": "method", + "signature": { + "parameters": [ + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "self" + }, + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "amount" + }, + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "asset" + }, + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "direction" + }, + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "duration" + }, + { + "annotation": "", + "default": "'TIME'", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "time_mode" + } + ], + "return_annotation": "tuple[bool, typing.Any]" + } + }, + "buy_optimized": { + "kind": "method", + "signature": { + "parameters": [ + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "self" + }, + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "asset" + }, + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "amount" + }, + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "direction" + }, + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "duration" + }, + { + "annotation": "float | None", + "default": "None", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "timeout" + } + ], + "return_annotation": "dict[str, typing.Any]" + } + }, + "calculate_indicator": { + "kind": "method", + "signature": { + "parameters": [ + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "self" + }, + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "asset" + }, + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "indicator" + }, + { + "annotation": "dict[str, typing.Any] | None", + "default": "None", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "params" + }, + { + "annotation": "", + "default": "3600", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "history_size" + }, + { + "annotation": "", + "default": "60", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "timeframe" + } + ], + "return_annotation": "dict[str, typing.Any]" + } + }, + "change_account": { + "kind": "method", + "signature": { + "parameters": [ + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "self" + }, + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "balance_mode" + }, + { + "annotation": "", + "default": "0", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "tournament_id" + } + ], + "return_annotation": "None" + } + }, + "change_time_offset": { + "kind": "method", + "signature": { + "parameters": [ + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "self" + }, + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "time_offset" + } + ], + "return_annotation": "typing.Any" + } + }, + "check_asset_open": { + "kind": "method", + "signature": { + "parameters": [ + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "self" + }, + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "asset_name" + } + ], + "return_annotation": "tuple[list[typing.Any] | None, tuple[typing.Any, typing.Any, typing.Any]]" + } + }, + "check_connect": { + "kind": "method", + "signature": { + "parameters": [ + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "self" + } + ], + "return_annotation": "" + } + }, + "check_win": { + "kind": "method", + "signature": { + "parameters": [ + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "self" + }, + { + "annotation": "str | int", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "order_id" + }, + { + "annotation": "", + "default": "0", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "duration" + } + ], + "return_annotation": "tuple[str, float]" + } + }, + "close": { + "kind": "method", + "signature": { + "parameters": [ + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "self" + } + ], + "return_annotation": "" + } + }, + "connect": { + "kind": "method", + "signature": { + "parameters": [ + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "self" + } + ], + "return_annotation": "tuple[bool, str]" + } + }, + "edit_practice_balance": { + "kind": "method", + "signature": { + "parameters": [ + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "self" + }, + { + "annotation": "float | int | None", + "default": "None", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "amount" + }, + { + "annotation": "", + "default": "30", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "timeout" + } + ], + "return_annotation": "dict[str, typing.Any]" + } + }, + "get_all_asset_name": { + "kind": "method", + "signature": { + "parameters": [ + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "self" + } + ], + "return_annotation": "list[list[str]] | None" + } + }, + "get_all_assets": { + "kind": "method", + "signature": { + "parameters": [ + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "self" + } + ], + "return_annotation": "dict[str, str]" + } + }, + "get_available_asset": { + "kind": "method", + "signature": { + "parameters": [ + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "self" + }, + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "asset_name" + }, + { + "annotation": "", + "default": "False", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "force_open" + } + ], + "return_annotation": "tuple[str, typing.Any]" + } + }, + "get_balance": { + "kind": "method", + "signature": { + "parameters": [ + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "self" + }, + { + "annotation": "", + "default": "30", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "timeout" + } + ], + "return_annotation": "" + } + }, + "get_balance_optimized": { + "kind": "method", + "signature": { + "parameters": [ + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "self" + }, + { + "annotation": "", + "default": "30.0", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "timeout" + } + ], + "return_annotation": "" + } + }, + "get_candle_v2": { + "kind": "method", + "signature": { + "parameters": [ + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "self" + }, + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "asset" + }, + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "period" + }, + { + "annotation": "", + "default": "30", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "timeout" + } + ], + "return_annotation": "list[dict[str, typing.Any]] | None" + } + }, + "get_candles": { + "kind": "method", + "signature": { + "parameters": [ + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "self" + }, + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "asset" + }, + { + "annotation": "float | None", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "end_from_time" + }, + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "offset" + }, + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "period" + }, + { + "annotation": "", + "default": "False", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "progressive" + }, + { + "annotation": "", + "default": "30", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "timeout" + } + ], + "return_annotation": "list[dict[str, typing.Any]] | None" + } + }, + "get_candles_deep": { + "kind": "method", + "signature": { + "parameters": [ + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "self" + }, + { + "annotation": "typing.Any", + "default": "", + "kind": "VAR_POSITIONAL", + "name": "args" + }, + { + "annotation": "typing.Any", + "default": "", + "kind": "VAR_KEYWORD", + "name": "kwargs" + } + ], + "return_annotation": "list[dict[str, typing.Any]]" + } + }, + "get_candles_optimized": { + "kind": "method", + "signature": { + "parameters": [ + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "self" + }, + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "asset" + }, + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "size" + }, + { + "annotation": "", + "default": "30.0", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "timeout" + } + ], + "return_annotation": "list[typing.Any]" + } + }, + "get_historical_candles": { + "kind": "method", + "signature": { + "parameters": [ + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "self" + }, + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "asset" + }, + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "amount_of_seconds" + }, + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "period" + }, + { + "annotation": "", + "default": "30", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "timeout" + }, + { + "annotation": "", + "default": "5", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "max_workers" + }, + { + "annotation": "typing.Optional[typing.Callable[[int, int, int, str], NoneType]]", + "default": "None", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "progress_callback" + } + ], + "return_annotation": "list[dict[str, typing.Any]]" + } + }, + "get_history": { + "kind": "method", + "signature": { + "parameters": [ + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "self" + } + ], + "return_annotation": "list[dict[str, typing.Any]]" + } + }, + "get_history_line": { + "kind": "method", + "signature": { + "parameters": [ + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "self" + }, + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "asset" + }, + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "end_from_time" + }, + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "offset" + }, + { + "annotation": "", + "default": "30", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "timeout" + } + ], + "return_annotation": "dict[str, typing.Any] | None" + } + }, + "get_instruments": { + "kind": "method", + "signature": { + "parameters": [ + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "self" + }, + { + "annotation": "", + "default": "30", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "timeout" + } + ], + "return_annotation": "list[typing.Any]" + } + }, + "get_instruments_optimized": { + "kind": "method", + "signature": { + "parameters": [ + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "self" + }, + { + "annotation": "", + "default": "30.0", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "timeout" + } + ], + "return_annotation": "list[typing.Any]" + } + }, + "get_payment": { + "kind": "method", + "signature": { + "parameters": [ + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "self" + } + ], + "return_annotation": "dict[str, typing.Any]" + } + }, + "get_payout_by_asset": { + "kind": "method", + "signature": { + "parameters": [ + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "self" + }, + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "asset_name" + }, + { + "annotation": "", + "default": "'1'", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "timeframe" + } + ], + "return_annotation": "float | dict[str, typing.Any] | None" + } + }, + "get_profile": { + "kind": "method", + "signature": { + "parameters": [ + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "self" + } + ], + "return_annotation": "typing.Any" + } + }, + "get_profit": { + "kind": "method", + "signature": { + "parameters": [ + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "self" + } + ], + "return_annotation": "" + } + }, + "get_realtime_candles": { + "kind": "method", + "signature": { + "parameters": [ + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "self" + }, + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "asset" + } + ], + "return_annotation": "list[typing.Any] | dict[typing.Any, typing.Any]" + } + }, + "get_realtime_price": { + "kind": "method", + "signature": { + "parameters": [ + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "self" + }, + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "asset" + } + ], + "return_annotation": "list[dict[str, typing.Any]]" + } + }, + "get_realtime_sentiment": { + "kind": "method", + "signature": { + "parameters": [ + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "self" + }, + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "asset" + } + ], + "return_annotation": "dict[str, typing.Any]" + } + }, + "get_result": { + "kind": "method", + "signature": { + "parameters": [ + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "self" + }, + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "operation_id" + } + ], + "return_annotation": "tuple[str | None, typing.Any]" + } + }, + "get_server_time": { + "kind": "method", + "signature": { + "parameters": [ + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "self" + } + ], + "return_annotation": "" + } + }, + "get_signal_data": { + "kind": "method", + "signature": { + "parameters": [ + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "self" + } + ], + "return_annotation": "dict[str, typing.Any]" + } + }, + "get_trader_history": { + "kind": "method", + "signature": { + "parameters": [ + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "self" + }, + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "account_type" + }, + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "page_number" + } + ], + "return_annotation": "dict[str, typing.Any]" + } + }, + "open_pending": { + "kind": "method", + "signature": { + "parameters": [ + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "self" + }, + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "amount" + }, + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "asset" + }, + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "direction" + }, + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "duration" + }, + { + "annotation": "str | None", + "default": "None", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "open_time" + } + ], + "return_annotation": "tuple[bool, typing.Any]" + } + }, + "opening_closing_current_candle": { + "kind": "method", + "signature": { + "parameters": [ + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "self" + }, + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "asset" + }, + { + "annotation": "", + "default": "0", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "period" + } + ], + "return_annotation": "dict[str, typing.Any]" + } + }, + "prepare_candles": { + "kind": "method", + "signature": { + "parameters": [ + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "self" + }, + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "asset" + }, + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "period" + }, + { + "annotation": "list[typing.Any] | None", + "default": "None", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "history" + } + ], + "return_annotation": "list[dict[str, typing.Any]]" + } + }, + "re_subscribe_stream": { + "kind": "method", + "signature": { + "parameters": [ + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "self" + } + ], + "return_annotation": "None" + } + }, + "reconnect": { + "kind": "method", + "signature": { + "parameters": [ + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "self" + } + ], + "return_annotation": "None" + } + }, + "sell_option": { + "kind": "method", + "signature": { + "parameters": [ + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "self" + }, + { + "annotation": "list[str] | str", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "options_ids" + }, + { + "annotation": "", + "default": "30", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "timeout" + } + ], + "return_annotation": "dict[str, typing.Any]" + } + }, + "sell_option_optimized": { + "kind": "method", + "signature": { + "parameters": [ + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "self" + }, + { + "annotation": "list[typing.Any]", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "options_ids" + }, + { + "annotation": "", + "default": "30.0", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "timeout" + } + ], + "return_annotation": "dict[str, typing.Any]" + } + }, + "set_account_mode": { + "kind": "method", + "signature": { + "parameters": [ + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "self" + }, + { + "annotation": "", + "default": "'PRACTICE'", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "balance_mode" + } + ], + "return_annotation": "None" + } + }, + "set_session": { + "kind": "method", + "signature": { + "parameters": [ + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "self" + }, + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "user_agent" + }, + { + "annotation": "str | None", + "default": "None", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "cookies" + }, + { + "annotation": "str | None", + "default": "None", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "ssid" + } + ], + "return_annotation": "None" + } + }, + "start_candles_all_size_stream": { + "kind": "method", + "signature": { + "parameters": [ + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "self" + }, + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "asset" + } + ], + "return_annotation": "" + } + }, + "start_candles_one_stream": { + "kind": "method", + "signature": { + "parameters": [ + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "self" + }, + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "asset" + }, + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "size" + } + ], + "return_annotation": "" + } + }, + "start_candles_stream": { + "kind": "method", + "signature": { + "parameters": [ + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "self" + }, + { + "annotation": "", + "default": "'EURUSD'", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "asset" + }, + { + "annotation": "", + "default": "0", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "period" + } + ], + "return_annotation": "None" + } + }, + "start_mood_stream": { + "kind": "method", + "signature": { + "parameters": [ + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "self" + }, + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "asset" + }, + { + "annotation": "", + "default": "'turbo-option'", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "instrument" + } + ], + "return_annotation": "None" + } + }, + "start_realtime_candle": { + "kind": "method", + "signature": { + "parameters": [ + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "self" + }, + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "asset" + }, + { + "annotation": "", + "default": "0", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "period" + }, + { + "annotation": "", + "default": "30", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "timeout" + } + ], + "return_annotation": "dict[int, typing.Any]" + } + }, + "start_realtime_price": { + "kind": "method", + "signature": { + "parameters": [ + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "self" + }, + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "asset" + }, + { + "annotation": "", + "default": "0", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "period" + }, + { + "annotation": "", + "default": "30", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "timeout" + } + ], + "return_annotation": "dict[str, typing.Any]" + } + }, + "start_realtime_sentiment": { + "kind": "method", + "signature": { + "parameters": [ + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "self" + }, + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "asset" + }, + { + "annotation": "", + "default": "0", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "period" + }, + { + "annotation": "", + "default": "30", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "timeout" + } + ], + "return_annotation": "dict[str, typing.Any]" + } + }, + "start_remaing_time": { + "kind": "method", + "signature": { + "parameters": [ + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "self" + } + ], + "return_annotation": "None" + } + }, + "start_signals_data": { + "kind": "method", + "signature": { + "parameters": [ + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "self" + } + ], + "return_annotation": "None" + } + }, + "stop_candles_stream": { + "kind": "method", + "signature": { + "parameters": [ + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "self" + }, + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "asset" + } + ], + "return_annotation": "None" + } + }, + "store_settings_apply": { + "kind": "method", + "signature": { + "parameters": [ + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "self" + }, + { + "annotation": "", + "default": "'EURUSD'", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "asset" + }, + { + "annotation": "", + "default": "0", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "period" + }, + { + "annotation": "", + "default": "'TIMER'", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "time_mode" + }, + { + "annotation": "", + "default": "5", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "deal" + }, + { + "annotation": "", + "default": "False", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "percent_mode" + }, + { + "annotation": "", + "default": "1", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "percent_deal" + }, + { + "annotation": "", + "default": "30", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "timeout" + } + ], + "return_annotation": "dict[str, typing.Any]" + } + }, + "subscribe_indicator": { + "kind": "method", + "signature": { + "parameters": [ + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "self" + }, + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "asset" + }, + { + "annotation": "", + "default": "", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "indicator" + }, + { + "annotation": "dict[str, typing.Any] | None", + "default": "None", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "params" + }, + { + "annotation": "typing.Optional[typing.Callable[[dict[str, typing.Any]], typing.Any]]", + "default": "None", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "callback" + }, + { + "annotation": "", + "default": "60", + "kind": "POSITIONAL_OR_KEYWORD", + "name": "timeframe" + } + ], + "return_annotation": "None" + } + }, + "websocket": { + "kind": "attribute", + "type": "property" + } +} diff --git a/tests/test_api_surface.py b/tests/test_api_surface.py new file mode 100644 index 00000000..0aee7aaf --- /dev/null +++ b/tests/test_api_surface.py @@ -0,0 +1,58 @@ +"""Regression test: Quotex's public surface must not shrink during refactors.""" +import json +from pathlib import Path + +from pyquotex.stable_api import Quotex +from scripts.surface_utils import extract_surface + +FIXTURE = Path(__file__).parent / "fixtures" / "api_surface.json" + + +def _current_surface() -> dict[str, dict]: + return extract_surface(Quotex, full_signatures=False) + + +def test_public_methods_present(): + """Every public method in the snapshot must still exist on Quotex.""" + baseline = json.loads(FIXTURE.read_text()) + current = _current_surface() + missing = sorted(set(baseline) - set(current)) + assert not missing, f"Public symbols removed: {missing}" + + +def test_public_method_params_unchanged(): + """Parameter names of public methods must not change (order matters).""" + baseline = json.loads(FIXTURE.read_text()) + current = _current_surface() + diffs: list[str] = [] + for name, baseline_entry in baseline.items(): + if baseline_entry.get("kind") != "method": + continue + if name not in current: + continue # caught by previous test + baseline_params = [ + p["name"] for p in baseline_entry["signature"]["parameters"] + ] + current_params = current[name].get("params", []) + if baseline_params != current_params: + diffs.append( + f"{name}: baseline={baseline_params} current={current_params}" + ) + assert not diffs, "Parameter signatures changed:\n" + "\n".join(diffs) + + +def test_public_method_kinds_unchanged(): + """A symbol's kind (method/attribute) must not change between baseline and current.""" + baseline = json.loads(FIXTURE.read_text()) + current = _current_surface() + changed = [] + for name in baseline: + if name not in current: + continue # caught by test_public_methods_present + baseline_kind = baseline[name].get("kind") + current_kind = current[name].get("kind") + if baseline_kind != current_kind: + changed.append( + f"{name}: baseline={baseline_kind} current={current_kind}" + ) + assert not changed, "Symbol kinds changed:\n" + "\n".join(changed) diff --git a/tests/test_cli_smoke.py b/tests/test_cli_smoke.py new file mode 100644 index 00000000..304f9611 --- /dev/null +++ b/tests/test_cli_smoke.py @@ -0,0 +1,29 @@ +"""Smoke tests: CLI entrypoints respond to --help.""" +import subprocess +import sys + + +def test_app_py_help_runs(): + """`python app.py --help` must exit 0 and list commands.""" + result = subprocess.run( + [sys.executable, "app.py", "--help"], + capture_output=True, + text=True, + timeout=15, + ) + assert result.returncode == 0, f"stderr: {result.stderr}" + assert "balance" in result.stdout + assert "buy" in result.stdout + + +def test_module_invocation_help_runs(): + """`python -m pyquotex --help` must exit 0 and list commands.""" + result = subprocess.run( + [sys.executable, "-m", "pyquotex", "--help"], + capture_output=True, + text=True, + timeout=15, + ) + assert result.returncode == 0, f"stderr: {result.stderr}" + assert "balance" in result.stdout + assert "buy" in result.stdout diff --git a/tests/test_import_compat.py b/tests/test_import_compat.py new file mode 100644 index 00000000..2aa3c8cf --- /dev/null +++ b/tests/test_import_compat.py @@ -0,0 +1,29 @@ +"""Verify legacy import paths continue to resolve.""" + +def test_stable_api_quotex_importable(): + from pyquotex.stable_api import Quotex + assert Quotex is not None + assert hasattr(Quotex, "buy") + assert hasattr(Quotex, "get_balance") + assert hasattr(Quotex, "connect") + + +def test_quotex_api_importable(): + from pyquotex.api import QuotexAPI + assert QuotexAPI is not None + + +def test_account_type_importable(): + from pyquotex.utils.account_type import AccountType + assert AccountType.DEMO is not None + assert AccountType.REAL is not None + + +def test_indicators_importable(): + from pyquotex.utils.indicators import TechnicalIndicators + assert TechnicalIndicators is not None + + +def test_exceptions_importable(): + from pyquotex.exceptions import QuotexTimeoutError + assert issubclass(QuotexTimeoutError, Exception) diff --git a/tests/test_waits.py b/tests/test_waits.py new file mode 100644 index 00000000..feabfb50 --- /dev/null +++ b/tests/test_waits.py @@ -0,0 +1,199 @@ +"""Unit tests for WaitableSlot and wait_until.""" +import asyncio +import pytest + +from pyquotex._api._waits import SlotRegistry, WaitableSlot, wait_until + + +@pytest.mark.asyncio +async def test_slot_resolves_with_set_value(): + slot: WaitableSlot[int] = WaitableSlot() + + async def setter(): + await asyncio.sleep(0.01) + slot.set(42) + + asyncio.create_task(setter()) + assert await slot.wait(timeout=1.0) == 42 + + +@pytest.mark.asyncio +async def test_slot_times_out_when_never_set(): + slot: WaitableSlot[int] = WaitableSlot() + with pytest.raises(asyncio.TimeoutError): + await slot.wait(timeout=0.05) + + +@pytest.mark.asyncio +async def test_slot_can_be_cleared_and_reused(): + slot: WaitableSlot[str] = WaitableSlot() + slot.set("first") + assert await slot.wait(timeout=0.1) == "first" + slot.clear() + with pytest.raises(asyncio.TimeoutError): + await slot.wait(timeout=0.05) + slot.set("second") + assert await slot.wait(timeout=0.1) == "second" + + +@pytest.mark.asyncio +async def test_slot_set_before_wait_resolves_immediately(): + slot: WaitableSlot[int] = WaitableSlot() + slot.set(7) + assert await slot.wait(timeout=0.1) == 7 + + +@pytest.mark.asyncio +async def test_wait_until_resolves_when_predicate_true(): + counter = {"n": 0} + + async def increment(): + await asyncio.sleep(0.01) + counter["n"] = 5 + + asyncio.create_task(increment()) + await wait_until(lambda: counter["n"] >= 5, timeout=1.0) + assert counter["n"] == 5 + + +@pytest.mark.asyncio +async def test_wait_until_times_out(): + with pytest.raises(asyncio.TimeoutError): + await wait_until(lambda: False, timeout=0.05) + + +def test_slot_registry_has_named_slots(): + reg = SlotRegistry() + assert reg.balance is not None + assert reg.balance_update is not None + assert reg.candle_v2_ready is not None + assert reg.historical_ready is not None + assert reg.pending_confirm is not None + assert reg.sold_option_confirm is not None + assert reg.training_balance_edit is not None + assert reg.auth_status is not None + + +def test_slot_registry_has_buy_confirm(): + reg = SlotRegistry() + assert reg.buy_confirm is not None + + +def test_slot_registry_keyed_slots_create_on_access(): + reg = SlotRegistry() + slot_a = reg.order_confirm("req-1") + slot_b = reg.order_confirm("req-1") + slot_c = reg.order_confirm("req-2") + assert slot_a is slot_b # same key returns same slot + assert slot_a is not slot_c # different key returns different slot + + +def test_slot_registry_keyed_slot_release(): + reg = SlotRegistry() + slot = reg.order_confirm("req-1") + slot.set({"id": 1}) + reg.release_order_confirm("req-1") + new_slot = reg.order_confirm("req-1") + assert new_slot is not slot + + +@pytest.mark.asyncio +async def test_slot_rejects_none(): + """set(None) is invalid β€” use clear() to reset.""" + slot: WaitableSlot[dict] = WaitableSlot() + with pytest.raises(ValueError): + slot.set(None) + assert not slot.is_set() + + +@pytest.mark.asyncio +async def test_slot_double_set_uses_latest_value(): + """Second set replaces the first; wait returns the latest value.""" + slot: WaitableSlot[int] = WaitableSlot() + slot.set(1) + slot.set(2) + assert await slot.wait(timeout=0.1) == 2 + + +@pytest.mark.asyncio +async def test_slot_two_consumers_both_resolve(): + """Multiple awaiters on the same slot all receive the value.""" + slot: WaitableSlot[str] = WaitableSlot() + + async def consumer() -> str: + return await slot.wait(timeout=1.0) + + t1 = asyncio.create_task(consumer()) + t2 = asyncio.create_task(consumer()) + await asyncio.sleep(0.01) # ensure both are blocked on _event + slot.set("hello") + assert await t1 == "hello" + assert await t2 == "hello" + + +def test_slot_registry_win_result_release(): + """release_win_result must drop the slot so a fresh one is created next.""" + reg = SlotRegistry() + slot = reg.win_result("op-1") + slot.set({"result": "win"}) + reg.release_win_result("op-1") + new_slot = reg.win_result("op-1") + assert new_slot is not slot + + +def test_quotex_api_has_slot_registry(): + """QuotexAPI must expose a SlotRegistry as .slots.""" + 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, + ) + assert isinstance(api.slots, SlotRegistry) + assert api.slots.balance is not None + + +def test_slot_registry_candle_v2_keyed(): + reg = SlotRegistry() + slot_a = reg.candle_v2("EURUSD") + slot_b = reg.candle_v2("EURUSD") + slot_c = reg.candle_v2("GBPUSD") + assert slot_a is slot_b + assert slot_a is not slot_c + + +def test_slot_registry_candle_v2_release(): + reg = SlotRegistry() + slot = reg.candle_v2("EURUSD") + slot.set({"foo": "bar"}) + reg.release_candle_v2("EURUSD") + new_slot = reg.candle_v2("EURUSD") + assert new_slot is not slot + + +@pytest.mark.asyncio +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) + elapsed = time.monotonic() - start + assert 0.005 <= elapsed <= 0.05 + + +@pytest.mark.asyncio +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) + elapsed = time.monotonic() - start + assert 0.04 <= elapsed <= 0.15