Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cc22174b78 | |||
| d1a84325ae | |||
| b4076f89b6 | |||
| 77032a3c3a | |||
| 834a427c18 | |||
| 2e22fd5635 | |||
| 043c3d5afe | |||
| 3340fd11ca | |||
| f40eec1420 | |||
| 0fdd8432a0 | |||
| 41fdc08a6e | |||
| 988368326c | |||
| 944a7280c9 | |||
| 503a1c8bde | |||
| bd033a5d84 | |||
| 0930e11495 | |||
| 86fe670ca0 | |||
| e913b32f34 | |||
| eb66485e76 | |||
| 3536193746 | |||
| 55d6ba4fee | |||
| 7b81adb6a2 | |||
| 5a99273c9d |
+4
-1
@@ -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
|
||||||
@@ -5,3 +5,6 @@
|
|||||||
# React Router
|
# React Router
|
||||||
/.react-router/
|
/.react-router/
|
||||||
/build/
|
/build/
|
||||||
|
|
||||||
|
/generated/prisma
|
||||||
|
/prisma/dev.db
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
Generated
+620
@@ -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",
|
||||||
|
|||||||
@@ -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
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");
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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+$/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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 }));
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user