commit 39875112a044ad27b9b957736a9e8c9e8d0cb8ea Author: otto Date: Thu Apr 16 21:38:25 2026 +0200 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. diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3605501 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8a58179 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..a7edf86 --- /dev/null +++ b/README.md @@ -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 diff --git a/bot/alpaclaudia/__init__.py b/bot/alpaclaudia/__init__.py new file mode 100644 index 0000000..c504b26 --- /dev/null +++ b/bot/alpaclaudia/__init__.py @@ -0,0 +1,2 @@ +"""alpaclaudia — Alpaca paper-trading bot (Wheel + trailing stops).""" +__version__ = "0.1.0" diff --git a/bot/alpaclaudia/__main__.py b/bot/alpaclaudia/__main__.py new file mode 100644 index 0000000..0cb59d2 --- /dev/null +++ b/bot/alpaclaudia/__main__.py @@ -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() diff --git a/bot/alpaclaudia/client.py b/bot/alpaclaudia/client.py new file mode 100644 index 0000000..8fd4517 --- /dev/null +++ b/bot/alpaclaudia/client.py @@ -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 diff --git a/bot/alpaclaudia/config.py b/bot/alpaclaudia/config.py new file mode 100644 index 0000000..bf0b468 --- /dev/null +++ b/bot/alpaclaudia/config.py @@ -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, + ) diff --git a/bot/alpaclaudia/executor.py b/bot/alpaclaudia/executor.py new file mode 100644 index 0000000..0c08d25 --- /dev/null +++ b/bot/alpaclaudia/executor.py @@ -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)} diff --git a/bot/alpaclaudia/logger.py b/bot/alpaclaudia/logger.py new file mode 100644 index 0000000..03abfdf --- /dev/null +++ b/bot/alpaclaudia/logger.py @@ -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) diff --git a/bot/alpaclaudia/reporter.py b/bot/alpaclaudia/reporter.py new file mode 100644 index 0000000..5194098 --- /dev/null +++ b/bot/alpaclaudia/reporter.py @@ -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 diff --git a/bot/alpaclaudia/risk.py b/bot/alpaclaudia/risk.py new file mode 100644 index 0000000..05f7763 --- /dev/null +++ b/bot/alpaclaudia/risk.py @@ -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) diff --git a/bot/alpaclaudia/scheduler.py b/bot/alpaclaudia/scheduler.py new file mode 100644 index 0000000..dbec7b5 --- /dev/null +++ b/bot/alpaclaudia/scheduler.py @@ -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"] diff --git a/bot/alpaclaudia/state.py b/bot/alpaclaudia/state.py new file mode 100644 index 0000000..1c35009 --- /dev/null +++ b/bot/alpaclaudia/state.py @@ -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] diff --git a/bot/alpaclaudia/strategies/__init__.py b/bot/alpaclaudia/strategies/__init__.py new file mode 100644 index 0000000..dc2759b --- /dev/null +++ b/bot/alpaclaudia/strategies/__init__.py @@ -0,0 +1,4 @@ +"""Strategy modules. Each emits zero-or-more OrderIntent objects per tick.""" +from .base import OrderIntent, Strategy + +__all__ = ["OrderIntent", "Strategy"] diff --git a/bot/alpaclaudia/strategies/base.py b/bot/alpaclaudia/strategies/base.py new file mode 100644 index 0000000..831d274 --- /dev/null +++ b/bot/alpaclaudia/strategies/base.py @@ -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]: + ... diff --git a/bot/alpaclaudia/strategies/trailing_stop.py b/bot/alpaclaudia/strategies/trailing_stop.py new file mode 100644 index 0000000..74606d4 --- /dev/null +++ b/bot/alpaclaudia/strategies/trailing_stop.py @@ -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 diff --git a/bot/alpaclaudia/strategies/wheel.py b/bot/alpaclaudia/strategies/wheel.py new file mode 100644 index 0000000..9a6e0af --- /dev/null +++ b/bot/alpaclaudia/strategies/wheel.py @@ -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 diff --git a/bot/pyproject.toml b/bot/pyproject.toml new file mode 100644 index 0000000..6a9f45f --- /dev/null +++ b/bot/pyproject.toml @@ -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" diff --git a/bot/tests/__init__.py b/bot/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/tests/test_risk.py b/bot/tests/test_risk.py new file mode 100644 index 0000000..f0e590e --- /dev/null +++ b/bot/tests/test_risk.py @@ -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 diff --git a/dashboard/.env.example b/dashboard/.env.example new file mode 100644 index 0000000..e738822 --- /dev/null +++ b/dashboard/.env.example @@ -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 diff --git a/dashboard/README.md b/dashboard/README.md new file mode 100644 index 0000000..8625577 --- /dev/null +++ b/dashboard/README.md @@ -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. diff --git a/dashboard/app/api/refresh/route.ts b/dashboard/app/api/refresh/route.ts new file mode 100644 index 0000000..c83ed69 --- /dev/null +++ b/dashboard/app/api/refresh/route.ts @@ -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(), + }); +} diff --git a/dashboard/app/components/equity-chart.tsx b/dashboard/app/components/equity-chart.tsx new file mode 100644 index 0000000..6980e58 --- /dev/null +++ b/dashboard/app/components/equity-chart.tsx @@ -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 ( +
+ No ticks recorded yet — the bot hasn't run. +
+ ); + } + return ( +
+
Equity (ticks)
+ + + + + + + + + + v.slice(11, 16)} + minTickGap={32} + /> + `$${(v / 1000).toFixed(1)}k`} + domain={["auto", "auto"]} + /> + { + const n = Number(v); + return [`$${n.toLocaleString("en-US", { maximumFractionDigits: 2 })}`, "Equity"]; + }} + /> + + + +
+ ); +} diff --git a/dashboard/app/components/intents-table.tsx b/dashboard/app/components/intents-table.tsx new file mode 100644 index 0000000..c8da311 --- /dev/null +++ b/dashboard/app/components/intents-table.tsx @@ -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 ( +
+ No strategy intents recorded yet. Run the bot once: alpaclaudia tick. +
+ ); + return ( +
+
+
Bot intents (latest 200)
+
{intents.length}
+
+
+ + + + + + + + + + + + + + + {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 ( + + + + + + + + + + + ); + })} + +
WhenStrategySymbolSideTypeQtyOutcomeRationale
+ {timeAgo(i.ts)} + {i.strategy}{i.symbol}{i.side}{i.order_type}{num(i.qty, 0)} + {i.status || (i.submitted ? "submitted" : "pending")} + + {rationale} + {blockedReason && ( +
blocked: {blockedReason}
+ )} +
+
+
+ ); +} diff --git a/dashboard/app/components/kpi.tsx b/dashboard/app/components/kpi.tsx new file mode 100644 index 0000000..a29eabb --- /dev/null +++ b/dashboard/app/components/kpi.tsx @@ -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 ( +
+
{label}
+
{value}
+ {sub !== undefined &&
{sub}
} +
+ ); +} diff --git a/dashboard/app/components/orders-table.tsx b/dashboard/app/components/orders-table.tsx new file mode 100644 index 0000000..e567665 --- /dev/null +++ b/dashboard/app/components/orders-table.tsx @@ -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
No orders yet.
; + return ( +
+
+
Orders (Alpaca)
+
{orders.length}
+
+
+ + + + + + + + + + + + + + + {orders.map((o) => ( + + + + + + + + + + + ))} + +
SubmittedSymbolSideTypeQtyLimit / TrailFilledStatus
+ {timeAgo(o.submitted_at)} + {o.symbol}{o.side}{o.order_type}{num(o.qty, 0)} + {o.limit_price + ? money(o.limit_price) + : o.trail_percent + ? `${Number(o.trail_percent).toFixed(2)}%` + : "—"} + + {o.filled_avg_price ? money(o.filled_avg_price) : "—"} + + {o.status} +
+
+
+ ); +} diff --git a/dashboard/app/components/positions-table.tsx b/dashboard/app/components/positions-table.tsx new file mode 100644 index 0000000..9eb4a66 --- /dev/null +++ b/dashboard/app/components/positions-table.tsx @@ -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 ( +
No open positions.
+ ); + } + return ( +
+
+
Positions
+
{positions.length}
+
+
+ + + + + + + + + + + + + + + {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 ( + + + + + + + + + + + ); + })} + +
SymbolClassQtyEntryMarkMarket valueP&L%
{p.symbol} + + {isOpt ? "opt" : "eq"} · {p.side || "long"} + + {num(p.qty, 0)}{money(p.avg_entry_price)}{money(p.current_price)}{money(p.market_value)} + {money(pl, { sign: true })} + {pct(plpc)}
+
+
+ ); +} diff --git a/dashboard/app/globals.css b/dashboard/app/globals.css new file mode 100644 index 0000000..bbfcc62 --- /dev/null +++ b/dashboard/app/globals.css @@ -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; } diff --git a/dashboard/app/layout.tsx b/dashboard/app/layout.tsx new file mode 100644 index 0000000..4479125 --- /dev/null +++ b/dashboard/app/layout.tsx @@ -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 ( + + {children} + + ); +} diff --git a/dashboard/app/lib/alpaca.ts b/dashboard/app/lib/alpaca.ts new file mode 100644 index 0000000..e54ddda --- /dev/null +++ b/dashboard/app/lib/alpaca.ts @@ -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(pathname: string): Promise { + 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("/v2/account"); +export const getPositions = () => alpacaGet("/v2/positions"); +export const getOrders = () => alpacaGet("/v2/orders?status=all&limit=50&nested=true"); +export const getClock = () => alpacaGet("/v2/clock"); diff --git a/dashboard/app/lib/db.ts b/dashboard/app/lib/db.ts new file mode 100644 index 0000000..911e58c --- /dev/null +++ b/dashboard/app/lib/db.ts @@ -0,0 +1,72 @@ +import Database from "better-sqlite3"; +import path from "node:path"; + +type Row = Record; + +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) }; + } +} diff --git a/dashboard/app/lib/format.ts b/dashboard/app/lib/format.ts new file mode 100644 index 0000000..b54d484 --- /dev/null +++ b/dashboard/app/lib/format.ts @@ -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`; +} diff --git a/dashboard/app/page.tsx b/dashboard/app/page.tsx new file mode 100644 index 0000000..a04457d --- /dev/null +++ b/dashboard/app/page.tsx @@ -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 ( +
+
+
+

+ alpaclaudia. +

+

+ Alpaca paper-trading bot — Wheel + trailing stops. +

+
+
+ {creds ? ( + + ● Alpaca connected + + ) : ( + + ● No Alpaca creds + + )} + {clock && ( + + market {clock.is_open ? "open" : "closed"} + + )} + {db.ok ? ( + db ok + ) : ( + db missing + )} +
+
+ +
+ + + 0 ? "up" : dayPL < 0 ? "down" : "mute"} + /> + p.asset_class?.toLowerCase().includes("option")).length} option · ${ + (positions || []).filter((p) => !p.asset_class?.toLowerCase().includes("option")).length + } equity`} + /> +
+ +
+ +
+ +
+ + + +
+ +
+ db: {db.path} + {db.error && — {db.error}} +
+
+ ); +} diff --git a/dashboard/next-env.d.ts b/dashboard/next-env.d.ts new file mode 100644 index 0000000..40c3d68 --- /dev/null +++ b/dashboard/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information. diff --git a/dashboard/next.config.mjs b/dashboard/next.config.mjs new file mode 100644 index 0000000..d925647 --- /dev/null +++ b/dashboard/next.config.mjs @@ -0,0 +1,8 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + experimental: { + serverComponentsExternalPackages: ["better-sqlite3"], + }, +}; + +export default nextConfig; diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json new file mode 100644 index 0000000..9043cc2 --- /dev/null +++ b/dashboard/package-lock.json @@ -0,0 +1,2446 @@ +{ + "name": "alpaclaudia-dashboard", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "alpaclaudia-dashboard", + "version": "0.1.0", + "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" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@next/env": { + "version": "14.2.35", + "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.35.tgz", + "integrity": "sha512-DuhvCtj4t9Gwrx80dmz2F4t/zKQ4ktN8WrMwOuVzkJfBilwAwGr6v16M5eI8yCuZ63H9TTuEU09Iu2HqkzFPVQ==", + "license": "MIT" + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.33.tgz", + "integrity": "sha512-HqYnb6pxlsshoSTubdXKu15g3iivcbsMXg4bYpjL2iS/V6aQot+iyF4BUc2qA/J/n55YtvE4PHMKWBKGCF/+wA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.33.tgz", + "integrity": "sha512-8HGBeAE5rX3jzKvF593XTTFg3gxeU4f+UWnswa6JPhzaR6+zblO5+fjltJWIZc4aUalqTclvN2QtTC37LxvZAA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.33.tgz", + "integrity": "sha512-JXMBka6lNNmqbkvcTtaX8Gu5by9547bukHQvPoLe9VRBx1gHwzf5tdt4AaezW85HAB3pikcvyqBToRTDA4DeLw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.33.tgz", + "integrity": "sha512-Bm+QulsAItD/x6Ih8wGIMfRJy4G73tu1HJsrccPW6AfqdZd0Sfm5Imhgkgq2+kly065rYMnCOxTBvmvFY1BKfg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.33.tgz", + "integrity": "sha512-FnFn+ZBgsVMbGDsTqo8zsnRzydvsGV8vfiWwUo1LD8FTmPTdV+otGSWKc4LJec0oSexFnCYVO4hX8P8qQKaSlg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.33.tgz", + "integrity": "sha512-345tsIWMzoXaQndUTDv1qypDRiebFxGYx9pYkhwY4hBRaOLt8UGfiWKr9FSSHs25dFIf8ZqIFaPdy5MljdoawA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.33.tgz", + "integrity": "sha512-nscpt0G6UCTkrT2ppnJnFsYbPDQwmum4GNXYTeoTIdsmMydSKFz9Iny2jpaRupTb+Wl298+Rh82WKzt9LCcqSQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-ia32-msvc": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.33.tgz", + "integrity": "sha512-pc9LpGNKhJ0dXQhZ5QMmYxtARwwmWLpeocFmVG5Z0DzWq5Uf0izcI8tLc+qOpqxO1PWqZ5A7J1blrUIKrIFc7Q==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.33.tgz", + "integrity": "sha512-nOjfZMy8B94MdisuzZo9/57xuFVLHJaDj5e/xrduJp9CV2/HrfxTRH2fbyLe+K9QT41WBLUd4iXX3R7jBp0EUg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "license": "Apache-2.0" + }, + "node_modules/@swc/helpers": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.5.tgz", + "integrity": "sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==", + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3", + "tslib": "^2.4.0" + } + }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", + "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", + "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.19.tgz", + "integrity": "sha512-qCkNLi2sfBOn8XhZQ0FXsT1Ki/Yo5P90hrkRamVFRS7/KV9hpfA4HkoWNU152+8w0zPjnxo5psx5NL3PSGgv5g==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/better-sqlite3": { + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", + "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001788", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz", + "integrity": "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.339", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.339.tgz", + "integrity": "sha512-Is+0BBHJ4NrdpAYiperrmp53pLywG/yV/6lIMTAnhxvzj/Cmn5Q/ogSHC6AKe7X+8kPLxxFk0cs5oc/3j/fxIg==", + "dev": true, + "license": "ISC" + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-equals": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", + "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/next": { + "version": "14.2.35", + "resolved": "https://registry.npmjs.org/next/-/next-14.2.35.tgz", + "integrity": "sha512-KhYd2Hjt/O1/1aZVX3dCwGXM1QmOV4eNM2UTacK5gipDdPN/oHHK/4oVGy7X8GMfPMsUTUEmGlsy0EY1YGAkig==", + "license": "MIT", + "dependencies": { + "@next/env": "14.2.35", + "@swc/helpers": "0.5.5", + "busboy": "1.6.0", + "caniuse-lite": "^1.0.30001579", + "graceful-fs": "^4.2.11", + "postcss": "8.4.31", + "styled-jsx": "5.1.1" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=18.17.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "14.2.33", + "@next/swc-darwin-x64": "14.2.33", + "@next/swc-linux-arm64-gnu": "14.2.33", + "@next/swc-linux-arm64-musl": "14.2.33", + "@next/swc-linux-x64-gnu": "14.2.33", + "@next/swc-linux-x64-musl": "14.2.33", + "@next/swc-win32-arm64-msvc": "14.2.33", + "@next/swc-win32-ia32-msvc": "14.2.33", + "@next/swc-win32-x64-msvc": "14.2.33" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.41.2", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next/node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/node-abi": { + "version": "3.89.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz", + "integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-releases": { + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/recharts": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", + "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "license": "MIT", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.1.tgz", + "integrity": "sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + } + } +} diff --git a/dashboard/package.json b/dashboard/package.json new file mode 100644 index 0000000..3feddc0 --- /dev/null +++ b/dashboard/package.json @@ -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" + } +} diff --git a/dashboard/postcss.config.mjs b/dashboard/postcss.config.mjs new file mode 100644 index 0000000..e008c9c --- /dev/null +++ b/dashboard/postcss.config.mjs @@ -0,0 +1,3 @@ +export default { + plugins: { tailwindcss: {}, autoprefixer: {} }, +}; diff --git a/dashboard/tailwind.config.ts b/dashboard/tailwind.config.ts new file mode 100644 index 0000000..43675fb --- /dev/null +++ b/dashboard/tailwind.config.ts @@ -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; diff --git a/dashboard/tsconfig.json b/dashboard/tsconfig.json new file mode 100644 index 0000000..afedc74 --- /dev/null +++ b/dashboard/tsconfig.json @@ -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"] +} diff --git a/docs/STRATEGY.md b/docs/STRATEGY.md new file mode 100644 index 0000000..aee3cc9 --- /dev/null +++ b/docs/STRATEGY.md @@ -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. diff --git a/systemd/README.md b/systemd/README.md new file mode 100644 index 0000000..79c592b --- /dev/null +++ b/systemd/README.md @@ -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 +``` diff --git a/systemd/alpaclaudia-loop.service b/systemd/alpaclaudia-loop.service new file mode 100644 index 0000000..a6532ff --- /dev/null +++ b/systemd/alpaclaudia-loop.service @@ -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 diff --git a/systemd/alpaclaudia-report.service b/systemd/alpaclaudia-report.service new file mode 100644 index 0000000..99c6c3d --- /dev/null +++ b/systemd/alpaclaudia-report.service @@ -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 diff --git a/systemd/alpaclaudia-report.timer b/systemd/alpaclaudia-report.timer new file mode 100644 index 0000000..896cd59 --- /dev/null +++ b/systemd/alpaclaudia-report.timer @@ -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