Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/trading_lab/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ def _run(command: list[str]) -> int:


def main() -> None:
argv_list = __import__("sys").argv[1:]
if argv_list and argv_list[0] in {"start", "stop", "service"}:
from trading_lab.portfolio import service as _tl_service

if argv_list[0] == "service":
raise SystemExit(_tl_service.main(argv_list[1:] or ["status"]))
Comment on lines +14 to +18
raise SystemExit(_tl_service.main(argv_list))

parser = argparse.ArgumentParser(prog="tl")
sub = parser.add_subparsers(dest="command")

Expand Down
4 changes: 2 additions & 2 deletions src/trading_lab/portfolio/gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from trading_lab.portfolio.gui_render import render_status_page


def run_gui(host: str = "127.0.0.1", port: int = 8765) -> None:
def run_gui(host: str = "127.0.0.1", port: int = 811, open_browser: bool = True) -> None:
class Handler(BaseHTTPRequestHandler):
def do_GET(self) -> None:
query = parse_qs(self.path.split("?", 1)[1]) if "?" in self.path else {}
Expand Down Expand Up @@ -46,7 +46,7 @@ def log_message(self, format: str, *args) -> None:
url = f"http://{host}:{server.server_port}/"
print(f"Local-only portfolio GUI: {url}")
print("Press Ctrl+C to stop.")
webbrowser.open(url)
webbrowser.open(url) if open_browser else None
try:
server.serve_forever()
except KeyboardInterrupt:
Expand Down
238 changes: 238 additions & 0 deletions src/trading_lab/portfolio/service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
"""Background localhost service for the portfolio GUI.

This module is intentionally local-only:
- binds to 127.0.0.1
- stores PID/log files under gitignored data/runtime
- never talks to a broker
- never places orders
"""

from __future__ import annotations

import argparse
import os
import platform
import signal
import subprocess
import sys
import time
import webbrowser
from dataclasses import dataclass
from pathlib import Path
from typing import Sequence
from urllib.error import URLError
from urllib.request import urlopen


DEFAULT_HOST = "127.0.0.1"
DEFAULT_PORT = 811
RUNTIME_DIR = Path("data/runtime")
PID_FILE = RUNTIME_DIR / "tl_gui.pid"
LOG_FILE = RUNTIME_DIR / "tl_gui.log"


@dataclass(frozen=True)
class ServiceStatus:
running: bool
pid: int | None
url: str
pid_file: Path
log_file: Path


def service_url(host: str = DEFAULT_HOST, port: int = DEFAULT_PORT) -> str:
return f"http://{host}:{port}/"


def _read_pid(pid_file: Path = PID_FILE) -> int | None:
try:
text = pid_file.read_text(encoding="utf-8").strip()
except FileNotFoundError:
return None
if not text:
return None
try:
return int(text)
except ValueError:
return None


def _pid_is_running(pid: int) -> bool:
if pid <= 0:
return False
try:
os.kill(pid, 0)
except OSError:
return False
return True


def _server_responds(host: str = DEFAULT_HOST, port: int = DEFAULT_PORT) -> bool:
try:
with urlopen(service_url(host, port), timeout=1.5) as response:
return 200 <= int(response.status) < 500
except (OSError, URLError, TimeoutError):
Comment on lines +70 to +74
return False


def status(host: str = DEFAULT_HOST, port: int = DEFAULT_PORT) -> ServiceStatus:
pid = _read_pid()
running = bool(pid and _pid_is_running(pid)) or _server_responds(host, port)
return ServiceStatus(
running=running,
pid=pid,
url=service_url(host, port),
pid_file=PID_FILE,
log_file=LOG_FILE,
)


def _creationflags_for_windows() -> int:
if platform.system().lower() != "windows":
return 0
flags = 0
for name in ("CREATE_NEW_PROCESS_GROUP", "DETACHED_PROCESS"):
flags |= int(getattr(subprocess, name, 0))
return flags


def _popen_kwargs(log_file):
system = platform.system().lower()
kwargs: dict[str, object] = {
"stdin": subprocess.DEVNULL,
"stdout": log_file,
"stderr": subprocess.STDOUT,
"cwd": str(Path.cwd()),
}
if system == "windows":
kwargs["creationflags"] = _creationflags_for_windows()
else:
kwargs["start_new_session"] = True
return kwargs


def start(
host: str = DEFAULT_HOST,
port: int = DEFAULT_PORT,
*,
open_browser: bool = True,
force: bool = False,
) -> ServiceStatus:
RUNTIME_DIR.mkdir(parents=True, exist_ok=True)

current = status(host, port)
if current.running and not force:
if open_browser:
webbrowser.open(current.url)
return current

if current.pid and force:
stop(host, port)
Comment on lines +129 to +130

LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
log_handle = LOG_FILE.open("a", encoding="utf-8")

cmd = [
sys.executable,
"-m",
"trading_lab.portfolio.service",
"serve",
"--host",
host,
"--port",
str(port),
]

process = subprocess.Popen(cmd, **_popen_kwargs(log_handle))
PID_FILE.write_text(str(process.pid), encoding="utf-8")

# Give the server a short moment to bind.
for _ in range(25):
if _server_responds(host, port):
break
time.sleep(0.2)

final = status(host, port)
if open_browser:
webbrowser.open(final.url)
Comment on lines +155 to +157
return final


def stop(host: str = DEFAULT_HOST, port: int = DEFAULT_PORT) -> ServiceStatus:
pid = _read_pid()
if pid and _pid_is_running(pid):
try:
if platform.system().lower() == "windows":
subprocess.run(
["taskkill", "/PID", str(pid), "/T", "/F"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
else:
os.kill(pid, signal.SIGTERM)
Comment on lines +162 to +173
except OSError:
pass

if PID_FILE.exists():
PID_FILE.unlink()

# Give the OS a moment to release the port.
time.sleep(0.5)
return status(host, port)


def serve(host: str = DEFAULT_HOST, port: int = DEFAULT_PORT) -> None:
# Import lazily so normal CLI startup stays fast.
from trading_lab.portfolio.gui import run_gui

run_gui(host=host, port=port, open_browser=False)


def print_status(state: ServiceStatus) -> None:
if state.running:
pid_text = str(state.pid) if state.pid else "unknown"
print(f"tl GUI service: RUNNING")
print(f"URL: {state.url}")
print(f"PID: {pid_text}")
print(f"Log: {state.log_file}")
else:
print("tl GUI service: STOPPED")
print(f"URL: {state.url}")
print(f"PID file: {state.pid_file}")
print(f"Log: {state.log_file}")


def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Manage the trading-lab portfolio GUI service.")
sub = parser.add_subparsers(dest="cmd", required=True)

for name in ("start", "stop", "status", "serve"):
p = sub.add_parser(name)
p.add_argument("--host", default=DEFAULT_HOST)
p.add_argument("--port", type=int, default=DEFAULT_PORT)
Comment on lines +210 to +213

sub.choices["start"].add_argument("--no-open", action="store_true")
sub.choices["start"].add_argument("--force", action="store_true")

args = parser.parse_args(argv)

if args.cmd == "start":
print_status(start(args.host, args.port, open_browser=not args.no_open, force=args.force))
return 0
if args.cmd == "stop":
print_status(stop(args.host, args.port))
return 0
Comment on lines +221 to +225
if args.cmd == "status":
print_status(status(args.host, args.port))
return 0
if args.cmd == "serve":
serve(args.host, args.port)
return 0

parser.error(f"unknown command {args.cmd}")
return 2


if __name__ == "__main__":
raise SystemExit(main())
47 changes: 47 additions & 0 deletions tests/test_portfolio_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
from pathlib import Path

from trading_lab.portfolio import service


def test_service_defaults_use_lab_port():
assert service.DEFAULT_HOST == "127.0.0.1"
assert service.DEFAULT_PORT == 811
assert service.service_url() == "http://127.0.0.1:811/"


def test_service_status_handles_missing_pid_file(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
monkeypatch.setattr(service, "RUNTIME_DIR", Path("data/runtime"))
monkeypatch.setattr(service, "PID_FILE", Path("data/runtime/tl_gui.pid"))
monkeypatch.setattr(service, "LOG_FILE", Path("data/runtime/tl_gui.log"))
monkeypatch.setattr(service, "_server_responds", lambda host, port: False)

state = service.status()
assert state.running is False
assert state.pid is None
assert str(state.url).endswith(":811/")


def test_service_stop_removes_pid_file_without_real_process(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
runtime = Path("data/runtime")
runtime.mkdir(parents=True)
pid_file = runtime / "tl_gui.pid"
pid_file.write_text("999999", encoding="utf-8")

monkeypatch.setattr(service, "RUNTIME_DIR", runtime)
monkeypatch.setattr(service, "PID_FILE", pid_file)
monkeypatch.setattr(service, "LOG_FILE", runtime / "tl_gui.log")
monkeypatch.setattr(service, "_pid_is_running", lambda pid: False)
monkeypatch.setattr(service, "_server_responds", lambda host, port: False)

state = service.stop()
assert state.running is False
assert not pid_file.exists()


def test_cli_has_top_level_service_shortcuts():
import trading_lab.cli.main as cli_main

text = Path(cli_main.__file__).read_text(encoding="utf-8")
assert '"start", "stop", "service"' in text
Comment on lines +43 to +47
Loading