39875112a0
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.
135 lines
3.7 KiB
Python
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,
|
|
)
|