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.
This commit is contained in:
@@ -0,0 +1,47 @@
|
|||||||
|
# --- Alpaca (Paper) -----------------------------------------------------------
|
||||||
|
ALPACA_API_KEY=PK_your_paper_key_here
|
||||||
|
ALPACA_API_SECRET=your_paper_secret_here
|
||||||
|
# https://paper-api.alpaca.markets for paper, https://api.alpaca.markets for live
|
||||||
|
ALPACA_BASE_URL=https://paper-api.alpaca.markets
|
||||||
|
# Hard guard — must explicitly set to "live" to touch the live endpoint
|
||||||
|
ALPACA_ENV=paper
|
||||||
|
|
||||||
|
# --- Bot runtime --------------------------------------------------------------
|
||||||
|
# dry = log intended orders only. live = actually submit to Alpaca paper.
|
||||||
|
BOT_MODE=dry
|
||||||
|
# Universe for the wheel (comma-separated)
|
||||||
|
WHEEL_UNIVERSE=TSLA
|
||||||
|
# Target DTE window for cash-secured puts (days)
|
||||||
|
WHEEL_PUT_DTE_MIN=14
|
||||||
|
WHEEL_PUT_DTE_MAX=28
|
||||||
|
# Target delta for CSPs (magnitude, e.g. 0.30 => ~30-delta short put)
|
||||||
|
WHEEL_PUT_TARGET_DELTA=0.30
|
||||||
|
# Strike distance for CSPs as fraction of spot (fallback when delta data missing)
|
||||||
|
WHEEL_PUT_OTM_PCT=0.10
|
||||||
|
# Strike distance for covered calls above cost basis (fraction)
|
||||||
|
WHEEL_CALL_OTM_PCT=0.10
|
||||||
|
# Minimum annualised yield (decimal) a candidate contract must offer
|
||||||
|
WHEEL_MIN_ANNUAL_YIELD=0.15
|
||||||
|
# Max concurrent short puts per symbol
|
||||||
|
WHEEL_MAX_SHORT_PUTS_PER_SYMBOL=1
|
||||||
|
|
||||||
|
# Trailing-stop config (equity positions only)
|
||||||
|
TRAILING_STOP_ENABLED=true
|
||||||
|
TRAILING_STOP_PCT=0.08
|
||||||
|
|
||||||
|
# Position sizing — max % of equity to put at risk per symbol
|
||||||
|
MAX_POSITION_PCT=0.25
|
||||||
|
# Minimum cash buffer (as fraction of equity) to keep unencumbered
|
||||||
|
MIN_CASH_BUFFER_PCT=0.05
|
||||||
|
|
||||||
|
# --- Scheduling ---------------------------------------------------------------
|
||||||
|
# Polling interval (seconds) inside market hours when --loop is used
|
||||||
|
TICK_INTERVAL_SECONDS=900
|
||||||
|
|
||||||
|
# --- Reporting ----------------------------------------------------------------
|
||||||
|
# Discord webhook URL (optional). If set, daily report is posted after close.
|
||||||
|
DISCORD_WEBHOOK_URL=
|
||||||
|
|
||||||
|
# --- Storage ------------------------------------------------------------------
|
||||||
|
DATA_DIR=./data
|
||||||
|
LOG_DIR=./logs
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
*.env
|
||||||
|
data/*.db
|
||||||
|
data/*.sqlite*
|
||||||
|
logs/*.log
|
||||||
|
logs/*.jsonl
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Node
|
||||||
|
node_modules/
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
.turbo/
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# OS / editor
|
||||||
|
.DS_Store
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# alpaclaudia
|
||||||
|
|
||||||
|
Automated **Alpaca paper-trading bot** with a **Next.js dashboard**.
|
||||||
|
|
||||||
|
Strategy coverage today:
|
||||||
|
- **Wheel** — sells cash-secured puts on a configurable universe, rolls into covered calls after assignment.
|
||||||
|
- **Trailing stops** — on every long equity position (opt-in).
|
||||||
|
|
||||||
|
Safety first: by default `BOT_MODE=dry` — the bot plans and logs intents but **submits nothing** to Alpaca. Flip to `live` only after you've reviewed the intent log.
|
||||||
|
|
||||||
|
```
|
||||||
|
finhacks/
|
||||||
|
├── bot/ # Python 3.11+ trading bot (alpaca-py, SQLite state)
|
||||||
|
├── dashboard/ # Next.js 14 dashboard (read-only view of bot + Alpaca)
|
||||||
|
├── systemd/ # user-unit templates for loop + daily report
|
||||||
|
├── data/ # SQLite DB lives here (gitignored)
|
||||||
|
├── logs/ # bot logs (gitignored)
|
||||||
|
└── docs/ # extra docs
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick start (paper)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 0. prerequisites: Python 3.11+, Node 20+, a paper Alpaca account
|
||||||
|
|
||||||
|
# 1. credentials
|
||||||
|
cp .env.example .env
|
||||||
|
# edit .env and set ALPACA_API_KEY, ALPACA_API_SECRET
|
||||||
|
# leave BOT_MODE=dry for the first few runs
|
||||||
|
|
||||||
|
# 2. bot
|
||||||
|
cd bot
|
||||||
|
python -m venv .venv
|
||||||
|
.venv/bin/pip install -e ".[dev]"
|
||||||
|
.venv/bin/python -m alpaclaudia status # smoke test
|
||||||
|
.venv/bin/python -m alpaclaudia tick # one dry iteration
|
||||||
|
|
||||||
|
# 3. dashboard
|
||||||
|
cd ../dashboard
|
||||||
|
cp .env.example .env.local
|
||||||
|
# set ALPACA_API_KEY + ALPACA_API_SECRET (same paper creds)
|
||||||
|
npm install
|
||||||
|
npm run dev # http://localhost:3030
|
||||||
|
|
||||||
|
# 4. schedule (optional, systemd user units)
|
||||||
|
# see systemd/README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## How a tick runs
|
||||||
|
|
||||||
|
```
|
||||||
|
scheduler.tick()
|
||||||
|
├─ snapshot account / positions / orders (Alpaca)
|
||||||
|
├─ record_tick() → SQLite
|
||||||
|
├─ plan_wheel() ── produces OrderIntent[]
|
||||||
|
├─ plan_trailing_stops() ── produces OrderIntent[]
|
||||||
|
├─ risk.check_*() ── per intent; blocked → logged, never submitted
|
||||||
|
└─ executor.submit_intent() ── no-op in dry-run; else Alpaca REST
|
||||||
|
```
|
||||||
|
|
||||||
|
Everything the bot considers ends up in `data/alpaclaudia.db::order_intents`, whether submitted, blocked, or dry-run. The dashboard reads this table verbatim, so you can audit the bot's reasoning independently of Alpaca.
|
||||||
|
|
||||||
|
## Going live (on paper — still "paper" at Alpaca)
|
||||||
|
|
||||||
|
After reviewing a couple of dry runs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# in .env
|
||||||
|
BOT_MODE=live
|
||||||
|
ALPACA_ENV=paper # still paper account — don't touch this unless you mean it
|
||||||
|
```
|
||||||
|
|
||||||
|
`ALPACA_ENV=live` is a separate, explicit guard that flips the SDK to the production endpoint. Don't set it unless you really want real money at stake.
|
||||||
|
|
||||||
|
## Daily Discord report
|
||||||
|
|
||||||
|
Set `DISCORD_WEBHOOK_URL` in `.env` and enable the timer:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl --user enable --now alpaclaudia-report.timer
|
||||||
|
```
|
||||||
|
|
||||||
|
It fires Mon–Fri at 22:30 local time (≈30 min after NYSE close if you're in Europe).
|
||||||
|
|
||||||
|
## Risk invariants (code in `bot/alpaclaudia/risk.py`)
|
||||||
|
|
||||||
|
- CSPs require `strike * 100 * qty ≤ cash − equity * MIN_CASH_BUFFER_PCT`.
|
||||||
|
- CSP collateral per symbol capped at `MAX_POSITION_PCT` of equity.
|
||||||
|
- Covered calls only sell above cost basis — never locking in a loss.
|
||||||
|
- Covered calls require ≥ `qty*100` underlying shares already held.
|
||||||
|
- Trailing stops only on long equity positions we own.
|
||||||
|
|
||||||
|
Tests: `cd bot && .venv/bin/pytest`.
|
||||||
|
|
||||||
|
## Repo
|
||||||
|
|
||||||
|
- Mirror: https://git.zeitanker.digital/admin/alpaclaudia
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
"""alpaclaudia — Alpaca paper-trading bot (Wheel + trailing stops)."""
|
||||||
|
__version__ = "0.1.0"
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
"""CLI entry point. Usage:
|
||||||
|
|
||||||
|
alpaclaudia tick # one-shot iteration
|
||||||
|
alpaclaudia loop # poll forever (intended for systemd)
|
||||||
|
alpaclaudia report # post daily summary to Discord
|
||||||
|
alpaclaudia status # print account snapshot + last tick
|
||||||
|
|
||||||
|
Honours BOT_MODE=dry (default) — nothing is submitted until BOT_MODE=live.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime, time as dtime, timezone
|
||||||
|
|
||||||
|
import typer
|
||||||
|
from rich import print as rprint
|
||||||
|
from rich.table import Table
|
||||||
|
|
||||||
|
from .client import (
|
||||||
|
build_clients,
|
||||||
|
get_account_snapshot,
|
||||||
|
list_positions,
|
||||||
|
list_recent_orders,
|
||||||
|
)
|
||||||
|
from .config import load_config
|
||||||
|
from .logger import get_logger, setup_logging
|
||||||
|
from .reporter import build_daily_summary, post_to_discord
|
||||||
|
from .scheduler import loop as run_loop, tick as run_tick
|
||||||
|
from .state import Store
|
||||||
|
|
||||||
|
app = typer.Typer(help="alpaclaudia — Alpaca paper-trading bot.")
|
||||||
|
log = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _bootstrap():
|
||||||
|
cfg = load_config()
|
||||||
|
setup_logging(cfg.log_dir)
|
||||||
|
store = Store(cfg.data_dir / "alpaclaudia.db")
|
||||||
|
clients = build_clients(cfg.alpaca)
|
||||||
|
return cfg, store, clients
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def tick():
|
||||||
|
"""Run one planning/execution cycle."""
|
||||||
|
cfg, store, clients = _bootstrap()
|
||||||
|
result = run_tick(cfg, clients, store)
|
||||||
|
rprint(result)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def loop():
|
||||||
|
"""Run indefinitely, polling every TICK_INTERVAL_SECONDS."""
|
||||||
|
cfg, store, clients = _bootstrap()
|
||||||
|
run_loop(cfg, clients, store)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def status():
|
||||||
|
"""Print account + positions snapshot."""
|
||||||
|
cfg, store, clients = _bootstrap()
|
||||||
|
acct = get_account_snapshot(clients)
|
||||||
|
positions = list_positions(clients)
|
||||||
|
rprint({"config_mode": cfg.mode, "env": cfg.alpaca.env, "account": acct})
|
||||||
|
t = Table(title="Positions")
|
||||||
|
for col in ("symbol", "qty", "avg_entry_price", "current_price", "market_value", "unrealized_pl"):
|
||||||
|
t.add_column(col)
|
||||||
|
for p in positions:
|
||||||
|
t.add_row(
|
||||||
|
str(p["symbol"]),
|
||||||
|
f"{p['qty']:g}",
|
||||||
|
f"{p['avg_entry_price']:.2f}",
|
||||||
|
f"{p['current_price']:.2f}",
|
||||||
|
f"{p['market_value']:.2f}",
|
||||||
|
f"{p['unrealized_pl']:+.2f}",
|
||||||
|
)
|
||||||
|
rprint(t)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def report():
|
||||||
|
"""Build today's summary and post to Discord webhook if configured."""
|
||||||
|
cfg, store, clients = _bootstrap()
|
||||||
|
acct = get_account_snapshot(clients)
|
||||||
|
positions = list_positions(clients)
|
||||||
|
today_start = datetime.now(tz=timezone.utc).replace(
|
||||||
|
hour=0, minute=0, second=0, microsecond=0
|
||||||
|
).isoformat()
|
||||||
|
intents_today = store.intents_since(today_start)
|
||||||
|
summary = build_daily_summary(
|
||||||
|
account=acct,
|
||||||
|
positions=positions,
|
||||||
|
intents_today=intents_today,
|
||||||
|
mode=cfg.mode,
|
||||||
|
)
|
||||||
|
rprint(summary)
|
||||||
|
if cfg.discord_webhook:
|
||||||
|
ok = post_to_discord(cfg.discord_webhook, summary)
|
||||||
|
rprint({"discord_posted": ok})
|
||||||
|
else:
|
||||||
|
rprint("(DISCORD_WEBHOOK_URL not set — skipping post)")
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def dump_state(limit: int = 50):
|
||||||
|
"""Dump recent ticks + intents as JSON (for dashboards / debugging)."""
|
||||||
|
cfg, store, _ = _bootstrap()
|
||||||
|
rprint(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"ticks": store.recent_ticks(limit),
|
||||||
|
"intents": store.recent_intents(limit),
|
||||||
|
},
|
||||||
|
default=str,
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app()
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
"""Thin Alpaca client wrappers + helpers for spot, chains, and account state."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import date, timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from alpaca.data.historical.option import OptionHistoricalDataClient
|
||||||
|
from alpaca.data.historical.stock import StockHistoricalDataClient
|
||||||
|
from alpaca.data.requests import OptionChainRequest, StockLatestQuoteRequest
|
||||||
|
from alpaca.trading.client import TradingClient
|
||||||
|
from alpaca.trading.enums import AssetClass
|
||||||
|
from alpaca.trading.requests import GetOptionContractsRequest
|
||||||
|
|
||||||
|
from .config import AlpacaConfig
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Clients:
|
||||||
|
trading: TradingClient
|
||||||
|
stock_data: StockHistoricalDataClient
|
||||||
|
option_data: OptionHistoricalDataClient
|
||||||
|
|
||||||
|
|
||||||
|
def build_clients(cfg: AlpacaConfig) -> Clients:
|
||||||
|
if not cfg.key or not cfg.secret:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Alpaca credentials missing. Set ALPACA_API_KEY / ALPACA_API_SECRET."
|
||||||
|
)
|
||||||
|
trading = TradingClient(cfg.key, cfg.secret, paper=cfg.is_paper)
|
||||||
|
stock_data = StockHistoricalDataClient(cfg.key, cfg.secret)
|
||||||
|
option_data = OptionHistoricalDataClient(cfg.key, cfg.secret)
|
||||||
|
return Clients(trading=trading, stock_data=stock_data, option_data=option_data)
|
||||||
|
|
||||||
|
|
||||||
|
def get_latest_spot(clients: Clients, symbol: str) -> float | None:
|
||||||
|
req = StockLatestQuoteRequest(symbol_or_symbols=symbol)
|
||||||
|
resp = clients.stock_data.get_stock_latest_quote(req)
|
||||||
|
q = resp.get(symbol) if isinstance(resp, dict) else getattr(resp, symbol, None)
|
||||||
|
if q is None:
|
||||||
|
return None
|
||||||
|
# mid-price if both sides present, else fallback to either
|
||||||
|
bid = getattr(q, "bid_price", None) or 0
|
||||||
|
ask = getattr(q, "ask_price", None) or 0
|
||||||
|
if bid and ask:
|
||||||
|
return (bid + ask) / 2
|
||||||
|
return bid or ask or None
|
||||||
|
|
||||||
|
|
||||||
|
def list_option_contracts(
|
||||||
|
clients: Clients,
|
||||||
|
underlying: str,
|
||||||
|
*,
|
||||||
|
option_type: str, # "put" or "call"
|
||||||
|
dte_min: int,
|
||||||
|
dte_max: int,
|
||||||
|
) -> list[Any]:
|
||||||
|
today = date.today()
|
||||||
|
req = GetOptionContractsRequest(
|
||||||
|
underlying_symbols=[underlying],
|
||||||
|
type=option_type,
|
||||||
|
expiration_date_gte=today + timedelta(days=dte_min),
|
||||||
|
expiration_date_lte=today + timedelta(days=dte_max),
|
||||||
|
status="active",
|
||||||
|
limit=500,
|
||||||
|
)
|
||||||
|
resp = clients.trading.get_option_contracts(req)
|
||||||
|
return list(getattr(resp, "option_contracts", resp) or [])
|
||||||
|
|
||||||
|
|
||||||
|
def get_option_snapshots(clients: Clients, underlying: str) -> dict[str, Any]:
|
||||||
|
"""Returns {contract_symbol: snapshot} with greeks/quote when available."""
|
||||||
|
try:
|
||||||
|
req = OptionChainRequest(underlying_symbol=underlying)
|
||||||
|
chain = clients.option_data.get_option_chain(req)
|
||||||
|
return dict(chain) if chain else {}
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def get_account_snapshot(clients: Clients) -> dict[str, Any]:
|
||||||
|
acct = clients.trading.get_account()
|
||||||
|
return {
|
||||||
|
"equity": float(getattr(acct, "equity", 0) or 0),
|
||||||
|
"cash": float(getattr(acct, "cash", 0) or 0),
|
||||||
|
"buying_power": float(getattr(acct, "buying_power", 0) or 0),
|
||||||
|
"portfolio_value": float(getattr(acct, "portfolio_value", 0) or 0),
|
||||||
|
"status": str(getattr(acct, "status", "")),
|
||||||
|
"pattern_day_trader": bool(getattr(acct, "pattern_day_trader", False)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def list_positions(clients: Clients) -> list[dict[str, Any]]:
|
||||||
|
pos = clients.trading.get_all_positions() or []
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
for p in pos:
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"symbol": getattr(p, "symbol", ""),
|
||||||
|
"asset_class": str(getattr(p, "asset_class", "")),
|
||||||
|
"qty": float(getattr(p, "qty", 0) or 0),
|
||||||
|
"avg_entry_price": float(getattr(p, "avg_entry_price", 0) or 0),
|
||||||
|
"market_value": float(getattr(p, "market_value", 0) or 0),
|
||||||
|
"unrealized_pl": float(getattr(p, "unrealized_pl", 0) or 0),
|
||||||
|
"unrealized_plpc": float(getattr(p, "unrealized_plpc", 0) or 0),
|
||||||
|
"current_price": float(getattr(p, "current_price", 0) or 0),
|
||||||
|
"side": str(getattr(p, "side", "")),
|
||||||
|
"is_option": str(getattr(p, "asset_class", "")).lower().endswith("option"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def list_recent_orders(clients: Clients, *, limit: int = 100) -> list[dict[str, Any]]:
|
||||||
|
from alpaca.trading.requests import GetOrdersRequest
|
||||||
|
|
||||||
|
req = GetOrdersRequest(status="all", limit=limit, nested=True)
|
||||||
|
orders = clients.trading.get_orders(filter=req) or []
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
for o in orders:
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"id": str(getattr(o, "id", "")),
|
||||||
|
"symbol": getattr(o, "symbol", ""),
|
||||||
|
"side": str(getattr(o, "side", "")),
|
||||||
|
"qty": float(getattr(o, "qty", 0) or 0),
|
||||||
|
"filled_qty": float(getattr(o, "filled_qty", 0) or 0),
|
||||||
|
"order_type": str(getattr(o, "order_type", "")),
|
||||||
|
"status": str(getattr(o, "status", "")),
|
||||||
|
"submitted_at": str(getattr(o, "submitted_at", "")),
|
||||||
|
"filled_avg_price": (
|
||||||
|
float(getattr(o, "filled_avg_price", 0) or 0)
|
||||||
|
if getattr(o, "filled_avg_price", None) is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"asset_class": str(getattr(o, "asset_class", AssetClass.US_EQUITY)),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
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,
|
||||||
|
)
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
"""Translates OrderIntent objects into Alpaca order requests.
|
||||||
|
|
||||||
|
All submissions go through submit_intent(). In dry-run mode it only records
|
||||||
|
the intent to SQLite and logs it; no network call to Alpaca is made.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from alpaca.trading.enums import OrderSide, OrderType, PositionIntent, TimeInForce
|
||||||
|
from alpaca.trading.requests import (
|
||||||
|
LimitOrderRequest,
|
||||||
|
MarketOrderRequest,
|
||||||
|
TrailingStopOrderRequest,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .client import Clients
|
||||||
|
from .config import Config
|
||||||
|
from .logger import get_logger
|
||||||
|
from .state import Store
|
||||||
|
from .strategies.base import OrderIntent
|
||||||
|
|
||||||
|
log = get_logger(__name__)
|
||||||
|
|
||||||
|
_SIDE_MAP = {
|
||||||
|
"buy": OrderSide.BUY,
|
||||||
|
"sell": OrderSide.SELL,
|
||||||
|
"sell_to_open": OrderSide.SELL,
|
||||||
|
"buy_to_close": OrderSide.BUY,
|
||||||
|
}
|
||||||
|
|
||||||
|
_POS_INTENT_MAP = {
|
||||||
|
"sell_to_open": PositionIntent.SELL_TO_OPEN,
|
||||||
|
"buy_to_close": PositionIntent.BUY_TO_CLOSE,
|
||||||
|
"buy": PositionIntent.BUY_TO_OPEN,
|
||||||
|
"sell": PositionIntent.SELL_TO_CLOSE,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_request(intent: OrderIntent) -> Any:
|
||||||
|
side = _SIDE_MAP[intent.side]
|
||||||
|
pos_intent = _POS_INTENT_MAP.get(intent.side)
|
||||||
|
common: dict[str, Any] = {
|
||||||
|
"symbol": intent.symbol,
|
||||||
|
"qty": intent.qty,
|
||||||
|
"side": side,
|
||||||
|
"time_in_force": TimeInForce.DAY,
|
||||||
|
}
|
||||||
|
if pos_intent and intent.side in {"sell_to_open", "buy_to_close"}:
|
||||||
|
common["position_intent"] = pos_intent
|
||||||
|
|
||||||
|
if intent.order_type == "trailing_stop":
|
||||||
|
return TrailingStopOrderRequest(
|
||||||
|
trail_percent=intent.trail_percent,
|
||||||
|
**common,
|
||||||
|
)
|
||||||
|
if intent.order_type == "limit":
|
||||||
|
return LimitOrderRequest(limit_price=intent.limit_price, **common)
|
||||||
|
return MarketOrderRequest(**common)
|
||||||
|
|
||||||
|
|
||||||
|
def submit_intent(
|
||||||
|
intent: OrderIntent,
|
||||||
|
*,
|
||||||
|
cfg: Config,
|
||||||
|
clients: Clients,
|
||||||
|
store: Store,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Record the intent; submit iff BOT_MODE=live. Returns result dict."""
|
||||||
|
if cfg.dry_run:
|
||||||
|
intent_id = store.record_intent(
|
||||||
|
strategy=intent.strategy,
|
||||||
|
symbol=intent.symbol,
|
||||||
|
side=intent.side,
|
||||||
|
qty=intent.qty,
|
||||||
|
order_type=intent.order_type,
|
||||||
|
limit_price=intent.limit_price,
|
||||||
|
stop_price=intent.stop_price,
|
||||||
|
trail_percent=intent.trail_percent,
|
||||||
|
details={**intent.details, "rationale": intent.rationale},
|
||||||
|
submitted=False,
|
||||||
|
status="dry_run",
|
||||||
|
)
|
||||||
|
log.info(
|
||||||
|
"DRY %s %s %s qty=%s %s — %s",
|
||||||
|
intent.strategy,
|
||||||
|
intent.side,
|
||||||
|
intent.symbol,
|
||||||
|
intent.qty,
|
||||||
|
intent.order_type,
|
||||||
|
intent.rationale,
|
||||||
|
)
|
||||||
|
return {"id": intent_id, "submitted": False, "status": "dry_run"}
|
||||||
|
|
||||||
|
req = _build_request(intent)
|
||||||
|
try:
|
||||||
|
order = clients.trading.submit_order(req)
|
||||||
|
alpaca_id = str(getattr(order, "id", ""))
|
||||||
|
status = str(getattr(order, "status", ""))
|
||||||
|
intent_id = store.record_intent(
|
||||||
|
strategy=intent.strategy,
|
||||||
|
symbol=intent.symbol,
|
||||||
|
side=intent.side,
|
||||||
|
qty=intent.qty,
|
||||||
|
order_type=intent.order_type,
|
||||||
|
limit_price=intent.limit_price,
|
||||||
|
stop_price=intent.stop_price,
|
||||||
|
trail_percent=intent.trail_percent,
|
||||||
|
details={**intent.details, "rationale": intent.rationale},
|
||||||
|
submitted=True,
|
||||||
|
alpaca_order_id=alpaca_id,
|
||||||
|
status=status,
|
||||||
|
)
|
||||||
|
log.info(
|
||||||
|
"SUBMIT %s %s %s qty=%s id=%s status=%s",
|
||||||
|
intent.strategy,
|
||||||
|
intent.side,
|
||||||
|
intent.symbol,
|
||||||
|
intent.qty,
|
||||||
|
alpaca_id,
|
||||||
|
status,
|
||||||
|
)
|
||||||
|
return {"id": intent_id, "submitted": True, "alpaca_order_id": alpaca_id, "status": status}
|
||||||
|
except Exception as exc: # noqa: BLE001 — we log everything, nothing is fatal at tick level
|
||||||
|
store.record_intent(
|
||||||
|
strategy=intent.strategy,
|
||||||
|
symbol=intent.symbol,
|
||||||
|
side=intent.side,
|
||||||
|
qty=intent.qty,
|
||||||
|
order_type=intent.order_type,
|
||||||
|
limit_price=intent.limit_price,
|
||||||
|
stop_price=intent.stop_price,
|
||||||
|
trail_percent=intent.trail_percent,
|
||||||
|
details={**intent.details, "rationale": intent.rationale, "error": str(exc)},
|
||||||
|
submitted=False,
|
||||||
|
status="error",
|
||||||
|
)
|
||||||
|
log.exception("submit failed for %s %s: %s", intent.symbol, intent.side, exc)
|
||||||
|
return {"submitted": False, "status": "error", "error": str(exc)}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_CONFIGURED = False
|
||||||
|
|
||||||
|
|
||||||
|
class _JsonlFormatter(logging.Formatter):
|
||||||
|
def format(self, record: logging.LogRecord) -> str:
|
||||||
|
payload = {
|
||||||
|
"ts": datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(),
|
||||||
|
"level": record.levelname,
|
||||||
|
"logger": record.name,
|
||||||
|
"msg": record.getMessage(),
|
||||||
|
}
|
||||||
|
if record.exc_info:
|
||||||
|
payload["exc"] = self.formatException(record.exc_info)
|
||||||
|
extra = getattr(record, "extra_fields", None)
|
||||||
|
if isinstance(extra, dict):
|
||||||
|
payload.update(extra)
|
||||||
|
return json.dumps(payload, default=str)
|
||||||
|
|
||||||
|
|
||||||
|
def setup_logging(log_dir: Path, level: int = logging.INFO) -> None:
|
||||||
|
global _CONFIGURED
|
||||||
|
if _CONFIGURED:
|
||||||
|
return
|
||||||
|
log_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
root = logging.getLogger()
|
||||||
|
root.setLevel(level)
|
||||||
|
root.handlers.clear()
|
||||||
|
|
||||||
|
stderr = logging.StreamHandler(sys.stderr)
|
||||||
|
stderr.setFormatter(logging.Formatter("%(asctime)s %(levelname)-7s %(name)s: %(message)s"))
|
||||||
|
root.addHandler(stderr)
|
||||||
|
|
||||||
|
file_handler = logging.FileHandler(log_dir / "bot.jsonl", encoding="utf-8")
|
||||||
|
file_handler.setFormatter(_JsonlFormatter())
|
||||||
|
root.addHandler(file_handler)
|
||||||
|
|
||||||
|
_CONFIGURED = True
|
||||||
|
|
||||||
|
|
||||||
|
def get_logger(name: str) -> logging.Logger:
|
||||||
|
return logging.getLogger(name)
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""Discord webhook reporting."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from .logger import get_logger
|
||||||
|
|
||||||
|
log = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_money(v: Any) -> str:
|
||||||
|
try:
|
||||||
|
return f"${float(v):,.2f}"
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return str(v)
|
||||||
|
|
||||||
|
|
||||||
|
def build_daily_summary(
|
||||||
|
*,
|
||||||
|
account: dict[str, Any],
|
||||||
|
positions: list[dict[str, Any]],
|
||||||
|
intents_today: list[dict[str, Any]],
|
||||||
|
mode: str,
|
||||||
|
) -> str:
|
||||||
|
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
|
||||||
|
lines: list[str] = [f"**alpaclaudia — Daily Report {today}** (`mode={mode}`)", ""]
|
||||||
|
|
||||||
|
lines.append(
|
||||||
|
f"**Portfolio:** equity {_fmt_money(account.get('equity'))} · "
|
||||||
|
f"cash {_fmt_money(account.get('cash'))} · "
|
||||||
|
f"buying power {_fmt_money(account.get('buying_power'))}"
|
||||||
|
)
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
if positions:
|
||||||
|
lines.append(f"**Positions ({len(positions)}):**")
|
||||||
|
for p in positions[:15]:
|
||||||
|
pl = p.get("unrealized_pl") or 0
|
||||||
|
plpc = (p.get("unrealized_plpc") or 0) * 100
|
||||||
|
lines.append(
|
||||||
|
f" • {p['symbol']} qty={p['qty']:g} "
|
||||||
|
f"mv={_fmt_money(p.get('market_value'))} "
|
||||||
|
f"P&L={_fmt_money(pl)} ({plpc:+.2f}%)"
|
||||||
|
)
|
||||||
|
if len(positions) > 15:
|
||||||
|
lines.append(f" …{len(positions)-15} more")
|
||||||
|
else:
|
||||||
|
lines.append("**Positions:** none")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
submitted = [i for i in intents_today if i.get("submitted")]
|
||||||
|
dry = [i for i in intents_today if not i.get("submitted")]
|
||||||
|
lines.append(
|
||||||
|
f"**Today's orders:** {len(submitted)} submitted · {len(dry)} dry/blocked"
|
||||||
|
)
|
||||||
|
for i in intents_today[:20]:
|
||||||
|
tag = "✓" if i.get("submitted") else "·"
|
||||||
|
details = i.get("details_json")
|
||||||
|
lines.append(
|
||||||
|
f" {tag} [{i['strategy']}] {i['side']} {i['symbol']} qty={i['qty']} "
|
||||||
|
f"type={i['order_type']} status={i.get('status')}"
|
||||||
|
)
|
||||||
|
if len(intents_today) > 20:
|
||||||
|
lines.append(f" …{len(intents_today)-20} more")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def post_to_discord(webhook_url: str, content: str) -> bool:
|
||||||
|
# Discord message limit is 2000 chars; split conservatively.
|
||||||
|
chunks = []
|
||||||
|
buf: list[str] = []
|
||||||
|
size = 0
|
||||||
|
for line in content.splitlines(keepends=True):
|
||||||
|
if size + len(line) > 1900 and buf:
|
||||||
|
chunks.append("".join(buf))
|
||||||
|
buf, size = [], 0
|
||||||
|
buf.append(line)
|
||||||
|
size += len(line)
|
||||||
|
if buf:
|
||||||
|
chunks.append("".join(buf))
|
||||||
|
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=15.0) as c:
|
||||||
|
for chunk in chunks:
|
||||||
|
r = c.post(webhook_url, json={"content": chunk})
|
||||||
|
r.raise_for_status()
|
||||||
|
return True
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log.exception("discord webhook failed: %s", exc)
|
||||||
|
return False
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"""Risk gates — every order passes through check_intent() before submission.
|
||||||
|
|
||||||
|
The core invariants this enforces:
|
||||||
|
|
||||||
|
- Cash-secured puts must actually be *cash-secured*: strike*100*qty ≤ free cash
|
||||||
|
(minus the required buffer).
|
||||||
|
- Covered calls cannot be sold below cost basis (strike ≥ avg_entry_price).
|
||||||
|
- Per-symbol concentration capped at MAX_POSITION_PCT of equity.
|
||||||
|
- The bot will never *short* stock, only sell what it already owns.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .config import RiskConfig
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CheckResult:
|
||||||
|
ok: bool
|
||||||
|
reason: str = "ok"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AccountSnapshot:
|
||||||
|
equity: float
|
||||||
|
cash: float
|
||||||
|
buying_power: float
|
||||||
|
|
||||||
|
|
||||||
|
def _position_for(symbol: str, positions: list[dict[str, Any]]) -> dict[str, Any] | None:
|
||||||
|
for p in positions:
|
||||||
|
if p.get("symbol") == symbol:
|
||||||
|
return p
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def check_csp(
|
||||||
|
*,
|
||||||
|
underlying: str,
|
||||||
|
strike: float,
|
||||||
|
qty: int,
|
||||||
|
account: AccountSnapshot,
|
||||||
|
existing_short_puts: int,
|
||||||
|
max_short_puts_per_symbol: int,
|
||||||
|
risk_cfg: RiskConfig,
|
||||||
|
) -> CheckResult:
|
||||||
|
if qty <= 0:
|
||||||
|
return CheckResult(False, "qty must be positive")
|
||||||
|
if strike <= 0:
|
||||||
|
return CheckResult(False, "invalid strike")
|
||||||
|
if existing_short_puts + qty > max_short_puts_per_symbol:
|
||||||
|
return CheckResult(
|
||||||
|
False,
|
||||||
|
f"would exceed max_short_puts_per_symbol={max_short_puts_per_symbol}",
|
||||||
|
)
|
||||||
|
collateral = strike * 100 * qty
|
||||||
|
buffer_floor = account.equity * risk_cfg.min_cash_buffer_pct
|
||||||
|
if collateral > (account.cash - buffer_floor):
|
||||||
|
return CheckResult(
|
||||||
|
False,
|
||||||
|
f"insufficient cash: need {collateral:.2f}, have {account.cash:.2f} "
|
||||||
|
f"(buffer floor {buffer_floor:.2f})",
|
||||||
|
)
|
||||||
|
position_notional = collateral
|
||||||
|
if position_notional > account.equity * risk_cfg.max_position_pct:
|
||||||
|
return CheckResult(
|
||||||
|
False,
|
||||||
|
f"collateral {collateral:.0f} exceeds per-symbol cap "
|
||||||
|
f"({risk_cfg.max_position_pct*100:.0f}% of equity)",
|
||||||
|
)
|
||||||
|
return CheckResult(True)
|
||||||
|
|
||||||
|
|
||||||
|
def check_covered_call(
|
||||||
|
*,
|
||||||
|
underlying: str,
|
||||||
|
strike: float,
|
||||||
|
qty: int,
|
||||||
|
positions: list[dict[str, Any]],
|
||||||
|
) -> CheckResult:
|
||||||
|
if qty <= 0:
|
||||||
|
return CheckResult(False, "qty must be positive")
|
||||||
|
pos = _position_for(underlying, positions)
|
||||||
|
if not pos or pos.get("qty", 0) < qty * 100:
|
||||||
|
return CheckResult(
|
||||||
|
False,
|
||||||
|
f"no long stock to cover ({qty*100} shares required)",
|
||||||
|
)
|
||||||
|
cost_basis = pos.get("avg_entry_price") or 0
|
||||||
|
if strike < cost_basis:
|
||||||
|
return CheckResult(
|
||||||
|
False,
|
||||||
|
f"strike {strike:.2f} below cost basis {cost_basis:.2f} — would lock in loss",
|
||||||
|
)
|
||||||
|
return CheckResult(True)
|
||||||
|
|
||||||
|
|
||||||
|
def check_trailing_stop(
|
||||||
|
*,
|
||||||
|
symbol: str,
|
||||||
|
qty: float,
|
||||||
|
positions: list[dict[str, Any]],
|
||||||
|
) -> CheckResult:
|
||||||
|
pos = _position_for(symbol, positions)
|
||||||
|
if not pos or pos.get("qty", 0) < qty:
|
||||||
|
return CheckResult(False, "no long stock to trail")
|
||||||
|
return CheckResult(True)
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
"""Single tick + loop driver. Pure orchestration — no strategy logic here."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from datetime import datetime, time as dtime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .client import (
|
||||||
|
Clients,
|
||||||
|
build_clients,
|
||||||
|
get_account_snapshot,
|
||||||
|
list_positions,
|
||||||
|
list_recent_orders,
|
||||||
|
)
|
||||||
|
from .config import Config
|
||||||
|
from .logger import get_logger
|
||||||
|
from .executor import submit_intent
|
||||||
|
from .risk import (
|
||||||
|
AccountSnapshot,
|
||||||
|
check_covered_call,
|
||||||
|
check_csp,
|
||||||
|
check_trailing_stop,
|
||||||
|
)
|
||||||
|
from .state import Store
|
||||||
|
from .strategies.trailing_stop import plan_trailing_stops
|
||||||
|
from .strategies.wheel import plan_wheel
|
||||||
|
|
||||||
|
log = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_market_open(clients: Clients) -> bool:
|
||||||
|
try:
|
||||||
|
clock = clients.trading.get_clock()
|
||||||
|
return bool(getattr(clock, "is_open", False))
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log.warning("clock check failed: %s", exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _enforce_risk(
|
||||||
|
intent,
|
||||||
|
*,
|
||||||
|
account_snap: AccountSnapshot,
|
||||||
|
positions: list[dict[str, Any]],
|
||||||
|
cfg: Config,
|
||||||
|
) -> tuple[bool, str]:
|
||||||
|
if intent.strategy == "wheel:cash_secured_put":
|
||||||
|
existing = sum(
|
||||||
|
1 for p in positions if p["is_option"] and p["qty"] < 0 and "P" in p["symbol"]
|
||||||
|
)
|
||||||
|
r = check_csp(
|
||||||
|
underlying=intent.details.get("underlying", ""),
|
||||||
|
strike=float(intent.details.get("strike") or 0),
|
||||||
|
qty=int(intent.qty),
|
||||||
|
account=account_snap,
|
||||||
|
existing_short_puts=existing,
|
||||||
|
max_short_puts_per_symbol=cfg.wheel.max_short_puts_per_symbol,
|
||||||
|
risk_cfg=cfg.risk,
|
||||||
|
)
|
||||||
|
return r.ok, r.reason
|
||||||
|
if intent.strategy == "wheel:covered_call":
|
||||||
|
r = check_covered_call(
|
||||||
|
underlying=intent.details.get("underlying", ""),
|
||||||
|
strike=float(intent.details.get("strike") or 0),
|
||||||
|
qty=int(intent.qty),
|
||||||
|
positions=positions,
|
||||||
|
)
|
||||||
|
return r.ok, r.reason
|
||||||
|
if intent.strategy == "trailing_stop":
|
||||||
|
r = check_trailing_stop(
|
||||||
|
symbol=intent.symbol, qty=float(intent.qty), positions=positions
|
||||||
|
)
|
||||||
|
return r.ok, r.reason
|
||||||
|
return True, "ok"
|
||||||
|
|
||||||
|
|
||||||
|
def tick(cfg: Config, clients: Clients, store: Store) -> dict[str, Any]:
|
||||||
|
t0 = time.time()
|
||||||
|
account = get_account_snapshot(clients)
|
||||||
|
positions = list_positions(clients)
|
||||||
|
orders = list_recent_orders(clients, limit=100)
|
||||||
|
account_snap = AccountSnapshot(
|
||||||
|
equity=account["equity"],
|
||||||
|
cash=account["cash"],
|
||||||
|
buying_power=account["buying_power"],
|
||||||
|
)
|
||||||
|
|
||||||
|
store.record_tick(
|
||||||
|
equity=account["equity"],
|
||||||
|
cash=account["cash"],
|
||||||
|
buying_power=account["buying_power"],
|
||||||
|
mode=cfg.mode,
|
||||||
|
)
|
||||||
|
|
||||||
|
intents = []
|
||||||
|
intents.extend(
|
||||||
|
plan_wheel(
|
||||||
|
cfg=cfg,
|
||||||
|
clients=clients,
|
||||||
|
account=account,
|
||||||
|
positions=positions,
|
||||||
|
orders=orders,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
intents.extend(
|
||||||
|
plan_trailing_stops(
|
||||||
|
cfg=cfg,
|
||||||
|
clients=clients,
|
||||||
|
account=account,
|
||||||
|
positions=positions,
|
||||||
|
orders=orders,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
submitted = 0
|
||||||
|
blocked = 0
|
||||||
|
for intent in intents:
|
||||||
|
ok, reason = _enforce_risk(
|
||||||
|
intent, account_snap=account_snap, positions=positions, cfg=cfg
|
||||||
|
)
|
||||||
|
if not ok:
|
||||||
|
blocked += 1
|
||||||
|
store.record_intent(
|
||||||
|
strategy=intent.strategy,
|
||||||
|
symbol=intent.symbol,
|
||||||
|
side=intent.side,
|
||||||
|
qty=intent.qty,
|
||||||
|
order_type=intent.order_type,
|
||||||
|
limit_price=intent.limit_price,
|
||||||
|
stop_price=intent.stop_price,
|
||||||
|
trail_percent=intent.trail_percent,
|
||||||
|
details={
|
||||||
|
**intent.details,
|
||||||
|
"rationale": intent.rationale,
|
||||||
|
"blocked_reason": reason,
|
||||||
|
},
|
||||||
|
submitted=False,
|
||||||
|
status="blocked",
|
||||||
|
)
|
||||||
|
log.warning("BLOCKED %s %s: %s", intent.strategy, intent.symbol, reason)
|
||||||
|
continue
|
||||||
|
submit_intent(intent, cfg=cfg, clients=clients, store=store)
|
||||||
|
submitted += 1
|
||||||
|
|
||||||
|
dt = time.time() - t0
|
||||||
|
store.record_event(
|
||||||
|
"tick",
|
||||||
|
{
|
||||||
|
"duration_s": round(dt, 3),
|
||||||
|
"intents": len(intents),
|
||||||
|
"submitted": submitted,
|
||||||
|
"blocked": blocked,
|
||||||
|
"positions": len(positions),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
log.info(
|
||||||
|
"tick: %.2fs intents=%d submitted=%d blocked=%d mode=%s",
|
||||||
|
dt,
|
||||||
|
len(intents),
|
||||||
|
submitted,
|
||||||
|
blocked,
|
||||||
|
cfg.mode,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"intents": len(intents),
|
||||||
|
"submitted": submitted,
|
||||||
|
"blocked": blocked,
|
||||||
|
"duration_s": dt,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def loop(cfg: Config, clients: Clients, store: Store) -> None:
|
||||||
|
"""Poll-loop. Skips ticks when market is closed (paper or not).
|
||||||
|
|
||||||
|
Market hours only — outside hours we still emit a heartbeat to the state DB
|
||||||
|
so the dashboard can see we're alive, but we don't plan orders.
|
||||||
|
"""
|
||||||
|
interval = max(60, cfg.tick_interval)
|
||||||
|
log.info("loop start: interval=%ss mode=%s", interval, cfg.mode)
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
if _is_market_open(clients):
|
||||||
|
tick(cfg, clients, store)
|
||||||
|
else:
|
||||||
|
store.record_event("heartbeat", {"market_open": False})
|
||||||
|
log.info("market closed; sleeping")
|
||||||
|
except Exception as exc: # noqa: BLE001 — never die inside the loop
|
||||||
|
log.exception("tick error: %s", exc)
|
||||||
|
store.record_event("error", {"message": str(exc)})
|
||||||
|
time.sleep(interval)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["tick", "loop", "build_clients"]
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Iterator
|
||||||
|
|
||||||
|
SCHEMA = """
|
||||||
|
CREATE TABLE IF NOT EXISTS ticks (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
ts TEXT NOT NULL,
|
||||||
|
equity REAL,
|
||||||
|
cash REAL,
|
||||||
|
buying_power REAL,
|
||||||
|
mode TEXT NOT NULL,
|
||||||
|
note TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS order_intents (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
ts TEXT NOT NULL,
|
||||||
|
strategy TEXT NOT NULL,
|
||||||
|
symbol TEXT NOT NULL,
|
||||||
|
side TEXT NOT NULL,
|
||||||
|
qty REAL NOT NULL,
|
||||||
|
order_type TEXT NOT NULL,
|
||||||
|
limit_price REAL,
|
||||||
|
stop_price REAL,
|
||||||
|
trail_percent REAL,
|
||||||
|
details_json TEXT,
|
||||||
|
submitted INTEGER NOT NULL DEFAULT 0,
|
||||||
|
alpaca_order_id TEXT,
|
||||||
|
status TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS events (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
ts TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
payload_json TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_order_intents_ts ON order_intents (ts);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_events_ts ON events (ts);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class Store:
|
||||||
|
def __init__(self, path: Path) -> None:
|
||||||
|
self.path = path
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with self._conn() as c:
|
||||||
|
c.executescript(SCHEMA)
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _conn(self) -> Iterator[sqlite3.Connection]:
|
||||||
|
conn = sqlite3.connect(self.path)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
try:
|
||||||
|
yield conn
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _now() -> str:
|
||||||
|
return datetime.now(tz=timezone.utc).isoformat()
|
||||||
|
|
||||||
|
def record_tick(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
equity: float | None,
|
||||||
|
cash: float | None,
|
||||||
|
buying_power: float | None,
|
||||||
|
mode: str,
|
||||||
|
note: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
with self._conn() as c:
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO ticks (ts, equity, cash, buying_power, mode, note) VALUES (?,?,?,?,?,?)",
|
||||||
|
(self._now(), equity, cash, buying_power, mode, note),
|
||||||
|
)
|
||||||
|
|
||||||
|
def record_intent(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
strategy: str,
|
||||||
|
symbol: str,
|
||||||
|
side: str,
|
||||||
|
qty: float,
|
||||||
|
order_type: str,
|
||||||
|
limit_price: float | None = None,
|
||||||
|
stop_price: float | None = None,
|
||||||
|
trail_percent: float | None = None,
|
||||||
|
details: dict[str, Any] | None = None,
|
||||||
|
submitted: bool = False,
|
||||||
|
alpaca_order_id: str | None = None,
|
||||||
|
status: str | None = None,
|
||||||
|
) -> int:
|
||||||
|
with self._conn() as c:
|
||||||
|
cur = c.execute(
|
||||||
|
"""INSERT INTO order_intents
|
||||||
|
(ts, strategy, symbol, side, qty, order_type, limit_price, stop_price,
|
||||||
|
trail_percent, details_json, submitted, alpaca_order_id, status)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||||
|
(
|
||||||
|
self._now(),
|
||||||
|
strategy,
|
||||||
|
symbol,
|
||||||
|
side,
|
||||||
|
qty,
|
||||||
|
order_type,
|
||||||
|
limit_price,
|
||||||
|
stop_price,
|
||||||
|
trail_percent,
|
||||||
|
json.dumps(details or {}, default=str),
|
||||||
|
1 if submitted else 0,
|
||||||
|
alpaca_order_id,
|
||||||
|
status,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return int(cur.lastrowid)
|
||||||
|
|
||||||
|
def record_event(self, kind: str, payload: dict[str, Any]) -> None:
|
||||||
|
with self._conn() as c:
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO events (ts, kind, payload_json) VALUES (?,?,?)",
|
||||||
|
(self._now(), kind, json.dumps(payload, default=str)),
|
||||||
|
)
|
||||||
|
|
||||||
|
def recent_ticks(self, limit: int = 200) -> list[dict[str, Any]]:
|
||||||
|
with self._conn() as c:
|
||||||
|
rows = c.execute(
|
||||||
|
"SELECT * FROM ticks ORDER BY id DESC LIMIT ?", (limit,)
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
def recent_intents(self, limit: int = 200) -> list[dict[str, Any]]:
|
||||||
|
with self._conn() as c:
|
||||||
|
rows = c.execute(
|
||||||
|
"SELECT * FROM order_intents ORDER BY id DESC LIMIT ?", (limit,)
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
def intents_since(self, since_iso: str) -> list[dict[str, Any]]:
|
||||||
|
with self._conn() as c:
|
||||||
|
rows = c.execute(
|
||||||
|
"SELECT * FROM order_intents WHERE ts >= ? ORDER BY id ASC", (since_iso,)
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
"""Strategy modules. Each emits zero-or-more OrderIntent objects per tick."""
|
||||||
|
from .base import OrderIntent, Strategy
|
||||||
|
|
||||||
|
__all__ = ["OrderIntent", "Strategy"]
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Protocol
|
||||||
|
|
||||||
|
from ..client import Clients
|
||||||
|
from ..config import Config
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class OrderIntent:
|
||||||
|
strategy: str
|
||||||
|
symbol: str # OCC symbol for options, ticker for equity
|
||||||
|
side: str # "buy" | "sell" | "sell_to_open" | "buy_to_close"
|
||||||
|
qty: float
|
||||||
|
order_type: str # "market" | "limit" | "trailing_stop"
|
||||||
|
limit_price: float | None = None
|
||||||
|
stop_price: float | None = None
|
||||||
|
trail_percent: float | None = None
|
||||||
|
details: dict[str, Any] = field(default_factory=dict)
|
||||||
|
rationale: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class Strategy(Protocol):
|
||||||
|
name: str
|
||||||
|
|
||||||
|
def plan(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
cfg: Config,
|
||||||
|
clients: Clients,
|
||||||
|
account: dict[str, Any],
|
||||||
|
positions: list[dict[str, Any]],
|
||||||
|
orders: list[dict[str, Any]],
|
||||||
|
) -> list[OrderIntent]:
|
||||||
|
...
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""Trailing stops on long equity positions.
|
||||||
|
|
||||||
|
For each stock position we intend to submit a trailing_stop sell order with
|
||||||
|
TRAILING_STOP_PCT trail. On each tick we check open orders and only submit
|
||||||
|
if none exists for that symbol (Alpaca rejects duplicate open stops).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from ..config import Config
|
||||||
|
from ..logger import get_logger
|
||||||
|
from .base import OrderIntent
|
||||||
|
|
||||||
|
log = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_open_trailing_stop(symbol: str, orders: list[dict[str, Any]]) -> bool:
|
||||||
|
for o in orders:
|
||||||
|
if (
|
||||||
|
o.get("symbol") == symbol
|
||||||
|
and "trailing" in (o.get("order_type") or "").lower()
|
||||||
|
and (o.get("status") or "").lower() in {"new", "accepted", "open", "pending_new", "held"}
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def plan_trailing_stops(
|
||||||
|
*,
|
||||||
|
cfg: Config,
|
||||||
|
clients: Any,
|
||||||
|
account: dict[str, Any],
|
||||||
|
positions: list[dict[str, Any]],
|
||||||
|
orders: list[dict[str, Any]],
|
||||||
|
) -> list[OrderIntent]:
|
||||||
|
if not cfg.risk.trailing_stop_enabled:
|
||||||
|
return []
|
||||||
|
|
||||||
|
intents: list[OrderIntent] = []
|
||||||
|
trail_pct = cfg.risk.trailing_stop_pct * 100 # Alpaca expects percent as "3" for 3%
|
||||||
|
for p in positions:
|
||||||
|
if p.get("is_option"):
|
||||||
|
continue
|
||||||
|
if p.get("qty", 0) <= 0:
|
||||||
|
continue
|
||||||
|
sym = p["symbol"]
|
||||||
|
if _has_open_trailing_stop(sym, orders):
|
||||||
|
continue
|
||||||
|
intents.append(
|
||||||
|
OrderIntent(
|
||||||
|
strategy="trailing_stop",
|
||||||
|
symbol=sym,
|
||||||
|
side="sell",
|
||||||
|
qty=float(p["qty"]),
|
||||||
|
order_type="trailing_stop",
|
||||||
|
trail_percent=trail_pct,
|
||||||
|
details={
|
||||||
|
"avg_entry_price": p.get("avg_entry_price"),
|
||||||
|
"current_price": p.get("current_price"),
|
||||||
|
"market_value": p.get("market_value"),
|
||||||
|
"unrealized_pl": p.get("unrealized_pl"),
|
||||||
|
},
|
||||||
|
rationale=(
|
||||||
|
f"trailing stop {trail_pct:.1f}% on {p['qty']} {sym} "
|
||||||
|
f"(entry {p.get('avg_entry_price')}, now {p.get('current_price')})"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return intents
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
"""Wheel strategy.
|
||||||
|
|
||||||
|
Phase 1 (no long stock): sell cash-secured puts ~target-delta, 2-4 weeks out,
|
||||||
|
meeting a minimum annualised yield.
|
||||||
|
|
||||||
|
Phase 2 (assigned, holding 100+ shares): sell covered calls above cost basis.
|
||||||
|
|
||||||
|
Contract selection is intentionally conservative:
|
||||||
|
- Only contracts within the DTE window.
|
||||||
|
- Prefer the contract whose absolute delta is closest to the target.
|
||||||
|
If greeks are missing, fall back to closest strike within ±OTM window.
|
||||||
|
- Annualised yield = (premium / collateral) * (365 / DTE).
|
||||||
|
Reject if below min_annual_yield.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from ..client import (
|
||||||
|
Clients,
|
||||||
|
get_latest_spot,
|
||||||
|
get_option_snapshots,
|
||||||
|
list_option_contracts,
|
||||||
|
)
|
||||||
|
from ..config import Config
|
||||||
|
from ..logger import get_logger
|
||||||
|
from .base import OrderIntent
|
||||||
|
|
||||||
|
log = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _dte(exp: Any) -> int:
|
||||||
|
if isinstance(exp, str):
|
||||||
|
exp = date.fromisoformat(exp[:10])
|
||||||
|
return (exp - date.today()).days
|
||||||
|
|
||||||
|
|
||||||
|
def _mid_from_snapshot(snap: Any) -> float | None:
|
||||||
|
if snap is None:
|
||||||
|
return None
|
||||||
|
q = getattr(snap, "latest_quote", None) or getattr(snap, "quote", None)
|
||||||
|
if q is None:
|
||||||
|
return None
|
||||||
|
bid = getattr(q, "bid_price", None) or 0
|
||||||
|
ask = getattr(q, "ask_price", None) or 0
|
||||||
|
if bid and ask:
|
||||||
|
return (bid + ask) / 2
|
||||||
|
return bid or ask or None
|
||||||
|
|
||||||
|
|
||||||
|
def _delta_from_snapshot(snap: Any) -> float | None:
|
||||||
|
greeks = getattr(snap, "greeks", None)
|
||||||
|
if greeks is None:
|
||||||
|
return None
|
||||||
|
d = getattr(greeks, "delta", None)
|
||||||
|
return float(d) if d is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
def _annualised_yield(premium: float, strike: float, dte: int) -> float:
|
||||||
|
if dte <= 0 or strike <= 0:
|
||||||
|
return 0.0
|
||||||
|
collateral = strike * 100
|
||||||
|
return (premium * 100 / collateral) * (365 / dte)
|
||||||
|
|
||||||
|
|
||||||
|
def _pick_contract(
|
||||||
|
contracts: list[Any],
|
||||||
|
snapshots: dict[str, Any],
|
||||||
|
*,
|
||||||
|
target_delta: float,
|
||||||
|
spot: float,
|
||||||
|
otm_pct: float,
|
||||||
|
option_type: str,
|
||||||
|
) -> tuple[Any, dict[str, Any]] | None:
|
||||||
|
best: tuple[Any, dict[str, Any]] | None = None
|
||||||
|
best_score = float("inf")
|
||||||
|
for c in contracts:
|
||||||
|
sym = getattr(c, "symbol", None)
|
||||||
|
if not sym:
|
||||||
|
continue
|
||||||
|
strike = float(getattr(c, "strike_price", 0) or 0)
|
||||||
|
if strike <= 0:
|
||||||
|
continue
|
||||||
|
snap = snapshots.get(sym)
|
||||||
|
delta = _delta_from_snapshot(snap)
|
||||||
|
mid = _mid_from_snapshot(snap)
|
||||||
|
dte = _dte(getattr(c, "expiration_date", None))
|
||||||
|
if dte <= 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if delta is not None:
|
||||||
|
score = abs(abs(delta) - target_delta)
|
||||||
|
else:
|
||||||
|
# fallback: distance from target OTM strike
|
||||||
|
target_strike = (
|
||||||
|
spot * (1 - otm_pct) if option_type == "put" else spot * (1 + otm_pct)
|
||||||
|
)
|
||||||
|
score = abs(strike - target_strike) / max(spot, 1)
|
||||||
|
|
||||||
|
info = {
|
||||||
|
"symbol": sym,
|
||||||
|
"strike": strike,
|
||||||
|
"expiration": str(getattr(c, "expiration_date", "")),
|
||||||
|
"dte": dte,
|
||||||
|
"delta": delta,
|
||||||
|
"premium_mid": mid,
|
||||||
|
"open_interest": getattr(c, "open_interest", None),
|
||||||
|
}
|
||||||
|
if score < best_score:
|
||||||
|
best_score = score
|
||||||
|
best = (c, info)
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
def plan_wheel(
|
||||||
|
*,
|
||||||
|
cfg: Config,
|
||||||
|
clients: Clients,
|
||||||
|
account: dict[str, Any],
|
||||||
|
positions: list[dict[str, Any]],
|
||||||
|
orders: list[dict[str, Any]],
|
||||||
|
) -> list[OrderIntent]:
|
||||||
|
intents: list[OrderIntent] = []
|
||||||
|
equity = float(account.get("equity") or 0)
|
||||||
|
cash = float(account.get("cash") or 0)
|
||||||
|
|
||||||
|
for underlying in cfg.wheel.universe:
|
||||||
|
stock_pos = next(
|
||||||
|
(p for p in positions if p["symbol"] == underlying and not p["is_option"]),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
short_put_count = sum(
|
||||||
|
1
|
||||||
|
for p in positions
|
||||||
|
if p["is_option"]
|
||||||
|
and p["symbol"].startswith(underlying)
|
||||||
|
and "P" in p["symbol"]
|
||||||
|
and p["qty"] < 0
|
||||||
|
)
|
||||||
|
|
||||||
|
spot = get_latest_spot(clients, underlying)
|
||||||
|
if spot is None:
|
||||||
|
log.warning("no spot for %s; skipping", underlying)
|
||||||
|
continue
|
||||||
|
|
||||||
|
snapshots = get_option_snapshots(clients, underlying)
|
||||||
|
|
||||||
|
shares = int(stock_pos["qty"]) if stock_pos else 0
|
||||||
|
lots_coverable = shares // 100
|
||||||
|
|
||||||
|
if lots_coverable >= 1:
|
||||||
|
# Phase 2: covered calls on every uncovered lot
|
||||||
|
short_call_count = sum(
|
||||||
|
1
|
||||||
|
for p in positions
|
||||||
|
if p["is_option"]
|
||||||
|
and p["symbol"].startswith(underlying)
|
||||||
|
and "C" in p["symbol"]
|
||||||
|
and p["qty"] < 0
|
||||||
|
)
|
||||||
|
uncovered = lots_coverable - short_call_count
|
||||||
|
if uncovered > 0:
|
||||||
|
contracts = list_option_contracts(
|
||||||
|
clients,
|
||||||
|
underlying,
|
||||||
|
option_type="call",
|
||||||
|
dte_min=cfg.wheel.put_dte_min,
|
||||||
|
dte_max=cfg.wheel.put_dte_max,
|
||||||
|
)
|
||||||
|
# target delta for calls: mirror (positive)
|
||||||
|
picked = _pick_contract(
|
||||||
|
contracts,
|
||||||
|
snapshots,
|
||||||
|
target_delta=cfg.wheel.put_target_delta,
|
||||||
|
spot=spot,
|
||||||
|
otm_pct=cfg.wheel.call_otm_pct,
|
||||||
|
option_type="call",
|
||||||
|
)
|
||||||
|
if picked is not None:
|
||||||
|
contract, info = picked
|
||||||
|
cost_basis = float(stock_pos["avg_entry_price"]) if stock_pos else 0
|
||||||
|
if info["strike"] < cost_basis:
|
||||||
|
log.info(
|
||||||
|
"%s covered-call candidate strike %.2f below cost basis %.2f — skip",
|
||||||
|
underlying,
|
||||||
|
info["strike"],
|
||||||
|
cost_basis,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
intents.append(
|
||||||
|
OrderIntent(
|
||||||
|
strategy="wheel:covered_call",
|
||||||
|
symbol=info["symbol"],
|
||||||
|
side="sell_to_open",
|
||||||
|
qty=min(uncovered, 1),
|
||||||
|
order_type="limit" if info["premium_mid"] else "market",
|
||||||
|
limit_price=info["premium_mid"],
|
||||||
|
details={**info, "underlying": underlying, "spot": spot},
|
||||||
|
rationale=(
|
||||||
|
f"cover {uncovered} lot(s) of {underlying} @ "
|
||||||
|
f"{info['strike']:.2f} ({info['dte']}dte)"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Phase 1: sell CSPs if room remains
|
||||||
|
if short_put_count < cfg.wheel.max_short_puts_per_symbol:
|
||||||
|
contracts = list_option_contracts(
|
||||||
|
clients,
|
||||||
|
underlying,
|
||||||
|
option_type="put",
|
||||||
|
dte_min=cfg.wheel.put_dte_min,
|
||||||
|
dte_max=cfg.wheel.put_dte_max,
|
||||||
|
)
|
||||||
|
picked = _pick_contract(
|
||||||
|
contracts,
|
||||||
|
snapshots,
|
||||||
|
target_delta=cfg.wheel.put_target_delta,
|
||||||
|
spot=spot,
|
||||||
|
otm_pct=cfg.wheel.put_otm_pct,
|
||||||
|
option_type="put",
|
||||||
|
)
|
||||||
|
if picked is None:
|
||||||
|
continue
|
||||||
|
contract, info = picked
|
||||||
|
premium = info["premium_mid"] or 0
|
||||||
|
yield_ann = _annualised_yield(premium, info["strike"], max(info["dte"], 1))
|
||||||
|
info["annualised_yield"] = yield_ann
|
||||||
|
if yield_ann < cfg.wheel.min_annual_yield:
|
||||||
|
log.info(
|
||||||
|
"%s CSP candidate yield %.2f%% < %.2f%% — skip",
|
||||||
|
underlying,
|
||||||
|
yield_ann * 100,
|
||||||
|
cfg.wheel.min_annual_yield * 100,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
intents.append(
|
||||||
|
OrderIntent(
|
||||||
|
strategy="wheel:cash_secured_put",
|
||||||
|
symbol=info["symbol"],
|
||||||
|
side="sell_to_open",
|
||||||
|
qty=1,
|
||||||
|
order_type="limit" if premium else "market",
|
||||||
|
limit_price=premium or None,
|
||||||
|
details={**info, "underlying": underlying, "spot": spot, "cash": cash, "equity": equity},
|
||||||
|
rationale=(
|
||||||
|
f"CSP {underlying} @ {info['strike']:.2f} "
|
||||||
|
f"({info['dte']}dte, Δ={info['delta']}, ann.yield={yield_ann*100:.1f}%)"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return intents
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
[project]
|
||||||
|
name = "alpaclaudia"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Alpaca Paper-Trading Bot — Wheel strategy + equity trailing stops."
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"alpaca-py>=0.43.0",
|
||||||
|
"python-dotenv>=1.0.1",
|
||||||
|
"httpx>=0.27.0",
|
||||||
|
"pydantic>=2.7.0",
|
||||||
|
"typer>=0.12.0",
|
||||||
|
"rich>=13.7.0",
|
||||||
|
"pandas>=2.2.0",
|
||||||
|
"pytz>=2024.1",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.0",
|
||||||
|
"ruff>=0.5",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
alpaclaudia = "alpaclaudia.__main__:app"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["alpaclaudia"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
target-version = "py311"
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
from alpaclaudia.config import RiskConfig
|
||||||
|
from alpaclaudia.risk import (
|
||||||
|
AccountSnapshot,
|
||||||
|
check_covered_call,
|
||||||
|
check_csp,
|
||||||
|
check_trailing_stop,
|
||||||
|
)
|
||||||
|
|
||||||
|
RISK = RiskConfig(
|
||||||
|
max_position_pct=0.25,
|
||||||
|
min_cash_buffer_pct=0.05,
|
||||||
|
trailing_stop_enabled=True,
|
||||||
|
trailing_stop_pct=0.08,
|
||||||
|
)
|
||||||
|
|
||||||
|
ACCT = AccountSnapshot(equity=100_000, cash=50_000, buying_power=50_000)
|
||||||
|
|
||||||
|
|
||||||
|
def test_csp_ok():
|
||||||
|
r = check_csp(
|
||||||
|
underlying="TSLA",
|
||||||
|
strike=200.0,
|
||||||
|
qty=1,
|
||||||
|
account=ACCT,
|
||||||
|
existing_short_puts=0,
|
||||||
|
max_short_puts_per_symbol=1,
|
||||||
|
risk_cfg=RISK,
|
||||||
|
)
|
||||||
|
assert r.ok, r.reason
|
||||||
|
|
||||||
|
|
||||||
|
def test_csp_blocked_by_cash_buffer():
|
||||||
|
r = check_csp(
|
||||||
|
underlying="TSLA",
|
||||||
|
strike=600.0, # 60k collateral, only 50k cash
|
||||||
|
qty=1,
|
||||||
|
account=ACCT,
|
||||||
|
existing_short_puts=0,
|
||||||
|
max_short_puts_per_symbol=1,
|
||||||
|
risk_cfg=RISK,
|
||||||
|
)
|
||||||
|
assert not r.ok
|
||||||
|
|
||||||
|
|
||||||
|
def test_csp_blocked_by_position_cap():
|
||||||
|
# 25% of 100k equity = 25k; strike 300 * 100 = 30k collateral
|
||||||
|
r = check_csp(
|
||||||
|
underlying="TSLA",
|
||||||
|
strike=300.0,
|
||||||
|
qty=1,
|
||||||
|
account=ACCT,
|
||||||
|
existing_short_puts=0,
|
||||||
|
max_short_puts_per_symbol=1,
|
||||||
|
risk_cfg=RISK,
|
||||||
|
)
|
||||||
|
assert not r.ok
|
||||||
|
assert "cap" in r.reason or "exceeds" in r.reason
|
||||||
|
|
||||||
|
|
||||||
|
def test_csp_blocked_by_concurrency():
|
||||||
|
r = check_csp(
|
||||||
|
underlying="TSLA",
|
||||||
|
strike=200.0,
|
||||||
|
qty=1,
|
||||||
|
account=ACCT,
|
||||||
|
existing_short_puts=1,
|
||||||
|
max_short_puts_per_symbol=1,
|
||||||
|
risk_cfg=RISK,
|
||||||
|
)
|
||||||
|
assert not r.ok
|
||||||
|
|
||||||
|
|
||||||
|
def test_covered_call_blocks_below_cost_basis():
|
||||||
|
positions = [
|
||||||
|
{"symbol": "TSLA", "qty": 200, "avg_entry_price": 250.0, "is_option": False}
|
||||||
|
]
|
||||||
|
r = check_covered_call(
|
||||||
|
underlying="TSLA",
|
||||||
|
strike=240.0,
|
||||||
|
qty=1,
|
||||||
|
positions=positions,
|
||||||
|
)
|
||||||
|
assert not r.ok
|
||||||
|
|
||||||
|
|
||||||
|
def test_covered_call_requires_shares():
|
||||||
|
r = check_covered_call(
|
||||||
|
underlying="TSLA",
|
||||||
|
strike=300.0,
|
||||||
|
qty=1,
|
||||||
|
positions=[],
|
||||||
|
)
|
||||||
|
assert not r.ok
|
||||||
|
|
||||||
|
|
||||||
|
def test_covered_call_ok():
|
||||||
|
positions = [
|
||||||
|
{"symbol": "TSLA", "qty": 200, "avg_entry_price": 250.0, "is_option": False}
|
||||||
|
]
|
||||||
|
r = check_covered_call(
|
||||||
|
underlying="TSLA",
|
||||||
|
strike=275.0,
|
||||||
|
qty=2,
|
||||||
|
positions=positions,
|
||||||
|
)
|
||||||
|
assert r.ok
|
||||||
|
|
||||||
|
|
||||||
|
def test_trailing_stop_needs_long_position():
|
||||||
|
r = check_trailing_stop(symbol="TSLA", qty=100, positions=[])
|
||||||
|
assert not r.ok
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Path to the bot's SQLite DB
|
||||||
|
ALPACLAUDIA_DB=../data/alpaclaudia.db
|
||||||
|
|
||||||
|
# Alpaca (Paper) — dashboard reads in read-only mode
|
||||||
|
ALPACA_API_KEY=
|
||||||
|
ALPACA_API_SECRET=
|
||||||
|
ALPACA_BASE_URL=https://paper-api.alpaca.markets
|
||||||
|
ALPACA_DATA_URL=https://data.alpaca.markets
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# alpaclaudia — dashboard
|
||||||
|
|
||||||
|
Next.js 14 dashboard that renders the bot's Alpaca snapshot (live via REST) and
|
||||||
|
its SQLite state log (read-only).
|
||||||
|
|
||||||
|
## Dev
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd dashboard
|
||||||
|
npm install
|
||||||
|
cp .env.example .env.local # fill ALPACA_API_KEY + ALPACA_API_SECRET
|
||||||
|
npm run dev # http://localhost:3030
|
||||||
|
```
|
||||||
|
|
||||||
|
The dashboard never places orders — it only reads. Write-paths are owned by
|
||||||
|
the Python bot (see `../bot`).
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
| Var | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `ALPACLAUDIA_DB` | Path to bot SQLite DB (default `../data/alpaclaudia.db`) |
|
||||||
|
| `ALPACA_API_KEY` / `ALPACA_API_SECRET` | Paper API creds |
|
||||||
|
| `ALPACA_BASE_URL` | `https://paper-api.alpaca.markets` |
|
||||||
|
|
||||||
|
## Production
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
npm start # serves on :3030
|
||||||
|
```
|
||||||
|
|
||||||
|
Reverse-proxy behind Caddy / nginx as needed; the process is single-tenant.
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { dbStatus, recentIntents, recentTicks } from "../../lib/db";
|
||||||
|
import {
|
||||||
|
credentialsPresent,
|
||||||
|
getAccount,
|
||||||
|
getClock,
|
||||||
|
getOrders,
|
||||||
|
getPositions,
|
||||||
|
} from "../../lib/alpaca";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const [account, positions, orders, clock] = await Promise.all([
|
||||||
|
getAccount(),
|
||||||
|
getPositions(),
|
||||||
|
getOrders(),
|
||||||
|
getClock(),
|
||||||
|
]);
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
alpaca_connected: credentialsPresent(),
|
||||||
|
db: dbStatus(),
|
||||||
|
account,
|
||||||
|
clock,
|
||||||
|
positions,
|
||||||
|
orders,
|
||||||
|
ticks: recentTicks(300),
|
||||||
|
intents: recentIntents(200),
|
||||||
|
server_time: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Area,
|
||||||
|
AreaChart,
|
||||||
|
CartesianGrid,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from "recharts";
|
||||||
|
|
||||||
|
export type EquityPoint = { ts: string; equity: number; cash: number };
|
||||||
|
|
||||||
|
export function EquityChart({ data }: { data: EquityPoint[] }) {
|
||||||
|
if (!data.length) {
|
||||||
|
return (
|
||||||
|
<div className="panel p-6 text-mute text-sm h-[300px] flex items-center justify-center">
|
||||||
|
No ticks recorded yet — the bot hasn't run.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="panel p-4 h-[320px]">
|
||||||
|
<div className="text-xs text-mute mb-2 uppercase tracking-wider">Equity (ticks)</div>
|
||||||
|
<ResponsiveContainer width="100%" height="90%">
|
||||||
|
<AreaChart data={data} margin={{ top: 8, right: 12, bottom: 0, left: 0 }}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="g-equity" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="#5eead4" stopOpacity={0.55} />
|
||||||
|
<stop offset="95%" stopColor="#5eead4" stopOpacity={0.0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid stroke="#252a35" strokeDasharray="3 3" vertical={false} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="ts"
|
||||||
|
stroke="#8a94a6"
|
||||||
|
fontSize={11}
|
||||||
|
tickFormatter={(v: string) => v.slice(11, 16)}
|
||||||
|
minTickGap={32}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
stroke="#8a94a6"
|
||||||
|
fontSize={11}
|
||||||
|
width={64}
|
||||||
|
tickFormatter={(v: number) => `$${(v / 1000).toFixed(1)}k`}
|
||||||
|
domain={["auto", "auto"]}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
background: "#13161d",
|
||||||
|
border: "1px solid #252a35",
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
labelStyle={{ color: "#8a94a6" }}
|
||||||
|
formatter={(v: unknown) => {
|
||||||
|
const n = Number(v);
|
||||||
|
return [`$${n.toLocaleString("en-US", { maximumFractionDigits: 2 })}`, "Equity"];
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="equity"
|
||||||
|
stroke="#5eead4"
|
||||||
|
strokeWidth={2}
|
||||||
|
fill="url(#g-equity)"
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { num, timeAgo } from "../lib/format";
|
||||||
|
|
||||||
|
type Intent = {
|
||||||
|
id: number;
|
||||||
|
ts: string;
|
||||||
|
strategy: string;
|
||||||
|
symbol: string;
|
||||||
|
side: string;
|
||||||
|
qty: number;
|
||||||
|
order_type: string;
|
||||||
|
limit_price: number | null;
|
||||||
|
trail_percent: number | null;
|
||||||
|
details_json: string;
|
||||||
|
submitted: number;
|
||||||
|
alpaca_order_id: string | null;
|
||||||
|
status: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function statusTone(status: string | null, submitted: number): string {
|
||||||
|
if (status === "blocked") return "text-down";
|
||||||
|
if (status === "error") return "text-down";
|
||||||
|
if (submitted) return "text-up";
|
||||||
|
if (status === "dry_run") return "text-mute";
|
||||||
|
return "text-warn";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IntentsTable({ intents }: { intents: Intent[] }) {
|
||||||
|
if (!intents.length)
|
||||||
|
return (
|
||||||
|
<div className="panel p-6 text-mute text-sm">
|
||||||
|
No strategy intents recorded yet. Run the bot once: <code>alpaclaudia tick</code>.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div className="panel overflow-hidden">
|
||||||
|
<div className="px-4 py-3 border-b border-border flex items-center justify-between">
|
||||||
|
<div className="text-sm font-medium">Bot intents (latest 200)</div>
|
||||||
|
<div className="text-xs text-mute">{intents.length}</div>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="table-header border-b border-border">
|
||||||
|
<th className="text-left px-4 py-2 font-normal">When</th>
|
||||||
|
<th className="text-left px-4 py-2 font-normal">Strategy</th>
|
||||||
|
<th className="text-left px-4 py-2 font-normal">Symbol</th>
|
||||||
|
<th className="text-left px-4 py-2 font-normal">Side</th>
|
||||||
|
<th className="text-left px-4 py-2 font-normal">Type</th>
|
||||||
|
<th className="text-right px-4 py-2 font-normal">Qty</th>
|
||||||
|
<th className="text-left px-4 py-2 font-normal">Outcome</th>
|
||||||
|
<th className="text-left px-4 py-2 font-normal">Rationale</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{intents.map((i) => {
|
||||||
|
let rationale = "";
|
||||||
|
let blockedReason = "";
|
||||||
|
try {
|
||||||
|
const d = JSON.parse(i.details_json || "{}");
|
||||||
|
rationale = d.rationale || "";
|
||||||
|
blockedReason = d.blocked_reason || d.error || "";
|
||||||
|
} catch {}
|
||||||
|
const tone = statusTone(i.status, i.submitted);
|
||||||
|
return (
|
||||||
|
<tr key={i.id} className="row-hover border-b border-border/60 align-top">
|
||||||
|
<td className="px-4 py-2 text-mute text-xs whitespace-nowrap">
|
||||||
|
{timeAgo(i.ts)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-xs">{i.strategy}</td>
|
||||||
|
<td className="px-4 py-2 font-mono text-xs">{i.symbol}</td>
|
||||||
|
<td className="px-4 py-2 uppercase text-xs">{i.side}</td>
|
||||||
|
<td className="px-4 py-2 text-xs">{i.order_type}</td>
|
||||||
|
<td className="px-4 py-2 text-right num">{num(i.qty, 0)}</td>
|
||||||
|
<td className={`px-4 py-2 text-xs ${tone}`}>
|
||||||
|
{i.status || (i.submitted ? "submitted" : "pending")}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-xs text-mute max-w-[520px]">
|
||||||
|
{rationale}
|
||||||
|
{blockedReason && (
|
||||||
|
<div className="text-down mt-0.5">blocked: {blockedReason}</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
export function Kpi({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
sub,
|
||||||
|
tone,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: React.ReactNode;
|
||||||
|
sub?: React.ReactNode;
|
||||||
|
tone?: "up" | "down" | "warn" | "mute";
|
||||||
|
}) {
|
||||||
|
const color =
|
||||||
|
tone === "up"
|
||||||
|
? "text-up"
|
||||||
|
: tone === "down"
|
||||||
|
? "text-down"
|
||||||
|
: tone === "warn"
|
||||||
|
? "text-warn"
|
||||||
|
: tone === "mute"
|
||||||
|
? "text-mute"
|
||||||
|
: "text-ink";
|
||||||
|
return (
|
||||||
|
<div className="panel p-4 flex flex-col gap-1">
|
||||||
|
<div className="kpi-label">{label}</div>
|
||||||
|
<div className={`text-2xl font-semibold num ${color}`}>{value}</div>
|
||||||
|
{sub !== undefined && <div className="text-xs text-mute num">{sub}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import type { Order } from "../lib/alpaca";
|
||||||
|
import { money, num, timeAgo } from "../lib/format";
|
||||||
|
|
||||||
|
function statusTone(status: string): string {
|
||||||
|
const s = status.toLowerCase();
|
||||||
|
if (["filled", "done_for_day"].includes(s)) return "text-up";
|
||||||
|
if (["canceled", "rejected", "expired"].includes(s)) return "text-down";
|
||||||
|
if (["new", "accepted", "pending_new", "held", "open"].includes(s)) return "text-warn";
|
||||||
|
return "text-mute";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OrdersTable({ orders }: { orders: Order[] }) {
|
||||||
|
if (!orders.length)
|
||||||
|
return <div className="panel p-6 text-mute text-sm">No orders yet.</div>;
|
||||||
|
return (
|
||||||
|
<div className="panel overflow-hidden">
|
||||||
|
<div className="px-4 py-3 border-b border-border flex items-center justify-between">
|
||||||
|
<div className="text-sm font-medium">Orders (Alpaca)</div>
|
||||||
|
<div className="text-xs text-mute">{orders.length}</div>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="table-header border-b border-border">
|
||||||
|
<th className="text-left px-4 py-2 font-normal">Submitted</th>
|
||||||
|
<th className="text-left px-4 py-2 font-normal">Symbol</th>
|
||||||
|
<th className="text-left px-4 py-2 font-normal">Side</th>
|
||||||
|
<th className="text-left px-4 py-2 font-normal">Type</th>
|
||||||
|
<th className="text-right px-4 py-2 font-normal">Qty</th>
|
||||||
|
<th className="text-right px-4 py-2 font-normal">Limit / Trail</th>
|
||||||
|
<th className="text-right px-4 py-2 font-normal">Filled</th>
|
||||||
|
<th className="text-left px-4 py-2 font-normal">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{orders.map((o) => (
|
||||||
|
<tr key={o.id} className="row-hover border-b border-border/60">
|
||||||
|
<td className="px-4 py-2 text-mute text-xs">
|
||||||
|
{timeAgo(o.submitted_at)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 font-mono">{o.symbol}</td>
|
||||||
|
<td className="px-4 py-2 uppercase text-xs">{o.side}</td>
|
||||||
|
<td className="px-4 py-2 text-xs">{o.order_type}</td>
|
||||||
|
<td className="px-4 py-2 text-right num">{num(o.qty, 0)}</td>
|
||||||
|
<td className="px-4 py-2 text-right num text-xs">
|
||||||
|
{o.limit_price
|
||||||
|
? money(o.limit_price)
|
||||||
|
: o.trail_percent
|
||||||
|
? `${Number(o.trail_percent).toFixed(2)}%`
|
||||||
|
: "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-right num">
|
||||||
|
{o.filled_avg_price ? money(o.filled_avg_price) : "—"}
|
||||||
|
</td>
|
||||||
|
<td className={`px-4 py-2 text-xs ${statusTone(o.status)}`}>
|
||||||
|
{o.status}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import type { Position } from "../lib/alpaca";
|
||||||
|
import { money, num, pct } from "../lib/format";
|
||||||
|
|
||||||
|
export function PositionsTable({ positions }: { positions: Position[] }) {
|
||||||
|
if (!positions.length) {
|
||||||
|
return (
|
||||||
|
<div className="panel p-6 text-mute text-sm">No open positions.</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="panel overflow-hidden">
|
||||||
|
<div className="px-4 py-3 border-b border-border flex items-center justify-between">
|
||||||
|
<div className="text-sm font-medium">Positions</div>
|
||||||
|
<div className="text-xs text-mute">{positions.length}</div>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="table-header border-b border-border">
|
||||||
|
<th className="text-left px-4 py-2 font-normal">Symbol</th>
|
||||||
|
<th className="text-left px-4 py-2 font-normal">Class</th>
|
||||||
|
<th className="text-right px-4 py-2 font-normal">Qty</th>
|
||||||
|
<th className="text-right px-4 py-2 font-normal">Entry</th>
|
||||||
|
<th className="text-right px-4 py-2 font-normal">Mark</th>
|
||||||
|
<th className="text-right px-4 py-2 font-normal">Market value</th>
|
||||||
|
<th className="text-right px-4 py-2 font-normal">P&L</th>
|
||||||
|
<th className="text-right px-4 py-2 font-normal">%</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{positions.map((p) => {
|
||||||
|
const pl = Number(p.unrealized_pl);
|
||||||
|
const plpc = Number(p.unrealized_plpc);
|
||||||
|
const tone = pl > 0 ? "text-up" : pl < 0 ? "text-down" : "text-mute";
|
||||||
|
const isOpt = p.asset_class?.toLowerCase().includes("option");
|
||||||
|
return (
|
||||||
|
<tr key={p.symbol} className="row-hover border-b border-border/60">
|
||||||
|
<td className="px-4 py-2 font-mono">{p.symbol}</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<span className="chip">
|
||||||
|
{isOpt ? "opt" : "eq"} · {p.side || "long"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-right num">{num(p.qty, 0)}</td>
|
||||||
|
<td className="px-4 py-2 text-right num">{money(p.avg_entry_price)}</td>
|
||||||
|
<td className="px-4 py-2 text-right num">{money(p.current_price)}</td>
|
||||||
|
<td className="px-4 py-2 text-right num">{money(p.market_value)}</td>
|
||||||
|
<td className={`px-4 py-2 text-right num ${tone}`}>
|
||||||
|
{money(pl, { sign: true })}
|
||||||
|
</td>
|
||||||
|
<td className={`px-4 py-2 text-right num ${tone}`}>{pct(plpc)}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
html, body { height: 100%; background: #0b0d11; color: #e6e8ee; }
|
||||||
|
body { font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
|
||||||
|
|
||||||
|
.panel { background: #13161d; border: 1px solid #252a35; border-radius: 12px; }
|
||||||
|
.kpi-label { font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; color: #8a94a6; }
|
||||||
|
.num { font-variant-numeric: tabular-nums; }
|
||||||
|
.table-header { font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; color: #8a94a6; }
|
||||||
|
.row-hover:hover { background: #1a1e27; }
|
||||||
|
.chip { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 999px; font-size: 11px; border: 1px solid #252a35; }
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "alpaclaudia",
|
||||||
|
description: "Alpaca paper-trading bot dashboard",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body>{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
const BASE = process.env.ALPACA_BASE_URL || "https://paper-api.alpaca.markets";
|
||||||
|
|
||||||
|
function headers(): HeadersInit {
|
||||||
|
const key = process.env.ALPACA_API_KEY || "";
|
||||||
|
const sec = process.env.ALPACA_API_SECRET || "";
|
||||||
|
return {
|
||||||
|
"APCA-API-KEY-ID": key,
|
||||||
|
"APCA-API-SECRET-KEY": sec,
|
||||||
|
Accept: "application/json",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function credentialsPresent(): boolean {
|
||||||
|
return Boolean(process.env.ALPACA_API_KEY && process.env.ALPACA_API_SECRET);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function alpacaGet<T>(pathname: string): Promise<T | null> {
|
||||||
|
if (!credentialsPresent()) return null;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${BASE}${pathname}`, {
|
||||||
|
headers: headers(),
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
return (await res.json()) as T;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Account = {
|
||||||
|
equity: string;
|
||||||
|
cash: string;
|
||||||
|
buying_power: string;
|
||||||
|
portfolio_value: string;
|
||||||
|
status: string;
|
||||||
|
pattern_day_trader?: boolean;
|
||||||
|
last_equity?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Position = {
|
||||||
|
symbol: string;
|
||||||
|
qty: string;
|
||||||
|
avg_entry_price: string;
|
||||||
|
current_price: string;
|
||||||
|
market_value: string;
|
||||||
|
unrealized_pl: string;
|
||||||
|
unrealized_plpc: string;
|
||||||
|
side: string;
|
||||||
|
asset_class: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Order = {
|
||||||
|
id: string;
|
||||||
|
symbol: string;
|
||||||
|
side: string;
|
||||||
|
qty: string | null;
|
||||||
|
filled_qty: string | null;
|
||||||
|
order_type: string;
|
||||||
|
status: string;
|
||||||
|
submitted_at: string;
|
||||||
|
filled_at: string | null;
|
||||||
|
filled_avg_price: string | null;
|
||||||
|
trail_percent?: string | null;
|
||||||
|
limit_price?: string | null;
|
||||||
|
stop_price?: string | null;
|
||||||
|
asset_class?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Clock = { is_open: boolean; next_open: string; next_close: string };
|
||||||
|
|
||||||
|
export const getAccount = () => alpacaGet<Account>("/v2/account");
|
||||||
|
export const getPositions = () => alpacaGet<Position[]>("/v2/positions");
|
||||||
|
export const getOrders = () => alpacaGet<Order[]>("/v2/orders?status=all&limit=50&nested=true");
|
||||||
|
export const getClock = () => alpacaGet<Clock>("/v2/clock");
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import Database from "better-sqlite3";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
type Row = Record<string, unknown>;
|
||||||
|
|
||||||
|
let _db: Database.Database | null = null;
|
||||||
|
|
||||||
|
function dbPath(): string {
|
||||||
|
const raw = process.env.ALPACLAUDIA_DB || "../data/alpaclaudia.db";
|
||||||
|
return path.isAbsolute(raw) ? raw : path.resolve(process.cwd(), raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDb(): Database.Database | null {
|
||||||
|
if (_db) return _db;
|
||||||
|
try {
|
||||||
|
_db = new Database(dbPath(), { readonly: true, fileMustExist: true });
|
||||||
|
return _db;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recentTicks(limit = 300): Row[] {
|
||||||
|
const db = getDb();
|
||||||
|
if (!db) return [];
|
||||||
|
return db
|
||||||
|
.prepare(`SELECT id, ts, equity, cash, buying_power, mode FROM ticks ORDER BY id DESC LIMIT ?`)
|
||||||
|
.all(limit) as Row[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recentIntents(limit = 200): Row[] {
|
||||||
|
const db = getDb();
|
||||||
|
if (!db) return [];
|
||||||
|
return db
|
||||||
|
.prepare(
|
||||||
|
`SELECT id, ts, strategy, symbol, side, qty, order_type, limit_price,
|
||||||
|
trail_percent, details_json, submitted, alpaca_order_id, status
|
||||||
|
FROM order_intents ORDER BY id DESC LIMIT ?`
|
||||||
|
)
|
||||||
|
.all(limit) as Row[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function intentsSince(iso: string): Row[] {
|
||||||
|
const db = getDb();
|
||||||
|
if (!db) return [];
|
||||||
|
return db
|
||||||
|
.prepare(
|
||||||
|
`SELECT id, ts, strategy, symbol, side, qty, order_type, limit_price,
|
||||||
|
trail_percent, details_json, submitted, alpaca_order_id, status
|
||||||
|
FROM order_intents WHERE ts >= ? ORDER BY id DESC`
|
||||||
|
)
|
||||||
|
.all(iso) as Row[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recentEvents(limit = 100): Row[] {
|
||||||
|
const db = getDb();
|
||||||
|
if (!db) return [];
|
||||||
|
return db
|
||||||
|
.prepare(`SELECT id, ts, kind, payload_json FROM events ORDER BY id DESC LIMIT ?`)
|
||||||
|
.all(limit) as Row[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dbStatus(): { ok: boolean; path: string; error?: string } {
|
||||||
|
const p = dbPath();
|
||||||
|
try {
|
||||||
|
const db = getDb();
|
||||||
|
if (!db) return { ok: false, path: p, error: "could not open db" };
|
||||||
|
return { ok: true, path: p };
|
||||||
|
} catch (e: unknown) {
|
||||||
|
return { ok: false, path: p, error: e instanceof Error ? e.message : String(e) };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
export function money(v: number | string | null | undefined, opts?: { sign?: boolean }): string {
|
||||||
|
const n = typeof v === "string" ? Number(v) : v ?? 0;
|
||||||
|
if (n === null || Number.isNaN(n)) return "—";
|
||||||
|
const f = n.toLocaleString("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "USD",
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
});
|
||||||
|
if (opts?.sign && n > 0) return `+${f}`;
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pct(v: number | string | null | undefined, digits = 2): string {
|
||||||
|
const n = typeof v === "string" ? Number(v) : v ?? 0;
|
||||||
|
if (Number.isNaN(n)) return "—";
|
||||||
|
return `${(n * 100).toFixed(digits)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function num(v: number | string | null | undefined, digits = 2): string {
|
||||||
|
const n = typeof v === "string" ? Number(v) : v ?? 0;
|
||||||
|
if (Number.isNaN(n)) return "—";
|
||||||
|
return n.toLocaleString("en-US", {
|
||||||
|
minimumFractionDigits: digits,
|
||||||
|
maximumFractionDigits: digits,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function timeAgo(iso: string): string {
|
||||||
|
const t = new Date(iso).getTime();
|
||||||
|
if (!t) return "—";
|
||||||
|
const sec = Math.round((Date.now() - t) / 1000);
|
||||||
|
if (sec < 60) return `${sec}s ago`;
|
||||||
|
const min = Math.round(sec / 60);
|
||||||
|
if (min < 60) return `${min}m ago`;
|
||||||
|
const hr = Math.round(min / 60);
|
||||||
|
if (hr < 24) return `${hr}h ago`;
|
||||||
|
const day = Math.round(hr / 24);
|
||||||
|
return `${day}d ago`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { Kpi } from "./components/kpi";
|
||||||
|
import { EquityChart, type EquityPoint } from "./components/equity-chart";
|
||||||
|
import { PositionsTable } from "./components/positions-table";
|
||||||
|
import { OrdersTable } from "./components/orders-table";
|
||||||
|
import { IntentsTable } from "./components/intents-table";
|
||||||
|
import { dbStatus, recentIntents, recentTicks } from "./lib/db";
|
||||||
|
import {
|
||||||
|
credentialsPresent,
|
||||||
|
getAccount,
|
||||||
|
getClock,
|
||||||
|
getOrders,
|
||||||
|
getPositions,
|
||||||
|
} from "./lib/alpaca";
|
||||||
|
import { money, pct } from "./lib/format";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const revalidate = 0;
|
||||||
|
|
||||||
|
export default async function Page() {
|
||||||
|
const [account, positions, orders, clock] = await Promise.all([
|
||||||
|
getAccount(),
|
||||||
|
getPositions(),
|
||||||
|
getOrders(),
|
||||||
|
getClock(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const ticks = recentTicks(300).reverse();
|
||||||
|
const intents = recentIntents(200);
|
||||||
|
const db = dbStatus();
|
||||||
|
|
||||||
|
const chartData: EquityPoint[] = ticks
|
||||||
|
.filter((t) => t.equity !== null)
|
||||||
|
.map((t) => ({
|
||||||
|
ts: String(t.ts),
|
||||||
|
equity: Number(t.equity || 0),
|
||||||
|
cash: Number(t.cash || 0),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const equity = Number(account?.equity || 0);
|
||||||
|
const lastEquity = Number(account?.last_equity || equity);
|
||||||
|
const dayPL = equity - lastEquity;
|
||||||
|
const dayPct = lastEquity ? dayPL / lastEquity : 0;
|
||||||
|
|
||||||
|
const creds = credentialsPresent();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen p-6 max-w-[1400px] mx-auto">
|
||||||
|
<header className="flex flex-wrap items-baseline justify-between gap-4 mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">
|
||||||
|
alpaclaudia<span className="text-brand">.</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-mute mt-1">
|
||||||
|
Alpaca paper-trading bot — Wheel + trailing stops.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
{creds ? (
|
||||||
|
<span className="chip text-up border-up/30">
|
||||||
|
● Alpaca connected
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="chip text-down border-down/30">
|
||||||
|
● No Alpaca creds
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{clock && (
|
||||||
|
<span className="chip">
|
||||||
|
market {clock.is_open ? "open" : "closed"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{db.ok ? (
|
||||||
|
<span className="chip text-mute">db ok</span>
|
||||||
|
) : (
|
||||||
|
<span className="chip text-down">db missing</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
|
||||||
|
<Kpi
|
||||||
|
label="Equity"
|
||||||
|
value={account ? money(account.equity) : "—"}
|
||||||
|
sub={account ? `PV ${money(account.portfolio_value)}` : "not connected"}
|
||||||
|
/>
|
||||||
|
<Kpi
|
||||||
|
label="Cash"
|
||||||
|
value={account ? money(account.cash) : "—"}
|
||||||
|
sub={account ? `BP ${money(account.buying_power)}` : undefined}
|
||||||
|
/>
|
||||||
|
<Kpi
|
||||||
|
label="Day P&L"
|
||||||
|
value={money(dayPL, { sign: true })}
|
||||||
|
sub={pct(dayPct)}
|
||||||
|
tone={dayPL > 0 ? "up" : dayPL < 0 ? "down" : "mute"}
|
||||||
|
/>
|
||||||
|
<Kpi
|
||||||
|
label="Positions"
|
||||||
|
value={positions?.length ?? 0}
|
||||||
|
sub={`${(positions || []).filter((p) => p.asset_class?.toLowerCase().includes("option")).length} option · ${
|
||||||
|
(positions || []).filter((p) => !p.asset_class?.toLowerCase().includes("option")).length
|
||||||
|
} equity`}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mb-6">
|
||||||
|
<EquityChart data={chartData} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid grid-cols-1 gap-6">
|
||||||
|
<PositionsTable positions={positions || []} />
|
||||||
|
<IntentsTable intents={intents as any} />
|
||||||
|
<OrdersTable orders={orders || []} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer className="mt-8 text-xs text-mute">
|
||||||
|
db: <code>{db.path}</code>
|
||||||
|
{db.error && <span className="text-down"> — {db.error}</span>}
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
Vendored
+5
@@ -0,0 +1,5 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
experimental: {
|
||||||
|
serverComponentsExternalPackages: ["better-sqlite3"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
Generated
+2446
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "alpaclaudia-dashboard",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev -p 3030",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start -p 3030",
|
||||||
|
"lint": "next lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"better-sqlite3": "^11.3.0",
|
||||||
|
"next": "^14.2.15",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"recharts": "^2.13.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/better-sqlite3": "^7.6.11",
|
||||||
|
"@types/node": "^22.7.4",
|
||||||
|
"@types/react": "^18.3.11",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"postcss": "^8.4.47",
|
||||||
|
"tailwindcss": "^3.4.13",
|
||||||
|
"typescript": "^5.6.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export default {
|
||||||
|
plugins: { tailwindcss: {}, autoprefixer: {} },
|
||||||
|
};
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { Config } from "tailwindcss";
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
content: ["./app/**/*.{js,ts,jsx,tsx,mdx}"],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
bg: "#0b0d11",
|
||||||
|
panel: "#13161d",
|
||||||
|
panel2: "#1a1e27",
|
||||||
|
border: "#252a35",
|
||||||
|
ink: "#e6e8ee",
|
||||||
|
mute: "#8a94a6",
|
||||||
|
brand: "#5eead4",
|
||||||
|
up: "#34d399",
|
||||||
|
down: "#f87171",
|
||||||
|
warn: "#fbbf24",
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
mono: ["ui-monospace", "SFMono-Regular", "Menlo", "Consolas", "monospace"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [{ "name": "next" }],
|
||||||
|
"paths": { "@/*": ["./*"] }
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Strategy notes
|
||||||
|
|
||||||
|
## The Wheel
|
||||||
|
|
||||||
|
A short-volatility, cash-secured income strategy. Two phases per underlying:
|
||||||
|
|
||||||
|
**Phase 1 — no shares held:** sell a cash-secured put (CSP). The bot picks a
|
||||||
|
contract 14-28 DTE (`WHEEL_PUT_DTE_MIN/MAX`) with absolute delta closest to
|
||||||
|
`WHEEL_PUT_TARGET_DELTA` (default 0.30). If the trade's annualised yield falls
|
||||||
|
below `WHEEL_MIN_ANNUAL_YIELD`, it's skipped. If delta data is missing (Alpaca
|
||||||
|
snapshot), it falls back to "closest to `WHEEL_PUT_OTM_PCT` below spot".
|
||||||
|
|
||||||
|
Two outcomes at expiry:
|
||||||
|
- Put expires worthless → keep premium, repeat Phase 1.
|
||||||
|
- Put is assigned → own 100 shares per contract at strike, go to Phase 2.
|
||||||
|
|
||||||
|
**Phase 2 — shares held:** for each uncovered 100-share lot, sell a covered
|
||||||
|
call ~same DTE, same target delta, **at or above cost basis** (hard floor in
|
||||||
|
`risk.check_covered_call`). Two outcomes at expiry:
|
||||||
|
- Call expires worthless → keep premium, keep shares, sell another call.
|
||||||
|
- Call is assigned → 100 shares called away at strike (a locked-in gain,
|
||||||
|
because strike ≥ cost basis), back to Phase 1.
|
||||||
|
|
||||||
|
Assumption: the underlying is one we'd be happy to own. That's why
|
||||||
|
`WHEEL_UNIVERSE` should be small and considered.
|
||||||
|
|
||||||
|
## Trailing stops
|
||||||
|
|
||||||
|
Separate from the wheel — applies to any long equity position. On each tick,
|
||||||
|
for every long stock position without an existing open trailing-stop order,
|
||||||
|
the bot submits a `trailing_stop` sell at `TRAILING_STOP_PCT` trail.
|
||||||
|
|
||||||
|
In practice this is idempotent: duplicates are avoided by the open-order check.
|
||||||
|
|
||||||
|
## Why not long calls / verticals?
|
||||||
|
|
||||||
|
Deliberately kept out of v0. They require managing more Greeks than the wheel
|
||||||
|
and increase the surface area of risk mistakes. Easy to add under
|
||||||
|
`strategies/` once the wheel is stable on paper for weeks.
|
||||||
|
|
||||||
|
## Candidates to consider later
|
||||||
|
|
||||||
|
- **Trailing-stop on short puts** once they're deep ITM vs. the underlying's
|
||||||
|
drift — close early and re-roll.
|
||||||
|
- **Cash-sweep** into BIL/SGOV for idle cash while waiting for assignments.
|
||||||
|
- **Volatility filter** — skip CSPs when IV rank is below a threshold (low premium).
|
||||||
|
- **Earnings blackout** — skip CSP entries within N days of earnings.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# alpaclaudia — systemd units
|
||||||
|
|
||||||
|
Install as **user units** (no root needed):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/.config/systemd/user
|
||||||
|
cp ~/finhacks/systemd/alpaclaudia-*.{service,timer} ~/.config/systemd/user/
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
systemctl --user enable --now alpaclaudia-loop.service
|
||||||
|
systemctl --user enable --now alpaclaudia-report.timer
|
||||||
|
loginctl enable-linger $USER # so services keep running after logout
|
||||||
|
```
|
||||||
|
|
||||||
|
Status / logs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl --user status alpaclaudia-loop
|
||||||
|
journalctl --user -u alpaclaudia-loop -f
|
||||||
|
systemctl --user list-timers | grep alpaclaudia
|
||||||
|
```
|
||||||
|
|
||||||
|
Switching to live mode:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# in ~/finhacks/.env set BOT_MODE=live (ALPACA_ENV stays "paper" for paper acct)
|
||||||
|
systemctl --user restart alpaclaudia-loop
|
||||||
|
```
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=alpaclaudia — Alpaca paper-trading loop
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
WorkingDirectory=/home/otto/finhacks
|
||||||
|
EnvironmentFile=/home/otto/finhacks/.env
|
||||||
|
ExecStart=/home/otto/finhacks/bot/.venv/bin/python -m alpaclaudia loop
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=30
|
||||||
|
StandardOutput=append:/home/otto/finhacks/logs/systemd.log
|
||||||
|
StandardError=append:/home/otto/finhacks/logs/systemd.log
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=alpaclaudia — post daily Discord report
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
WorkingDirectory=/home/otto/finhacks
|
||||||
|
EnvironmentFile=/home/otto/finhacks/.env
|
||||||
|
ExecStart=/home/otto/finhacks/bot/.venv/bin/python -m alpaclaudia report
|
||||||
|
StandardOutput=append:/home/otto/finhacks/logs/report.log
|
||||||
|
StandardError=append:/home/otto/finhacks/logs/report.log
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=alpaclaudia — daily Discord report after NYSE close
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
# 22:30 Europe/Berlin ≈ 16:30 ET (30 min after 16:00 ET close); timer is in UTC,
|
||||||
|
# systemd will apply the system timezone. Adjust if you run on a UTC server.
|
||||||
|
OnCalendar=Mon..Fri 22:30
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
Reference in New Issue
Block a user