Skip to content

Commit 0a49f2c

Browse files
committed
Improve export script workflow and logging
1 parent aab80bf commit 0a49f2c

4 files changed

Lines changed: 383 additions & 40 deletions

File tree

README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ Read the following section for step-by-step instructions.
1414

1515
### Prerequisites
1616

17-
- You need to be a [FUTU/Moomoo](https://www.moomoo.com/) user with an active account, anywhere in the world (incl. US, HK, SG, etc.).
17+
- You need to be a [FUTU/Moomoo](https://www.moomoo.com/) user with an active account, anywhere in the world (incl. US, HK, SG, etc.).
1818

1919
### Step 1: Setup Moomoo API Client Locally
2020

@@ -48,7 +48,7 @@ Read the following section for step-by-step instructions.
4848
3. Run the export script:
4949

5050
```bash
51-
uv run moomoo_export.py
51+
uv run main.py
5252
```
5353

5454
- After the script is done, you will find the exported data (a single JSON file) in the `python` directory.
@@ -58,8 +58,8 @@ Read the following section for step-by-step instructions.
5858

5959
**Environment:**
6060

61-
- `Node.js`
62-
- `pnpm` (optional, but recommended)
61+
- `Node.js`
62+
- `pnpm` (optional, but recommended)
6363

6464
**Commands:**
6565

python/console_output.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
from rich.console import Console
2+
3+
console = Console()
4+
5+
6+
def info(scope: str, message: str) -> None:
7+
console.print(f"[bold cyan]{scope}:[/bold cyan] {message}")
8+
9+
10+
def success(scope: str, message: str) -> None:
11+
console.print(f"[bold green]{scope}:[/bold green] {message}")
12+
13+
14+
def warning(scope: str, message: str) -> None:
15+
console.print(f"[bold yellow]{scope}:[/bold yellow] {message}")
16+
17+
18+
def error(scope: str, message: str) -> None:
19+
console.print(f"[bold red]{scope}:[/bold red] {message}")
20+
21+
22+
def detail(label: str, value: str) -> None:
23+
console.print(f" [dim]{f'{label}:':<12}[/dim] {value}")
24+
25+
26+
def blank_line() -> None:
27+
console.print()

python/main.py

Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
import os
2+
import platform
3+
import signal
4+
import subprocess
5+
import time
6+
from dataclasses import dataclass, field
7+
from pathlib import Path
8+
9+
from console_output import error, info, success, warning
10+
from moomoo_export import (
11+
configure_moomoo_console_logging,
12+
export_account_data_for_web,
13+
get_trade_context,
14+
moomoo_is_running,
15+
)
16+
17+
MOOMOO_HOST = "127.0.0.1"
18+
MOOMOO_PORT = 11111
19+
MOOMOO_APP_NAME = "moomoo_OpenD"
20+
MOOMOO_PROCESS_NAME = "moomoo_OpenD"
21+
MOOMOO_STARTUP_TIMEOUT_SECONDS = 60.0
22+
MOOMOO_READY_PROCESS_TIMEOUT_SECONDS = 15.0
23+
MOOMOO_SHUTDOWN_TIMEOUT_SECONDS = 15.0
24+
MOOMOO_FORCE_SHUTDOWN_TIMEOUT_SECONDS = 5.0
25+
26+
27+
@dataclass
28+
class OpenDLaunchState:
29+
started_by_script: bool = False
30+
export_started: bool = False
31+
initial_pids: set[int] = field(default_factory=set)
32+
app_path: Path | None = None
33+
34+
35+
def _candidate_opend_paths() -> list[Path]:
36+
candidates: list[Path] = []
37+
configured_path = os.getenv("MOOMOO_OPEND_APP_PATH")
38+
if configured_path:
39+
candidates.append(Path(configured_path).expanduser())
40+
41+
candidates.extend(
42+
[
43+
Path("/Applications/moomoo_OpenD.app"),
44+
Path.home() / "Applications" / "moomoo_OpenD.app",
45+
Path("/Applications/moomoo_OpenD.app/Contents/MacOS/moomoo_OpenD"),
46+
Path.home() / "Applications/moomoo_OpenD.app/Contents/MacOS/moomoo_OpenD",
47+
]
48+
)
49+
return candidates
50+
51+
52+
def _resolve_opend_path() -> Path:
53+
checked_paths: list[Path] = []
54+
for candidate in _candidate_opend_paths():
55+
checked_paths.append(candidate)
56+
if candidate.is_dir() and candidate.suffix == ".app":
57+
return candidate
58+
if candidate.is_file():
59+
return candidate
60+
61+
searched = ", ".join(str(path) for path in checked_paths)
62+
raise FileNotFoundError(
63+
"Unable to locate moomoo_OpenD.app. "
64+
f"Set MOOMOO_OPEND_APP_PATH or install it in one of: {searched}"
65+
)
66+
67+
68+
def _find_moomoo_pids() -> set[int]:
69+
pid_patterns = [
70+
["pgrep", "-x", MOOMOO_PROCESS_NAME],
71+
["pgrep", "-f", f"/{MOOMOO_APP_NAME}.app/Contents/MacOS/{MOOMOO_PROCESS_NAME}"],
72+
]
73+
pids: set[int] = set()
74+
75+
for command in pid_patterns:
76+
try:
77+
result = subprocess.run(
78+
command,
79+
capture_output=True,
80+
text=True,
81+
check=False,
82+
)
83+
except FileNotFoundError:
84+
break
85+
86+
if result.returncode not in (0, 1):
87+
continue
88+
89+
for line in result.stdout.splitlines():
90+
line = line.strip()
91+
if line.isdigit():
92+
pids.add(int(line))
93+
94+
return pids
95+
96+
97+
def _wait_for_opend_ready(timeout_seconds: float) -> bool:
98+
deadline = time.monotonic() + timeout_seconds
99+
while time.monotonic() < deadline:
100+
if moomoo_is_running(
101+
host=MOOMOO_HOST,
102+
port=MOOMOO_PORT,
103+
timeout=0,
104+
retry_delay=0.25,
105+
):
106+
return True
107+
time.sleep(0.5)
108+
109+
return moomoo_is_running(
110+
host=MOOMOO_HOST,
111+
port=MOOMOO_PORT,
112+
timeout=0,
113+
retry_delay=0.25,
114+
)
115+
116+
117+
def _wait_for_opend_shutdown(timeout_seconds: float) -> bool:
118+
deadline = time.monotonic() + timeout_seconds
119+
while time.monotonic() < deadline:
120+
if not _find_moomoo_pids() and not moomoo_is_running(
121+
host=MOOMOO_HOST,
122+
port=MOOMOO_PORT,
123+
timeout=0,
124+
retry_delay=0.25,
125+
):
126+
return True
127+
time.sleep(0.5)
128+
129+
return not _find_moomoo_pids() and not moomoo_is_running(
130+
host=MOOMOO_HOST,
131+
port=MOOMOO_PORT,
132+
timeout=0,
133+
retry_delay=0.25,
134+
)
135+
136+
137+
def _launch_opend(app_path: Path) -> None:
138+
if platform.system() != "Darwin":
139+
raise RuntimeError(
140+
"Automatic launch of moomoo_OpenD is only supported on macOS. "
141+
"Please start Moomoo OpenD manually."
142+
)
143+
144+
if app_path.suffix == ".app":
145+
result = subprocess.run(
146+
["open", str(app_path)],
147+
capture_output=True,
148+
text=True,
149+
check=False,
150+
)
151+
if result.returncode != 0:
152+
stderr = result.stderr.strip() or "unknown launch error"
153+
raise RuntimeError(f"Failed to launch {app_path}: {stderr}")
154+
return
155+
156+
try:
157+
subprocess.Popen(
158+
[str(app_path)],
159+
stdout=subprocess.DEVNULL,
160+
stderr=subprocess.DEVNULL,
161+
start_new_session=True,
162+
)
163+
except OSError as exc:
164+
raise RuntimeError(f"Failed to launch {app_path}: {exc}") from exc
165+
166+
167+
def ensure_moomoo_opend_ready(state: OpenDLaunchState) -> None:
168+
if moomoo_is_running(
169+
host=MOOMOO_HOST,
170+
port=MOOMOO_PORT,
171+
timeout=0,
172+
retry_delay=0.25,
173+
):
174+
success("OpenD", f"Already running on {MOOMOO_HOST}:{MOOMOO_PORT}")
175+
return
176+
177+
state.initial_pids = _find_moomoo_pids()
178+
if state.initial_pids:
179+
info("OpenD", f"Process detected, waiting for {MOOMOO_HOST}:{MOOMOO_PORT}")
180+
if _wait_for_opend_ready(MOOMOO_READY_PROCESS_TIMEOUT_SECONDS):
181+
success("OpenD", f"Ready on {MOOMOO_HOST}:{MOOMOO_PORT}")
182+
return
183+
184+
raise RuntimeError(
185+
"Detected a running moomoo_OpenD process, but it never became ready on "
186+
f"{MOOMOO_HOST}:{MOOMOO_PORT}. Finish logging in to OpenD and try again."
187+
)
188+
189+
state.app_path = _resolve_opend_path()
190+
info("OpenD", f"Launching from {state.app_path}")
191+
_launch_opend(state.app_path)
192+
state.started_by_script = True
193+
194+
if not _wait_for_opend_ready(MOOMOO_STARTUP_TIMEOUT_SECONDS):
195+
raise RuntimeError(
196+
"Launched moomoo_OpenD, but it did not open port "
197+
f"{MOOMOO_PORT} within {MOOMOO_STARTUP_TIMEOUT_SECONDS:.0f} seconds. "
198+
"Make sure OpenD finishes launching and that you are logged in."
199+
)
200+
201+
success("OpenD", f"Ready on {MOOMOO_HOST}:{MOOMOO_PORT}")
202+
203+
204+
def _terminate_pids(pids: set[int], sig: int) -> None:
205+
for pid in pids:
206+
try:
207+
os.kill(pid, sig)
208+
except ProcessLookupError:
209+
continue
210+
211+
212+
def shutdown_moomoo_opend(state: OpenDLaunchState) -> None:
213+
if not state.started_by_script:
214+
return
215+
216+
info("OpenD", "Stopping")
217+
218+
if platform.system() == "Darwin":
219+
subprocess.run(
220+
["osascript", "-e", f'tell application "{MOOMOO_APP_NAME}" to quit'],
221+
capture_output=True,
222+
text=True,
223+
check=False,
224+
)
225+
226+
if _wait_for_opend_shutdown(MOOMOO_SHUTDOWN_TIMEOUT_SECONDS):
227+
success("OpenD", "Stopped")
228+
return
229+
230+
remaining_pids = _find_moomoo_pids()
231+
if remaining_pids:
232+
warning("OpenD", "Did not exit cleanly, sending SIGTERM")
233+
_terminate_pids(remaining_pids, signal.SIGTERM)
234+
235+
if _wait_for_opend_shutdown(MOOMOO_SHUTDOWN_TIMEOUT_SECONDS):
236+
success("OpenD", "Stopped")
237+
return
238+
239+
remaining_pids = _find_moomoo_pids()
240+
if remaining_pids:
241+
warning("OpenD", "Ignored SIGTERM, sending SIGKILL")
242+
_terminate_pids(remaining_pids, signal.SIGKILL)
243+
244+
if _wait_for_opend_shutdown(MOOMOO_FORCE_SHUTDOWN_TIMEOUT_SECONDS):
245+
success("OpenD", "Stopped")
246+
return
247+
248+
raise RuntimeError(
249+
"moomoo_OpenD was started by this script but could not be stopped cleanly. "
250+
"Please close it manually."
251+
)
252+
253+
254+
def main():
255+
configure_moomoo_console_logging()
256+
launch_state = OpenDLaunchState()
257+
trd_ctx = None
258+
main_error: BaseException | None = None
259+
260+
try:
261+
ensure_moomoo_opend_ready(launch_state)
262+
263+
info("Trade Context", "Opening connection to Moomoo OpenD")
264+
trd_ctx = get_trade_context()
265+
success("Trade Context", "Connection established")
266+
267+
launch_state.export_started = True
268+
export_account_data_for_web(trd_ctx=trd_ctx)
269+
except BaseException as exc:
270+
main_error = exc
271+
raise
272+
finally:
273+
if trd_ctx is not None:
274+
try:
275+
trd_ctx.close()
276+
info("Trade Context", "Closed")
277+
except Exception as exc:
278+
if main_error is None:
279+
raise
280+
warning("Trade Context", f"Failed to close cleanly: {exc}")
281+
282+
if launch_state.started_by_script and launch_state.export_started:
283+
try:
284+
shutdown_moomoo_opend(launch_state)
285+
except Exception as exc:
286+
if main_error is None:
287+
raise
288+
error("OpenD", f"Failed to stop cleanly: {exc}")
289+
290+
291+
if __name__ == "__main__":
292+
main()

0 commit comments

Comments
 (0)