initial: alpaclaudia paper-trading bot + dashboard

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

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

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

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

Defaults to BOT_MODE=dry — nothing is submitted to Alpaca until explicitly
enabled in .env. ALPACA_ENV=paper by default; flipping to live requires an
explicit second guard.
This commit is contained in:
2026-04-16 21:38:25 +02:00
commit 39875112a0
46 changed files with 5195 additions and 0 deletions
+32
View File
@@ -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(),
});
}
+73
View File
@@ -0,0 +1,73 @@
"use client";
import {
Area,
AreaChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
export type EquityPoint = { ts: string; equity: number; cash: number };
export function EquityChart({ data }: { data: EquityPoint[] }) {
if (!data.length) {
return (
<div className="panel p-6 text-mute text-sm h-[300px] flex items-center justify-center">
No ticks recorded yet the bot hasn't run.
</div>
);
}
return (
<div className="panel p-4 h-[320px]">
<div className="text-xs text-mute mb-2 uppercase tracking-wider">Equity (ticks)</div>
<ResponsiveContainer width="100%" height="90%">
<AreaChart data={data} margin={{ top: 8, right: 12, bottom: 0, left: 0 }}>
<defs>
<linearGradient id="g-equity" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#5eead4" stopOpacity={0.55} />
<stop offset="95%" stopColor="#5eead4" stopOpacity={0.0} />
</linearGradient>
</defs>
<CartesianGrid stroke="#252a35" strokeDasharray="3 3" vertical={false} />
<XAxis
dataKey="ts"
stroke="#8a94a6"
fontSize={11}
tickFormatter={(v: string) => v.slice(11, 16)}
minTickGap={32}
/>
<YAxis
stroke="#8a94a6"
fontSize={11}
width={64}
tickFormatter={(v: number) => `$${(v / 1000).toFixed(1)}k`}
domain={["auto", "auto"]}
/>
<Tooltip
contentStyle={{
background: "#13161d",
border: "1px solid #252a35",
borderRadius: 8,
fontSize: 12,
}}
labelStyle={{ color: "#8a94a6" }}
formatter={(v: unknown) => {
const n = Number(v);
return [`$${n.toLocaleString("en-US", { maximumFractionDigits: 2 })}`, "Equity"];
}}
/>
<Area
type="monotone"
dataKey="equity"
stroke="#5eead4"
strokeWidth={2}
fill="url(#g-equity)"
/>
</AreaChart>
</ResponsiveContainer>
</div>
);
}
@@ -0,0 +1,91 @@
import { num, timeAgo } from "../lib/format";
type Intent = {
id: number;
ts: string;
strategy: string;
symbol: string;
side: string;
qty: number;
order_type: string;
limit_price: number | null;
trail_percent: number | null;
details_json: string;
submitted: number;
alpaca_order_id: string | null;
status: string | null;
};
function statusTone(status: string | null, submitted: number): string {
if (status === "blocked") return "text-down";
if (status === "error") return "text-down";
if (submitted) return "text-up";
if (status === "dry_run") return "text-mute";
return "text-warn";
}
export function IntentsTable({ intents }: { intents: Intent[] }) {
if (!intents.length)
return (
<div className="panel p-6 text-mute text-sm">
No strategy intents recorded yet. Run the bot once: <code>alpaclaudia tick</code>.
</div>
);
return (
<div className="panel overflow-hidden">
<div className="px-4 py-3 border-b border-border flex items-center justify-between">
<div className="text-sm font-medium">Bot intents (latest 200)</div>
<div className="text-xs text-mute">{intents.length}</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="table-header border-b border-border">
<th className="text-left px-4 py-2 font-normal">When</th>
<th className="text-left px-4 py-2 font-normal">Strategy</th>
<th className="text-left px-4 py-2 font-normal">Symbol</th>
<th className="text-left px-4 py-2 font-normal">Side</th>
<th className="text-left px-4 py-2 font-normal">Type</th>
<th className="text-right px-4 py-2 font-normal">Qty</th>
<th className="text-left px-4 py-2 font-normal">Outcome</th>
<th className="text-left px-4 py-2 font-normal">Rationale</th>
</tr>
</thead>
<tbody>
{intents.map((i) => {
let rationale = "";
let blockedReason = "";
try {
const d = JSON.parse(i.details_json || "{}");
rationale = d.rationale || "";
blockedReason = d.blocked_reason || d.error || "";
} catch {}
const tone = statusTone(i.status, i.submitted);
return (
<tr key={i.id} className="row-hover border-b border-border/60 align-top">
<td className="px-4 py-2 text-mute text-xs whitespace-nowrap">
{timeAgo(i.ts)}
</td>
<td className="px-4 py-2 text-xs">{i.strategy}</td>
<td className="px-4 py-2 font-mono text-xs">{i.symbol}</td>
<td className="px-4 py-2 uppercase text-xs">{i.side}</td>
<td className="px-4 py-2 text-xs">{i.order_type}</td>
<td className="px-4 py-2 text-right num">{num(i.qty, 0)}</td>
<td className={`px-4 py-2 text-xs ${tone}`}>
{i.status || (i.submitted ? "submitted" : "pending")}
</td>
<td className="px-4 py-2 text-xs text-mute max-w-[520px]">
{rationale}
{blockedReason && (
<div className="text-down mt-0.5">blocked: {blockedReason}</div>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
export function Kpi({
label,
value,
sub,
tone,
}: {
label: string;
value: React.ReactNode;
sub?: React.ReactNode;
tone?: "up" | "down" | "warn" | "mute";
}) {
const color =
tone === "up"
? "text-up"
: tone === "down"
? "text-down"
: tone === "warn"
? "text-warn"
: tone === "mute"
? "text-mute"
: "text-ink";
return (
<div className="panel p-4 flex flex-col gap-1">
<div className="kpi-label">{label}</div>
<div className={`text-2xl font-semibold num ${color}`}>{value}</div>
{sub !== undefined && <div className="text-xs text-mute num">{sub}</div>}
</div>
);
}
+65
View File
@@ -0,0 +1,65 @@
import type { Order } from "../lib/alpaca";
import { money, num, timeAgo } from "../lib/format";
function statusTone(status: string): string {
const s = status.toLowerCase();
if (["filled", "done_for_day"].includes(s)) return "text-up";
if (["canceled", "rejected", "expired"].includes(s)) return "text-down";
if (["new", "accepted", "pending_new", "held", "open"].includes(s)) return "text-warn";
return "text-mute";
}
export function OrdersTable({ orders }: { orders: Order[] }) {
if (!orders.length)
return <div className="panel p-6 text-mute text-sm">No orders yet.</div>;
return (
<div className="panel overflow-hidden">
<div className="px-4 py-3 border-b border-border flex items-center justify-between">
<div className="text-sm font-medium">Orders (Alpaca)</div>
<div className="text-xs text-mute">{orders.length}</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="table-header border-b border-border">
<th className="text-left px-4 py-2 font-normal">Submitted</th>
<th className="text-left px-4 py-2 font-normal">Symbol</th>
<th className="text-left px-4 py-2 font-normal">Side</th>
<th className="text-left px-4 py-2 font-normal">Type</th>
<th className="text-right px-4 py-2 font-normal">Qty</th>
<th className="text-right px-4 py-2 font-normal">Limit / Trail</th>
<th className="text-right px-4 py-2 font-normal">Filled</th>
<th className="text-left px-4 py-2 font-normal">Status</th>
</tr>
</thead>
<tbody>
{orders.map((o) => (
<tr key={o.id} className="row-hover border-b border-border/60">
<td className="px-4 py-2 text-mute text-xs">
{timeAgo(o.submitted_at)}
</td>
<td className="px-4 py-2 font-mono">{o.symbol}</td>
<td className="px-4 py-2 uppercase text-xs">{o.side}</td>
<td className="px-4 py-2 text-xs">{o.order_type}</td>
<td className="px-4 py-2 text-right num">{num(o.qty, 0)}</td>
<td className="px-4 py-2 text-right num text-xs">
{o.limit_price
? money(o.limit_price)
: o.trail_percent
? `${Number(o.trail_percent).toFixed(2)}%`
: "—"}
</td>
<td className="px-4 py-2 text-right num">
{o.filled_avg_price ? money(o.filled_avg_price) : "—"}
</td>
<td className={`px-4 py-2 text-xs ${statusTone(o.status)}`}>
{o.status}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
@@ -0,0 +1,60 @@
import type { Position } from "../lib/alpaca";
import { money, num, pct } from "../lib/format";
export function PositionsTable({ positions }: { positions: Position[] }) {
if (!positions.length) {
return (
<div className="panel p-6 text-mute text-sm">No open positions.</div>
);
}
return (
<div className="panel overflow-hidden">
<div className="px-4 py-3 border-b border-border flex items-center justify-between">
<div className="text-sm font-medium">Positions</div>
<div className="text-xs text-mute">{positions.length}</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="table-header border-b border-border">
<th className="text-left px-4 py-2 font-normal">Symbol</th>
<th className="text-left px-4 py-2 font-normal">Class</th>
<th className="text-right px-4 py-2 font-normal">Qty</th>
<th className="text-right px-4 py-2 font-normal">Entry</th>
<th className="text-right px-4 py-2 font-normal">Mark</th>
<th className="text-right px-4 py-2 font-normal">Market value</th>
<th className="text-right px-4 py-2 font-normal">P&amp;L</th>
<th className="text-right px-4 py-2 font-normal">%</th>
</tr>
</thead>
<tbody>
{positions.map((p) => {
const pl = Number(p.unrealized_pl);
const plpc = Number(p.unrealized_plpc);
const tone = pl > 0 ? "text-up" : pl < 0 ? "text-down" : "text-mute";
const isOpt = p.asset_class?.toLowerCase().includes("option");
return (
<tr key={p.symbol} className="row-hover border-b border-border/60">
<td className="px-4 py-2 font-mono">{p.symbol}</td>
<td className="px-4 py-2">
<span className="chip">
{isOpt ? "opt" : "eq"} · {p.side || "long"}
</span>
</td>
<td className="px-4 py-2 text-right num">{num(p.qty, 0)}</td>
<td className="px-4 py-2 text-right num">{money(p.avg_entry_price)}</td>
<td className="px-4 py-2 text-right num">{money(p.current_price)}</td>
<td className="px-4 py-2 text-right num">{money(p.market_value)}</td>
<td className={`px-4 py-2 text-right num ${tone}`}>
{money(pl, { sign: true })}
</td>
<td className={`px-4 py-2 text-right num ${tone}`}>{pct(plpc)}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}
+13
View File
@@ -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; }
+15
View File
@@ -0,0 +1,15 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "alpaclaudia",
description: "Alpaca paper-trading bot dashboard",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
+75
View File
@@ -0,0 +1,75 @@
const BASE = process.env.ALPACA_BASE_URL || "https://paper-api.alpaca.markets";
function headers(): HeadersInit {
const key = process.env.ALPACA_API_KEY || "";
const sec = process.env.ALPACA_API_SECRET || "";
return {
"APCA-API-KEY-ID": key,
"APCA-API-SECRET-KEY": sec,
Accept: "application/json",
};
}
export function credentialsPresent(): boolean {
return Boolean(process.env.ALPACA_API_KEY && process.env.ALPACA_API_SECRET);
}
async function alpacaGet<T>(pathname: string): Promise<T | null> {
if (!credentialsPresent()) return null;
try {
const res = await fetch(`${BASE}${pathname}`, {
headers: headers(),
cache: "no-store",
});
if (!res.ok) return null;
return (await res.json()) as T;
} catch {
return null;
}
}
export type Account = {
equity: string;
cash: string;
buying_power: string;
portfolio_value: string;
status: string;
pattern_day_trader?: boolean;
last_equity?: string;
};
export type Position = {
symbol: string;
qty: string;
avg_entry_price: string;
current_price: string;
market_value: string;
unrealized_pl: string;
unrealized_plpc: string;
side: string;
asset_class: string;
};
export type Order = {
id: string;
symbol: string;
side: string;
qty: string | null;
filled_qty: string | null;
order_type: string;
status: string;
submitted_at: string;
filled_at: string | null;
filled_avg_price: string | null;
trail_percent?: string | null;
limit_price?: string | null;
stop_price?: string | null;
asset_class?: string;
};
export type Clock = { is_open: boolean; next_open: string; next_close: string };
export const getAccount = () => alpacaGet<Account>("/v2/account");
export const getPositions = () => alpacaGet<Position[]>("/v2/positions");
export const getOrders = () => alpacaGet<Order[]>("/v2/orders?status=all&limit=50&nested=true");
export const getClock = () => alpacaGet<Clock>("/v2/clock");
+72
View File
@@ -0,0 +1,72 @@
import Database from "better-sqlite3";
import path from "node:path";
type Row = Record<string, unknown>;
let _db: Database.Database | null = null;
function dbPath(): string {
const raw = process.env.ALPACLAUDIA_DB || "../data/alpaclaudia.db";
return path.isAbsolute(raw) ? raw : path.resolve(process.cwd(), raw);
}
function getDb(): Database.Database | null {
if (_db) return _db;
try {
_db = new Database(dbPath(), { readonly: true, fileMustExist: true });
return _db;
} catch {
return null;
}
}
export function recentTicks(limit = 300): Row[] {
const db = getDb();
if (!db) return [];
return db
.prepare(`SELECT id, ts, equity, cash, buying_power, mode FROM ticks ORDER BY id DESC LIMIT ?`)
.all(limit) as Row[];
}
export function recentIntents(limit = 200): Row[] {
const db = getDb();
if (!db) return [];
return db
.prepare(
`SELECT id, ts, strategy, symbol, side, qty, order_type, limit_price,
trail_percent, details_json, submitted, alpaca_order_id, status
FROM order_intents ORDER BY id DESC LIMIT ?`
)
.all(limit) as Row[];
}
export function intentsSince(iso: string): Row[] {
const db = getDb();
if (!db) return [];
return db
.prepare(
`SELECT id, ts, strategy, symbol, side, qty, order_type, limit_price,
trail_percent, details_json, submitted, alpaca_order_id, status
FROM order_intents WHERE ts >= ? ORDER BY id DESC`
)
.all(iso) as Row[];
}
export function recentEvents(limit = 100): Row[] {
const db = getDb();
if (!db) return [];
return db
.prepare(`SELECT id, ts, kind, payload_json FROM events ORDER BY id DESC LIMIT ?`)
.all(limit) as Row[];
}
export function dbStatus(): { ok: boolean; path: string; error?: string } {
const p = dbPath();
try {
const db = getDb();
if (!db) return { ok: false, path: p, error: "could not open db" };
return { ok: true, path: p };
} catch (e: unknown) {
return { ok: false, path: p, error: e instanceof Error ? e.message : String(e) };
}
}
+40
View File
@@ -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`;
}
+122
View File
@@ -0,0 +1,122 @@
import { Kpi } from "./components/kpi";
import { EquityChart, type EquityPoint } from "./components/equity-chart";
import { PositionsTable } from "./components/positions-table";
import { OrdersTable } from "./components/orders-table";
import { IntentsTable } from "./components/intents-table";
import { dbStatus, recentIntents, recentTicks } from "./lib/db";
import {
credentialsPresent,
getAccount,
getClock,
getOrders,
getPositions,
} from "./lib/alpaca";
import { money, pct } from "./lib/format";
export const dynamic = "force-dynamic";
export const revalidate = 0;
export default async function Page() {
const [account, positions, orders, clock] = await Promise.all([
getAccount(),
getPositions(),
getOrders(),
getClock(),
]);
const ticks = recentTicks(300).reverse();
const intents = recentIntents(200);
const db = dbStatus();
const chartData: EquityPoint[] = ticks
.filter((t) => t.equity !== null)
.map((t) => ({
ts: String(t.ts),
equity: Number(t.equity || 0),
cash: Number(t.cash || 0),
}));
const equity = Number(account?.equity || 0);
const lastEquity = Number(account?.last_equity || equity);
const dayPL = equity - lastEquity;
const dayPct = lastEquity ? dayPL / lastEquity : 0;
const creds = credentialsPresent();
return (
<main className="min-h-screen p-6 max-w-[1400px] mx-auto">
<header className="flex flex-wrap items-baseline justify-between gap-4 mb-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">
alpaclaudia<span className="text-brand">.</span>
</h1>
<p className="text-sm text-mute mt-1">
Alpaca paper-trading bot Wheel + trailing stops.
</p>
</div>
<div className="flex items-center gap-2 text-xs">
{creds ? (
<span className="chip text-up border-up/30">
Alpaca connected
</span>
) : (
<span className="chip text-down border-down/30">
No Alpaca creds
</span>
)}
{clock && (
<span className="chip">
market {clock.is_open ? "open" : "closed"}
</span>
)}
{db.ok ? (
<span className="chip text-mute">db ok</span>
) : (
<span className="chip text-down">db missing</span>
)}
</div>
</header>
<section className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
<Kpi
label="Equity"
value={account ? money(account.equity) : "—"}
sub={account ? `PV ${money(account.portfolio_value)}` : "not connected"}
/>
<Kpi
label="Cash"
value={account ? money(account.cash) : "—"}
sub={account ? `BP ${money(account.buying_power)}` : undefined}
/>
<Kpi
label="Day P&L"
value={money(dayPL, { sign: true })}
sub={pct(dayPct)}
tone={dayPL > 0 ? "up" : dayPL < 0 ? "down" : "mute"}
/>
<Kpi
label="Positions"
value={positions?.length ?? 0}
sub={`${(positions || []).filter((p) => p.asset_class?.toLowerCase().includes("option")).length} option · ${
(positions || []).filter((p) => !p.asset_class?.toLowerCase().includes("option")).length
} equity`}
/>
</section>
<section className="mb-6">
<EquityChart data={chartData} />
</section>
<section className="grid grid-cols-1 gap-6">
<PositionsTable positions={positions || []} />
<IntentsTable intents={intents as any} />
<OrdersTable orders={orders || []} />
</section>
<footer className="mt-8 text-xs text-mute">
db: <code>{db.path}</code>
{db.error && <span className="text-down"> {db.error}</span>}
</footer>
</main>
);
}