brain-login-and-sim 0.1.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tanakrit
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,101 @@
1
+ Metadata-Version: 2.4
2
+ Name: brain-login-and-sim
3
+ Version: 0.1.0
4
+ Summary: WorldQuant BRAIN client — login (biometric Scan + auto re-login), simulation, batch run, and auto-keeping promising alphas
5
+ Author-email: Tanakrit <9tanakrit.work@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/Tanakrit/brain-login-and-sim
8
+ Project-URL: Issues, https://github.com/Tanakrit/brain-login-and-sim/issues
9
+ Keywords: worldquant,brain,alpha,quant,simulation,trading
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: Financial and Insurance Industry
15
+ Classifier: Topic :: Office/Business :: Financial :: Investment
16
+ Requires-Python: >=3.9
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Requires-Dist: requests>=2.28
20
+ Requires-Dist: python-dotenv>=0.19
21
+ Provides-Extra: dev
22
+ Requires-Dist: build; extra == "dev"
23
+ Requires-Dist: twine; extra == "dev"
24
+ Requires-Dist: pytest; extra == "dev"
25
+ Dynamic: license-file
26
+
27
+ # brain-login-and-sim
28
+
29
+ ไคลเอนต์ Python สำหรับ **WorldQuant BRAIN** — login (จัดการ biometric "Scan" + auto re-login),
30
+ ส่ง simulation, รัน batch (สูงสุด 3 พร้อมกันตามลิมิต), และคัดเก็บ alpha ที่ผ่านเกณฑ์อัตโนมัติ
31
+
32
+ ## ติดตั้ง
33
+
34
+ ```bash
35
+ pip install brain-login-and-sim
36
+ ```
37
+
38
+ ## ตั้งค่า credentials
39
+
40
+ สร้างไฟล์ `.env` (ดู `.env.example`):
41
+
42
+ ```
43
+ WQ_EMAIL=your-email@example.com
44
+ WQ_PASSWORD=your-password
45
+ # ส่งลิงก์ Scan เข้า Telegram (ทางเลือก แต่แนะนำ)
46
+ TELEGRAM_BOT_TOKEN=123456789:AAH...
47
+ TELEGRAM_CHAT_ID=123456789
48
+ ```
49
+
50
+ ## ใช้งาน
51
+
52
+ ```python
53
+ from brain_login_and_sim import BrainClient, Simulator
54
+
55
+ # login — ถ้าระบบขอ biometric Scan จะส่งลิงก์เข้า Telegram แล้วรอจนสแกนเสร็จ
56
+ client = BrainClient(notify_telegram=True).authenticate()
57
+ print(client.whoami()["id"])
58
+
59
+ sim = Simulator(client)
60
+
61
+ # ยิง 1 ตัว
62
+ r = sim.simulate("ts_rank(close, 5)")
63
+ print(r["simulation"]["status"], r["alpha"]["is"]["sharpe"])
64
+
65
+ # รันหลายตัวจากไฟล์ JSONL คัดเก็บตัวที่ผ่านเกณฑ์อัตโนมัติ
66
+ sim.simulate_batch("alphas.jsonl", save_promising=True)
67
+ ```
68
+
69
+ ## ฟีเจอร์หลัก
70
+
71
+ - **Login + biometric Scan** — ส่งลิงก์สแกนเข้า Telegram/อีเมล แล้ว poll รอจนเสร็จ (ไม่ต้องเฝ้าจอ)
72
+ - **Auto re-login** — เมื่อ session หมดอายุ (WorldQuant ให้ ~4 ชม.) เจอ 401 จะ login ใหม่แล้วลองซ้ำให้เอง
73
+ - **คัดเก็บ alpha ดี** — เกณฑ์ `|Sharpe| ≥ 1` และ `|Fitness| ≥ 0.9` (ค่าสัมบูรณ์ จับ flip candidate ที่ negate แล้วดี)
74
+ - **Batch** — รันสูงสุด 3 ตัวพร้อมกัน (ลิมิต WorldQuant), error รายตัวไม่ล้มทั้ง batch
75
+ - **Resume** — จด checkpoint ด้วย hash รันไฟล์เดิมซ้ำจะข้ามตัวที่รันแล้ว รันต่อจากที่ค้าง
76
+ - **Input ยืดหยุ่น** — list ของสูตร, ไฟล์ `.txt` (1 สูตร/บรรทัด), หรือ `.jsonl` ที่มี field `expression` (+`hash`)
77
+
78
+ ## รูปแบบ JSONL
79
+
80
+ ```jsonl
81
+ {"hash": "f7b3...", "expression": "rank(reverse(ts_delta(anl4_cfo_median, 10)))", "status": "generated"}
82
+ ```
83
+
84
+ ระบบดึง `expression` ไปรัน และถ้าผ่านเกณฑ์จะเก็บ `source_hash` ไว้อ้างกลับไฟล์ต้นฉบับใน `promising_alphas.jsonl`
85
+
86
+ ## API ย่อ
87
+
88
+ `BrainClient`: `authenticate(on_biometric=None)`, `whoami()`, `get(path)`, `post(path)`, `auto_relogin`
89
+
90
+ `Simulator(client)`: `simulate(...)`, `simulate_batch(...)`, `is_promising(...)`, `save_if_promising(...)`,
91
+ `load_promising(path)`, `get_operators()`, `get_data_fields(...)`, `get_alpha(id)`
92
+
93
+ ## ทดสอบ
94
+
95
+ ```bash
96
+ python tests/test_full_loop.py # mock API ไม่ต้องต่อเน็ต/รหัสผ่าน
97
+ ```
98
+
99
+ ## License
100
+
101
+ MIT
@@ -0,0 +1,75 @@
1
+ # brain-login-and-sim
2
+
3
+ ไคลเอนต์ Python สำหรับ **WorldQuant BRAIN** — login (จัดการ biometric "Scan" + auto re-login),
4
+ ส่ง simulation, รัน batch (สูงสุด 3 พร้อมกันตามลิมิต), และคัดเก็บ alpha ที่ผ่านเกณฑ์อัตโนมัติ
5
+
6
+ ## ติดตั้ง
7
+
8
+ ```bash
9
+ pip install brain-login-and-sim
10
+ ```
11
+
12
+ ## ตั้งค่า credentials
13
+
14
+ สร้างไฟล์ `.env` (ดู `.env.example`):
15
+
16
+ ```
17
+ WQ_EMAIL=your-email@example.com
18
+ WQ_PASSWORD=your-password
19
+ # ส่งลิงก์ Scan เข้า Telegram (ทางเลือก แต่แนะนำ)
20
+ TELEGRAM_BOT_TOKEN=123456789:AAH...
21
+ TELEGRAM_CHAT_ID=123456789
22
+ ```
23
+
24
+ ## ใช้งาน
25
+
26
+ ```python
27
+ from brain_login_and_sim import BrainClient, Simulator
28
+
29
+ # login — ถ้าระบบขอ biometric Scan จะส่งลิงก์เข้า Telegram แล้วรอจนสแกนเสร็จ
30
+ client = BrainClient(notify_telegram=True).authenticate()
31
+ print(client.whoami()["id"])
32
+
33
+ sim = Simulator(client)
34
+
35
+ # ยิง 1 ตัว
36
+ r = sim.simulate("ts_rank(close, 5)")
37
+ print(r["simulation"]["status"], r["alpha"]["is"]["sharpe"])
38
+
39
+ # รันหลายตัวจากไฟล์ JSONL คัดเก็บตัวที่ผ่านเกณฑ์อัตโนมัติ
40
+ sim.simulate_batch("alphas.jsonl", save_promising=True)
41
+ ```
42
+
43
+ ## ฟีเจอร์หลัก
44
+
45
+ - **Login + biometric Scan** — ส่งลิงก์สแกนเข้า Telegram/อีเมล แล้ว poll รอจนเสร็จ (ไม่ต้องเฝ้าจอ)
46
+ - **Auto re-login** — เมื่อ session หมดอายุ (WorldQuant ให้ ~4 ชม.) เจอ 401 จะ login ใหม่แล้วลองซ้ำให้เอง
47
+ - **คัดเก็บ alpha ดี** — เกณฑ์ `|Sharpe| ≥ 1` และ `|Fitness| ≥ 0.9` (ค่าสัมบูรณ์ จับ flip candidate ที่ negate แล้วดี)
48
+ - **Batch** — รันสูงสุด 3 ตัวพร้อมกัน (ลิมิต WorldQuant), error รายตัวไม่ล้มทั้ง batch
49
+ - **Resume** — จด checkpoint ด้วย hash รันไฟล์เดิมซ้ำจะข้ามตัวที่รันแล้ว รันต่อจากที่ค้าง
50
+ - **Input ยืดหยุ่น** — list ของสูตร, ไฟล์ `.txt` (1 สูตร/บรรทัด), หรือ `.jsonl` ที่มี field `expression` (+`hash`)
51
+
52
+ ## รูปแบบ JSONL
53
+
54
+ ```jsonl
55
+ {"hash": "f7b3...", "expression": "rank(reverse(ts_delta(anl4_cfo_median, 10)))", "status": "generated"}
56
+ ```
57
+
58
+ ระบบดึง `expression` ไปรัน และถ้าผ่านเกณฑ์จะเก็บ `source_hash` ไว้อ้างกลับไฟล์ต้นฉบับใน `promising_alphas.jsonl`
59
+
60
+ ## API ย่อ
61
+
62
+ `BrainClient`: `authenticate(on_biometric=None)`, `whoami()`, `get(path)`, `post(path)`, `auto_relogin`
63
+
64
+ `Simulator(client)`: `simulate(...)`, `simulate_batch(...)`, `is_promising(...)`, `save_if_promising(...)`,
65
+ `load_promising(path)`, `get_operators()`, `get_data_fields(...)`, `get_alpha(id)`
66
+
67
+ ## ทดสอบ
68
+
69
+ ```bash
70
+ python tests/test_full_loop.py # mock API ไม่ต้องต่อเน็ต/รหัสผ่าน
71
+ ```
72
+
73
+ ## License
74
+
75
+ MIT
@@ -0,0 +1,35 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "brain-login-and-sim"
7
+ version = "0.1.0"
8
+ description = "WorldQuant BRAIN client — login (biometric Scan + auto re-login), simulation, batch run, and auto-keeping promising alphas"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Tanakrit", email = "9tanakrit.work@gmail.com" }]
13
+ keywords = ["worldquant", "brain", "alpha", "quant", "simulation", "trading"]
14
+ dependencies = [
15
+ "requests>=2.28",
16
+ "python-dotenv>=0.19",
17
+ ]
18
+ classifiers = [
19
+ "Programming Language :: Python :: 3",
20
+ "License :: OSI Approved :: MIT License",
21
+ "Operating System :: OS Independent",
22
+ "Intended Audience :: Developers",
23
+ "Intended Audience :: Financial and Insurance Industry",
24
+ "Topic :: Office/Business :: Financial :: Investment",
25
+ ]
26
+
27
+ [project.optional-dependencies]
28
+ dev = ["build", "twine", "pytest"]
29
+
30
+ [project.urls]
31
+ Homepage = "https://github.com/Tanakrit/brain-login-and-sim"
32
+ Issues = "https://github.com/Tanakrit/brain-login-and-sim/issues"
33
+
34
+ [tool.setuptools.packages.find]
35
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,33 @@
1
+ """
2
+ brain_login_and_sim
3
+ ====================
4
+ ไคลเอนต์ WorldQuant BRAIN — login (จัดการ biometric Scan + auto re-login),
5
+ ส่ง simulation, รัน batch (สูงสุด 3 พร้อมกัน), คัดเก็บ alpha ที่ผ่านเกณฑ์อัตโนมัติ
6
+ และ resume ได้
7
+
8
+ ใช้งานสั้น ๆ:
9
+ from brain_login_and_sim import BrainClient, Simulator
10
+
11
+ client = BrainClient(notify_telegram=True).authenticate()
12
+ sim = Simulator(client)
13
+ sim.simulate_batch("alphas.jsonl", save_promising=True)
14
+ """
15
+
16
+ from .auth import (
17
+ BrainClient,
18
+ BrainAuthError,
19
+ send_biometric_email,
20
+ send_biometric_telegram,
21
+ )
22
+ from .simulation import Simulator
23
+
24
+ __version__ = "0.1.0"
25
+
26
+ __all__ = [
27
+ "BrainClient",
28
+ "BrainAuthError",
29
+ "Simulator",
30
+ "send_biometric_email",
31
+ "send_biometric_telegram",
32
+ "__version__",
33
+ ]
@@ -0,0 +1,257 @@
1
+ """
2
+ auth.py — ระบบ login เข้า WorldQuant BRAIN
3
+
4
+ - login ด้วย HTTP Basic Auth ที่ POST /authentication
5
+ - จัดการขั้นตอน biometric "Scan" (Persona): ส่งลิงก์เข้า Telegram/อีเมล แล้ว poll รอ
6
+ - auto re-login เมื่อ session หมดอายุ (WorldQuant ให้ ~4 ชม.): เจอ 401 -> login ใหม่ -> retry
7
+ - อ่าน credential จาก .env (WQ_EMAIL/WQ_PASSWORD), จาก argument, หรือไฟล์ JSON
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import smtplib
14
+ import ssl
15
+ import threading
16
+ import time
17
+ from email.message import EmailMessage
18
+ from pathlib import Path
19
+ from urllib.parse import urljoin
20
+
21
+ import requests
22
+ from requests.auth import HTTPBasicAuth
23
+
24
+ try:
25
+ from dotenv import load_dotenv
26
+ # อ่าน .env ที่อยู่ข้างไฟล์นี้ก่อน แล้วค่อยลอง cwd (ไม่ทับค่าที่โหลดแล้ว)
27
+ load_dotenv(Path(__file__).with_name(".env"))
28
+ load_dotenv()
29
+ except ImportError:
30
+ pass
31
+
32
+ API_BASE = "https://api.worldquantbrain.com"
33
+
34
+
35
+ class BrainAuthError(Exception):
36
+ """ยกขึ้นเมื่อ authenticate ไม่สำเร็จ"""
37
+
38
+
39
+ def send_biometric_email(scan_url, to_addr=None, smtp_host=None, smtp_port=None,
40
+ smtp_user=None, smtp_password=None):
41
+ """ส่งลิงก์ Scan ไปอีเมล (อ่านค่า SMTP_* / WQ_NOTIFY_EMAIL จาก env ได้)"""
42
+ host = smtp_host or os.environ.get("SMTP_HOST", "smtp.gmail.com")
43
+ port = int(smtp_port or os.environ.get("SMTP_PORT", 587))
44
+ user = smtp_user or os.environ.get("SMTP_USER")
45
+ pw = smtp_password or os.environ.get("SMTP_PASSWORD")
46
+ to_addr = to_addr or os.environ.get("WQ_NOTIFY_EMAIL") or user
47
+ if not (user and pw and to_addr):
48
+ raise BrainAuthError(
49
+ "ส่งอีเมลไม่ได้ — ต้องตั้ง SMTP_USER, SMTP_PASSWORD (App Password), WQ_NOTIFY_EMAIL")
50
+ msg = EmailMessage()
51
+ msg["Subject"] = "WorldQuant BRAIN — ลิงก์ยืนยันตัวตน (Scan)"
52
+ msg["From"] = user
53
+ msg["To"] = to_addr
54
+ msg.set_content(
55
+ "เปิดลิงก์นี้บนมือถือแล้วสแกนให้เสร็จ:\n\n"
56
+ f"{scan_url}\n\nเมื่อสแกนเสร็จ โปรแกรมจะตรวจพบและทำงานต่อให้อัตโนมัติ")
57
+ ctx = ssl.create_default_context()
58
+ if port == 465:
59
+ with smtplib.SMTP_SSL(host, port, context=ctx) as s:
60
+ s.login(user, pw)
61
+ s.send_message(msg)
62
+ else:
63
+ with smtplib.SMTP(host, port) as s:
64
+ s.starttls(context=ctx)
65
+ s.login(user, pw)
66
+ s.send_message(msg)
67
+
68
+
69
+ def send_biometric_telegram(scan_url, bot_token=None, chat_id=None):
70
+ """ส่งลิงก์ Scan ผ่าน Telegram bot (อ่าน TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID จาก env)"""
71
+ token = bot_token or os.environ.get("TELEGRAM_BOT_TOKEN")
72
+ chat = chat_id or os.environ.get("TELEGRAM_CHAT_ID")
73
+ if not (token and chat):
74
+ raise BrainAuthError(
75
+ "ส่ง Telegram ไม่ได้ — ต้องตั้ง TELEGRAM_BOT_TOKEN และ TELEGRAM_CHAT_ID")
76
+ text = ("WorldQuant BRAIN ขอยืนยันตัวตน (ขั้นตอน Scan)\n\n"
77
+ "เปิดลิงก์นี้บนมือถือแล้วสแกนให้เสร็จ:\n"
78
+ f"{scan_url}\n\nเมื่อสแกนเสร็จ โปรแกรมจะตรวจพบและทำงานต่อให้อัตโนมัติ")
79
+ r = requests.post(
80
+ f"https://api.telegram.org/bot{token}/sendMessage",
81
+ json={"chat_id": chat, "text": text, "disable_web_page_preview": False},
82
+ timeout=15)
83
+ if not r.ok:
84
+ raise BrainAuthError(f"ส่ง Telegram ไม่สำเร็จ: HTTP {r.status_code} — {r.text[:200]}")
85
+
86
+
87
+ class BrainClient:
88
+ def __init__(self, email=None, password=None, credentials_file=None,
89
+ api_base=API_BASE, notify_email=False, notify_telegram=False,
90
+ poll_interval=5.0, biometric_timeout=600.0):
91
+ """
92
+ หา credential ตามลำดับ:
93
+ 1. argument email/password
94
+ 2. env WQ_EMAIL / WQ_PASSWORD (มาจาก .env ผ่าน python-dotenv)
95
+ 3. ไฟล์ JSON ที่ระบุใน credentials_file หรือ env WQ_CREDENTIALS_FILE
96
+ """
97
+ self.api_base = api_base.rstrip("/")
98
+ self.session = requests.Session()
99
+ self.session.headers.update({"User-Agent": "brain-login-and-sim/0.1"})
100
+ self._email, self._password = self._resolve_credentials(
101
+ email, password, credentials_file)
102
+ self.user_id = None
103
+ self.notify_email = notify_email
104
+ self.notify_telegram = notify_telegram
105
+ self.poll_interval = poll_interval
106
+ self.biometric_timeout = biometric_timeout
107
+ # auto re-login เมื่อ session หมดอายุ (~4 ชม.)
108
+ self.auto_relogin = True
109
+ self._auth_lock = threading.Lock()
110
+ self._last_auth = 0.0
111
+ self._on_biometric = None
112
+
113
+ # -- credentials --------------------------------------------------- #
114
+ @staticmethod
115
+ def _resolve_credentials(email, password, credentials_file):
116
+ if email and password:
117
+ return email, password
118
+ env_email = os.environ.get("WQ_EMAIL")
119
+ env_pw = os.environ.get("WQ_PASSWORD")
120
+ if env_email and env_pw:
121
+ return env_email, env_pw
122
+ path = credentials_file or os.environ.get("WQ_CREDENTIALS_FILE")
123
+ if path and Path(path).exists():
124
+ data = json.loads(Path(path).read_text(encoding="utf-8"))
125
+ if data.get("email") and data.get("password"):
126
+ return data["email"], data["password"]
127
+ raise BrainAuthError(
128
+ "ไม่พบ credentials — แนะนำตั้ง WQ_EMAIL/WQ_PASSWORD ในไฟล์ .env (ดู .env.example) "
129
+ "| หรือส่ง email/password เข้า BrainClient ตรง ๆ | หรือใช้ไฟล์ JSON ผ่าน "
130
+ "WQ_CREDENTIALS_FILE. ถ้าตั้งใน .env แล้วยังไม่เจอ เช็กว่าติดตั้ง python-dotenv แล้ว")
131
+
132
+ # -- authentication ------------------------------------------------ #
133
+ def authenticate(self, on_biometric=None):
134
+ """login เข้า BRAIN; on_biometric=callback(url) เพื่อจัดการ Scan เอง"""
135
+ self._on_biometric = on_biometric
136
+ url = f"{self.api_base}/authentication"
137
+ r = self.session.post(url, auth=HTTPBasicAuth(self._email, self._password))
138
+ if r.status_code in (200, 201):
139
+ self._capture_user(r)
140
+ return self
141
+ if r.status_code == requests.codes.unauthorized:
142
+ www = r.headers.get("WWW-Authenticate", "").lower()
143
+ location = r.headers.get("Location")
144
+ if "persona" in www or "biometric" in www or location:
145
+ return self._handle_biometric(r, on_biometric)
146
+ raise BrainAuthError(
147
+ f"login ไม่สำเร็จ (401) — ตรวจ email/password. response: {r.text[:200]}")
148
+ raise BrainAuthError(f"login ไม่สำเร็จ: HTTP {r.status_code} — {r.text[:200]}")
149
+
150
+ def _handle_biometric(self, resp, on_biometric):
151
+ location = resp.headers.get("Location")
152
+ if not location:
153
+ try:
154
+ inquiry = resp.json().get("inquiry")
155
+ except Exception:
156
+ inquiry = None
157
+ location = (f"/authentication/persona?inquiry-id={inquiry}"
158
+ if inquiry else "/authentication/persona")
159
+ bio_url = urljoin(resp.url, location)
160
+
161
+ if on_biometric is not None:
162
+ on_biometric(bio_url)
163
+ return self._wait_for_biometric(bio_url)
164
+
165
+ notified = False
166
+ if self.notify_telegram:
167
+ try:
168
+ send_biometric_telegram(bio_url)
169
+ dest = os.environ.get("TELEGRAM_CHAT_ID")
170
+ print(f"\nส่งลิงก์ Scan ทาง Telegram แล้ว (chat {dest}) — สแกนผ่านมือถือได้เลย")
171
+ notified = True
172
+ except Exception as e:
173
+ print(f"ส่ง Telegram ไม่สำเร็จ ({e}) — ใช้ลิงก์ด้านล่างแทน")
174
+ if self.notify_email:
175
+ try:
176
+ send_biometric_email(bio_url)
177
+ print("ส่งลิงก์ Scan ไปอีเมลแล้ว — สแกนผ่านมือถือได้เลย")
178
+ notified = True
179
+ except Exception as e:
180
+ print(f"ส่งอีเมลไม่สำเร็จ ({e}) — ใช้ลิงก์ด้านล่างแทน")
181
+
182
+ print("\nต้องยืนยันตัวตนด้วย biometric (ขั้นตอน Scan) เปิดลิงก์นี้แล้วสแกน:")
183
+ print(f" {bio_url}\n")
184
+ if notified:
185
+ print(f"กำลังรอการสแกน... (เช็กทุก {self.poll_interval:.0f} วิ)")
186
+ return self._wait_for_biometric(bio_url, poll=True)
187
+ input("ทำ Scan เสร็จแล้วกด Enter เพื่อทำต่อ...")
188
+ return self._wait_for_biometric(bio_url)
189
+
190
+ def _wait_for_biometric(self, bio_url, poll=False):
191
+ deadline = time.time() + self.biometric_timeout
192
+ while True:
193
+ done = self.session.post(bio_url)
194
+ if done.status_code in (200, 201):
195
+ self._capture_user(done)
196
+ return self
197
+ recheck = self.session.post(
198
+ f"{self.api_base}/authentication",
199
+ auth=HTTPBasicAuth(self._email, self._password))
200
+ if recheck.status_code in (200, 201):
201
+ self._capture_user(recheck)
202
+ return self
203
+ if not poll or time.time() >= deadline:
204
+ raise BrainAuthError(
205
+ f"ยืนยัน biometric ไม่สำเร็จ (persona HTTP {done.status_code})")
206
+ time.sleep(self.poll_interval)
207
+
208
+ def _capture_user(self, resp):
209
+ self._last_auth = time.time()
210
+ try:
211
+ data = resp.json()
212
+ self.user_id = (data.get("user") or {}).get("id") or data.get("id")
213
+ except Exception:
214
+ self.user_id = None
215
+
216
+ @property
217
+ def is_authenticated(self):
218
+ return len(self.session.cookies) > 0
219
+
220
+ # -- request helpers (auto re-login) ------------------------------- #
221
+ def get(self, path, **kwargs):
222
+ u = self._url(path)
223
+ return self._with_relogin(lambda: self.session.get(u, **kwargs), u)
224
+
225
+ def post(self, path, **kwargs):
226
+ u = self._url(path)
227
+ return self._with_relogin(lambda: self.session.post(u, **kwargs), u)
228
+
229
+ def _url(self, path):
230
+ if path.startswith("http"):
231
+ return path
232
+ return f"{self.api_base}/{path.lstrip('/')}"
233
+
234
+ @staticmethod
235
+ def _is_auth_url(url):
236
+ u = url.split("?")[0].rstrip("/")
237
+ return u.endswith("/authentication") or "/persona" in url
238
+
239
+ def _relogin_if_stale(self):
240
+ with self._auth_lock:
241
+ if time.time() - self._last_auth < 15:
242
+ return # thread อื่นเพิ่ง re-login ไปแล้ว
243
+ print("session หมดอายุ — กำลัง login ใหม่อัตโนมัติ ...")
244
+ self.authenticate(on_biometric=self._on_biometric)
245
+
246
+ def _with_relogin(self, call, url):
247
+ """เรียก call(); ถ้าได้ 401 และไม่ใช่ endpoint login -> re-login แล้วลองซ้ำ 1 ครั้ง"""
248
+ resp = call()
249
+ if (getattr(resp, "status_code", None) == 401
250
+ and self.auto_relogin and not self._is_auth_url(url)):
251
+ self._relogin_if_stale()
252
+ resp = call()
253
+ return resp
254
+
255
+ def whoami(self):
256
+ """ข้อมูลผู้ใช้ปัจจุบัน (ยืนยันว่า session ใช้งานได้)"""
257
+ return self.get("/users/self").json()
@@ -0,0 +1,350 @@
1
+ """
2
+ simulation.py — ส่ง simulation + คัดเก็บ alpha ที่ผ่านเกณฑ์
3
+
4
+ Simulator รับ BrainClient ที่ login แล้ว (จาก auth.py) มาใช้ session ร่วมกัน
5
+ - simulate() : ยิง 1 ตัว, poll จนเสร็จ, ดึง metric จาก /alphas
6
+ - simulate_batch() : รันหลายตัว (สูงสุด 3 พร้อมกันตามลิมิต WorldQuant),
7
+ คัดเก็บอัตโนมัติ, resume (ข้ามตัวที่รันแล้ว), error รายตัวไม่ล้ม batch
8
+ - is_promising() : เกณฑ์ |Sharpe|>=1 และ |Fitness|>=0.9 (จับ flip candidate ด้วย)
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import threading
14
+ import time
15
+ from concurrent.futures import ThreadPoolExecutor, as_completed
16
+ from datetime import datetime, timezone
17
+ from pathlib import Path
18
+
19
+ # WorldQuant จำกัด simulation ที่รันพร้อมกันได้สูงสุด 3 ตัว/บัญชี
20
+ MAX_CONCURRENT = 3
21
+
22
+
23
+ class Simulator:
24
+ """ห่อ BrainClient ที่ login แล้ว เพื่อเรียก endpoint ฝั่ง simulation/data"""
25
+
26
+ MIN_SHARPE = 1.0
27
+ MIN_FITNESS = 0.9
28
+
29
+ DEFAULT_SETTINGS = {
30
+ "instrumentType": "EQUITY", "region": "USA", "universe": "TOP3000",
31
+ "delay": 1, "decay": 0, "neutralization": "INDUSTRY", "truncation": 0.08,
32
+ "pasteurization": "ON", "testPeriod": "P0Y0M", "unitHandling": "VERIFY",
33
+ "nanHandling": "OFF", "language": "FASTEXPR", "visualization": False,
34
+ }
35
+
36
+ def __init__(self, client):
37
+ self.client = client
38
+ self._save_lock = threading.Lock()
39
+ self._print_lock = threading.Lock()
40
+
41
+ # -- ข้อมูลประกอบ -------------------------------------------------- #
42
+ def get_operators(self):
43
+ return self.client.get("/operators").json()
44
+
45
+ def get_data_fields(self, region="USA", delay=1, universe="TOP3000",
46
+ instrument_type="EQUITY"):
47
+ return self.client.get("/data-fields", params={
48
+ "instrumentType": instrument_type, "region": region,
49
+ "delay": delay, "universe": universe}).json()
50
+
51
+ def get_alpha(self, alpha_id):
52
+ """รายละเอียด alpha รวม metric in-sample (`is`) / out-of-sample (`os`)"""
53
+ return self.client.get(f"/alphas/{alpha_id}").json()
54
+
55
+ # -- คัดกรอง + เก็บ ------------------------------------------------ #
56
+ @staticmethod
57
+ def is_promising(alpha_detail, min_sharpe=MIN_SHARPE, min_fitness=MIN_FITNESS,
58
+ include_inverse=True):
59
+ """
60
+ ผ่านเกณฑ์เมื่อ (in-sample):
61
+ include_inverse=True (ดีฟอลต์): |sharpe|>=min_sharpe และ |fitness|>=min_fitness
62
+ -> จับทั้งตัวบวกแรง และตัวลบแรง (พลิกเครื่องหมายสูตรแล้วดี)
63
+ include_inverse=False: เฉพาะตัวบวก
64
+ """
65
+ stats = (alpha_detail or {}).get("is") or {}
66
+ sharpe = stats.get("sharpe")
67
+ fitness = stats.get("fitness")
68
+ if sharpe is None or fitness is None:
69
+ return False
70
+ if include_inverse:
71
+ return abs(sharpe) >= min_sharpe and abs(fitness) >= min_fitness
72
+ return sharpe >= min_sharpe and fitness >= min_fitness
73
+
74
+ @staticmethod
75
+ def _flatten(alpha_detail, source=None):
76
+ a = alpha_detail or {}
77
+ is_ = a.get("is") or {}
78
+ settings = a.get("settings") or {}
79
+ code = ((a.get("regular") or {}).get("code")
80
+ if isinstance(a.get("regular"), dict) else a.get("regular"))
81
+ sharpe = is_.get("sharpe")
82
+ inverse = sharpe is not None and sharpe < 0 # flip candidate
83
+ return {
84
+ "savedAt": datetime.now(timezone.utc).isoformat(timespec="seconds"),
85
+ "source_hash": (source or {}).get("hash"),
86
+ "alpha_id": a.get("id"), "regular": code, "inverse": inverse,
87
+ "regular_inverse": f"-({code})" if (inverse and code) else None,
88
+ "sharpe": sharpe, "fitness": is_.get("fitness"),
89
+ "turnover": is_.get("turnover"), "returns": is_.get("returns"),
90
+ "drawdown": is_.get("drawdown"), "margin": is_.get("margin"),
91
+ "region": settings.get("region"), "universe": settings.get("universe"),
92
+ "delay": settings.get("delay"),
93
+ "neutralization": settings.get("neutralization"),
94
+ "dateCreated": a.get("dateCreated"),
95
+ }
96
+
97
+ def save_if_promising(self, alpha_detail, path="promising_alphas.jsonl",
98
+ min_sharpe=MIN_SHARPE, min_fitness=MIN_FITNESS,
99
+ include_inverse=True, source=None):
100
+ """เก็บ alpha ลงไฟล์ JSONL ถ้าผ่านเกณฑ์ (กันบันทึกซ้ำ alpha id เดิม)"""
101
+ if not self.is_promising(alpha_detail, min_sharpe, min_fitness, include_inverse):
102
+ return False
103
+ record = self._flatten(alpha_detail, source)
104
+ p = Path(path)
105
+ with self._save_lock:
106
+ if p.exists() and record.get("alpha_id"):
107
+ for line in p.read_text(encoding="utf-8").splitlines():
108
+ try:
109
+ if json.loads(line).get("alpha_id") == record["alpha_id"]:
110
+ return False
111
+ except Exception:
112
+ continue
113
+ with p.open("a", encoding="utf-8") as f:
114
+ f.write(json.dumps(record, ensure_ascii=False) + "\n")
115
+ return True
116
+
117
+ @staticmethod
118
+ def load_promising(path="promising_alphas.jsonl"):
119
+ p = Path(path)
120
+ if not p.exists():
121
+ return []
122
+ out = []
123
+ for line in p.read_text(encoding="utf-8").splitlines():
124
+ line = line.strip()
125
+ if line:
126
+ try:
127
+ out.append(json.loads(line))
128
+ except Exception:
129
+ pass
130
+ return out
131
+
132
+ # -- simulate ------------------------------------------------------ #
133
+ def simulate(self, regular, settings=None, timeout=600.0, with_metrics=True,
134
+ save_promising=False, save_path="promising_alphas.jsonl",
135
+ min_sharpe=MIN_SHARPE, min_fitness=MIN_FITNESS,
136
+ include_inverse=True, source=None):
137
+ """
138
+ ยิง simulation 1 ตัวแล้วรอจนเสร็จ
139
+ flow: POST /simulations -> poll GET /simulations/{id} (Retry-After ระหว่างรัน)
140
+ -> GET /alphas/{id} เอา metric
141
+ คืน: {simulation, alpha_id, alpha, promising, saved}
142
+ """
143
+ payload = {"type": "REGULAR",
144
+ "settings": settings or dict(self.DEFAULT_SETTINGS),
145
+ "regular": regular}
146
+ r = self.client.post("/simulations", json=payload)
147
+ r.raise_for_status()
148
+ progress_url = r.headers.get("Location")
149
+ if not progress_url:
150
+ return {"simulation": r.json(), "alpha_id": None, "alpha": None,
151
+ "promising": False, "saved": False}
152
+
153
+ deadline = time.time() + timeout
154
+ sim = {}
155
+ while time.time() < deadline:
156
+ pr = self.client.get(progress_url) # ผ่าน auto-relogin ถ้า session หมด
157
+ pr.raise_for_status()
158
+ retry_after = pr.headers.get("Retry-After")
159
+ if retry_after: # ยังรันอยู่
160
+ time.sleep(float(retry_after))
161
+ continue
162
+ sim = pr.json()
163
+ break
164
+ else:
165
+ raise TimeoutError("simulation ใช้เวลานานเกินกำหนด")
166
+
167
+ result = {"simulation": sim, "alpha_id": sim.get("alpha"),
168
+ "alpha": None, "promising": False, "saved": False}
169
+ if sim.get("status") and sim["status"] != "COMPLETE":
170
+ return result
171
+ if (with_metrics or save_promising) and result["alpha_id"]:
172
+ result["alpha"] = self.get_alpha(result["alpha_id"])
173
+ result["promising"] = self.is_promising(
174
+ result["alpha"], min_sharpe, min_fitness, include_inverse)
175
+ if save_promising and result["promising"]:
176
+ result["saved"] = self.save_if_promising(
177
+ result["alpha"], save_path, min_sharpe, min_fitness,
178
+ include_inverse, source=source)
179
+ return result
180
+
181
+ # -- batch + resume ------------------------------------------------ #
182
+ def simulate_batch(self, expressions, settings=None, save_promising=True,
183
+ save_path="promising_alphas.jsonl", min_sharpe=MIN_SHARPE,
184
+ min_fitness=MIN_FITNESS, include_inverse=True, timeout=600.0,
185
+ max_concurrent=MAX_CONCURRENT, stop_on_error=False,
186
+ skip_processed=True, processed_path="processed.jsonl",
187
+ on_progress=None):
188
+ """
189
+ รันหลาย alpha (สูงสุด max_concurrent พร้อมกัน, บีบไม่เกิน MAX_CONCURRENT=3)
190
+ expressions: path ไฟล์ (.txt/.jsonl) | list ของ string | list ของ dict {expression, hash}
191
+ skip_processed: resume — ข้ามตัวที่เคยรันแล้ว (key=hash) จด checkpoint ลง processed_path
192
+ คืน: list ของ record ต่อ alpha (เรียงตามลำดับ input)
193
+ """
194
+ if isinstance(expressions, str):
195
+ items = self.load_alpha_items(expressions)
196
+ else:
197
+ items = []
198
+ for x in expressions:
199
+ if isinstance(x, dict):
200
+ expr = x.get("expression") or x.get("regular")
201
+ if expr:
202
+ items.append({"expression": expr, "source": x})
203
+ else:
204
+ items.append({"expression": x, "source": None})
205
+ total = len(items)
206
+ workers = max(1, min(max_concurrent, MAX_CONCURRENT, total or 1))
207
+
208
+ def item_key(item):
209
+ return (item.get("source") or {}).get("hash") or item["expression"]
210
+
211
+ processed = self._load_processed_keys(processed_path) if skip_processed else set()
212
+
213
+ def run_one(item):
214
+ expr = item["expression"]
215
+ source = item.get("source")
216
+ rec = {"regular": expr, "source_hash": (source or {}).get("hash"),
217
+ "ok": False, "status": None, "alpha_id": None, "sharpe": None,
218
+ "fitness": None, "promising": False, "saved": False,
219
+ "skipped": False, "error": None}
220
+ try:
221
+ r = self.simulate(expr, settings=settings, timeout=timeout,
222
+ with_metrics=True, save_promising=save_promising,
223
+ save_path=save_path, min_sharpe=min_sharpe,
224
+ min_fitness=min_fitness,
225
+ include_inverse=include_inverse, source=source)
226
+ is_ = (r["alpha"] or {}).get("is", {})
227
+ rec.update(ok=True, status=r["simulation"].get("status"),
228
+ alpha_id=r["alpha_id"], sharpe=is_.get("sharpe"),
229
+ fitness=is_.get("fitness"), promising=r["promising"],
230
+ saved=r["saved"])
231
+ except Exception as e:
232
+ rec["error"] = str(e)
233
+ if skip_processed:
234
+ self._append_processed(processed_path, item_key(item), rec)
235
+ return rec
236
+
237
+ def report(done, rec):
238
+ if on_progress:
239
+ on_progress(rec)
240
+ return
241
+ with self._print_lock:
242
+ if rec["skipped"]:
243
+ print(f"[{done}/{total}] {rec['regular'][:45]:45} skip (เคยรันแล้ว)")
244
+ elif rec["ok"]:
245
+ flip = " (flip ต้อง negate)" if (
246
+ rec["sharpe"] is not None and rec["sharpe"] < 0) else ""
247
+ tag = ("เก็บแล้ว" + flip if rec["saved"]
248
+ else "ผ่านเกณฑ์" + flip if rec["promising"] else "ไม่ผ่าน")
249
+ print(f"[{done}/{total}] {rec['regular'][:45]:45} "
250
+ f"sharpe={rec['sharpe']} fitness={rec['fitness']} {tag}")
251
+ else:
252
+ print(f"[{done}/{total}] {rec['regular'][:45]:45} ERROR: {rec['error']}")
253
+
254
+ def skipped_rec(item):
255
+ src = item.get("source") or {}
256
+ return {"regular": item["expression"], "source_hash": src.get("hash"),
257
+ "ok": False, "status": None, "alpha_id": None, "sharpe": None,
258
+ "fitness": None, "promising": False, "saved": False,
259
+ "skipped": True, "error": None}
260
+
261
+ results = [None] * total
262
+ to_run = []
263
+ for i, item in enumerate(items):
264
+ if skip_processed and item_key(item) in processed:
265
+ results[i] = skipped_rec(item)
266
+ else:
267
+ to_run.append((i, item))
268
+ n_skip = total - len(to_run)
269
+
270
+ if workers == 1:
271
+ done = n_skip
272
+ for i, item in to_run:
273
+ rec = run_one(item)
274
+ results[i] = rec
275
+ done += 1
276
+ report(done, rec)
277
+ if rec["error"] and stop_on_error:
278
+ break
279
+ else:
280
+ with ThreadPoolExecutor(max_workers=workers) as ex:
281
+ fut_to_idx = {ex.submit(run_one, item): i for i, item in to_run}
282
+ done = n_skip
283
+ for fut in as_completed(fut_to_idx):
284
+ idx = fut_to_idx[fut]
285
+ rec = fut.result()
286
+ results[idx] = rec
287
+ done += 1
288
+ report(done, rec)
289
+
290
+ results = [r for r in results if r is not None]
291
+ ok = sum(r["ok"] for r in results)
292
+ promising = sum(r["promising"] for r in results)
293
+ saved = sum(r["saved"] for r in results)
294
+ print(f"\nสรุป: รันใหม่สำเร็จ {ok} | ข้าม {n_skip} | ผ่านเกณฑ์ {promising} | "
295
+ f"เก็บใหม่ {saved} -> {save_path}")
296
+ return results
297
+
298
+ # -- checkpoint (resume) ------------------------------------------- #
299
+ @staticmethod
300
+ def _load_processed_keys(path):
301
+ p = Path(path)
302
+ if not p.exists():
303
+ return set()
304
+ keys = set()
305
+ for line in p.read_text(encoding="utf-8").splitlines():
306
+ line = line.strip()
307
+ if not line:
308
+ continue
309
+ try:
310
+ k = json.loads(line).get("key")
311
+ if k is not None:
312
+ keys.add(k)
313
+ except Exception:
314
+ continue
315
+ return keys
316
+
317
+ def _append_processed(self, path, key, rec):
318
+ entry = {"key": key, "source_hash": rec.get("source_hash"),
319
+ "alpha_id": rec.get("alpha_id"), "regular": rec.get("regular"),
320
+ "ok": rec.get("ok"), "status": rec.get("status"),
321
+ "promising": rec.get("promising"), "error": rec.get("error"),
322
+ "at": datetime.now(timezone.utc).isoformat(timespec="seconds")}
323
+ with self._save_lock:
324
+ with Path(path).open("a", encoding="utf-8") as f:
325
+ f.write(json.dumps(entry, ensure_ascii=False) + "\n")
326
+
327
+ # -- โหลดรายการ alpha จากไฟล์ -------------------------------------- #
328
+ @staticmethod
329
+ def load_alpha_items(path):
330
+ """อ่าน list ของ {expression, source} จากไฟล์ — รองรับ JSONL และข้อความล้วน"""
331
+ items = []
332
+ for raw in Path(path).read_text(encoding="utf-8").splitlines():
333
+ line = raw.strip()
334
+ if not line or line.startswith("#"):
335
+ continue
336
+ if line.startswith("{"):
337
+ try:
338
+ obj = json.loads(line)
339
+ except Exception:
340
+ continue
341
+ expr = obj.get("expression") or obj.get("regular")
342
+ if expr:
343
+ items.append({"expression": expr, "source": obj})
344
+ else:
345
+ items.append({"expression": line, "source": None})
346
+ return items
347
+
348
+ @classmethod
349
+ def load_expressions(cls, path):
350
+ return [it["expression"] for it in cls.load_alpha_items(path)]
@@ -0,0 +1,101 @@
1
+ Metadata-Version: 2.4
2
+ Name: brain-login-and-sim
3
+ Version: 0.1.0
4
+ Summary: WorldQuant BRAIN client — login (biometric Scan + auto re-login), simulation, batch run, and auto-keeping promising alphas
5
+ Author-email: Tanakrit <9tanakrit.work@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/Tanakrit/brain-login-and-sim
8
+ Project-URL: Issues, https://github.com/Tanakrit/brain-login-and-sim/issues
9
+ Keywords: worldquant,brain,alpha,quant,simulation,trading
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: Financial and Insurance Industry
15
+ Classifier: Topic :: Office/Business :: Financial :: Investment
16
+ Requires-Python: >=3.9
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Requires-Dist: requests>=2.28
20
+ Requires-Dist: python-dotenv>=0.19
21
+ Provides-Extra: dev
22
+ Requires-Dist: build; extra == "dev"
23
+ Requires-Dist: twine; extra == "dev"
24
+ Requires-Dist: pytest; extra == "dev"
25
+ Dynamic: license-file
26
+
27
+ # brain-login-and-sim
28
+
29
+ ไคลเอนต์ Python สำหรับ **WorldQuant BRAIN** — login (จัดการ biometric "Scan" + auto re-login),
30
+ ส่ง simulation, รัน batch (สูงสุด 3 พร้อมกันตามลิมิต), และคัดเก็บ alpha ที่ผ่านเกณฑ์อัตโนมัติ
31
+
32
+ ## ติดตั้ง
33
+
34
+ ```bash
35
+ pip install brain-login-and-sim
36
+ ```
37
+
38
+ ## ตั้งค่า credentials
39
+
40
+ สร้างไฟล์ `.env` (ดู `.env.example`):
41
+
42
+ ```
43
+ WQ_EMAIL=your-email@example.com
44
+ WQ_PASSWORD=your-password
45
+ # ส่งลิงก์ Scan เข้า Telegram (ทางเลือก แต่แนะนำ)
46
+ TELEGRAM_BOT_TOKEN=123456789:AAH...
47
+ TELEGRAM_CHAT_ID=123456789
48
+ ```
49
+
50
+ ## ใช้งาน
51
+
52
+ ```python
53
+ from brain_login_and_sim import BrainClient, Simulator
54
+
55
+ # login — ถ้าระบบขอ biometric Scan จะส่งลิงก์เข้า Telegram แล้วรอจนสแกนเสร็จ
56
+ client = BrainClient(notify_telegram=True).authenticate()
57
+ print(client.whoami()["id"])
58
+
59
+ sim = Simulator(client)
60
+
61
+ # ยิง 1 ตัว
62
+ r = sim.simulate("ts_rank(close, 5)")
63
+ print(r["simulation"]["status"], r["alpha"]["is"]["sharpe"])
64
+
65
+ # รันหลายตัวจากไฟล์ JSONL คัดเก็บตัวที่ผ่านเกณฑ์อัตโนมัติ
66
+ sim.simulate_batch("alphas.jsonl", save_promising=True)
67
+ ```
68
+
69
+ ## ฟีเจอร์หลัก
70
+
71
+ - **Login + biometric Scan** — ส่งลิงก์สแกนเข้า Telegram/อีเมล แล้ว poll รอจนเสร็จ (ไม่ต้องเฝ้าจอ)
72
+ - **Auto re-login** — เมื่อ session หมดอายุ (WorldQuant ให้ ~4 ชม.) เจอ 401 จะ login ใหม่แล้วลองซ้ำให้เอง
73
+ - **คัดเก็บ alpha ดี** — เกณฑ์ `|Sharpe| ≥ 1` และ `|Fitness| ≥ 0.9` (ค่าสัมบูรณ์ จับ flip candidate ที่ negate แล้วดี)
74
+ - **Batch** — รันสูงสุด 3 ตัวพร้อมกัน (ลิมิต WorldQuant), error รายตัวไม่ล้มทั้ง batch
75
+ - **Resume** — จด checkpoint ด้วย hash รันไฟล์เดิมซ้ำจะข้ามตัวที่รันแล้ว รันต่อจากที่ค้าง
76
+ - **Input ยืดหยุ่น** — list ของสูตร, ไฟล์ `.txt` (1 สูตร/บรรทัด), หรือ `.jsonl` ที่มี field `expression` (+`hash`)
77
+
78
+ ## รูปแบบ JSONL
79
+
80
+ ```jsonl
81
+ {"hash": "f7b3...", "expression": "rank(reverse(ts_delta(anl4_cfo_median, 10)))", "status": "generated"}
82
+ ```
83
+
84
+ ระบบดึง `expression` ไปรัน และถ้าผ่านเกณฑ์จะเก็บ `source_hash` ไว้อ้างกลับไฟล์ต้นฉบับใน `promising_alphas.jsonl`
85
+
86
+ ## API ย่อ
87
+
88
+ `BrainClient`: `authenticate(on_biometric=None)`, `whoami()`, `get(path)`, `post(path)`, `auto_relogin`
89
+
90
+ `Simulator(client)`: `simulate(...)`, `simulate_batch(...)`, `is_promising(...)`, `save_if_promising(...)`,
91
+ `load_promising(path)`, `get_operators()`, `get_data_fields(...)`, `get_alpha(id)`
92
+
93
+ ## ทดสอบ
94
+
95
+ ```bash
96
+ python tests/test_full_loop.py # mock API ไม่ต้องต่อเน็ต/รหัสผ่าน
97
+ ```
98
+
99
+ ## License
100
+
101
+ MIT
@@ -0,0 +1,12 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/brain_login_and_sim/__init__.py
5
+ src/brain_login_and_sim/auth.py
6
+ src/brain_login_and_sim/simulation.py
7
+ src/brain_login_and_sim.egg-info/PKG-INFO
8
+ src/brain_login_and_sim.egg-info/SOURCES.txt
9
+ src/brain_login_and_sim.egg-info/dependency_links.txt
10
+ src/brain_login_and_sim.egg-info/requires.txt
11
+ src/brain_login_and_sim.egg-info/top_level.txt
12
+ tests/test_full_loop.py
@@ -0,0 +1,7 @@
1
+ requests>=2.28
2
+ python-dotenv>=0.19
3
+
4
+ [dev]
5
+ build
6
+ twine
7
+ pytest
@@ -0,0 +1 @@
1
+ brain_login_and_sim
@@ -0,0 +1,205 @@
1
+ """Full-loop test (mock API, no network/credentials). Run: pytest -s หรือ python tests/test_full_loop.py"""
2
+ import json
3
+ import sys
4
+ import tempfile
5
+ from pathlib import Path
6
+
7
+ import requests
8
+
9
+ from brain_login_and_sim import BrainClient, Simulator
10
+
11
+ _p = 0
12
+ _f = 0
13
+
14
+
15
+ def check(name, cond):
16
+ global _p, _f
17
+ if cond:
18
+ _p += 1
19
+ print(" PASS", name)
20
+ else:
21
+ _f += 1
22
+ print(" FAIL", name, "<--")
23
+
24
+
25
+ API = "https://api.worldquantbrain.com"
26
+ METRICS = {
27
+ "rank(close - open)": (1.40, 0.95),
28
+ "-rank(returns)": (-1.80, -0.95),
29
+ "ts_rank(close, 5)": (0.50, 0.40),
30
+ "broken_expr(": None,
31
+ }
32
+
33
+
34
+ class FR:
35
+ def __init__(self, code=200, data=None, headers=None, url=API + "/authentication"):
36
+ self.status_code = code
37
+ self._d = data or {}
38
+ self.headers = headers or {}
39
+ self.text = json.dumps(self._d)
40
+ self.url = url
41
+
42
+ def json(self):
43
+ return self._d
44
+
45
+ @property
46
+ def ok(self):
47
+ return self.status_code < 400
48
+
49
+ def raise_for_status(self):
50
+ if self.status_code >= 400:
51
+ raise requests.HTTPError(f"HTTP {self.status_code}")
52
+
53
+
54
+ class FS:
55
+ def __init__(self, biometric=False):
56
+ self.headers = {}
57
+ self.cookies = []
58
+ self.biometric = biometric
59
+ self._bio = False
60
+ self._n = 0
61
+ self._se = {}
62
+ self._poll = {}
63
+ self.expired = False
64
+
65
+ def post(self, url, auth=None, json=None, **kw):
66
+ if url.endswith("/authentication"):
67
+ if self.biometric and not self._bio:
68
+ return FR(401, {}, {"WWW-Authenticate": "persona",
69
+ "Location": "/authentication/persona?inquiry-id=X"},
70
+ url=API + "/authentication")
71
+ self.expired = False
72
+ return FR(201, {"user": {"id": "U1"}})
73
+ if "/persona" in url:
74
+ self._bio = True
75
+ self.expired = False
76
+ return FR(201, {"user": {"id": "U1"}})
77
+ if url.endswith("/simulations"):
78
+ expr = (json or {}).get("regular")
79
+ if METRICS.get(expr, "x") is None:
80
+ return FR(400, {"error": "bad"})
81
+ self._n += 1
82
+ sid = f"SIM{self._n}"
83
+ self._se[sid] = expr
84
+ self._poll[sid] = 0
85
+ return FR(201, {}, {"Location": f"{API}/simulations/{sid}"})
86
+ return FR(404, {})
87
+
88
+ def get(self, url, params=None, **kw):
89
+ if self.expired:
90
+ return FR(401, {"error": "expired"})
91
+ if "/simulations/" in url:
92
+ sid = url.rsplit("/", 1)[-1]
93
+ self._poll[sid] += 1
94
+ if self._poll[sid] == 1:
95
+ return FR(200, {"progress": 0.5}, {"Retry-After": "0"})
96
+ expr = self._se[sid]
97
+ return FR(200, {"id": sid, "status": "COMPLETE", "alpha": f"ALPHA_{sid}",
98
+ "regular": expr, "settings": {"region": "USA",
99
+ "universe": "TOP3000", "delay": 1, "neutralization": "INDUSTRY"}})
100
+ if "/alphas/" in url:
101
+ aid = url.rsplit("/", 1)[-1]
102
+ sid = aid.replace("ALPHA_", "")
103
+ expr = self._se.get(sid)
104
+ sh, fi = METRICS[expr]
105
+ return FR(200, {"id": aid, "regular": expr,
106
+ "settings": {"region": "USA", "universe": "TOP3000",
107
+ "delay": 1, "neutralization": "INDUSTRY"},
108
+ "is": {"sharpe": sh, "fitness": fi, "turnover": 0.3,
109
+ "returns": 0.1, "drawdown": 0.2, "margin": 0.001}, "os": {}})
110
+ if url.endswith("/users/self"):
111
+ return FR(200, {"id": "U1"})
112
+ if url.endswith("/operators"):
113
+ return FR(200, [{"name": "rank"}, {"name": "ts_rank"}])
114
+ if "/data-fields" in url:
115
+ return FR(200, {"count": 2, "results": []})
116
+ return FR(404, {})
117
+
118
+
119
+ def mk(biometric=False):
120
+ c = BrainClient(email="t@e.com", password="x")
121
+ c.session = FS(biometric=biometric)
122
+ return c
123
+
124
+
125
+ def run():
126
+ print("=" * 50)
127
+ print("FULL-LOOP TEST")
128
+ print("=" * 50)
129
+
130
+ print("\n[1] login direct")
131
+ c = mk().authenticate()
132
+ check("returns client", isinstance(c, BrainClient))
133
+ check("uid U1", c.user_id == "U1")
134
+ check("whoami", c.whoami().get("id") == "U1")
135
+
136
+ print("\n[2] login biometric")
137
+ seen = {}
138
+ c = mk(biometric=True)
139
+ c.authenticate(on_biometric=lambda u: seen.update(u=u))
140
+ check("persona url", "/persona" in seen.get("u", ""))
141
+ check("login ok", c.user_id == "U1")
142
+
143
+ print("\n[3] single simulate")
144
+ sim = Simulator(mk().authenticate())
145
+ r = sim.simulate("rank(close - open)")
146
+ check("COMPLETE", r["simulation"]["status"] == "COMPLETE")
147
+ check("alpha_id", bool(r["alpha_id"]))
148
+ check("sharpe 1.4", r["alpha"]["is"]["sharpe"] == 1.40)
149
+ check("promising", r["promising"] is True)
150
+
151
+ print("\n[4] is_promising")
152
+ check("pos", Simulator.is_promising({"is": {"sharpe": 1.4, "fitness": 0.95}}))
153
+ check("neg abs", Simulator.is_promising({"is": {"sharpe": -1.8, "fitness": -0.95}}))
154
+ check("weak", Simulator.is_promising({"is": {"sharpe": 0.5, "fitness": 0.4}}) is False)
155
+ check("neg no-inv", Simulator.is_promising(
156
+ {"is": {"sharpe": -1.8, "fitness": -0.95}}, include_inverse=False) is False)
157
+
158
+ print("\n[5-7] batch + resume + inverse + error")
159
+ with tempfile.TemporaryDirectory() as d:
160
+ tmp = Path(d)
161
+ jl = tmp / "a.jsonl"
162
+ jl.write_text("\n".join(json.dumps(o) for o in [
163
+ {"hash": "H_pos", "expression": "rank(close - open)"},
164
+ {"hash": "H_neg", "expression": "-rank(returns)"},
165
+ {"hash": "H_weak", "expression": "ts_rank(close, 5)"},
166
+ {"hash": "H_err", "expression": "broken_expr("}]), encoding="utf-8")
167
+ sv = str(tmp / "p.jsonl")
168
+ pr = str(tmp / "proc.jsonl")
169
+ sim = Simulator(mk().authenticate())
170
+ r1 = sim.simulate_batch(str(jl), save_promising=True, save_path=sv,
171
+ processed_path=pr, max_concurrent=3)
172
+ check("ran 4", len([x for x in r1 if not x["skipped"]]) == 4)
173
+ check("promising 2", sum(x["promising"] for x in r1) == 2)
174
+ check("saved 2", sum(x["saved"] for x in r1) == 2)
175
+ check("1 error", sum(1 for x in r1 if x["error"]) == 1)
176
+ saved = Simulator.load_promising(sv)
177
+ neg = next((s for s in saved if s["source_hash"] == "H_neg"), None)
178
+ check("inverse flag", neg and neg["inverse"] is True)
179
+ check("neg expr", "-(-rank(returns))" == (neg and neg["regular_inverse"]))
180
+ check("source_hash", any(s["source_hash"] == "H_pos" for s in saved))
181
+ r2 = sim.simulate_batch(str(jl), save_promising=True, save_path=sv,
182
+ processed_path=pr, max_concurrent=3)
183
+ check("resume skip all", all(x["skipped"] for x in r2) and len(r2) == 4)
184
+ check("no dup save", len(Simulator.load_promising(sv)) == len(saved))
185
+
186
+ print("\n[8] auto re-login")
187
+ c = mk().authenticate()
188
+ c.session.expired = True
189
+ c._last_auth = 0
190
+ rr = c.get("/operators")
191
+ check("401 -> relogin -> 200", rr.status_code == 200)
192
+ check("usable again", c.session.expired is False)
193
+
194
+ print("\n" + "=" * 50)
195
+ print(f"RESULT: passed {_p} | failed {_f}")
196
+ print("=" * 50)
197
+ return _f
198
+
199
+
200
+ def test_full_loop():
201
+ assert run() == 0
202
+
203
+
204
+ if __name__ == "__main__":
205
+ sys.exit(1 if run() else 0)