Skip to content
Open
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -205,3 +205,6 @@ cython_debug/
marimo/_static/
marimo/_lsp/
__marimo__/

# BrainAlpha local data
.brainalpha/
49 changes: 49 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 16 additions & 0 deletions configs/sample.yml
Original file line number Diff line number Diff line change
@@ -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
23 changes: 23 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"]
6 changes: 6 additions & 0 deletions src/brainalpha/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""BrainAlpha package initializer."""

__app_name__ = "brainalpha"
__version__ = "0.1.0"

__all__ = ["__app_name__", "__version__"]
35 changes: 35 additions & 0 deletions src/brainalpha/cli/__init__.py
Original file line number Diff line number Diff line change
@@ -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()
48 changes: 48 additions & 0 deletions src/brainalpha/cli/config.py
Original file line number Diff line number Diff line change
@@ -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)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: None value bypasses default factory, corrupts storage path

When users run brainalpha config init without specifying --storage-path, the storage_path parameter is None and gets passed explicitly to BrainAlphaConfig. The dataclass's default_factory only triggers when the argument is omitted entirely, not when None is passed. This causes to_dict() to serialize storage_path as the string "None", and when reloaded via from_dict(), it creates Path("None") - a literal path named "None" instead of the intended default storage location.

Additional Locations (1)

Fix in Cursor Fix in Web

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)
5 changes: 5 additions & 0 deletions src/brainalpha/config/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Configuration package for BrainAlpha."""

from .manager import BrainAlphaConfig, ConfigManager

__all__ = ["BrainAlphaConfig", "ConfigManager"]
63 changes: 63 additions & 0 deletions src/brainalpha/config/manager.py
Original file line number Diff line number Diff line change
@@ -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)
5 changes: 5 additions & 0 deletions src/brainalpha/infra/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Infrastructure utilities for external integrations."""

from .http import BrainHttpClient

__all__ = ["BrainHttpClient"]
95 changes: 95 additions & 0 deletions src/brainalpha/infra/http.py
Original file line number Diff line number Diff line change
@@ -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()
22 changes: 22 additions & 0 deletions src/brainalpha/logging.py
Original file line number Diff line number Diff line change
@@ -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)],
)
1 change: 1 addition & 0 deletions src/brainalpha/services/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Service layer placeholder for domain operations."""