+"""
+from __future__ import annotations
+
+import json
+import re
+import sys
+from pathlib import Path
+from typing import Any
+
+from curl_cffi import requests
+
+from pyquotex.config import credentials, resource_path
+
+BASE = "https://qxbroker.com"
+LANG = "en"
+IMPERSONATE = "firefox133"
+UA = (
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 14.4; rv:127.0) "
+ "Gecko/20100101 Firefox/127.0"
+)
+STATE_PATH = Path("/tmp/qx_seed_state.json")
+
+
+def _cookies_to_header(jar: dict[str, str]) -> str:
+ return "; ".join(f"{k}={v}" for k, v in jar.items())
+
+
+def main(code: str) -> int:
+ if not STATE_PATH.exists():
+ print(" ❌ Run scripts/seed_session_step1.py first.")
+ return 1
+
+ state = json.loads(STATE_PATH.read_text())
+ email = state["email"]
+ cookies = state["cookies"]
+ token = state["token"]
+
+ s = requests.Session(impersonate=IMPERSONATE)
+ s.headers.update({"User-Agent": UA, "Accept-Language": "en-US,en;q=0.5"})
+ for k, v in cookies.items():
+ s.cookies.set(k, v, domain=".qxbroker.com")
+
+ pwd = credentials()[1]
+ r = s.post(
+ f"{BASE}/{LANG}/sign-in/modal",
+ data={
+ "_token": token,
+ "email": email,
+ "password": pwd,
+ "remember": 1,
+ "keep_code": 1,
+ "code": code,
+ },
+ headers={
+ "Referer": f"{BASE}/{LANG}/sign-in/modal",
+ "Origin": BASE,
+ "Content-Type": "application/x-www-form-urlencoded",
+ },
+ )
+ print(f" POST /sign-in/modal (with code) -> {r.status_code} final_url={r.url}")
+ if 'name="keep_code"' in r.text:
+ Path("/tmp/qx_step2_response.html").write_text(r.text)
+ m = re.search(
+ r']*class="[^"]*error[^"]*"[^>]*>(.*?)
',
+ r.text, re.S,
+ )
+ if m:
+ print(f" Error: {m.group(1).strip()[:200]}")
+ return 2
+
+ # We should now be at /trade
+ if "/trade" not in str(r.url):
+ r = s.get(f"{BASE}/{LANG}/trade")
+ print(f" GET /trade -> {r.status_code}")
+
+ ssid: str | None = None
+ m = re.search(r"window\.settings\s*=\s*(\{.*?\});", r.text, re.S)
+ if m:
+ try:
+ data_settings = json.loads(m.group(1))
+ ssid = data_settings.get("token")
+ except Exception as e:
+ print(f" window.settings parse failed: {e}")
+ if not ssid:
+ r2 = s.get(
+ f"{BASE}/api/v1/cabinets/digest",
+ headers={"Referer": f"{BASE}/{LANG}/trade"},
+ )
+ print(f" GET /api/v1/cabinets/digest -> {r2.status_code}")
+ if r2.status_code == 200:
+ try:
+ ssid = r2.json().get("data", {}).get("token")
+ except Exception:
+ pass
+
+ if not ssid:
+ print(" ❌ Login passed but SSID not found.")
+ Path("/tmp/qx_step2_trade.html").write_text(r.text)
+ return 3
+
+ print(f" ✅ SSID: {ssid[:24]}…")
+
+ cookie_jar = s.cookies.get_dict()
+ session_path = Path(resource_path("session.json"))
+ out: dict[str, Any] = {}
+ if session_path.exists():
+ try:
+ out = json.loads(session_path.read_text())
+ except Exception:
+ pass
+ out[email] = {
+ "cookies": _cookies_to_header(cookie_jar),
+ "token": ssid,
+ "user_agent": UA,
+ }
+ session_path.write_text(json.dumps(out, indent=4))
+ print(f" ✅ Wrote session.json ({len(cookie_jar)} cookies)")
+ return 0
+
+
+if __name__ == "__main__":
+ if len(sys.argv) < 2:
+ print("Usage: seed_session_step2.py ")
+ sys.exit(1)
+ sys.exit(main(sys.argv[1].strip()))
diff --git a/scripts/seed_session_via_curlcffi.py b/scripts/seed_session_via_curlcffi.py
new file mode 100644
index 00000000..aad5c499
--- /dev/null
+++ b/scripts/seed_session_via_curlcffi.py
@@ -0,0 +1,174 @@
+"""Seed session.json by performing the full login via curl_cffi (TLS impersonation).
+
+The library's normal httpx login fails behind Cloudflare from datacenter
+IPs because httpx's TLS fingerprint doesn't match a real Firefox.
+``curl_cffi`` does proper JA3 impersonation, so we use it ONCE here just
+to obtain the SSID + cookies, then write them to ``session.json`` so the
+regular library code can pick up from there using its WebSocket flow.
+
+This script is NOT a runtime dependency of pyquotex — it's a smoke-test
+helper. Requires ``pip install curl_cffi`` in the local venv only.
+
+Usage:
+ PYTHONPATH=. python scripts/seed_session_via_curlcffi.py
+"""
+from __future__ import annotations
+
+import json
+import re
+import sys
+from pathlib import Path
+from typing import Any
+
+from curl_cffi import requests # local dev dep only
+
+from pyquotex.config import credentials, resource_path
+
+BASE = "https://qxbroker.com"
+LANG = "en"
+IMPERSONATE = "firefox133"
+UA = (
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 14.4; rv:127.0) "
+ "Gecko/20100101 Firefox/127.0"
+)
+
+
+def _cookies_to_header(jar: dict[str, str]) -> str:
+ return "; ".join(f"{k}={v}" for k, v in jar.items())
+
+
+def _extract_token(html: str) -> str | None:
+ m = re.search(
+ r']*name=["\']_token["\'][^>]*value=["\']([^"\']+)["\']',
+ html,
+ )
+ return m.group(1) if m else None
+
+
+def main() -> int:
+ email, password = credentials()
+ s = requests.Session(impersonate=IMPERSONATE)
+ s.headers.update({"User-Agent": UA, "Accept-Language": "en-US,en;q=0.5"})
+
+ # 1. Warm: pick up __cf_bm + laravel_session
+ r = s.get(f"{BASE}/{LANG}")
+ print(f" GET /{LANG} -> {r.status_code} (cookies: {list(s.cookies.keys())})")
+ if r.status_code != 200:
+ print(" ❌ Cloudflare still blocking — try a different impersonate value")
+ return 1
+
+ # 2. Get sign-in modal + CSRF _token
+ r = s.get(f"{BASE}/{LANG}/sign-in/modal/")
+ print(f" GET /sign-in/modal/ -> {r.status_code}")
+ token = _extract_token(r.text)
+ if not token:
+ print(" ❌ Could not find _token in modal page")
+ return 1
+ print(f" _token: {token[:32]}…")
+
+ # 3. POST credentials — exactly the lib's path: /sign-in/ (trailing slash)
+ data = {
+ "_token": token,
+ "email": email,
+ "password": password,
+ "remember": 1,
+ }
+ r = s.post(
+ f"{BASE}/{LANG}/sign-in/",
+ data=data,
+ headers={
+ "Referer": f"{BASE}/{LANG}/sign-in",
+ "Origin": BASE,
+ "Content-Type": "application/x-www-form-urlencoded",
+ },
+ )
+ print(f" POST /sign-in/ -> {r.status_code} final_url={r.url}")
+
+ if 'name="keep_code"' in r.text:
+ print(" ⚠️ 2FA challenge required — paste the code:")
+ code = input(" > ").strip()
+ data["keep_code"] = 1
+ data["code"] = code
+ r = s.post(
+ f"{BASE}/{LANG}/sign-in/modal",
+ data=data,
+ headers={
+ "Referer": f"{BASE}/{LANG}/sign-in/modal",
+ "Origin": BASE,
+ "Content-Type": "application/x-www-form-urlencoded",
+ },
+ )
+ print(f" POST /sign-in/modal -> {r.status_code} final_url={r.url}")
+ Path("/tmp/qx_pin_response.html").write_text(r.text)
+ print(" (saved response to /tmp/qx_pin_response.html)")
+ # Snippet of error if any
+ import re as _re
+ err = _re.search(
+ r']*class="[^"]*error[^"]*"[^>]*>(.*?)
',
+ r.text, _re.S,
+ )
+ if err:
+ print(f" Error block: {err.group(1).strip()[:200]}")
+ if 'name="keep_code"' in r.text:
+ print(" ⚠️ Still on PIN form after submitting — code rejected or expired")
+
+ if "/trade" not in str(r.url):
+ # If still not on /trade, hit it explicitly
+ r = s.get(f"{BASE}/{LANG}/trade")
+ print(f" GET /trade -> {r.status_code}")
+
+ ssid: str | None = None
+ m = re.search(r"window\.settings\s*=\s*(\{.*?\});", r.text, re.S)
+ if m:
+ try:
+ settings_data = json.loads(m.group(1))
+ ssid = settings_data.get("token")
+ if ssid:
+ print(f" SSID via window.settings: {ssid[:24]}…")
+ except Exception as e:
+ print(f" ⚠️ window.settings parse failed: {e}")
+
+ cookie_jar = s.cookies.get_dict()
+
+ # Fallback: /api/v1/cabinets/digest (used by Login.get_profile)
+ if not ssid:
+ r2 = s.get(
+ f"{BASE}/api/v1/cabinets/digest",
+ headers={"Referer": f"{BASE}/{LANG}/trade"},
+ )
+ print(f" GET /api/v1/cabinets/digest -> {r2.status_code}")
+ if r2.status_code == 200:
+ try:
+ ssid = r2.json().get("data", {}).get("token")
+ if ssid:
+ print(f" SSID via /digest: {ssid[:24]}…")
+ except Exception as e:
+ print(f" ⚠️ digest parse failed: {e}")
+
+ if not ssid:
+ print(" ❌ Could not extract SSID from /trade page")
+ print(" Cookies available:", list(cookie_jar.keys()))
+ return 2
+
+ cookies_header = _cookies_to_header(cookie_jar)
+ out: dict[str, Any] = {}
+ session_path = Path(resource_path("session.json"))
+ if session_path.exists():
+ try:
+ out = json.loads(session_path.read_text())
+ except Exception:
+ pass
+ out[email] = {
+ "cookies": cookies_header,
+ "token": ssid,
+ "user_agent": UA,
+ }
+ session_path.write_text(json.dumps(out, indent=4))
+ print(f"\n ✅ Wrote session.json for {email}")
+ print(f" cookies: {len(cookie_jar)} entries")
+ print(f" ssid: {ssid[:16]}…")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/scripts/smoke_demo.py b/scripts/smoke_demo.py
new file mode 100644
index 00000000..69b4e619
--- /dev/null
+++ b/scripts/smoke_demo.py
@@ -0,0 +1,195 @@
+"""DEMO-account smoke test for the resilience + perf PR.
+
+Exercises everything that cannot be verified offline:
+
+ 1. Async context manager (`async with Quotex(...)`).
+ 2. Auth on DEMO and balance retrieval.
+ 3. `get_candles` with `use_cache=True` (hit on second call).
+ 4. Streaming indicators warmed up from real candles.
+ 5. Subscription tracking after `start_candles_stream`.
+ 6. Manual auto-reconnect: close the underlying socket from underneath
+ the client and confirm it comes back up + replays the candle
+ subscription.
+
+Run:
+ PYTHONPATH=. python scripts/smoke_demo.py
+"""
+from __future__ import annotations
+
+import asyncio
+import logging
+import sys
+import time
+from typing import Any
+
+from pyquotex import Candle, ReconnectPolicy
+from pyquotex.config import credentials
+from pyquotex.stable_api import Quotex
+from pyquotex.utils.streaming_indicators import StreamingRSI, StreamingSMA
+
+logging.basicConfig(
+ level=logging.INFO,
+ format="%(asctime)s %(levelname)s %(name)s %(message)s",
+)
+log = logging.getLogger("smoke")
+
+
+SEPARATOR = "─" * 60
+
+
+def banner(title: str) -> None:
+ print(f"\n{SEPARATOR}\n {title}\n{SEPARATOR}")
+
+
+async def step_balance(q: Quotex) -> None:
+ banner("Step 1 — balance + profile (event-driven path)")
+ profile = await q.get_profile()
+ balance = await q.get_balance()
+ print(f" Nick: {profile.nick_name} Country: {profile.country_name}")
+ print(f" Currency: {profile.currency_code} Balance(DEMO): {balance}")
+
+
+async def step_candles_cache(q: Quotex) -> None:
+ banner("Step 2 — get_candles with use_cache=True")
+ asset = "EURUSD_otc"
+ period = 60
+
+ t0 = time.monotonic()
+ first = await q.get_candles(asset, None, 3600, period, use_cache=True)
+ t1 = time.monotonic() - t0
+ n_first = len(first) if first else 0
+ print(f" First call: {n_first} candles in {t1*1000:.1f} ms")
+
+ t0 = time.monotonic()
+ second = await q.get_candles(asset, None, 3600, period, use_cache=True)
+ t2 = time.monotonic() - t0
+ n_second = len(second) if second else 0
+ print(f" Cached call: {n_second} candles in {t2*1000:.1f} ms")
+ if t2 < t1 * 0.5 or t2 < 0.005:
+ print(" ✅ cache hit confirmed (second call is much faster)")
+ else:
+ print(" ⚠️ expected speedup not observed — TTL may have expired")
+
+ return first, asset, period # type: ignore[return-value]
+
+
+async def step_streaming_indicators(candles: list[dict[str, Any]]) -> None:
+ banner("Step 3 — streaming indicators on live candles")
+ if not candles:
+ print(" ⚠️ no candles to feed — skipping")
+ return
+
+ sma14 = StreamingSMA(period=14)
+ rsi14 = StreamingRSI(period=14)
+ last_sma: float | None = None
+ last_rsi: float | None = None
+ for c in candles:
+ close = float(c["close"])
+ last_sma = sma14.update(close) or last_sma
+ last_rsi = rsi14.update(close) or last_rsi
+
+ closes = [float(c["close"]) for c in candles]
+ print(f" Candles fed: {len(closes)}")
+ print(f" SMA(14) latest: {last_sma}")
+ print(f" RSI(14) latest: {last_rsi}")
+ # Sanity check against batch
+ from pyquotex.utils.indicators import TechnicalIndicators
+ batch = TechnicalIndicators.calculate_sma(closes, 14)
+ batch_last = batch[-1] if batch else None
+ print(f" SMA(14) batch: {batch_last} (rounded match: "
+ f"{round(last_sma or 0, 2) == round(batch_last or 0, 2)})")
+
+
+async def step_typed_candle(candles: list[dict[str, Any]]) -> None:
+ banner("Step 4 — Candle.from_dict typed conversion")
+ if not candles:
+ return
+ typed = [Candle.from_dict(c) for c in candles[-3:]]
+ for c in typed:
+ print(f" t={c.time} o={c.open} h={c.high} l={c.low} c={c.close} "
+ f"color={c.color}")
+
+
+async def step_subscription_replay(q: Quotex, asset: str, period: int) -> None:
+ banner("Step 5 — subscription tracking & forced reconnect")
+
+ # Make sure the subscription is registered.
+ await q.start_candles_stream(asset, period)
+ subs = q.api._subscriptions # noqa: SLF001 — smoke test
+ print(f" Subscriptions tracked: {list(subs.keys())}")
+ assert any(s.startswith("candle:" + asset) for s in subs), \
+ "candle subscription not tracked"
+
+ # Force a reconnect by closing the underlying socket directly.
+ ws_client = q.api.websocket_client
+ print(" Forcing socket close to trigger auto-reconnect…")
+ raw_ws = ws_client._ws # noqa: SLF001
+ if raw_ws is not None:
+ await raw_ws.close(code=4001, reason="smoke-test-forced")
+
+ # Wait up to 20s for the reconnect loop to bring it back.
+ for i in range(40):
+ await asyncio.sleep(0.5)
+ if ws_client.is_alive():
+ print(f" ✅ Reconnected after ~{(i + 1) * 0.5:.1f}s "
+ f"(open_count={ws_client._open_count})")
+ break
+ else:
+ print(" ❌ Did not reconnect within 20s")
+ return
+
+ # Confirm subscription is still tracked (replay does NOT clear it).
+ subs_after = q.api._subscriptions # noqa: SLF001
+ if any(s.startswith("candle:" + asset) for s in subs_after):
+ print(f" ✅ Subscription still tracked post-reconnect: "
+ f"{list(subs_after.keys())}")
+
+ # Confirm fresh candles flow.
+ fresh = await q.get_candles(asset, None, 600, period)
+ print(f" Post-reconnect candles fetched: {len(fresh or [])}")
+
+
+async def main() -> None:
+ email, password = credentials()
+ policy = ReconnectPolicy(
+ enabled=True,
+ max_attempts=0,
+ base_delay=0.5,
+ max_delay=10.0,
+ jitter=0.1,
+ stale_timeout=90.0,
+ )
+
+ log.info("Connecting as %s with auto-reconnect…", email)
+ real_ua = (
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 14.4; rv:127.0) "
+ "Gecko/20100101 Firefox/127.0"
+ )
+ async with Quotex(
+ email=email,
+ password=password,
+ lang="en",
+ user_agent=real_ua,
+ reconnect_policy=policy,
+ ) as q:
+ q.set_account_mode("PRACTICE")
+ # Re-issue change_account on the WS so the session is on DEMO.
+ if q.api is not None:
+ from pyquotex.utils.account_type import AccountType
+ await q.api.change_account(AccountType.DEMO)
+ await asyncio.sleep(0.5)
+
+ await step_balance(q)
+ candles, asset, period = await step_candles_cache(q)
+ await step_streaming_indicators(candles)
+ await step_typed_candle(candles)
+ await step_subscription_replay(q, asset, period)
+
+ banner("Done — context manager cleanly closed the connection.")
+
+
+if __name__ == "__main__":
+ try:
+ asyncio.run(main())
+ except KeyboardInterrupt:
+ sys.exit(130)
diff --git a/tests/fixtures/api_surface.json b/tests/fixtures/api_surface.json
index f9384c44..9f077158 100644
--- a/tests/fixtures/api_surface.json
+++ b/tests/fixtures/api_surface.json
@@ -10,37 +10,37 @@
"name": "self"
},
{
- "annotation": "",
+ "annotation": "float",
"default": "",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "amount"
},
{
- "annotation": "",
+ "annotation": "str",
"default": "",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "asset"
},
{
- "annotation": "",
+ "annotation": "str",
"default": "",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "direction"
},
{
- "annotation": "",
+ "annotation": "int",
"default": "",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "duration"
},
{
- "annotation": "",
+ "annotation": "str",
"default": "'TIME'",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "time_mode"
}
],
- "return_annotation": "tuple[bool, typing.Any]"
+ "return_annotation": "tuple[bool, Any]"
}
},
"buy_optimized": {
@@ -98,37 +98,37 @@
"name": "self"
},
{
- "annotation": "",
+ "annotation": "str",
"default": "",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "asset"
},
{
- "annotation": "",
+ "annotation": "str",
"default": "",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "indicator"
},
{
- "annotation": "dict[str, typing.Any] | None",
+ "annotation": "dict[str, Any] | None",
"default": "None",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "params"
},
{
- "annotation": "",
+ "annotation": "int",
"default": "3600",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "history_size"
},
{
- "annotation": "",
+ "annotation": "int",
"default": "60",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "timeframe"
}
],
- "return_annotation": "dict[str, typing.Any]"
+ "return_annotation": "dict[str, Any]"
}
},
"change_account": {
@@ -142,13 +142,13 @@
"name": "self"
},
{
- "annotation": "",
+ "annotation": "str",
"default": "",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "balance_mode"
},
{
- "annotation": "",
+ "annotation": "int",
"default": "0",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "tournament_id"
@@ -168,13 +168,13 @@
"name": "self"
},
{
- "annotation": "",
+ "annotation": "int",
"default": "",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "time_offset"
}
],
- "return_annotation": "typing.Any"
+ "return_annotation": "Any"
}
},
"check_asset_open": {
@@ -188,13 +188,13 @@
"name": "self"
},
{
- "annotation": "",
+ "annotation": "str",
"default": "",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "asset_name"
}
],
- "return_annotation": "tuple[list[typing.Any] | None, tuple[typing.Any, typing.Any, typing.Any]]"
+ "return_annotation": "tuple[list[Any] | None, tuple[Any, Any, Any]]"
}
},
"check_connect": {
@@ -228,7 +228,7 @@
"name": "order_id"
},
{
- "annotation": "",
+ "annotation": "int",
"default": "0",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "duration"
@@ -282,13 +282,13 @@
"name": "amount"
},
{
- "annotation": "",
+ "annotation": "int",
"default": "30",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "timeout"
}
],
- "return_annotation": "dict[str, typing.Any]"
+ "return_annotation": "dict[str, Any]"
}
},
"get_all_asset_name": {
@@ -330,19 +330,19 @@
"name": "self"
},
{
- "annotation": "",
+ "annotation": "str",
"default": "",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "asset_name"
},
{
- "annotation": "",
+ "annotation": "bool",
"default": "False",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "force_open"
}
],
- "return_annotation": "tuple[str, typing.Any]"
+ "return_annotation": "tuple[str, Any]"
}
},
"get_balance": {
@@ -356,13 +356,13 @@
"name": "self"
},
{
- "annotation": "",
+ "annotation": "int",
"default": "30",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "timeout"
}
],
- "return_annotation": ""
+ "return_annotation": "float"
}
},
"get_balance_optimized": {
@@ -396,25 +396,25 @@
"name": "self"
},
{
- "annotation": "",
+ "annotation": "str",
"default": "",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "asset"
},
{
- "annotation": "",
+ "annotation": "int",
"default": "",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "period"
},
{
- "annotation": "",
+ "annotation": "int",
"default": "30",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "timeout"
}
],
- "return_annotation": "list[dict[str, typing.Any]] | None"
+ "return_annotation": "list[dict[str, Any]] | None"
}
},
"get_candles": {
@@ -428,7 +428,7 @@
"name": "self"
},
{
- "annotation": "",
+ "annotation": "str",
"default": "",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "asset"
@@ -440,31 +440,37 @@
"name": "end_from_time"
},
{
- "annotation": "",
+ "annotation": "int",
"default": "",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "offset"
},
{
- "annotation": "",
+ "annotation": "int",
"default": "",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "period"
},
{
- "annotation": "",
+ "annotation": "bool",
"default": "False",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "progressive"
},
{
- "annotation": "",
+ "annotation": "int",
"default": "30",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "timeout"
+ },
+ {
+ "annotation": "bool",
+ "default": "False",
+ "kind": "POSITIONAL_OR_KEYWORD",
+ "name": "use_cache"
}
],
- "return_annotation": "list[dict[str, typing.Any]] | None"
+ "return_annotation": "list[dict[str, Any]] | None"
}
},
"get_candles_deep": {
@@ -478,19 +484,19 @@
"name": "self"
},
{
- "annotation": "typing.Any",
+ "annotation": "Any",
"default": "",
"kind": "VAR_POSITIONAL",
"name": "args"
},
{
- "annotation": "typing.Any",
+ "annotation": "Any",
"default": "",
"kind": "VAR_KEYWORD",
"name": "kwargs"
}
],
- "return_annotation": "list[dict[str, typing.Any]]"
+ "return_annotation": "list[dict[str, Any]]"
}
},
"get_candles_optimized": {
@@ -536,43 +542,43 @@
"name": "self"
},
{
- "annotation": "",
+ "annotation": "str",
"default": "",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "asset"
},
{
- "annotation": "",
+ "annotation": "int",
"default": "",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "amount_of_seconds"
},
{
- "annotation": "",
+ "annotation": "int",
"default": "",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "period"
},
{
- "annotation": "",
+ "annotation": "int",
"default": "30",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "timeout"
},
{
- "annotation": "",
+ "annotation": "int",
"default": "5",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "max_workers"
},
{
- "annotation": "typing.Optional[typing.Callable[[int, int, int, str], NoneType]]",
+ "annotation": "Callable[[int, int, int, str], None] | None",
"default": "None",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "progress_callback"
}
],
- "return_annotation": "list[dict[str, typing.Any]]"
+ "return_annotation": "list[dict[str, Any]]"
}
},
"get_history": {
@@ -586,7 +592,7 @@
"name": "self"
}
],
- "return_annotation": "list[dict[str, typing.Any]]"
+ "return_annotation": "list[dict[str, Any]]"
}
},
"get_history_line": {
@@ -600,31 +606,31 @@
"name": "self"
},
{
- "annotation": "",
+ "annotation": "str",
"default": "",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "asset"
},
{
- "annotation": "",
+ "annotation": "float",
"default": "",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "end_from_time"
},
{
- "annotation": "",
+ "annotation": "int",
"default": "",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "offset"
},
{
- "annotation": "",
+ "annotation": "int",
"default": "30",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "timeout"
}
],
- "return_annotation": "dict[str, typing.Any] | None"
+ "return_annotation": "dict[str, Any] | None"
}
},
"get_instruments": {
@@ -638,13 +644,13 @@
"name": "self"
},
{
- "annotation": "",
+ "annotation": "int",
"default": "30",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "timeout"
}
],
- "return_annotation": "list[typing.Any]"
+ "return_annotation": "list[Any]"
}
},
"get_instruments_optimized": {
@@ -678,7 +684,7 @@
"name": "self"
}
],
- "return_annotation": "dict[str, typing.Any]"
+ "return_annotation": "dict[str, Any]"
}
},
"get_payout_by_asset": {
@@ -692,19 +698,19 @@
"name": "self"
},
{
- "annotation": "",
+ "annotation": "str",
"default": "",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "asset_name"
},
{
- "annotation": "",
+ "annotation": "str",
"default": "'1'",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "timeframe"
}
],
- "return_annotation": "float | dict[str, typing.Any] | None"
+ "return_annotation": "float | dict[str, Any] | None"
}
},
"get_profile": {
@@ -718,7 +724,7 @@
"name": "self"
}
],
- "return_annotation": "typing.Any"
+ "return_annotation": "Any"
}
},
"get_profit": {
@@ -732,7 +738,7 @@
"name": "self"
}
],
- "return_annotation": ""
+ "return_annotation": "float"
}
},
"get_realtime_candles": {
@@ -746,13 +752,13 @@
"name": "self"
},
{
- "annotation": "",
+ "annotation": "str",
"default": "",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "asset"
}
],
- "return_annotation": "list[typing.Any] | dict[typing.Any, typing.Any]"
+ "return_annotation": "list[Any] | dict[Any, Any]"
}
},
"get_realtime_price": {
@@ -766,13 +772,13 @@
"name": "self"
},
{
- "annotation": "",
+ "annotation": "str",
"default": "",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "asset"
}
],
- "return_annotation": "list[dict[str, typing.Any]]"
+ "return_annotation": "list[dict[str, Any]]"
}
},
"get_realtime_sentiment": {
@@ -786,13 +792,13 @@
"name": "self"
},
{
- "annotation": "",
+ "annotation": "str",
"default": "",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "asset"
}
],
- "return_annotation": "dict[str, typing.Any]"
+ "return_annotation": "dict[str, Any]"
}
},
"get_result": {
@@ -806,13 +812,13 @@
"name": "self"
},
{
- "annotation": "",
+ "annotation": "str",
"default": "",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "operation_id"
}
],
- "return_annotation": "tuple[str | None, typing.Any]"
+ "return_annotation": "tuple[str | None, Any]"
}
},
"get_server_time": {
@@ -826,7 +832,7 @@
"name": "self"
}
],
- "return_annotation": ""
+ "return_annotation": "int"
}
},
"get_signal_data": {
@@ -840,7 +846,7 @@
"name": "self"
}
],
- "return_annotation": "dict[str, typing.Any]"
+ "return_annotation": "dict[str, Any]"
}
},
"get_trader_history": {
@@ -854,19 +860,19 @@
"name": "self"
},
{
- "annotation": "",
+ "annotation": "int",
"default": "",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "account_type"
},
{
- "annotation": "",
+ "annotation": "int",
"default": "",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "page_number"
}
],
- "return_annotation": "dict[str, typing.Any]"
+ "return_annotation": "dict[str, Any]"
}
},
"open_pending": {
@@ -880,25 +886,25 @@
"name": "self"
},
{
- "annotation": "",
+ "annotation": "float",
"default": "",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "amount"
},
{
- "annotation": "",
+ "annotation": "str",
"default": "",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "asset"
},
{
- "annotation": "",
+ "annotation": "str",
"default": "",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "direction"
},
{
- "annotation": "",
+ "annotation": "int",
"default": "",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "duration"
@@ -910,7 +916,7 @@
"name": "open_time"
}
],
- "return_annotation": "tuple[bool, typing.Any]"
+ "return_annotation": "tuple[bool, Any]"
}
},
"opening_closing_current_candle": {
@@ -924,19 +930,19 @@
"name": "self"
},
{
- "annotation": "",
+ "annotation": "str",
"default": "",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "asset"
},
{
- "annotation": "",
+ "annotation": "int",
"default": "0",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "period"
}
],
- "return_annotation": "dict[str, typing.Any]"
+ "return_annotation": "dict[str, Any]"
}
},
"prepare_candles": {
@@ -950,25 +956,25 @@
"name": "self"
},
{
- "annotation": "",
+ "annotation": "str",
"default": "",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "asset"
},
{
- "annotation": "",
+ "annotation": "int",
"default": "",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "period"
},
{
- "annotation": "list[typing.Any] | None",
+ "annotation": "list[Any] | None",
"default": "None",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "history"
}
],
- "return_annotation": "list[dict[str, typing.Any]]"
+ "return_annotation": "list[dict[str, Any]]"
}
},
"re_subscribe_stream": {
@@ -1016,13 +1022,13 @@
"name": "options_ids"
},
{
- "annotation": "",
+ "annotation": "int",
"default": "30",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "timeout"
}
],
- "return_annotation": "dict[str, typing.Any]"
+ "return_annotation": "dict[str, Any]"
}
},
"sell_option_optimized": {
@@ -1062,7 +1068,7 @@
"name": "self"
},
{
- "annotation": "",
+ "annotation": "str",
"default": "'PRACTICE'",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "balance_mode"
@@ -1114,13 +1120,13 @@
"name": "self"
},
{
- "annotation": "",
+ "annotation": "str",
"default": "",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "asset"
}
],
- "return_annotation": ""
+ "return_annotation": "bool"
}
},
"start_candles_one_stream": {
@@ -1134,19 +1140,19 @@
"name": "self"
},
{
- "annotation": "",
+ "annotation": "str",
"default": "",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "asset"
},
{
- "annotation": "",
+ "annotation": "int",
"default": "",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "size"
}
],
- "return_annotation": ""
+ "return_annotation": "bool"
}
},
"start_candles_stream": {
@@ -1160,13 +1166,13 @@
"name": "self"
},
{
- "annotation": "",
+ "annotation": "str",
"default": "'EURUSD'",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "asset"
},
{
- "annotation": "",
+ "annotation": "int",
"default": "0",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "period"
@@ -1186,13 +1192,13 @@
"name": "self"
},
{
- "annotation": "",
+ "annotation": "str",
"default": "",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "asset"
},
{
- "annotation": "",
+ "annotation": "str",
"default": "'turbo-option'",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "instrument"
@@ -1212,25 +1218,25 @@
"name": "self"
},
{
- "annotation": "",
+ "annotation": "str",
"default": "",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "asset"
},
{
- "annotation": "",
+ "annotation": "int",
"default": "0",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "period"
},
{
- "annotation": "",
+ "annotation": "int",
"default": "30",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "timeout"
}
],
- "return_annotation": "dict[int, typing.Any]"
+ "return_annotation": "dict[int, Any]"
}
},
"start_realtime_price": {
@@ -1244,25 +1250,25 @@
"name": "self"
},
{
- "annotation": "",
+ "annotation": "str",
"default": "",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "asset"
},
{
- "annotation": "",
+ "annotation": "int",
"default": "0",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "period"
},
{
- "annotation": "",
+ "annotation": "int",
"default": "30",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "timeout"
}
],
- "return_annotation": "dict[str, typing.Any]"
+ "return_annotation": "dict[str, Any]"
}
},
"start_realtime_sentiment": {
@@ -1276,25 +1282,25 @@
"name": "self"
},
{
- "annotation": "",
+ "annotation": "str",
"default": "",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "asset"
},
{
- "annotation": "",
+ "annotation": "int",
"default": "0",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "period"
},
{
- "annotation": "",
+ "annotation": "int",
"default": "30",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "timeout"
}
],
- "return_annotation": "dict[str, typing.Any]"
+ "return_annotation": "dict[str, Any]"
}
},
"start_remaing_time": {
@@ -1336,7 +1342,7 @@
"name": "self"
},
{
- "annotation": "",
+ "annotation": "str",
"default": "",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "asset"
@@ -1356,49 +1362,49 @@
"name": "self"
},
{
- "annotation": "",
+ "annotation": "str",
"default": "'EURUSD'",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "asset"
},
{
- "annotation": "",
+ "annotation": "int",
"default": "0",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "period"
},
{
- "annotation": "",
+ "annotation": "str",
"default": "'TIMER'",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "time_mode"
},
{
- "annotation": "",
+ "annotation": "int",
"default": "5",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "deal"
},
{
- "annotation": "",
+ "annotation": "bool",
"default": "False",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "percent_mode"
},
{
- "annotation": "",
+ "annotation": "int",
"default": "1",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "percent_deal"
},
{
- "annotation": "",
+ "annotation": "int",
"default": "30",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "timeout"
}
],
- "return_annotation": "dict[str, typing.Any]"
+ "return_annotation": "dict[str, Any]"
}
},
"subscribe_indicator": {
@@ -1412,31 +1418,31 @@
"name": "self"
},
{
- "annotation": "",
+ "annotation": "str",
"default": "",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "asset"
},
{
- "annotation": "",
+ "annotation": "str",
"default": "",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "indicator"
},
{
- "annotation": "dict[str, typing.Any] | None",
+ "annotation": "dict[str, Any] | None",
"default": "None",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "params"
},
{
- "annotation": "typing.Optional[typing.Callable[[dict[str, typing.Any]], typing.Any]]",
+ "annotation": "Callable[[dict[str, Any]], Any] | None",
"default": "None",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "callback"
},
{
- "annotation": "",
+ "annotation": "int",
"default": "60",
"kind": "POSITIONAL_OR_KEYWORD",
"name": "timeframe"
diff --git a/tests/test_cache.py b/tests/test_cache.py
new file mode 100644
index 00000000..cfdd7ecc
--- /dev/null
+++ b/tests/test_cache.py
@@ -0,0 +1,63 @@
+"""Tests for ``pyquotex.utils.cache.TTLCache``."""
+import time
+
+import pytest
+
+from pyquotex.utils.cache import TTLCache
+
+
+@pytest.mark.unit
+def test_set_and_get() -> None:
+ c: TTLCache[str, int] = TTLCache(maxsize=4, ttl=1.0)
+ c.set("a", 1)
+ assert c.get("a") == 1
+
+
+@pytest.mark.unit
+def test_lru_eviction_on_overflow() -> None:
+ c: TTLCache[str, int] = TTLCache(maxsize=2, ttl=60)
+ c.set("a", 1)
+ c.set("b", 2)
+ c.set("c", 3)
+ assert c.get("a") is None # evicted as least-recent
+ assert c.get("b") == 2
+ assert c.get("c") == 3
+
+
+@pytest.mark.unit
+def test_get_moves_to_end() -> None:
+ c: TTLCache[str, int] = TTLCache(maxsize=2, ttl=60)
+ c.set("a", 1)
+ c.set("b", 2)
+ _ = c.get("a")
+ c.set("c", 3) # should evict 'b', not 'a' since 'a' was accessed
+ assert c.get("a") == 1
+ assert c.get("b") is None
+
+
+@pytest.mark.unit
+def test_lazy_expiration() -> None:
+ c: TTLCache[str, int] = TTLCache(maxsize=4, ttl=0.05)
+ c.set("a", 1)
+ time.sleep(0.07)
+ assert c.get("a") is None
+
+
+@pytest.mark.unit
+def test_invalidate_and_clear() -> None:
+ c: TTLCache[str, int] = TTLCache(maxsize=4, ttl=60)
+ c.set("a", 1)
+ c.set("b", 2)
+ c.invalidate("a")
+ assert c.get("a") is None
+ c.clear()
+ assert c.get("b") is None
+ assert len(c) == 0
+
+
+@pytest.mark.unit
+def test_rejects_bad_params() -> None:
+ with pytest.raises(ValueError):
+ TTLCache(maxsize=0, ttl=10)
+ with pytest.raises(ValueError):
+ TTLCache(maxsize=4, ttl=0)
diff --git a/tests/test_dispatch_table.py b/tests/test_dispatch_table.py
new file mode 100644
index 00000000..ade953f4
--- /dev/null
+++ b/tests/test_dispatch_table.py
@@ -0,0 +1,86 @@
+"""Tests for the dispatch-table refactor of ``QuotexAPI._on_message``.
+
+These exercise the control-event handlers directly, without involving the
+WebSocket or HTTP layers.
+"""
+import pytest
+
+from pyquotex.api import QuotexAPI
+from pyquotex.global_value import AuthStatus
+
+
+def _make_api() -> QuotexAPI:
+ return QuotexAPI(
+ host="qxbroker.com",
+ username="x",
+ password="x",
+ lang="en",
+ proxies=None,
+ resource_path=".",
+ user_data_dir="browser",
+ on_otp_callback=None,
+ )
+
+
+@pytest.mark.unit
+def test_control_handlers_registered() -> None:
+ api = _make_api()
+ for event in (
+ "s_authorization",
+ "instruments/list",
+ "trader/history",
+ "balance",
+ "candle-generated",
+ "sentiment",
+ ):
+ assert event in api._control_handlers
+
+
+@pytest.mark.asyncio
+async def test_balance_handler_sets_slot_and_state() -> None:
+ api = _make_api()
+ payload = {"demoBalance": 100.0, "liveBalance": 1.0}
+ await api._control_handlers["balance"](payload)
+ assert api.account_balance == payload
+ assert api.slots.balance.is_set()
+
+
+@pytest.mark.asyncio
+async def test_auth_handler_flips_state() -> None:
+ api = _make_api()
+ await api._control_handlers["s_authorization"](None)
+ assert api.state.auth_status == AuthStatus.AUTHENTICATED
+
+
+@pytest.mark.asyncio
+async def test_instruments_list_handler_caches_list() -> None:
+ api = _make_api()
+ rows = [[1, "EURUSD", "EUR/USD"]]
+ await api._control_handlers["instruments/list"](rows)
+ assert api.instruments == rows
+
+
+@pytest.mark.asyncio
+async def test_instruments_list_handler_handles_placeholder() -> None:
+ api = _make_api()
+ placeholder = {"_placeholder": True, "num": 0}
+ await api._control_handlers["instruments/list"](placeholder)
+ assert "instruments/list" in api._temp_status
+
+
+@pytest.mark.asyncio
+async def test_sentiment_handler_indexes_by_asset() -> None:
+ api = _make_api()
+ payload = {"asset": "EURUSD", "value": 0.6}
+ await api._control_handlers["sentiment"](payload)
+ assert api.traders_mood["EURUSD"] == payload
+ assert api.realtime_sentiment["EURUSD"] == payload
+
+
+@pytest.mark.asyncio
+async def test_candle_generated_handler_caches_by_asset_period() -> None:
+ api = _make_api()
+ payload = {"asset": "EURUSD", "period": 60, "close": 1.1}
+ await api._control_handlers["candle-generated"](payload)
+ assert api.candle_generated_check["EURUSD"][60] == payload
+ assert api.candle_generated_all_size_check["EURUSD"] == payload
diff --git a/tests/test_json_utils.py b/tests/test_json_utils.py
new file mode 100644
index 00000000..ea977ca1
--- /dev/null
+++ b/tests/test_json_utils.py
@@ -0,0 +1,33 @@
+"""Tests for ``pyquotex.utils.json_utils``."""
+import pytest
+
+from pyquotex.utils import json_utils as j
+
+
+@pytest.mark.unit
+def test_dumps_returns_bytes() -> None:
+ assert isinstance(j.dumps({"a": 1}), bytes)
+
+
+@pytest.mark.unit
+def test_dumps_bytes_is_alias() -> None:
+ assert j.dumps_bytes({"a": 1}) == j.dumps({"a": 1})
+
+
+@pytest.mark.unit
+def test_dumps_str_returns_str() -> None:
+ s = j.dumps_str({"a": 1})
+ assert isinstance(s, str)
+ assert '"a"' in s and "1" in s
+
+
+@pytest.mark.unit
+def test_roundtrip() -> None:
+ payload = {"k": [1, 2, 3], "s": "x"}
+ assert j.loads(j.dumps(payload)) == payload
+ assert j.loads(j.dumps_str(payload)) == payload
+
+
+@pytest.mark.unit
+def test_has_orjson_flag_is_bool() -> None:
+ assert isinstance(j.HAS_ORJSON, bool)
diff --git a/tests/test_reconnect.py b/tests/test_reconnect.py
new file mode 100644
index 00000000..2366a70d
--- /dev/null
+++ b/tests/test_reconnect.py
@@ -0,0 +1,243 @@
+"""Tests for the resilience layer: ReconnectPolicy + WebsocketClient.
+
+These tests stub out the actual ``websockets.connect`` call and exercise
+:meth:`WebsocketClient.run_forever` to verify the auto-reconnect loop,
+backoff, watchdog, and subscription replay logic in isolation.
+"""
+from __future__ import annotations
+
+import asyncio
+import time
+from contextlib import asynccontextmanager
+from typing import Any
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from websockets.exceptions import ConnectionClosed
+from websockets.frames import Close
+
+from pyquotex.types import ReconnectPolicy, Subscription
+from pyquotex.ws.client import WebsocketClient
+
+
+class _FakeApi:
+ """Minimal duck-typed stand-in for QuotexAPI used by these tests."""
+
+ def __init__(self) -> None:
+ self.state = MagicMock(status=1) # WebsocketStatus.CONNECTED
+ self.last_message_at = time.monotonic()
+ self._subscriptions: dict[str, Subscription] = {}
+ self.replayed: list[tuple[str, str, int | None]] = []
+ # Used by _replay_one
+ self.subscribe_realtime_candle = AsyncMock(
+ side_effect=lambda a, p: self.replayed.append(("candle", a, p))
+ )
+ self.chart_notification = AsyncMock()
+ self.follow_candle = AsyncMock()
+ self.subscribe_all_size = AsyncMock(
+ side_effect=lambda a: self.replayed.append(("all_size", a, None))
+ )
+ self.subscribe_Traders_mood = AsyncMock(
+ side_effect=lambda a, i: self.replayed.append(("mood", a, None))
+ )
+ self._on_open = AsyncMock()
+ self._on_message = AsyncMock()
+ self._on_close = MagicMock()
+ self._on_error = MagicMock()
+
+
+class _FakeWS:
+ """Minimal stand-in for an open websocket connection."""
+
+ def __init__(self, frames: list[str] | None = None, raise_on_iter: Exception | None = None):
+ self.state = MagicMock()
+ from websockets.protocol import State
+ self.state = State.OPEN
+ self._frames = frames or []
+ self._raise = raise_on_iter
+ self.closed = False
+
+ async def __aenter__(self) -> "_FakeWS":
+ return self
+
+ async def __aexit__(self, *args: Any) -> None:
+ self.closed = True
+
+ def __aiter__(self) -> "_FakeWS":
+ return self
+
+ async def __anext__(self) -> str:
+ if self._raise is not None:
+ raise self._raise
+ if not self._frames:
+ raise StopAsyncIteration
+ return self._frames.pop(0)
+
+ async def send(self, data: str) -> None:
+ return None
+
+ async def close(self, code: int = 1000, reason: str = "") -> None:
+ from websockets.protocol import State
+ self.state = State.CLOSED
+ self.closed = True
+
+
+def _fake_connect_factory(ws_sequence: list[_FakeWS]):
+ """Return a function suitable for patching ``websockets.connect``.
+
+ Each call pops one ``_FakeWS`` from ``ws_sequence``.
+ """
+
+ @asynccontextmanager
+ async def _fake_connect(*args: Any, **kwargs: Any):
+ ws = ws_sequence.pop(0)
+ try:
+ yield ws
+ finally:
+ await ws.close()
+
+ return _fake_connect
+
+
+@pytest.mark.unit
+@pytest.mark.asyncio
+async def test_no_reconnect_when_disabled() -> None:
+ api = _FakeApi()
+ client = WebsocketClient(api, ReconnectPolicy(enabled=False))
+
+ ws = _FakeWS(frames=["msg1", "msg2"])
+ with patch("pyquotex.ws.client.websockets.connect", _fake_connect_factory([ws])):
+ await client.run_forever("wss://example/test")
+
+ # _on_open and _on_message called; no second connect attempted.
+ assert api._on_open.await_count == 1
+ assert api._on_message.await_count == 2
+
+
+@pytest.mark.unit
+@pytest.mark.asyncio
+async def test_auto_reconnect_after_unexpected_close() -> None:
+ api = _FakeApi()
+ policy = ReconnectPolicy(
+ enabled=True,
+ max_attempts=1, # one retry, then bail
+ base_delay=0.001,
+ max_delay=0.005,
+ jitter=0.0,
+ stale_timeout=0, # disable watchdog for this test
+ )
+ client = WebsocketClient(api, policy)
+
+ closed = ConnectionClosed(rcvd=Close(1006, "abrupt"), sent=None)
+ ws1 = _FakeWS(raise_on_iter=closed)
+ ws2 = _FakeWS(frames=["after-reconnect"])
+
+ with patch(
+ "pyquotex.ws.client.websockets.connect",
+ _fake_connect_factory([ws1, ws2]),
+ ):
+ await client.run_forever("wss://example/test")
+
+ assert api._on_open.await_count == 2
+ assert api._on_close.call_count == 1
+ # The reconnect run consumed the "after-reconnect" frame.
+ assert api._on_message.await_count >= 1
+
+
+@pytest.mark.unit
+@pytest.mark.asyncio
+async def test_subscriptions_replayed_on_reconnect() -> None:
+ api = _FakeApi()
+ api._subscriptions["candle:EURUSD:60"] = Subscription(
+ kind="candle", asset="EURUSD", period=60
+ )
+ api._subscriptions["mood:EURUSD:0"] = Subscription(
+ kind="mood", asset="EURUSD"
+ )
+ policy = ReconnectPolicy(
+ enabled=True,
+ max_attempts=1,
+ base_delay=0.001,
+ max_delay=0.005,
+ jitter=0.0,
+ stale_timeout=0,
+ )
+ client = WebsocketClient(api, policy)
+
+ closed = ConnectionClosed(rcvd=Close(1011, "fail"), sent=None)
+ ws1 = _FakeWS(raise_on_iter=closed)
+ ws2 = _FakeWS(frames=[])
+
+ with patch(
+ "pyquotex.ws.client.websockets.connect",
+ _fake_connect_factory([ws1, ws2]),
+ ):
+ task = asyncio.create_task(client.run_forever("wss://example/test"))
+ # Let the background replay task run; cap to keep CI fast.
+ await asyncio.sleep(0.5)
+ await client.close()
+ await asyncio.wait_for(task, timeout=2)
+
+ # Replay should have re-issued both subscriptions exactly once.
+ kinds = [r[0] for r in api.replayed]
+ assert "candle" in kinds
+ assert "mood" in kinds
+
+
+@pytest.mark.unit
+@pytest.mark.asyncio
+async def test_close_stops_reconnect_loop() -> None:
+ api = _FakeApi()
+ policy = ReconnectPolicy(
+ enabled=True,
+ max_attempts=100,
+ base_delay=0.001,
+ max_delay=0.005,
+ jitter=0.0,
+ stale_timeout=0,
+ )
+ client = WebsocketClient(api, policy)
+
+ ws = _FakeWS(frames=[])
+
+ async def slow_connect(*args, **kwargs):
+ # Never resolves until cancelled, simulating an alive socket
+ @asynccontextmanager
+ async def _ctx():
+ try:
+ yield ws
+ await asyncio.sleep(5)
+ except asyncio.CancelledError:
+ raise
+
+ return _ctx()
+
+ with patch("pyquotex.ws.client.websockets.connect", _fake_connect_factory([ws])):
+ task = asyncio.create_task(client.run_forever("wss://example/test"))
+ await asyncio.sleep(0.05)
+ await client.close()
+ await asyncio.wait_for(task, timeout=2)
+ assert client._closing is True
+
+
+@pytest.mark.unit
+def test_api_tracks_and_forgets_subscriptions() -> None:
+ """QuotexAPI helper methods record subscriptions for replay."""
+ from pyquotex.api import QuotexAPI
+
+ api = QuotexAPI(
+ host="qxbroker.com",
+ username="x",
+ password="x",
+ lang="en",
+ proxies=None,
+ resource_path=".",
+ user_data_dir="browser",
+ on_otp_callback=None,
+ )
+ api._track_subscription("candle", "EURUSD", 60)
+ api._track_subscription("mood", "EURUSD")
+ assert "candle:EURUSD:60" in api._subscriptions
+ assert "mood:EURUSD:0" in api._subscriptions
+ api._forget_subscription("candle", "EURUSD", 60)
+ assert "candle:EURUSD:60" not in api._subscriptions
diff --git a/tests/test_streaming_indicators.py b/tests/test_streaming_indicators.py
new file mode 100644
index 00000000..613f1940
--- /dev/null
+++ b/tests/test_streaming_indicators.py
@@ -0,0 +1,77 @@
+"""Tests for the incremental streaming indicators."""
+import pytest
+
+from pyquotex.utils.indicators import TechnicalIndicators
+from pyquotex.utils.streaming_indicators import (
+ StreamingBollinger,
+ StreamingEMA,
+ StreamingRSI,
+ StreamingSMA,
+)
+
+
+@pytest.mark.unit
+class TestStreamingSMA:
+ def test_returns_none_until_warmed(self) -> None:
+ sma = StreamingSMA(period=3)
+ assert sma.update(1.0) is None
+ assert sma.update(2.0) is None
+ assert sma.update(3.0) == pytest.approx(2.0)
+ assert sma.update(4.0) == pytest.approx(3.0)
+
+ def test_matches_batch_implementation(self) -> None:
+ prices = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0]
+ batch = TechnicalIndicators.calculate_sma(prices, 3)
+ streaming = StreamingSMA(3)
+ out = [streaming.update(p) for p in prices]
+ non_none = [round(x, 2) for x in out if x is not None]
+ assert non_none == batch
+
+ def test_rejects_invalid_period(self) -> None:
+ with pytest.raises(ValueError):
+ StreamingSMA(0)
+
+
+@pytest.mark.unit
+class TestStreamingEMA:
+ def test_warms_via_sma_seed(self) -> None:
+ ema = StreamingEMA(period=3)
+ ema.update(1.0)
+ ema.update(2.0)
+ warmed = ema.update(3.0)
+ assert warmed == pytest.approx(2.0) # SMA seed
+
+ def test_post_warm_uses_alpha(self) -> None:
+ ema = StreamingEMA(period=2)
+ ema.update(1.0)
+ ema.update(3.0) # warmed: (1+3)/2 = 2
+ v = ema.update(5.0) # alpha = 2/3; new = 5*2/3 + 2*1/3
+ assert v == pytest.approx(5 * 2 / 3 + 2 * 1 / 3)
+
+
+@pytest.mark.unit
+class TestStreamingRSI:
+ def test_constant_prices_return_neutral_when_warmed(self) -> None:
+ rsi = StreamingRSI(period=3)
+ outs = [rsi.update(1.0) for _ in range(5)]
+ # No movement → avg_loss == avg_gain == 0 → returns 100 (max signal,
+ # by Wilder convention when loss == 0).
+ assert outs[-1] == 100.0
+
+ def test_monotonic_up_pushes_rsi_high(self) -> None:
+ rsi = StreamingRSI(period=3)
+ outs = [rsi.update(p) for p in [1.0, 2.0, 3.0, 4.0, 5.0]]
+ assert outs[-1] is not None and outs[-1] > 80
+
+
+@pytest.mark.unit
+class TestStreamingBollinger:
+ def test_returns_triplet_when_warmed(self) -> None:
+ bb = StreamingBollinger(period=3, num_std=2)
+ assert bb.update(1.0) is None
+ assert bb.update(2.0) is None
+ result = bb.update(3.0)
+ assert result is not None
+ upper, middle, lower = result
+ assert middle == pytest.approx(2.0)
+ assert upper > middle > lower
diff --git a/tests/test_types.py b/tests/test_types.py
new file mode 100644
index 00000000..fa125e97
--- /dev/null
+++ b/tests/test_types.py
@@ -0,0 +1,113 @@
+"""Tests for the new public dataclasses in ``pyquotex.types``."""
+import pytest
+
+from pyquotex.types import (
+ AssetInfo,
+ Balance,
+ Candle,
+ ProfileInfo,
+ ReconnectPolicy,
+ Subscription,
+ TradeResult,
+)
+
+
+@pytest.mark.unit
+class TestCandle:
+ def test_from_dict_full(self) -> None:
+ c = Candle.from_dict(
+ {"time": 1, "open": 2.0, "high": 3.0, "low": 1.5, "close": 2.5, "volume": 100}
+ )
+ assert (c.time, c.open, c.high, c.low, c.close, c.volume) == (
+ 1, 2.0, 3.0, 1.5, 2.5, 100.0
+ )
+
+ def test_from_array_orders_match_broker(self) -> None:
+ # broker order: [t, o, c, h, l]
+ c = Candle.from_array([10, 1.0, 1.4, 1.5, 0.8])
+ assert c.time == 10
+ assert c.open == 1.0
+ assert c.close == 1.4
+ assert c.high == 1.5
+ assert c.low == 0.8
+
+ def test_from_array_rejects_short(self) -> None:
+ with pytest.raises(ValueError):
+ Candle.from_array([1, 2, 3])
+
+ @pytest.mark.parametrize(
+ "open_,close,expected",
+ [(1.0, 1.5, "green"), (1.5, 1.0, "red"), (1.0, 1.0, "doji")],
+ )
+ def test_color(self, open_: float, close: float, expected: str) -> None:
+ c = Candle(time=0, open=open_, high=2, low=0.5, close=close)
+ assert c.color == expected
+
+ def test_is_frozen(self) -> None:
+ c = Candle(time=0, open=1, high=1, low=1, close=1)
+ with pytest.raises(Exception):
+ c.time = 99 # type: ignore[misc]
+
+
+@pytest.mark.unit
+def test_trade_result_from_dict_infers_status() -> None:
+ win = TradeResult.from_dict({"id": "t1", "profit": 5.0, "asset": "EURUSD"})
+ assert win.status == "win"
+ loss = TradeResult.from_dict({"ticket": "t2", "profit": -2.0})
+ assert loss.status == "loss"
+ draw = TradeResult.from_dict({"id": "t3", "profit": 0})
+ assert draw.status == "draw"
+
+
+@pytest.mark.unit
+def test_balance_from_dict() -> None:
+ b = Balance.from_dict(
+ {"demoBalance": 10000.0, "liveBalance": 50.0, "currencyCode": "USD"}
+ )
+ assert b.demo == 10000.0
+ assert b.live == 50.0
+ assert b.currency_code == "USD"
+
+
+@pytest.mark.unit
+def test_profile_info_from_profile_object() -> None:
+ class P:
+ nick_name = "alice"
+ profile_id = 42
+ demo_balance = 100.0
+ live_balance = 0.0
+ currency_code = "USD"
+ currency_symbol = "$"
+ country_name = "BR"
+ offset = 0
+ p = ProfileInfo.from_profile(P())
+ assert p.nickname == "alice"
+ assert p.profile_id == 42
+ assert p.demo_balance == 100.0
+
+
+@pytest.mark.unit
+def test_asset_info_from_row() -> None:
+ row = [1, "EURUSD", "EUR/USD\n", 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, True]
+ a = AssetInfo.from_instrument_row(row)
+ assert a.id == 1
+ assert a.symbol == "EURUSD"
+ assert a.name == "EUR/USD"
+ assert a.is_open is True
+
+
+@pytest.mark.unit
+def test_reconnect_policy_defaults_sensible() -> None:
+ p = ReconnectPolicy()
+ assert p.enabled is True
+ assert p.max_attempts == 0 # infinite by default
+ assert p.base_delay >= 0
+ assert p.max_delay >= p.base_delay
+ assert p.stale_timeout > 0
+
+
+@pytest.mark.unit
+def test_subscription_mutability() -> None:
+ s = Subscription(kind="candle", asset="EURUSD", period=60)
+ s.extra["foo"] = "bar" # Subscription is intentionally mutable
+ assert s.extra == {"foo": "bar"}