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,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
|
||||
@@ -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.
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
@@ -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) };
|
||||
}
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
||||
@@ -0,0 +1,8 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
experimental: {
|
||||
serverComponentsExternalPackages: ["better-sqlite3"],
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
Generated
+2446
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
plugins: { tailwindcss: {}, autoprefixer: {} },
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user