initial: alpaclaudia paper-trading bot + dashboard
Python bot (bot/alpaclaudia): alpaca-py client, wheel strategy (CSP + covered calls) plus equity trailing stops, risk gates (cash buffer, cost-basis guard, per-symbol concentration cap), SQLite state log, Typer CLI (tick/loop/status/ report/dump-state), Discord daily report, pytest suite. Next.js 14 dashboard (dashboard/): read-only — reads the bot's SQLite directly and pulls live account/positions/orders from Alpaca. KPIs, equity chart, positions, bot-intents audit table, and orders table. Dark UI with Tailwind. systemd/: user-unit templates for the polling loop and the post-close report timer. docs/STRATEGY.md: wheel mechanics, risk invariants, later candidates. Defaults to BOT_MODE=dry — nothing is submitted to Alpaca until explicitly enabled in .env. ALPACA_ENV=paper by default; flipping to live requires an explicit second guard.
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
CartesianGrid,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
export type EquityPoint = { ts: string; equity: number; cash: number };
|
||||
|
||||
export function EquityChart({ data }: { data: EquityPoint[] }) {
|
||||
if (!data.length) {
|
||||
return (
|
||||
<div className="panel p-6 text-mute text-sm h-[300px] flex items-center justify-center">
|
||||
No ticks recorded yet — the bot hasn't run.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="panel p-4 h-[320px]">
|
||||
<div className="text-xs text-mute mb-2 uppercase tracking-wider">Equity (ticks)</div>
|
||||
<ResponsiveContainer width="100%" height="90%">
|
||||
<AreaChart data={data} margin={{ top: 8, right: 12, bottom: 0, left: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="g-equity" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#5eead4" stopOpacity={0.55} />
|
||||
<stop offset="95%" stopColor="#5eead4" stopOpacity={0.0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid stroke="#252a35" strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="ts"
|
||||
stroke="#8a94a6"
|
||||
fontSize={11}
|
||||
tickFormatter={(v: string) => v.slice(11, 16)}
|
||||
minTickGap={32}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="#8a94a6"
|
||||
fontSize={11}
|
||||
width={64}
|
||||
tickFormatter={(v: number) => `$${(v / 1000).toFixed(1)}k`}
|
||||
domain={["auto", "auto"]}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: "#13161d",
|
||||
border: "1px solid #252a35",
|
||||
borderRadius: 8,
|
||||
fontSize: 12,
|
||||
}}
|
||||
labelStyle={{ color: "#8a94a6" }}
|
||||
formatter={(v: unknown) => {
|
||||
const n = Number(v);
|
||||
return [`$${n.toLocaleString("en-US", { maximumFractionDigits: 2 })}`, "Equity"];
|
||||
}}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="equity"
|
||||
stroke="#5eead4"
|
||||
strokeWidth={2}
|
||||
fill="url(#g-equity)"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { num, timeAgo } from "../lib/format";
|
||||
|
||||
type Intent = {
|
||||
id: number;
|
||||
ts: string;
|
||||
strategy: string;
|
||||
symbol: string;
|
||||
side: string;
|
||||
qty: number;
|
||||
order_type: string;
|
||||
limit_price: number | null;
|
||||
trail_percent: number | null;
|
||||
details_json: string;
|
||||
submitted: number;
|
||||
alpaca_order_id: string | null;
|
||||
status: string | null;
|
||||
};
|
||||
|
||||
function statusTone(status: string | null, submitted: number): string {
|
||||
if (status === "blocked") return "text-down";
|
||||
if (status === "error") return "text-down";
|
||||
if (submitted) return "text-up";
|
||||
if (status === "dry_run") return "text-mute";
|
||||
return "text-warn";
|
||||
}
|
||||
|
||||
export function IntentsTable({ intents }: { intents: Intent[] }) {
|
||||
if (!intents.length)
|
||||
return (
|
||||
<div className="panel p-6 text-mute text-sm">
|
||||
No strategy intents recorded yet. Run the bot once: <code>alpaclaudia tick</code>.
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="panel overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-border flex items-center justify-between">
|
||||
<div className="text-sm font-medium">Bot intents (latest 200)</div>
|
||||
<div className="text-xs text-mute">{intents.length}</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="table-header border-b border-border">
|
||||
<th className="text-left px-4 py-2 font-normal">When</th>
|
||||
<th className="text-left px-4 py-2 font-normal">Strategy</th>
|
||||
<th className="text-left px-4 py-2 font-normal">Symbol</th>
|
||||
<th className="text-left px-4 py-2 font-normal">Side</th>
|
||||
<th className="text-left px-4 py-2 font-normal">Type</th>
|
||||
<th className="text-right px-4 py-2 font-normal">Qty</th>
|
||||
<th className="text-left px-4 py-2 font-normal">Outcome</th>
|
||||
<th className="text-left px-4 py-2 font-normal">Rationale</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{intents.map((i) => {
|
||||
let rationale = "";
|
||||
let blockedReason = "";
|
||||
try {
|
||||
const d = JSON.parse(i.details_json || "{}");
|
||||
rationale = d.rationale || "";
|
||||
blockedReason = d.blocked_reason || d.error || "";
|
||||
} catch {}
|
||||
const tone = statusTone(i.status, i.submitted);
|
||||
return (
|
||||
<tr key={i.id} className="row-hover border-b border-border/60 align-top">
|
||||
<td className="px-4 py-2 text-mute text-xs whitespace-nowrap">
|
||||
{timeAgo(i.ts)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-xs">{i.strategy}</td>
|
||||
<td className="px-4 py-2 font-mono text-xs">{i.symbol}</td>
|
||||
<td className="px-4 py-2 uppercase text-xs">{i.side}</td>
|
||||
<td className="px-4 py-2 text-xs">{i.order_type}</td>
|
||||
<td className="px-4 py-2 text-right num">{num(i.qty, 0)}</td>
|
||||
<td className={`px-4 py-2 text-xs ${tone}`}>
|
||||
{i.status || (i.submitted ? "submitted" : "pending")}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-xs text-mute max-w-[520px]">
|
||||
{rationale}
|
||||
{blockedReason && (
|
||||
<div className="text-down mt-0.5">blocked: {blockedReason}</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
export function Kpi({
|
||||
label,
|
||||
value,
|
||||
sub,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
value: React.ReactNode;
|
||||
sub?: React.ReactNode;
|
||||
tone?: "up" | "down" | "warn" | "mute";
|
||||
}) {
|
||||
const color =
|
||||
tone === "up"
|
||||
? "text-up"
|
||||
: tone === "down"
|
||||
? "text-down"
|
||||
: tone === "warn"
|
||||
? "text-warn"
|
||||
: tone === "mute"
|
||||
? "text-mute"
|
||||
: "text-ink";
|
||||
return (
|
||||
<div className="panel p-4 flex flex-col gap-1">
|
||||
<div className="kpi-label">{label}</div>
|
||||
<div className={`text-2xl font-semibold num ${color}`}>{value}</div>
|
||||
{sub !== undefined && <div className="text-xs text-mute num">{sub}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { Order } from "../lib/alpaca";
|
||||
import { money, num, timeAgo } from "../lib/format";
|
||||
|
||||
function statusTone(status: string): string {
|
||||
const s = status.toLowerCase();
|
||||
if (["filled", "done_for_day"].includes(s)) return "text-up";
|
||||
if (["canceled", "rejected", "expired"].includes(s)) return "text-down";
|
||||
if (["new", "accepted", "pending_new", "held", "open"].includes(s)) return "text-warn";
|
||||
return "text-mute";
|
||||
}
|
||||
|
||||
export function OrdersTable({ orders }: { orders: Order[] }) {
|
||||
if (!orders.length)
|
||||
return <div className="panel p-6 text-mute text-sm">No orders yet.</div>;
|
||||
return (
|
||||
<div className="panel overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-border flex items-center justify-between">
|
||||
<div className="text-sm font-medium">Orders (Alpaca)</div>
|
||||
<div className="text-xs text-mute">{orders.length}</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="table-header border-b border-border">
|
||||
<th className="text-left px-4 py-2 font-normal">Submitted</th>
|
||||
<th className="text-left px-4 py-2 font-normal">Symbol</th>
|
||||
<th className="text-left px-4 py-2 font-normal">Side</th>
|
||||
<th className="text-left px-4 py-2 font-normal">Type</th>
|
||||
<th className="text-right px-4 py-2 font-normal">Qty</th>
|
||||
<th className="text-right px-4 py-2 font-normal">Limit / Trail</th>
|
||||
<th className="text-right px-4 py-2 font-normal">Filled</th>
|
||||
<th className="text-left px-4 py-2 font-normal">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{orders.map((o) => (
|
||||
<tr key={o.id} className="row-hover border-b border-border/60">
|
||||
<td className="px-4 py-2 text-mute text-xs">
|
||||
{timeAgo(o.submitted_at)}
|
||||
</td>
|
||||
<td className="px-4 py-2 font-mono">{o.symbol}</td>
|
||||
<td className="px-4 py-2 uppercase text-xs">{o.side}</td>
|
||||
<td className="px-4 py-2 text-xs">{o.order_type}</td>
|
||||
<td className="px-4 py-2 text-right num">{num(o.qty, 0)}</td>
|
||||
<td className="px-4 py-2 text-right num text-xs">
|
||||
{o.limit_price
|
||||
? money(o.limit_price)
|
||||
: o.trail_percent
|
||||
? `${Number(o.trail_percent).toFixed(2)}%`
|
||||
: "—"}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right num">
|
||||
{o.filled_avg_price ? money(o.filled_avg_price) : "—"}
|
||||
</td>
|
||||
<td className={`px-4 py-2 text-xs ${statusTone(o.status)}`}>
|
||||
{o.status}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { Position } from "../lib/alpaca";
|
||||
import { money, num, pct } from "../lib/format";
|
||||
|
||||
export function PositionsTable({ positions }: { positions: Position[] }) {
|
||||
if (!positions.length) {
|
||||
return (
|
||||
<div className="panel p-6 text-mute text-sm">No open positions.</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="panel overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-border flex items-center justify-between">
|
||||
<div className="text-sm font-medium">Positions</div>
|
||||
<div className="text-xs text-mute">{positions.length}</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="table-header border-b border-border">
|
||||
<th className="text-left px-4 py-2 font-normal">Symbol</th>
|
||||
<th className="text-left px-4 py-2 font-normal">Class</th>
|
||||
<th className="text-right px-4 py-2 font-normal">Qty</th>
|
||||
<th className="text-right px-4 py-2 font-normal">Entry</th>
|
||||
<th className="text-right px-4 py-2 font-normal">Mark</th>
|
||||
<th className="text-right px-4 py-2 font-normal">Market value</th>
|
||||
<th className="text-right px-4 py-2 font-normal">P&L</th>
|
||||
<th className="text-right px-4 py-2 font-normal">%</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{positions.map((p) => {
|
||||
const pl = Number(p.unrealized_pl);
|
||||
const plpc = Number(p.unrealized_plpc);
|
||||
const tone = pl > 0 ? "text-up" : pl < 0 ? "text-down" : "text-mute";
|
||||
const isOpt = p.asset_class?.toLowerCase().includes("option");
|
||||
return (
|
||||
<tr key={p.symbol} className="row-hover border-b border-border/60">
|
||||
<td className="px-4 py-2 font-mono">{p.symbol}</td>
|
||||
<td className="px-4 py-2">
|
||||
<span className="chip">
|
||||
{isOpt ? "opt" : "eq"} · {p.side || "long"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right num">{num(p.qty, 0)}</td>
|
||||
<td className="px-4 py-2 text-right num">{money(p.avg_entry_price)}</td>
|
||||
<td className="px-4 py-2 text-right num">{money(p.current_price)}</td>
|
||||
<td className="px-4 py-2 text-right num">{money(p.market_value)}</td>
|
||||
<td className={`px-4 py-2 text-right num ${tone}`}>
|
||||
{money(pl, { sign: true })}
|
||||
</td>
|
||||
<td className={`px-4 py-2 text-right num ${tone}`}>{pct(plpc)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user