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,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`;
|
||||
}
|
||||
Reference in New Issue
Block a user