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 +79 -0
- pybynix/auth.py +207 -0
- pybynix/cli.py +158 -0
- pybynix/client.py +1263 -0
- pybynix/data.py +85 -0
- pybynix/domains.json +28 -0
- pybynix/domains.py +35 -0
- pybynix/errors.py +74 -0
- pybynix/indicators.py +328 -0
- pybynix/journal.py +195 -0
- pybynix/models.py +98 -0
- pybynix/network.py +152 -0
- pybynix/orchestration.py +477 -0
- pybynix/pool.py +44 -0
- pybynix/runtime.py +23 -0
- pybynix/sentiment.py +44 -0
- pybynix/stable_api.py +5 -0
- pybynix/stream.py +54 -0
- pybynix-1.0.0.dist-info/METADATA +199 -0
- pybynix-1.0.0.dist-info/RECORD +24 -0
- pybynix-1.0.0.dist-info/WHEEL +4 -0
- pybynix-1.0.0.dist-info/entry_points.txt +2 -0
- pybynix-1.0.0.dist-info/licenses/LICENSE +21 -0
- pybynix-1.0.0.dist-info/licenses/NOTICE.md +9 -0
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)
|