diff --git a/.env.example b/.env.example index 69a4699..700bed3 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,4 @@ ALPACA_API_KEY=your_alpaca_api_key_here ALPACA_SECRET_KEY=your_alpaca_secret_key_here -ALPACA_BASE_URL=https://paper-api.alpaca.markets \ No newline at end of file +ALPACA_BASE_URL=https://paper-api.alpaca.markets +OPENROUTER_API_KEY=your_openrouter_api_key_here \ No newline at end of file diff --git a/.gitignore b/.gitignore index 039ee62..8abc591 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,6 @@ # React Router /.react-router/ /build/ + +/generated/prisma +/prisma/dev.db diff --git a/app/agents/fundamentals.ts b/app/agents/fundamentals.ts index 299b8ae..1290779 100644 --- a/app/agents/fundamentals.ts +++ b/app/agents/fundamentals.ts @@ -11,7 +11,7 @@ export class FundamentalsAnalyst { constructor(client: OpenRouterClient, config?: FundamentalsConfig) { this.client = client; - this.model = config?.model ?? "google/gemini-2.0-flash-exp:free"; + this.model = config?.model ?? "openai/gpt-oss-120b:free"; } getModel(): string { diff --git a/app/agents/researchers.ts b/app/agents/researchers.ts index b8eb4da..5cb3527 100644 --- a/app/agents/researchers.ts +++ b/app/agents/researchers.ts @@ -11,7 +11,7 @@ export class BullishResearcher { constructor(client: OpenRouterClient, model?: string) { this.client = client; - this.model = model ?? "google/gemini-2.0-flash-exp:free"; + this.model = model ?? "openai/gpt-oss-120b:free"; } async research(ticker: string, reports: AnalystReport[]): Promise { @@ -48,7 +48,7 @@ export class BearishResearcher { constructor(client: OpenRouterClient, model?: string) { this.client = client; - this.model = model ?? "google/gemini-2.0-flash-exp:free"; + this.model = model ?? "openai/gpt-oss-120b:free"; } async research(ticker: string, reports: AnalystReport[]): Promise { diff --git a/app/agents/sentiment.ts b/app/agents/sentiment.ts index c4264cf..73a4c46 100644 --- a/app/agents/sentiment.ts +++ b/app/agents/sentiment.ts @@ -16,7 +16,7 @@ export class SentimentAnalyst { constructor(client: OpenRouterClient, config?: SentimentConfig) { this.client = client; - this.model = config?.model ?? "google/gemini-2.0-flash-exp:free"; + this.model = config?.model ?? "openai/gpt-oss-120b:free"; } getModel(): string { diff --git a/app/agents/technical.ts b/app/agents/technical.ts index 80bf726..42211a0 100644 --- a/app/agents/technical.ts +++ b/app/agents/technical.ts @@ -19,7 +19,7 @@ export class TechnicalAnalyst { constructor(client: OpenRouterClient, config?: TechnicalAnalystConfig) { this.client = client; - this.model = config?.model ?? "google/gemini-2.0-flash-exp:free"; + this.model = config?.model ?? "openai/gpt-oss-120b:free"; } getModel(): string { diff --git a/app/agents/trader.ts b/app/agents/trader.ts index 9fdd51f..9f01029 100644 --- a/app/agents/trader.ts +++ b/app/agents/trader.ts @@ -11,7 +11,7 @@ export class Trader { constructor(client: OpenRouterClient, model?: string) { this.client = client; - this.model = model ?? "google/gemini-2.0-flash-exp:free"; + this.model = model ?? "openai/gpt-oss-120b:free"; } async decide( diff --git a/app/agents/tradingGraph.ts b/app/agents/tradingGraph.ts index 771cc19..3ded285 100644 --- a/app/agents/tradingGraph.ts +++ b/app/agents/tradingGraph.ts @@ -6,6 +6,11 @@ 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; @@ -18,7 +23,7 @@ export class TradingGraph { constructor(client: OpenRouterClient, model?: string) { this.client = client; - this.model = model ?? "google/gemini-2.0-flash-exp:free"; + this.model = model ?? "openai/gpt-oss-120b:free"; this.fundamentalsAnalyst = new FundamentalsAnalyst(client, { model: this.model }); this.technicalAnalyst = new TechnicalAnalyst(client, { model: this.model }); @@ -36,9 +41,15 @@ export class TradingGraph { sentimentData: { headlines: string[]; source?: "news" | "social" | "stocktwits" }; } ): Promise { + 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; } @@ -50,21 +61,33 @@ export class TradingGraph { sentimentData: { headlines: string[]; source?: "news" | "social" | "stocktwits" }; } ): Promise { + 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 { + 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, diff --git a/app/components/StockViewer.tsx b/app/components/StockViewer.tsx index 7be5a79..94f0c95 100644 --- a/app/components/StockViewer.tsx +++ b/app/components/StockViewer.tsx @@ -58,14 +58,14 @@ export default function StockViewer() { {indicators && (
-

+

Results for {symbol.toUpperCase()}

{Object.entries(indicators).map(([key, value]) => (
{key} - {value.toFixed(2)} + {value.toFixed(2)}
))}
diff --git a/app/lib/db.server.ts b/app/lib/db.server.ts new file mode 100644 index 0000000..5acd3c2 --- /dev/null +++ b/app/lib/db.server.ts @@ -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; +} \ No newline at end of file diff --git a/app/lib/openrouter.ts b/app/lib/openrouter.ts index c13f974..ba0da4b 100644 --- a/app/lib/openrouter.ts +++ b/app/lib/openrouter.ts @@ -9,20 +9,21 @@ type OpenRouterConfig = { }; export class OpenRouterClient { - private apiKey: string; - private baseURL: string; - private defaultModel: string; - private freeModels = [ - "google/gemini-2.0-flash-exp:free", - "deepseek/deepseek-chat:free", - "meta/llama-3.3-70b-instruct:free", - ]; - private providers = ["openai", "google", "anthropic", "deepseek", "meta", "xai"]; + 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 ?? "google/gemini-2.0-flash-exp:free"; + this.defaultModel = config?.defaultModel ?? "openai/gpt-oss-120b:free"; } getFreeModels(): string[] { diff --git a/app/routes.ts b/app/routes.ts index b7eb2d0..3b5ad8b 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -3,6 +3,11 @@ import { type RouteConfig, index, route } from "@react-router/dev/routes"; export default [ index("routes/landing.tsx"), route("api/alpaca/account", "routes/api/alpaca/account.ts"), + route("api/alpaca/quote/:ticker", "routes/api/alpaca/quote.ts"), + route("api/alpaca/positions", "routes/api/alpaca/positions.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("analyze", "routes/analyze.tsx"), ] satisfies RouteConfig; \ No newline at end of file diff --git a/app/routes/analyze.tsx b/app/routes/analyze.tsx index a6290ff..941830e 100644 --- a/app/routes/analyze.tsx +++ b/app/routes/analyze.tsx @@ -1,78 +1,262 @@ -import { useState } from "react"; +import { useState, useEffect } from "react"; import Navbar from "../components/Navbar"; -import type { AgentSignal, AnalystReport, DebateRound, TradingDecision } from "../types/agents"; +import type { TradingDecision } from "../types/agents"; -type AnalysisStep = "input" | "analysts" | "researchers" | "trader" | "decision"; - -interface AnalysisState { +interface StockRow { + id: string; ticker: string; - date: string; - analysts: AnalystReport[]; - debates: DebateRound[]; - decision: TradingDecision | null; - isLoading: boolean; - error: string | null; + currentPrice: number | null; + position: number; + rsi: number | null; + analysis: TradingDecision | null; + loading: boolean; } -const SignalBadge = ({ signal }: { signal: AgentSignal["signal"] }) => { - const colors = { - bullish: "bg-green-100 text-green-800", - bearish: "bg-red-100 text-red-800", - neutral: "bg-gray-100 text-gray-800", - }; - return ( - - {signal} - - ); -}; - export const meta = () => { return [ - { title: "Analyze - AITrader" }, - { name: "description", content: "Multi-agent trading analysis with dataflow visualization" }, + { title: "Portfolio Analysis - AITrader" }, + { name: "description", content: "Analyze your stock portfolio with AI trading insights" }, ]; }; export default function Analyze() { - const [state, setState] = useState({ - ticker: "", - date: new Date().toISOString().split("T")[0], - analysts: [], - debates: [], - decision: null, - isLoading: false, - error: null, - }); + const [stocks, setStocks] = useState([]); + const [newTicker, setNewTicker] = useState(""); - const [currentStep, setCurrentStep] = useState("input"); +// 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 runAnalysis = async (e: React.FormEvent) => { - e.preventDefault(); - setState((s) => ({ ...s, isLoading: true, error: null })); + 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 { - const response = await fetch("/api/analyze", { + 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 = (id: string) => { + 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: state.ticker, date: state.date }), + body: JSON.stringify({ ticker }), }); - - if (!response.ok) throw new Error("Analysis failed"); - const decision = await response.json(); - - setState((s) => ({ - ...s, - decision, - isLoading: false, - })); - setCurrentStep("decision"); - } catch (err) { - setState((s) => ({ - ...s, - error: err instanceof Error ? err.message : "Unknown error", - isLoading: false, - })); + 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)); } }; @@ -80,131 +264,103 @@ export default function Analyze() {
-

Multi-Agent Trading Analysis

- +

Portfolio Analysis

+
-

Input Parameters

-
-
- - setState((s) => ({ ...s, ticker: e.target.value.toUpperCase() }))} - placeholder="AAPL" - className="border border-gray-300 rounded-lg px-4 py-2.5 focus:ring-2 focus:ring-blue-500 text-gray-900 placeholder-gray-500" - required - /> - setState((s) => ({ ...s, date: e.target.value }))} - className="border border-gray-300 rounded-lg px-4 py-2.5 focus:ring-2 focus:ring-blue-500 text-gray-900" - /> -
-
- - setState((s) => ({ ...s, date: e.target.value }))} - className="border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 text-gray-900" - /> -
-
- -
-
-
- - {state.error && ( -
- {state.error} +
+ 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()} + /> +
- )} - - {currentStep !== "input" && ( -
-
- {["input", "analysts", "researchers", "trader", "decision"].map((step, i) => ( -
-
- {i + 1} -
- {i < 4 &&
} -
- ))} -
- - {state.analysts.length > 0 && ( -
-

Analyst Reports

-
- {state.analysts.map((report, i) => ( -
-
- {report.analyst} - -
-

{report.report}

-
+ + {stocks.length === 0 ? ( +

No stocks added. Add a ticker to get started.

+ ) : ( +
+ + + + + + + + + + + + + {stocks.map((stock) => ( + + + + + + + + ))} - - - )} - - {state.debates.length > 0 && ( -
-

Research Debate

- {state.debates.map((debate, i) => ( -
-
- Bullish View: -

{debate.bullishView}

-
-
- Bearish View: -

{debate.bearishView}

-
-
- ))} -
- )} - - {state.decision && ( -
-

Trading Decision

-
- {state.decision.action} - Confidence: {(state.decision.confidence * 100).toFixed(0)}% -
-

{state.decision.reasoning}

- -
- Agent Signals: -
- {state.decision.agentSignals.map((s, i) => ( -
- {s.agent}: - -
- ))} -
-
-
- )} - - )} + +
TickerPricePositionRSIAnalysisActions
{stock.ticker} + {stock.currentPrice ? `$${stock.currentPrice.toFixed(2)}` : "-"} + + {stock.position} + + {stock.rsi ? ( + 70 ? "text-red-600" : + stock.rsi < 30 ? "text-green-600" : "text-gray-900" + }> + {stock.rsi.toFixed(2)} + + ) : "-"} + + {stock.analysis ? ( +
+ + {stock.analysis.action.toUpperCase()} + +
+ Confidence: {(stock.analysis.confidence * 100).toFixed(0)}% +
+
+ ) : stock.loading ? ( + Analyzing... + ) : "-"} +
+
+ + +
+
+
+ )} +
); diff --git a/app/routes/api/alpaca/positions.ts b/app/routes/api/alpaca/positions.ts new file mode 100644 index 0000000..8446d78 --- /dev/null +++ b/app/routes/api/alpaca/positions.ts @@ -0,0 +1,23 @@ +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 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 }); + } +} \ No newline at end of file diff --git a/app/routes/api/alpaca/quote.ts b/app/routes/api/alpaca/quote.ts new file mode 100644 index 0000000..9cb0939 --- /dev/null +++ b/app/routes/api/alpaca/quote.ts @@ -0,0 +1,30 @@ +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({ params }: { params: { ticker: string } }) { + const ticker = params.ticker?.toUpperCase(); + if (!ticker) { + return Response.json({ error: "Ticker is required" }, { status: 400 }); + } + + try { + // Use latest trade instead of quote for real transaction price + const trade = await alpaca.getLatestTrade(ticker); + // trade has: Price, Size, Exchange, Timestamp, etc. + const price = (trade as { Price?: number }).Price || 0; + return Response.json({ + ticker, + price, + timestamp: (trade as { Timestamp?: string }).Timestamp, + }); + } catch (error) { + console.error("Alpaca trade error:", error); + return Response.json({ error: "Failed to fetch trade" }, { status: 500 }); + } +} \ No newline at end of file diff --git a/app/routes/api/analyze.ts b/app/routes/api/analyze.ts index 740fc95..2c15b53 100644 --- a/app/routes/api/analyze.ts +++ b/app/routes/api/analyze.ts @@ -2,17 +2,54 @@ 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; - if (!apiKey) { - return Response.json({ error: "OPENROUTER_API_KEY not configured" }, { status: 500 }); + 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); @@ -34,10 +71,13 @@ export async function action({ request }: { request: Request }) { }; 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 }); } } \ No newline at end of file diff --git a/app/routes/api/stocks/index.ts b/app/routes/api/stocks/index.ts new file mode 100644 index 0000000..a7765ee --- /dev/null +++ b/app/routes/api/stocks/index.ts @@ -0,0 +1,22 @@ +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); +} \ No newline at end of file diff --git a/docs/superpowers/plans/2026-05-14-multiagent-trading-framework.md b/docs/superpowers/plans/2026-05-14-multiagent-trading-framework.md new file mode 100644 index 0000000..98e40d5 --- /dev/null +++ b/docs/superpowers/plans/2026-05-14-multiagent-trading-framework.md @@ -0,0 +1,1015 @@ +# Multi-Agent Trading Framework Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a multi-agent LLM trading framework inspired by TradingAgents with OpenRouter integration and free model support. + +**Architecture:** Create agent-based architecture where specialized LLM agents analyze stocks through different lenses (fundamentals, sentiment, technical) and debate before making trading decisions. Use OpenRouter for model access with ability to select free models. + +**Tech Stack:** OpenRouter API, TypeScript, React Router 7, existing Alpaca integration, node-fetch for HTTP requests + +--- + +## Phase 1: OpenRouter Client Setup + +### Task 1: Create OpenRouter API Client + +**Files:** +- Create: `app/lib/openrouter.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// app/lib/__tests__/openrouter.test.ts +import { describe, it, expect, vi } from 'vitest' +import { OpenRouterClient } from '../openrouter' + +describe('OpenRouterClient', () => { + it('should create instance with API key', () => { + const client = new OpenRouterClient('test-key') + expect(client).toBeDefined() + }) + + it('should have default free models list', () => { + const client = new OpenRouterClient('test-key') + expect(client.getFreeModels()).toContain('google/gemini-2.0-flash-exp:free') + }) + + it('should have available model providers', () => { + const client = new OpenRouterClient('test-key') + const providers = client.getProviders() + expect(providers).toContain('openai') + expect(providers).toContain('google') + expect(providers).toContain('anthropic') + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm run test -- app/lib/__tests__/openrouter.test.ts` +Expected: FAIL with "Cannot find module" or similar + +- [ ] **Step 3: Write minimal implementation** + +```typescript +// app/lib/openrouter.ts +import type { ChatCompletion } from '../types' + +export interface OpenRouterConfig { + apiKey: string + baseURL?: string + defaultModel?: string +} + +export class OpenRouterClient { + private apiKey: string + private baseURL: string + private defaultModel: string + private freeModels = [ + 'google/gemini-2.0-flash-exp:free', + 'deepseek/deepseek-chat:free', + 'meta/llama-3.3-70b-instruct:free' + ] + + constructor(apiKey: string, config?: Partial) { + this.apiKey = apiKey + this.baseURL = config?.baseURL || 'https://openrouter.ai/api/v1' + this.defaultModel = config?.defaultModel || 'google/gemini-2.0-flash-exp:free' + } + + getFreeModels(): string[] { + return [...this.freeModels] + } + + getProviders(): string[] { + return ['openai', 'google', 'anthropic', 'deepseek', 'meta', 'xai'] + } + + async createChatCompletion( + messages: Array<{ role: string; content: string }>, + model?: string + ): Promise { + const response = await fetch(`${this.baseURL}/chat/completions`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${this.apiKey}`, + 'Content-Type': 'application/json', + '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() + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npm run test -- app/lib/__tests__/openrouter.test.ts` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add app/lib/openrouter.ts app/lib/__tests__/openrouter.test.ts +git commit -m "feat: add OpenRouter API client with free model support" +``` + +--- + +## Phase 2: Agent Type Definitions + +### Task 2: Define Agent Types and Interfaces + +**Files:** +- Create: `app/types/agents.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// app/types/__tests__/agents.test.ts +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' + } + 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' + } + expect(signal.signal).toBe('neutral') + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm run test -- app/types/__tests__/agents.test.ts` +Expected: FAIL - types file doesn't exist + +- [ ] **Step 3: Write minimal implementation** + +```typescript +// app/types/agents.ts +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 +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npm run test -- app/types/__tests__/agents.test.ts` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add app/types/agents.ts app/types/__tests__/agents.test.ts +git commit -m "feat: add agent types and interfaces" +``` + +--- + +## Phase 3: Fundamentals Analyst Agent + +### Task 3: Implement Fundamentals Analyst Agent + +**Files:** +- Create: `app/agents/fundamentals.ts` +- Create: `app/agents/__tests__/fundamentals.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// app/agents/__tests__/fundamentals.test.ts +import { describe, it, expect, vi } from 'vitest' +import { FundamentalsAnalyst } from '../fundamentals' +import type { OpenRouterClient } from '../../lib/openrouter' + +describe('FundamentalsAnalyst', () => { + const mockClient = { + createChatCompletion: vi.fn().mockResolvedValue({ + choices: [{ message: { content: 'Bullish: Strong revenue growth' } }] + }) + } as unknown as OpenRouterClient + + it('should analyze company fundamentals', async () => { + const analyst = new FundamentalsAnalyst(mockClient) + const result = await analyst.analyze('AAPL', { + revenue: 394300000000, + netIncome: 97000000000, + debtToEquity: 1.43 + }) + + expect(result.analyst).toBe('fundamentals') + expect(result.signal.signal).toBe('bullish') + }) + + it('should use specified model', () => { + const client = { createChatCompletion: vi.fn() } as unknown as OpenRouterClient + const analyst = new FundamentalsAnalyst(client, { model: 'custom-model' }) + expect(analyst.getModel()).toBe('custom-model') + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm run test -- app/agents/__tests__/fundamentals.test.ts` +Expected: FAIL + +- [ ] **Step 3: Write minimal implementation** + +```typescript +// app/agents/fundamentals.ts +import type { OpenRouterClient } from '../lib/openrouter' +import type { AnalystReport, AgentSignal } from '../types/agents' + +interface FinancialData { + revenue?: number + netIncome?: number + debtToEquity?: number + earningsPerShare?: number + priceToEarnings?: number +} + +interface AnalystConfig { + model?: string +} + +export class FundamentalsAnalyst { + private client: OpenRouterClient + private model: string + + constructor(client: OpenRouterClient, config?: AnalystConfig) { + this.client = client + this.model = config?.model || 'google/gemini-2.0-flash-exp:free' + } + + getModel(): string { + return this.model + } + + async analyze(ticker: string, financialData: FinancialData): Promise { + const messages = [ + { + role: 'system', + content: `You are a fundamental analyst. Analyze the financial data and provide a trading signal (bullish/bearish/neutral) with confidence and concise reasoning.` + }, + { + role: 'user', + content: `Analyze ${ticker} with this data:\n${JSON.stringify(financialData, null, 2)}` + } + ] + + const response = await this.client.createChatCompletion(messages, this.model) + const content = response.choices[0].message.content + + // Parse signal from response + const isBullish = content.toLowerCase().includes('bullish') + const isBearish = content.toLowerCase().includes('bearish') + const signal: AgentSignal['signal'] = isBullish ? 'bullish' : isBearish ? 'bearish' : 'neutral' + + return { + analyst: 'fundamentals', + report: content, + signal: { + agent: 'fundamentals', + signal, + confidence: 0.75, + reasoning: content, + timestamp: new Date().toISOString() + } + } + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npm run test -- app/agents/__tests__/fundamentals.test.ts` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add app/agents/fundamentals.ts app/agents/__tests__/fundamentals.test.ts +git commit -m "feat: add fundamentals analyst agent" +``` + +--- + +## Phase 4: Technical Analyst Agent (Wraps Existing Indicators) + +### Task 4: Implement Technical Analyst Agent + +**Files:** +- Create: `app/agents/technical.ts` +- Create: `app/agents/__tests__/technical.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// app/agents/__tests__/technical.test.ts +import { describe, it, expect, vi } from 'vitest' +import { TechnicalAnalyst } from '../technical' +import type { OpenRouterClient } from '../../lib/openrouter' + +describe('TechnicalAnalyst', () => { + const mockClient = { + createChatCompletion: vi.fn().mockResolvedValue({ + choices: [{ message: { content: 'Bullish: MACD crossover positive' } }] + }) + } as unknown as OpenRouterClient + + it('should analyze technical indicators', async () => { + const analyst = new TechnicalAnalyst(mockClient) + const result = await analyst.analyze('AAPL', { + prices: [100, 102, 101, 103, 105], + sma: 102, + rsi: 65, + macd: 1.2 + }) + + expect(result.analyst).toBe('technical') + expect(result.signal.signal).toBe('bullish') + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm run test -- app/agents/__tests__/technical.test.ts` +Expected: FAIL + +- [ ] **Step 3: Write minimal implementation** + +```typescript +// app/agents/technical.ts +import type { OpenRouterClient } from '../lib/openrouter' +import type { AnalystReport, AgentSignal } from '../types/agents' + +interface TechnicalData { + prices: number[] + sma?: number + ema?: number + rsi?: number + macd?: number +} + +export class TechnicalAnalyst { + private client: OpenRouterClient + private model: string + + constructor(client: OpenRouterClient, model?: string) { + this.client = client + this.model = model || 'google/gemini-2.0-flash-exp:free' + } + + async analyze(ticker: string, data: TechnicalData): Promise { + const messages = [ + { + role: 'system', + content: `You are a technical analyst. Analyze indicators and provide signal.` + }, + { + role: 'user', + content: `Analyze ${ticker} technicals:\nSMA: ${data.sma}, RSI: ${data.rsi}, MACD: ${data.macd}` + } + ] + + const response = await this.client.createChatCompletion(messages, this.model) + const content = response.choices[0].message.content + + const isBullish = content.toLowerCase().includes('bullish') + const isBearish = content.toLowerCase().includes('bearish') + const signal: AgentSignal['signal'] = isBullish ? 'bullish' : isBearish ? 'bearish' : 'neutral' + + return { + analyst: 'technical', + report: content, + signal: { + agent: 'technical', + signal, + confidence: 0.7, + reasoning: content, + timestamp: new Date().toISOString() + } + } + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npm run test -- app/agents/__tests__/technical.test.ts` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add app/agents/technical.ts app/agents/__tests__/technical.test.ts +git commit -m "feat: add technical analyst agent" +``` + +--- + +## Phase 5: Sentiment Analyst Agent + +### Task 5: Implement Sentiment Analyst Agent + +**Files:** +- Create: `app/agents/sentiment.ts` +- Create: `app/agents/__tests__/sentiment.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// app/agents/__tests__/sentiment.test.ts +import { describe, it, expect, vi } from 'vitest' +import { SentimentAnalyst } from '../sentiment' +import type { OpenRouterClient } from '../../lib/openrouter' + +describe('SentimentAnalyst', () => { + const mockClient = { + createChatCompletion: vi.fn().mockResolvedValue({ + choices: [{ message: { content: 'Bullish: Positive sentiment from news' } }] + }) + } as unknown as OpenRouterClient + + it('should analyze sentiment from headlines', async () => { + 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') + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm run test -- app/agents/__tests__/sentiment.test.ts` +Expected: FAIL + +- [ ] **Step 3: Write minimal implementation** + +```typescript +// app/agents/sentiment.ts +import type { OpenRouterClient } from '../lib/openrouter' +import type { AnalystReport, AgentSignal } from '../types/agents' + +interface SentimentData { + headlines: string[] + source?: 'news' | 'social' | 'stocktwits' +} + +export class SentimentAnalyst { + private client: OpenRouterClient + private model: string + + constructor(client: OpenRouterClient, model?: string) { + this.client = client + this.model = model || 'google/gemini-2.0-flash-exp:free' + } + + async analyze(ticker: string, data: SentimentData): Promise { + const messages = [ + { + role: 'system', + content: `Analyze sentiment from headlines. Provide signal.` + }, + { + role: 'user', + content: `Analyze ${ticker} sentiment:\n${data.headlines.join('\n')}` + } + ] + + const response = await this.client.createChatCompletion(messages, this.model) + const content = response.choices[0].message.content + + const isBullish = content.toLowerCase().includes('bullish') + const isBearish = content.toLowerCase().includes('bearish') + const signal: AgentSignal['signal'] = isBullish ? 'bullish' : isBearish ? 'bearish' : 'neutral' + + return { + analyst: 'sentiment', + report: content, + signal: { + agent: 'sentiment', + signal, + confidence: 0.65, + reasoning: content, + timestamp: new Date().toISOString() + } + } + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npm run test -- app/agents/__tests__/sentiment.test.ts` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add app/agents/sentiment.ts app/agents/__tests__/sentiment.test.ts +git commit -m "feat: add sentiment analyst agent" +``` + +--- + +## Phase 6: Researcher Agents (Bullish/Bearish) + +### Task 6: Implement Researcher Agents + +**Files:** +- Create: `app/agents/researchers.ts` +- Create: `app/agents/__tests__/researchers.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// app/agents/__tests__/researchers.test.ts +import { describe, it, expect, vi } from 'vitest' +import { BullishResearcher, BearishResearcher } from '../researchers' +import type { AnalystReport } from '../../types/agents' +import type { OpenRouterClient } from '../../lib/openrouter' + +describe('Researchers', () => { + const mockClient = { + createChatCompletion: vi.fn().mockResolvedValue({ + choices: [{ message: { content: 'Bullish case: Strong fundamentals' } }] + }) + } as unknown as OpenRouterClient + + const sampleReports: AnalystReport[] = [ + { + analyst: 'fundamentals', + report: 'Strong revenue growth', + signal: { agent: 'fundamentals', signal: 'bullish', confidence: 0.8, reasoning: '', timestamp: '' } + } + ] + + it('should create bullish researcher', async () => { + const researcher = new BullishResearcher(mockClient) + const result = await researcher.research('AAPL', sampleReports) + expect(result.researcher).toBe('bullish') + }) + + it('should create bearish researcher', async () => { + const researcher = new BearishResearcher(mockClient) + const result = await researcher.research('AAPL', sampleReports) + expect(result.researcher).toBe('bearish') + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm run test -- app/agents/__tests__/researchers.test.ts` +Expected: FAIL + +- [ ] **Step 3: Write minimal implementation** + +```typescript +// app/agents/researchers.ts +import type { OpenRouterClient } from '../lib/openrouter' +import type { AnalystReport, DebateRound } from '../types/agents' + +export class BullishResearcher { + private client: OpenRouterClient + private model: string + + constructor(client: OpenRouterClient, model?: string) { + this.client = client + this.model = model || 'google/gemini-2.0-flash-exp:free' + } + + async research(ticker: string, reports: AnalystReport[]): Promise<{ + researcher: 'bullish' + view: string + debate: DebateRound + }> { + const messages = [ + { role: 'system', content: 'Synthesize bullish case from analyst reports.' }, + { role: 'user', content: `Create bullish thesis for ${ticker} using:\n${reports.map(r => r.report).join('\n')}` } + ] + + const response = await this.client.createChatCompletion(messages, this.model) + const view = response.choices[0].message.content + + return { + researcher: 'bullish', + view, + debate: { + bullishView: view, + bearishView: '', + researcher: 'bullish' + } + } + } +} + +export class BearishResearcher { + private client: OpenRouterClient + private model: string + + constructor(client: OpenRouterClient, model?: string) { + this.client = client + this.model = model || 'google/gemini-2.0-flash-exp:free' + } + + async research(ticker: string, reports: AnalystReport[]): Promise<{ + researcher: 'bearish' + view: string + debate: DebateRound + }> { + const messages = [ + { role: 'system', content: 'Synthesize bearish case from analyst reports.' }, + { role: 'user', content: `Create bearish thesis for ${ticker} using:\n${reports.map(r => r.report).join('\n')}` } + ] + + const response = await this.client.createChatCompletion(messages, this.model) + const view = response.choices[0].message.content + + return { + researcher: 'bearish', + view, + debate: { + bullishView: '', + bearishView: view, + researcher: 'bearish' + } + } + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npm run test -- app/agents/__tests__/researchers.test.ts` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add app/agents/researchers.ts app/agents/__tests__/researchers.test.ts +git commit -m "feat: add bullish and bearish researcher agents" +``` + +--- + +## Phase 7: Trader Agent + +### Task 7: Implement Trader Agent + +**Files:** +- Create: `app/agents/trader.ts` +- Create: `app/agents/__tests__/trader.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// app/agents/__tests__/trader.test.ts +import { describe, it, expect, vi } from 'vitest' +import { Trader } from '../trader' +import type { OpenRouterClient } from '../../lib/openrouter' +import type { AnalystReport, DebateRound } from '../../types/agents' + +describe('Trader', () => { + const mockClient = { + createChatCompletion: vi.fn().mockResolvedValue({ + choices: [{ message: { content: 'Decision: BUY with high confidence' } }] + }) + } as unknown as OpenRouterClient + + it('should make trading decision', async () => { + const trader = new Trader(mockClient) + const reports: AnalystReport[] = [{ + analyst: 'fundamentals', + report: 'Strong fundamentals', + signal: { agent: 'fundamentals', signal: 'bullish', confidence: 0.8, reasoning: '', timestamp: '' } + }] + const debates: DebateRound[] = [{ + bullishView: 'Strong long term', + bearishView: 'Short term risks', + researcher: 'bullish' + }] + + const decision = await trader.decide('AAPL', reports, debates) + expect(decision.action).toBe('buy') + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm run test -- app/agents/__tests__/trader.test.ts` +Expected: FAIL + +- [ ] **Step 3: Write minimal implementation** + +```typescript +// app/agents/trader.ts +import type { OpenRouterClient } from '../lib/openrouter' +import type { AnalystReport, DebateRound, TradingDecision, AgentSignal } from '../types/agents' + +export class Trader { + private client: OpenRouterClient + private model: string + + constructor(client: OpenRouterClient, model?: string) { + this.client = client + this.model = model || 'google/gemini-2.0-flash-exp:free' + } + + async decide( + ticker: string, + reports: AnalystReport[], + debates: DebateRound[] + ): Promise { + const messages = [ + { role: 'system', content: 'Make trading decision based on all signals.' }, + { role: 'user', content: `Decide for ${ticker}:\nReports: ${JSON.stringify(reports)}\nDebates: ${JSON.stringify(debates)}` } + ] + + const response = await this.client.createChatCompletion(messages, this.model) + const content = response.choices[0].message.content + + const lower = content.toLowerCase() + const action = lower.includes('buy') ? 'buy' : lower.includes('sell') ? 'sell' : 'hold' + + const agentSignals: AgentSignal[] = reports.map(r => r.signal) + + return { + action, + confidence: 0.7, + reasoning: content, + agentSignals, + debateRounds: debates + } + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npm run test -- app/agents/__tests__/trader.test.ts` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add app/agents/trader.ts app/agents/__tests__/trader.test.ts +git commit -m "feat: add trader agent" +``` + +--- + +## Phase 8: Trading Graph Orchestrator + +### Task 8: Create Trading Graph Orchestrator + +**Files:** +- Create: `app/agents/tradingGraph.ts` +- Create: `app/agents/__tests__/tradingGraph.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// app/agents/__tests__/tradingGraph.test.ts +import { describe, it, expect, vi } from 'vitest' +import { TradingGraph } from '../tradingGraph' +import type { OpenRouterClient } from '../../lib/openrouter' + +describe('TradingGraph', () => { + const mockClient = { + createChatCompletion: vi.fn().mockResolvedValue({ + choices: [{ message: { content: 'Bullish signal' } }] + }) + } as unknown as OpenRouterClient + + it('should run full analysis', async () => { + const graph = new TradingGraph(mockClient) + const decision = await graph.propagate('AAPL', { + ticker: 'AAPL', + date: '2026-01-15' + }) + + expect(decision).toHaveProperty('action') + expect(decision).toHaveProperty('confidence') + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm run test -- app/agents/__tests__/tradingGraph.test.ts` +Expected: FAIL + +- [ ] **Step 3: Write minimal implementation** + +```typescript +// app/agents/tradingGraph.ts +import { FundamentalsAnalyst } from './fundamentals' +import { TechnicalAnalyst } from './technical' +import { SentimentAnalyst } from './sentiment' +import { BullishResearcher, BearishResearcher } from './researchers' +import { Trader } from './trader' +import type { OpenRouterClient } from '../lib/openrouter' +import type { TradingDecision } from '../types/agents' + +interface AnalysisInput { + ticker: string + date: string +} + +export class TradingGraph { + private client: OpenRouterClient + private fundamentals: FundamentalsAnalyst + private technical: TechnicalAnalyst + private sentiment: SentimentAnalyst + private bullishResearcher: BullishResearcher + private bearishResearcher: BearishResearcher + private trader: Trader + + constructor(client: OpenRouterClient) { + this.client = client + const model = 'google/gemini-2.0-flash-exp:free' + this.fundamentals = new FundamentalsAnalyst(client, { model }) + this.technical = new TechnicalAnalyst(client, model) + this.sentiment = new SentimentAnalyst(client, model) + this.bullishResearcher = new BullishResearcher(client, model) + this.bearishResearcher = new BearishResearcher(client, model) + this.trader = new Trader(client, model) + } + + async propagate(ticker: string, input: AnalysisInput): Promise { + // Analyst phase + const reports = await this.runAnalysts(ticker) + + // Researcher phase + const debates = await this.runDebate(ticker, reports) + + // Trader phase + const decision = await this.trader.decide(ticker, reports, debates) + + return decision + } + + private async runAnalysts(ticker: string) { + // Placeholder - would integrate with real data sources + return [] + } + + private async runDebate(ticker: string, reports: any[]) { + const bullish = await this.bullishResearcher.research(ticker, reports) + return [bullish.debate] + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npm run test -- app/agents/__tests__/tradingGraph.test.ts` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add app/agents/tradingGraph.ts app/agents/__tests__/tradingGraph.test.ts +git commit -m "feat: add trading graph orchestrator" +``` + +--- + +## Phase 9: API Routes for Analysis + +### Task 9: Create API Routes + +**Files:** +- Create: `app/routes/api/analyze.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// tests/api/analyze.test.ts +import { describe, it, expect } from 'vitest' + +describe('Analyze API', () => { + it('should be defined', () => { + expect(true).toBe(true) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm run test -- tests/api/analyze.test.ts` +Expected: PASS (placeholder) + +- [ ] **Step 3: Write minimal implementation** + +```typescript +// app/routes/api/analyze.ts +import { TradingGraph } from '../../agents/tradingGraph' +import { OpenRouterClient } from '../../lib/openrouter' + +export async function action({ request }: { request: Request }) { + const formData = await request.formData() + const ticker = formData.get('ticker') as string + const date = formData.get('date') as string + + const apiKey = process.env.OPENROUTER_API_KEY! + const client = new OpenRouterClient(apiKey) + const graph = new TradingGraph(client) + + const decision = await graph.propagate(ticker, { ticker, date }) + return Response.json(decision) +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npm run test -- tests/api/analyze.test.ts` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add app/routes/api/analyze.ts tests/api/analyze.test.ts +git commit -m "feat: add analysis API route" +``` + +--- + +Plan complete. Two execution options: + +**1. Subagent-Driven (recommended)** - I dispatch a fresh subagent per task, review between tasks +**2. Inline Execution** - Execute tasks in this session with checkpoints + +Which approach? \ No newline at end of file diff --git a/docs/superpowers/plans/2026-05-14-stock-database.md b/docs/superpowers/plans/2026-05-14-stock-database.md new file mode 100644 index 0000000..6312050 --- /dev/null +++ b/docs/superpowers/plans/2026-05-14-stock-database.md @@ -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" +``` \ No newline at end of file diff --git a/docs/superpowers/specs/2026-05-14-prisma-stock-model.md b/docs/superpowers/specs/2026-05-14-prisma-stock-model.md new file mode 100644 index 0000000..15c581e --- /dev/null +++ b/docs/superpowers/specs/2026-05-14-prisma-stock-model.md @@ -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) \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 0727b72..db565d1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ }, "devDependencies": { "@playwright/test": "^1.60.0", + "@prisma/client": "^5.22.0", "@react-router/dev": "7.15.0", "@tailwindcss/vite": "^4.2.2", "@testing-library/jest-dom": "^6.9.1", @@ -26,7 +27,9 @@ "@types/node": "^22", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "jsdom": "^29.1.1", "playwright": "^1.42.0", + "prisma": "^5.22.0", "tailwindcss": "^4.2.2", "tsx": "^4.21.0", "typescript": "^5.9.3", @@ -64,6 +67,57 @@ "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": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -537,6 +591,159 @@ "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": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -1094,6 +1301,24 @@ "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": { "version": "1.19.14", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", @@ -1585,6 +1810,75 @@ "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": { "version": "7.15.0", "resolved": "https://registry.npmjs.org/@react-router/dev/-/dev-7.15.0.tgz", @@ -3219,6 +3513,16 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "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": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", @@ -3628,6 +3932,20 @@ "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": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", @@ -3642,6 +3960,20 @@ "dev": true, "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": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -3659,6 +3991,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": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", @@ -3797,6 +4136,19 @@ "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": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -4630,6 +4982,19 @@ "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": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -4791,6 +5156,13 @@ "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": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -4850,6 +5222,57 @@ "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": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", @@ -5267,6 +5690,13 @@ "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": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -5633,6 +6063,19 @@ "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": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -5846,6 +6289,26 @@ "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": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -6250,6 +6713,19 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "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": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -6545,6 +7021,13 @@ "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": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", @@ -6616,6 +7099,26 @@ "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": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -6625,6 +7128,32 @@ "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": { "version": "1.0.16", "resolved": "https://registry.npmjs.org/ts-nkeys/-/ts-nkeys-1.0.16.tgz", @@ -6719,6 +7248,16 @@ "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": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -7110,6 +7649,54 @@ "dev": true, "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": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -7178,6 +7765,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": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index 3ad20c9..7d70776 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ }, "devDependencies": { "@playwright/test": "^1.60.0", + "@prisma/client": "^5.22.0", "@react-router/dev": "7.15.0", "@tailwindcss/vite": "^4.2.2", "@testing-library/jest-dom": "^6.9.1", @@ -32,7 +33,9 @@ "@types/node": "^22", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "jsdom": "^29.1.1", "playwright": "^1.42.0", + "prisma": "^5.22.0", "tailwindcss": "^4.2.2", "tsx": "^4.21.0", "typescript": "^5.9.3", diff --git a/playwright-report/index.html b/playwright-report/index.html index 05c958b..5186d5b 100644 --- a/playwright-report/index.html +++ b/playwright-report/index.html @@ -87,4 +87,4 @@ Error generating stack: `+l.message+`
- \ No newline at end of file + \ No newline at end of file diff --git a/prisma.config.ts b/prisma.config.ts deleted file mode 100644 index 52a7204..0000000 --- a/prisma.config.ts +++ /dev/null @@ -1,14 +0,0 @@ -// This file was generated by Prisma, and assumes you have installed the following: -// npm install --save-dev prisma dotenv -import "dotenv/config"; -import { defineConfig } from "prisma/config"; - -export default defineConfig({ - schema: "prisma/schema.prisma", - migrations: { - path: "prisma/migrations", - }, - datasource: { - url: "file:./prisma/dev.db", - }, -}); diff --git a/prisma/dev.db b/prisma/dev.db index cce0442..a4c8a0e 100644 Binary files a/prisma/dev.db and b/prisma/dev.db differ diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a50c553..5a0496f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -5,6 +5,7 @@ generator client { datasource db { provider = "sqlite" + url = "file:./dev.db" } model Stock { diff --git a/tests/analyze.spec.ts b/tests/analyze.spec.ts new file mode 100644 index 0000000..06a61e4 --- /dev/null +++ b/tests/analyze.spec.ts @@ -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+$/); + } + }); +}); \ No newline at end of file diff --git a/tests/api/analyze.test.ts b/tests/api/analyze.test.ts deleted file mode 100644 index c3d47c8..0000000 --- a/tests/api/analyze.test.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { test, expect } from "vitest"; - -test("placeholder test", () => { - expect(true).toBe(true); -}); \ No newline at end of file diff --git a/tests/stock-db.spec.ts b/tests/stock-db.spec.ts new file mode 100644 index 0000000..f011200 --- /dev/null +++ b/tests/stock-db.spec.ts @@ -0,0 +1,31 @@ +import { test, expect } from "@playwright/test"; + +test.describe("Stock Database", () => { + test("should add and list stocks", async ({ request }) => { + const uniqueTicker = `TEST${Date.now()}`; + + const createRes = await request.post("/api/stocks", { + form: { ticker: uniqueTicker }, + }); + expect(createRes.ok()).toBeTruthy(); + + const listRes = await request.get("/api/stocks"); + const stocks = await listRes.json(); + expect(stocks).toContainEqual(expect.objectContaining({ ticker: uniqueTicker })); + }); + + test("should persist tickers after page reload", async ({ request, page }) => { + const uniqueTicker = `PERSIST${Date.now()}`; + + await request.post("/api/stocks", { + form: { ticker: uniqueTicker }, + }); + + await page.goto("/stocks"); + await page.waitForLoadState("networkidle"); + + const listRes = await request.get("/api/stocks"); + const stocks = await listRes.json(); + expect(stocks).toContainEqual(expect.objectContaining({ ticker: uniqueTicker })); + }); +}); \ No newline at end of file