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

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

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

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

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

95 lines
2.9 KiB
Python

"""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