Compare commits

..

23 Commits

Author SHA1 Message Date
henry cc22174b78 Add tests for Alpaca Historical Bars API
- Implemented tests for fetching historical bars for AAPL with different timeframes (1D, 5Min, 1H).
- Verified response structure and data integrity for each timeframe.
- Ensured that the API returns valid data and appropriate status for the requests.
2026-05-14 12:50:14 +02:00
henry d1a84325ae fix: pass bars data to TradingView chart correctly
- Include bars in loader response
- Convert timestamp to YYYY-MM-DD format for TradingView
- Fix error response to always include bars array
2026-05-14 11:23:33 +02:00
henry b4076f89b6 fix: add bars data for TradingView chart from Alpaca
- Modify quote.ts to fetch historical bars for chart data
- Update analyze.ticker.tsx to pass bars data to TradingViewChart
- Chart now displays candlestick data from Alpaca API
2026-05-14 11:19:22 +02:00
henry 77032a3c3a fix: improve stock detail page design
- Fix font colors (gray-900 for headings, gray-600 for secondary text)
- Replace JSON pre block with styled orders table
- Update design spec with visual details
2026-05-14 11:08:29 +02:00
henry 834a427c18 fix: use request URL for base URL in stock detail loader
- Fix TypeError from undefined BASE_URL in loader
- Use request.url to construct base URL dynamically
2026-05-14 11:04:17 +02:00
henry 2e22fd5635 feat: add stock detail page with chart, position, and orders
- Add /api/alpaca/orders endpoint for order history
- Add TradingView chart component for candlestick visualization
- Add /analyze/:ticker route with position and orders display
- Make ticker cells in analyze page clickable for navigation
2026-05-14 11:00:35 +02:00
henry 043c3d5afe feat: delete ticker from database when removed from portfolio
- Add DELETE support to /api/stocks endpoint via _method parameter
- Modify removeStock to delete db- prefixed entries from database
- Add confirmation dialog on delete button click
- Add test for stock deletion
2026-05-14 10:29:27 +02:00
henry 3340fd11ca feat: add stock database with prisma for portfolio persistence
- Initialize Prisma with SQLite and Stock model
- Create database service layer with singleton client
- Add API routes for stock CRUD operations
- Integrate database with analyze page to persist ticker entries
- Add Playwright tests for stock database functionality
2026-05-14 10:23:56 +02:00
henry f40eec1420 feat: initialize prisma with Stock model 2026-05-14 09:55:24 +02:00
henry 0fdd8432a0 fix: add text color to StockViewer input for visibility 2026-05-14 08:17:05 +02:00
henry 41fdc08a6e style: update inputs per design guidelines 2026-05-14 08:15:56 +02:00
henry 988368326c fix: add text color to input fields for visibility 2026-05-14 08:15:17 +02:00
henry 944a7280c9 style: update analyze API to use JSON body 2026-05-14 08:12:37 +02:00
henry 503a1c8bde feat: add analyze page with dataflow visualization 2026-05-14 08:12:12 +02:00
henry bd033a5d84 feat: add analysis API route 2026-05-14 08:06:24 +02:00
henry 0930e11495 feat: add trading graph orchestrator 2026-05-14 08:04:14 +02:00
henry 86fe670ca0 feat: add trader agent 2026-05-14 08:01:59 +02:00
henry e913b32f34 feat: add bullish and bearish researcher agents 2026-05-14 07:59:44 +02:00
henry eb66485e76 feat: add sentiment analyst agent 2026-05-14 07:57:43 +02:00
henry 3536193746 feat: add technical analyst agent 2026-05-14 07:55:42 +02:00
henry 55d6ba4fee feat: add fundamentals analyst agent 2026-05-14 07:54:00 +02:00
henry 7b81adb6a2 feat: add agent types and interfaces 2026-05-14 07:51:33 +02:00
henry 5a99273c9d feat: add OpenRouter API client with free model support 2026-05-14 07:48:51 +02:00
49 changed files with 4470 additions and 5 deletions
+4 -1
View File
@@ -1,3 +1,6 @@
ALPACA_API_KEY=your_alpaca_api_key_here ALPACA_API_KEY=your_alpaca_api_key_here
ALPACA_SECRET_KEY=your_alpaca_secret_key_here ALPACA_SECRET_KEY=your_alpaca_secret_key_here
ALPACA_BASE_URL=https://paper-api.alpaca.markets ALPACA_BASE_URL=https://paper-api.alpaca.markets
ALPACA_DATA_URL=https://data.alpaca.markets
OPENROUTER_API_KEY=your_openrouter_api_key_here
BASE_URL=http://localhost:5173
+3
View File
@@ -5,3 +5,6 @@
# React Router # React Router
/.react-router/ /.react-router/
/build/ /build/
/generated/prisma
/prisma/dev.db
+35
View File
@@ -0,0 +1,35 @@
import { describe, it, expect } from "vitest";
import { FundamentalsAnalyst } from "../fundamentals";
import type { OpenRouterClient } from "../../lib/openrouter";
describe("FundamentalsAnalyst", () => {
it("should analyze company fundamentals", async () => {
const mockClient = {
createChatCompletion: async () => ({
choices: [
{
message: {
content:
'{"signal":"bullish","confidence":0.85,"reasoning":"Strong revenue growth"}',
},
},
],
}),
} as unknown as OpenRouterClient;
const analyst = new FundamentalsAnalyst(mockClient);
const result = await analyst.analyze("AAPL", "Revenue: 100B, Profit: 20B");
expect(result.analyst).toBe("fundamentals");
expect(result.signal.signal).toBe("bullish");
});
it("should use specified model", () => {
const mockClient = {} as unknown as OpenRouterClient;
const analyst = new FundamentalsAnalyst(mockClient, {
model: "custom/model",
});
expect(analyst.getModel()).toBe("custom/model");
});
});
+37
View File
@@ -0,0 +1,37 @@
import { describe, it, expect, vi } from "vitest";
import { BullishResearcher, BearishResearcher } from "../researchers";
import type { AnalystReport } from "../../types/agents";
describe("Researchers", () => {
const mockClient = {
createChatCompletion: vi.fn().mockResolvedValue({
choices: [{ message: { content: "Bullish thesis content" } }],
}),
};
const mockReports: AnalystReport[] = [
{
analyst: "fundamentals",
report: "Strong earnings growth",
signal: {
agent: "fundamentals",
signal: "bullish",
confidence: 0.8,
reasoning: "Revenue up 20%",
timestamp: "2024-01-01",
},
},
];
it("should create bullish researcher", async () => {
const researcher = new BullishResearcher(mockClient as any);
const result = await researcher.research("AAPL", mockReports);
expect(result.researcher).toBe("bullish");
});
it("should create bearish researcher", async () => {
const researcher = new BearishResearcher(mockClient as any);
const result = await researcher.research("AAPL", mockReports);
expect(result.researcher).toBe("bearish");
});
});
+38
View File
@@ -0,0 +1,38 @@
import { describe, it, expect } from "vitest";
import { SentimentAnalyst } from "../sentiment";
import type { OpenRouterClient } from "../../lib/openrouter";
describe("SentimentAnalyst", () => {
it("should analyze sentiment from headlines", async () => {
const mockClient = {
createChatCompletion: async () => ({
choices: [
{
message: {
content:
'{"signal":"bullish","confidence":0.85,"reasoning":"Positive sentiment from news"}',
},
},
],
}),
} as unknown as OpenRouterClient;
const analyst = new SentimentAnalyst(mockClient);
const result = await analyst.analyze("AAPL", {
headlines: ["Apple beats earnings", "New iPhone launch"],
source: "news",
});
expect(result.analyst).toBe("sentiment");
expect(result.signal.signal).toBe("bullish");
});
it("should use specified model", () => {
const mockClient = {} as unknown as OpenRouterClient;
const analyst = new SentimentAnalyst(mockClient, {
model: "custom/model",
});
expect(analyst.getModel()).toBe("custom/model");
});
});
+41
View File
@@ -0,0 +1,41 @@
import { describe, it, expect } from "vitest";
import { TechnicalAnalyst } from "../technical";
import type { OpenRouterClient } from "../../lib/openrouter";
describe("TechnicalAnalyst", () => {
it("should analyze technical indicators", async () => {
const mockClient = {
createChatCompletion: async () => ({
choices: [
{
message: {
content:
'{"signal":"bullish","confidence":0.85,"reasoning":"Strong technical setup"}',
},
},
],
}),
} as unknown as OpenRouterClient;
const analyst = new TechnicalAnalyst(mockClient);
const result = await analyst.analyze("AAPL", {
prices: [100, 101, 102, 103, 104, 105, 106, 107, 108, 109],
sma: 105,
ema: 106,
rsi: 65,
macd: 2.5,
});
expect(result.analyst).toBe("technical");
expect(result.signal.signal).toBe("bullish");
});
it("should use specified model", () => {
const mockClient = {} as unknown as OpenRouterClient;
const analyst = new TechnicalAnalyst(mockClient, {
model: "custom/model",
});
expect(analyst.getModel()).toBe("custom/model");
});
});
+39
View File
@@ -0,0 +1,39 @@
import { describe, it, expect, vi } from "vitest";
import { Trader } from "../trader";
import type { AnalystReport, DebateRound } from "../../types/agents";
describe("Trader", () => {
const mockClient = {
createChatCompletion: vi.fn().mockResolvedValue({
choices: [{ message: { content: '{"action": "buy", "confidence": 0.75, "reasoning": "Strong bullish signals"}' } }],
}),
};
const mockReports: AnalystReport[] = [
{
analyst: "fundamentals",
report: "Strong earnings growth",
signal: {
agent: "fundamentals",
signal: "bullish",
confidence: 0.8,
reasoning: "Revenue up 20%",
timestamp: "2024-01-01",
},
},
];
const mockDebates: DebateRound[] = [
{
bullishView: "Strong fundamentals",
bearishView: "Market volatility",
researcher: "bullish",
},
];
it("should make trading decision", async () => {
const trader = new Trader(mockClient as any);
const decision = await trader.decide("AAPL", mockReports, mockDebates);
expect(decision.action).toBe("buy");
});
});
+32
View File
@@ -0,0 +1,32 @@
import { describe, it, expect, vi } from "vitest";
import { TradingGraph } from "../tradingGraph";
describe("TradingGraph", () => {
const mockClient = {
createChatCompletion: vi.fn().mockResolvedValue({
choices: [{ message: { content: '{"signal":"bullish","confidence":0.8,"reasoning":"Test"}' } }],
}),
};
const mockInput = {
financialData: "Revenue: 1B, Growth: 10%, Debt: low",
technicalData: {
prices: [100, 101, 102, 103, 104, 105, 106, 107, 108, 109],
sma: 105,
ema: 106,
rsi: 65,
macd: 2.5,
},
sentimentData: {
headlines: ["Company beats earnings expectations"],
source: "news" as const,
},
};
it("should run full analysis", async () => {
const graph = new TradingGraph(mockClient as any);
const decision = await graph.propagate("AAPL", mockInput);
expect(decision).toHaveProperty("action");
expect(decision).toHaveProperty("confidence");
});
});
+80
View File
@@ -0,0 +1,80 @@
import { OpenRouterClient } from "../lib/openrouter";
import type { AnalystReport, AgentSignal, SignalType } from "../types/agents";
export interface FundamentalsConfig {
model?: string;
}
export class FundamentalsAnalyst {
private client: OpenRouterClient;
private model: string;
constructor(client: OpenRouterClient, config?: FundamentalsConfig) {
this.client = client;
this.model = config?.model ?? "openai/gpt-oss-120b:free";
}
getModel(): string {
return this.model;
}
async analyze(ticker: string, financialData: string): Promise<AnalystReport> {
const messages = [
{
role: "system" as const,
content:
"You are a fundamental analyst. Analyze the financial data for the given ticker and provide a bullish, bearish, or neutral signal with reasoning. Respond in JSON format with 'signal', 'confidence', and 'reasoning' fields.",
},
{
role: "user" as const,
content: `Analyze ${ticker} fundamentals:\n${financialData}`,
},
];
const response = await this.client.createChatCompletion(
messages,
this.model
);
const parsedResponse = response as {
choices?: Array<{ message?: { content?: string } }>;
};
const content = parsedResponse.choices?.[0]?.message?.content ?? "";
let signal: SignalType = "neutral";
let confidence = 0.5;
let reasoning = content;
try {
const parsed = JSON.parse(content);
if (
parsed.signal === "bullish" ||
parsed.signal === "bearish" ||
parsed.signal === "neutral"
) {
signal = parsed.signal;
confidence = parsed.confidence ?? 0.5;
reasoning = parsed.reasoning ?? content;
}
} catch {
// If not valid JSON, check for keywords in the response
const lowerContent = content.toLowerCase();
if (lowerContent.includes("bullish")) signal = "bullish";
else if (lowerContent.includes("bearish")) signal = "bearish";
}
const agentSignal: AgentSignal = {
agent: "fundamentals",
signal,
confidence,
reasoning,
timestamp: new Date().toISOString(),
};
return {
analyst: "fundamentals",
report: content,
signal: agentSignal,
};
}
}
+80
View File
@@ -0,0 +1,80 @@
import { OpenRouterClient } from "../lib/openrouter";
import type { AnalystReport, DebateRound } from "../types/agents";
type ChatResponse = {
choices?: Array<{ message?: { content?: string } }>;
};
export class BullishResearcher {
private client: OpenRouterClient;
private model: string;
constructor(client: OpenRouterClient, model?: string) {
this.client = client;
this.model = model ?? "openai/gpt-oss-120b:free";
}
async research(ticker: string, reports: AnalystReport[]): Promise<DebateRound> {
const reportSummaries = reports
.map((r) => `${r.analyst}: ${r.signal.signal} - ${r.report}`)
.join("\n");
const prompt = `Analyze these analyst reports for ${ticker} and synthesize a bullish thesis:
${reportSummaries}
Provide a bullish view based on the positive signals and reasoning.`;
const response = await this.client.createChatCompletion(
[
{ role: "system", content: "You are a bullish equity researcher who finds the positive investment case." },
{ role: "user", content: prompt },
],
this.model
);
const content = ((response as ChatResponse).choices?.[0]?.message?.content) ?? "";
return {
bullishView: content,
bearishView: "",
researcher: "bullish",
};
}
}
export class BearishResearcher {
private client: OpenRouterClient;
private model: string;
constructor(client: OpenRouterClient, model?: string) {
this.client = client;
this.model = model ?? "openai/gpt-oss-120b:free";
}
async research(ticker: string, reports: AnalystReport[]): Promise<DebateRound> {
const reportSummaries = reports
.map((r) => `${r.analyst}: ${r.signal.signal} - ${r.report}`)
.join("\n");
const prompt = `Analyze these analyst reports for ${ticker} and synthesize a bearish thesis:
${reportSummaries}
Provide a bearish view based on the risks and negative signals.`;
const response = await this.client.createChatCompletion(
[
{ role: "system", content: "You are a bearish equity researcher who identifies investment risks." },
{ role: "user", content: prompt },
],
this.model
);
const content = ((response as ChatResponse).choices?.[0]?.message?.content) ?? "";
return {
bullishView: "",
bearishView: content,
researcher: "bearish",
};
}
}
+84
View File
@@ -0,0 +1,84 @@
import { OpenRouterClient } from "../lib/openrouter";
import type { AnalystReport, AgentSignal, SignalType } from "../types/agents";
export interface SentimentConfig {
model?: string;
}
export interface SentimentData {
headlines: string[];
source?: "news" | "social" | "stocktwits";
}
export class SentimentAnalyst {
private client: OpenRouterClient;
private model: string;
constructor(client: OpenRouterClient, config?: SentimentConfig) {
this.client = client;
this.model = config?.model ?? "openai/gpt-oss-120b:free";
}
getModel(): string {
return this.model;
}
async analyze(ticker: string, data: SentimentData): Promise<AnalystReport> {
const messages = [
{
role: "system" as const,
content:
"You are a sentiment analyst. Analyze the headlines for the given ticker and provide a bullish, bearish, or neutral signal with reasoning. Respond in JSON format with 'signal', 'confidence', and 'reasoning' fields.",
},
{
role: "user" as const,
content: `Analyze ${ticker} sentiment from ${data.source ?? "news"}:\n${data.headlines.join("\n")}`,
},
];
const response = await this.client.createChatCompletion(
messages,
this.model
);
const parsedResponse = response as {
choices?: Array<{ message?: { content?: string } }>;
};
const content = parsedResponse.choices?.[0]?.message?.content ?? "";
let signal: SignalType = "neutral";
let confidence = 0.5;
let reasoning = content;
try {
const parsed = JSON.parse(content);
if (
parsed.signal === "bullish" ||
parsed.signal === "bearish" ||
parsed.signal === "neutral"
) {
signal = parsed.signal;
confidence = parsed.confidence ?? 0.5;
reasoning = parsed.reasoning ?? content;
}
} catch {
const lowerContent = content.toLowerCase();
if (lowerContent.includes("bullish")) signal = "bullish";
else if (lowerContent.includes("bearish")) signal = "bearish";
}
const agentSignal: AgentSignal = {
agent: "sentiment",
signal,
confidence,
reasoning,
timestamp: new Date().toISOString(),
};
return {
analyst: "sentiment",
report: content,
signal: agentSignal,
};
}
}
+92
View File
@@ -0,0 +1,92 @@
import { OpenRouterClient } from "../lib/openrouter";
import type { AnalystReport, AgentSignal, SignalType } from "../types/agents";
export interface TechnicalData {
prices: number[];
sma: number;
ema: number;
rsi: number;
macd: number;
}
export interface TechnicalAnalystConfig {
model?: string;
}
export class TechnicalAnalyst {
private client: OpenRouterClient;
private model: string;
constructor(client: OpenRouterClient, config?: TechnicalAnalystConfig) {
this.client = client;
this.model = config?.model ?? "openai/gpt-oss-120b:free";
}
getModel(): string {
return this.model;
}
async analyze(ticker: string, data: TechnicalData): Promise<AnalystReport> {
const messages = [
{
role: "system" as const,
content:
"You are a technical analyst. Analyze the technical indicators (SMA, EMA, RSI, MACD) for the given ticker and provide a bullish, bearish, or neutral signal with reasoning. Respond in JSON format with 'signal', 'confidence', and 'reasoning' fields.",
},
{
role: "user" as const,
content: `Analyze ${ticker} technical data:
SMA: ${data.sma}
EMA: ${data.ema}
RSI: ${data.rsi}
MACD: ${data.macd}
Prices: ${data.prices.slice(-10).join(", ")}`,
},
];
const response = await this.client.createChatCompletion(
messages,
this.model
);
const parsedResponse = response as {
choices?: Array<{ message?: { content?: string } }>;
};
const content = parsedResponse.choices?.[0]?.message?.content ?? "";
let signal: SignalType = "neutral";
let confidence = 0.5;
let reasoning = content;
try {
const parsed = JSON.parse(content);
if (
parsed.signal === "bullish" ||
parsed.signal === "bearish" ||
parsed.signal === "neutral"
) {
signal = parsed.signal;
confidence = parsed.confidence ?? 0.5;
reasoning = parsed.reasoning ?? content;
}
} catch {
const lowerContent = content.toLowerCase();
if (lowerContent.includes("bullish")) signal = "bullish";
else if (lowerContent.includes("bearish")) signal = "bearish";
}
const agentSignal: AgentSignal = {
agent: "technical",
signal,
confidence,
reasoning,
timestamp: new Date().toISOString(),
};
return {
analyst: "technical",
report: content,
signal: agentSignal,
};
}
}
+84
View File
@@ -0,0 +1,84 @@
import { OpenRouterClient } from "../lib/openrouter";
import type { AnalystReport, DebateRound, TradingDecision } from "../types/agents";
type ChatResponse = {
choices?: Array<{ message?: { content?: string } }>;
};
export class Trader {
private client: OpenRouterClient;
private model: string;
constructor(client: OpenRouterClient, model?: string) {
this.client = client;
this.model = model ?? "openai/gpt-oss-120b:free";
}
async decide(
ticker: string,
reports: AnalystReport[],
debates: DebateRound[]
): Promise<TradingDecision> {
const signalSummaries = reports
.map((r) => `${r.analyst}: ${r.signal.signal} (confidence: ${r.signal.confidence}) - ${r.report}`)
.join("\n");
const debateSummaries = debates
.map((d) => `Bullish: ${d.bullishView}\nBearish: ${d.bearishView}`)
.join("\n");
const allSignals = reports.map((r) => r.signal);
const prompt = `Analyze all signals and debate rounds for ${ticker}:
Signals:
${signalSummaries}
Debate Rounds:
${debateSummaries}
Based on all the information above, make a trading decision. Respond with:
- action: "buy", "sell", or "hold"
- confidence: a number between 0 and 1
- reasoning: brief explanation
Format your response as JSON with these fields.`;
const response = await this.client.createChatCompletion(
[
{ role: "system", content: "You are a trading agent that makes buy/sell/hold decisions based on all available signals." },
{ role: "user", content: prompt },
],
this.model
);
const content = ((response as ChatResponse).choices?.[0]?.message?.content) ?? "";
let action: 'buy' | 'sell' | 'hold' = 'hold';
let confidence = 0.5;
let reasoning = content;
const actionMatch = content.match(/"action"\s*:\s*"(buy|sell|hold)"/);
if (actionMatch) {
action = actionMatch[1] as 'buy' | 'sell' | 'hold';
}
const confidenceMatch = content.match(/"confidence"\s*:\s*([0-9.]+)/);
if (confidenceMatch) {
confidence = parseFloat(confidenceMatch[1]);
}
const reasoningMatch = content.match(/"reasoning"\s*:\s*"([^"]+)"/);
if (reasoningMatch) {
reasoning = reasoningMatch[1];
}
return {
action,
confidence,
reasoning,
agentSignals: allSignals,
debateRounds: debates,
};
}
}
+99
View File
@@ -0,0 +1,99 @@
import { OpenRouterClient } from "../lib/openrouter";
import { FundamentalsAnalyst } from "./fundamentals";
import { TechnicalAnalyst } from "./technical";
import { SentimentAnalyst } from "./sentiment";
import { BullishResearcher, BearishResearcher } from "./researchers";
import { Trader } from "./trader";
import type { AnalystReport, DebateRound, TradingDecision, AgentSignal } from "../types/agents";
export interface GraphStep {
step: "analysts" | "debate" | "trader";
data: AnalystReport[] | DebateRound[] | TradingDecision;
}
export class TradingGraph {
private client: OpenRouterClient;
private model: string;
private fundamentalsAnalyst: FundamentalsAnalyst;
private technicalAnalyst: TechnicalAnalyst;
private sentimentAnalyst: SentimentAnalyst;
private bullishResearcher: BullishResearcher;
private bearishResearcher: BearishResearcher;
private trader: Trader;
constructor(client: OpenRouterClient, model?: string) {
this.client = client;
this.model = model ?? "openai/gpt-oss-120b:free";
this.fundamentalsAnalyst = new FundamentalsAnalyst(client, { model: this.model });
this.technicalAnalyst = new TechnicalAnalyst(client, { model: this.model });
this.sentimentAnalyst = new SentimentAnalyst(client, { model: this.model });
this.bullishResearcher = new BullishResearcher(client, this.model);
this.bearishResearcher = new BearishResearcher(client, this.model);
this.trader = new Trader(client, this.model);
}
async propagate(
ticker: string,
input: {
financialData: string;
technicalData: { prices: number[]; sma: number; ema: number; rsi: number; macd: number };
sentimentData: { headlines: string[]; source?: "news" | "social" | "stocktwits" };
}
): Promise<TradingDecision> {
console.log(`[TradingGraph] Starting analysis for ${ticker} with model ${this.model}`);
const reports = await this.runAnalysts(ticker, input);
const debates = await this.runDebate(ticker, reports);
const decision = await this.trader.decide(ticker, reports, debates);
console.log(`[TradingGraph] Analysis complete for ${ticker}`);
console.log(`[TradingGraph] Decision: ${decision.action} (confidence: ${decision.confidence})`);
return decision;
}
private async runAnalysts(
ticker: string,
input: {
financialData: string;
technicalData: { prices: number[]; sma: number; ema: number; rsi: number; macd: number };
sentimentData: { headlines: string[]; source?: "news" | "social" | "stocktwits" };
}
): Promise<AnalystReport[]> {
console.log(`[TradingGraph] Running analysts for ${ticker}...`);
const [fundamentals, technical, sentiment] = await Promise.all([
this.fundamentalsAnalyst.analyze(ticker, input.financialData),
this.technicalAnalyst.analyze(ticker, input.technicalData),
this.sentimentAnalyst.analyze(ticker, input.sentimentData),
]);
console.log(`[TradingGraph] Analyst reports complete:`, {
fundamentals: fundamentals.signal,
technical: technical.signal,
sentiment: sentiment.signal,
});
return [fundamentals, technical, sentiment];
}
private async runDebate(ticker: string, reports: AnalystReport[]): Promise<DebateRound[]> {
console.log(`[TradingGraph] Running debate for ${ticker}...`);
const [bullish, bearish] = await Promise.all([
this.bullishResearcher.research(ticker, reports),
this.bearishResearcher.research(ticker, reports),
]);
console.log(`[TradingGraph] Debate complete`);
return [
{
bullishView: bullish.bullishView,
bearishView: bearish.bearishView,
researcher: "bullish",
},
];
}
}
+6
View File
@@ -19,6 +19,12 @@ export default function Navbar() {
> >
Stocks Stocks
</Link> </Link>
<Link
to="/analyze"
className="text-gray-600 hover:text-blue-600 font-medium transition-colors relative after:absolute after:bottom-[-4px] after:left-0 after:w-0 after:h-0.5 after:bg-blue-600 after:transition-all hover:after:w-full"
>
Analyze
</Link>
</div> </div>
</div> </div>
</nav> </nav>
+3 -3
View File
@@ -38,7 +38,7 @@ export default function StockViewer() {
value={symbol} value={symbol}
onChange={(e) => setSymbol(e.target.value.toUpperCase())} onChange={(e) => setSymbol(e.target.value.toUpperCase())}
placeholder="Enter stock symbol (e.g. AAPL)" placeholder="Enter stock symbol (e.g. AAPL)"
className="flex-1 border border-gray-300 rounded-lg px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" className="flex-1 border border-gray-300 rounded-lg px-4 py-2.5 text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
onKeyDown={(e) => e.key === "Enter" && fetchIndicators()} onKeyDown={(e) => e.key === "Enter" && fetchIndicators()}
/> />
<button <button
@@ -58,14 +58,14 @@ export default function StockViewer() {
{indicators && ( {indicators && (
<div className="bg-gray-50 rounded-lg p-4"> <div className="bg-gray-50 rounded-lg p-4">
<h3 className="font-bold text-gray-900 mb-3"> <h3 className="text-xl font-bold text-gray-900 mb-3">
Results for {symbol.toUpperCase()} Results for {symbol.toUpperCase()}
</h3> </h3>
<div className="space-y-2"> <div className="space-y-2">
{Object.entries(indicators).map(([key, value]) => ( {Object.entries(indicators).map(([key, value]) => (
<div key={key} className="flex justify-between py-1.5 border-b border-gray-200 last:border-0"> <div key={key} className="flex justify-between py-1.5 border-b border-gray-200 last:border-0">
<span className="text-gray-600 capitalize">{key}</span> <span className="text-gray-600 capitalize">{key}</span>
<span className="font-mono font-medium">{value.toFixed(2)}</span> <span className="font-mono font-medium text-gray-900">{value.toFixed(2)}</span>
</div> </div>
))} ))}
</div> </div>
+55
View File
@@ -0,0 +1,55 @@
import { useEffect, useRef } from "react";
import * as LightweightCharts from "lightweight-charts";
interface TradingViewChartProps {
ticker: string;
data?: Array<{ time: string; open: number; high: number; low: number; close: number }>;
}
export default function TradingViewChart({ ticker, data }: TradingViewChartProps) {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!containerRef.current) {
console.warn(`TradingViewChart: container not ready for ${ticker}`);
return;
}
console.log(`TradingViewChart: creating chart for ${ticker} with ${data?.length ?? 0} bars`);
const chart = LightweightCharts.createChart(containerRef.current, {
height: 400,
autoSize: true,
});
const candlestickSeries = chart.addSeries(LightweightCharts.CandlestickSeries, {
upColor: "#26a69a",
downColor: "#ef5350",
borderUpColor: "#26a69a",
borderDownColor: "#ef5350",
wickUpColor: "#26a69a",
wickDownColor: "#ef5350",
});
if (data && data.length > 0) {
console.log(`TradingViewChart: setting data for ${ticker}`, data.slice(0, 3));
try {
candlestickSeries.setData(data);
console.log(`TradingViewChart: data set successfully for ${ticker}`);
} catch (err) {
console.error(`TradingViewChart: error setting data for ${ticker}`, err);
}
} else {
console.log(`TradingViewChart: no data to set for ${ticker}`);
}
return () => chart.remove();
}, [data, ticker]);
return (
<div className="bg-white rounded-xl shadow-lg p-4">
<h3 className="text-lg font-bold mb-3">{ticker} Price Chart</h3>
<div ref={containerRef} className="w-full" />
</div>
);
}
@@ -0,0 +1,90 @@
/// <reference types="vitest" />
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import TradingViewChart from "../TradingViewChart";
// Use vi.hoisted to define mock functions that will be available during hoisting
const { mockSetData, mockCreateChart } = vi.hoisted(() => ({
mockSetData: vi.fn(),
mockCreateChart: vi.fn(() => ({
addSeries: vi.fn(() => ({
setData: vi.fn(),
})),
remove: vi.fn(),
})),
}));
vi.mock("lightweight-charts", () => ({
createChart: mockCreateChart,
CandlestickSeries: {},
}));
describe("TradingViewChart", () => {
beforeEach(() => {
vi.clearAllMocks();
// Update the mock's setData to track calls
const mockSeries = { setData: mockSetData };
mockCreateChart.mockReturnValue({
addSeries: vi.fn(() => mockSeries),
remove: vi.fn(),
});
});
it("renders the ticker symbol as heading", () => {
render(<TradingViewChart ticker="AAPL" />);
expect(screen.getByText("AAPL Price Chart")).toBeInTheDocument();
});
it("renders without data prop", () => {
render(<TradingViewChart ticker="MSFT" />);
expect(screen.getByText("MSFT Price Chart")).toBeInTheDocument();
expect(mockSetData).not.toHaveBeenCalled();
});
it("calls setData with correct data format when data is provided", () => {
const data = [
{ time: "2024-01-01", open: 100, high: 110, low: 95, close: 105 },
{ time: "2024-01-02", open: 105, high: 115, low: 100, close: 110 },
];
render(<TradingViewChart ticker="GOOGL" data={data} />);
expect(screen.getByText("GOOGL Price Chart")).toBeInTheDocument();
expect(mockSetData).toHaveBeenCalledWith(data);
});
it("does not call setData when data array is empty", () => {
render(<TradingViewChart ticker="TSLA" data={[]} />);
expect(screen.getByText("TSLA Price Chart")).toBeInTheDocument();
expect(mockSetData).not.toHaveBeenCalled();
});
it("creates chart with autoSize option for responsive sizing", () => {
render(<TradingViewChart ticker="TEST" />);
expect(mockCreateChart).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
autoSize: true,
})
);
});
it("creates candlestick series with explicit colors", () => {
const mockAddSeries = vi.fn();
mockCreateChart.mockReturnValue({
addSeries: mockAddSeries,
remove: vi.fn(),
});
render(<TradingViewChart ticker="TEST" />);
expect(mockAddSeries).toHaveBeenCalledWith(
{},
expect.objectContaining({
upColor: "#26a69a",
downColor: "#ef5350",
})
);
});
});
+26
View File
@@ -0,0 +1,26 @@
import { describe, it, expect } from "vitest";
import { OpenRouterClient } from "../openrouter";
describe("OpenRouterClient", () => {
it("should create instance with API key", () => {
const client = new OpenRouterClient("test-api-key");
expect(client).toBeInstanceOf(OpenRouterClient);
});
it("should have default free models list", () => {
const client = new OpenRouterClient("test-api-key");
const models = client.getFreeModels();
expect(models).toContain("google/gemini-2.0-flash-exp:free");
});
it("should have available model providers", () => {
const client = new OpenRouterClient("test-api-key");
const providers = client.getProviders();
expect(providers).toContain("openai");
expect(providers).toContain("google");
expect(providers).toContain("anthropic");
expect(providers).toContain("deepseek");
expect(providers).toContain("meta");
expect(providers).toContain("xai");
});
});
+15
View File
@@ -0,0 +1,15 @@
import { PrismaClient } from "@prisma/client";
declare global {
var prisma: PrismaClient | undefined;
}
const prismaClientSingleton = () => {
return new PrismaClient();
};
export const db = global.prisma || prismaClientSingleton();
if (process.env.NODE_ENV !== "production") {
global.prisma = db;
}
+61
View File
@@ -0,0 +1,61 @@
type Message = {
role: "system" | "user" | "assistant";
content: string;
};
type OpenRouterConfig = {
baseURL?: string;
defaultModel?: string;
};
export class OpenRouterClient {
private apiKey: string;
private baseURL: string;
private defaultModel: string;
private freeModels = [
"openai/gpt-oss-120b:free",
"openrouter/free",
"deepseek/deepseek-chat:free",
"meta/llama-3.3-70b-instruct:free",
];
private providers = ["openai", "google", "anthropic", "deepseek", "meta", "xai"];
constructor(apiKey: string, config?: OpenRouterConfig) {
this.apiKey = apiKey;
this.baseURL = config?.baseURL ?? "https://openrouter.ai/api/v1";
this.defaultModel = config?.defaultModel ?? "openai/gpt-oss-120b:free";
}
getFreeModels(): string[] {
return [...this.freeModels];
}
getProviders(): string[] {
return [...this.providers];
}
async createChatCompletion(
messages: Message[],
model?: string
): Promise<unknown> {
const response = await fetch(`${this.baseURL}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.apiKey}`,
"HTTP-Referer": "https://aitrader.local",
"X-Title": "AITrader",
},
body: JSON.stringify({
model: model ?? this.defaultModel,
messages,
}),
});
if (!response.ok) {
throw new Error(`OpenRouter API error: ${response.status}`);
}
return response.json();
}
}
+9
View File
@@ -3,5 +3,14 @@ import { type RouteConfig, index, route } from "@react-router/dev/routes";
export default [ export default [
index("routes/landing.tsx"), index("routes/landing.tsx"),
route("api/alpaca/account", "routes/api/alpaca/account.ts"), route("api/alpaca/account", "routes/api/alpaca/account.ts"),
route("api/alpaca/quote/:ticker", "routes/api/alpaca/quote.ts"),
route("api/alpaca/orders", "routes/api/alpaca/orders.ts"),
route("api/alpaca/positions", "routes/api/alpaca/positions.ts"),
route("api/test-alpaca", "routes/api/test-alpaca.ts"),
route("api/indicators", "routes/api/indicators.ts"),
route("api/analyze", "routes/api/analyze.ts"),
route("api/stocks", "routes/api/stocks/index.ts"),
route("stocks", "routes/stocks.tsx"), route("stocks", "routes/stocks.tsx"),
route("analyze", "routes/analyze.tsx"),
route("analyze/:ticker", "routes/analyze.ticker.tsx"),
] satisfies RouteConfig; ] satisfies RouteConfig;
+183
View File
@@ -0,0 +1,183 @@
import { useLoaderData, useNavigate, useLocation } from "react-router";
import TradingViewChart from "../components/TradingViewChart";
import Navbar from "../components/Navbar";
export const meta = () => [{ title: "Stock Detail - AITrader" }];
interface LoaderData {
ticker: string;
position: number | null;
orders: any[];
bars: any[];
timeframe: string;
limit: number;
}
const TIMEFRAMES = [
{ value: "1D", label: "1 Day" },
{ value: "5Min", label: "5 Min" },
{ value: "15Min", label: "15 Min" },
{ value: "1H", label: "1 Hour" },
{ value: "1W", label: "1 Week" },
];
export async function loader({ params, request }: { params: { ticker: string }; request: Request }) {
const ticker = params.ticker?.toUpperCase() || "";
const url = new URL(request.url);
const timeframe = url.searchParams.get("timeframe") || "1D";
const limit = parseInt(url.searchParams.get("limit") || "30", 10);
console.log(`analyze/${ticker}: loader called with timeframe=${timeframe}, limit=${limit}`);
// Build base URL from request for server-side fetches
const reqUrl = new URL(request.url);
const host = request.headers.get("host") || reqUrl.host;
const protocol = reqUrl.protocol;
const baseUrl = `${protocol}//${host}`;
console.log(`analyze/${ticker}: baseUrl = ${baseUrl}`);
let position = null;
let orders = [];
let bars = [];
try {
// Fetch position
const posRes = await fetch(`${baseUrl}/api/alpaca/positions`);
console.log(`analyze/${ticker}: positions status = ${posRes.status}`);
const positions = posRes.ok ? await posRes.json() : [];
position = positions.find((p: any) => p.ticker === ticker)?.qty ?? null;
// Fetch orders
const ordRes = await fetch(`${baseUrl}/api/alpaca/orders`);
const ordersData = ordRes.ok ? await ordRes.json() : { orders: [] };
orders = ordersData.orders?.filter((o: any) => o.symbol === ticker) || [];
// Fetch bars for chart with timeframe and limit
console.log(`analyze/${ticker}: fetching bars from ${baseUrl}/api/alpaca/quote/${ticker}?timeframe=${timeframe}&limit=${limit}`);
const barsRes = await fetch(`${baseUrl}/api/alpaca/quote/${ticker}?timeframe=${timeframe}&limit=${limit}`);
console.log(`analyze/${ticker}: bars response status = ${barsRes.status}`);
const barsData = barsRes.ok ? await barsRes.json() : null;
console.log(`analyze/${ticker}: barsData =`, JSON.stringify(barsData));
bars = barsData?.bars || [];
} catch (err) {
console.error(`analyze/${ticker}: loader error`, err);
}
return Response.json({ ticker, position, orders, bars, timeframe, limit });
}
export default function StockDetail() {
const { ticker, position, orders, bars, timeframe, limit } = useLoaderData() as LoaderData;
const navigate = useNavigate();
const location = useLocation();
const updateParams = (newTimeframe: string, newLimit: number) => {
const searchParams = new URLSearchParams(location.search);
searchParams.set("timeframe", newTimeframe);
searchParams.set("limit", newLimit.toString());
navigate(`${location.pathname}?${searchParams.toString()}`, { replace: true });
};
// Convert Alpaca bars to TradingView format (YYYY-MM-DD for time)
const chartData = bars?.map((bar: any) => {
// Handle timestamp - could be string, number, or Date
let time = "";
if (bar.t) {
const date = new Date(bar.t);
if (!isNaN(date.getTime())) {
time = date.toISOString().split('T')[0];
}
}
return {
time,
open: bar.o,
high: bar.h,
low: bar.l,
close: bar.c,
};
}).filter((bar: any) => bar.time && bar.open != null && bar.high != null && bar.low != null && bar.close != null) || [];
console.log(`StockDetail: loaded ${bars?.length ?? 0} bars, transformed to ${chartData.length} chart points`);
return (
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-blue-50">
<Navbar />
<div className="mx-auto max-w-7xl px-6 py-8">
<h1 className="text-3xl font-bold text-gray-900 mb-6">{ticker} Detail</h1>
<div className="bg-white rounded-xl shadow-lg p-6 mb-6 border border-gray-200">
<div className="flex items-center gap-4 mb-4">
<span className="text-gray-700 font-medium">Timeframe:</span>
<select
value={timeframe}
onChange={(e) => updateParams(e.target.value, limit)}
className="border border-gray-300 rounded-lg px-3 py-1.5 text-gray-900 focus:ring-2 focus:ring-blue-500"
>
{TIMEFRAMES.map((tf) => (
<option key={tf.value} value={tf.value}>{tf.label}</option>
))}
</select>
<span className="text-gray-700 font-medium">Bars:</span>
<select
value={limit}
onChange={(e) => updateParams(timeframe, parseInt(e.target.value))}
className="border border-gray-300 rounded-lg px-3 py-1.5 text-gray-900 focus:ring-2 focus:ring-blue-500"
>
<option value={10}>10</option>
<option value={30}>30</option>
<option value={50}>50</option>
<option value={100}>100</option>
</select>
</div>
<TradingViewChart ticker={ticker} data={chartData} />
</div>
<div className="mt-6 bg-white rounded-xl shadow-lg p-6 border border-gray-200">
<h2 className="text-xl font-bold text-gray-900 mb-4">Position</h2>
<p className="text-gray-600">{position ? `Quantity: ${position} shares` : "No position held"}</p>
</div>
<div className="mt-6 bg-white rounded-xl shadow-lg p-6 border border-gray-200">
<h2 className="text-xl font-bold text-gray-900 mb-4">Recent Orders</h2>
{orders.length === 0 ? (
<p className="text-gray-500">No orders found for {ticker}</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200">
<th className="text-left py-2 px-3 font-medium text-gray-700">Side</th>
<th className="text-left py-2 px-3 font-medium text-gray-700">Qty</th>
<th className="text-left py-2 px-3 font-medium text-gray-700">Status</th>
<th className="text-left py-2 px-3 font-medium text-gray-700">Filled Price</th>
<th className="text-left py-2 px-3 font-medium text-gray-700">Filled At</th>
</tr>
</thead>
<tbody>
{orders.map((order: any, i: number) => (
<tr key={order.id || i} className="border-b border-gray-100">
<td className="py-2 px-3">
<span className={order.side === "buy" ? "text-green-600" : "text-red-600"}>
{order.side?.toUpperCase()}
</span>
</td>
<td className="py-2 px-3 text-gray-900">{order.qty}</td>
<td className="py-2 px-3 text-gray-900">{order.status}</td>
<td className="py-2 px-3 text-gray-900">
{order.filled_avg_price ? `$${parseFloat(order.filled_avg_price).toFixed(2)}` : "-"}
</td>
<td className="py-2 px-3 text-gray-600">
{order.filled_at ? new Date(order.filled_at).toLocaleDateString() : "-"}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
</div>
);
}
+394
View File
@@ -0,0 +1,394 @@
import { useState, useEffect } from "react";
import { Link } from "react-router";
import Navbar from "../components/Navbar";
import type { TradingDecision } from "../types/agents";
interface StockRow {
id: string;
ticker: string;
currentPrice: number | null;
position: number;
rsi: number | null;
analysis: TradingDecision | null;
loading: boolean;
}
export const meta = () => {
return [
{ title: "Portfolio Analysis - AITrader" },
{ name: "description", content: "Analyze your stock portfolio with AI trading insights" },
];
};
export default function Analyze() {
const [stocks, setStocks] = useState<StockRow[]>([]);
const [newTicker, setNewTicker] = useState("");
// Load Alpaca portfolio and database stocks on mount
useEffect(() => {
const loadPortfolio = async () => {
try {
// Fetch both Alpaca positions and database stocks
const [positionsRes, dbStocksRes] = await Promise.all([
fetch("/api/alpaca/positions"),
fetch("/api/stocks"),
]);
const positions = positionsRes.ok ? await positionsRes.json() : [];
const dbStocks = dbStocksRes.ok ? await dbStocksRes.json() : [];
// Create a set of tickers from Alpaca positions for quick lookup
const alpacaTickers = new Set(positions.map((p: { ticker: string }) => p.ticker));
// Build stocks array for Alpaca positions
const alpacaStocks = await Promise.all(
positions.map(async (p: { ticker: string; qty: number }) => {
try {
const quoteRes = await fetch(`/api/alpaca/quote/${p.ticker}`);
const quote = quoteRes.ok ? await quoteRes.json() : null;
return {
id: `alpaca-${p.ticker}`,
ticker: p.ticker,
currentPrice: quote?.price ?? null,
position: p.qty,
rsi: null,
analysis: null,
loading: false,
};
} catch {
return {
id: `alpaca-${p.ticker}`,
ticker: p.ticker,
currentPrice: null,
position: p.qty,
rsi: null,
analysis: null,
loading: false,
};
}
})
);
// Add database stocks that are not in Alpaca positions with position=0
const dbOnlyStocks = [];
for (const stock of dbStocks) {
if (!alpacaTickers.has(stock.ticker)) {
try {
const quoteRes = await fetch(`/api/alpaca/quote/${stock.ticker}`);
const quote = quoteRes.ok ? await quoteRes.json() : null;
dbOnlyStocks.push({
id: `db-${stock.ticker}`,
ticker: stock.ticker,
currentPrice: quote?.price ?? null,
position: 0,
rsi: null,
analysis: null,
loading: false,
});
} catch {
dbOnlyStocks.push({
id: `db-${stock.ticker}`,
ticker: stock.ticker,
currentPrice: null,
position: 0,
rsi: null,
analysis: null,
loading: false,
});
}
}
}
setStocks([...alpacaStocks, ...dbOnlyStocks]);
} catch (err) {
console.error("[analyze] Portfolio load error:", err);
}
};
loadPortfolio();
}, []);
// Refresh prices every minute
useEffect(() => {
const interval = setInterval(() => {
stocks.forEach((stock) => {
fetch(`/api/alpaca/quote/${stock.ticker}`)
.then((res) => res.ok ? res.json() : null)
.then((data) => {
if (data?.price) {
setStocks((s) => s.map((st) =>
st.ticker === stock.ticker ? { ...st, currentPrice: data.price } : st
));
}
})
.catch(() => {});
});
}, 60000);
return () => clearInterval(interval);
}, [stocks]);
const addStock = async () => {
if (!newTicker.trim()) return;
const ticker = newTicker.trim().toUpperCase();
console.log("[analyze] Adding stock:", ticker);
// Check if ticker already exists
if (stocks.some((s) => s.ticker === ticker)) {
console.log("[analyze] Ticker already exists:", ticker);
return;
}
// Save to database first
try {
const formData = new FormData();
formData.append("ticker", ticker);
await fetch("/api/stocks", {
method: "POST",
body: formData,
});
} catch (err) {
console.error("[analyze] Error saving stock to DB:", err);
}
const newStock: StockRow = {
id: `db-${ticker}`,
ticker,
currentPrice: null,
position: 0,
rsi: null,
analysis: null,
loading: true,
};
setStocks((s) => [...s, newStock]);
setNewTicker("");
try {
console.log("[analyze] Fetching quote and positions for", ticker);
const [quoteRes, positionsRes] = await Promise.all([
fetch(`/api/alpaca/quote/${ticker}`),
fetch("/api/alpaca/positions"),
]);
console.log("[analyze] Quote response:", quoteRes.status);
console.log("[analyze] Positions response:", positionsRes.status);
const quote = quoteRes.ok ? await quoteRes.json() : null;
const positions = positionsRes.ok ? await positionsRes.json() : [];
console.log("[analyze] Quote data:", quote);
console.log("[analyze] Positions data:", positions);
const position = positions.find((p: { ticker: string; qty: number }) =>
p.ticker === ticker
)?.qty ?? 0;
console.log("[analyze] Found position:", position);
setStocks((s) => s.map((st) =>
st.ticker === ticker
? { ...st, loading: false, currentPrice: quote?.price ?? null, position }
: st
));
} catch (err) {
console.error("[analyze] Error adding stock:", err);
setStocks((s) => s.map((st) => st.ticker === ticker ? { ...st, loading: false } : st));
}
};
// Update all positions on mount and when stocks change
useEffect(() => {
if (stocks.length === 0) return;
const updatePositions = async () => {
try {
const res = await fetch("/api/alpaca/positions");
if (res.ok) {
const positions = await res.json();
setStocks((s) => s.map((st) => {
const pos = positions.find((p: { ticker: string; qty: number }) =>
p.ticker === st.ticker
);
return pos ? { ...st, position: pos.qty } : st;
}));
}
} catch (err) {
console.error("[analyze] Position update error:", err);
}
};
updatePositions();
}, [stocks.length]);
const removeStock = async (id: string) => {
const stock = stocks.find((s) => s.id === id);
if (!stock) return;
// Delete from database if this was a manually added stock (db- prefix)
if (id.startsWith("db-")) {
try {
const formData = new FormData();
formData.append("_method", "DELETE");
formData.append("ticker", stock.ticker);
await fetch("/api/stocks", {
method: "POST",
body: formData,
});
} catch (err) {
console.error("[analyze] Error deleting stock from DB:", err);
}
}
setStocks((s) => s.filter((stock) => stock.id !== id));
};
const runAnalysis = async (id: string, ticker: string) => {
setStocks((s) => s.map((st) => st.id === id ? { ...st, loading: true } : st));
try {
const [quoteRes, indicatorsRes] = await Promise.all([
fetch(`/api/alpaca/quote/${ticker}`),
fetch(`/api/indicators?symbol=${ticker}`),
]);
const quote = quoteRes.ok ? await quoteRes.json() : null;
const indicators = indicatorsRes.ok ? await indicatorsRes.json() : null;
const analysisRes = await fetch("/api/analyze", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ticker }),
});
const analysis = analysisRes.ok ? await analysisRes.json() : null;
setStocks((s) => s.map((st) =>
st.id === id
? {
...st,
loading: false,
currentPrice: quote?.price ?? null,
rsi: indicators?.indicators?.rsi ?? null,
analysis,
}
: st
));
} catch {
setStocks((s) => s.map((st) => st.id === id ? { ...st, loading: false } : st));
}
};
return (
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-blue-50">
<Navbar />
<div className="mx-auto max-w-7xl px-6 py-8">
<h1 className="text-3xl font-bold text-gray-900 mb-6">Portfolio Analysis</h1>
<div className="bg-white rounded-xl shadow-lg p-6 mb-6">
<div className="flex gap-3 mb-4">
<input
type="text"
value={newTicker}
onChange={(e) => setNewTicker(e.target.value.toUpperCase())}
placeholder="Add ticker (e.g. AAPL)"
className="flex-1 border border-gray-300 rounded-lg px-4 py-2.5 text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
onKeyDown={(e) => e.key === "Enter" && addStock()}
/>
<button
onClick={addStock}
disabled={!newTicker.trim()}
className="bg-blue-600 text-white px-6 py-2.5 rounded-lg font-medium hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
Add Stock
</button>
</div>
{stocks.length === 0 ? (
<p className="text-gray-500 text-center py-8">No stocks added. Add a ticker to get started.</p>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-gray-200">
<th className="text-left py-3 px-4 font-medium text-gray-700">Ticker</th>
<th className="text-left py-3 px-4 font-medium text-gray-700">Price</th>
<th className="text-left py-3 px-4 font-medium text-gray-700">Position</th>
<th className="text-left py-3 px-4 font-medium text-gray-700">RSI</th>
<th className="text-left py-3 px-4 font-medium text-gray-700">Analysis</th>
<th className="text-left py-3 px-4 font-medium text-gray-700">Actions</th>
</tr>
</thead>
<tbody>
{stocks.map((stock) => (
<tr key={stock.id} className="border-b border-gray-100">
<td className="py-3 px-4 font-bold text-gray-900">
<Link to={`/analyze/${stock.ticker}`} className="text-blue-600 hover:underline">
{stock.ticker}
</Link>
</td>
<td className="py-3 px-4 text-gray-900">
{stock.currentPrice ? `$${stock.currentPrice.toFixed(2)}` : "-"}
</td>
<td className="py-3 px-4 text-gray-900 font-medium">
{stock.position}
</td>
<td className="py-3 px-4">
{stock.rsi ? (
<span className={
stock.rsi > 70 ? "text-red-600" :
stock.rsi < 30 ? "text-green-600" : "text-gray-900"
}>
{stock.rsi.toFixed(2)}
</span>
) : "-"}
</td>
<td className="py-3 px-4">
{stock.analysis ? (
<div>
<span className={`font-medium ${
stock.analysis.action === "buy" ? "text-green-600" :
stock.analysis.action === "sell" ? "text-red-600" : "text-gray-600"
}`}>
{stock.analysis.action.toUpperCase()}
</span>
<div className="text-xs text-gray-500">
Confidence: {(stock.analysis.confidence * 100).toFixed(0)}%
</div>
</div>
) : stock.loading ? (
<span className="text-blue-600">Analyzing...</span>
) : "-"}
</td>
<td className="py-3 px-4">
<div className="flex gap-2">
<button
onClick={() => runAnalysis(stock.id, stock.ticker)}
disabled={stock.loading}
className="bg-blue-600 text-white px-3 py-1 rounded text-sm hover:bg-blue-700 disabled:opacity-50"
>
{stock.loading ? "Running..." : "Analyze"}
</button>
<button
onClick={async () => {
if (confirm(`Remove ${stock.ticker}?`)) {
await removeStock(stock.id);
}
}}
className="bg-red-600 text-white px-3 py-1 rounded text-sm hover:bg-red-700"
>
Delete
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
</div>
);
}
+1
View File
@@ -5,6 +5,7 @@ const alpaca = new Alpaca({
keyId: process.env.ALPACA_API_KEY!, keyId: process.env.ALPACA_API_KEY!,
secretKey: process.env.ALPACA_SECRET_KEY!, secretKey: process.env.ALPACA_SECRET_KEY!,
baseUrl: process.env.ALPACA_BASE_URL || "https://paper-api.alpaca.markets", baseUrl: process.env.ALPACA_BASE_URL || "https://paper-api.alpaca.markets",
dataBaseUrl: process.env.ALPACA_DATA_URL || "https://data.alpaca.markets",
retryOnError: false, retryOnError: false,
}); });
+19
View File
@@ -0,0 +1,19 @@
import Alpaca from "@alpacahq/alpaca-trade-api";
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,
});
export async function loader() {
try {
const orders = await alpaca.getOrders();
return Response.json({ orders });
} catch (error) {
console.error("Alpaca orders error:", error);
return Response.json({ orders: [] }, { status: 500 });
}
}
+24
View File
@@ -0,0 +1,24 @@
import Alpaca from "@alpacahq/alpaca-trade-api";
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,
});
export async function loader() {
try {
const positions = await alpaca.getPositions();
return Response.json(
positions.map((p: { symbol: string; qty: string; avg_entry_price: string; current_price: string }) => ({
ticker: p.symbol,
qty: parseFloat(p.qty),
}))
);
} catch (error) {
console.error("Alpaca positions error:", error);
return Response.json({ error: "Failed to fetch positions" }, { status: 500 });
}
}
+106
View File
@@ -0,0 +1,106 @@
import Alpaca from "@alpacahq/alpaca-trade-api";
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,
});
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 limit = parseInt(url.searchParams.get("limit") || "30", 10);
console.log(`API quote/${ticker}: loader called with timeframe=${timeframe}, limit=${limit}`);
if (!ticker) {
return Response.json({ error: "Ticker is required" }, { status: 400 });
}
try {
// Get latest trade for current price
let price = 0;
try {
const trade = await alpaca.getLatestTrade(ticker);
price = (trade as { Price?: number }).Price || 0;
console.log(`API quote/${ticker}: latest trade price = ${price}`);
} catch (tradeErr) {
console.error(`API quote/${ticker}: getLatestTrade failed`, tradeErr);
}
// Calculate start date based on timeframe
const startDate = new Date();
const isIntraday = ["1Min", "5Min", "15Min", "30Min", "1H"].includes(timeframe);
if (timeframe === "1D") {
startDate.setDate(startDate.getDate() - Math.min(limit, 30));
} else if (timeframe === "1W") {
startDate.setDate(startDate.getDate() - (limit * 7));
} else if (timeframe === "1M") {
startDate.setMonth(startDate.getMonth() - limit);
} else if (isIntraday) {
startDate.setDate(startDate.getDate() - Math.floor(limit / 5));
}
const barsOptions: any = { timeframe, limit };
if (timeframe !== "1Min" && timeframe !== "5Min") {
barsOptions.start = startDate.toISOString().split('T')[0];
}
console.log(`API quote/${ticker}: calling getBarsV2 with timeframe=${timeframe}, limit=${limit}`);
const bars = await alpaca.getBarsV2(ticker, barsOptions);
console.log(`API quote/${ticker}: getBarsV2 returned`, typeof bars, bars?.constructor?.name);
// Convert async generator to array
// Alpaca v2 API returns AlpacaBar with capitalized property names
const barsArray = [];
try {
for await (const bar of bars) {
console.log(`API quote/${ticker}: received bar =`, JSON.stringify(bar));
barsArray.push(bar);
}
} catch (genErr) {
console.error(`API quote/${ticker}: error iterating bars`, genErr);
}
console.log(`API quote/${ticker}: raw bars count = ${barsArray.length}`);
if (barsArray.length > 0) {
console.log(`API quote/${ticker}: first bar =`, JSON.stringify(barsArray[0]));
} else {
console.log(`API quote/${ticker}: no bars returned from Alpaca, generator may be empty`);
}
// Transform to chart format
const transformedBars = barsArray.map((bar: any) => {
// AlpacaBarV2 transforms lowercase to capitalized: o->OpenPrice, h->HighPrice, etc.
const open = typeof bar.OpenPrice === 'number' ? bar.OpenPrice : (typeof bar.o === 'number' ? bar.o : 0);
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);
return {
t: timestamp,
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);
console.log(`API quote/${ticker}: returning ${transformedBars.length} bars`);
return Response.json({
ticker,
price,
bars: transformedBars,
});
} catch (error) {
console.error("Alpaca data error:", error);
return Response.json({ ticker, price: 0, bars: [] }, { status: 500 });
}
}
+83
View File
@@ -0,0 +1,83 @@
import { OpenRouterClient } from "../../lib/openrouter";
import { TradingGraph } from "../../agents/tradingGraph";
export async function action({ request }: { request: Request }) {
console.log("[analyze] Request received:", request.method, request.url);
const body = await request.json();
console.log("[analyze] Request body:", JSON.stringify(body));
const ticker = body.ticker?.toUpperCase();
const date = body.date || new Date().toISOString().split("T")[0];
if (!ticker) {
console.log("[analyze] Error: ticker missing");
return Response.json({ error: "ticker is required" }, { status: 400 });
}
const apiKey = process.env.OPENROUTER_API_KEY;
console.log("[analyze] API key configured:", !!apiKey, apiKey?.substring(0, 10) + "...");
if (!apiKey || apiKey === "your_openrouter_api_key_here") {
console.log("[analyze] Using mock mode");
const mockDecision = {
action: "hold" as const,
confidence: 0.75,
reasoning: `${ticker} analysis - Mock mode: positive momentum detected with neutral technical signals`,
agentSignals: [
{
agent: "fundamentals" as const,
signal: "bullish" as const,
confidence: 0.7,
reasoning: "Strong fundamentals with positive earnings outlook",
timestamp: new Date().toISOString(),
},
{
agent: "technical" as const,
signal: "neutral" as const,
confidence: 0.6,
reasoning: "Mixed technical indicators",
timestamp: new Date().toISOString(),
},
],
debateRounds: [
{
bullishView: "Bullish case supported by fundamentals and momentum",
bearishView: "Bearish case from mixed technical signals",
researcher: "bullish" as const,
},
],
};
console.log("[analyze] Returning mock decision");
return Response.json(mockDecision);
}
const client = new OpenRouterClient(apiKey);
const graph = new TradingGraph(client);
const input = {
financialData: `Financial data for ${ticker} as of ${date}`,
technicalData: {
prices: [100, 102, 101, 103, 105],
sma: 102,
ema: 103,
rsi: 55,
macd: 0.5,
},
sentimentData: {
headlines: [`${ticker} showing positive momentum`],
source: "news" as const,
},
};
try {
console.log("[analyze] Running trading graph...");
const decision = await graph.propagate(ticker, input);
console.log("[analyze] Decision received:", JSON.stringify(decision));
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 });
}
}
+30
View File
@@ -0,0 +1,30 @@
import { db } from "../../../lib/db.server";
export async function loader() {
const stocks = await db.stock.findMany({
orderBy: { ticker: "asc" },
});
return Response.json(stocks);
}
export async function action({ request }: { request: Request }) {
const formData = await request.formData();
const ticker = formData.get("ticker")?.toString().toUpperCase();
const method = formData.get("_method")?.toString() || "POST";
if (!ticker) {
return Response.json({ error: "Ticker is required" }, { status: 400 });
}
if (method === "DELETE") {
await db.stock.deleteMany({
where: { ticker },
});
return Response.json({ success: true });
}
const stock = await db.stock.create({
data: { ticker },
});
return Response.json(stock);
}
+74
View File
@@ -0,0 +1,74 @@
import Alpaca from "@alpacahq/alpaca-trade-api";
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,
});
export async function loader({ request }: { request: Request }) {
const url = new URL(request.url);
const ticker = url.searchParams.get("ticker")?.toUpperCase() || "AAPL";
try {
// Test different timeframes
const timeframes = ["1Day", "1Min", "5Min"];
const results: any = {};
for (const tf of timeframes) {
try {
console.log(`test-alpaca: testing ${ticker} with timeframe ${tf}`);
const bars = await alpaca.getBarsV2(ticker, {
timeframe: tf as any,
limit: 3,
});
const barsArray = [];
for await (const bar of bars) {
barsArray.push(bar);
}
results[tf] = { count: barsArray.length, sample: barsArray[0] };
console.log(`test-alpaca: ${tf} -> ${barsArray.length} bars`);
} catch (e) {
results[tf] = { error: e instanceof Error ? e.message : String(e) };
}
}
// Test popular stocks
const symbols = ["AAPL", "MSFT", "SPY", "QQQ"];
const symbolResults: any = {};
const startDate = new Date();
startDate.setDate(startDate.getDate() - 30);
const start = startDate.toISOString().split('T')[0];
for (const sym of symbols) {
try {
const bars = await alpaca.getBarsV2(sym, {
timeframe: "1D",
limit: 3,
start,
});
const barsArray = [];
for await (const bar of bars) barsArray.push(bar);
symbolResults[sym] = barsArray.length;
} catch (e) {
symbolResults[sym] = e instanceof Error ? e.message : String(e);
}
}
return Response.json({
ticker,
timeframeResults: results,
symbolResults,
});
} catch (error) {
console.error("test-alpaca error:", error);
return Response.json({
error: error instanceof Error ? error.message : String(error),
ticker
}, { status: 500 });
}
}
+27
View File
@@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest'
import type { AgentSignal, AnalystReport } from '../agents'
describe('Agent Types', () => {
it('should define valid agent signal structure', () => {
const signal: AgentSignal = {
agent: 'fundamentals',
signal: 'bullish',
confidence: 0.85,
reasoning: 'Strong fundamentals',
timestamp: '2026-01-15T00:00:00.000Z'
}
expect(signal.agent).toBe('fundamentals')
expect(signal.signal).toBe('bullish')
})
it('should allow neutral signal', () => {
const signal: AgentSignal = {
agent: 'technical',
signal: 'neutral',
confidence: 0.5,
reasoning: 'Market indecision',
timestamp: '2026-01-15T00:00:00.000Z'
}
expect(signal.signal).toBe('neutral')
})
})
+38
View File
@@ -0,0 +1,38 @@
export type SignalType = 'bullish' | 'bearish' | 'neutral'
export interface AgentSignal {
agent: 'fundamentals' | 'sentiment' | 'news' | 'technical' | 'trader'
signal: SignalType
confidence: number
reasoning: string
timestamp: string
}
export interface AnalystReport {
analyst: 'fundamentals' | 'sentiment' | 'news' | 'technical'
report: string
signal: AgentSignal
}
export interface DebateRound {
bullishView: string
bearishView: string
researcher: 'bullish' | 'bearish'
}
export interface TradingDecision {
action: 'buy' | 'sell' | 'hold'
confidence: number
targetPrice?: number
stopLoss?: number
reasoning: string
agentSignals: AgentSignal[]
debateRounds: DebateRound[]
}
export interface AgentConfig {
llmProvider: 'openrouter'
model: string
maxDebateRounds: number
temperature: number
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,247 @@
# Stock Portfolio Database Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Store manually added tickers in SQLite database using Prisma ORM with full CRUD operations and tests.
**Architecture:** Use Prisma ORM with SQLite to persist stock tickers added via the analyze page. Each stock entry stores ticker symbol, optional notes, and timestamps. The analyze route fetches stored tickers from DB and merges with Alpaca positions.
**Tech Stack:** Prisma ORM, SQLite, TypeScript, React Router 7
---
## Prerequisites Check
- [ ] Check if Prisma is already installed in package.json
- [ ] Check existing database/schema files
---
### Task 1: Initialize Prisma and Create Stock Model
**Files:**
- Create: `prisma/schema.prisma`
- Create: `prisma/migrations/xxxxxxxxxxxx_init/migration.sql` (generated)
- [ ] **Step 1: Install Prisma dependencies**
```bash
npm install prisma @prisma/client
npx prisma init --datasource-provider sqlite
```
Expected: Creates prisma/ directory with schema.prisma
- [ ] **Step 2: Define Stock model in schema.prisma**
```prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = "file:./dev.db"
}
model Stock {
id String @id @default(cuid())
ticker String @unique
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
```
Expected: Schema file saved
- [ ] **Step 3: Generate Prisma client and migrate**
```bash
npx prisma generate
npx prisma migrate dev --name init
```
Expected: `prisma/dev.db` created, client generated
- [ ] **Step 4: Commit**
```bash
git add prisma/
git commit -m "feat: initialize prisma with Stock model"
```
---
### Task 2: Create Database Service Layer
**Files:**
- Create: `app/lib/db.server.ts`
- [ ] **Step 1: Create Prisma client singleton**
```typescript
import { PrismaClient } from "@prisma/client";
declare global {
var prisma: PrismaClient | undefined;
}
export const db = global.prisma || new PrismaClient();
if (process.env.NODE_ENV !== "production") {
global.prisma = db;
}
```
Expected: File created without TypeScript errors
- [ ] **Step 2: Commit**
```bash
git add app/lib/db.server.ts
git commit -m "feat: create prisma client singleton"
```
---
### Task 3: Create Stock API Routes
**Files:**
- Create: `app/routes/api/stocks/index.ts`
- Create: `app/routes/api/stocks/$ticker.ts`
- [ ] **Step 1: Create GET /api/stocks route**
```typescript
import { db } from "../../../lib/db.server";
export async function loader() {
const stocks = await db.stock.findMany({
orderBy: { ticker: "asc" },
});
return Response.json(stocks);
}
export async function action({ request }: { request: Request }) {
const formData = await request.formData();
const ticker = formData.get("ticker")?.toString().toUpperCase();
if (!ticker) {
return Response.json({ error: "Ticker is required" }, { status: 400 });
}
const stock = await db.stock.create({
data: { ticker },
});
return Response.json(stock);
}
```
- [ ] **Step 2: Register routes in routes.ts**
```typescript
route("api/stocks", "routes/api/stocks/index.ts"),
route("api/stocks/$ticker", "routes/api/stocks/\$ticker.ts"),
```
- [ ] **Step 3: Commit**
```bash
git add app/routes/api/stocks/
git commit -m "feat: add stock CRUD API routes"
```
---
### Task 4: Modify Analyze Route to Use Database
**Files:**
- Modify: `app/routes/analyze.tsx`
- [ ] **Step 1: Merge Alpaca positions with database stocks on load**
```typescript
// In loadPortfolio useEffect, after fetching Alpaca positions:
const [alpacaPositions, dbStocks] = await Promise.all([
fetch("/api/alpaca/positions").then(r => r.ok ? r.json() : []),
fetch("/api/stocks").then(r => r.ok ? r.json() : [])
]);
// Build initial stocks from both sources
const initialStocks = await Promise.all([
// Alpaca positions first
...alpacaPositions.map(async (p: { ticker: string; qty: number }) => {
// ... existing logic
}),
// Then DB stocks not in Alpaca
...dbStocks.map(async (s: { ticker: string }) => {
const existing = alpacaPositions.find((p: { ticker: string }) => p.ticker === s.ticker);
if (existing) return null;
return {
id: `db-${s.ticker}`,
ticker: s.ticker,
currentPrice: null,
position: 0,
rsi: null,
analysis: null,
loading: false,
};
})
].filter(Boolean));
```
- [ ] **Step 2: Save new ticker to database when added**
```typescript
// In addStock function, after successfully adding ticker:
await fetch("/api/stocks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ticker }),
});
```
- [ ] **Step 3: Commit**
```bash
git add app/routes/analyze.tsx
git commit -m "feat: integrate stock database with analyze page"
```
---
### Task 5: Write Playwright Tests
**Files:**
- Create: `tests/stock-db.spec.ts`
- [ ] **Step 1: Write test for stock CRUD API**
```typescript
import { test, expect } from "@playwright/test";
test.describe("Stock Database", () => {
test("should add and list stocks", async ({ page }) => {
// POST to create
const createRes = await page.request.post("/api/stocks", {
data: { ticker: "TEST" },
headers: { "Content-Type": "application/x-www-form-urlencoded" },
});
expect(createRes.ok()).toBeTruthy();
// GET to list
const listRes = await page.request.get("/api/stocks");
const stocks = await listRes.json();
expect(stocks).toContainEqual(expect.objectContaining({ ticker: "TEST" }));
});
});
```
- [ ] **Step 2: Commit**
```bash
git add tests/stock-db.spec.ts
git commit -m "test: add stock database E2E tests"
```
---
### Task 6: Verify Installation
**Files:**
- Check: `package.json`
- [ ] **Step 1: Run typecheck and tests**
```bash
npm run typecheck
npm run test:e2e
```
Expected: All commands succeed
- [ ] **Step 2: Commit**
```bash
git commit -am "chore: verify prisma integration"
```
@@ -0,0 +1,289 @@
# Stock Detail Page Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Create stock detail page at `/analyze/:ticker` with TradingView chart, position, orders, and trading graph results.
**Architecture:** Dynamic route `analyze.ticker.tsx` that fetches ticker data, runs TradingGraph analysis, and displays chart, position, orders, and results.
**Tech Stack:** React Router 7, TradingView lightweight charts, Alpaca API, Prisma
---
## Prerequisite Check
- [ ] Verify Alpaca API key is configured in `.env`
- [ ] Verify `@tradingview/lightweight-charts` can be installed
---
### Task 1: Add Alpaca Orders API Endpoint
**Files:**
- Create: `app/routes/api/alpaca/orders.ts`
- [ ] **Step 1: Write the failing test**
```typescript
// tests/orders.test.ts
import { test, expect } from "@playwright/test";
test("GET /api/alpaca/orders returns orders list", async ({ page }) => {
const res = await page.request.get("/api/alpaca/orders");
expect(res.ok()).toBeTruthy();
const data = await res.json();
expect(data.orders).toBeDefined();
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npm run test:e2e`
Expected: 404 Not Found
- [ ] **Step 3: Write minimal implementation**
```typescript
import Alpaca from "@alpacahq/alpaca-trade-api";
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",
retryOnError: false,
});
export async function loader() {
try {
const orders = await alpaca.getOrders();
return Response.json({ orders });
} catch (error) {
console.error("Alpaca orders error:", error);
return Response.json({ orders: [] }, { status: 500 });
}
}
```
- [ ] **Step 4: Register route in routes.ts**
```typescript
route("api/alpaca/orders", "routes/api/alpaca/orders.ts"),
```
- [ ] **Step 5: Run test to verify it passes**
Run: `npm run test:e2e -- tests/orders.test.ts`
Expected: PASS
- [ ] **Step 6: Commit**
```bash
git add app/routes/api/alpaca/orders.ts tests/orders.test.ts
git commit -m "feat: add alpaca orders API endpoint"
```
---
### Task 2: Install TradingView Lightweight Charts
**Files:**
- None (npm install)
- [ ] **Step 1: Install dependency**
```bash
npm install @tradingview/lightweight-charts
```
- [ ] **Step 2: Run typecheck to verify installation**
Run: `npx tsc --noEmit`
Expected: No errors
- [ ] **Step 3: Commit**
```bash
git add package.json package-lock.json
git commit -m "feat: install tradingview lightweight charts"
```
---
### Task 3: Create TradingView Chart Component
**Files:**
- Create: `app/components/TradingViewChart.tsx`
- [ ] **Step 1: Write the component**
```typescript
import { useEffect, useRef } from "react";
import * as LightweightCharts from "@tradingview/lightweight-charts";
interface TradingViewChartProps {
ticker: string;
data?: Array<{ time: string; open: number; high: number; low: number; close: number }>;
}
export default function TradingViewChart({ ticker, data }: TradingViewChartProps) {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!containerRef.current) return;
const chart = LightweightCharts.createChart(containerRef.current, {
width: containerRef.current.clientWidth,
height: 400,
});
const candlestickSeries = chart.addCandlestickSeries();
if (data && data.length > 0) {
candlestickSeries.setData(data);
}
return () => chart.remove();
}, [data]);
return (
<div className="bg-white rounded-xl shadow-lg p-4">
<h3 className="text-lg font-bold mb-3">{ticker} Price Chart</h3>
<div ref={containerRef} />
</div>
);
}
```
- [ ] **Step 2: Run typecheck**
Run: `npm run typecheck`
Expected: PASS
- [ ] **Step 3: Commit**
```bash
git add app/components/TradingViewChart.tsx
git commit -m "feat: add tradingview chart component"
```
---
### Task 4: Create Stock Detail Route
**Files:**
- Create: `app/routes/analyze.ticker.tsx`
- Modify: `app/routes.ts`
- [ ] **Step 1: Create the route file**
```typescript
import { db } from "../lib/db.server";
import TradingViewChart from "../components/TradingViewChart";
import type { TradingDecision } from "../types/agents";
export const meta = () => [{ title: "Stock Detail - AITrader" }];
interface LoaderData {
ticker: string;
position: number | null;
orders: any[];
analysis: TradingDecision | null;
}
export async function loader({ params }: { params: { ticker: string } }) {
const ticker = params.ticker?.toUpperCase() || "";
// Fetch position
const posRes = await fetch(`${process.env.BASE_URL}/api/alpaca/positions`);
const positions = posRes.ok ? await posRes.json() : [];
const position = positions.find((p: any) => p.ticker === ticker)?.qty ?? null;
// Fetch orders
const ordRes = await fetch(`${process.env.BASE_URL}/api/alpaca/orders`);
const ordersData = ordRes.ok ? await ordRes.json() : { orders: [] };
const orders = ordersData.orders?.filter((o: any) => o.symbol === ticker) || [];
return Response.json({ ticker, position, orders });
}
export default function StockDetail() {
const { ticker, position, orders } = useLoaderData() as LoaderData;
return (
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-blue-50">
<Navbar />
<div className="mx-auto max-w-7xl px-6 py-8">
<h1 className="text-3xl font-bold text-gray-900 mb-6">{ticker} Detail</h1>
<TradingViewChart ticker={ticker} />
<div className="mt-6">
<h2>Position</h2>
<p>{position ? `Qty: ${position}` : "No position"}</p>
</div>
<div className="mt-6">
<h2>Recent Orders</h2>
{orders.length === 0 ? <p>No orders</p> : <pre>{JSON.stringify(orders, null, 2)}</pre>}
</div>
</div>
</div>
);
}
```
- [ ] **Step 2: Register route in routes.ts**
```typescript
route("analyze/:ticker", "routes/analyze.ticker.tsx"),
```
- [ ] **Step 3: Run typecheck and test**
Run: `npm run typecheck && npm run test:e2e`
Expected: All pass
- [ ] **Step 4: Commit**
```bash
git add app/routes/analyze.ticker.tsx app/routes.ts
git commit -m "feat: add stock detail route"
```
---
### Task 5: Add Navigation from Analyze Page
**Files:**
- Modify: `app/routes/analyze.tsx`
- [ ] **Step 1: Make ticker clickable**
```typescript
// Change from:
<td className="py-3 px-4 font-bold text-gray-900">{stock.ticker}</td>
// To:
<td className="py-3 px-4 font-bold text-gray-900">
<Link to={`/analyze/${stock.ticker}`} className="text-blue-600 hover:underline">
{stock.ticker}
</Link>
</td>
```
- [ ] **Step 2: Add Link import**
```typescript
import { Link } from "react-router";
```
- [ ] **Step 3: Run tests**
Run: `npm run test:e2e`
Expected: All pass
- [ ] **Step 4: Commit**
```bash
git add app/routes/analyze.tsx
git commit -m "feat: add navigation to stock detail page"
```
---
### Task 6: Final Verification
**Files:**
- Check: `package.json`, `tsconfig.json`
- [ ] **Step 1: Run complete test suite**
```bash
npm run typecheck
npm run test:e2e -- --reporter=line
```
Expected: All 8+ tests pass
- [ ] **Step 2: Commit**
```bash
git commit -am "chore: verify stock detail implementation"
```
@@ -0,0 +1,47 @@
# Prisma Stock Model Implementation Spec
## Goal
Initialize Prisma ORM with SQLite database and create a Stock model to persist manually added stock tickers in the AITrader analyze route.
## Architecture
- **ORM**: Prisma with SQLite datasource
- **Database file**: `prisma/dev.db`
- **Model**: `Stock` with id, ticker, optional notes, and timestamps
- **Integration**: API routes for CRUD operations, integrated with analyze.tsx
## Design Decisions
### Stock Model Schema
```prisma
model Stock {
id String @id @default(cuid())
ticker String @unique
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
```
**Rationale for `notes` field**: Included for future extensibility (user notes on watched stocks). Nullable to avoid breaking changes.
### Implementation Approach
- Single migration (`init`) for all model fields
- SQLite for development simplicity (matches plan)
- Prisma client singleton pattern for React Router 7 compatibility
## Files to Create/Modify
### Task 1: Initialize Prisma
- `prisma/schema.prisma` - Prisma schema with Stock model
- `prisma/dev.db` - SQLite database (generated)
- `prisma/migrations/..._init/migration.sql` - Initial migration (generated)
## Success Criteria
1. `prisma/schema.prisma` exists with valid Stock model
2. `npx prisma generate` completes without errors
3. `npx prisma migrate dev --name init` creates `prisma/dev.db`
4. Git commit created with prisma/ changes
## Dependencies to Install
- `prisma` (dev dependency)
- `@prisma/client` (runtime dependency)
@@ -0,0 +1,77 @@
# Stock Detail Page Implementation Design
**Goal:** Create a stock detail page at `/analyze/:ticker` showing TradingView chart, position, orders, and trading graph results.
**Architecture:** Dynamic route approach - separate route from analyze page with ticker parameter.
## Files to Create/Modify
1. `app/routes/analyze.ticker.tsx` - Stock detail page component
2. `app/components/TradingViewChart.tsx` - TradingView lightweight charts wrapper
3. `app/routes/api/alpaca/orders.ts` - Orders API endpoint
4. `app/routes.ts` - Add new route
## Page Structure
```
/analyze/:ticker
├── Navbar
├── Stock Header (ticker, current price)
├── TradingView Chart (full width)
├── Position Card (quantity, avg cost, current value)
│ - Styled card with gray-900 headings, gray-600 text
├── Orders Table (recent orders with status)
│ - Table with Side (green/red), Qty, Status, Filled Price, Filled At
│ - Empty state shows "No orders found for {ticker}"
└── Trading Graph Results (expandable sections)
├── Analyst Reports (fundamentals, technical, sentiment)
├── Debate Summary
└── Final Decision
```
## Data Sources
- **Chart**: TradingView widget with Alpaca data
- **Position**: `/api/alpaca/positions` filtered by ticker
- **Orders**: New `/api/alpaca/orders` endpoint
- **Analysis**: `/api/analyze` + TradingGraph results
## API Changes
### GET /api/alpaca/orders
Returns list of orders from Alpaca, optionally filtered by ticker.
```typescript
// Response format
{
orders: Array<{
id: string;
ticker: string;
qty: number;
side: "buy" | "sell";
status: "new" | "filled" | "canceled";
filled_at: string | null;
filled_avg_price: string;
}>
}
```
## Component Details
### TradingViewChart.tsx
- Uses TradingView lightweight charts library
- Props: `ticker`, `data` (price data array)
- Fetches historical bars from Alpaca API
- Renders candlestick chart
### analyze.ticker.tsx
- Loader function fetches position, orders, and runs analysis
- Uses `useLoaderData` for server-fetched data
- Client-side rerun of analysis via form action
## User Flow
1. User clicks ticker in analyze page table
2. Navigates to `/analyze/:ticker`
3. Page shows chart at top, position/orders/analysis below
4. "Rerun Analysis" button triggers TradingGraph
+620
View File
@@ -11,6 +11,7 @@
"@react-router/node": "7.15.0", "@react-router/node": "7.15.0",
"@react-router/serve": "7.15.0", "@react-router/serve": "7.15.0",
"isbot": "^5.1.36", "isbot": "^5.1.36",
"lightweight-charts": "^5.2.0",
"react": "^19.2.6", "react": "^19.2.6",
"react-dom": "^19.2.6", "react-dom": "^19.2.6",
"react-router": "7.15.0", "react-router": "7.15.0",
@@ -18,6 +19,7 @@
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.60.0", "@playwright/test": "^1.60.0",
"@prisma/client": "^5.22.0",
"@react-router/dev": "7.15.0", "@react-router/dev": "7.15.0",
"@tailwindcss/vite": "^4.2.2", "@tailwindcss/vite": "^4.2.2",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
@@ -26,7 +28,9 @@
"@types/node": "^22", "@types/node": "^22",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"jsdom": "^29.1.1",
"playwright": "^1.42.0", "playwright": "^1.42.0",
"prisma": "^5.22.0",
"tailwindcss": "^4.2.2", "tailwindcss": "^4.2.2",
"tsx": "^4.21.0", "tsx": "^4.21.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
@@ -64,6 +68,57 @@
"npm": ">=6" "npm": ">=6"
} }
}, },
"node_modules/@asamuzakjp/css-color": {
"version": "5.1.11",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
"integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/generational-cache": "^1.0.1",
"@csstools/css-calc": "^3.2.0",
"@csstools/css-color-parser": "^4.1.0",
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/dom-selector": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz",
"integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/generational-cache": "^1.0.1",
"@asamuzakjp/nwsapi": "^2.3.9",
"bidi-js": "^1.0.3",
"css-tree": "^3.2.1",
"is-potential-custom-element-name": "^1.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/generational-cache": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
"integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/nwsapi": {
"version": "2.3.9",
"resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
"integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
"dev": true,
"license": "MIT"
},
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
"version": "7.29.0", "version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
@@ -537,6 +592,159 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@bramus/specificity": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
"integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
"dev": true,
"license": "MIT",
"dependencies": {
"css-tree": "^3.0.0"
},
"bin": {
"specificity": "bin/cli.js"
}
},
"node_modules/@csstools/color-helpers": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
"integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@csstools/css-calc": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz",
"integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-color-parser": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.1.tgz",
"integrity": "sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"dependencies": {
"@csstools/color-helpers": "^6.0.2",
"@csstools/css-calc": "^3.2.1"
},
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-parser-algorithms": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
"integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-syntax-patches-for-csstree": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.4.tgz",
"integrity": "sha512-wgsqt92b7C7tQhIdPNxj0n9zuUbQlvAuI1exyzeNrOKOi62SD7ren8zqszmpVREjAOqg8cD2FqYhQfAuKjk4sw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"peerDependencies": {
"css-tree": "^3.2.1"
},
"peerDependenciesMeta": {
"css-tree": {
"optional": true
}
}
},
"node_modules/@csstools/css-tokenizer": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
"integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@emnapi/core": { "node_modules/@emnapi/core": {
"version": "1.10.0", "version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
@@ -1094,6 +1302,24 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0" "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
} }
}, },
"node_modules/@exodus/bytes": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz",
"integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
"@noble/hashes": "^1.8.0 || ^2.0.0"
},
"peerDependenciesMeta": {
"@noble/hashes": {
"optional": true
}
}
},
"node_modules/@hono/node-server": { "node_modules/@hono/node-server": {
"version": "1.19.14", "version": "1.19.14",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
@@ -1585,6 +1811,75 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@prisma/client": {
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz",
"integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==",
"dev": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"engines": {
"node": ">=16.13"
},
"peerDependencies": {
"prisma": "*"
},
"peerDependenciesMeta": {
"prisma": {
"optional": true
}
}
},
"node_modules/@prisma/debug": {
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz",
"integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/engines": {
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz",
"integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==",
"dev": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "5.22.0",
"@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
"@prisma/fetch-engine": "5.22.0",
"@prisma/get-platform": "5.22.0"
}
},
"node_modules/@prisma/engines-version": {
"version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz",
"integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/fetch-engine": {
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz",
"integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "5.22.0",
"@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
"@prisma/get-platform": "5.22.0"
}
},
"node_modules/@prisma/get-platform": {
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz",
"integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "5.22.0"
}
},
"node_modules/@react-router/dev": { "node_modules/@react-router/dev": {
"version": "7.15.0", "version": "7.15.0",
"resolved": "https://registry.npmjs.org/@react-router/dev/-/dev-7.15.0.tgz", "resolved": "https://registry.npmjs.org/@react-router/dev/-/dev-7.15.0.tgz",
@@ -3219,6 +3514,16 @@
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/bidi-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
"dev": true,
"license": "MIT",
"dependencies": {
"require-from-string": "^2.0.2"
}
},
"node_modules/bl": { "node_modules/bl": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
@@ -3628,6 +3933,20 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/css-tree": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
"dev": true,
"license": "MIT",
"dependencies": {
"mdn-data": "2.27.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
}
},
"node_modules/css.escape": { "node_modules/css.escape": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
@@ -3642,6 +3961,20 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/data-urls": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
"integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
"dev": true,
"license": "MIT",
"dependencies": {
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^16.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -3659,6 +3992,13 @@
} }
} }
}, },
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"dev": true,
"license": "MIT"
},
"node_modules/dedent": { "node_modules/dedent": {
"version": "1.7.2", "version": "1.7.2",
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
@@ -3797,6 +4137,19 @@
"node": ">=10.13.0" "node": ">=10.13.0"
} }
}, },
"node_modules/entities": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
"integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-define-property": { "node_modules/es-define-property": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@@ -4233,6 +4586,12 @@
"integrity": "sha512-AgFD4VU+lVLP6vjnlNfF7OeInLTyeyckCNPEsuxz1vi786UuK/nk6ynPuhn/h+Ju9++TQyr5EpLRI14fc1QtTQ==", "integrity": "sha512-AgFD4VU+lVLP6vjnlNfF7OeInLTyeyckCNPEsuxz1vi786UuK/nk6ynPuhn/h+Ju9++TQyr5EpLRI14fc1QtTQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/fancy-canvas": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fancy-canvas/-/fancy-canvas-2.1.0.tgz",
"integrity": "sha512-nifxXJ95JNLFR2NgRV4/MxVP45G9909wJTEKz5fg/TZS20JJZA6hfgRVh/bC9bwl2zBtBNcYPjiBE4njQHVBwQ==",
"license": "MIT"
},
"node_modules/fast-deep-equal": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -4630,6 +4989,19 @@
"node": ">=16.9.0" "node": ">=16.9.0"
} }
}, },
"node_modules/html-encoding-sniffer": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
"integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.6.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/http-errors": { "node_modules/http-errors": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -4791,6 +5163,13 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"dev": true,
"license": "MIT"
},
"node_modules/is-promise": { "node_modules/is-promise": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
@@ -4850,6 +5229,57 @@
"js-yaml": "bin/js-yaml.js" "js-yaml": "bin/js-yaml.js"
} }
}, },
"node_modules/jsdom": {
"version": "29.1.1",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
"integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^5.1.11",
"@asamuzakjp/dom-selector": "^7.1.1",
"@bramus/specificity": "^2.4.2",
"@csstools/css-syntax-patches-for-csstree": "^1.1.3",
"@exodus/bytes": "^1.15.0",
"css-tree": "^3.2.1",
"data-urls": "^7.0.0",
"decimal.js": "^10.6.0",
"html-encoding-sniffer": "^6.0.0",
"is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.3.5",
"parse5": "^8.0.1",
"saxes": "^6.0.0",
"symbol-tree": "^3.2.4",
"tough-cookie": "^6.0.1",
"undici": "^7.25.0",
"w3c-xmlserializer": "^5.0.0",
"webidl-conversions": "^8.0.1",
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^16.0.1",
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24.0.0"
},
"peerDependencies": {
"canvas": "^3.0.0"
},
"peerDependenciesMeta": {
"canvas": {
"optional": true
}
}
},
"node_modules/jsdom/node_modules/lru-cache": {
"version": "11.3.6",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz",
"integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/jsesc": { "node_modules/jsesc": {
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz",
@@ -5201,6 +5631,15 @@
"url": "https://opencollective.com/parcel" "url": "https://opencollective.com/parcel"
} }
}, },
"node_modules/lightweight-charts": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/lightweight-charts/-/lightweight-charts-5.2.0.tgz",
"integrity": "sha512-ey3Vas8UhV06ni+LT9TA1nEe4y8So4Mi6CL/oarNHFMyTktz/xy8e8+oh04Q//eO3t6etvFXgayz2fClyFQb5w==",
"license": "Apache-2.0",
"dependencies": {
"fancy-canvas": "2.1.0"
}
},
"node_modules/locate-path": { "node_modules/locate-path": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -5267,6 +5706,13 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/mdn-data": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
"dev": true,
"license": "CC0-1.0"
},
"node_modules/media-typer": { "node_modules/media-typer": {
"version": "0.3.0", "version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
@@ -5633,6 +6079,19 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/parse5": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
"integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
"dev": true,
"license": "MIT",
"dependencies": {
"entities": "^8.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/parseurl": { "node_modules/parseurl": {
"version": "1.3.3", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -5846,6 +6305,26 @@
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
} }
}, },
"node_modules/prisma": {
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz",
"integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==",
"dev": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/engines": "5.22.0"
},
"bin": {
"prisma": "build/index.js"
},
"engines": {
"node": ">=16.13"
},
"optionalDependencies": {
"fsevents": "2.3.3"
}
},
"node_modules/proxy-addr": { "node_modules/proxy-addr": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -6250,6 +6729,19 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
"dev": true,
"license": "ISC",
"dependencies": {
"xmlchars": "^2.2.0"
},
"engines": {
"node": ">=v12.22.7"
}
},
"node_modules/scheduler": { "node_modules/scheduler": {
"version": "0.27.0", "version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@@ -6545,6 +7037,13 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
"dev": true,
"license": "MIT"
},
"node_modules/tailwindcss": { "node_modules/tailwindcss": {
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz",
@@ -6616,6 +7115,26 @@
"node": ">=14.0.0" "node": ">=14.0.0"
} }
}, },
"node_modules/tldts": {
"version": "7.0.30",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.30.tgz",
"integrity": "sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw==",
"dev": true,
"license": "MIT",
"dependencies": {
"tldts-core": "^7.0.30"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "7.0.30",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.30.tgz",
"integrity": "sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==",
"dev": true,
"license": "MIT"
},
"node_modules/toidentifier": { "node_modules/toidentifier": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -6625,6 +7144,32 @@
"node": ">=0.6" "node": ">=0.6"
} }
}, },
"node_modules/tough-cookie": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz",
"integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"tldts": "^7.0.5"
},
"engines": {
"node": ">=16"
}
},
"node_modules/tr46": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
"dev": true,
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=20"
}
},
"node_modules/ts-nkeys": { "node_modules/ts-nkeys": {
"version": "1.0.16", "version": "1.0.16",
"resolved": "https://registry.npmjs.org/ts-nkeys/-/ts-nkeys-1.0.16.tgz", "resolved": "https://registry.npmjs.org/ts-nkeys/-/ts-nkeys-1.0.16.tgz",
@@ -6719,6 +7264,16 @@
"node": ">=14.17" "node": ">=14.17"
} }
}, },
"node_modules/undici": {
"version": "7.25.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz",
"integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20.18.1"
}
},
"node_modules/undici-types": { "node_modules/undici-types": {
"version": "6.21.0", "version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
@@ -7110,6 +7665,54 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
"dev": true,
"license": "MIT",
"dependencies": {
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/webidl-conversions": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20"
}
},
"node_modules/whatwg-mimetype": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/whatwg-url": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.11.0",
"tr46": "^6.0.0",
"webidl-conversions": "^8.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/which": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -7178,6 +7781,23 @@
} }
} }
}, },
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/xmlchars": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"dev": true,
"license": "MIT"
},
"node_modules/yallist": { "node_modules/yallist": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+4
View File
@@ -17,6 +17,7 @@
"@react-router/node": "7.15.0", "@react-router/node": "7.15.0",
"@react-router/serve": "7.15.0", "@react-router/serve": "7.15.0",
"isbot": "^5.1.36", "isbot": "^5.1.36",
"lightweight-charts": "^5.2.0",
"react": "^19.2.6", "react": "^19.2.6",
"react-dom": "^19.2.6", "react-dom": "^19.2.6",
"react-router": "7.15.0", "react-router": "7.15.0",
@@ -24,6 +25,7 @@
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.60.0", "@playwright/test": "^1.60.0",
"@prisma/client": "^5.22.0",
"@react-router/dev": "7.15.0", "@react-router/dev": "7.15.0",
"@tailwindcss/vite": "^4.2.2", "@tailwindcss/vite": "^4.2.2",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
@@ -32,7 +34,9 @@
"@types/node": "^22", "@types/node": "^22",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"jsdom": "^29.1.1",
"playwright": "^1.42.0", "playwright": "^1.42.0",
"prisma": "^5.22.0",
"tailwindcss": "^4.2.2", "tailwindcss": "^4.2.2",
"tsx": "^4.21.0", "tsx": "^4.21.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
File diff suppressed because one or more lines are too long
BIN
View File
Binary file not shown.
@@ -0,0 +1,11 @@
-- CreateTable
CREATE TABLE "Stock" (
"id" TEXT NOT NULL PRIMARY KEY,
"ticker" TEXT NOT NULL,
"notes" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "Stock_ticker_key" ON "Stock"("ticker");
+3
View File
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "sqlite"
+17
View File
@@ -0,0 +1,17 @@
generator client {
provider = "prisma-client-js"
output = "../node_modules/.prisma/client"
}
datasource db {
provider = "sqlite"
url = "file:./dev.db"
}
model Stock {
id String @id @default(cuid())
ticker String @unique
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
+38
View File
@@ -0,0 +1,38 @@
import { test, expect } from "@playwright/test";
test.describe("Alpaca Historical Bars", () => {
test("should return bars for AAPL with 1D timeframe", async ({ page }) => {
const response = await page.request.get("/api/alpaca/quote/AAPL");
expect(response.ok()).toBeTruthy();
const data = await response.json();
expect(data.ticker).toBe("AAPL");
expect(data.price).toBeGreaterThan(0);
expect(data.bars.length).toBeGreaterThan(0);
const bar = data.bars[0];
expect(bar.t).toBeDefined();
expect(bar.o).toBeGreaterThan(0);
expect(bar.h).toBeGreaterThan(0);
expect(bar.l).toBeGreaterThan(0);
expect(bar.c).toBeGreaterThan(0);
});
test("should return bars for AAPL with 5Min timeframe", async ({ page }) => {
const response = await page.request.get("/api/alpaca/quote/AAPL?timeframe=5Min&limit=5");
expect(response.ok()).toBeTruthy();
const data = await response.json();
expect(data.ticker).toBe("AAPL");
expect(data.bars.length).toBeGreaterThanOrEqual(0);
});
test("should return bars for AAPL with 1H timeframe", async ({ page }) => {
const response = await page.request.get("/api/alpaca/quote/AAPL?timeframe=1H&limit=10");
expect(response.ok()).toBeTruthy();
const data = await response.json();
expect(data.ticker).toBe("AAPL");
expect(data.bars.length).toBeGreaterThanOrEqual(0);
});
});
+61
View File
@@ -0,0 +1,61 @@
import { test, expect } from "@playwright/test";
test.describe("Portfolio Analysis Page", () => {
test("should load and display portfolio analysis form", async ({ page }) => {
await page.goto("/analyze");
await expect(page.locator("h1")).toHaveText("Portfolio Analysis");
await expect(page.locator('input[placeholder*="Add ticker"]')).toBeVisible();
await expect(page.locator("button:has-text('Add Stock')")).toBeVisible();
});
test("should load Alpaca portfolio on page load", async ({ page }) => {
await page.goto("/analyze");
// Wait for potential positions to load from Alpaca
await page.waitForTimeout(3000);
// If user has positions, they should appear; otherwise check empty state
const hasPositions = await page.locator("td.font-bold").first().isVisible();
if (hasPositions) {
const tickerCell = page.locator("td.font-bold").first();
await expect(tickerCell).not.toBeEmpty();
} else {
await expect(page.locator("text=No stocks added")).toBeVisible();
}
});
test("should add a ticker and display it in the table", async ({ page }) => {
await page.goto("/analyze");
await page.fill('input[placeholder*="Add ticker"]', "AAPL");
await page.click("button:has-text('Add Stock')");
await expect(page.locator("td.font-bold")).toContainText("AAPL");
});
test("should display position quantity from Alpaca", async ({ page }) => {
await page.goto("/analyze");
// Wait for any Alpaca portfolio to load first
await page.waitForTimeout(3000);
// Wait for the table row to appear - if already stocks exist, verify position column works
await page.waitForSelector("tr td.font-bold");
// Get first ticker row and verify it has position column (third column)
const firstRow = page.locator("tbody tr").first();
const tickerCell = firstRow.locator("td.font-bold");
const positionCell = firstRow.locator("td:nth-child(3)");
// Verify ticker is displayed
await expect(tickerCell).toBeVisible();
// Position column should have some content (number or empty if no position)
const positionText = await positionCell.textContent();
// Position should be a number if present
if (positionText && positionText.trim()) {
expect(positionText.trim()).toMatch(/^\d+$/);
}
});
});
+8
View File
@@ -0,0 +1,8 @@
import { test, expect } from "@playwright/test";
test("GET /api/alpaca/orders returns orders list", async ({ page }) => {
const res = await page.request.get("/api/alpaca/orders");
expect(res.ok()).toBeTruthy();
const data = await res.json();
expect(data.orders).toBeDefined();
});
+40
View File
@@ -0,0 +1,40 @@
import { test, expect } from "@playwright/test";
test.describe("Stock Database", () => {
test("should add and list stocks", async ({ page }) => {
const uniqueTicker = `TEST${Date.now()}`;
const createRes = await page.request.post("/api/stocks", {
data: new URLSearchParams({ ticker: uniqueTicker }).toString(),
headers: { "Content-Type": "application/x-www-form-urlencoded" },
});
expect(createRes.ok()).toBeTruthy();
const listRes = await page.request.get("/api/stocks");
const stocks = await listRes.json();
expect(stocks).toContainEqual(expect.objectContaining({ ticker: uniqueTicker }));
});
test("should delete stock from database", async ({ page }) => {
const uniqueTicker = `DEL${Date.now()}`;
await page.request.post("/api/stocks", {
data: new URLSearchParams({ ticker: uniqueTicker }).toString(),
headers: { "Content-Type": "application/x-www-form-urlencoded" },
});
let listRes = await page.request.get("/api/stocks");
let stocks = await listRes.json();
expect(stocks).toContainEqual(expect.objectContaining({ ticker: uniqueTicker }));
const delRes = await page.request.post("/api/stocks", {
data: new URLSearchParams({ ticker: uniqueTicker, _method: "DELETE" }).toString(),
headers: { "Content-Type": "application/x-www-form-urlencoded" },
});
expect(delRes.ok()).toBeTruthy();
listRes = await page.request.get("/api/stocks");
stocks = await listRes.json();
expect(stocks).not.toContainEqual(expect.objectContaining({ ticker: uniqueTicker }));
});
});