pybynix 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pybynix/__init__.py ADDED
@@ -0,0 +1,79 @@
1
+ """Unofficial Bynix SDK. No affiliation with the platform or Python Software Foundation."""
2
+
3
+ from .auth import AuthChain, BrowserAuth, PasswordAuth, SessionProvider, load_session, save_session
4
+ from .client import Bynix
5
+ from .data import candles_to_dataframe, candles_to_records, export_candles, resample_candles
6
+ from .indicators import (
7
+ BollingerBandsResult,
8
+ MACDResult,
9
+ StochasticResult,
10
+ atr,
11
+ average_true_range,
12
+ bollinger_bands,
13
+ ema,
14
+ exponential_moving_average,
15
+ macd,
16
+ moving_average_convergence_divergence,
17
+ relative_strength_index,
18
+ rsi,
19
+ simple_moving_average,
20
+ sma,
21
+ stochastic,
22
+ stochastic_oscillator,
23
+ )
24
+ from .journal import MemoryJournal, SQLiteJournal
25
+ from .models import AccountType, Candle, ConnectionEvent, Session, Tick
26
+ from .orchestration import (
27
+ AutoExecutor,
28
+ DecisionCallback,
29
+ ExecutionIntent,
30
+ ExecutionOrchestrator,
31
+ ExecutionRecord,
32
+ OrderIntent,
33
+ )
34
+ from .pool import ClientPool
35
+
36
+ __version__ = "1.0.0"
37
+ __all__ = [
38
+ "AccountType",
39
+ "AuthChain",
40
+ "BollingerBandsResult",
41
+ "BrowserAuth",
42
+ "Bynix",
43
+ "Candle",
44
+ "ClientPool",
45
+ "ConnectionEvent",
46
+ "AutoExecutor",
47
+ "DecisionCallback",
48
+ "ExecutionIntent",
49
+ "ExecutionOrchestrator",
50
+ "ExecutionRecord",
51
+ "MemoryJournal",
52
+ "MACDResult",
53
+ "PasswordAuth",
54
+ "SQLiteJournal",
55
+ "Session",
56
+ "SessionProvider",
57
+ "StochasticResult",
58
+ "Tick",
59
+ "OrderIntent",
60
+ "candles_to_dataframe",
61
+ "candles_to_records",
62
+ "export_candles",
63
+ "load_session",
64
+ "atr",
65
+ "average_true_range",
66
+ "bollinger_bands",
67
+ "ema",
68
+ "exponential_moving_average",
69
+ "macd",
70
+ "moving_average_convergence_divergence",
71
+ "relative_strength_index",
72
+ "resample_candles",
73
+ "rsi",
74
+ "save_session",
75
+ "simple_moving_average",
76
+ "sma",
77
+ "stochastic",
78
+ "stochastic_oscillator",
79
+ ]
pybynix/auth.py ADDED
@@ -0,0 +1,207 @@
1
+ """Explicit session, credential and browser providers. No implicit browser launch."""
2
+
3
+ import json
4
+ import os
5
+ import tempfile
6
+ from pathlib import Path
7
+ from typing import Protocol
8
+
9
+ import httpx
10
+
11
+ from .errors import AuthenticationError, ChallengeRequired, ProtocolError, RateLimitError
12
+ from .models import LoginChallenge, Session
13
+ from .network import DNSHTTPTransport, validate_dns, validate_dns_servers
14
+
15
+
16
+ class SessionProvider(Protocol):
17
+ async def __call__(self, host: str) -> Session: ...
18
+
19
+
20
+ class PasswordAuth:
21
+ def __init__(
22
+ self,
23
+ email: str,
24
+ password: str,
25
+ *,
26
+ proxy=None,
27
+ dns_overrides=None,
28
+ otp_callback=None,
29
+ dns_servers=(),
30
+ dns_port=53,
31
+ ):
32
+ self._email, self._password = email, password
33
+ if proxy and (dns_overrides or dns_servers):
34
+ raise ValueError("Use proxy or static DNS overrides, not both")
35
+ self._proxy = proxy
36
+ self._dns = validate_dns(dns_overrides)
37
+ self._dns_servers = validate_dns_servers(dns_servers)
38
+ self._dns_port = dns_port
39
+ self._otp_callback = otp_callback
40
+
41
+ async def __call__(self, host: str) -> Session:
42
+ try:
43
+ async with httpx.AsyncClient(
44
+ timeout=30,
45
+ follow_redirects=False,
46
+ trust_env=False,
47
+ proxy=self._proxy,
48
+ transport=DNSHTTPTransport(
49
+ self._dns, nameservers=self._dns_servers, port=self._dns_port
50
+ )
51
+ if self._dns or self._dns_servers
52
+ else None,
53
+ ) as http:
54
+ response = await http.post(
55
+ f"https://{host}/api/auth/login",
56
+ json={"email": self._email, "password": self._password},
57
+ )
58
+ except httpx.TransportError:
59
+ raise ProtocolError("Login transport failed") from None
60
+ if not response.is_success:
61
+ if response.status_code == 429:
62
+ raise RateLimitError(response.headers.get("Retry-After"))
63
+ if response.status_code == 403:
64
+ try:
65
+ failure = response.json()
66
+ except ValueError:
67
+ failure = {}
68
+ if (
69
+ response.headers.get("cf-mitigated") == "challenge"
70
+ or isinstance(failure, dict)
71
+ and failure.get("code")
72
+ in {"challenge_required", "captcha_required", "turnstile_required"}
73
+ ):
74
+ raise ChallengeRequired()
75
+ raise AuthenticationError(response.status_code)
76
+ data = response.json()
77
+ if not isinstance(data, dict):
78
+ raise ProtocolError("Expected login object")
79
+ if data.get("requires_2fa"):
80
+ if not self._otp_callback:
81
+ raise ChallengeRequired(data.get("method"))
82
+ challenge = LoginChallenge(data["challenge_id"], data.get("method", "unknown"))
83
+ code = await self._otp_callback(challenge)
84
+ if not isinstance(code, str) or not code.strip():
85
+ raise ValueError("OTP callback must return a nonempty code")
86
+ async with httpx.AsyncClient(
87
+ timeout=30,
88
+ follow_redirects=False,
89
+ trust_env=False,
90
+ proxy=self._proxy,
91
+ transport=DNSHTTPTransport(
92
+ self._dns, nameservers=self._dns_servers, port=self._dns_port
93
+ )
94
+ if self._dns or self._dns_servers
95
+ else None,
96
+ ) as http:
97
+ verification = await http.post(
98
+ f"https://{host}/api/auth/login/2fa",
99
+ json={"challenge_id": challenge.challenge_id, "code": code.strip()},
100
+ )
101
+ if not verification.is_success:
102
+ raise AuthenticationError(verification.status_code)
103
+ data = verification.json()
104
+ if not data.get("access_token"):
105
+ raise ProtocolError("Login response lacks an access token")
106
+ return Session(data["access_token"], host)
107
+
108
+
109
+ class BrowserAuth:
110
+ """Optional Playwright provider; visible browser permits manual challenges.
111
+
112
+ Bynix web app uses localStorage['token']. The result is subsequently validated
113
+ by the client's /auth/me request, never assumed authenticated from the page URL.
114
+ """
115
+
116
+ def __init__(self, email=None, password=None, *, headless=False, timeout=180, profile_dir=None):
117
+ self._email, self._password = email, password
118
+ self.headless, self.timeout = headless, timeout
119
+ self.profile_dir = Path(profile_dir).expanduser().resolve() if profile_dir else None
120
+
121
+ async def __call__(self, host):
122
+ from playwright.async_api import async_playwright
123
+
124
+ async with async_playwright() as p:
125
+ browser = None
126
+ context = None
127
+ try:
128
+ if self.profile_dir:
129
+ context = await p.chromium.launch_persistent_context(
130
+ str(self.profile_dir), headless=self.headless
131
+ )
132
+ page = await context.new_page()
133
+ else:
134
+ browser = await p.chromium.launch(headless=self.headless)
135
+ context = await browser.new_context()
136
+ page = await context.new_page()
137
+ await page.goto(f"https://{host}/login", wait_until="domcontentloaded")
138
+ existing = await page.evaluate("localStorage.getItem('token')")
139
+ if existing:
140
+ profile = await page.request.get(
141
+ f"https://{host}/api/auth/me",
142
+ headers={"Authorization": f"Bearer {existing}"},
143
+ )
144
+ if profile.status == 401:
145
+ await page.evaluate(
146
+ "localStorage.removeItem('token'); localStorage.removeItem('user')"
147
+ )
148
+ existing = None
149
+ await page.goto(f"https://{host}/login", wait_until="domcontentloaded")
150
+ elif profile.status != 200:
151
+ raise AuthenticationError(profile.status)
152
+ if not existing and self._email and self._password:
153
+ await page.get_by_test_id("login-email").fill(self._email)
154
+ await page.get_by_test_id("login-password").fill(self._password)
155
+ await page.get_by_test_id("login-submit").click()
156
+ await page.wait_for_function(
157
+ "Boolean(localStorage.getItem('token'))", timeout=self.timeout * 1000
158
+ )
159
+ token = await page.evaluate("localStorage.getItem('token')")
160
+ return Session(token, host)
161
+ finally:
162
+ if context:
163
+ await context.close()
164
+ if browser:
165
+ await browser.close()
166
+
167
+
168
+ class AuthChain:
169
+ """Fall back only on an interactive challenge, not bad credentials/rate limits."""
170
+
171
+ def __init__(self, *providers: SessionProvider):
172
+ if not providers:
173
+ raise ValueError("At least one provider is required")
174
+ self.providers = providers
175
+
176
+ async def __call__(self, host):
177
+ for provider in self.providers:
178
+ try:
179
+ return await provider(host)
180
+ except ChallengeRequired:
181
+ continue
182
+ raise ChallengeRequired()
183
+
184
+
185
+ def load_session(path: str | Path) -> Session:
186
+ data = json.loads(Path(path).read_text(encoding="utf-8"))
187
+ return Session(data["token"], data["host"])
188
+
189
+
190
+ def save_session(session: Session, path: str | Path):
191
+ """Opt-in plaintext export; protect the containing directory on Windows."""
192
+ path = Path(path)
193
+ path.parent.mkdir(parents=True, exist_ok=True)
194
+ temporary = None
195
+ try:
196
+ with tempfile.NamedTemporaryFile(
197
+ mode="w", encoding="utf-8", dir=path.parent, prefix=".pybynix-session-", delete=False
198
+ ) as stream:
199
+ temporary = Path(stream.name)
200
+ os.chmod(temporary, 0o600)
201
+ json.dump({"host": session.host, "token": session.token}, stream)
202
+ stream.flush()
203
+ os.fsync(stream.fileno())
204
+ os.replace(temporary, path)
205
+ finally:
206
+ if temporary is not None and temporary.exists():
207
+ temporary.unlink()
pybynix/cli.py ADDED
@@ -0,0 +1,158 @@
1
+ """Login, market data and diagnostics."""
2
+
3
+ import argparse
4
+ import asyncio
5
+ import getpass
6
+ import json
7
+ import os
8
+ from pathlib import Path
9
+
10
+ from . import (
11
+ BrowserAuth,
12
+ Bynix,
13
+ PasswordAuth,
14
+ Session,
15
+ __version__,
16
+ export_candles,
17
+ load_session,
18
+ save_session,
19
+ )
20
+ from .domains import check_domains
21
+ from .errors import BynixError
22
+
23
+
24
+ async def run(args):
25
+ host = args.host or os.getenv("BYNIX_HOST", "bynixbrokers.com")
26
+ if args.command == "domains":
27
+ print(json.dumps(await check_domains(), indent=2))
28
+ return
29
+ if args.command == "login":
30
+ if args.browser:
31
+ provider = BrowserAuth(profile_dir=args.profile_dir)
32
+ else:
33
+ email = os.getenv("BYNIX_EMAIL") or input("Bynix email: ").strip()
34
+ password = os.getenv("BYNIX_PASSWORD") or getpass.getpass("Bynix password: ")
35
+
36
+ async def otp(challenge):
37
+ return getpass.getpass(f"{challenge.method} verification code: ").strip()
38
+
39
+ provider = PasswordAuth(email, password, otp_callback=otp)
40
+ async with Bynix(host=host, provider=provider, timeout=args.timeout) as client:
41
+ save_session(client.session, args.session)
42
+ print(f"Session saved to {args.session}. Keep this file private.")
43
+ return
44
+ token = os.getenv("BYNIX_TOKEN")
45
+ session = Session(token, host) if token else None
46
+ if session is None and args.session.is_file():
47
+ session = load_session(args.session)
48
+ if args.host is None:
49
+ host = session.host
50
+ provider = None
51
+ if os.getenv("BYNIX_EMAIL") and os.getenv("BYNIX_PASSWORD"):
52
+ provider = PasswordAuth(os.environ["BYNIX_EMAIL"], os.environ["BYNIX_PASSWORD"])
53
+ anonymous = session is None and provider is None
54
+ if anonymous and args.command in {"balances", "trades"}:
55
+ raise ValueError("Run pybynix login first or supply BYNIX_TOKEN")
56
+ async with Bynix(
57
+ session=session,
58
+ provider=provider,
59
+ anonymous=anonymous,
60
+ host=host,
61
+ stream_transport=args.transport,
62
+ timeout=args.timeout,
63
+ ) as client:
64
+ if args.command == "doctor":
65
+ value = {
66
+ "version": __version__,
67
+ "connected": client.connected,
68
+ "mode": "public" if anonymous else "authenticated",
69
+ "websocket": client._socket.connected,
70
+ "platform": await client.get_platform_status(),
71
+ "assets": len(await client.get_assets()),
72
+ "clock": await client.get_server_clock(),
73
+ }
74
+ elif args.command == "assets":
75
+ value = await client.get_assets()
76
+ elif args.command == "payouts":
77
+ value = await client.get_payouts()
78
+ elif args.command == "balances":
79
+ value = await client.get_balance_breakdown()
80
+ elif args.command == "trades":
81
+ value = await client.get_trade_history(account_type=args.account, limit=args.count)
82
+ elif args.command == "candles":
83
+ candles = await client.get_candles(
84
+ args.symbol, count=args.count, interval=args.interval
85
+ )
86
+ if args.output:
87
+ export_candles(candles, args.output)
88
+ print(f"Saved {len(candles)} candles to {args.output}")
89
+ return
90
+ value = [
91
+ {
92
+ "timestamp": c.timestamp,
93
+ "open": c.open,
94
+ "high": c.high,
95
+ "low": c.low,
96
+ "close": c.close,
97
+ "volume": c.volume,
98
+ }
99
+ for c in candles
100
+ ]
101
+ elif args.command == "sentiment":
102
+ value = await client.get_realtime_sentiment(args.symbol)
103
+ elif args.command == "stream":
104
+ async with await client.subscribe_ticks(args.symbol, interval=args.interval) as ticks:
105
+ for _ in range(args.count):
106
+ tick = await asyncio.wait_for(anext(ticks), args.timeout)
107
+ print(
108
+ json.dumps(
109
+ {
110
+ "symbol": tick.symbol,
111
+ "timestamp": tick.timestamp,
112
+ "price": str(tick.price),
113
+ "payout": str(tick.payout),
114
+ }
115
+ )
116
+ )
117
+ return
118
+ print(json.dumps(value, indent=2, default=str))
119
+
120
+
121
+ def main():
122
+ parser = argparse.ArgumentParser(description="PyBynix login, market data and diagnostics")
123
+ parser.add_argument("--version", action="version", version=__version__)
124
+ parser.add_argument(
125
+ "command",
126
+ choices=[
127
+ "login",
128
+ "doctor",
129
+ "domains",
130
+ "assets",
131
+ "payouts",
132
+ "balances",
133
+ "trades",
134
+ "candles",
135
+ "stream",
136
+ "sentiment",
137
+ ],
138
+ )
139
+ parser.add_argument("--host")
140
+ parser.add_argument("--session", type=Path, default=Path.home() / ".pybynix" / "session.json")
141
+ parser.add_argument("--browser", action="store_true", help="Interactive browser login")
142
+ parser.add_argument("--profile-dir", type=Path)
143
+ parser.add_argument("--transport", choices=["auto", "websocket", "polling"], default="auto")
144
+ parser.add_argument("--symbol", default="EUR/USD OTC")
145
+ parser.add_argument("--interval", default="1m")
146
+ parser.add_argument("--count", type=int, default=5)
147
+ parser.add_argument("--timeout", type=float, default=20)
148
+ parser.add_argument("--account", choices=["demo", "real", "funded"], default="demo")
149
+ parser.add_argument("--output", type=Path)
150
+ args = parser.parse_args()
151
+ if args.count < 1:
152
+ parser.error("count must be positive")
153
+ try:
154
+ asyncio.run(run(args))
155
+ except (BynixError, ValueError, RuntimeError, OSError, TimeoutError) as error:
156
+ parser.exit(1, f"{type(error).__name__}: {error}\n")
157
+ except KeyboardInterrupt:
158
+ parser.exit(130)