Files
alpaclaudia/bot/alpaclaudia/config.py
T
admin 39875112a0 initial: alpaclaudia paper-trading bot + dashboard
Python bot (bot/alpaclaudia): alpaca-py client, wheel strategy (CSP + covered
calls) plus equity trailing stops, risk gates (cash buffer, cost-basis guard,
per-symbol concentration cap), SQLite state log, Typer CLI (tick/loop/status/
report/dump-state), Discord daily report, pytest suite.

Next.js 14 dashboard (dashboard/): read-only — reads the bot's SQLite directly
and pulls live account/positions/orders from Alpaca. KPIs, equity chart,
positions, bot-intents audit table, and orders table. Dark UI with Tailwind.

systemd/: user-unit templates for the polling loop and the post-close report
timer.

docs/STRATEGY.md: wheel mechanics, risk invariants, later candidates.

Defaults to BOT_MODE=dry — nothing is submitted to Alpaca until explicitly
enabled in .env. ALPACA_ENV=paper by default; flipping to live requires an
explicit second guard.
2026-04-16 21:38:25 +02:00

135 lines
3.7 KiB
Python

from __future__ import annotations
import os
from dataclasses import dataclass, field
from pathlib import Path
from dotenv import load_dotenv
def _load_env() -> None:
root = Path(__file__).resolve().parents[2]
for candidate in (root / ".env", root.parent / ".env"):
if candidate.exists():
load_dotenv(candidate, override=False)
return
def _bool(val: str | None, default: bool = False) -> bool:
if val is None:
return default
return val.strip().lower() in {"1", "true", "yes", "y", "on"}
def _float(val: str | None, default: float) -> float:
try:
return float(val) if val is not None else default
except ValueError:
return default
def _int(val: str | None, default: int) -> int:
try:
return int(val) if val is not None else default
except ValueError:
return default
@dataclass(frozen=True)
class AlpacaConfig:
key: str
secret: str
base_url: str
env: str # "paper" or "live"
@property
def is_paper(self) -> bool:
return self.env.lower() != "live"
@dataclass(frozen=True)
class WheelConfig:
universe: tuple[str, ...]
put_dte_min: int
put_dte_max: int
put_target_delta: float
put_otm_pct: float
call_otm_pct: float
min_annual_yield: float
max_short_puts_per_symbol: int
@dataclass(frozen=True)
class RiskConfig:
max_position_pct: float
min_cash_buffer_pct: float
trailing_stop_enabled: bool
trailing_stop_pct: float
@dataclass(frozen=True)
class Config:
mode: str # "dry" or "live"
alpaca: AlpacaConfig
wheel: WheelConfig
risk: RiskConfig
tick_interval: int
data_dir: Path
log_dir: Path
discord_webhook: str | None = None
@property
def dry_run(self) -> bool:
return self.mode.lower() != "live"
def load_config() -> Config:
_load_env()
env = os.environ
alpaca = AlpacaConfig(
key=env.get("ALPACA_API_KEY", ""),
secret=env.get("ALPACA_API_SECRET", ""),
base_url=env.get("ALPACA_BASE_URL", "https://paper-api.alpaca.markets"),
env=env.get("ALPACA_ENV", "paper"),
)
universe = tuple(
s.strip().upper() for s in env.get("WHEEL_UNIVERSE", "TSLA").split(",") if s.strip()
)
wheel = WheelConfig(
universe=universe,
put_dte_min=_int(env.get("WHEEL_PUT_DTE_MIN"), 14),
put_dte_max=_int(env.get("WHEEL_PUT_DTE_MAX"), 28),
put_target_delta=_float(env.get("WHEEL_PUT_TARGET_DELTA"), 0.30),
put_otm_pct=_float(env.get("WHEEL_PUT_OTM_PCT"), 0.10),
call_otm_pct=_float(env.get("WHEEL_CALL_OTM_PCT"), 0.10),
min_annual_yield=_float(env.get("WHEEL_MIN_ANNUAL_YIELD"), 0.15),
max_short_puts_per_symbol=_int(env.get("WHEEL_MAX_SHORT_PUTS_PER_SYMBOL"), 1),
)
risk = RiskConfig(
max_position_pct=_float(env.get("MAX_POSITION_PCT"), 0.25),
min_cash_buffer_pct=_float(env.get("MIN_CASH_BUFFER_PCT"), 0.05),
trailing_stop_enabled=_bool(env.get("TRAILING_STOP_ENABLED"), True),
trailing_stop_pct=_float(env.get("TRAILING_STOP_PCT"), 0.08),
)
root = Path(__file__).resolve().parents[2]
data_dir = Path(env.get("DATA_DIR") or (root / "data")).expanduser().resolve()
log_dir = Path(env.get("LOG_DIR") or (root / "logs")).expanduser().resolve()
data_dir.mkdir(parents=True, exist_ok=True)
log_dir.mkdir(parents=True, exist_ok=True)
return Config(
mode=env.get("BOT_MODE", "dry"),
alpaca=alpaca,
wheel=wheel,
risk=risk,
tick_interval=_int(env.get("TICK_INTERVAL_SECONDS"), 900),
data_dir=data_dir,
log_dir=log_dir,
discord_webhook=env.get("DISCORD_WEBHOOK_URL") or None,
)