39875112a0
Python bot (bot/alpaclaudia): alpaca-py client, wheel strategy (CSP + covered calls) plus equity trailing stops, risk gates (cash buffer, cost-basis guard, per-symbol concentration cap), SQLite state log, Typer CLI (tick/loop/status/ report/dump-state), Discord daily report, pytest suite. Next.js 14 dashboard (dashboard/): read-only — reads the bot's SQLite directly and pulls live account/positions/orders from Alpaca. KPIs, equity chart, positions, bot-intents audit table, and orders table. Dark UI with Tailwind. systemd/: user-unit templates for the polling loop and the post-close report timer. docs/STRATEGY.md: wheel mechanics, risk invariants, later candidates. Defaults to BOT_MODE=dry — nothing is submitted to Alpaca until explicitly enabled in .env. ALPACA_ENV=paper by default; flipping to live requires an explicit second guard.
95 lines
2.9 KiB
Python
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
|