From 17aa566f73af7410120b53487a1a3eb61fb55901 Mon Sep 17 00:00:00 2001 From: Gucc111 <154232679+Gucc111@users.noreply.github.com> Date: Sat, 29 Nov 2025 13:17:11 +0800 Subject: [PATCH] Add initial BrainAlpha CLI scaffolding --- .gitignore | 3 + README.md | 49 +++++++++++++++ configs/sample.yml | 16 +++++ pyproject.toml | 23 +++++++ src/brainalpha/__init__.py | 6 ++ src/brainalpha/cli/__init__.py | 35 +++++++++++ src/brainalpha/cli/config.py | 48 +++++++++++++++ src/brainalpha/config/__init__.py | 5 ++ src/brainalpha/config/manager.py | 63 +++++++++++++++++++ src/brainalpha/infra/__init__.py | 5 ++ src/brainalpha/infra/http.py | 95 +++++++++++++++++++++++++++++ src/brainalpha/logging.py | 22 +++++++ src/brainalpha/services/__init__.py | 1 + 13 files changed, 371 insertions(+) create mode 100644 README.md create mode 100644 configs/sample.yml create mode 100644 pyproject.toml create mode 100644 src/brainalpha/__init__.py create mode 100644 src/brainalpha/cli/__init__.py create mode 100644 src/brainalpha/cli/config.py create mode 100644 src/brainalpha/config/__init__.py create mode 100644 src/brainalpha/config/manager.py create mode 100644 src/brainalpha/infra/__init__.py create mode 100644 src/brainalpha/infra/http.py create mode 100644 src/brainalpha/logging.py create mode 100644 src/brainalpha/services/__init__.py diff --git a/.gitignore b/.gitignore index b7faf40..30aa957 100644 --- a/.gitignore +++ b/.gitignore @@ -205,3 +205,6 @@ cython_debug/ marimo/_static/ marimo/_lsp/ __marimo__/ + +# BrainAlpha local data +.brainalpha/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..b4c5e23 --- /dev/null +++ b/README.md @@ -0,0 +1,49 @@ +# BrainAlpha + +A Python CLI toolbox for automating WorldQuant Brain alpha research workflows. The project aims to turn repetitive +web actions—creating alphas, running backtests, and submitting qualified strategies—into a scriptable pipeline that can +also collaborate with LLMs. + +## Vision & Scope +- Manage credentials and API access for WorldQuant Brain. +- Provide CLI commands for alpha CRUD, backtests, submissions, and data schema discovery. +- Integrate LLMs to propose or refine alpha expressions based on field metadata and prior performance. +- Enable end-to-end pipelines: generate → validate → backtest → qualify → submit. +- Keep the architecture cleanly layered (infra, services, CLI) so future Web/App clients can reuse the core services. + +## Project Milestones +- **M1:** Project skeleton, configuration system, logging, and CLI scaffolding. +- **M2:** WorldQuant Brain API client with alpha CRUD and backtests. +- **M3:** LLM-driven alpha generation with validation. +- **M4:** Automated qualify-and-submit pipeline driven by config files. +- **M5:** Documentation and stable service interfaces for downstream clients. + +## Getting Started + +### Installation (editable) +```bash +pip install --editable . +``` + +### Commands +- `brainalpha version` — show installed version. +- `brainalpha config init` — write credentials and defaults to `~/.brainalpha/config.yml`. +- `brainalpha config show` — view saved configuration (secrets are redacted). + +## Configuration +Configuration is stored at `~/.brainalpha/config.yml` by default. The file captures: + +- `api_key`: WorldQuant Brain API key (kept private; file permissions set to `600`). +- `base_url`: API base URL (defaults to production endpoint). +- `storage_path`: Local directory for caching artifacts and backtests. + +You can override the config path by instantiating `ConfigManager` with a custom path. + +## Development Notes +- Packaging uses [`hatchling`](https://hatch.pypa.io/latest/). +- CLI built on [`typer`](https://typer.tiangolo.com/) and [`rich`](https://rich.readthedocs.io/). +- HTTP interactions use [`httpx`](https://www.python-httpx.org/) with a lightweight retry helper. + +## Roadmap +See `configs/sample.yml` for an example of future pipeline configuration. Upcoming work will flesh out API services, +backtest orchestration, qualification rules, and LLM prompt templates. diff --git a/configs/sample.yml b/configs/sample.yml new file mode 100644 index 0000000..7a26f16 --- /dev/null +++ b/configs/sample.yml @@ -0,0 +1,16 @@ +# Example pipeline configuration for BrainAlpha +alpha: + name: sample_alpha + expression: "rank(ts_mean(close, 10) - ts_mean(close, 60))" + tags: + - demo +workflow: + generate_with_llm: false + backtest: + start_date: 2020-01-01 + end_date: 2024-01-01 + universe: top_3000 + qualification: + sharpe_min: 1.0 + turnover_max: 0.6 + submit_if_qualified: false diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..40a04a7 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,23 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "brainalpha" +version = "0.1.0" +description = "CLI tooling for automating WorldQuant Brain alpha workflows" +authors = [{ name = "BrainAlphaLab", email = "devnull@example.com" }] +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "typer[all]>=0.12", + "rich>=13.7", + "httpx>=0.26", + "pyyaml>=6.0", +] + +[project.scripts] +brainalpha = "brainalpha.cli:run" + +[tool.hatch.build.targets.wheel] +packages = ["src/brainalpha"] diff --git a/src/brainalpha/__init__.py b/src/brainalpha/__init__.py new file mode 100644 index 0000000..7acb5e7 --- /dev/null +++ b/src/brainalpha/__init__.py @@ -0,0 +1,6 @@ +"""BrainAlpha package initializer.""" + +__app_name__ = "brainalpha" +__version__ = "0.1.0" + +__all__ = ["__app_name__", "__version__"] diff --git a/src/brainalpha/cli/__init__.py b/src/brainalpha/cli/__init__.py new file mode 100644 index 0000000..927f042 --- /dev/null +++ b/src/brainalpha/cli/__init__.py @@ -0,0 +1,35 @@ +"""Command-line interface for BrainAlpha.""" +from __future__ import annotations + +import typer +from rich.console import Console + +from brainalpha import __app_name__, __version__ +from brainalpha.cli.config import config_app +from brainalpha.logging import configure_logging + +console = Console() +app = typer.Typer(help="CLI toolkit for WorldQuant Brain alpha workflows") +app.add_typer(config_app, name="config") + + +@app.callback() +def main(ctx: typer.Context) -> None: + """Configure global options and logging.""" + configure_logging() + ctx.obj = {} + + +@app.command() +def version() -> None: + """Show the installed version of brainalpha.""" + console.print(f"{__app_name__} v{__version__}") + + +def run() -> None: + """Entry point for console_scripts.""" + app() + + +if __name__ == "__main__": + run() diff --git a/src/brainalpha/cli/config.py b/src/brainalpha/cli/config.py new file mode 100644 index 0000000..3faba76 --- /dev/null +++ b/src/brainalpha/cli/config.py @@ -0,0 +1,48 @@ +"""Configuration management commands.""" +from __future__ import annotations + +from pathlib import Path +from typing import Optional + +import typer +from rich.console import Console +from rich.table import Table + +from brainalpha.config.manager import BrainAlphaConfig, ConfigManager + +console = Console() +config_app = typer.Typer(help="Manage authentication and runtime configuration") + + +@config_app.command("init") +def init_config( + api_key: str = typer.Option( + "", prompt="WorldQuant Brain API key", hide_input=True, confirmation_prompt=True + ), + base_url: str = typer.Option( + "https://www.worldquantbrain.com/api", prompt="API base URL" + ), + storage_path: Optional[Path] = typer.Option( + None, + help="Optional override for local data storage path.", + ), +) -> None: + """Initialize the local configuration file with credentials and defaults.""" + manager = ConfigManager() + config = BrainAlphaConfig(api_key=api_key, base_url=base_url, storage_path=storage_path) + manager.save(config) + console.print(f"Configuration written to {manager.config_path}") + + +@config_app.command("show") +def show_config() -> None: + """Display the current configuration (hiding secrets).""" + manager = ConfigManager() + config = manager.load() + table = Table(title="BrainAlpha Configuration") + table.add_column("Field") + table.add_column("Value") + table.add_row("base_url", config.base_url) + table.add_row("api_key", "***" if config.api_key else "(not set)") + table.add_row("storage_path", str(config.storage_path)) + console.print(table) diff --git a/src/brainalpha/config/__init__.py b/src/brainalpha/config/__init__.py new file mode 100644 index 0000000..fee10cb --- /dev/null +++ b/src/brainalpha/config/__init__.py @@ -0,0 +1,5 @@ +"""Configuration package for BrainAlpha.""" + +from .manager import BrainAlphaConfig, ConfigManager + +__all__ = ["BrainAlphaConfig", "ConfigManager"] diff --git a/src/brainalpha/config/manager.py b/src/brainalpha/config/manager.py new file mode 100644 index 0000000..378ede7 --- /dev/null +++ b/src/brainalpha/config/manager.py @@ -0,0 +1,63 @@ +"""Configuration data classes and persistence utilities.""" +from __future__ import annotations + +import os +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional + +import yaml + +DEFAULT_CONFIG_DIR = Path.home() / ".brainalpha" +DEFAULT_CONFIG_PATH = DEFAULT_CONFIG_DIR / "config.yml" +DEFAULT_STORAGE_PATH = DEFAULT_CONFIG_DIR / "storage" + + +@dataclass +class BrainAlphaConfig: + """User configuration for accessing WorldQuant Brain APIs.""" + + api_key: str = "" + base_url: str = "https://www.worldquantbrain.com/api" + storage_path: Path = field(default_factory=lambda: DEFAULT_STORAGE_PATH) + + def to_dict(self) -> dict: + data = { + "api_key": self.api_key, + "base_url": self.base_url, + "storage_path": str(self.storage_path), + } + return data + + @classmethod + def from_dict(cls, raw: dict) -> "BrainAlphaConfig": + return cls( + api_key=raw.get("api_key", ""), + base_url=raw.get("base_url", "https://www.worldquantbrain.com/api"), + storage_path=Path(raw.get("storage_path", DEFAULT_STORAGE_PATH)), + ) + + +class ConfigManager: + """Load and persist configuration to disk.""" + + def __init__(self, config_path: Optional[Path] = None) -> None: + self.config_path = config_path or DEFAULT_CONFIG_PATH + self.config_dir = self.config_path.parent + + def ensure_config_dir(self) -> None: + self.config_dir.mkdir(parents=True, exist_ok=True) + + def load(self) -> BrainAlphaConfig: + if not self.config_path.exists(): + return BrainAlphaConfig() + with self.config_path.open("r", encoding="utf-8") as f: + data = yaml.safe_load(f) or {} + return BrainAlphaConfig.from_dict(data) + + def save(self, config: BrainAlphaConfig) -> None: + self.ensure_config_dir() + with self.config_path.open("w", encoding="utf-8") as f: + yaml.safe_dump(config.to_dict(), f, default_flow_style=False) + if "posix" in os.name: + os.chmod(self.config_path, 0o600) diff --git a/src/brainalpha/infra/__init__.py b/src/brainalpha/infra/__init__.py new file mode 100644 index 0000000..9a95c3b --- /dev/null +++ b/src/brainalpha/infra/__init__.py @@ -0,0 +1,5 @@ +"""Infrastructure utilities for external integrations.""" + +from .http import BrainHttpClient + +__all__ = ["BrainHttpClient"] diff --git a/src/brainalpha/infra/http.py b/src/brainalpha/infra/http.py new file mode 100644 index 0000000..a782c7a --- /dev/null +++ b/src/brainalpha/infra/http.py @@ -0,0 +1,95 @@ +"""HTTP client abstraction for WorldQuant Brain API access.""" +from __future__ import annotations + +import logging +import time +from typing import Any, Dict, Optional + +import httpx + +logger = logging.getLogger(__name__) + +DEFAULT_TIMEOUT = 15.0 +DEFAULT_RETRIES = 3 +RETRY_BACKOFF = 2.0 + + +class BrainHttpClient: + """Simple HTTP client with retry and base URL handling.""" + + def __init__( + self, + base_url: str, + api_key: str = "", + timeout: float = DEFAULT_TIMEOUT, + retries: int = DEFAULT_RETRIES, + ) -> None: + self.base_url = base_url.rstrip("/") + self.api_key = api_key + self.timeout = timeout + self.retries = retries + self._client = httpx.Client(timeout=timeout) + + def _headers(self) -> Dict[str, str]: + headers: Dict[str, str] = { + "User-Agent": "brainalpha-cli/0.1", + } + if self.api_key: + headers["Authorization"] = f"Bearer {self.api_key}" + return headers + + def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response: + url = f"{self.base_url}/{path.lstrip('/')}" + kwargs.setdefault("headers", {}).update(self._headers()) + + attempt = 0 + while True: + attempt += 1 + try: + response = self._client.request(method, url, **kwargs) + if response.status_code in (429, 500, 502, 503, 504) and attempt <= self.retries: + delay = RETRY_BACKOFF ** (attempt - 1) + logger.warning( + "Received %s for %s %s, retrying in %.1fs (attempt %s/%s)", + response.status_code, + method, + url, + delay, + attempt, + self.retries, + ) + time.sleep(delay) + continue + response.raise_for_status() + return response + except httpx.HTTPStatusError: + raise + except httpx.HTTPError as exc: # network level + if attempt > self.retries: + raise + delay = RETRY_BACKOFF ** (attempt - 1) + logger.warning( + "HTTP error for %s %s: %s. Retrying in %.1fs (attempt %s/%s)", + method, + url, + exc, + delay, + attempt, + self.retries, + ) + time.sleep(delay) + + def get(self, path: str, params: Optional[Dict[str, Any]] = None) -> httpx.Response: + return self._request("GET", path, params=params) + + def post(self, path: str, json: Optional[Dict[str, Any]] = None) -> httpx.Response: + return self._request("POST", path, json=json) + + def patch(self, path: str, json: Optional[Dict[str, Any]] = None) -> httpx.Response: + return self._request("PATCH", path, json=json) + + def delete(self, path: str) -> httpx.Response: + return self._request("DELETE", path) + + def close(self) -> None: + self._client.close() diff --git a/src/brainalpha/logging.py b/src/brainalpha/logging.py new file mode 100644 index 0000000..3785faa --- /dev/null +++ b/src/brainalpha/logging.py @@ -0,0 +1,22 @@ +"""Logging configuration utilities.""" +from __future__ import annotations + +import logging +import sys + +from rich.logging import RichHandler + +LOG_LEVEL = logging.INFO + + +def configure_logging(level: int = LOG_LEVEL) -> None: + """Configure application-wide logging with a consistent format.""" + if logging.getLogger().handlers: + return + + logging.basicConfig( + level=level, + format="%(asctime)s | %(levelname)-8s | %(name)s | %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + handlers=[RichHandler(rich_tracebacks=True, console=None, markup=True, stream=sys.stderr)], + ) diff --git a/src/brainalpha/services/__init__.py b/src/brainalpha/services/__init__.py new file mode 100644 index 0000000..61ce7c6 --- /dev/null +++ b/src/brainalpha/services/__init__.py @@ -0,0 +1 @@ +"""Service layer placeholder for domain operations."""