feat(settings): add settings route and API updates\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

This commit is contained in:
2026-05-16 20:19:35 +02:00
parent 9b63d981b0
commit 0ee89cf052
38 changed files with 1426 additions and 562 deletions
@@ -1,4 +1,5 @@
import { settingsService } from '../../../lib/settings.server';
import { test, expect } from 'vitest';
import { settingsService } from '../../../../lib/settings.server';
test('settings API helper behavior', async () => {
const key = 'api_test_' + Date.now();
+4 -5
View File
@@ -1,12 +1,11 @@
import type { ActionFunction } from '@remix-run/node';
import { settingsService } from '~/lib/settings.server';
import { requireAdmin } from '~/lib/auth.server';
import { settingsService } from '../../../../lib/settings.server';
import { requireAdmin } from '../../../../lib/auth.server';
export const action: ActionFunction = async ({ request, params }) => {
export async function action({ request, params }: { request: Request; params: any }) {
await requireAdmin(request);
const key = params.key as string;
const body = await request.json();
if (!key) return new Response('Missing key', { status: 400 });
await settingsService.set(key, body.value, 'admin');
return new Response(null, { status: 204 });
};
}
+9 -12
View File
@@ -1,23 +1,20 @@
import type { LoaderFunction, ActionFunction } from '@remix-run/node';
import { json } from '@remix-run/node';
import { settingsService } from '~/lib/settings.server';
import { requireAdmin } from '~/lib/auth.server';
import { settingsService } from '../../../../lib/settings.server';
import { requireAdmin } from '../../../../lib/auth.server';
export const loader: LoaderFunction = async ({ request }) => {
export async function loader({ request }: { request: Request }) {
await requireAdmin(request);
await settingsService.init?.();
await (settingsService as any).init?.();
const entries: any[] = [];
// @ts-ignore access cache
for (const key of (settingsService as any).cache.keys()) {
entries.push({ key, value: await settingsService.get(key) });
}
return json(entries);
};
return new Response(JSON.stringify(entries), { headers: { 'content-type': 'application/json' } });
}
export const action: ActionFunction = async ({ request }) => {
export async function action({ request }: { request: Request }) {
await requireAdmin(request);
const body = await request.json();
if (!body || !body.key) return new Response('Missing key', { status: 400 });
const created = await settingsService.set(body.key, body.value, 'admin');
return new Response(JSON.stringify(created), { status: 201 });
};
return new Response(JSON.stringify(created), { status: 201, headers: { 'content-type': 'application/json' } });
}
+3 -31
View File
@@ -1,37 +1,9 @@
import type { AlpacaAccount } from "../../../types";
import Alpaca from "@alpacahq/alpaca-trade-api";
import alpacaService from "../../../lib/alpacaClient";
const alpaca = new Alpaca({
keyId: process.env.ALPACA_API_KEY!,
secretKey: process.env.ALPACA_SECRET_KEY!,
baseUrl: process.env.ALPACA_BASE_URL || "https://paper-api.alpaca.markets",
dataBaseUrl: process.env.ALPACA_DATA_URL || "https://data.alpaca.markets",
retryOnError: false,
});
async function fetchAlpacaAccount(): Promise<AlpacaAccount> {
export async function loader({ request }: { request: Request }) {
try {
console.log("Fetching Alpaca account with key:", process.env.ALPACA_API_KEY?.substring(0, 8) + "...");
const account = await alpaca.getAccount();
console.log("Alpaca account fetched successfully");
return {
cash: parseFloat(account.cash),
buying_power: parseFloat(account.buying_power),
portfolio_value: parseFloat(account.portfolio_value),
};
} catch (error) {
console.error("Alpaca API fetch error:", error);
return {
cash: 0,
buying_power: 0,
portfolio_value: 0,
};
}
}
export async function loader() {
try {
const account = await fetchAlpacaAccount();
const account = await alpacaService.fetchAccount();
return Response.json(account);
} catch (error) {
console.error("Alpaca API error:", error);
+42 -12
View File
@@ -1,21 +1,36 @@
import { fetchBars, fetchLatestBar } from "../../../lib/alpacaClient";
import alpacaService from "../../../lib/alpacaClient";
export async function loader({ request, params }: { request: Request; params: { ticker: string } }) {
const ticker = params.ticker?.toUpperCase();
const url = new URL(request.url);
const timeframe = url.searchParams.get("timeframe") || "1D";
const range = url.searchParams.get("range") || "1M"; // 1D, 1W, 1M, 3M, 1Y, 3Y, ALL
const mode = url.searchParams.get('mode') === 'live' ? 'live' : 'paper';
if (!ticker) {
return Response.json({ error: "Ticker is required" }, { status: 400 });
}
try {
// Normalize timeframe to Alpaca API expected values
function mapToAlpacaTimeframe(tf: string) {
switch (tf) {
case "1H":
return "1Hour";
case "1D":
return "1Day";
case "1W":
case "1M":
return "1Day"; // weekly/monthly UI ranges use daily bars
default:
return tf; // 1Min,5Min,15Min,30Min expected to be supported
}
}
const alpacaTimeframe = mapToAlpacaTimeframe(timeframe);
// Get latest bar for current price (uses paper by default unless mode=live)
let price = 0;
try {
const last = await fetchLatestBar(ticker, timeframe, mode as any);
const last = await alpacaService.fetchLatestBar(ticker, alpacaTimeframe);
price = last ? (last.ClosePrice ?? last.c ?? 0) : 0;
} catch (tradeErr) {
console.error(`API quote/${ticker}: fetchLatestBar failed`, tradeErr);
@@ -44,13 +59,15 @@ export async function loader({ request, params }: { request: Request; params: {
}
const barsOptions: any = { limit: 1000 }; // High limit for time range
if (!isIntraday && range !== "ALL") {
barsOptions.start = startDate.toISOString().split('T')[0];
} else if (!isIntraday) {
// For daily/non-intraday queries pass just the date part (YYYY-MM-DD)
if (!isIntraday) {
barsOptions.start = startDate.toISOString().split('T')[0];
} else {
// For intraday, pass full ISO start to be precise
barsOptions.start = startDate.toISOString();
}
const barsArray = await fetchBars(ticker, timeframe, barsOptions, mode as any);
const barsArray = await alpacaService.fetchBars(ticker, alpacaTimeframe, barsOptions);
// Transform to chart format
const transformedBars = barsArray.map((bar: any) => {
@@ -59,18 +76,31 @@ export async function loader({ request, params }: { request: Request; params: {
const high = typeof bar.HighPrice === 'number' ? bar.HighPrice : (typeof bar.h === 'number' ? bar.h : 0);
const low = typeof bar.LowPrice === 'number' ? bar.LowPrice : (typeof bar.l === 'number' ? bar.l : 0);
const close = typeof bar.ClosePrice === 'number' ? bar.ClosePrice : (typeof bar.c === 'number' ? bar.c : 0);
const timestamp = bar.Timestamp ?? bar.t;
const volume = typeof bar.Volume === 'number' ? bar.Volume : (typeof bar.v === 'number' ? bar.v : 0);
// Normalize timestamp to ISO string so client can parse reliably
const rawTs = bar.Timestamp ?? bar.t ?? bar.T ?? bar.timestamp;
let dateObj: Date | null = null;
if (rawTs != null) {
if (typeof rawTs === 'number') {
// If it's likely in seconds (< 1e12) convert to ms
const asMs = rawTs > 1e12 ? rawTs : rawTs * 1000;
dateObj = new Date(asMs);
} else {
dateObj = new Date(rawTs);
}
}
const iso = dateObj && !isNaN(dateObj.getTime()) ? dateObj.toISOString() : null;
return {
t: timestamp,
t: iso,
o: open,
h: high,
l: low,
c: close,
v: volume,
};
}).filter((bar: any) => bar.o > 0 && bar.h > 0 && bar.l > 0 && bar.c > 0);
}).filter((bar: any) => bar.o > 0 && bar.h > 0 && bar.l > 0 && bar.c > 0 && bar.t);
return Response.json({
ticker,
+23 -3
View File
@@ -1,3 +1,5 @@
/* TRADINGGRAPH related file */
// Server-only imports are loaded dynamically inside the action to avoid client bundling issues
export async function action({ request }: { request: Request }) {
@@ -18,7 +20,7 @@ export async function action({ request }: { request: Request }) {
const { OpenRouterClient } = await import("../../lib/openrouter");
const { TradingGraph } = await import("../../agents/tradingGraph");
const { db } = await import("../../lib/db.server");
const { fetchAccount, fetchRecentCloses } = await import("../../lib/alpacaClient");
const { fetchAccount, fetchRecentCloses, fetchBars } = await import("../../lib/alpacaClient");
const apiKey = process.env.OPENROUTER_API_KEY;
console.log("[analyze] API key configured:", !!apiKey, apiKey?.substring(0, 10) + "...");
@@ -63,9 +65,20 @@ export async function action({ request }: { request: Request }) {
// Fetch latest Alpaca account and recent prices; abort if unavailable
let account: any = undefined;
let prices: number[] = [];
let recentBars: any[] = [];
try {
account = await fetchAccount();
prices = await fetchRecentCloses(ticker);
// Also fetch recent intraday bars to enable deterministic execution plan calculation
try {
recentBars = await fetchBars(ticker, '1Min', { limit: 200 });
// derive prices from bars if available (prefer freshest closes)
if (recentBars && recentBars.length) {
prices = recentBars.map((b: any) => (typeof b.ClosePrice === 'number' ? b.ClosePrice : (typeof b.c === 'number' ? b.c : 0))).filter((p: number) => p > 0);
}
} catch (barErr) {
console.warn('[analyze] Failed to fetch recent bars for deterministic execution plan:', barErr);
}
} catch (e) {
console.error("[analyze] Failed to fetch Alpaca data before analysis:", e);
return Response.json({ error: "Failed to fetch Alpaca data: " + String(e) }, { status: 502 });
@@ -75,6 +88,7 @@ export async function action({ request }: { request: Request }) {
financialData: `Financial data for ${ticker} as of ${date}`,
technicalData: {
prices,
bars: recentBars,
sma: 0,
ema: 0,
rsi: 0,
@@ -119,11 +133,17 @@ export async function action({ request }: { request: Request }) {
console.warn("Failed to enrich execution plan:", e);
}
console.log("[analyze] Decision received:", JSON.stringify(decision));
// Avoid logging potentially verbose debate rounds to server CLI
try {
const { debateRounds, ...decisionSafe } = decision as any;
console.log("[analyze] Decision received (debate redacted):", JSON.stringify(decisionSafe));
} catch (e) {
console.log("[analyze] Decision received");
}
return Response.json(decision);
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
console.error("[analyze] Error:", error);
return Response.json({ error: message }, { status: 500 });
}
}
}
+3
View File
@@ -0,0 +1,3 @@
export async function loader(){
return Response.json({ ok: true, msg: "price-stream-test" });
}
+47 -15
View File
@@ -1,11 +1,18 @@
import { fetchLatestBar } from "../../lib/alpacaClient";
import alpacaService from "../../lib/alpacaClient";
export async function loader({ request }: { request: Request }) {
const url = new URL(request.url);
const ticker = (url.searchParams.get("ticker") || "").toUpperCase();
if (!ticker) return new Response("ticker required", { status: 400 });
const timeframe = url.searchParams.get("timeframe") || "1Min"; // default to 1Min bars for live price
const mode = url.searchParams.get("mode") === "live" ? 'live' : 'paper';
function mapToAlpacaTimeframe(tf: string) {
switch (tf) {
case "1H": return "1Hour";
case "1D": return "1Day";
default: return tf;
}
}
const alpacaTimeframe = mapToAlpacaTimeframe(timeframe);
const headers = new Headers({
"Content-Type": "text/event-stream",
@@ -13,9 +20,8 @@ export async function loader({ request }: { request: Request }) {
Connection: "keep-alive",
});
// Create a ReadableStream that polls latest bar every second and pushes SSE
// Create a ReadableStream that polls latest bar with adaptive backoff and SSE
let closed = false;
let interval: any;
const stream = new ReadableStream({
start(controller) {
// helper to push SSE event
@@ -31,28 +37,54 @@ export async function loader({ request }: { request: Request }) {
// initial ping
pushEvent({ event: "connected", ticker, timeframe });
interval = setInterval(async () => {
const baseDelay = 5000; // start with 5s between Alpaca calls
const maxDelay = 60000; // cap backoff at 60s
let delay = baseDelay;
let lastBarId: string | number | null = null;
async function poll() {
if (closed) return;
try {
const last = await fetchLatestBar(ticker, timeframe, mode);
const last = await alpacaService.fetchLatestBar(ticker, alpacaTimeframe);
const price = last ? (last.ClosePrice ?? last.c ?? null) : null;
// create a dedupe id from available fields
const barId = last ? (last.T ?? last.t ?? last.Timestamp ?? last.ClosePrice ?? last.c ?? null) : null;
if (price != null) {
pushEvent({ price, ts: Date.now(), timeframe });
if (barId == null || barId !== lastBarId) {
lastBarId = barId;
pushEvent({ price, ts: Date.now(), timeframe });
}
} else {
pushEvent({ error: "no_bar", ts: Date.now() });
}
} catch (err) {
console.error("price-stream: error fetching latest bar", err);
pushEvent({ error: String(err), ts: Date.now() });
// keep trying
}
}, 1000);
// no-op here; cleanup handled in cancel
// on success, reset backoff
delay = baseDelay;
} catch (err: any) {
const msg = String(err?.message ?? err ?? "error");
console.error("price-stream: error fetching latest bar", msg);
pushEvent({ error: msg, ts: Date.now() });
// apply exponential backoff on rate limit errors
if (/429|too many requests/i.test(msg)) {
delay = Math.min(delay * 2, maxDelay);
console.warn(`price-stream: rate limited, backing off to ${delay}ms`);
} else {
// mild backoff for other errors
delay = Math.min(Math.floor(delay * 1.5), maxDelay);
}
}
if (!closed) setTimeout(poll, delay);
}
// start polling immediately
setTimeout(poll, 0);
},
cancel() {
closed = true;
clearInterval(interval);
},
});