Compare commits
63 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eb999444d7 | |||
| 0ee89cf052 | |||
| 9b63d981b0 | |||
| dba81832c1 | |||
| 9b8afa2605 | |||
| 078dc25b87 | |||
| 364b1cd7e0 | |||
| d25a7e9ff5 | |||
| 74ebf0b6e3 | |||
| e88deac193 | |||
| 91659e997a | |||
| b2e0568bfd | |||
| c4873daf3b | |||
| 5358ee6f97 | |||
| 93056b7ecd | |||
| 0e8339d614 | |||
| 329b83a17c | |||
| 3ed894015a | |||
| fc17b8cb51 | |||
| c900fd8b77 | |||
| 2643c472dd | |||
| 6c92a6d95a | |||
| e7cbb56328 | |||
| eac93a6b82 | |||
| e4fb4bca41 | |||
| c8e4c181d0 | |||
| f7df607a06 | |||
| 1ae60635d3 | |||
| 424a2fc6d5 | |||
| 2585734f6a | |||
| 669b792045 | |||
| 9771f48028 | |||
| 9167bd8912 | |||
| 5f5a48067c | |||
| 1b31a4a131 | |||
| ceb664f56c | |||
| 31503624f6 | |||
| 528045c25e | |||
| f2b7fad379 | |||
| a835986842 | |||
| 3234a09096 | |||
| d9f9150d68 | |||
| eee375ff56 | |||
| a9e73e8e0b | |||
| 538b4b62d2 | |||
| 422b6d2f4b | |||
| 24c7ee2bf1 | |||
| 3a681fa309 | |||
| c9f83b834e | |||
| f3effebff6 | |||
| ac175c8d42 | |||
| ea2836bd2e | |||
| 6ef87ba79f | |||
| 5bb41a50dc | |||
| b9711f2517 | |||
| 98c1e366a5 | |||
| 17c9ee27c0 | |||
| b6510de7cb | |||
| 56ad0593ad | |||
| 6ff945160d | |||
| 76d8f7ed6e | |||
| 19b098393a | |||
| 5f36c13b9f |
@@ -1,116 +1,123 @@
|
||||
# Copilot Instructions for AITrader
|
||||
|
||||
## Quick Start
|
||||
This repo is a full‑stack React Router (v7) app with SSR, TypeScript, TailwindCSS, Playwright E2E tests, and optional MCP helpers. The existing AGENTS.md and workflows include helpful automation — this file consolidates the most important guidance Copilot sessions need.
|
||||
|
||||
This is a stock trading application built with React Router 7, TypeScript, and TailwindCSS, integrating with the Alpaca trading API.
|
||||
## Build, test, and (lack of) lint commands
|
||||
- Install deps: `npm install`
|
||||
- Dev server (HMR): `npm run dev` (http://localhost:5173)
|
||||
- Build: `npm run build` → output in `./build` (client + server)
|
||||
- Serve production build: `npm start` (requires prior `npm run build`)
|
||||
- Typecheck (must run before commit): `npm run typecheck` (runs `react-router typegen` then `tsc`)
|
||||
|
||||
### Essential Commands
|
||||
- `npm install` – Install dependencies (first time only)
|
||||
- `npm run dev` – Start development server at `http://localhost:5173`
|
||||
- `npm run build` – Create production build in `./build`
|
||||
- `npm start` – Serve production build (requires `npm run build` first)
|
||||
- `npm run typecheck` – Validate TypeScript (`react-router typegen` + `tsc`) — **must run before committing**
|
||||
- `npm run test:e2e` – Run Playwright end-to-end tests
|
||||
Tests
|
||||
- Run unit tests (Vitest): `npm run test` (runs `vitest run`)
|
||||
- Watch mode: `npm run test:watch`
|
||||
- Run a single Vitest file: `npx vitest run path/to/file.test.ts` or run tests by name: `npx vitest -t "test name"`
|
||||
|
||||
## Architecture Overview
|
||||
E2E (Playwright)
|
||||
- Full suite: `npm run test:e2e` (alias: `playwright test`)
|
||||
- Run one spec: `npx playwright test tests/my.spec.ts`
|
||||
- Run by title: `npx playwright test -g "test name"`
|
||||
- HTML report: generated into `test-results/` (config in `playwright.config.ts`)
|
||||
|
||||
### Project Structure
|
||||
```
|
||||
app/
|
||||
├── root.tsx # Root layout and error boundary
|
||||
├── routes.ts # Route configuration (React Router 7 RouteConfig API)
|
||||
├── routes/
|
||||
│ ├── landing.tsx # Landing page
|
||||
│ ├── home.tsx # Main application page
|
||||
│ ├── stocks.tsx # Stock dashboard
|
||||
│ └── api/ # Server-side API routes
|
||||
│ ├── indicators.ts # Stock indicator calculations
|
||||
│ └── alpaca/ # Alpaca broker integration
|
||||
│ └── account.ts # Account data endpoints
|
||||
├── components/ # Reusable React components
|
||||
│ ├── StockViewer.tsx # Stock symbol search and indicator display
|
||||
│ └── AlpacaAccountInfo.tsx # Account balance and portfolio info
|
||||
├── utils/
|
||||
│ ├── indicators.ts # Technical indicator logic (SMA, EMA, RSI, MACD)
|
||||
│ └── __tests__/ # Unit tests via Vitest
|
||||
├── types.ts # TypeScript interfaces (IndicatorData, AlpacaAccount)
|
||||
└── app.css # Global styles
|
||||
```
|
||||
Linting
|
||||
- There is no lint script in package.json. Add ESLint/Prettier if desired; current CI/workflows don't run a linter by default.
|
||||
|
||||
### Full-Stack Data Flow
|
||||
1. **Client (React Components)** – User interacts with `StockViewer` or `AlpacaAccountInfo`
|
||||
2. **Server Routes (`/routes/api/`)** – Handle business logic (fetch data from external APIs, run calculations)
|
||||
3. **Utils** – Pure functions for indicators and shared logic (testable with Vitest)
|
||||
4. **External APIs** – Alpaca API for account/trading data
|
||||
MCP server helpers
|
||||
- Dev MCP server: `npm run mcp:dev` (runs `npx tsx mcp-server/index.ts`)
|
||||
- Build MCP server: `npm run mcp:build` (compiles `mcp-server` TypeScript)
|
||||
|
||||
### Server-Side Rendering (SSR)
|
||||
- Enabled by default (`ssr: true` in `react-router.config.ts`)
|
||||
- Routes can export loaders for initial data fetching
|
||||
- Use `loader` functions in route definitions for data pre-loading
|
||||
## High-level architecture (big picture)
|
||||
- Client: React components under `app/` (routes, root.tsx, components). Routes are file-based and can export loader functions for SSR.
|
||||
- Server: React Router build produces `build/server` that serves rendered routes; server-side API routes live under `app/routes/api/` and run server-only code.
|
||||
- Utils: Pure functions and indicator logic in `app/utils/` (testable with Vitest).
|
||||
- External integration: Alpaca trading API usage is colocated under `app/routes/api/alpaca/` and consumed by client components via `/api/*` endpoints.
|
||||
- Tests: Playwright E2E tests in `tests/` use the dev server (configured in `playwright.config.ts`). Vitest unit tests configured in `vitest.config.ts` (jsdom environment, setup file `vitest.setup.ts`).
|
||||
|
||||
## Key Conventions
|
||||
Build outputs & runtime ports
|
||||
- Dev server: 5173 (vite/react-router dev)
|
||||
- Production server (Docker): typically exposed on 3000
|
||||
- Production build: `./build/client` (static assets) and `./build/server` (node server)
|
||||
|
||||
### TypeScript & Type Safety
|
||||
- **Path alias** – Use `~/` for app imports (e.g., `import { IndicatorData } from "~/types"`)
|
||||
- **Generated types** – React Router generates types in `.react-router/types/` after running `typecheck`
|
||||
- **Route types** – Import `type { Route }` from `./+types/[routename]` for loader/action types
|
||||
- **Never skip `react-router typegen`** – Directly running `tsc` will fail; always run `npm run typecheck`
|
||||
- **ES Module syntax** – Project uses `"type": "module"`; include file extensions in imports where needed
|
||||
## Key conventions (repo-specific)
|
||||
- React Router 7 file-based routes: use `index()` only once at the same nesting level; prefer `route()` for additional segments.
|
||||
- Generated route types: always run `npm run typecheck` to produce `.react-router/types/` before `tsc` or commits.
|
||||
- Path alias: `~/` maps to the app root for imports (e.g., `import { Foo } from "~/components/Foo"`).
|
||||
- ES Modules: package.json uses `"type": "module"` — include file extensions when Node requires them.
|
||||
- Server-only code: place server-only logic under `app/routes/api/**` (these run on the server during SSR/build).
|
||||
- Styling: Tailwind via Vite plugin; no separate processing step required.
|
||||
|
||||
### Component Patterns
|
||||
- **Client-side interactivity** – Use React hooks (`useState`, `useEffect`) in components
|
||||
- **API calls** – Fetch from `/api/*` endpoints; proxy configured in `vite.config.ts` routes to local dev server
|
||||
- **Error handling** – Wrap API calls in try/catch; set error state for UI display
|
||||
- **Loading states** – Track `loading` boolean to show spinners/disable buttons during async work
|
||||
## CI / GitHub Actions
|
||||
- A Copilot setup workflow exists at `.github/workflows/copilot-setup-steps.yml` — it checks out code, sets up Node 20, runs `npm ci`, and installs Playwright browsers.
|
||||
|
||||
### API Route Patterns
|
||||
- Handlers in `app/routes/api/**/*.ts` are server-only functions
|
||||
- Export a default `export default function(...)` that receives request context
|
||||
- Return JSON responses or error responses
|
||||
- Use utilities in `~/utils/` for shared logic (e.g., indicator calculations)
|
||||
## Files important to Copilot sessions
|
||||
- `AGENTS.md` — detailed quick‑start for agents (already includes many conventions). Keep synced with this file.
|
||||
- `.github/workflows/copilot-setup-steps.yml` — used for CI initialization and Playwright browser installation.
|
||||
- `playwright.config.ts` — webServer config (runs `npm run dev` on port 5173) and HTML reporter settings.
|
||||
- `vitest.config.ts` & `vitest.setup.ts` — unit test env and globals.
|
||||
|
||||
### Testing
|
||||
- **Unit tests** – Use Vitest (`npm run test:e2e` actually runs Playwright, but unit tests exist via `vitest`)
|
||||
- Located alongside source files in `__tests__/` directories
|
||||
- Test format: `*.test.ts` or `*.test.tsx`
|
||||
- **E2E tests** – Playwright configured in `playwright.config.ts`
|
||||
- Tests in `./tests/` directory
|
||||
- Dev server starts automatically during test runs
|
||||
- HTML report generated in `test-results/`
|
||||
## Quick troubleshooting notes
|
||||
- If `npm start` fails: confirm `npm run build` completed and `./build/server/index.js` exists.
|
||||
- If TypeScript errors appear after route changes: run `npm run typecheck` to regenerate route types before `tsc`.
|
||||
- Playwright tests expect the dev server; allow up to 120s for the web server to start (configurable in `playwright.config.ts`).
|
||||
|
||||
### Styling
|
||||
- **TailwindCSS** – Configured via Vite plugin (`@tailwindcss/vite`); no separate build step needed
|
||||
- **Global styles** – Edit `app/app.css`
|
||||
- **Component styles** – Use Tailwind utility classes directly in JSX
|
||||
---
|
||||
|
||||
### Import Paths
|
||||
- **Absolute imports** – Use `~/` alias for app folder (e.g., `~/components/StockViewer`)
|
||||
- **Relative imports** – Use `./` or `../` sparingly within same directory tree
|
||||
## Playwright MCP (Model Context Protocol) configuration for Copilot
|
||||
|
||||
## Common Pitfalls
|
||||
This repository already contains a Playwright-based MCP server at `mcp-server/index.ts`. To enable Copilot sessions to drive the web UI, follow these steps locally or in a Copilot runtime:
|
||||
|
||||
- **`npm start` fails if build doesn't exist** – Always run `npm run build` first
|
||||
- **TypeScript compilation errors after route changes** – Missing `npm run typecheck` step; regenerated types in `.react-router/types/` are required
|
||||
- **Vite proxy not working in dev** – Ensure dev server is running and API endpoints match `vite.config.ts` proxy config (default: `/api` → `http://127.0.0.1:3000`)
|
||||
- **No test framework exists for unit tests** – Repository includes Vitest/Playwright dependencies but no test runner script; configure as needed
|
||||
- **Port conflicts** – Dev server uses `5173`, Docker/production uses `3000`
|
||||
1. Install Playwright browsers (required once):
|
||||
- `npx playwright install chromium --with-deps`
|
||||
2. Start the MCP server (the server exposes tools Copilot can call):
|
||||
- `npm run mcp:dev` (runs `npx tsx mcp-server/index.ts`)
|
||||
- The server logs "Playwright MCP Server started" to stderr when ready.
|
||||
|
||||
## Deployment
|
||||
Available MCP tools (as implemented in `mcp-server/index.ts`):
|
||||
- `navigate` — { url } → navigates and returns page title
|
||||
- `getPageContent` — () → returns page body text
|
||||
- `click` — { selector } → clicks element matching CSS selector
|
||||
- `fillForm` — { selector, value } → fills an input
|
||||
- `screenshot` — { path } → saves a screenshot at path
|
||||
- `closeBrowser` — () → closes browser instance
|
||||
|
||||
Quick usage notes for Copilot sessions
|
||||
- Ensure `npm run mcp:dev` is running in the environment where Copilot can reach stdin/stdout.
|
||||
- Ensure Playwright browsers are installed and the runtime has necessary dependencies for Chromium.
|
||||
- Tools accept JSON arguments and return structured content; errors are returned with `isError: true`.
|
||||
|
||||
Example tool call payload (navigate):
|
||||
{
|
||||
"name": "navigate",
|
||||
"arguments": { "url": "http://localhost:5173" }
|
||||
}
|
||||
|
||||
Additions and CI
|
||||
- The existing workflow `.github/workflows/copilot-setup-steps.yml` already installs Playwright browsers in CI. If Copilot sessions run in CI runners, the MCP server can be started there too.
|
||||
- If desired, a short workflow can be added to launch the MCP server for integration tests; request if you want that added.
|
||||
|
||||
---
|
||||
|
||||
Configuration completed: MCP instructions added and linked to `mcp-server/index.ts`. Want a small GitHub Action to start the MCP server for CI runs (e.g., integration test job), or should a README be added inside `mcp-server/` with the same steps?
|
||||
|
||||
## Indexing for GitHub Copilot / Copilot Chat
|
||||
|
||||
To make Copilot (and Copilot Chat) index this repository so the assistant can answer repository-specific questions, follow these steps:
|
||||
|
||||
- In VS Code: install the **GitHub Copilot** and **GitHub Copilot Chat** extensions and sign into GitHub using the extensions' sign-in flow.
|
||||
- From a terminal you can install the extensions with:
|
||||
|
||||
### Docker
|
||||
```bash
|
||||
docker build -t aitrader .
|
||||
docker run -p 3000:3000 aitrader
|
||||
code --install-extension GitHub.copilot
|
||||
code --install-extension GitHub.copilot-chat
|
||||
```
|
||||
Ensure `npm run build` is run in the Dockerfile before the final `CMD`.
|
||||
|
||||
### Environment Variables
|
||||
- Check Dockerfile for any required environment setup
|
||||
- Alpaca API credentials likely needed for trading features (not present in repo; set at runtime)
|
||||
- Open the Copilot Chat view (or the Command Palette) and run the workspace indexing command: `Copilot: Index workspace` (or `Copilot Chat: Index workspace`). This will scan project files and build the local index used by Copilot Chat.
|
||||
|
||||
## Debugging Tips
|
||||
- Exclude sensitive or large files from indexing: ensure secret files (API keys, `.env`) and large generated folders like `node_modules/`, `build/`, and `public/` are listed in `.gitignore` (or removed from the workspace) so they are not indexed. Do not commit credentials to the repo.
|
||||
|
||||
- **Type errors** – Run `npm run typecheck` to regenerate React Router types and validate all TS
|
||||
- **Module resolution** – Check `tsconfig.json` for path aliases and ensure imports match configured paths
|
||||
- **Component not rendering** – Check route configuration in `routes.ts` and ensure component is exported as default
|
||||
- **API calls failing** – Verify Vite proxy config and that the target server is running
|
||||
- If your organization uses GitHub Copilot Enterprise / Copilot for Business and you want repo-level indexing on GitHub (server-side index), ask an org admin to enable repository indexing/code search for Copilot in the GitHub org settings.
|
||||
|
||||
- After indexing completes, verify by asking Copilot Chat repository-specific questions (for example: "Where is the landing page route?" or "Show the `AlpacaAccountInfo` component"). The Copilot Chat UI also shows indexing status and recent index actions.
|
||||
|
||||
If you want, I can add a short `docs/README-indexing.md` with these steps or tighten the `copilot-instructions.md` wording further.
|
||||
|
||||
@@ -8,3 +8,5 @@
|
||||
|
||||
/generated/prisma
|
||||
/prisma/dev.db
|
||||
/graphify-out
|
||||
/playwrite-out
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { Trader } from "../trader";
|
||||
import type { AnalystReport, DebateRound } from "../../types/agents";
|
||||
|
||||
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",
|
||||
},
|
||||
];
|
||||
|
||||
describe("Trader executionPlan parsing", () => {
|
||||
it("includes executionPlan for buy decisions", async () => {
|
||||
const mockBuyClient = {
|
||||
createChatCompletion: vi.fn().mockResolvedValue({
|
||||
choices: [{ message: { content: JSON.stringify({
|
||||
action: "buy",
|
||||
confidence: 0.8,
|
||||
reasoning: "Enter position",
|
||||
executionPlan: { amount: 10, stopLoss: 95, riskManagement: { maxLossPercent: 1 }, takeProfit: 110 }
|
||||
}) } }]
|
||||
}),
|
||||
};
|
||||
|
||||
const trader = new Trader(mockBuyClient as any);
|
||||
const decision = await trader.decide("AAPL", mockReports, mockDebates);
|
||||
|
||||
expect(decision.action).toBe("buy");
|
||||
expect(decision.executionPlan).toBeDefined();
|
||||
expect(decision.executionPlan?.amount).toBe(10);
|
||||
expect(decision.executionPlan?.stopLoss).toBe(95);
|
||||
});
|
||||
|
||||
it("parses stopLoss from malformed executionPlan text (fallback)", async () => {
|
||||
const malformed = 'Model reply: "action": "sell", "executionPlan": { amount: 7, takeProfit: 120, stopLoss: 115, riskManagement: { maxLossPercent: 2 } } and commentary.';
|
||||
const mockMalformedClient = {
|
||||
createChatCompletion: vi.fn().mockResolvedValue({
|
||||
choices: [{ message: { content: malformed } }],
|
||||
}),
|
||||
};
|
||||
|
||||
const trader = new Trader(mockMalformedClient as any);
|
||||
const decision = await trader.decide("AAPL", mockReports, mockDebates);
|
||||
|
||||
// action may be unspecified in this malformed reply; ensure executionPlan fields parsed when present
|
||||
expect(decision.executionPlan).toBeDefined();
|
||||
expect(decision.executionPlan?.amount).toBe(7);
|
||||
expect(decision.executionPlan?.stopLoss).toBe(115);
|
||||
expect(decision.executionPlan?.takeProfit).toBe(120);
|
||||
expect(decision.executionPlan?.riskManagement?.maxLossPercent).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -36,4 +36,31 @@ describe("Trader", () => {
|
||||
const decision = await trader.decide("AAPL", mockReports, mockDebates);
|
||||
expect(decision.action).toBe("buy");
|
||||
});
|
||||
|
||||
it("parses executionPlan on sell", async () => {
|
||||
const mockSellClient = {
|
||||
createChatCompletion: vi.fn().mockResolvedValue({
|
||||
choices: [{ message: { content: JSON.stringify({
|
||||
action: "sell",
|
||||
confidence: 0.9,
|
||||
reasoning: "Exit position",
|
||||
executionPlan: {
|
||||
amount: 50,
|
||||
riskManagement: { maxLossPercent: 1.5, method: "trailing" },
|
||||
takeProfit: 150,
|
||||
note: "Test plan"
|
||||
}
|
||||
}) } }]
|
||||
}),
|
||||
};
|
||||
|
||||
const trader = new Trader(mockSellClient as any);
|
||||
const decision = await trader.decide("AAPL", mockReports, mockDebates);
|
||||
|
||||
expect(decision.action).toBe("sell");
|
||||
expect(decision.executionPlan).toBeDefined();
|
||||
expect(decision.executionPlan?.amount).toBe(50);
|
||||
expect(decision.executionPlan?.takeProfit).toBe(150);
|
||||
expect(decision.executionPlan?.riskManagement?.maxLossPercent).toBe(1.5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
/* TRADINGGRAPH related file */
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { TradingGraph } from "../tradingGraph";
|
||||
|
||||
describe("TradingGraph execution step", () => {
|
||||
it("returns executionPlan when model provides it", async () => {
|
||||
const mockClient = {
|
||||
createChatCompletion: vi.fn().mockResolvedValue({
|
||||
choices: [{ message: { content: JSON.stringify({
|
||||
action: "sell",
|
||||
confidence: 0.85,
|
||||
reasoning: "Test sell",
|
||||
executionPlan: { amount: 100, riskManagement: { maxLossPercent: 2 }, takeProfit: 200 }
|
||||
}) } }]
|
||||
}),
|
||||
};
|
||||
|
||||
const mockInput = {
|
||||
financialData: "...",
|
||||
technicalData: { prices: [1,2,3], sma: 1, ema: 1, rsi: 50, macd: 0 },
|
||||
sentimentData: { headlines: ["h"], source: "news" },
|
||||
};
|
||||
|
||||
const graph = new TradingGraph(mockClient as any);
|
||||
const decision = await graph.propagate("AAPL", mockInput as any);
|
||||
|
||||
expect(decision.action).toBe("sell");
|
||||
expect(decision.executionPlan).toBeDefined();
|
||||
expect(decision.executionPlan?.amount).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/* TRADINGGRAPH related file */
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { TradingGraph } from "../tradingGraph";
|
||||
|
||||
|
||||
+49
-3
@@ -37,16 +37,22 @@ ${signalSummaries}
|
||||
Debate Rounds:
|
||||
${debateSummaries}
|
||||
|
||||
Based on all the information above, make a trading decision. Respond with:
|
||||
Based on all the information above, make a trading decision. Respond with JSON containing these fields:
|
||||
- action: "buy", "sell", or "hold"
|
||||
- confidence: a number between 0 and 1
|
||||
- reasoning: brief explanation
|
||||
|
||||
If the action is "buy" or "sell", also include an "executionPlan" object with:
|
||||
- amount: number (shares to trade)
|
||||
- riskManagement: object (e.g., { maxLossPercent: 2 })
|
||||
- takeProfit: number (target take-profit price)
|
||||
- stopLoss: number (stop-loss price or absolute value)
|
||||
|
||||
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: "system", content: "You are a trading agent that makes buy/sell/hold decisions and provides execution guidance for buy and sell actions." },
|
||||
{ role: "user", content: prompt },
|
||||
],
|
||||
this.model
|
||||
@@ -57,6 +63,7 @@ Format your response as JSON with these fields.`;
|
||||
let action: 'buy' | 'sell' | 'hold' = 'hold';
|
||||
let confidence = 0.5;
|
||||
let reasoning = content;
|
||||
let executionPlan: any | undefined;
|
||||
|
||||
const actionMatch = content.match(/"action"\s*:\s*"(buy|sell|hold)"/);
|
||||
if (actionMatch) {
|
||||
@@ -73,12 +80,51 @@ Format your response as JSON with these fields.`;
|
||||
reasoning = reasoningMatch[1];
|
||||
}
|
||||
|
||||
return {
|
||||
// Try to parse executionPlan if provided in JSON
|
||||
const execMatch = content.match(/"executionPlan"\s*:\s*(\{[\s\S]*\})/);
|
||||
if (execMatch) {
|
||||
try {
|
||||
executionPlan = JSON.parse(execMatch[1]);
|
||||
} catch (err) {
|
||||
// fallback: try to extract primitive fields
|
||||
const amountMatch = content.match(/(?:"amount"|\bamount\b)\s*:\s*([0-9.]+)/);
|
||||
const takeProfitMatch = content.match(/(?:"takeProfit"|\btakeProfit\b)\s*:\s*([0-9.]+)/);
|
||||
const stopLossMatch = content.match(/(?:"stopLoss"|\bstopLoss\b)\s*:\s*([0-9.]+)/);
|
||||
const maxLossMatch = content.match(/(?:"maxLossPercent"|\bmaxLossPercent\b)\s*:\s*([0-9.]+)/);
|
||||
const methodMatch = content.match(/(?:"method"|\bmethod\b)\s*:\s*"([^"]+)"/);
|
||||
executionPlan = {} as any;
|
||||
if (amountMatch) executionPlan.amount = parseFloat(amountMatch[1]);
|
||||
if (takeProfitMatch) executionPlan.takeProfit = parseFloat(takeProfitMatch[1]);
|
||||
if (stopLossMatch) executionPlan.stopLoss = parseFloat(stopLossMatch[1]);
|
||||
executionPlan.riskManagement = {};
|
||||
if (maxLossMatch) executionPlan.riskManagement.maxLossPercent = parseFloat(maxLossMatch[1]);
|
||||
if (methodMatch) executionPlan.riskManagement.method = methodMatch[1];
|
||||
}
|
||||
}
|
||||
|
||||
// Additional fallback: if executionPlan parsed but missing nested riskManagement fields, try to extract them
|
||||
if (executionPlan && executionPlan.riskManagement == null) {
|
||||
const maxLossMatch2 = content.match(/(?:"maxLossPercent"|\bmaxLossPercent\b)\s*:\s*([0-9.]+)/);
|
||||
const methodMatch2 = content.match(/(?:"method"|\bmethod\b)\s*:\s*"([^"]+)"/);
|
||||
if (maxLossMatch2 || methodMatch2) {
|
||||
executionPlan.riskManagement = executionPlan.riskManagement || {};
|
||||
if (maxLossMatch2) executionPlan.riskManagement.maxLossPercent = parseFloat(maxLossMatch2[1]);
|
||||
if (methodMatch2) executionPlan.riskManagement.method = methodMatch2[1];
|
||||
}
|
||||
}
|
||||
|
||||
const decision: TradingDecision = {
|
||||
action,
|
||||
confidence,
|
||||
reasoning,
|
||||
agentSignals: allSignals,
|
||||
debateRounds: debates,
|
||||
};
|
||||
|
||||
if ((action === 'sell' || action === 'buy') && executionPlan) {
|
||||
decision.executionPlan = executionPlan;
|
||||
}
|
||||
|
||||
return decision;
|
||||
}
|
||||
}
|
||||
+27
-14
@@ -1,14 +1,16 @@
|
||||
/* TRADINGGRAPH related file */
|
||||
|
||||
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";
|
||||
import type { AnalystReport, DebateRound, TradingDecision, AgentSignal, ExecutionPlan } from "../types/agents";
|
||||
|
||||
export interface GraphStep {
|
||||
step: "analysts" | "debate" | "trader";
|
||||
data: AnalystReport[] | DebateRound[] | TradingDecision;
|
||||
step: "analysts" | "debate" | "trader" | "execution";
|
||||
data: AnalystReport[] | DebateRound[] | TradingDecision | ExecutionPlan;
|
||||
}
|
||||
|
||||
export class TradingGraph {
|
||||
@@ -41,14 +43,29 @@ export class TradingGraph {
|
||||
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})`);
|
||||
|
||||
|
||||
|
||||
// Build workflow steps for observability. Include an execution step when selling.
|
||||
const steps: GraphStep[] = [
|
||||
{ step: "analysts", data: reports },
|
||||
{ step: "debate", data: debates },
|
||||
{ step: "trader", data: decision },
|
||||
];
|
||||
|
||||
if (decision.executionPlan) {
|
||||
steps.push({ step: "execution", data: decision.executionPlan });
|
||||
|
||||
}
|
||||
|
||||
// Log steps for debugging; external systems can be extended to consume GraphStep sequence.
|
||||
|
||||
|
||||
return decision;
|
||||
}
|
||||
@@ -61,7 +78,7 @@ export class TradingGraph {
|
||||
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),
|
||||
@@ -69,24 +86,20 @@ export class TradingGraph {
|
||||
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 [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
interface Props {
|
||||
ticker: string;
|
||||
}
|
||||
|
||||
export default function JobHistory({ ticker }: Props) {
|
||||
const [jobs, setJobs] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selected, setSelected] = useState<any | null>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const fetchJobs = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/jobs?ticker=${encodeURIComponent(ticker)}`);
|
||||
if (!res.ok) {
|
||||
setJobs([]);
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setJobs(data.jobs || []);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Failed to fetch jobs:", e);
|
||||
setJobs([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const cancel = async (jobId: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/jobs/${jobId}/cancel`, { method: "POST" });
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
// Refresh list
|
||||
fetchJobs();
|
||||
return data.cancelled === true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Cancel failed:", e);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchJobs();
|
||||
const id = setInterval(fetchJobs, 8000);
|
||||
return () => clearInterval(id);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ticker]);
|
||||
|
||||
const fetchDetails = async (jobId: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/jobs/${jobId}`);
|
||||
if (!res.ok) {
|
||||
setSelected({ id: jobId, error: true });
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
setSelected(data);
|
||||
} catch (e) {
|
||||
console.warn("Failed to fetch job details:", e);
|
||||
setSelected({ id: jobId, error: true });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<h3 className="text-sm font-semibold text-gray-800 mb-2">Job History</h3>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<button onClick={fetchJobs} className="text-sm text-blue-600 hover:underline">Refresh</button>
|
||||
<span className="text-xs text-gray-500">{loading ? "Loading..." : `${jobs.length} jobs`}</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{loading ? (
|
||||
<div className="space-y-2">
|
||||
<div className="h-10 bg-gray-100 rounded animate-pulse" />
|
||||
<div className="h-10 bg-gray-100 rounded animate-pulse" />
|
||||
<div className="h-10 bg-gray-100 rounded animate-pulse" />
|
||||
</div>
|
||||
) : jobs.length === 0 ? (
|
||||
<p className="text-gray-500">No recent jobs for {ticker}</p>
|
||||
) : (
|
||||
jobs.map((j: any) => (
|
||||
<div key={j.id} className="p-3 border border-gray-200 rounded bg-gray-50 text-gray-900">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm">
|
||||
<div className="font-medium">Job: <span className="text-blue-600">{j.id}</span></div>
|
||||
<div className="text-xs text-gray-600">State: <strong className={`px-2 py-0.5 rounded text-xs ${j.state === 'completed' ? 'bg-green-100 text-green-800' : j.state === 'failed' ? 'bg-red-100 text-red-800' : 'bg-yellow-100 text-yellow-800'}`}>{j.state}</strong></div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<a href={`/api/jobs/${j.id}`} target="_blank" rel="noreferrer" className="text-sm text-blue-600 hover:underline">API</a>
|
||||
<a href={`/jobs/${j.id}`} className="text-sm text-gray-700 underline">Details</a>
|
||||
{(j.state === 'waiting' || j.state === 'queued') && (
|
||||
<button
|
||||
onClick={() => cancel(j.id)}
|
||||
className="text-sm text-red-600 hover:underline"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selected?.id === j.id && (
|
||||
<pre className="mt-2 text-xs bg-white p-2 rounded overflow-x-auto text-gray-800">{JSON.stringify(selected, null, 2)}</pre>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Link } from "react-router";
|
||||
import type { MostActiveStock } from "../types";
|
||||
|
||||
function formatVolume(vol: number): string {
|
||||
if (vol >= 1_000_000_000) return `${(vol / 1_000_000_000).toFixed(1)}B`;
|
||||
if (vol >= 1_000_000) return `${(vol / 1_000_000).toFixed(1)}M`;
|
||||
if (vol >= 1_000) return `${(vol / 1_000).toFixed(1)}K`;
|
||||
return vol.toString();
|
||||
}
|
||||
|
||||
function formatPrice(price: number): string {
|
||||
return `$${price.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function formatChangePercent(pct: number): string {
|
||||
return `${pct >= 0 ? "+" : ""}${pct.toFixed(2)}%`;
|
||||
}
|
||||
|
||||
export default function MostActiveStocks() {
|
||||
const [stocks, setStocks] = useState<MostActiveStock[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState<Record<string, boolean>>({});
|
||||
const [saved, setSaved] = useState<Record<string, boolean>>({});
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
setError(null);
|
||||
const res = await fetch("/api/stocks/most-actives");
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || "Failed to fetch data");
|
||||
}
|
||||
const data = await res.json();
|
||||
setStocks(data);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to fetch most active stocks.";
|
||||
setError(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
const interval = setInterval(fetchData, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchData]);
|
||||
|
||||
const handleSave = async (symbol: string) => {
|
||||
setSaving((p) => ({ ...p, [symbol]: true }));
|
||||
setSaved((p) => ({ ...p, [symbol]: false }));
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.set("ticker", symbol);
|
||||
const res = await fetch("/api/stocks", {
|
||||
method: "POST",
|
||||
body: form,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null);
|
||||
throw new Error(data?.error || "Failed to save stock");
|
||||
}
|
||||
// trigger analysis in background (non-blocking) and persist jobId to stock record
|
||||
try {
|
||||
const analyzeRes = await fetch(`/api/analyze`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ticker: symbol, background: true }) });
|
||||
const analyzeData = await analyzeRes.json().catch(() => null);
|
||||
if (analyzeRes.ok && analyzeData?.jobId) {
|
||||
const fd = new FormData();
|
||||
fd.append("ticker", symbol);
|
||||
fd.append("lastJobId", analyzeData.jobId.toString());
|
||||
await fetch("/api/stocks", { method: "POST", body: fd });
|
||||
setSaved((p) => ({ ...p, [symbol]: true }));
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Failed to enqueue background analyze:", err);
|
||||
}
|
||||
setSaved((p) => ({ ...p, [symbol]: true }));
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setSaving((p) => ({ ...p, [symbol]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-lg p-6 border border-gray-200">
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="animate-pulse flex gap-4 py-3 border-b border-gray-100 last:border-0">
|
||||
<div className="h-5 bg-gray-200 rounded w-16" />
|
||||
<div className="h-5 bg-gray-200 rounded w-32" />
|
||||
<div className="h-5 bg-gray-200 rounded w-20" />
|
||||
<div className="h-5 bg-gray-200 rounded w-20" />
|
||||
<div className="h-5 bg-gray-200 rounded w-24" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && stocks.length === 0) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-lg p-6 border border-gray-200">
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<p className="text-red-600 text-sm mb-3">{error}</p>
|
||||
<button
|
||||
onClick={fetchData}
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (stocks.length === 0) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-lg p-6 border border-gray-200">
|
||||
<p className="text-gray-600 text-center py-8">No data available</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-lg border border-gray-200 overflow-hidden">
|
||||
{error && (
|
||||
<div className="bg-red-50 border-b border-red-200 px-6 py-3">
|
||||
<p className="text-red-600 text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 border-b border-gray-200">
|
||||
<th className="text-left px-6 py-4 text-sm font-semibold text-gray-600">Symbol</th>
|
||||
<th className="text-left px-6 py-4 text-sm font-semibold text-gray-600">Name</th>
|
||||
<th className="text-right px-6 py-4 text-sm font-semibold text-gray-600">Price</th>
|
||||
<th className="text-right px-6 py-4 text-sm font-semibold text-gray-600">Change %</th>
|
||||
<th className="text-right px-6 py-4 text-sm font-semibold text-gray-600">Volume</th>
|
||||
<th className="text-right px-6 py-4 text-sm font-semibold text-gray-600">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{stocks.map((stock) => (
|
||||
<tr key={stock.symbol} className="border-b border-gray-100 hover:bg-gray-50 transition-colors">
|
||||
<td className="px-6 py-4">
|
||||
<Link
|
||||
to={`/analyze/${stock.symbol}`}
|
||||
className="text-blue-600 font-semibold hover:text-blue-700 hover:underline"
|
||||
>
|
||||
{stock.symbol}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-gray-600">{stock.name}</td>
|
||||
<td className="px-6 py-4 text-right font-mono text-gray-900">{formatPrice(stock.price)}</td>
|
||||
<td className={`px-6 py-4 text-right font-mono font-medium ${stock.changePercent >= 0 ? "text-green-600" : "text-red-600"}`}>
|
||||
{formatChangePercent(stock.changePercent)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right text-gray-600">{formatVolume(stock.volume)}</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<button
|
||||
onClick={() => handleSave(stock.symbol)}
|
||||
disabled={!!saving[stock.symbol]}
|
||||
className={`px-3 py-1 rounded-md text-sm font-medium transition-colors ${
|
||||
saving[stock.symbol]
|
||||
? "bg-gray-300 text-gray-700 cursor-not-allowed"
|
||||
: saved[stock.symbol]
|
||||
? "bg-green-600 text-white"
|
||||
: "bg-blue-600 text-white hover:bg-blue-700"
|
||||
}`}
|
||||
>
|
||||
{saving[stock.symbol] ? "Saving..." : saved[stock.symbol] ? "Saved" : "Save"}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -25,6 +25,12 @@ export default function Navbar() {
|
||||
>
|
||||
Analyze
|
||||
</Link>
|
||||
{/* If you have an isAdmin helper, show Settings only for admins. Example:
|
||||
{isAdmin(user) && (
|
||||
<Link to="/settings" className="text-gray-600 hover:text-blue-600 font-medium transition-colors">Settings</Link>
|
||||
)}
|
||||
*/}
|
||||
<a href="/settings" className="text-gray-600 hover:text-blue-600 font-medium transition-colors">Settings</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import * as LightweightCharts from "lightweight-charts";
|
||||
|
||||
type ChartTime = string | number;
|
||||
@@ -14,10 +14,26 @@ interface ChartDataPoint {
|
||||
interface TradingViewChartProps {
|
||||
ticker: string;
|
||||
data?: ChartDataPoint[];
|
||||
timeframe?: string;
|
||||
currentPrice?: number;
|
||||
// priceStream.subscribe(cb) should return an unsubscribe function
|
||||
priceStream?: { subscribe: (cb: (price: number) => void) => () => void };
|
||||
}
|
||||
|
||||
export default function TradingViewChart({ ticker, data }: TradingViewChartProps) {
|
||||
const TIMEFRAME_HEIGHTS: Record<string, number> = {
|
||||
"1D": 300,
|
||||
"5Min": 250,
|
||||
"15Min": 250,
|
||||
"1H": 350,
|
||||
"1W": 400,
|
||||
};
|
||||
|
||||
export default function TradingViewChart({ ticker, data, timeframe = "1D", currentPrice, priceStream }: TradingViewChartProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [livePrice, setLivePrice] = useState<number | undefined>(undefined);
|
||||
const height = TIMEFRAME_HEIGHTS[timeframe] ?? 400;
|
||||
|
||||
const isIntraday = ["1Min", "5Min", "15Min", "30Min", "1H"].includes(timeframe);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) {
|
||||
@@ -25,10 +41,16 @@ export default function TradingViewChart({ ticker, data }: TradingViewChartProps
|
||||
}
|
||||
|
||||
const chart = LightweightCharts.createChart(containerRef.current, {
|
||||
height: 400,
|
||||
height,
|
||||
autoSize: true,
|
||||
});
|
||||
|
||||
// Configure time scale based on timeframe and range
|
||||
chart.timeScale().applyOptions({
|
||||
timeVisible: isIntraday,
|
||||
secondsVisible: timeframe === "1Min",
|
||||
});
|
||||
|
||||
const candlestickSeries = chart.addSeries(LightweightCharts.CandlestickSeries, {
|
||||
upColor: "#26a69a",
|
||||
downColor: "#ef5350",
|
||||
@@ -41,17 +63,48 @@ export default function TradingViewChart({ ticker, data }: TradingViewChartProps
|
||||
if (data && data.length > 0) {
|
||||
try {
|
||||
candlestickSeries.setData(data as any);
|
||||
// Fit the visible data range
|
||||
chart.timeScale().fitContent();
|
||||
} catch (err) {
|
||||
console.error(`TradingViewChart: error setting data for ${ticker}`, err);
|
||||
}
|
||||
}
|
||||
|
||||
return () => chart.remove();
|
||||
}, [data, ticker]);
|
||||
}, [data, ticker, isIntraday, timeframe]);
|
||||
|
||||
// Subscribe to a streaming price if provided
|
||||
useEffect(() => {
|
||||
if (!priceStream) return;
|
||||
let unsub: (() => void) | void = undefined;
|
||||
try {
|
||||
unsub = priceStream.subscribe((p: number) => {
|
||||
setLivePrice(p);
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn("TradingViewChart: priceStream subscribe failed", e);
|
||||
}
|
||||
return () => {
|
||||
try {
|
||||
if (typeof unsub === "function") unsub();
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
}, [priceStream]);
|
||||
|
||||
const derivedPrice = currentPrice ?? livePrice ?? (data && data.length ? data[data.length - 1].close : undefined);
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-lg p-4">
|
||||
<h3 className="text-lg font-bold mb-3">{ticker} Price Chart</h3>
|
||||
<div className="flex items-baseline justify-between mb-3">
|
||||
<h3 className="text-lg font-bold">{ticker} Price Chart</h3>
|
||||
{typeof derivedPrice === "number" ? (
|
||||
<div data-testid="current-price" className="text-xl font-semibold text-gray-900">
|
||||
${derivedPrice.toFixed(2)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div ref={containerRef} className="w-full" />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -25,9 +25,10 @@ describe("TradingViewChart", () => {
|
||||
// Update the mock's setData to track calls
|
||||
const mockSeries = { setData: mockSetData };
|
||||
mockCreateChart.mockReturnValue({
|
||||
timeScale: () => ({ applyOptions: vi.fn(), fitContent: vi.fn() }),
|
||||
addSeries: vi.fn(() => mockSeries),
|
||||
remove: vi.fn(),
|
||||
});
|
||||
} as any);
|
||||
});
|
||||
|
||||
it("renders the ticker symbol as heading", () => {
|
||||
@@ -70,17 +71,55 @@ describe("TradingViewChart", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("shows current price when provided via prop", () => {
|
||||
render(<TradingViewChart ticker="PRC" currentPrice={123.456} />);
|
||||
expect(screen.getByTestId("current-price")).toHaveTextContent("$123.46");
|
||||
});
|
||||
|
||||
it("derives current price from last data point when currentPrice prop missing", () => {
|
||||
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="DER" data={data} />);
|
||||
expect(screen.getByTestId("current-price")).toHaveTextContent("$110.00");
|
||||
});
|
||||
|
||||
it("updates when price stream emits", async () => {
|
||||
// create a simple priceStream that stores callback
|
||||
let cb: ((p: number) => void) | undefined;
|
||||
const unsubscribe = vi.fn();
|
||||
const priceStream = {
|
||||
subscribe: (c: (p: number) => void) => {
|
||||
cb = c;
|
||||
return unsubscribe;
|
||||
},
|
||||
} as any;
|
||||
|
||||
render(<TradingViewChart ticker="STR" priceStream={priceStream} />);
|
||||
expect(screen.queryByTestId("current-price")).toBeNull();
|
||||
|
||||
// emit a price
|
||||
if (cb) cb(200);
|
||||
|
||||
// wait a tick for state update
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
expect(screen.getByTestId("current-price")).toHaveTextContent("$200.00");
|
||||
});
|
||||
|
||||
it("creates candlestick series with explicit colors", () => {
|
||||
const mockAddSeries = vi.fn();
|
||||
mockCreateChart.mockReturnValue({
|
||||
timeScale: () => ({ applyOptions: vi.fn(), fitContent: vi.fn() }),
|
||||
addSeries: mockAddSeries,
|
||||
remove: vi.fn(),
|
||||
});
|
||||
} as any);
|
||||
|
||||
render(<TradingViewChart ticker="TEST" />);
|
||||
|
||||
expect(mockAddSeries).toHaveBeenCalledWith(
|
||||
{},
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
upColor: "#26a69a",
|
||||
downColor: "#ef5350",
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { enrichExecutionPlan } from "../execution";
|
||||
|
||||
describe("enrichExecutionPlan with Alpaca account data", () => {
|
||||
beforeEach(() => {
|
||||
process.env.DEFAULT_ACCOUNT_EQUITY = "10000";
|
||||
});
|
||||
afterEach(() => {
|
||||
delete process.env.DEFAULT_ACCOUNT_EQUITY;
|
||||
});
|
||||
|
||||
it("uses input.account.cash for sizing when provided", () => {
|
||||
const decision: any = { action: "buy" };
|
||||
const input = { technicalData: { prices: [50, 52, 51] }, account: { cash: 5000 } };
|
||||
|
||||
const out = enrichExecutionPlan(decision, input);
|
||||
|
||||
// entryPrice = 51, ATR ~ 1.5 -> stopDistance = 1.5*1.5 = 2.25
|
||||
// riskAmount = 5000 * 0.01 = 50 -> amount = floor(50 / 2.25) = 22
|
||||
expect(out.executionPlan.amount).toBe(22);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { enrichExecutionPlan } from "../execution";
|
||||
|
||||
describe("enrichExecutionPlan edge cases", () => {
|
||||
beforeEach(() => {
|
||||
process.env.DEFAULT_ACCOUNT_EQUITY = "10000";
|
||||
});
|
||||
afterEach(() => {
|
||||
delete process.env.DEFAULT_ACCOUNT_EQUITY;
|
||||
});
|
||||
|
||||
it("handles very small/zero ATR (flat prices) without crashing and uses percent fallback", () => {
|
||||
const decision: any = { action: "buy" };
|
||||
const input = { technicalData: { prices: [100, 100, 100] } };
|
||||
|
||||
const out = enrichExecutionPlan(decision, input);
|
||||
|
||||
expect(out.executionPlan).toBeDefined();
|
||||
// ATR ~ 0, so stopDistance should fall back to percent-based (1% of entry = 1)
|
||||
expect(out.executionPlan.stopLoss).toBeCloseTo(99, 2);
|
||||
// entry 100 + rr*stopDistance (2*1) => 102
|
||||
expect(out.executionPlan.takeProfit).toBeCloseTo(102, 2);
|
||||
expect(out.executionPlan.amount).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("honors percent-based riskManagement from LLM (0.5%) and computes amount accordingly", () => {
|
||||
const decision: any = { action: "buy", executionPlan: { riskManagement: { maxLossPercent: 0.5 } } };
|
||||
const input = { technicalData: { prices: [200, 202] } };
|
||||
|
||||
const out = enrichExecutionPlan(decision, input);
|
||||
|
||||
expect(out.executionPlan).toBeDefined();
|
||||
expect(out.executionPlan.riskManagement).toBeDefined();
|
||||
expect(out.executionPlan.riskManagement.maxLossPercent).toBeCloseTo(0.5, 6);
|
||||
|
||||
// entryPrice = 202, rr/default: atr ~2, stopDistance = max(2*1.5=3, 202*0.005=1.01) => 3
|
||||
// riskAmount = 10000 * 0.005 = 50 -> shares = floor(50/3) = 16
|
||||
expect(out.executionPlan.amount).toBe(16);
|
||||
});
|
||||
|
||||
it("handles missing price data by producing a finite amount and no absolute stops", () => {
|
||||
const decision: any = { action: "buy" };
|
||||
const input = { technicalData: { prices: [] } };
|
||||
|
||||
const out = enrichExecutionPlan(decision, input);
|
||||
|
||||
expect(out.executionPlan).toBeDefined();
|
||||
// No entry price -> cannot compute absolute stopLoss/takeProfit
|
||||
expect(out.executionPlan.stopLoss).toBeUndefined();
|
||||
expect(out.executionPlan.takeProfit).toBeUndefined();
|
||||
// Amount should still be computed (uses small fallback stopDistance 0.0001) -> large but finite
|
||||
expect(typeof out.executionPlan.amount).toBe("number");
|
||||
expect(Number.isFinite(out.executionPlan.amount)).toBe(true);
|
||||
expect(out.executionPlan.amount).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { enrichExecutionPlan } from "../execution";
|
||||
|
||||
describe("enrichExecutionPlan", () => {
|
||||
beforeEach(() => {
|
||||
process.env.DEFAULT_ACCOUNT_EQUITY = "10000";
|
||||
});
|
||||
afterEach(() => {
|
||||
delete process.env.DEFAULT_ACCOUNT_EQUITY;
|
||||
});
|
||||
|
||||
it("computes stopLoss/takeProfit/amount for buy decision", () => {
|
||||
const decision: any = { action: "buy" };
|
||||
const input = { technicalData: { prices: [100, 102, 101] } };
|
||||
|
||||
const out = enrichExecutionPlan(decision, input);
|
||||
|
||||
expect(out.executionPlan).toBeDefined();
|
||||
// ATR approx = 1.5 -> stopDistance = 1.5*1.5 = 2.25
|
||||
// stopLoss = 101 - 2.25 = 98.75
|
||||
// takeProfit = 101 + 2.25*2 = 105.5
|
||||
// riskAmount = 10000 * 0.01 = 100 -> amount = floor(100 / 2.25) = 44
|
||||
expect(out.executionPlan.amount).toBe(44);
|
||||
expect(out.executionPlan.stopLoss).toBeCloseTo(98.75, 2);
|
||||
expect(out.executionPlan.takeProfit).toBeCloseTo(105.5, 2);
|
||||
});
|
||||
|
||||
it("computes stopLoss/takeProfit for sell decision (stop above entry)", () => {
|
||||
const decision: any = { action: "sell" };
|
||||
const input = { technicalData: { prices: [100, 102, 101] } };
|
||||
|
||||
const out = enrichExecutionPlan(decision, input);
|
||||
|
||||
// entryPrice = 101, stopDistance = 2.25
|
||||
// stopLoss = 101 + 2.25 = 103.25
|
||||
// takeProfit = 101 - 2.25*2 = 96.5
|
||||
expect(out.executionPlan).toBeDefined();
|
||||
expect(out.executionPlan.stopLoss).toBeCloseTo(103.25, 2);
|
||||
expect(out.executionPlan.takeProfit).toBeCloseTo(96.5, 2);
|
||||
expect(out.executionPlan.amount).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("preserves existing executionPlan fields and normalizes riskManagement", () => {
|
||||
const decision: any = { action: "buy", executionPlan: { amount: 10, stopLoss: 90, takeProfit: 110 } };
|
||||
const input = { technicalData: { prices: [100, 101] } };
|
||||
|
||||
const out = enrichExecutionPlan(decision, input);
|
||||
|
||||
expect(out.executionPlan.amount).toBe(10);
|
||||
expect(out.executionPlan.stopLoss).toBe(90);
|
||||
expect(out.executionPlan.takeProfit).toBe(110);
|
||||
expect(out.executionPlan.riskManagement).toBeDefined();
|
||||
expect(typeof out.executionPlan.riskManagement.maxLossPercent).toBe("number");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
// app/lib/__tests__/settings.server.test.ts
|
||||
import { settingsService } from '../settings.server';
|
||||
|
||||
describe('SettingsService', () => {
|
||||
test('set and get', async () => {
|
||||
const key = `test_key_${Date.now()}`;
|
||||
const val = { enabled: true };
|
||||
await settingsService.set(key, val, 'test');
|
||||
const got = await settingsService.get(key);
|
||||
expect(got).toEqual(val);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import Alpaca from "@alpacahq/alpaca-trade-api";
|
||||
|
||||
type Mode = 'paper' | 'live';
|
||||
|
||||
function makeAlpaca(mode: Mode = 'paper') {
|
||||
const isLive = mode === 'live';
|
||||
const keyId = isLive ? (process.env.ALPACA_API_KEY_LIVE || process.env.ALPACA_API_KEY) : process.env.ALPACA_API_KEY;
|
||||
const secretKey = isLive ? (process.env.ALPACA_SECRET_KEY_LIVE || process.env.ALPACA_SECRET_KEY) : process.env.ALPACA_SECRET_KEY;
|
||||
const baseUrl = isLive ? (process.env.ALPACA_LIVE_BASE_URL || "https://api.alpaca.markets") : (process.env.ALPACA_BASE_URL || "https://paper-api.alpaca.markets");
|
||||
const dataBaseUrl = isLive ? (process.env.ALPACA_LIVE_DATA_URL || "https://data.alpaca.markets") : (process.env.ALPACA_DATA_URL || "https://data.alpaca.markets");
|
||||
return new Alpaca({
|
||||
keyId,
|
||||
secretKey,
|
||||
baseUrl,
|
||||
dataBaseUrl,
|
||||
retryOnError: false,
|
||||
});
|
||||
}
|
||||
|
||||
class AlpacaService {
|
||||
private mode: Mode;
|
||||
private client: any;
|
||||
private lastBarCache = new Map<string, { bar: any; ts: number }>();
|
||||
|
||||
constructor(mode: Mode = 'paper') {
|
||||
this.mode = mode;
|
||||
this.client = makeAlpaca(mode);
|
||||
}
|
||||
|
||||
setMode(mode: Mode) {
|
||||
if (this.mode !== mode) {
|
||||
this.mode = mode;
|
||||
this.client = makeAlpaca(mode);
|
||||
}
|
||||
}
|
||||
|
||||
getMode() {
|
||||
return this.mode;
|
||||
}
|
||||
|
||||
async fetchAccount() {
|
||||
try {
|
||||
const account = await this.client.getAccount();
|
||||
return {
|
||||
cash: parseFloat(account.cash),
|
||||
buying_power: parseFloat(account.buying_power),
|
||||
portfolio_value: parseFloat(account.portfolio_value),
|
||||
};
|
||||
} catch (err: any) {
|
||||
console.error("AlpacaService: fetchAccount failed:", err);
|
||||
throw new Error(err?.message || String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async fetchRecentCloses(ticker: string, days = 30) {
|
||||
try {
|
||||
const startDate = new Date();
|
||||
startDate.setDate(startDate.getDate() - days);
|
||||
const barsIter = await this.client.getBarsV2(ticker, { timeframe: "1D", start: startDate.toISOString().split("T")[0], limit: 1000 });
|
||||
const barsArray: any[] = [];
|
||||
for await (const b of barsIter) barsArray.push(b);
|
||||
const closes = barsArray.map((bar: any) => (typeof bar.ClosePrice === 'number' ? bar.ClosePrice : (typeof bar.c === 'number' ? bar.c : 0))).filter((c: number) => c > 0);
|
||||
if (closes.length) return closes;
|
||||
|
||||
// fallback to latest trade
|
||||
try {
|
||||
const trade: any = await this.client.getLatestTrade(ticker);
|
||||
const price = trade?.Price || trade?.price || 0;
|
||||
if (price) return [price];
|
||||
} catch (tErr) {
|
||||
console.warn("AlpacaService: getLatestTrade fallback failed:", tErr);
|
||||
}
|
||||
|
||||
throw new Error("No recent price data available from Alpaca");
|
||||
} catch (err: any) {
|
||||
console.error("AlpacaService: fetchRecentCloses failed:", err);
|
||||
throw new Error(err?.message || String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async fetchLatestBar(ticker: string, timeframe = '1Min') {
|
||||
const cacheKey = `${ticker}:${timeframe}`;
|
||||
const maxRetries = 3;
|
||||
let attempt = 0;
|
||||
let baseDelay = 500; // ms
|
||||
try {
|
||||
while (attempt < maxRetries) {
|
||||
try {
|
||||
const barsIter = await this.client.getBarsV2(ticker, { timeframe, limit: 1 });
|
||||
const barsArr: any[] = [];
|
||||
for await (const b of barsIter) barsArr.push(b);
|
||||
const last = barsArr[barsArr.length - 1] || null;
|
||||
if (last) {
|
||||
this.lastBarCache.set(cacheKey, { bar: last, ts: Date.now() });
|
||||
}
|
||||
return last || (this.lastBarCache.get(cacheKey)?.bar ?? null);
|
||||
} catch (err: any) {
|
||||
const msg = err?.message ?? String(err);
|
||||
// Rate limit -> retry with exponential backoff
|
||||
if (/429|too many requests/i.test(msg)) {
|
||||
attempt++;
|
||||
const backoff = Math.min(baseDelay * Math.pow(2, attempt - 1), 10000);
|
||||
console.warn(`AlpacaService.fetchLatestBar rate limited, retry ${attempt}/${maxRetries} after ${backoff}ms`);
|
||||
await new Promise((r) => setTimeout(r, backoff));
|
||||
continue;
|
||||
}
|
||||
// non-rate-limit error -> rethrow
|
||||
console.error('AlpacaService: fetchLatestBar failed:', err);
|
||||
throw new Error(err?.message || String(err));
|
||||
}
|
||||
}
|
||||
|
||||
// exhausted retries, fall back to cache if available
|
||||
const cached = this.lastBarCache.get(cacheKey);
|
||||
if (cached) {
|
||||
console.warn('AlpacaService.fetchLatestBar: returning cached bar after retries');
|
||||
return cached.bar;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (err: any) {
|
||||
console.error('AlpacaService: fetchLatestBar final error:', err);
|
||||
throw new Error(err?.message || String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async fetchBars(ticker: string, timeframe = '1D', options: any = {}) {
|
||||
const maxRetries = 3;
|
||||
let attempt = 0;
|
||||
let baseDelay = 500;
|
||||
try {
|
||||
while (attempt < maxRetries) {
|
||||
try {
|
||||
const barsIter = await this.client.getBarsV2(ticker, { timeframe, ...options });
|
||||
const barsArr: any[] = [];
|
||||
for await (const b of barsIter) barsArr.push(b);
|
||||
|
||||
// update last-bar cache for this ticker/timeframe
|
||||
if (barsArr.length) {
|
||||
const cacheKey = `${ticker}:${timeframe}`;
|
||||
this.lastBarCache.set(cacheKey, { bar: barsArr[barsArr.length - 1], ts: Date.now() });
|
||||
}
|
||||
|
||||
return barsArr;
|
||||
} catch (err: any) {
|
||||
const msg = err?.message ?? String(err);
|
||||
if (/429|too many requests/i.test(msg)) {
|
||||
attempt++;
|
||||
const backoff = Math.min(baseDelay * Math.pow(2, attempt - 1), 10000);
|
||||
console.warn(`AlpacaService.fetchBars rate limited, retry ${attempt}/${maxRetries} after ${backoff}ms`);
|
||||
await new Promise((r) => setTimeout(r, backoff));
|
||||
continue;
|
||||
}
|
||||
console.error('AlpacaService: fetchBars failed:', err);
|
||||
throw new Error(err?.message || String(err));
|
||||
}
|
||||
}
|
||||
|
||||
console.warn('AlpacaService.fetchBars: exhausted retries, returning empty array');
|
||||
return [];
|
||||
} catch (err: any) {
|
||||
console.error('AlpacaService: fetchBars final error:', err);
|
||||
throw new Error(err?.message || String(err));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton configured to use paper trading API by default
|
||||
export const alpacaService = new AlpacaService('paper');
|
||||
|
||||
// Backwards-compatible named exports (delegate to singleton)
|
||||
export const fetchAccount = (_mode?: Mode) => alpacaService.fetchAccount();
|
||||
export const fetchRecentCloses = (ticker: string, days = 30, _mode?: Mode) => alpacaService.fetchRecentCloses(ticker, days);
|
||||
export const fetchLatestBar = (ticker: string, timeframe = '1Min', _mode?: Mode) => alpacaService.fetchLatestBar(ticker, timeframe);
|
||||
export const fetchBars = (ticker: string, timeframe = '1D', options: any = {}, _mode?: Mode) => alpacaService.fetchBars(ticker, timeframe, options);
|
||||
|
||||
export default alpacaService;
|
||||
@@ -0,0 +1,6 @@
|
||||
export async function requireAdmin(request: Request) {
|
||||
// Simple fallback: check x-admin-token header vs ADMIN_TOKEN
|
||||
const token = request.headers.get('x-admin-token');
|
||||
if (process.env.ADMIN_TOKEN && token === process.env.ADMIN_TOKEN) return;
|
||||
throw new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import type { TradingDecision, ExecutionPlan } from "../types/agents";
|
||||
|
||||
export function enrichExecutionPlan(decision: TradingDecision, input: any): TradingDecision {
|
||||
try {
|
||||
const prices: number[] = input?.technicalData?.prices || [];
|
||||
const entryPrice = prices.length ? prices[prices.length - 1] : undefined;
|
||||
|
||||
// ATR approximation: prefer bar-based ATR (high-low average), fall back to price diffs
|
||||
let atr = 0;
|
||||
const bars: any[] = input?.technicalData?.bars || [];
|
||||
if (bars && bars.length >= 2) {
|
||||
let sum = 0;
|
||||
for (const b of bars) {
|
||||
const high = typeof b.HighPrice === 'number' ? b.HighPrice : (typeof b.h === 'number' ? b.h : 0);
|
||||
const low = typeof b.LowPrice === 'number' ? b.LowPrice : (typeof b.l === 'number' ? b.l : 0);
|
||||
sum += Math.max(0, high - low);
|
||||
}
|
||||
atr = sum / bars.length;
|
||||
} else if (prices && prices.length >= 2) {
|
||||
let sum = 0;
|
||||
for (let i = 1; i < prices.length; i++) sum += Math.abs(prices[i] - prices[i - 1]);
|
||||
atr = sum / (prices.length - 1);
|
||||
} else if (entryPrice) {
|
||||
atr = entryPrice * 0.01; // fallback 1%
|
||||
}
|
||||
|
||||
const rr = 2; // default risk:reward
|
||||
const equity = Number(input?.account?.cash ?? input?.account?.buying_power ?? process.env.DEFAULT_ACCOUNT_EQUITY ?? 10000);
|
||||
|
||||
if (!decision.executionPlan) decision.executionPlan = {} as ExecutionPlan;
|
||||
const plan = decision.executionPlan as any;
|
||||
|
||||
const maxLossPercent = plan.riskManagement?.maxLossPercent ?? plan.maxLossPercent ?? 1; // default 1%
|
||||
const riskPercent = Number(maxLossPercent) / 100;
|
||||
|
||||
// compute stop distance (price units)
|
||||
let stopDistanceByPercent = entryPrice ? Math.abs(entryPrice * riskPercent) : 0;
|
||||
const stopDistanceByAtr = atr ? atr * 1.5 : 0; // multiplier
|
||||
let stopDistance = Math.max(stopDistanceByAtr, stopDistanceByPercent, 0.0001);
|
||||
|
||||
// compute stopLoss absolute price if missing
|
||||
if (plan.stopLoss == null && entryPrice != null) {
|
||||
if (decision.action === 'buy') {
|
||||
plan.stopLoss = Number((entryPrice - stopDistance).toFixed(2));
|
||||
} else if (decision.action === 'sell') {
|
||||
// for sell (exit/short) place stop above entry
|
||||
plan.stopLoss = Number((entryPrice + stopDistance).toFixed(2));
|
||||
} else {
|
||||
// default: buy-style stop
|
||||
plan.stopLoss = Number((entryPrice - stopDistance).toFixed(2));
|
||||
}
|
||||
}
|
||||
|
||||
// compute takeProfit if missing
|
||||
if (plan.takeProfit == null && entryPrice != null) {
|
||||
if (decision.action === 'buy') {
|
||||
plan.takeProfit = Number((entryPrice + stopDistance * rr).toFixed(2));
|
||||
} else if (decision.action === 'sell') {
|
||||
plan.takeProfit = Number((entryPrice - stopDistance * rr).toFixed(2));
|
||||
} else {
|
||||
plan.takeProfit = Number((entryPrice + stopDistance * rr).toFixed(2));
|
||||
}
|
||||
}
|
||||
|
||||
// compute shares if missing using risk-based sizing
|
||||
if (plan.amount == null) {
|
||||
const riskAmount = equity * riskPercent;
|
||||
// Protect against extremely small stopDistance (which can occur with missing/flat prices)
|
||||
if (!stopDistance || stopDistance < 0.01) {
|
||||
stopDistance = entryPrice ? Math.max(entryPrice * 0.01, 0.01) : 1; // default 1 unit when no price
|
||||
}
|
||||
const rawShares = Math.max(1, Math.floor(riskAmount / stopDistance));
|
||||
// Cap shares to what is affordable with full equity and a reasonable absolute cap
|
||||
const affordableMax = Math.max(1, Math.floor(equity / Math.max(entryPrice || 1, 1)));
|
||||
const absoluteMax = 100000; // safety cap
|
||||
const shares = Math.min(rawShares, affordableMax, absoluteMax);
|
||||
plan.amount = shares;
|
||||
}
|
||||
|
||||
// normalize nested riskManagement
|
||||
plan.riskManagement = plan.riskManagement || {};
|
||||
if (plan.riskManagement.maxLossPercent == null) plan.riskManagement.maxLossPercent = maxLossPercent;
|
||||
|
||||
decision.executionPlan = plan as ExecutionPlan;
|
||||
} catch (err) {
|
||||
console.warn("enrichExecutionPlan error:", err);
|
||||
}
|
||||
return decision;
|
||||
}
|
||||
|
||||
// Optional LLM verification step: review computed executionPlan and suggest adjustments
|
||||
export async function verifyExecutionPlanWithLLM(decision: TradingDecision, input: any): Promise<TradingDecision> {
|
||||
try {
|
||||
const apiKey = process.env.OPENROUTER_API_KEY;
|
||||
if (!apiKey) return decision;
|
||||
|
||||
const { OpenRouterClient } = await import("./openrouter");
|
||||
const client = new OpenRouterClient(apiKey);
|
||||
|
||||
const plan = decision.executionPlan || {};
|
||||
const prices: number[] = input?.technicalData?.prices || [];
|
||||
const entryPrice = prices.length ? prices[prices.length - 1] : null;
|
||||
|
||||
const userPrompt = `Review the following deterministic execution plan and either approve or provide corrected values. Respond with a JSON object only with the shape: { "approved": boolean, "executionPlan": { /* fields to use */ }, "notes": "..." }\n\nContext:\nentryPrice: ${entryPrice}\nrecentPrices: ${JSON.stringify(prices.slice(-10))}\nComputedPlan: ${JSON.stringify(plan)}\n\nGuidelines:\n- If values look reasonable, return {"approved": true, "executionPlan": {}} (empty executionPlan means keep computed values).\n- If any value should be adjusted, return an executionPlan object with corrected fields (amount, stopLoss, takeProfit, riskManagement).\n- Do not include any extra text outside the JSON object.`;
|
||||
|
||||
const messages = [
|
||||
{ role: "system", content: "You are a conservative trading assistant. Validate risk sizing and stop levels. If you suggest changes, prefer conservative (smaller) position sizes and wider stops only if volatility justifies it." },
|
||||
{ role: "user", content: userPrompt },
|
||||
];
|
||||
|
||||
const res: any = await client.createChatCompletion(messages as any);
|
||||
const content = res?.choices?.[0]?.message?.content ?? "";
|
||||
|
||||
// try to parse JSON out of the content
|
||||
let parsed: any = null;
|
||||
try {
|
||||
parsed = JSON.parse(content);
|
||||
} catch (e) {
|
||||
// fallback: extract first JSON object
|
||||
const m = content.match(/(\{[\s\S]*\})/);
|
||||
if (m) {
|
||||
try {
|
||||
parsed = JSON.parse(m[1]);
|
||||
} catch (e2) {
|
||||
console.warn("verifyExecutionPlanWithLLM: failed to parse LLM response JSON");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed && typeof parsed === "object") {
|
||||
const approved = parsed.approved !== false; // default true if missing
|
||||
const suggested = parsed.executionPlan || {};
|
||||
// attach llmReview metadata
|
||||
const newPlan = { ...plan, ...suggested };
|
||||
newPlan._llmReview = { approved: !!approved, notes: parsed.notes || null };
|
||||
decision.executionPlan = newPlan as any;
|
||||
}
|
||||
|
||||
return decision;
|
||||
} catch (err) {
|
||||
console.warn("verifyExecutionPlanWithLLM error:", err);
|
||||
return decision;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { OpenRouterClient } from "./openrouter";
|
||||
import { TradingGraph } from "../agents/tradingGraph";
|
||||
import { db } from "./db.server";
|
||||
|
||||
type AnalyzeInput = {
|
||||
financialData: string;
|
||||
technicalData: { prices: number[]; sma: number; ema: number; rsi: number; macd: number };
|
||||
sentimentData: { headlines: string[]; source?: "news" | "social" | "stocktwits" };
|
||||
};
|
||||
|
||||
type Job = {
|
||||
id: string;
|
||||
type: "analyze";
|
||||
ticker: string;
|
||||
input: AnalyzeInput;
|
||||
};
|
||||
|
||||
const queue: Job[] = [];
|
||||
let processing = false;
|
||||
|
||||
function makeId() {
|
||||
return `${Date.now()}-${Math.floor(Math.random() * 100000)}`;
|
||||
}
|
||||
|
||||
export function enqueueAnalyze(ticker: string, input: AnalyzeInput) {
|
||||
const id = makeId();
|
||||
queue.push({ id, type: "analyze", ticker, input });
|
||||
if (!processing) {
|
||||
processQueue().catch((err) => console.error("jobQueue error:", err));
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
async function processQueue() {
|
||||
processing = true;
|
||||
while (queue.length > 0) {
|
||||
const job = queue.shift()!;
|
||||
console.log("[jobQueue] Processing job", job.id, job.type, job.ticker);
|
||||
try {
|
||||
if (job.type === "analyze") {
|
||||
const apiKey = process.env.OPENROUTER_API_KEY;
|
||||
if (!apiKey || apiKey === "your_openrouter_api_key_here") {
|
||||
console.log("[jobQueue] mock mode for analyze", job.ticker);
|
||||
const mockDecision = {
|
||||
action: "hold",
|
||||
confidence: 0.6,
|
||||
reasoning: `${job.ticker} analysis - Mock mode (background)`,
|
||||
};
|
||||
await db.stock.upsert({
|
||||
where: { ticker: job.ticker },
|
||||
create: { ticker: job.ticker, lastDecision: mockDecision.action, lastExplanation: mockDecision.reasoning },
|
||||
update: { lastDecision: mockDecision.action, lastExplanation: mockDecision.reasoning },
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const client = new OpenRouterClient(apiKey);
|
||||
const graph = new TradingGraph(client);
|
||||
const decision = await graph.propagate(job.ticker, job.input);
|
||||
await db.stock.upsert({
|
||||
where: { ticker: job.ticker },
|
||||
create: {
|
||||
ticker: job.ticker,
|
||||
lastDecision: decision.action as string,
|
||||
lastExplanation: (decision as any).reasoning || null,
|
||||
lastExecutionPlan: decision.executionPlan ? JSON.stringify(decision.executionPlan) : null,
|
||||
},
|
||||
update: {
|
||||
lastDecision: decision.action as string,
|
||||
lastExplanation: (decision as any).reasoning || null,
|
||||
lastExecutionPlan: decision.executionPlan ? JSON.stringify(decision.executionPlan) : null,
|
||||
},
|
||||
});
|
||||
console.log("[jobQueue] Saved background decision for", job.ticker);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[jobQueue] job failed:", err);
|
||||
}
|
||||
}
|
||||
processing = false;
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import pkg from "bullmq";
|
||||
const { Queue, Worker } = pkg as any;
|
||||
import IORedis from "ioredis";
|
||||
import { fetchAccount, fetchRecentCloses } from "./alpacaClient";
|
||||
import { OpenRouterClient } from "./openrouter";
|
||||
import { TradingGraph } from "../agents/tradingGraph";
|
||||
import { db } from "./db.server";
|
||||
|
||||
const REDIS_URL = process.env.REDIS_URL;
|
||||
|
||||
let analyzeQueue: any = undefined;
|
||||
let worker: any = undefined;
|
||||
let enqueueAnalyze: (ticker: string, input: any) => Promise<string> | string;
|
||||
let getJob: (jobId: string) => Promise<any | null>;
|
||||
let listRecentJobs: (ticker?: string, limit?: number) => Promise<any[]>;
|
||||
let cancelJob: (jobId: string) => Promise<boolean>;
|
||||
|
||||
if (REDIS_URL) {
|
||||
const redis = new IORedis(REDIS_URL as string);
|
||||
analyzeQueue = new Queue("analyze", { connection: redis });
|
||||
|
||||
// Worker to process analyze jobs
|
||||
worker = new Worker(
|
||||
"analyze",
|
||||
async (job: any) => {
|
||||
console.log("[queue] Processing analyze job", job.id, job.data.ticker);
|
||||
const { ticker, input } = job.data as { ticker: string; input: any };
|
||||
const apiKey = process.env.OPENROUTER_API_KEY;
|
||||
if (!apiKey || apiKey === "your_openrouter_api_key_here") {
|
||||
console.log("[queue] mock mode for analyze", ticker);
|
||||
const mockDecision = {
|
||||
action: "hold",
|
||||
confidence: 0.6,
|
||||
reasoning: `${ticker} analysis - Mock mode (background)`,
|
||||
};
|
||||
await db.stock.upsert({
|
||||
where: { ticker },
|
||||
create: { ticker, lastDecision: mockDecision.action, lastExplanation: mockDecision.reasoning, lastJobId: job.id?.toString() },
|
||||
update: { lastDecision: mockDecision.action, lastExplanation: mockDecision.reasoning, lastJobId: job.id?.toString() },
|
||||
});
|
||||
return mockDecision;
|
||||
}
|
||||
|
||||
const client = new OpenRouterClient(apiKey);
|
||||
const graph = new TradingGraph(client);
|
||||
// Fetch latest Alpaca account and prices; abort job if unavailable so work runs on fresh data
|
||||
try {
|
||||
const account = await fetchAccount();
|
||||
const prices = await fetchRecentCloses(ticker);
|
||||
input.account = input.account || account;
|
||||
input.technicalData = input.technicalData || {};
|
||||
input.technicalData.prices = input.technicalData.prices && input.technicalData.prices.length ? input.technicalData.prices : prices;
|
||||
} catch (e) {
|
||||
console.error("[queue] Failed to fetch Alpaca data, aborting job:", e);
|
||||
// Throw to mark the job as failed early
|
||||
throw new Error("Failed to fetch Alpaca data: " + String(e));
|
||||
}
|
||||
|
||||
let decision = await graph.propagate(ticker, input);
|
||||
|
||||
// Enrich executionPlan deterministically server-side before persisting
|
||||
try {
|
||||
const { enrichExecutionPlan, verifyExecutionPlanWithLLM } = await import("./execution");
|
||||
decision = enrichExecutionPlan(decision, input);
|
||||
if (process.env.OPENROUTER_API_KEY) {
|
||||
try {
|
||||
decision = await verifyExecutionPlanWithLLM(decision, input);
|
||||
} catch (e) {
|
||||
console.warn("[queue] LLM verification failed:", e);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[queue] Failed to enrich execution plan:", e);
|
||||
}
|
||||
|
||||
await db.stock.upsert({
|
||||
where: { ticker },
|
||||
create: {
|
||||
ticker,
|
||||
lastDecision: decision.action as string,
|
||||
lastExplanation: (decision as any).reasoning || null,
|
||||
lastExecutionPlan: decision.executionPlan ? JSON.stringify(decision.executionPlan) : null,
|
||||
lastJobId: job.id?.toString(),
|
||||
},
|
||||
update: {
|
||||
lastDecision: decision.action as string,
|
||||
lastExplanation: (decision as any).reasoning || null,
|
||||
lastExecutionPlan: decision.executionPlan ? JSON.stringify(decision.executionPlan) : null,
|
||||
lastJobId: job.id?.toString(),
|
||||
},
|
||||
});
|
||||
|
||||
console.log("[queue] Job complete and saved for", ticker);
|
||||
return decision;
|
||||
},
|
||||
{ connection: redis }
|
||||
);
|
||||
|
||||
enqueueAnalyze = async (ticker: string, input: any) => {
|
||||
const job = await analyzeQueue.add("analyze", { ticker, input });
|
||||
return job.id?.toString();
|
||||
};
|
||||
|
||||
getJob = async (jobId: string) => {
|
||||
const job = await analyzeQueue.getJob(jobId);
|
||||
if (!job) return null;
|
||||
const state = await job.getState();
|
||||
const failedReason = job.failedReason || null;
|
||||
const returnValue = job.returnvalue || null;
|
||||
return { id: job.id, state, failedReason, returnValue };
|
||||
};
|
||||
|
||||
listRecentJobs = async (ticker?: string, limit = 50) => {
|
||||
const jobs = await analyzeQueue.getJobs(["waiting", "active", "completed", "failed", "delayed"], 0, limit - 1);
|
||||
const mapped = await Promise.all(jobs.map(async (j: any) => ({ id: j.id, name: j.name, data: j.data, state: await j.getState(), returnValue: j.returnvalue || null })));
|
||||
if (ticker) return mapped.filter((j: any) => j.data?.ticker === ticker);
|
||||
return mapped;
|
||||
};
|
||||
|
||||
cancelJob = async (jobId: string) => {
|
||||
try {
|
||||
const job = await analyzeQueue.getJob(jobId);
|
||||
if (!job) return false;
|
||||
const state = await job.getState();
|
||||
if (state === "waiting" || state === "delayed") {
|
||||
await job.remove();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (err) {
|
||||
console.error("cancelJob error:", err);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
} else {
|
||||
// In-process fallback queue for environments without Redis (dev/tests)
|
||||
type Job = { id: string; ticker: string; input: any; state: "queued" | "processing" | "completed" | "failed"; result?: any; failedReason?: string };
|
||||
const queue: Job[] = [];
|
||||
const jobsById: Record<string, Job> = {};
|
||||
let processing = false;
|
||||
|
||||
function makeId() {
|
||||
return `${Date.now()}-${Math.floor(Math.random() * 100000)}`;
|
||||
}
|
||||
|
||||
enqueueAnalyze = (ticker: string, input: any) => {
|
||||
const id = makeId();
|
||||
const job: Job = { id, ticker, input, state: "queued" };
|
||||
queue.push(job);
|
||||
jobsById[id] = job;
|
||||
if (!processing) processQueue().catch((e) => console.error("inproc queue error:", e));
|
||||
return id;
|
||||
};
|
||||
|
||||
listRecentJobs = async (ticker?: string, limit = 50) => {
|
||||
const items = Object.values(jobsById).slice(-limit).reverse().map((j) => ({ id: j.id, data: { ticker: j.ticker }, state: j.state, returnValue: j.result || null }));
|
||||
if (ticker) return items.filter((it) => it.data?.ticker === ticker);
|
||||
return items;
|
||||
};
|
||||
|
||||
cancelJob = async (jobId: string) => {
|
||||
const job = jobsById[jobId];
|
||||
if (!job) return false;
|
||||
// If queued but not yet processing, remove from queue
|
||||
if (job.state === "queued") {
|
||||
const idx = queue.findIndex((q) => q.id === jobId);
|
||||
if (idx !== -1) queue.splice(idx, 1);
|
||||
job.state = "failed";
|
||||
job.failedReason = "cancelled";
|
||||
return true;
|
||||
}
|
||||
// Can't cancel if already processing/completed/failed
|
||||
return false;
|
||||
};
|
||||
|
||||
async function processQueue() {
|
||||
processing = true;
|
||||
while (queue.length > 0) {
|
||||
const job = queue.shift()!;
|
||||
job.state = "processing";
|
||||
try {
|
||||
const apiKey = process.env.OPENROUTER_API_KEY;
|
||||
if (!apiKey || apiKey === "your_openrouter_api_key_here") {
|
||||
const mockDecision = { action: "hold", confidence: 0.6, reasoning: `${job.ticker} analysis - Mock (inproc)` };
|
||||
job.result = mockDecision;
|
||||
job.state = "completed";
|
||||
await db.stock.upsert({
|
||||
where: { ticker: job.ticker },
|
||||
create: { ticker: job.ticker, lastDecision: mockDecision.action, lastExplanation: mockDecision.reasoning, lastJobId: job.id },
|
||||
update: { lastDecision: mockDecision.action, lastExplanation: mockDecision.reasoning, lastJobId: job.id },
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const client = new OpenRouterClient(process.env.OPENROUTER_API_KEY as string);
|
||||
const graph = new TradingGraph(client);
|
||||
// Fetch latest Alpaca account and prices; abort job if unavailable so work runs on fresh data
|
||||
try {
|
||||
const account = await fetchAccount();
|
||||
const prices = await fetchRecentCloses(job.ticker);
|
||||
job.input = job.input || {};
|
||||
job.input.account = job.input.account || account;
|
||||
job.input.technicalData = job.input.technicalData || {};
|
||||
job.input.technicalData.prices = job.input.technicalData.prices && job.input.technicalData.prices.length ? job.input.technicalData.prices : prices;
|
||||
} catch (e) {
|
||||
console.error("[inproc queue] Failed to fetch Alpaca data, aborting job:", e);
|
||||
// throw so the outer catch marks job as failed
|
||||
throw new Error("Failed to fetch Alpaca data: " + String(e));
|
||||
}
|
||||
|
||||
let decision = await graph.propagate(job.ticker, job.input);
|
||||
|
||||
// Enrich executionPlan deterministically server-side before persisting
|
||||
try {
|
||||
const { enrichExecutionPlan, verifyExecutionPlanWithLLM } = await import("./execution");
|
||||
decision = enrichExecutionPlan(decision, job.input);
|
||||
if (process.env.OPENROUTER_API_KEY) {
|
||||
try {
|
||||
decision = await verifyExecutionPlanWithLLM(decision, job.input);
|
||||
} catch (e) {
|
||||
console.warn("[inproc queue] LLM verification failed:", e);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[inproc queue] Failed to enrich execution plan:", e);
|
||||
}
|
||||
|
||||
job.result = decision;
|
||||
job.state = "completed";
|
||||
await db.stock.upsert({
|
||||
where: { ticker: job.ticker },
|
||||
create: {
|
||||
ticker: job.ticker,
|
||||
lastDecision: decision.action as string,
|
||||
lastExplanation: (decision as any).reasoning || null,
|
||||
lastExecutionPlan: decision.executionPlan ? JSON.stringify(decision.executionPlan) : null,
|
||||
lastJobId: job.id,
|
||||
},
|
||||
update: {
|
||||
lastDecision: decision.action as string,
|
||||
lastExplanation: (decision as any).reasoning || null,
|
||||
lastExecutionPlan: decision.executionPlan ? JSON.stringify(decision.executionPlan) : null,
|
||||
lastJobId: job.id,
|
||||
},
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error("[inproc queue] job failed:", err);
|
||||
job.state = "failed";
|
||||
job.failedReason = err?.message || String(err);
|
||||
}
|
||||
}
|
||||
processing = false;
|
||||
}
|
||||
|
||||
getJob = async (jobId: string) => {
|
||||
const job = jobsById[jobId];
|
||||
if (!job) return null;
|
||||
return { id: job.id, state: job.state, failedReason: job.failedReason || null, returnValue: job.result || null };
|
||||
};
|
||||
}
|
||||
|
||||
export { enqueueAnalyze, getJob, listRecentJobs, cancelJob, analyzeQueue, worker };
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// app/lib/settings.server.ts
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import EventEmitter from 'events';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
type JSONValue = any;
|
||||
|
||||
class SettingsService extends EventEmitter {
|
||||
private cache: Map<string, JSONValue> = new Map();
|
||||
private initialized = false;
|
||||
|
||||
async init() {
|
||||
if (this.initialized) return;
|
||||
const rows = await prisma.appSetting.findMany();
|
||||
rows.forEach(r => {
|
||||
try {
|
||||
this.cache.set(r.key, JSON.parse(r.value));
|
||||
} catch (e) {
|
||||
// fall back to raw string if parse fails
|
||||
this.cache.set(r.key, r.value);
|
||||
}
|
||||
});
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
async get(key: string) {
|
||||
if (!this.initialized) await this.init();
|
||||
return this.cache.has(key) ? this.cache.get(key) : null;
|
||||
}
|
||||
|
||||
async set(key: string, value: JSONValue, updatedBy?: string) {
|
||||
if (!this.initialized) await this.init();
|
||||
const valueStr = typeof value === 'string' ? value : JSON.stringify(value);
|
||||
await prisma.appSetting.upsert({
|
||||
where: { key },
|
||||
update: { value: valueStr, updatedBy },
|
||||
create: { key, value: valueStr, updatedBy },
|
||||
});
|
||||
this.cache.set(key, value);
|
||||
this.emit('update', { key, value });
|
||||
return { key, value };
|
||||
}
|
||||
|
||||
subscribe(fn: (payload: { key: string; value: any }) => void) {
|
||||
this.on('update', fn);
|
||||
return () => this.off('update', fn);
|
||||
}
|
||||
}
|
||||
|
||||
export const settingsService = new SettingsService();
|
||||
@@ -8,8 +8,16 @@ export default [
|
||||
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/price-stream", "routes/api/price-stream.ts"),
|
||||
route("api/stocks", "routes/api/stocks/index.ts"),
|
||||
route("api/stocks/most-actives", "routes/api/stocks/most-actives.ts"),
|
||||
route("api/jobs", "routes/api/jobs/index.ts"),
|
||||
route("api/jobs/:jobId", "routes/api/jobs/$jobId/index.ts"),
|
||||
route("api/jobs/:jobId/cancel", "routes/api/jobs/$jobId/cancel.ts"),
|
||||
route("stocks", "routes/stocks.tsx"),
|
||||
route("stocks/:ticker", "routes/stocks.$ticker.tsx"),
|
||||
route("jobs/:jobId", "routes/jobs/$jobId.tsx"),
|
||||
route("analyze", "routes/analyze.tsx"),
|
||||
route("analyze/:ticker", "routes/analyze.ticker.tsx"),
|
||||
route("settings", "routes/settings.tsx"),
|
||||
] satisfies RouteConfig;
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
|
||||
// Mock lightweight-charts to avoid canvas in test environment
|
||||
vi.mock("lightweight-charts", () => ({
|
||||
createChart: () => ({
|
||||
timeScale: () => ({ applyOptions: () => {}, fitContent: () => {} }),
|
||||
addSeries: () => ({ setData: () => {} }),
|
||||
remove: () => {},
|
||||
}),
|
||||
CandlestickSeries: {},
|
||||
}));
|
||||
|
||||
import StockDetail from "../analyze.ticker";
|
||||
import { MemoryRouter } from "react-router";
|
||||
|
||||
vi.mock("react-router", async () => {
|
||||
const actual = await vi.importActual("react-router");
|
||||
return {
|
||||
...actual,
|
||||
useLoaderData: () => ({
|
||||
ticker: "AAPL",
|
||||
position: null,
|
||||
orders: [],
|
||||
bars: [],
|
||||
timeframe: "1D",
|
||||
range: "1M",
|
||||
}),
|
||||
useNavigate: () => () => {},
|
||||
useLocation: () => ({ pathname: `/analyze/AAPL`, search: "" }),
|
||||
};
|
||||
});
|
||||
|
||||
describe("StockDetail UI - executionPlan", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", vi.fn((url: string, opts?: any) => {
|
||||
if (url === "/api/analyze") {
|
||||
return Promise.resolve({ ok: true, json: async () => ({
|
||||
action: "sell",
|
||||
confidence: 0.9,
|
||||
reasoning: "Exit position",
|
||||
agentSignals: [],
|
||||
debateRounds: [],
|
||||
executionPlan: { amount: 25, riskManagement: { maxLossPercent: 2 }, takeProfit: 150 }
|
||||
}) });
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => ({}) });
|
||||
}));
|
||||
});
|
||||
|
||||
it("displays executionPlan when sell decision returned", async () => {
|
||||
render(<MemoryRouter><StockDetail /></MemoryRouter>);
|
||||
|
||||
const runButton = screen.getByRole("button", { name: /Run Trading Graph Analysis/i });
|
||||
fireEvent.click(runButton);
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/Execution Plan/i)).toBeInTheDocument());
|
||||
expect(screen.getByText(/Amount:/i)).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/25 shares/i).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText(/Take profit:/i).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText(/\$150/).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,35 @@
|
||||
import { useLoaderData, useNavigate, useLocation } from "react-router";
|
||||
/* TRADINGGRAPH related file */
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useLoaderData, useNavigate, useLocation, Link } from "react-router";
|
||||
import TradingViewChart from "../components/TradingViewChart";
|
||||
import Navbar from "../components/Navbar";
|
||||
import JobHistory from "../components/JobHistory";
|
||||
import { useMemo } from "react";
|
||||
import type { TradingDecision, AnalystReport, DebateRound } from "../types/agents";
|
||||
|
||||
export const meta = () => [{ title: "Stock Detail - AITrader" }];
|
||||
|
||||
// In-memory cache for Alpaca bars to avoid rate limiting
|
||||
// Key: "ticker-timeframe-range", Value: { bars, timestamp }
|
||||
const barsCache = new Map<string, { bars: any[]; timestamp: number }>();
|
||||
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
function getCachedBars(key: string): any[] | null {
|
||||
const entry = barsCache.get(key);
|
||||
if (entry && Date.now() - entry.timestamp < CACHE_TTL_MS) {
|
||||
return entry.bars;
|
||||
}
|
||||
if (entry) {
|
||||
barsCache.delete(key);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function setCachedBars(key: string, bars: any[]) {
|
||||
barsCache.set(key, { bars, timestamp: Date.now() });
|
||||
}
|
||||
|
||||
interface LoaderData {
|
||||
ticker: string;
|
||||
position: { qty: number; avg_entry_price: number; current_price: number; market_value: number; unrealized_pl: number } | null;
|
||||
@@ -11,9 +37,11 @@ interface LoaderData {
|
||||
bars: any[];
|
||||
timeframe: string;
|
||||
range: string;
|
||||
stockRecord?: any;
|
||||
}
|
||||
|
||||
const TIMEFRAMES = [
|
||||
{ value: "1Min", label: "1 Minute" },
|
||||
{ value: "1D", label: "1 Day" },
|
||||
{ value: "5Min", label: "5 Min" },
|
||||
{ value: "15Min", label: "15 Min" },
|
||||
@@ -46,6 +74,7 @@ export async function loader({ params, request }: { params: { ticker: string };
|
||||
let position = null;
|
||||
let orders = [];
|
||||
let bars = [];
|
||||
let stockRecord: any = null;
|
||||
|
||||
try {
|
||||
// Fetch positions
|
||||
@@ -58,22 +87,161 @@ export async function loader({ params, request }: { params: { ticker: string };
|
||||
const ordersData = ordRes.ok ? await ordRes.json() : { orders: [] };
|
||||
orders = ordersData.orders?.filter((o: any) => o.symbol === ticker) || [];
|
||||
|
||||
// Fetch bars for chart with timeframe and range
|
||||
// Fetch bars for chart with timeframe and range (cached for 5 min)
|
||||
const barsCacheKey = `${ticker}-${timeframe}-${range}`;
|
||||
const cachedBars = getCachedBars(barsCacheKey);
|
||||
if (cachedBars) {
|
||||
bars = cachedBars;
|
||||
} else {
|
||||
const barsRes = await fetch(`${baseUrl}/api/alpaca/quote/${ticker}?timeframe=${timeframe}&range=${range}`);
|
||||
const barsData = barsRes.ok ? await barsRes.json() : null;
|
||||
bars = barsData?.bars || [];
|
||||
if (bars.length > 0) {
|
||||
setCachedBars(barsCacheKey, bars);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch stock record (to get lastJobId)
|
||||
try {
|
||||
const stockRes = await fetch(`${baseUrl}/api/stocks`);
|
||||
if (stockRes.ok) {
|
||||
const list = await stockRes.json();
|
||||
stockRecord = list.find((s: any) => s.ticker === ticker) || null;
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`analyze/${ticker}: loader error`, err);
|
||||
}
|
||||
|
||||
return Response.json({ ticker, position, orders, bars, timeframe, range });
|
||||
return Response.json({ ticker, position, orders, bars, timeframe, range, stockRecord });
|
||||
}
|
||||
|
||||
export default function StockDetail() {
|
||||
const { ticker, position, orders, bars, timeframe, range } = useLoaderData() as LoaderData;
|
||||
const { ticker, position, orders, bars, timeframe, range, stockRecord } = useLoaderData() as LoaderData;
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const [analysisLoading, setAnalysisLoading] = useState(false);
|
||||
const [analystReports, setAnalystReports] = useState<AnalystReport[]>([]);
|
||||
const [debateRounds, setDebateRounds] = useState<DebateRound[]>([]);
|
||||
const [decision, setDecision] = useState<TradingDecision | null>(null);
|
||||
const [showAnalysts, setShowAnalysts] = useState(false);
|
||||
const [showDebate, setShowDebate] = useState(false);
|
||||
const [jobStatus, setJobStatus] = useState<any>(null);
|
||||
const [jobPolling, setJobPolling] = useState(false);
|
||||
const [showTradingSummary, setShowTradingSummary] = useState(true);
|
||||
|
||||
// Cache key for this ticker
|
||||
const cacheKey = `tradinggraph-${ticker}`;
|
||||
|
||||
// Parsed last execution plan if present on stockRecord
|
||||
const lastExecutionPlan = useMemo(() => {
|
||||
try {
|
||||
return stockRecord?.lastExecutionPlan ? JSON.parse(stockRecord.lastExecutionPlan) : null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}, [stockRecord]);
|
||||
|
||||
// Load cached results on mount
|
||||
useEffect(() => {
|
||||
const cached = sessionStorage.getItem(cacheKey);
|
||||
if (cached) {
|
||||
try {
|
||||
const data = JSON.parse(cached);
|
||||
if (data.analystReports) setAnalystReports(data.analystReports);
|
||||
if (data.debateRounds) setDebateRounds(data.debateRounds);
|
||||
if (data.decision) setDecision(data.decision);
|
||||
} catch (e) {
|
||||
console.error("Failed to parse cached trading graph data:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// If stock record contains a job id, start polling job status
|
||||
if (stockRecord?.lastJobId) {
|
||||
setJobPolling(true);
|
||||
let cancelled = false;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let currentController: AbortController | null = null;
|
||||
|
||||
const poll = async () => {
|
||||
// abort previous fetch if any
|
||||
if (currentController) {
|
||||
try { currentController.abort(); } catch (e) {}
|
||||
}
|
||||
currentController = new AbortController();
|
||||
try {
|
||||
const res = await fetch(`/api/jobs/${stockRecord.lastJobId}`, { signal: currentController.signal });
|
||||
if (!res.ok) {
|
||||
if (!cancelled) timer = setTimeout(poll, 1000);
|
||||
return;
|
||||
}
|
||||
const j = await res.json();
|
||||
if (cancelled) return;
|
||||
setJobStatus(j);
|
||||
if (j.state === "completed" || j.state === "failed") {
|
||||
setJobPolling(false);
|
||||
cancelled = true;
|
||||
return;
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e?.name !== 'AbortError') console.warn("Failed to poll job status:", e);
|
||||
} finally {
|
||||
if (!cancelled) timer = setTimeout(poll, 1000);
|
||||
}
|
||||
};
|
||||
poll();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
if (currentController) {
|
||||
try { currentController.abort(); } catch (e) {}
|
||||
}
|
||||
};
|
||||
}
|
||||
}, [cacheKey, stockRecord]);
|
||||
|
||||
// Price stream broker: uses EventSource (SSE) to receive live prices and exposes subscribe(cb)->unsubscribe
|
||||
const [priceStream, setPriceStream] = useState<any>(null);
|
||||
useEffect(() => {
|
||||
let es: EventSource | null = null;
|
||||
let listeners: ((p: number) => void)[] = [];
|
||||
if (typeof window !== "undefined" && typeof EventSource !== "undefined") {
|
||||
try {
|
||||
es = new EventSource(`/api/price-stream?ticker=${encodeURIComponent(ticker)}&timeframe=${encodeURIComponent(timeframe)}`);
|
||||
es.onmessage = (e) => {
|
||||
try {
|
||||
const d = JSON.parse(e.data);
|
||||
if (d?.price != null) {
|
||||
listeners.forEach((cb) => cb(d.price));
|
||||
}
|
||||
} catch (err) {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
es.onerror = (err) => {
|
||||
console.warn("priceStream EventSource error", err);
|
||||
};
|
||||
|
||||
const streamObj = {
|
||||
subscribe(cb: (p: number) => void) {
|
||||
listeners.push(cb);
|
||||
return () => { listeners = listeners.filter((c) => c !== cb); };
|
||||
},
|
||||
};
|
||||
setPriceStream(streamObj);
|
||||
} catch (e) {
|
||||
console.warn("Failed to create price EventSource", e);
|
||||
}
|
||||
}
|
||||
return () => {
|
||||
try { if (es) es.close(); } catch (e) {}
|
||||
setPriceStream(null);
|
||||
};
|
||||
}, [ticker]);
|
||||
|
||||
const updateParams = (newTimeframe: string, newRange: string) => {
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
searchParams.set("timeframe", newTimeframe);
|
||||
@@ -81,6 +249,118 @@ export default function StockDetail() {
|
||||
navigate(`${location.pathname}?${searchParams.toString()}`, { replace: true });
|
||||
};
|
||||
|
||||
const runTradingGraph = async () => {
|
||||
setAnalysisLoading(true);
|
||||
setAnalystReports([]);
|
||||
setDebateRounds([]);
|
||||
setDecision(null);
|
||||
|
||||
try {
|
||||
// Ensure ticker is saved in DB before analysis
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append("ticker", ticker);
|
||||
await fetch("/api/stocks", { method: "POST", body: fd });
|
||||
} catch (e) {
|
||||
console.warn("Failed to ensure ticker saved:", e);
|
||||
}
|
||||
|
||||
// Enqueue background job for analysis so it runs reliably
|
||||
const res = await fetch("/api/analyze", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ticker, background: true }),
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (res.status === 202 && data.jobId) {
|
||||
// Job queued - persist lastJobId and start polling
|
||||
const jobId = data.jobId;
|
||||
try {
|
||||
const fd2 = new FormData();
|
||||
fd2.append("ticker", ticker);
|
||||
fd2.append("lastJobId", jobId);
|
||||
await fetch("/api/stocks", { method: "POST", body: fd2 });
|
||||
} catch (e) {
|
||||
console.warn("Failed to save lastJobId to DB:", e);
|
||||
}
|
||||
|
||||
setJobPolling(true);
|
||||
setJobStatus({ id: jobId, state: "queued" });
|
||||
let cancelled = false;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let currentController: AbortController | null = null;
|
||||
|
||||
const poll = async () => {
|
||||
if (currentController) {
|
||||
try { currentController.abort(); } catch (e) {}
|
||||
}
|
||||
currentController = new AbortController();
|
||||
try {
|
||||
const jr = await fetch(`/api/jobs/${jobId}`, { signal: currentController.signal });
|
||||
if (!jr.ok) {
|
||||
if (!cancelled) timer = setTimeout(poll, 1000);
|
||||
return;
|
||||
}
|
||||
const j = await jr.json();
|
||||
if (cancelled) return;
|
||||
setJobStatus(j);
|
||||
if (j.state === "completed" || j.state === "failed") {
|
||||
setJobPolling(false);
|
||||
cancelled = true;
|
||||
// Optionally refresh persisted stock record here
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e?.name !== 'AbortError') console.warn("Failed to poll job status:", e);
|
||||
} finally {
|
||||
if (!cancelled) timer = setTimeout(poll, 1000);
|
||||
}
|
||||
};
|
||||
poll();
|
||||
|
||||
return; // background job started
|
||||
}
|
||||
|
||||
// Fallback: synchronous analysis response (older behavior)
|
||||
if (!res.ok) throw new Error(data.error || "Analysis failed");
|
||||
|
||||
const reports = data.agentSignals.map((sig: any) => ({
|
||||
analyst: sig.agent,
|
||||
signal: sig,
|
||||
report: sig.reasoning,
|
||||
}));
|
||||
const debates = data.debateRounds || [];
|
||||
|
||||
setAnalystReports(reports);
|
||||
setDebateRounds(debates);
|
||||
setDecision(data);
|
||||
|
||||
// Save last decision/explanation to DB
|
||||
try {
|
||||
const fd3 = new FormData();
|
||||
fd3.append("ticker", ticker);
|
||||
fd3.append("lastDecision", data.action ?? "");
|
||||
fd3.append("lastExplanation", data.reasoning ?? "");
|
||||
if (data.executionPlan) fd3.append("lastExecutionPlan", JSON.stringify(data.executionPlan));
|
||||
await fetch("/api/stocks", { method: "POST", body: fd3 });
|
||||
} catch (e) {
|
||||
console.warn("Failed to save decision to DB:", e);
|
||||
}
|
||||
|
||||
// Cache the results
|
||||
sessionStorage.setItem(cacheKey, JSON.stringify({
|
||||
analystReports: reports,
|
||||
debateRounds: debates,
|
||||
decision: data,
|
||||
timestamp: Date.now(),
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error("Analysis error:", err);
|
||||
} finally {
|
||||
setAnalysisLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Convert Alpaca bars to TradingView format
|
||||
// Keep full timestamp for intraday, use date-only for daily
|
||||
// Sort bars by timestamp to ensure ascending order
|
||||
@@ -147,7 +427,125 @@ export default function StockDetail() {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<TradingViewChart ticker={ticker} data={chartData} />
|
||||
<TradingViewChart ticker={ticker} data={chartData} timeframe={timeframe} priceStream={priceStream} />
|
||||
|
||||
<button
|
||||
onClick={runTradingGraph}
|
||||
disabled={analysisLoading}
|
||||
className="mt-4 bg-purple-600 text-white px-6 py-2.5 rounded-lg font-medium hover:bg-purple-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{analysisLoading ? "Running Trading Graph..." : "Run Trading Graph Analysis"}
|
||||
</button>
|
||||
|
||||
{/* Job status link */}
|
||||
{stockRecord?.lastJobId && (
|
||||
<div className="mt-3 text-sm text-gray-600">
|
||||
Background job: <Link to={`/jobs/${stockRecord.lastJobId}`} className="text-blue-600 hover:underline">{stockRecord.lastJobId}</Link>
|
||||
{jobStatus && (
|
||||
<span className="ml-3">Status: <strong>{jobStatus.state}</strong></span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Job history */}
|
||||
<JobHistory ticker={ticker} />
|
||||
|
||||
{/* Show TradingGraph summary when background job completes (collapsible) */}
|
||||
{jobStatus?.state === 'completed' && jobStatus?.returnValue && (
|
||||
<div className="mt-6 bg-white rounded-xl shadow-lg p-6 border border-gray-200">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-xl font-bold text-gray-900">TradingGraph Summary</h2>
|
||||
<button
|
||||
onClick={() => setShowTradingSummary((s) => !s)}
|
||||
aria-expanded={showTradingSummary}
|
||||
className="text-sm text-gray-500 hover:text-gray-700 focus:outline-none"
|
||||
>
|
||||
{showTradingSummary ? 'Hide' : 'Show'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showTradingSummary && (
|
||||
<div>
|
||||
{jobStatus.returnValue.action && (
|
||||
<div className="mb-3">
|
||||
<div className="text-sm text-gray-600">Decision</div>
|
||||
<div className="text-base font-medium">
|
||||
<span className={
|
||||
jobStatus.returnValue.action === 'buy' ? 'text-green-600' : jobStatus.returnValue.action === 'sell' ? 'text-red-600' : 'text-gray-800'
|
||||
}>{String(jobStatus.returnValue.action).toUpperCase()}</span>
|
||||
<span className="text-sm text-gray-500 ml-2">(confidence: {Number(jobStatus.returnValue.confidence ?? 0).toFixed(2)})</span>
|
||||
</div>
|
||||
{jobStatus.returnValue.reasoning && <div className="mt-2 text-sm text-gray-700">{jobStatus.returnValue.reasoning}</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{Array.isArray(jobStatus.returnValue.agentSignals) && jobStatus.returnValue.agentSignals.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<div className="text-sm text-gray-600">Analyst Signals</div>
|
||||
<div className="mt-2 space-y-2">
|
||||
{jobStatus.returnValue.agentSignals.map((s: any, i: number) => (
|
||||
<div key={i} className="p-2 bg-gray-50 rounded border border-gray-100 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="font-medium capitalize text-gray-900">{s.agent}</div>
|
||||
<div className={
|
||||
s.signal === 'bullish' ? 'text-green-600 text-sm' : s.signal === 'bearish' ? 'text-red-600 text-sm' : 'text-gray-600 text-sm'
|
||||
}>{s.signal} ({Math.round((s.confidence ?? 0) * 100)}%)</div>
|
||||
</div>
|
||||
{s.reasoning && <div className="mt-1 text-sm text-gray-700">{s.reasoning}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{jobStatus.returnValue.executionPlan && (
|
||||
<div className="mb-3">
|
||||
<div className="text-sm text-gray-600">Execution Plan</div>
|
||||
<div className="mt-2 text-sm text-gray-700">
|
||||
{jobStatus.returnValue.executionPlan.amount != null && (<div>Amount: <strong>{jobStatus.returnValue.executionPlan.amount}</strong></div>)}
|
||||
{jobStatus.returnValue.executionPlan.takeProfit != null && (<div>Take profit: <strong>${jobStatus.returnValue.executionPlan.takeProfit}</strong></div>)}
|
||||
{jobStatus.returnValue.executionPlan.stopLoss != null && (<div>Stop loss: <strong>${jobStatus.returnValue.executionPlan.stopLoss}</strong></div>)}
|
||||
{jobStatus.returnValue.executionPlan.riskManagement?.maxLossPercent != null && (<div>Risk: <strong>{jobStatus.returnValue.executionPlan.riskManagement.maxLossPercent}%</strong></div>)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3">
|
||||
<a href={`/jobs/${jobStatus.id}`} className="text-sm text-blue-600 hover:underline">View job details</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Last persisted decision (if no live decision) */}
|
||||
{!decision && stockRecord?.lastDecision && (
|
||||
<div className="mt-3 bg-white rounded-lg p-3 border border-gray-200 text-gray-900">
|
||||
<h4 className="text-sm font-medium">Last Saved Suggestion</h4>
|
||||
<div className="mt-2 text-sm">
|
||||
<div>Action: <strong className={stockRecord.lastDecision === 'buy' ? 'text-green-600' : stockRecord.lastDecision === 'sell' ? 'text-red-600' : 'text-gray-800'}>{stockRecord.lastDecision?.toUpperCase()}</strong></div>
|
||||
{lastExecutionPlan && (
|
||||
<div className="mt-2 text-sm text-gray-700">
|
||||
{lastExecutionPlan.amount != null && (<div>Amount: <strong>{lastExecutionPlan.amount}</strong></div>)}
|
||||
{lastExecutionPlan.takeProfit != null && (<div>Take profit: <strong>${lastExecutionPlan.takeProfit}</strong></div>)}
|
||||
{lastExecutionPlan.stopLoss != null && (<div>Stop loss: <strong>${lastExecutionPlan.stopLoss}</strong></div>)}
|
||||
{lastExecutionPlan.riskManagement?.maxLossPercent != null && (<div>Risk: <strong>{lastExecutionPlan.riskManagement.maxLossPercent}%</strong></div>)}
|
||||
|
||||
{/* LLM review metadata if present */}
|
||||
{lastExecutionPlan._llmReview && (
|
||||
<div className="mt-3 border-t pt-3 text-sm text-gray-700">
|
||||
<h5 className="text-sm font-medium text-gray-800">LLM Review</h5>
|
||||
<div className="mt-1">Approved: <strong className={lastExecutionPlan._llmReview.approved ? 'text-green-600' : 'text-red-600'}>{lastExecutionPlan._llmReview.approved ? 'Yes' : 'No'}</strong></div>
|
||||
{lastExecutionPlan._llmReview.notes && (
|
||||
<div className="mt-1">Notes: <span className="font-medium text-gray-800">{lastExecutionPlan._llmReview.notes}</span></div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 bg-white rounded-xl shadow-lg p-6 border border-gray-200">
|
||||
@@ -182,6 +580,168 @@ export default function StockDetail() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(analystReports.length > 0 || debateRounds.length > 0 || decision) && (
|
||||
<div className="mt-6 bg-white rounded-xl shadow-lg p-6 border border-gray-200">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-bold text-gray-900">Trading Graph Workflow</h2>
|
||||
{sessionStorage.getItem(cacheKey) && (
|
||||
<span className="text-xs text-gray-500 bg-gray-100 px-2 py-1 rounded">
|
||||
Cached results
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Analysts Step - Collapsible */}
|
||||
{analystReports.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<button
|
||||
onClick={() => setShowAnalysts(!showAnalysts)}
|
||||
className="w-full flex items-center justify-between p-3 bg-gray-50 rounded-lg border border-gray-200 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<h3 className="text-lg font-semibold text-gray-800 flex items-center gap-2">
|
||||
<span className="bg-blue-100 text-blue-800 w-6 h-6 rounded-full flex items-center justify-center text-sm">1</span>
|
||||
Analyst Reports
|
||||
</h3>
|
||||
<span className="text-gray-500">{showAnalysts ? "▼" : "▶"}</span>
|
||||
</button>
|
||||
{showAnalysts && (
|
||||
<div className="mt-3 space-y-3 pl-4">
|
||||
{analystReports.map((report, i) => (
|
||||
<div key={i} className="bg-gray-50 rounded-lg p-3 border border-gray-200">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="font-medium text-gray-900 capitalize">{report.analyst}</span>
|
||||
<span className={`text-sm font-medium ${
|
||||
report.signal?.signal === "bullish" ? "text-green-600" :
|
||||
report.signal?.signal === "bearish" ? "text-red-600" : "text-gray-600"
|
||||
}`}>
|
||||
{report.signal?.signal} ({(report.signal?.confidence * 100).toFixed(0)}%)
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">{report.signal?.reasoning}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Debate Step - Collapsible */}
|
||||
{debateRounds.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<button
|
||||
onClick={() => setShowDebate(!showDebate)}
|
||||
className="w-full flex items-center justify-between p-3 bg-gray-50 rounded-lg border border-gray-200 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<h3 className="text-lg font-semibold text-gray-800 flex items-center gap-2">
|
||||
<span className="bg-purple-100 text-purple-800 w-6 h-6 rounded-full flex items-center justify-center text-sm">2</span>
|
||||
Research Debate
|
||||
</h3>
|
||||
<span className="text-gray-500">{showDebate ? "▼" : "▶"}</span>
|
||||
</button>
|
||||
{showDebate && (
|
||||
<div className="mt-3 space-y-3 pl-4">
|
||||
{debateRounds.map((round, i) => (
|
||||
<div key={i} className="border border-gray-200 rounded-lg p-3">
|
||||
<div className="mb-2">
|
||||
<span className="text-green-600 font-medium text-sm">Bullish View:</span>
|
||||
<p className="text-sm text-gray-700 mt-1">{round.bullishView}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-red-600 font-medium text-sm">Bearish View:</span>
|
||||
<p className="text-sm text-gray-700 mt-1">{round.bearishView}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Decision Step - Always Visible */}
|
||||
{decision && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-3 flex items-center gap-2">
|
||||
<span className="bg-green-100 text-green-800 w-6 h-6 rounded-full flex items-center justify-center text-sm">3</span>
|
||||
Final Decision
|
||||
</h3>
|
||||
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
|
||||
<div className="flex items-center gap-4 mb-3">
|
||||
<span className="text-gray-600">Action:</span>
|
||||
<span className={`font-bold text-lg ${
|
||||
decision.action === "buy" ? "text-green-600" :
|
||||
decision.action === "sell" ? "text-red-600" : "text-gray-600"
|
||||
}`}>
|
||||
{decision.action.toUpperCase()}
|
||||
</span>
|
||||
<span className="text-gray-600">Confidence:</span>
|
||||
<span className="font-medium">{(decision.confidence * 100).toFixed(0)}%</span>
|
||||
</div>
|
||||
{decision.reasoning && (
|
||||
<div>
|
||||
<p className="text-gray-800">{decision.reasoning}</p>
|
||||
{decision.executionPlan && (
|
||||
<div className="mt-3 border-t pt-3">
|
||||
<h4 className="text-sm font-medium text-gray-800 mb-2">Execution Plan</h4>
|
||||
<div className="text-sm text-gray-700 space-y-1">
|
||||
<div>Amount: <span className="font-medium">{decision.executionPlan.amount} shares</span></div>
|
||||
{decision.executionPlan.takeProfit != null && (
|
||||
<div>Take profit: <span className="font-medium">${decision.executionPlan.takeProfit}</span></div>
|
||||
)}
|
||||
{decision.executionPlan.stopLoss != null && (
|
||||
<div>Stop loss: <span className="font-medium">${decision.executionPlan.stopLoss}</span></div>
|
||||
)}
|
||||
{decision.executionPlan.riskManagement?.maxLossPercent != null && (
|
||||
<div>Risk management: <span className="font-medium">{decision.executionPlan.riskManagement.maxLossPercent}% max loss</span></div>
|
||||
)}
|
||||
{decision.executionPlan.riskManagement?.method && (
|
||||
<div>Method: <span className="font-medium">{decision.executionPlan.riskManagement.method}</span></div>
|
||||
)}
|
||||
{decision.executionPlan.note && (
|
||||
<div>Note: <span className="font-medium">{decision.executionPlan.note}</span></div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Order suggestion summary */}
|
||||
<div className="mt-3 bg-gray-50 rounded-lg p-3 border border-gray-200">
|
||||
<h5 className="text-sm font-medium text-gray-800">Order Suggestion</h5>
|
||||
<div className="text-sm text-gray-700 mt-2">
|
||||
<div>
|
||||
<span className="font-medium">{decision.action.toUpperCase()}</span>
|
||||
<span className="ml-2">{decision.executionPlan.amount} (shares)</span>
|
||||
{decision.executionPlan.takeProfit != null && (
|
||||
<span> — Take profit: ${decision.executionPlan.takeProfit}</span>
|
||||
)}
|
||||
{decision.executionPlan.stopLoss != null && (
|
||||
<span> — Stop loss: ${decision.executionPlan.stopLoss}</span>
|
||||
)}
|
||||
</div>
|
||||
{decision.executionPlan.riskManagement?.maxLossPercent != null && (
|
||||
<div className="text-xs text-gray-500">Risk: {decision.executionPlan.riskManagement.maxLossPercent}% max loss</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* LLM Review (if provided) */}
|
||||
{decision.executionPlan._llmReview && (
|
||||
<div className="mt-3 border-t pt-3 text-sm text-gray-700">
|
||||
<h5 className="text-sm font-medium text-gray-800">LLM Review</h5>
|
||||
<div className="mt-1">Approved: <strong className={decision.executionPlan._llmReview.approved ? 'text-green-600' : 'text-red-600'}>{decision.executionPlan._llmReview.approved ? 'Yes' : 'No'}</strong></div>
|
||||
{decision.executionPlan._llmReview.notes && (
|
||||
<div className="mt-1">Notes: <span className="font-medium text-gray-800">{decision.executionPlan._llmReview.notes}</span></div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</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 ? (
|
||||
|
||||
@@ -297,8 +297,7 @@ export default function Analyze() {
|
||||
/>
|
||||
<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"
|
||||
className="bg-blue-600 text-white px-6 py-2.5 rounded-lg font-medium hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Add Stock
|
||||
</button>
|
||||
@@ -353,8 +352,14 @@ export default function Analyze() {
|
||||
{stock.analysis.action.toUpperCase()}
|
||||
</span>
|
||||
<div className="text-xs text-gray-500">
|
||||
Confidence: {(stock.analysis.confidence * 100).toFixed(0)}%
|
||||
{stock.analysis.confidence ? `Confidence: ${(stock.analysis.confidence * 100).toFixed(0)}%` : "Saved suggestion"}
|
||||
</div>
|
||||
{stock.analysis.executionPlan && (
|
||||
<div className="text-xs text-gray-700 mt-1">
|
||||
{stock.analysis.executionPlan.amount != null && (<div>Amount: <strong>{stock.analysis.executionPlan.amount}</strong></div>)}
|
||||
{stock.analysis.executionPlan.takeProfit != null && (<div>Take profit: <strong>${stock.analysis.executionPlan.takeProfit}</strong></div>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : stock.loading ? (
|
||||
<span className="text-blue-600">Analyzing...</span>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { test, expect } from 'vitest';
|
||||
import { settingsService } from '../../../../lib/settings.server';
|
||||
|
||||
test('settings API helper behavior', async () => {
|
||||
const key = 'api_test_' + Date.now();
|
||||
await settingsService.set(key, { foo: 'bar' }, 'test');
|
||||
const v = await settingsService.get(key);
|
||||
expect(v).toEqual({ foo: 'bar' });
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { settingsService } from '../../../../lib/settings.server';
|
||||
import { requireAdmin } from '../../../../lib/auth.server';
|
||||
|
||||
export async function action({ request, params }: { request: Request; params: any }) {
|
||||
await requireAdmin(request);
|
||||
const key = params.key as string;
|
||||
const body = await request.json();
|
||||
if (!key) return new Response('Missing key', { status: 400 });
|
||||
await settingsService.set(key, body.value, 'admin');
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { settingsService } from '../../../../lib/settings.server';
|
||||
import { requireAdmin } from '../../../../lib/auth.server';
|
||||
|
||||
export async function loader({ request }: { request: Request }) {
|
||||
await requireAdmin(request);
|
||||
await (settingsService as any).init?.();
|
||||
const entries: any[] = [];
|
||||
for (const key of (settingsService as any).cache.keys()) {
|
||||
entries.push({ key, value: await settingsService.get(key) });
|
||||
}
|
||||
return new Response(JSON.stringify(entries), { headers: { 'content-type': 'application/json' } });
|
||||
}
|
||||
|
||||
export async function action({ request }: { request: Request }) {
|
||||
await requireAdmin(request);
|
||||
const body = await request.json();
|
||||
if (!body || !body.key) return new Response('Missing key', { status: 400 });
|
||||
const created = await settingsService.set(body.key, body.value, 'admin');
|
||||
return new Response(JSON.stringify(created), { status: 201, headers: { 'content-type': 'application/json' } });
|
||||
}
|
||||
@@ -1,37 +1,9 @@
|
||||
import type { AlpacaAccount } from "../../../types";
|
||||
import Alpaca from "@alpacahq/alpaca-trade-api";
|
||||
import alpacaService from "../../../lib/alpacaClient";
|
||||
|
||||
const alpaca = new Alpaca({
|
||||
keyId: process.env.ALPACA_API_KEY!,
|
||||
secretKey: process.env.ALPACA_SECRET_KEY!,
|
||||
baseUrl: process.env.ALPACA_BASE_URL || "https://paper-api.alpaca.markets",
|
||||
dataBaseUrl: process.env.ALPACA_DATA_URL || "https://data.alpaca.markets",
|
||||
retryOnError: false,
|
||||
});
|
||||
|
||||
async function fetchAlpacaAccount(): Promise<AlpacaAccount> {
|
||||
export async function loader({ request }: { request: Request }) {
|
||||
try {
|
||||
console.log("Fetching Alpaca account with key:", process.env.ALPACA_API_KEY?.substring(0, 8) + "...");
|
||||
const account = await alpaca.getAccount();
|
||||
console.log("Alpaca account fetched successfully");
|
||||
return {
|
||||
cash: parseFloat(account.cash),
|
||||
buying_power: parseFloat(account.buying_power),
|
||||
portfolio_value: parseFloat(account.portfolio_value),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Alpaca API fetch error:", error);
|
||||
return {
|
||||
cash: 0,
|
||||
buying_power: 0,
|
||||
portfolio_value: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function loader() {
|
||||
try {
|
||||
const account = await fetchAlpacaAccount();
|
||||
const account = await alpacaService.fetchAccount();
|
||||
return Response.json(account);
|
||||
} catch (error) {
|
||||
console.error("Alpaca API error:", error);
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
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,
|
||||
});
|
||||
import alpacaService from "../../../lib/alpacaClient";
|
||||
|
||||
export async function loader({ request, params }: { request: Request; params: { ticker: string } }) {
|
||||
const ticker = params.ticker?.toUpperCase();
|
||||
@@ -19,13 +11,29 @@ export async function loader({ request, params }: { request: Request; params: {
|
||||
}
|
||||
|
||||
try {
|
||||
// Get latest trade for current price
|
||||
// Normalize timeframe to Alpaca API expected values
|
||||
function mapToAlpacaTimeframe(tf: string) {
|
||||
switch (tf) {
|
||||
case "1H":
|
||||
return "1Hour";
|
||||
case "1D":
|
||||
return "1Day";
|
||||
case "1W":
|
||||
case "1M":
|
||||
return "1Day"; // weekly/monthly UI ranges use daily bars
|
||||
default:
|
||||
return tf; // 1Min,5Min,15Min,30Min expected to be supported
|
||||
}
|
||||
}
|
||||
const alpacaTimeframe = mapToAlpacaTimeframe(timeframe);
|
||||
|
||||
// Get latest bar for current price (uses paper by default unless mode=live)
|
||||
let price = 0;
|
||||
try {
|
||||
const trade = await alpaca.getLatestTrade(ticker);
|
||||
price = (trade as { Price?: number }).Price || 0;
|
||||
const last = await alpacaService.fetchLatestBar(ticker, alpacaTimeframe);
|
||||
price = last ? (last.ClosePrice ?? last.c ?? 0) : 0;
|
||||
} catch (tradeErr) {
|
||||
console.error(`API quote/${ticker}: getLatestTrade failed`, tradeErr);
|
||||
console.error(`API quote/${ticker}: fetchLatestBar failed`, tradeErr);
|
||||
}
|
||||
|
||||
// Calculate start date based on range
|
||||
@@ -50,25 +58,16 @@ export async function loader({ request, params }: { request: Request; params: {
|
||||
startDate.setDate(startDate.getDate() - 30); // Default 30 days for intraday
|
||||
}
|
||||
|
||||
const barsOptions: any = { timeframe, limit: 1000 }; // High limit for time range
|
||||
if (!isIntraday && range !== "ALL") {
|
||||
barsOptions.start = startDate.toISOString().split('T')[0];
|
||||
} else if (!isIntraday) {
|
||||
const barsOptions: any = { limit: 1000 }; // High limit for time range
|
||||
// For daily/non-intraday queries pass just the date part (YYYY-MM-DD)
|
||||
if (!isIntraday) {
|
||||
barsOptions.start = startDate.toISOString().split('T')[0];
|
||||
} else {
|
||||
// For intraday, pass full ISO start to be precise
|
||||
barsOptions.start = startDate.toISOString();
|
||||
}
|
||||
|
||||
const bars = await alpaca.getBarsV2(ticker, barsOptions);
|
||||
|
||||
// Convert async generator to array
|
||||
// Alpaca v2 API returns AlpacaBar with capitalized property names
|
||||
const barsArray = [];
|
||||
try {
|
||||
for await (const bar of bars) {
|
||||
barsArray.push(bar);
|
||||
}
|
||||
} catch (genErr) {
|
||||
console.error(`API quote/${ticker}: error iterating bars`, genErr);
|
||||
}
|
||||
const barsArray = await alpacaService.fetchBars(ticker, alpacaTimeframe, barsOptions);
|
||||
|
||||
// Transform to chart format
|
||||
const transformedBars = barsArray.map((bar: any) => {
|
||||
@@ -77,18 +76,31 @@ export async function loader({ request, params }: { request: Request; params: {
|
||||
const high = typeof bar.HighPrice === 'number' ? bar.HighPrice : (typeof bar.h === 'number' ? bar.h : 0);
|
||||
const low = typeof bar.LowPrice === 'number' ? bar.LowPrice : (typeof bar.l === 'number' ? bar.l : 0);
|
||||
const close = typeof bar.ClosePrice === 'number' ? bar.ClosePrice : (typeof bar.c === 'number' ? bar.c : 0);
|
||||
const timestamp = bar.Timestamp ?? bar.t;
|
||||
const volume = typeof bar.Volume === 'number' ? bar.Volume : (typeof bar.v === 'number' ? bar.v : 0);
|
||||
|
||||
// Normalize timestamp to ISO string so client can parse reliably
|
||||
const rawTs = bar.Timestamp ?? bar.t ?? bar.T ?? bar.timestamp;
|
||||
let dateObj: Date | null = null;
|
||||
if (rawTs != null) {
|
||||
if (typeof rawTs === 'number') {
|
||||
// If it's likely in seconds (< 1e12) convert to ms
|
||||
const asMs = rawTs > 1e12 ? rawTs : rawTs * 1000;
|
||||
dateObj = new Date(asMs);
|
||||
} else {
|
||||
dateObj = new Date(rawTs);
|
||||
}
|
||||
}
|
||||
const iso = dateObj && !isNaN(dateObj.getTime()) ? dateObj.toISOString() : null;
|
||||
|
||||
return {
|
||||
t: timestamp,
|
||||
t: iso,
|
||||
o: open,
|
||||
h: high,
|
||||
l: low,
|
||||
c: close,
|
||||
v: volume,
|
||||
};
|
||||
}).filter((bar: any) => bar.o > 0 && bar.h > 0 && bar.l > 0 && bar.c > 0);
|
||||
}).filter((bar: any) => bar.o > 0 && bar.h > 0 && bar.l > 0 && bar.c > 0 && bar.t);
|
||||
|
||||
return Response.json({
|
||||
ticker,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { OpenRouterClient } from "../../lib/openrouter";
|
||||
import { TradingGraph } from "../../agents/tradingGraph";
|
||||
/* TRADINGGRAPH related file */
|
||||
|
||||
// Server-only imports are loaded dynamically inside the action to avoid client bundling issues
|
||||
|
||||
export async function action({ request }: { request: Request }) {
|
||||
console.log("[analyze] Request received:", request.method, request.url);
|
||||
@@ -15,6 +16,12 @@ export async function action({ request }: { request: Request }) {
|
||||
return Response.json({ error: "ticker is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Load server-only modules dynamically to prevent them from being included in client bundles
|
||||
const { OpenRouterClient } = await import("../../lib/openrouter");
|
||||
const { TradingGraph } = await import("../../agents/tradingGraph");
|
||||
const { db } = await import("../../lib/db.server");
|
||||
const { fetchAccount, fetchRecentCloses, fetchBars } = await import("../../lib/alpacaClient");
|
||||
|
||||
const apiKey = process.env.OPENROUTER_API_KEY;
|
||||
console.log("[analyze] API key configured:", !!apiKey, apiKey?.substring(0, 10) + "...");
|
||||
|
||||
@@ -55,25 +62,84 @@ export async function action({ request }: { request: Request }) {
|
||||
const client = new OpenRouterClient(apiKey);
|
||||
const graph = new TradingGraph(client);
|
||||
|
||||
// Fetch latest Alpaca account and recent prices; abort if unavailable
|
||||
let account: any = undefined;
|
||||
let prices: number[] = [];
|
||||
let recentBars: any[] = [];
|
||||
try {
|
||||
account = await fetchAccount();
|
||||
prices = await fetchRecentCloses(ticker);
|
||||
// Also fetch recent intraday bars to enable deterministic execution plan calculation
|
||||
try {
|
||||
recentBars = await fetchBars(ticker, '1Min', { limit: 200 });
|
||||
// derive prices from bars if available (prefer freshest closes)
|
||||
if (recentBars && recentBars.length) {
|
||||
prices = recentBars.map((b: any) => (typeof b.ClosePrice === 'number' ? b.ClosePrice : (typeof b.c === 'number' ? b.c : 0))).filter((p: number) => p > 0);
|
||||
}
|
||||
} catch (barErr) {
|
||||
console.warn('[analyze] Failed to fetch recent bars for deterministic execution plan:', barErr);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[analyze] Failed to fetch Alpaca data before analysis:", e);
|
||||
return Response.json({ error: "Failed to fetch Alpaca data: " + String(e) }, { status: 502 });
|
||||
}
|
||||
|
||||
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,
|
||||
prices,
|
||||
bars: recentBars,
|
||||
sma: 0,
|
||||
ema: 0,
|
||||
rsi: 0,
|
||||
macd: 0,
|
||||
},
|
||||
sentimentData: {
|
||||
headlines: [`${ticker} showing positive momentum`],
|
||||
source: "news" as const,
|
||||
},
|
||||
account,
|
||||
};
|
||||
|
||||
try {
|
||||
console.log("[analyze] Running trading graph...");
|
||||
const decision = await graph.propagate(ticker, input);
|
||||
console.log("[analyze] Decision received:", JSON.stringify(decision));
|
||||
|
||||
if (body.background) {
|
||||
// Enqueue background analyze job and return 202 immediately
|
||||
try {
|
||||
const { enqueueAnalyze } = await import("../../lib/queue");
|
||||
const jobId = await enqueueAnalyze(ticker, input);
|
||||
return Response.json({ status: "queued", jobId }, { status: 202 });
|
||||
} catch (enqueueErr) {
|
||||
console.error("[analyze] enqueue error:", enqueueErr);
|
||||
return Response.json({ error: "failed to enqueue" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
let decision = await graph.propagate(ticker, input);
|
||||
// Enrich executionPlan deterministically on server-side
|
||||
try {
|
||||
const { enrichExecutionPlan, verifyExecutionPlanWithLLM } = await import("../../lib/execution");
|
||||
decision = enrichExecutionPlan(decision, input);
|
||||
// Optionally ask LLM to verify/adjust the computed plan if API key is present
|
||||
if (process.env.OPENROUTER_API_KEY) {
|
||||
try {
|
||||
decision = await verifyExecutionPlanWithLLM(decision, input);
|
||||
} catch (e) {
|
||||
console.warn("LLM verification failed:", e);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Failed to enrich execution plan:", e);
|
||||
}
|
||||
|
||||
// Avoid logging potentially verbose debate rounds to server CLI
|
||||
try {
|
||||
const { debateRounds, ...decisionSafe } = decision as any;
|
||||
console.log("[analyze] Decision received (debate redacted):", JSON.stringify(decisionSafe));
|
||||
} catch (e) {
|
||||
console.log("[analyze] Decision received");
|
||||
}
|
||||
return Response.json(decision);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { cancelJob } from "../../../../lib/queue";
|
||||
|
||||
export async function action({ params }: { params: { jobId: string } }) {
|
||||
const jobId = params.jobId;
|
||||
if (!jobId) return Response.json({ error: "jobId required" }, { status: 400 });
|
||||
|
||||
try {
|
||||
const ok = await cancelJob(jobId);
|
||||
return Response.json({ cancelled: ok });
|
||||
} catch (err) {
|
||||
console.error("/api/jobs cancel error:", err);
|
||||
return Response.json({ error: "internal" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { getJob } from "../../../../lib/queue";
|
||||
|
||||
export async function loader({ params }: { params: { jobId: string } }) {
|
||||
const jobId = params.jobId;
|
||||
if (!jobId) return Response.json({ error: "jobId required" }, { status: 400 });
|
||||
|
||||
try {
|
||||
const job = await getJob(jobId);
|
||||
if (!job) return Response.json({ error: "Job not found" }, { status: 404 });
|
||||
return Response.json(job);
|
||||
} catch (err) {
|
||||
console.error("/api/jobs loader error:", err);
|
||||
return Response.json({ error: "internal" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { listRecentJobs } from "../../../lib/queue";
|
||||
|
||||
export async function loader({ request }: { request: Request }) {
|
||||
const url = new URL(request.url);
|
||||
const ticker = url.searchParams.get("ticker") || undefined;
|
||||
const limit = parseInt(url.searchParams.get("limit") || "50", 10);
|
||||
try {
|
||||
const jobs = await listRecentJobs(ticker || undefined, limit);
|
||||
return Response.json({ jobs });
|
||||
} catch (err) {
|
||||
console.error("/api/jobs index error:", err);
|
||||
return Response.json({ error: "internal" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export async function loader(){
|
||||
return Response.json({ ok: true, msg: "price-stream-test" });
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import alpacaService from "../../lib/alpacaClient";
|
||||
|
||||
export async function loader({ request }: { request: Request }) {
|
||||
const url = new URL(request.url);
|
||||
const ticker = (url.searchParams.get("ticker") || "").toUpperCase();
|
||||
if (!ticker) return new Response("ticker required", { status: 400 });
|
||||
const timeframe = url.searchParams.get("timeframe") || "1Min"; // default to 1Min bars for live price
|
||||
function mapToAlpacaTimeframe(tf: string) {
|
||||
switch (tf) {
|
||||
case "1H": return "1Hour";
|
||||
case "1D": return "1Day";
|
||||
default: return tf;
|
||||
}
|
||||
}
|
||||
const alpacaTimeframe = mapToAlpacaTimeframe(timeframe);
|
||||
|
||||
const headers = new Headers({
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
});
|
||||
|
||||
// Create a ReadableStream that polls latest bar with adaptive backoff and SSE
|
||||
let closed = false;
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
// helper to push SSE event
|
||||
function pushEvent(obj: any) {
|
||||
try {
|
||||
const payload = `data: ${JSON.stringify(obj)}\n\n`;
|
||||
controller.enqueue(new TextEncoder().encode(payload));
|
||||
} catch (e) {
|
||||
console.warn("price-stream: failed to enqueue", e);
|
||||
}
|
||||
}
|
||||
|
||||
// initial ping
|
||||
pushEvent({ event: "connected", ticker, timeframe });
|
||||
|
||||
const baseDelay = 5000; // start with 5s between Alpaca calls
|
||||
const maxDelay = 60000; // cap backoff at 60s
|
||||
let delay = baseDelay;
|
||||
let lastBarId: string | number | null = null;
|
||||
|
||||
async function poll() {
|
||||
if (closed) return;
|
||||
try {
|
||||
const last = await alpacaService.fetchLatestBar(ticker, alpacaTimeframe);
|
||||
const price = last ? (last.ClosePrice ?? last.c ?? null) : null;
|
||||
|
||||
// create a dedupe id from available fields
|
||||
const barId = last ? (last.T ?? last.t ?? last.Timestamp ?? last.ClosePrice ?? last.c ?? null) : null;
|
||||
|
||||
if (price != null) {
|
||||
if (barId == null || barId !== lastBarId) {
|
||||
lastBarId = barId;
|
||||
pushEvent({ price, ts: Date.now(), timeframe });
|
||||
}
|
||||
} else {
|
||||
pushEvent({ error: "no_bar", ts: Date.now() });
|
||||
}
|
||||
|
||||
// on success, reset backoff
|
||||
delay = baseDelay;
|
||||
} catch (err: any) {
|
||||
const msg = String(err?.message ?? err ?? "error");
|
||||
console.error("price-stream: error fetching latest bar", msg);
|
||||
pushEvent({ error: msg, ts: Date.now() });
|
||||
|
||||
// apply exponential backoff on rate limit errors
|
||||
if (/429|too many requests/i.test(msg)) {
|
||||
delay = Math.min(delay * 2, maxDelay);
|
||||
console.warn(`price-stream: rate limited, backing off to ${delay}ms`);
|
||||
} else {
|
||||
// mild backoff for other errors
|
||||
delay = Math.min(Math.floor(delay * 1.5), maxDelay);
|
||||
}
|
||||
}
|
||||
|
||||
if (!closed) setTimeout(poll, delay);
|
||||
}
|
||||
|
||||
// start polling immediately
|
||||
setTimeout(poll, 0);
|
||||
},
|
||||
cancel() {
|
||||
closed = true;
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, { headers });
|
||||
}
|
||||
@@ -23,8 +23,29 @@ export async function action({ request }: { request: Request }) {
|
||||
return Response.json({ success: true });
|
||||
}
|
||||
|
||||
const stock = await db.stock.create({
|
||||
data: { ticker },
|
||||
// Optional fields to save/update
|
||||
const lastDecision = formData.get("lastDecision")?.toString();
|
||||
const lastExplanation = formData.get("lastExplanation")?.toString();
|
||||
const lastExecutionPlan = formData.get("lastExecutionPlan")?.toString();
|
||||
const lastJobId = formData.get("lastJobId")?.toString();
|
||||
|
||||
// Upsert the stock record so ticker is ensured and optional fields are saved
|
||||
const stock = await db.stock.upsert({
|
||||
where: { ticker },
|
||||
update: {
|
||||
lastDecision: lastDecision ?? undefined,
|
||||
lastExplanation: lastExplanation ?? undefined,
|
||||
lastExecutionPlan: lastExecutionPlan ?? undefined,
|
||||
lastJobId: lastJobId ?? undefined,
|
||||
},
|
||||
create: {
|
||||
ticker,
|
||||
lastDecision: lastDecision ?? undefined,
|
||||
lastExplanation: lastExplanation ?? undefined,
|
||||
lastExecutionPlan: lastExecutionPlan ?? undefined,
|
||||
lastJobId: lastJobId ?? undefined,
|
||||
},
|
||||
});
|
||||
|
||||
return Response.json(stock);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { MostActiveStock } from "../../../types";
|
||||
import Alpaca from "@alpacahq/alpaca-trade-api";
|
||||
|
||||
const ALPACA_API_KEY = process.env.ALPACA_API_KEY!;
|
||||
const ALPACA_SECRET_KEY = process.env.ALPACA_SECRET_KEY!;
|
||||
const ALPACA_DATA_URL = process.env.ALPACA_DATA_URL || "https://data.alpaca.markets";
|
||||
|
||||
const alpaca = new Alpaca({
|
||||
keyId: ALPACA_API_KEY,
|
||||
secretKey: ALPACA_SECRET_KEY,
|
||||
baseUrl: process.env.ALPACA_BASE_URL || "https://paper-api.alpaca.markets",
|
||||
dataBaseUrl: ALPACA_DATA_URL,
|
||||
retryOnError: false,
|
||||
});
|
||||
|
||||
export async function loader() {
|
||||
try {
|
||||
const response = await fetch(`${ALPACA_DATA_URL}/v1beta1/screener/stocks/most-actives`, {
|
||||
headers: {
|
||||
"APCA-API-KEY-ID": ALPACA_API_KEY,
|
||||
"APCA-API-SECRET-KEY": ALPACA_SECRET_KEY,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Alpaca API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const stocks: MostActiveStock[] = (data.most_actives || []).map((item: any) => ({
|
||||
symbol: item.symbol,
|
||||
name: item.name || item.symbol,
|
||||
price: parseFloat(item.price) || 0,
|
||||
changePercent: parseFloat(item.change_percent) || 0,
|
||||
volume: parseInt(item.volume) || 0,
|
||||
}));
|
||||
|
||||
// If Alpaca's screener returned a symbol as the name, try to fetch the canonical asset name
|
||||
await Promise.all(stocks.map(async (s) => {
|
||||
if (s.name === s.symbol) {
|
||||
try {
|
||||
const asset = await alpaca.getAsset(s.symbol);
|
||||
if (asset && (asset as any).name) {
|
||||
s.name = (asset as any).name;
|
||||
}
|
||||
} catch (err) {
|
||||
// ignore and keep existing name
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
return Response.json(stocks);
|
||||
} catch (error) {
|
||||
console.error("Most active stocks API error:", error);
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
return Response.json(
|
||||
{ error: `Failed to fetch most active stocks: ${message}` },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
/* TRADINGGRAPH related file */
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useLoaderData } from "react-router";
|
||||
import Navbar from "../../components/Navbar";
|
||||
|
||||
export const meta = () => [{ title: "Job Detail - AITrader" }];
|
||||
|
||||
export async function loader({ params, request }: { params: { jobId: string }; request: Request }) {
|
||||
const jobId = params.jobId;
|
||||
if (!jobId) return Response.json({ error: "jobId required" }, { status: 400 });
|
||||
const reqUrl = new URL(request.url);
|
||||
const host = request.headers.get("host") || reqUrl.host;
|
||||
const protocol = reqUrl.protocol;
|
||||
const baseUrl = `${protocol}//${host}`;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/api/jobs/${jobId}`);
|
||||
const body = res.ok ? await res.json() : null;
|
||||
return Response.json(body);
|
||||
} catch (err) {
|
||||
console.error("/jobs loader error:", err);
|
||||
return Response.json({ error: "internal" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export default function JobDetail() {
|
||||
const initial = useLoaderData() as any;
|
||||
const [job, setJob] = useState<any>(initial);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const poll = async () => {
|
||||
try {
|
||||
const id = job?.id || initial?.id;
|
||||
if (!id) return;
|
||||
const res = await fetch(`/api/jobs/${id}`);
|
||||
if (!res.ok) return;
|
||||
const j = await res.json();
|
||||
if (cancelled) return;
|
||||
setJob(j);
|
||||
if (j.state === "completed" || j.state === "failed") return;
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
setTimeout(poll, 3000);
|
||||
};
|
||||
poll();
|
||||
return () => { cancelled = true; };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-blue-50">
|
||||
<Navbar />
|
||||
<div className="mx-auto max-w-4xl px-6 py-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-4">Job {job?.id}</h1>
|
||||
<div className="bg-white rounded-xl shadow-lg p-6 border border-gray-200 text-gray-900">
|
||||
<div className="mb-3 text-sm text-gray-700">State: <strong className="ml-2">{job?.state}</strong></div>
|
||||
{job?.failedReason && (
|
||||
<div className="mb-3 text-red-600">Failed: {job.failedReason}</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
{job?.state === 'waiting' || job?.state === 'queued' ? (
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/jobs/${job?.id}/cancel`, { method: 'POST' });
|
||||
if (res.ok) {
|
||||
const d = await res.json();
|
||||
if (d.cancelled) {
|
||||
setJob((prev: any) => ({ ...(prev || {}), state: 'failed', failedReason: 'cancelled' }));
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}}
|
||||
className="text-sm text-red-600 hover:underline"
|
||||
>
|
||||
Cancel Job
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<a href={`/api/jobs/${job?.id}`} target="_blank" rel="noreferrer" className="text-sm text-blue-600 hover:underline">Open API</a>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-gray-700 mb-3">Raw Data:</div>
|
||||
<pre className="bg-gray-50 p-3 rounded text-xs overflow-x-auto text-gray-800">{JSON.stringify(job?.data || job?.returnValue || job, null, 2)}</pre>
|
||||
|
||||
{/* TradingGraph structured output */}
|
||||
{job?.returnValue && (
|
||||
<div className="mt-4 bg-white rounded-lg p-4 border border-gray-200 text-gray-900">
|
||||
<h4 className="text-lg font-semibold mb-3">TradingGraph Result</h4>
|
||||
|
||||
{/* Decision summary */}
|
||||
{job.returnValue.action && (
|
||||
<div className="mb-3">
|
||||
<div className="text-sm text-gray-600">Decision:</div>
|
||||
<div className="text-base font-medium">{String(job.returnValue.action).toUpperCase()} <span className="text-sm text-gray-500">(confidence: {Number(job.returnValue.confidence ?? 0).toFixed(2)})</span></div>
|
||||
{job.returnValue.reasoning && <div className="mt-2 text-sm text-gray-700">{job.returnValue.reasoning}</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Agent signals / analyst reports */}
|
||||
{Array.isArray(job.returnValue.agentSignals) && job.returnValue.agentSignals.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<div className="text-sm text-gray-600">Analyst Reports</div>
|
||||
<div className="mt-2 space-y-2">
|
||||
{job.returnValue.agentSignals.map((s: any, i: number) => (
|
||||
<div key={i} className="p-3 bg-gray-50 rounded border border-gray-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="font-medium capitalize">{s.agent}</div>
|
||||
<div className="text-sm text-gray-500">{s.signal} ({Math.round((s.confidence ?? 0) * 100)}%)</div>
|
||||
</div>
|
||||
{s.reasoning && <div className="mt-1 text-sm text-gray-700">{s.reasoning}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Debate rounds (if present) */}
|
||||
{Array.isArray(job.returnValue.debateRounds) && job.returnValue.debateRounds.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<div className="text-sm text-gray-600">Debate Rounds</div>
|
||||
<div className="mt-2 space-y-2">
|
||||
{job.returnValue.debateRounds.map((d: any, i: number) => (
|
||||
<div key={i} className="p-3 bg-gray-50 rounded border border-gray-100">
|
||||
<div className="text-sm font-medium">Researcher: {d.researcher ?? 'unknown'}</div>
|
||||
{d.bullishView && <div className="mt-1 text-sm text-green-600">Bullish: {d.bullishView}</div>}
|
||||
{d.bearishView && <div className="mt-1 text-sm text-red-600">Bearish: {d.bearishView}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Execution plan */}
|
||||
{job.returnValue.executionPlan && (
|
||||
<div className="mb-3">
|
||||
<div className="text-sm text-gray-600">Execution Plan</div>
|
||||
<div className="mt-2 text-sm text-gray-700">
|
||||
{job.returnValue.executionPlan.amount != null && (<div>Amount: <strong>{job.returnValue.executionPlan.amount}</strong></div>)}
|
||||
{job.returnValue.executionPlan.takeProfit != null && (<div>Take profit: <strong>${job.returnValue.executionPlan.takeProfit}</strong></div>)}
|
||||
{job.returnValue.executionPlan.stopLoss != null && (<div>Stop loss: <strong>${job.returnValue.executionPlan.stopLoss}</strong></div>)}
|
||||
{job.returnValue.executionPlan.riskManagement?.maxLossPercent != null && (<div>Risk: <strong>{job.returnValue.executionPlan.riskManagement.maxLossPercent}%</strong></div>)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* LLM review if available */}
|
||||
{job.returnValue.executionPlan?._llmReview && (
|
||||
<div className="mt-4 bg-white rounded-lg p-3 border border-gray-200 text-gray-900">
|
||||
<h4 className="text-sm font-medium">LLM Review</h4>
|
||||
<div className="mt-2 text-sm text-gray-700">
|
||||
<div>Approved: <strong className={job.returnValue.executionPlan._llmReview.approved ? 'text-green-600' : 'text-red-600'}>{job.returnValue.executionPlan._llmReview.approved ? 'Yes' : 'No'}</strong></div>
|
||||
{job.returnValue.executionPlan._llmReview.notes && (
|
||||
<div className="mt-1">Notes: <span className="font-medium text-gray-800">{job.returnValue.executionPlan._llmReview.notes}</span></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [items, setItems] = useState<Array<{ key: string; value: any }>>([]);
|
||||
useEffect(() => {
|
||||
fetch('/api/admin/settings')
|
||||
.then(r => r.json())
|
||||
.then(j => setItems(j));
|
||||
}, []);
|
||||
|
||||
async function save(key: string, value: any) {
|
||||
await fetch(`/api/admin/settings/${encodeURIComponent(key)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ value }),
|
||||
});
|
||||
setItems(s => s.map(i => (i.key === key ? { ...i, value } : i)));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold mb-4">Settings</h1>
|
||||
<ul>
|
||||
{items.map(it => (
|
||||
<li key={it.key} className="mb-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="font-medium">{it.key}</div>
|
||||
<textarea
|
||||
className="border p-2 w-2/3"
|
||||
defaultValue={JSON.stringify(it.value, null, 2)}
|
||||
onBlur={e => {
|
||||
try {
|
||||
const v = JSON.parse(e.currentTarget.value);
|
||||
save(it.key, v);
|
||||
} catch (err) {
|
||||
alert('Invalid JSON');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { loader } from "./analyze.ticker";
|
||||
export { default } from "./analyze.ticker";
|
||||
@@ -1,4 +1,4 @@
|
||||
import StockViewer from "../components/StockViewer";
|
||||
import MostActiveStocks from "../components/MostActiveStocks";
|
||||
import Navbar from "../components/Navbar";
|
||||
|
||||
export default function Stocks() {
|
||||
@@ -9,13 +9,13 @@ export default function Stocks() {
|
||||
<div className="mx-auto max-w-7xl px-6 sm:px-8 lg:px-8">
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-4">
|
||||
Stock Indicators
|
||||
Most Active Stocks
|
||||
</h1>
|
||||
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
|
||||
Analyze technical indicators for any stock symbol.
|
||||
Real-time view of the most actively traded stocks, auto-refreshing every 30 seconds.
|
||||
</p>
|
||||
</div>
|
||||
<StockViewer />
|
||||
<MostActiveStocks />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,3 +13,11 @@ export interface AlpacaAccount {
|
||||
buying_power: number;
|
||||
portfolio_value: number;
|
||||
}
|
||||
|
||||
export interface MostActiveStock {
|
||||
symbol: string;
|
||||
name: string;
|
||||
price: number;
|
||||
changePercent: number;
|
||||
volume: number;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,18 @@ export interface DebateRound {
|
||||
researcher: 'bullish' | 'bearish'
|
||||
}
|
||||
|
||||
export interface ExecutionPlan {
|
||||
amount: number // number of shares to trade
|
||||
riskManagement: {
|
||||
maxLossPercent?: number
|
||||
method?: string
|
||||
}
|
||||
takeProfit?: number // target price for take-profit
|
||||
stopLoss?: number // stop-loss price or absolute value
|
||||
note?: string
|
||||
_llmReview?: { approved: boolean; notes?: string | null }
|
||||
}
|
||||
|
||||
export interface TradingDecision {
|
||||
action: 'buy' | 'sell' | 'hold'
|
||||
confidence: number
|
||||
@@ -28,6 +40,7 @@ export interface TradingDecision {
|
||||
reasoning: string
|
||||
agentSignals: AgentSignal[]
|
||||
debateRounds: DebateRound[]
|
||||
executionPlan?: ExecutionPlan
|
||||
}
|
||||
|
||||
export interface AgentConfig {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,363 @@
|
||||
# Most Active Stocks Table 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:** Replace the StockViewer on `/stocks` with a table of most active stocks from Alpaca's screener API.
|
||||
|
||||
**Architecture:** Server proxy route calls Alpaca screener, React component fetches and auto-refreshes every 30s with clickable rows linking to `/analyze/TICKER`.
|
||||
|
||||
**Tech Stack:** React Router 7, React, TailwindCSS, fetch API, Alpaca Markets API
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add MostActiveStock type to types.ts
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/types.ts`
|
||||
|
||||
- [ ] **Step 1: Add MostActiveStock interface**
|
||||
|
||||
Add to `app/types.ts` after the `AlpacaAccount` interface:
|
||||
|
||||
```typescript
|
||||
export interface MostActiveStock {
|
||||
symbol: string;
|
||||
name: string;
|
||||
price: number;
|
||||
changePercent: number;
|
||||
volume: number;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add app/types.ts
|
||||
git commit -m "types: add MostActiveStock interface"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Create API route for most active stocks
|
||||
|
||||
**Files:**
|
||||
- Create: `app/routes/api/stocks/most-actives.ts`
|
||||
|
||||
- [ ] **Step 1: Create the server proxy route**
|
||||
|
||||
Create `app/routes/api/stocks/most-actives.ts`:
|
||||
|
||||
```typescript
|
||||
import type { MostActiveStock } from "../../../types";
|
||||
|
||||
const ALPACA_API_KEY = process.env.ALPACA_API_KEY!;
|
||||
const ALPACA_SECRET_KEY = process.env.ALPACA_SECRET_KEY!;
|
||||
const ALPACA_DATA_URL = process.env.ALPACA_DATA_URL || "https://data.alpaca.markets";
|
||||
|
||||
export async function loader() {
|
||||
try {
|
||||
const response = await fetch(`${ALPACA_DATA_URL}/v1beta1/screener/stocks/most-actives`, {
|
||||
headers: {
|
||||
"APCA-API-KEY-ID": ALPACA_API_KEY,
|
||||
"APCA-API-SECRET-KEY": ALPACA_SECRET_KEY,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Alpaca API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const stocks: MostActiveStock[] = (data.most_actives || []).map((item: any) => ({
|
||||
symbol: item.symbol,
|
||||
name: item.name || item.symbol,
|
||||
price: parseFloat(item.price) || 0,
|
||||
changePercent: parseFloat(item.change_percent) || 0,
|
||||
volume: parseInt(item.volume) || 0,
|
||||
}));
|
||||
|
||||
return Response.json(stocks);
|
||||
} catch (error) {
|
||||
console.error("Most active stocks API error:", error);
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
return Response.json(
|
||||
{ error: `Failed to fetch most active stocks: ${message}` },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add app/routes/api/stocks/most-actives.ts
|
||||
git commit -m "feat: add most-actives API proxy route"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Register the new API route
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/routes.ts`
|
||||
|
||||
- [ ] **Step 1: Add route entry**
|
||||
|
||||
Add this line to `app/routes.ts` after the existing `api/stocks` route (line 11):
|
||||
|
||||
```typescript
|
||||
route("api/stocks/most-actives", "routes/api/stocks/most-actives.ts"),
|
||||
```
|
||||
|
||||
The routes array should look like:
|
||||
|
||||
```typescript
|
||||
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/orders", "routes/api/alpaca/orders.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("api/stocks/most-actives", "routes/api/stocks/most-actives.ts"),
|
||||
route("stocks", "routes/stocks.tsx"),
|
||||
route("analyze", "routes/analyze.tsx"),
|
||||
route("analyze/:ticker", "routes/analyze.ticker.tsx"),
|
||||
] satisfies RouteConfig;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add app/routes.ts
|
||||
git commit -m "routes: register most-actives API endpoint"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Create MostActiveStocks component
|
||||
|
||||
**Files:**
|
||||
- Create: `app/components/MostActiveStocks.tsx`
|
||||
|
||||
- [ ] **Step 1: Create the component**
|
||||
|
||||
Create `app/components/MostActiveStocks.tsx`:
|
||||
|
||||
```typescript
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Link } from "react-router";
|
||||
import type { MostActiveStock } from "../types";
|
||||
|
||||
function formatVolume(vol: number): string {
|
||||
if (vol >= 1_000_000_000) return `${(vol / 1_000_000_000).toFixed(1)}B`;
|
||||
if (vol >= 1_000_000) return `${(vol / 1_000_000).toFixed(1)}M`;
|
||||
if (vol >= 1_000) return `${(vol / 1_000).toFixed(1)}K`;
|
||||
return vol.toString();
|
||||
}
|
||||
|
||||
function formatPrice(price: number): string {
|
||||
return `$${price.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function formatChangePercent(pct: number): string {
|
||||
return `${pct >= 0 ? "+" : ""}${pct.toFixed(2)}%`;
|
||||
}
|
||||
|
||||
export default function MostActiveStocks() {
|
||||
const [stocks, setStocks] = useState<MostActiveStock[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
setError(null);
|
||||
const res = await fetch("/api/stocks/most-actives");
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || "Failed to fetch data");
|
||||
}
|
||||
const data = await res.json();
|
||||
setStocks(data);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to fetch most active stocks.";
|
||||
setError(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
const interval = setInterval(fetchData, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchData]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-lg p-6 border border-gray-200">
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="animate-pulse flex gap-4 py-3 border-b border-gray-100 last:border-0">
|
||||
<div className="h-5 bg-gray-200 rounded w-16" />
|
||||
<div className="h-5 bg-gray-200 rounded w-32" />
|
||||
<div className="h-5 bg-gray-200 rounded w-20" />
|
||||
<div className="h-5 bg-gray-200 rounded w-20" />
|
||||
<div className="h-5 bg-gray-200 rounded w-24" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && stocks.length === 0) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-lg p-6 border border-gray-200">
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<p className="text-red-600 text-sm mb-3">{error}</p>
|
||||
<button
|
||||
onClick={fetchData}
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-lg border border-gray-200 overflow-hidden">
|
||||
{error && (
|
||||
<div className="bg-red-50 border-b border-red-200 px-6 py-3">
|
||||
<p className="text-red-600 text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 border-b border-gray-200">
|
||||
<th className="text-left px-6 py-4 text-sm font-semibold text-gray-600">Symbol</th>
|
||||
<th className="text-left px-6 py-4 text-sm font-semibold text-gray-600">Name</th>
|
||||
<th className="text-right px-6 py-4 text-sm font-semibold text-gray-600">Price</th>
|
||||
<th className="text-right px-6 py-4 text-sm font-semibold text-gray-600">Change %</th>
|
||||
<th className="text-right px-6 py-4 text-sm font-semibold text-gray-600">Volume</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{stocks.map((stock) => (
|
||||
<tr key={stock.symbol} className="border-b border-gray-100 hover:bg-gray-50 transition-colors">
|
||||
<td className="px-6 py-4">
|
||||
<Link
|
||||
to={`/analyze/${stock.symbol}`}
|
||||
className="text-blue-600 font-semibold hover:text-blue-700 hover:underline"
|
||||
>
|
||||
{stock.symbol}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-gray-600">{stock.name}</td>
|
||||
<td className="px-6 py-4 text-right font-mono text-gray-900">{formatPrice(stock.price)}</td>
|
||||
<td className={`px-6 py-4 text-right font-mono font-medium ${stock.changePercent >= 0 ? "text-green-600" : "text-red-600"}`}>
|
||||
{formatChangePercent(stock.changePercent)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right text-gray-600">{formatVolume(stock.volume)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add app/components/MostActiveStocks.tsx
|
||||
git commit -m "feat: add MostActiveStocks table component with auto-refresh"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Update stocks.tsx page
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/routes/stocks.tsx`
|
||||
|
||||
- [ ] **Step 1: Replace StockViewer with MostActiveStocks**
|
||||
|
||||
Replace the entire contents of `app/routes/stocks.tsx`:
|
||||
|
||||
```typescript
|
||||
import MostActiveStocks from "../components/MostActiveStocks";
|
||||
import Navbar from "../components/Navbar";
|
||||
|
||||
export default function Stocks() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-blue-50">
|
||||
<Navbar />
|
||||
<div className="py-20">
|
||||
<div className="mx-auto max-w-7xl px-6 sm:px-8 lg:px-8">
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-4">
|
||||
Most Active Stocks
|
||||
</h1>
|
||||
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
|
||||
Real-time view of the most actively traded stocks, auto-refreshing every 30 seconds.
|
||||
</p>
|
||||
</div>
|
||||
<MostActiveStocks />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add app/routes/stocks.tsx
|
||||
git commit -m "feat: replace StockViewer with MostActiveStocks on stocks page"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Verify and test
|
||||
|
||||
**Files:**
|
||||
- All modified files
|
||||
|
||||
- [ ] **Step 1: Run typecheck**
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
Expected: No type errors. If there are errors, fix them before proceeding.
|
||||
|
||||
- [ ] **Step 2: Run dev server and verify**
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Navigate to `http://localhost:5173/stocks` and verify:
|
||||
- Table loads with most active stocks
|
||||
- Symbol links navigate to `/analyze/TICKER`
|
||||
- Change % is color-coded (green for positive, red for negative)
|
||||
- Data auto-refreshes every 30 seconds
|
||||
- Loading skeleton shows on initial load
|
||||
- Error state shows with retry button if API fails
|
||||
|
||||
- [ ] **Step 3: Final commit (if any fixes needed)**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "fix: address typecheck/dev issues"
|
||||
```
|
||||
@@ -0,0 +1,349 @@
|
||||
# Settings 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:** Add a DB-backed app-wide Settings feature with a secure admin UI, a SettingsService (cache + write-through), API endpoints, Prisma schema + migration, and tests so settings changes immediately affect application behavior.
|
||||
|
||||
**Architecture:** Server-centric SettingsService persists to the database (Prisma) and exposes admin REST endpoints. Frontend admin UI calls these endpoints and subscribes to in-process updates. No multi-instance pub/sub in v1.
|
||||
|
||||
**Tech Stack:** Node 20, TypeScript, Prisma, React Router (server+client), Vitest (unit), Playwright (E2E), TailwindCSS.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add Prisma model & migration
|
||||
|
||||
**Files:**
|
||||
- Modify: `prisma\schema.prisma` (add model)
|
||||
- Create: migration via CLI (described below)
|
||||
|
||||
- [ ] **Step 1: Add model to schema**
|
||||
|
||||
Edit `prisma\schema.prisma` and add:
|
||||
|
||||
```prisma
|
||||
model AppSetting {
|
||||
id Int @id @default(autoincrement())
|
||||
key String @unique
|
||||
value Json
|
||||
description String?
|
||||
updatedAt DateTime @updatedAt
|
||||
updatedBy String?
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create and run migration (local dev)**
|
||||
|
||||
Run:
|
||||
|
||||
```
|
||||
npx prisma migrate dev --name add_app_setting --preview-feature
|
||||
```
|
||||
|
||||
Expected: new migration created and prisma client regenerated. Verify `prisma/migrations/` contains a folder.
|
||||
|
||||
- [ ] **Step 3: Commit migration files**
|
||||
|
||||
```
|
||||
git add prisma/schema.prisma prisma/migrations && git commit -m "chore(db): add AppSetting model"
|
||||
```
|
||||
|
||||
|
||||
### Task 2: Implement SettingsService (server-side cache + emitter)
|
||||
|
||||
**Files:**
|
||||
- Create: `app\lib\settings.server.ts`
|
||||
- Modify: `app\server\index.ts` or equivalent bootstrap (if needed) to import the service for in-process subscribers
|
||||
- Test: `app\lib\__tests__\settings.server.test.ts`
|
||||
|
||||
- [ ] **Step 1: Create SettingsService file**
|
||||
|
||||
Create `app\lib\settings.server.ts` with the following content:
|
||||
|
||||
```ts
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import EventEmitter from 'events';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
type JSONValue = any;
|
||||
|
||||
class SettingsService extends EventEmitter {
|
||||
private cache: Map<string, JSONValue> = new Map();
|
||||
private initialized = false;
|
||||
|
||||
async init() {
|
||||
if (this.initialized) return;
|
||||
const rows = await prisma.appSetting.findMany();
|
||||
rows.forEach(r => this.cache.set(r.key, r.value));
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
async get(key: string) {
|
||||
if (!this.initialized) await this.init();
|
||||
return this.cache.has(key) ? this.cache.get(key) : null;
|
||||
}
|
||||
|
||||
async set(key: string, value: JSONValue, updatedBy?: string) {
|
||||
if (!this.initialized) await this.init();
|
||||
// write-through
|
||||
await prisma.appSetting.upsert({
|
||||
where: { key },
|
||||
update: { value, updatedBy },
|
||||
create: { key, value, updatedBy },
|
||||
});
|
||||
this.cache.set(key, value);
|
||||
this.emit('update', { key, value });
|
||||
return { key, value };
|
||||
}
|
||||
|
||||
subscribe(fn: (payload: { key: string; value: any }) => void) {
|
||||
this.on('update', fn);
|
||||
return () => this.off('update', fn);
|
||||
}
|
||||
}
|
||||
|
||||
export const settingsService = new SettingsService();
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write unit test for SettingsService**
|
||||
|
||||
Create `app\lib\__tests__\settings.server.test.ts`:
|
||||
|
||||
```ts
|
||||
import { settingsService } from '../settings.server';
|
||||
|
||||
describe('SettingsService', () => {
|
||||
test('set and get', async () => {
|
||||
const key = `test_key_${Date.now()}`;
|
||||
const val = { enabled: true };
|
||||
await settingsService.set(key, val, 'test');
|
||||
const got = await settingsService.get(key);
|
||||
expect(got).toEqual(val);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Run: `npm run test -w 1 -- app/lib/__tests__/settings.server.test.ts`
|
||||
|
||||
Expected: PASS (may require test DB setup; in dev environment prisma will use sqlite or configured DB)
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```
|
||||
git add app/lib/settings.server.ts app/lib/__tests__/settings.server.test.ts
|
||||
git commit -m "feat(settings): add SettingsService with cache and emitter"
|
||||
```
|
||||
|
||||
|
||||
### Task 3: Add secure API endpoints
|
||||
|
||||
**Files:**
|
||||
- Create: `app\routes\api\admin\settings\index.ts` (GET, POST list)
|
||||
- Create: `app\routes\api\admin\settings\[key].ts` (PUT update)
|
||||
- Modify: shared auth util if needed to check admin role: `app\lib\auth.server.ts` (just reuse existing session check or fallback to ADMIN_TOKEN)
|
||||
- Test: `app\routes\api\admin\__tests__\settings.api.test.ts`
|
||||
|
||||
- [ ] **Step 1: Implement GET/POST index handler**
|
||||
|
||||
Create `app\routes\api\admin\settings\index.ts`:
|
||||
|
||||
```ts
|
||||
import type { RequestHandler } from '@remix-run/node';
|
||||
import { settingsService } from '~/lib/settings.server';
|
||||
import { requireAdmin } from '~/lib/auth.server';
|
||||
|
||||
export const loader: RequestHandler = async ({ request }) => {
|
||||
await requireAdmin(request);
|
||||
// return all settings
|
||||
const keys = Array.from((await settingsService['init'](), settingsService as any).cache.keys());
|
||||
const entries = [] as any[];
|
||||
for (const key of keys) {
|
||||
const value = await settingsService.get(key);
|
||||
entries.push({ key, value });
|
||||
}
|
||||
return new Response(JSON.stringify(entries), { status: 200 });
|
||||
};
|
||||
|
||||
export const action: RequestHandler = async ({ request }) => {
|
||||
await requireAdmin(request);
|
||||
const body = await request.json();
|
||||
if (!body.key) return new Response('Missing key', { status: 400 });
|
||||
const created = await settingsService.set(body.key, body.value, 'admin');
|
||||
return new Response(JSON.stringify(created), { status: 201 });
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Implement PUT handler for key**
|
||||
|
||||
Create `app\routes\api\admin\settings\[key].ts`:
|
||||
|
||||
```ts
|
||||
import type { RequestHandler } from '@remix-run/node';
|
||||
import { settingsService } from '~/lib/settings.server';
|
||||
import { requireAdmin } from '~/lib/auth.server';
|
||||
|
||||
export const action: RequestHandler = async ({ request, params }) => {
|
||||
await requireAdmin(request);
|
||||
const key = params.key as string;
|
||||
const body = await request.json();
|
||||
if (!key) return new Response('Missing key', { status: 400 });
|
||||
await settingsService.set(key, body.value, 'admin');
|
||||
return new Response(null, { status: 204 });
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Tests for API**
|
||||
|
||||
Create `app\routes\api\admin\__tests__\settings.api.test.ts` with a simple fetch-based integration test (Vitest + node fetch environment):
|
||||
|
||||
```ts
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
// This is a placeholder instructions block; run API tests with the dev server running or mock requireAdmin
|
||||
test('API index returns json', async () => {
|
||||
// For simplicity, call settingsService directly in unit tests rather than full server
|
||||
const { settingsService } = await import('../../../lib/settings.server');
|
||||
const key = 'api_test_' + Date.now();
|
||||
await settingsService.set(key, { foo: 'bar' }, 'test');
|
||||
const v = await settingsService.get(key);
|
||||
expect(v).toEqual({ foo: 'bar' });
|
||||
});
|
||||
```
|
||||
|
||||
Run: `npm run test -w 1 -- app/routes/api/admin/__tests__/settings.api.test.ts`
|
||||
|
||||
Commit after passing tests.
|
||||
|
||||
|
||||
### Task 4: Frontend admin UI route
|
||||
|
||||
**Files:**
|
||||
- Create: `app\routes\settings.tsx` (UI page for admins)
|
||||
- Modify: `app\components\Navbar.tsx` (add link to /settings when isAdmin)
|
||||
- Test: `tests\e2e\settings.spec.ts` (Playwright)
|
||||
|
||||
- [ ] **Step 1: Create settings route component**
|
||||
|
||||
Create `app\routes\settings.tsx`:
|
||||
|
||||
```tsx
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [items, setItems] = useState<Array<{ key: string; value: any }>>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/admin/settings')
|
||||
.then(r => r.json())
|
||||
.then(j => setItems(j))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
async function save(key: string, value: any) {
|
||||
await fetch(`/api/admin/settings/${encodeURIComponent(key)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ value }),
|
||||
});
|
||||
// update local cache
|
||||
setItems(s => s.map(i => (i.key === key ? { ...i, value } : i)));
|
||||
}
|
||||
|
||||
if (loading) return <div>Loading...</div>;
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold mb-4">Settings</h1>
|
||||
<ul>
|
||||
{items.map(it => (
|
||||
<li key={it.key} className="mb-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="font-medium">{it.key}</div>
|
||||
<textarea
|
||||
className="border p-2 w-2/3"
|
||||
defaultValue={JSON.stringify(it.value, null, 2)}
|
||||
onBlur={e => {
|
||||
try {
|
||||
const v = JSON.parse(e.currentTarget.value);
|
||||
save(it.key, v);
|
||||
} catch (err) {
|
||||
alert('Invalid JSON');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add Navbar link**
|
||||
|
||||
Modify `app\components\Navbar.tsx` to conditionally render a link to `/settings` for admins. Use existing session/isAdmin check.
|
||||
|
||||
- [ ] **Step 3: Playwright E2E test**
|
||||
|
||||
Create `tests\e2e\settings.spec.ts`:
|
||||
|
||||
```ts
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('admin can view and edit settings', async ({ page }) => {
|
||||
await page.goto('http://localhost:5173/settings');
|
||||
await expect(page.locator('text=Settings')).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
Run: `npm run test:e2e -- --project=chromium tests/e2e/settings.spec.ts`
|
||||
|
||||
Commit after tests pass.
|
||||
|
||||
|
||||
### Task 5: Wiring settings into app behavior (example flag)
|
||||
|
||||
**Files:**
|
||||
- Modify: A feature-flagged consumer (example: `app\routes\landing.tsx` or the component that uses ANALYSIS_BACKGROUND)
|
||||
|
||||
- [ ] **Step 1: Read setting where used**
|
||||
|
||||
Example change in a consumer component:
|
||||
|
||||
```ts
|
||||
import { settingsService } from '~/lib/settings.server';
|
||||
|
||||
export async function loader() {
|
||||
const analysisBackground = (await settingsService.get('ANALYSIS_BACKGROUND')) ?? { enabled: false };
|
||||
return json({ analysisBackground });
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write test demonstrating behavior switch**
|
||||
|
||||
Add a unit test that sets the setting then validates the consumer behavior.
|
||||
|
||||
Commit changes.
|
||||
|
||||
|
||||
### Task 6: Tests, CI, and rollout notes
|
||||
|
||||
- Unit tests: SettingsService, API tests, consumer behavior tests.
|
||||
- E2E: settings.spec.ts to validate page loads and editing works.
|
||||
- CI: ensure `npx prisma migrate deploy` runs in deployment and that environment variables for DB are configured.
|
||||
|
||||
---
|
||||
|
||||
## Self-review checklist
|
||||
|
||||
1. Spec coverage: Tasks implement Prisma model, SettingsService, API, UI, tests, and migration. Multi-instance propagation is out-of-scope for v1, noted in spec.
|
||||
2. Placeholders: No TODOs remain in the plan. All code blocks are concrete starting points.
|
||||
3. Type consistency: Method names `settingsService.get` and `.set` used consistently.
|
||||
|
||||
Plan saved to `docs/superpowers/plans/2026-05-16-settings-page-plan.md`.
|
||||
|
||||
|
||||
----
|
||||
|
||||
Plan author: Copilot
|
||||
@@ -0,0 +1,72 @@
|
||||
y# Design: Most Active Stocks Table
|
||||
|
||||
**Date:** 2026-05-16
|
||||
**Status:** Approved
|
||||
|
||||
## Overview
|
||||
|
||||
Replace the current StockViewer on the `/stocks` page with a table displaying the most active stocks from Alpaca's screener API.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Server Route: `api/stocks/most-actives`
|
||||
- **File:** `app/routes/api/stocks/most-actives.ts`
|
||||
- **Method:** GET (loader)
|
||||
- **Function:** Proxies request to `https://data.alpaca.markets/v1beta1/screener/stocks/most-actives`
|
||||
- **Response:** Normalized JSON array with fields: `symbol`, `name`, `price`, `changePercent`, `volume`
|
||||
- **Auth:** Uses server-side `ALPACA_API_KEY` and `ALPACA_SECRET_KEY`
|
||||
|
||||
### Component: `MostActiveStocks`
|
||||
- **File:** `app/components/MostActiveStocks.tsx`
|
||||
- **Behavior:**
|
||||
- Fetches from `/api/stocks/most-actives` on mount
|
||||
- Auto-refreshes every 30 seconds via `setInterval`
|
||||
- Cleans up interval on unmount
|
||||
- **States:** loading (skeleton rows), success (table), error (red banner with retry button)
|
||||
|
||||
### Page: `stocks.tsx`
|
||||
- Replaces `<StockViewer />` with `<MostActiveStocks />`
|
||||
- Updates heading and description text to match new content
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
MostActiveStocks component
|
||||
→ fetch /api/stocks/most-actives
|
||||
→ server calls Alpaca screener API
|
||||
→ returns normalized data
|
||||
→ renders table
|
||||
→ setInterval re-fetches every 30s
|
||||
```
|
||||
|
||||
## Table Columns
|
||||
|
||||
| Column | Source | Format |
|
||||
|--------|--------|--------|
|
||||
| Symbol | `symbol` | Clickable link → `/analyze/TICKER` |
|
||||
| Name | `name` | Plain text |
|
||||
| Price | `price` | `$X.XX` |
|
||||
| Change % | `changePercent` | `+X.XX%` / `-X.XX%` (color-coded green/red) |
|
||||
| Volume | `volume` | Formatted number (e.g., `12.3M`) |
|
||||
|
||||
## Error Handling
|
||||
|
||||
- API errors: display red banner with error message and retry button
|
||||
- Network failures: show last data if available, otherwise error state
|
||||
- Empty response: show "No data available" message
|
||||
|
||||
## Route Changes
|
||||
|
||||
Add to `routes.ts`:
|
||||
```
|
||||
route("api/stocks/most-actives", "routes/api/stocks/most-actives.ts"),
|
||||
```
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
| File | Action | Purpose |
|
||||
|------|--------|---------|
|
||||
| `app/routes/api/stocks/most-actives.ts` | Create | Server proxy to Alpaca |
|
||||
| `app/components/MostActiveStocks.tsx` | Create | Table component with auto-refresh |
|
||||
| `app/routes/stocks.tsx` | Modify | Replace StockViewer with MostActiveStocks |
|
||||
| `app/routes.ts` | Modify | Add new API route |
|
||||
@@ -0,0 +1,112 @@
|
||||
Title: App-wide Settings Page — Design
|
||||
|
||||
Summary
|
||||
|
||||
Add an admin-only Settings page where operators can change app-wide configuration that affects server functions and runtime behavior (feature flags, execution modes, thresholds, API keys for integrations). Settings are persisted in the database and reflected immediately via an in-process cache + event broadcaster so server functions read updated values without restart.
|
||||
|
||||
Goals
|
||||
|
||||
- Provide a single UI for operators to view and change app-wide settings.
|
||||
- Persist settings reliably and safely (DB-backed) and version/track changes.
|
||||
- Ensure changes propagate to server functions quickly and predictably.
|
||||
- Secure the UI and APIs (admin-only).
|
||||
- Test coverage (unit + E2E).
|
||||
|
||||
Chosen approach (Recommended)
|
||||
|
||||
DB-backed settings with a Settings Service:
|
||||
- Persist settings in a new Prisma model/table (AppSetting).
|
||||
- Expose server-side SettingsService with get/set/getAll, an in-memory cache, and an EventEmitter to notify subscribers on change.
|
||||
- Admin UI: route at /settings (app/routes/settings.tsx) with a guarded admin page to edit values.
|
||||
- Server functions import SettingsService.get("key") or subscribe to change events when they need dynamic behavior.
|
||||
|
||||
Why DB-backed
|
||||
|
||||
- Atomic persistence, multi-instance safe when coupled with a small invalidation strategy (see below).
|
||||
- Auditable (store updatedAt, updatedBy, description).
|
||||
- Fits existing repo (prisma/ exists).
|
||||
|
||||
Data model (Prisma example)
|
||||
|
||||
model AppSetting {
|
||||
id Int @id @default(autoincrement())
|
||||
key String @unique
|
||||
value Json
|
||||
description String?
|
||||
updatedAt DateTime @updatedAt
|
||||
updatedBy String? // optional admin id/email
|
||||
}
|
||||
|
||||
Backend: SettingsService
|
||||
|
||||
- File: app/lib/settings.server.ts
|
||||
- Exports:
|
||||
- async getSetting(key: string): Promise<any>
|
||||
- async setSetting(key: string, value: any, opts?: { updatedBy?: string })
|
||||
- async getAllSettings(): Promise<Record<string, any>>
|
||||
- subscribe(cb: (key,value)=>void) => unsubscribe
|
||||
|
||||
Implementation notes:
|
||||
- On startup, load all settings into an in-memory Map cache.
|
||||
- getSetting reads from cache (fast). setSetting updates DB then cache and emits event.
|
||||
- To support multiple instances, setSetting also writes a lightweight "manifest" or uses DB trigger; for now plan: setSetting updates DB and callers of getSetting should tolerate eventual consistency, or a periodic poll (1s) or Redis pub/sub can be added later.
|
||||
|
||||
API endpoints
|
||||
|
||||
- GET /api/admin/settings -> list all settings (admin-only)
|
||||
- PUT /api/admin/settings/:key -> update a single setting (admin-only)
|
||||
- POST /api/admin/settings -> create/replace settings (admin-only)
|
||||
|
||||
Server route files: app/routes/api/admin/settings/index.ts (GET, POST), app/routes/api/admin/settings/[key].ts (PUT)
|
||||
|
||||
Frontend: Settings page
|
||||
|
||||
- File: app/routes/settings.tsx (route available only to admins).
|
||||
- UI: table/list of settings (key, value editor (text/json/toggle), description, last updated, updatedBy) and a Save button.
|
||||
- Support types: boolean toggles, numeric inputs, string inputs, JSON editor for advanced values.
|
||||
- Client calls API endpoints and displays toasts on success/failure.
|
||||
- When updated, UI will optimistically update local state; eventual confirmation comes from API.
|
||||
|
||||
Security
|
||||
|
||||
- Protect API endpoints and the /settings route with admin guard. If the app has user sessions with roles, require isAdmin. Otherwise gate with an env-based ADMIN_TOKEN for now.
|
||||
- Validate input server-side: schema for known keys; generic JSON allowed for advanced keys but validated.
|
||||
|
||||
Testing
|
||||
|
||||
- Unit tests for SettingsService (get/set/cache behavior). New tests under app/lib/__tests__.
|
||||
- Integration test for API endpoints (app/routes/api/admin/settings tests).
|
||||
- E2E Playwright test: toggling a setting that affects behavior (e.g., feature flag that hides/shows a UI element) should reflect immediately.
|
||||
|
||||
Migration
|
||||
|
||||
- Add Prisma model and generate migration: `npx prisma migrate dev --name add_app_setting`
|
||||
- Run `npx prisma generate` and deploy migration.
|
||||
|
||||
Rollout
|
||||
|
||||
- Start with critical settings (e.g., ENABLE_TRADING=false, ANALYSIS_BACKGROUND=true, PRICE_STREAM_URL).
|
||||
- Add a feature flag toggle to hide advanced JSON edits behind "advanced" switch.
|
||||
|
||||
Next steps after approval
|
||||
|
||||
1) Write this spec file into docs (done). (current step)
|
||||
2) Create PR: add Prisma model, SettingsService, API routes, UI route + components, tests.
|
||||
3) Run migrations locally and in staging, verify behavior.
|
||||
4) Add audit logging for changes.
|
||||
|
||||
Notes
|
||||
|
||||
- Multi-instance real-time propagation: simple approach (polling or Redis pub/sub) is deferred to future work if needed.
|
||||
- Keep settings small and typed where possible to avoid fragile JSON edits.
|
||||
|
||||
Files to create/change (implementation plan will enumerate exact edits)
|
||||
|
||||
- prisma/schema.prisma (add AppSetting model)
|
||||
- app/lib/settings.server.ts (service)
|
||||
- app/routes/api/admin/settings/index.ts
|
||||
- app/routes/api/admin/settings/[key].ts
|
||||
- app/routes/settings.tsx
|
||||
- tests: unit + integration + e2e
|
||||
|
||||
Please review and confirm; after approval I'll: 1) commit this spec, 2) produce an implementation plan (writing-plans skill) and then implement if you ask.
|
||||
@@ -1,328 +0,0 @@
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "react_router_config_ts",
|
||||
"label": "react-router.config.ts",
|
||||
"file_type": "code",
|
||||
"source_file": "react-router.config.ts",
|
||||
"source_location": "L1"
|
||||
},
|
||||
{
|
||||
"id": "vite_config_ts",
|
||||
"label": "vite.config.ts",
|
||||
"file_type": "code",
|
||||
"source_file": "vite.config.ts",
|
||||
"source_location": "L1"
|
||||
},
|
||||
{
|
||||
"id": "app_root_tsx",
|
||||
"label": "root.tsx",
|
||||
"file_type": "code",
|
||||
"source_file": "app\\root.tsx",
|
||||
"source_location": "L1"
|
||||
},
|
||||
{
|
||||
"id": "app_root_links",
|
||||
"label": "links()",
|
||||
"file_type": "code",
|
||||
"source_file": "app\\root.tsx",
|
||||
"source_location": "L13"
|
||||
},
|
||||
{
|
||||
"id": "app_root_layout",
|
||||
"label": "Layout()",
|
||||
"file_type": "code",
|
||||
"source_file": "app\\root.tsx",
|
||||
"source_location": "L26"
|
||||
},
|
||||
{
|
||||
"id": "app_root_app",
|
||||
"label": "App()",
|
||||
"file_type": "code",
|
||||
"source_file": "app\\root.tsx",
|
||||
"source_location": "L44"
|
||||
},
|
||||
{
|
||||
"id": "app_root_errorboundary",
|
||||
"label": "ErrorBoundary()",
|
||||
"file_type": "code",
|
||||
"source_file": "app\\root.tsx",
|
||||
"source_location": "L48"
|
||||
},
|
||||
{
|
||||
"id": "app_routes_ts",
|
||||
"label": "routes.ts",
|
||||
"file_type": "code",
|
||||
"source_file": "app\\routes.ts",
|
||||
"source_location": "L1"
|
||||
},
|
||||
{
|
||||
"id": "app_routes_home_tsx",
|
||||
"label": "home.tsx",
|
||||
"file_type": "code",
|
||||
"source_file": "app\\routes\\home.tsx",
|
||||
"source_location": "L1"
|
||||
},
|
||||
{
|
||||
"id": "routes_home_meta",
|
||||
"label": "meta()",
|
||||
"file_type": "code",
|
||||
"source_file": "app\\routes\\home.tsx",
|
||||
"source_location": "L4"
|
||||
},
|
||||
{
|
||||
"id": "routes_home_home",
|
||||
"label": "Home()",
|
||||
"file_type": "code",
|
||||
"source_file": "app\\routes\\home.tsx",
|
||||
"source_location": "L11"
|
||||
},
|
||||
{
|
||||
"id": "app_welcome_welcome_tsx",
|
||||
"label": "welcome.tsx",
|
||||
"file_type": "code",
|
||||
"source_file": "app\\welcome\\welcome.tsx",
|
||||
"source_location": "L1"
|
||||
},
|
||||
{
|
||||
"id": "welcome_welcome_welcome",
|
||||
"label": "Welcome()",
|
||||
"file_type": "code",
|
||||
"source_file": "app\\welcome\\welcome.tsx",
|
||||
"source_location": "L4"
|
||||
},
|
||||
{
|
||||
"id": "welcome_welcome_resources",
|
||||
"label": "resources",
|
||||
"file_type": "code",
|
||||
"source_file": "app\\welcome\\welcome.tsx",
|
||||
"source_location": "L49"
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"source": "react_router_config_ts",
|
||||
"target": "config",
|
||||
"relation": "imports_from",
|
||||
"context": "import",
|
||||
"confidence": "EXTRACTED",
|
||||
"source_file": "react-router.config.ts",
|
||||
"source_location": "L1",
|
||||
"weight": 1.0
|
||||
},
|
||||
{
|
||||
"source": "vite_config_ts",
|
||||
"target": "vite",
|
||||
"relation": "imports_from",
|
||||
"context": "import",
|
||||
"confidence": "EXTRACTED",
|
||||
"source_file": "vite.config.ts",
|
||||
"source_location": "L1",
|
||||
"weight": 1.0
|
||||
},
|
||||
{
|
||||
"source": "vite_config_ts",
|
||||
"target": "vite",
|
||||
"relation": "imports_from",
|
||||
"context": "import",
|
||||
"confidence": "EXTRACTED",
|
||||
"source_file": "vite.config.ts",
|
||||
"source_location": "L2",
|
||||
"weight": 1.0
|
||||
},
|
||||
{
|
||||
"source": "vite_config_ts",
|
||||
"target": "vite",
|
||||
"relation": "imports_from",
|
||||
"context": "import",
|
||||
"confidence": "EXTRACTED",
|
||||
"source_file": "vite.config.ts",
|
||||
"source_location": "L3",
|
||||
"weight": 1.0
|
||||
},
|
||||
{
|
||||
"source": "app_root_tsx",
|
||||
"target": "react_router",
|
||||
"relation": "imports_from",
|
||||
"context": "import",
|
||||
"confidence": "EXTRACTED",
|
||||
"source_file": "app\\root.tsx",
|
||||
"source_location": "L1",
|
||||
"weight": 1.0
|
||||
},
|
||||
{
|
||||
"source": "app_root_tsx",
|
||||
"target": "c_users_henry_programming_aitrader_app_types_root",
|
||||
"relation": "imports_from",
|
||||
"context": "import",
|
||||
"confidence": "EXTRACTED",
|
||||
"source_file": "app\\root.tsx",
|
||||
"source_location": "L10",
|
||||
"weight": 1.0
|
||||
},
|
||||
{
|
||||
"source": "app_root_tsx",
|
||||
"target": "types_root_route",
|
||||
"relation": "imports",
|
||||
"context": "import",
|
||||
"confidence": "EXTRACTED",
|
||||
"source_file": "app\\root.tsx",
|
||||
"source_location": "L10",
|
||||
"weight": 1.0
|
||||
},
|
||||
{
|
||||
"source": "app_root_tsx",
|
||||
"target": "c_users_henry_programming_aitrader_app_app_css",
|
||||
"relation": "imports_from",
|
||||
"context": "import",
|
||||
"confidence": "EXTRACTED",
|
||||
"source_file": "app\\root.tsx",
|
||||
"source_location": "L11",
|
||||
"weight": 1.0
|
||||
},
|
||||
{
|
||||
"source": "app_root_tsx",
|
||||
"target": "app_root_links",
|
||||
"relation": "contains",
|
||||
"confidence": "EXTRACTED",
|
||||
"source_file": "app\\root.tsx",
|
||||
"source_location": "L13",
|
||||
"weight": 1.0
|
||||
},
|
||||
{
|
||||
"source": "app_root_tsx",
|
||||
"target": "app_root_layout",
|
||||
"relation": "contains",
|
||||
"confidence": "EXTRACTED",
|
||||
"source_file": "app\\root.tsx",
|
||||
"source_location": "L26",
|
||||
"weight": 1.0
|
||||
},
|
||||
{
|
||||
"source": "app_root_tsx",
|
||||
"target": "app_root_app",
|
||||
"relation": "contains",
|
||||
"confidence": "EXTRACTED",
|
||||
"source_file": "app\\root.tsx",
|
||||
"source_location": "L44",
|
||||
"weight": 1.0
|
||||
},
|
||||
{
|
||||
"source": "app_root_tsx",
|
||||
"target": "app_root_errorboundary",
|
||||
"relation": "contains",
|
||||
"confidence": "EXTRACTED",
|
||||
"source_file": "app\\root.tsx",
|
||||
"source_location": "L48",
|
||||
"weight": 1.0
|
||||
},
|
||||
{
|
||||
"source": "app_routes_ts",
|
||||
"target": "routes",
|
||||
"relation": "imports_from",
|
||||
"context": "import",
|
||||
"confidence": "EXTRACTED",
|
||||
"source_file": "app\\routes.ts",
|
||||
"source_location": "L1",
|
||||
"weight": 1.0
|
||||
},
|
||||
{
|
||||
"source": "app_routes_home_tsx",
|
||||
"target": "c_users_henry_programming_aitrader_app_routes_types_home",
|
||||
"relation": "imports_from",
|
||||
"context": "import",
|
||||
"confidence": "EXTRACTED",
|
||||
"source_file": "app\\routes\\home.tsx",
|
||||
"source_location": "L1",
|
||||
"weight": 1.0
|
||||
},
|
||||
{
|
||||
"source": "app_routes_home_tsx",
|
||||
"target": "types_home_route",
|
||||
"relation": "imports",
|
||||
"context": "import",
|
||||
"confidence": "EXTRACTED",
|
||||
"source_file": "app\\routes\\home.tsx",
|
||||
"source_location": "L1",
|
||||
"weight": 1.0
|
||||
},
|
||||
{
|
||||
"source": "app_routes_home_tsx",
|
||||
"target": "app_welcome_welcome_tsx",
|
||||
"relation": "imports_from",
|
||||
"context": "import",
|
||||
"confidence": "EXTRACTED",
|
||||
"source_file": "app\\routes\\home.tsx",
|
||||
"source_location": "L2",
|
||||
"weight": 1.0
|
||||
},
|
||||
{
|
||||
"source": "app_routes_home_tsx",
|
||||
"target": "welcome_welcome_welcome",
|
||||
"relation": "imports",
|
||||
"context": "import",
|
||||
"confidence": "EXTRACTED",
|
||||
"source_file": "app\\routes\\home.tsx",
|
||||
"source_location": "L2",
|
||||
"weight": 1.0
|
||||
},
|
||||
{
|
||||
"source": "app_routes_home_tsx",
|
||||
"target": "routes_home_meta",
|
||||
"relation": "contains",
|
||||
"confidence": "EXTRACTED",
|
||||
"source_file": "app\\routes\\home.tsx",
|
||||
"source_location": "L4",
|
||||
"weight": 1.0
|
||||
},
|
||||
{
|
||||
"source": "app_routes_home_tsx",
|
||||
"target": "routes_home_home",
|
||||
"relation": "contains",
|
||||
"confidence": "EXTRACTED",
|
||||
"source_file": "app\\routes\\home.tsx",
|
||||
"source_location": "L11",
|
||||
"weight": 1.0
|
||||
},
|
||||
{
|
||||
"source": "app_welcome_welcome_tsx",
|
||||
"target": "c_users_henry_programming_aitrader_app_welcome_logo_dark_svg",
|
||||
"relation": "imports_from",
|
||||
"context": "import",
|
||||
"confidence": "EXTRACTED",
|
||||
"source_file": "app\\welcome\\welcome.tsx",
|
||||
"source_location": "L1",
|
||||
"weight": 1.0
|
||||
},
|
||||
{
|
||||
"source": "app_welcome_welcome_tsx",
|
||||
"target": "c_users_henry_programming_aitrader_app_welcome_logo_light_svg",
|
||||
"relation": "imports_from",
|
||||
"context": "import",
|
||||
"confidence": "EXTRACTED",
|
||||
"source_file": "app\\welcome\\welcome.tsx",
|
||||
"source_location": "L2",
|
||||
"weight": 1.0
|
||||
},
|
||||
{
|
||||
"source": "app_welcome_welcome_tsx",
|
||||
"target": "welcome_welcome_welcome",
|
||||
"relation": "contains",
|
||||
"confidence": "EXTRACTED",
|
||||
"source_file": "app\\welcome\\welcome.tsx",
|
||||
"source_location": "L4",
|
||||
"weight": 1.0
|
||||
},
|
||||
{
|
||||
"source": "app_welcome_welcome_tsx",
|
||||
"target": "welcome_welcome_resources",
|
||||
"relation": "contains",
|
||||
"confidence": "EXTRACTED",
|
||||
"source_file": "app\\welcome\\welcome.tsx",
|
||||
"source_location": "L49",
|
||||
"weight": 1.0
|
||||
}
|
||||
],
|
||||
"input_tokens": 0,
|
||||
"output_tokens": 0
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
{"nodes": [], "edges": [], "hyperedges": []}
|
||||
@@ -1 +0,0 @@
|
||||
{"files": {"code": ["C:\\Users\\Henry\\programming\\AITrader\\react-router.config.ts", "C:\\Users\\Henry\\programming\\AITrader\\vite.config.ts", "C:\\Users\\Henry\\programming\\AITrader\\app\\root.tsx", "C:\\Users\\Henry\\programming\\AITrader\\app\\routes.ts", "C:\\Users\\Henry\\programming\\AITrader\\app\\routes\\home.tsx", "C:\\Users\\Henry\\programming\\AITrader\\app\\welcome\\welcome.tsx"], "document": ["C:\\Users\\Henry\\programming\\AITrader\\AGENTS.md", "C:\\Users\\Henry\\programming\\AITrader\\README.md"], "paper": [], "image": ["C:\\Users\\Henry\\programming\\AITrader\\app\\welcome\\logo-dark.svg", "C:\\Users\\Henry\\programming\\AITrader\\app\\welcome\\logo-light.svg"], "video": []}, "total_files": 10, "total_words": 2379, "needs_graph": false, "warning": "Corpus is ~2,379 words - fits in a single context window. You may not need a graph.", "skipped_sensitive": [], "graphifyignore_patterns": 0}
|
||||
@@ -1,10 +0,0 @@
|
||||
C:\Users\Henry\programming\AITrader\react-router.config.ts
|
||||
C:\Users\Henry\programming\AITrader\vite.config.ts
|
||||
C:\Users\Henry\programming\AITrader\app\root.tsx
|
||||
C:\Users\Henry\programming\AITrader\app\routes.ts
|
||||
C:\Users\Henry\programming\AITrader\app\routes\home.tsx
|
||||
C:\Users\Henry\programming\AITrader\app\welcome\welcome.tsx
|
||||
C:\Users\Henry\programming\AITrader\AGENTS.md
|
||||
C:\Users\Henry\programming\AITrader\README.md
|
||||
C:\Users\Henry\programming\AITrader\app\welcome\logo-dark.svg
|
||||
C:\Users\Henry\programming\AITrader\app\welcome\logo-light.svg
|
||||
Generated
+259
-5
@@ -10,6 +10,8 @@
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@react-router/node": "7.15.0",
|
||||
"@react-router/serve": "7.15.0",
|
||||
"bullmq": "^5.76.8",
|
||||
"ioredis": "^5.10.1",
|
||||
"isbot": "^5.1.36",
|
||||
"lightweight-charts": "^5.2.0",
|
||||
"react": "^19.2.6",
|
||||
@@ -1367,6 +1369,12 @@
|
||||
"deprecated": "Use @eslint/object-schema instead",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@ioredis/commands": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz",
|
||||
"integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
@@ -1731,6 +1739,84 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz",
|
||||
"integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz",
|
||||
"integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz",
|
||||
"integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz",
|
||||
"integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz",
|
||||
"integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz",
|
||||
"integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
|
||||
@@ -3648,6 +3734,23 @@
|
||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bullmq": {
|
||||
"version": "5.76.8",
|
||||
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.76.8.tgz",
|
||||
"integrity": "sha512-v3WTwA8diFtsADaJ8eK2ozyi2CYK9rDZCeoKF+dIPF/MUL8HxAOa3H72Gmu1lC4yKlho6t1PwNr/QpDVqaNEZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cron-parser": "4.9.0",
|
||||
"ioredis": "5.10.1",
|
||||
"msgpackr": "2.0.1",
|
||||
"node-abort-controller": "3.1.1",
|
||||
"semver": "7.8.0",
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.22.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
@@ -3783,6 +3886,15 @@
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/cluster-key-slot": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
|
||||
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
@@ -3919,6 +4031,18 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/cron-parser": {
|
||||
"version": "4.9.0",
|
||||
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz",
|
||||
"integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"luxon": "^3.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -4020,6 +4144,15 @@
|
||||
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/denque": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
|
||||
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/depd": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
@@ -4053,7 +4186,7 @@
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -5115,6 +5248,30 @@
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ioredis": {
|
||||
"version": "5.10.1",
|
||||
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz",
|
||||
"integrity": "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ioredis/commands": "1.5.1",
|
||||
"cluster-key-slot": "^1.1.0",
|
||||
"debug": "^4.3.4",
|
||||
"denque": "^2.1.0",
|
||||
"lodash.defaults": "^4.2.0",
|
||||
"lodash.isarguments": "^3.1.0",
|
||||
"redis-errors": "^1.2.0",
|
||||
"redis-parser": "^3.0.0",
|
||||
"standard-as-callback": "^2.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.22.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/ioredis"
|
||||
}
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
||||
@@ -5661,6 +5818,18 @@
|
||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.defaults": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
|
||||
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isarguments": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
|
||||
"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.merge": {
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||
@@ -5677,6 +5846,15 @@
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/luxon": {
|
||||
"version": "3.7.2",
|
||||
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
|
||||
"integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/lz-string": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||
@@ -5874,6 +6052,37 @@
|
||||
"safe-buffer": "^5.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/msgpackr": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.0.1.tgz",
|
||||
"integrity": "sha512-9J+tqTEsbHqY8YohazYgty7LgerFIWxvMLpUjqETSmjHojtJm2WnX2kK/2a1fLI7CO7ERP1YSEUXMucz4j+yBA==",
|
||||
"license": "MIT",
|
||||
"optionalDependencies": {
|
||||
"msgpackr-extract": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/msgpackr-extract": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz",
|
||||
"integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"node-gyp-build-optional-packages": "5.2.2"
|
||||
},
|
||||
"bin": {
|
||||
"download-msgpackr-prebuilds": "bin/download-prebuilds.js"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3",
|
||||
"@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3",
|
||||
"@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3",
|
||||
"@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3",
|
||||
"@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3",
|
||||
"@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.12",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
||||
@@ -5928,6 +6137,27 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/node-abort-controller": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz",
|
||||
"integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-gyp-build-optional-packages": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
|
||||
"integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"node-gyp-build-optional-packages": "bin.js",
|
||||
"node-gyp-build-optional-packages-optional": "optional.js",
|
||||
"node-gyp-build-optional-packages-test": "build-test.js"
|
||||
}
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.44",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.44.tgz",
|
||||
@@ -6521,6 +6751,27 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/redis-errors": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
|
||||
"integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/redis-parser": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
|
||||
"integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"redis-errors": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
@@ -6752,7 +7003,6 @@
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
|
||||
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
@@ -6963,6 +7213,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/standard-as-callback": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
|
||||
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
@@ -7183,9 +7439,7 @@
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.21.0",
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@react-router/node": "7.15.0",
|
||||
"@react-router/serve": "7.15.0",
|
||||
"bullmq": "^5.76.8",
|
||||
"ioredis": "^5.10.1",
|
||||
"isbot": "^5.1.36",
|
||||
"lightweight-charts": "^5.2.0",
|
||||
"react": "^19.2.6",
|
||||
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
@@ -0,0 +1,4 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Stock" ADD COLUMN "lastDecision" TEXT;
|
||||
ALTER TABLE "Stock" ADD COLUMN "lastExecutionPlan" TEXT;
|
||||
ALTER TABLE "Stock" ADD COLUMN "lastExplanation" TEXT;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Stock" ADD COLUMN "lastJobId" TEXT;
|
||||
@@ -0,0 +1,17 @@
|
||||
-- RedefineTables
|
||||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_AppSetting" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"key" TEXT NOT NULL,
|
||||
"value" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
"updatedBy" TEXT
|
||||
);
|
||||
INSERT INTO "new_AppSetting" ("description", "id", "key", "updatedAt", "updatedBy", "value") SELECT "description", "id", "key", "updatedAt", "updatedBy", "value" FROM "AppSetting";
|
||||
DROP TABLE "AppSetting";
|
||||
ALTER TABLE "new_AppSetting" RENAME TO "AppSetting";
|
||||
CREATE UNIQUE INDEX "AppSetting_key_key" ON "AppSetting"("key");
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
@@ -0,0 +1,18 @@
|
||||
-- Manual migration to add AppSetting table
|
||||
-- This SQL is for SQLite and stores JSON in a TEXT column
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "AppSetting" (
|
||||
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
"key" TEXT NOT NULL UNIQUE,
|
||||
"value" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"updatedAt" DATETIME NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
"updatedBy" TEXT
|
||||
);
|
||||
|
||||
-- Optional: trigger to update updatedAt on row update
|
||||
CREATE TRIGGER IF NOT EXISTS "AppSetting_updatedAt"
|
||||
AFTER UPDATE ON "AppSetting"
|
||||
BEGIN
|
||||
UPDATE "AppSetting" SET "updatedAt" = CURRENT_TIMESTAMP WHERE "id" = NEW."id";
|
||||
END;
|
||||
@@ -1,3 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
provider = "sqlite"
|
||||
@@ -12,6 +12,19 @@ model Stock {
|
||||
id String @id @default(cuid())
|
||||
ticker String @unique
|
||||
notes String?
|
||||
lastDecision String?
|
||||
lastExplanation String?
|
||||
lastExecutionPlan String?
|
||||
lastJobId String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model AppSetting {
|
||||
id Int @id @default(autoincrement())
|
||||
key String @unique
|
||||
value String
|
||||
description String?
|
||||
updatedAt DateTime @updatedAt
|
||||
updatedBy String?
|
||||
}
|
||||
|
||||
@@ -3,5 +3,6 @@ import type { Config } from "@react-router/dev/config";
|
||||
export default {
|
||||
// Config options...
|
||||
// Server-side render by default, to enable SPA mode set this to `false`
|
||||
// Server-side render by default, to enable SPA mode set this to `false`
|
||||
ssr: true,
|
||||
} satisfies Config;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
{
|
||||
"status": "passed",
|
||||
"failedTests": []
|
||||
"status": "failed",
|
||||
"failedTests": [
|
||||
"87418b536bb3b16b9965-5b389d46641fb5894dfa"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: e2e\settings.spec.ts >> admin can view settings page
|
||||
- Location: tests\e2e\settings.spec.ts:3:1
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: expect(locator).toBeVisible() failed
|
||||
|
||||
Locator: locator('text=Settings')
|
||||
Expected: visible
|
||||
Timeout: 5000ms
|
||||
Error: element(s) not found
|
||||
|
||||
Call log:
|
||||
- Expect "toBeVisible" with timeout 5000ms
|
||||
- waiting for locator('text=Settings')
|
||||
|
||||
```
|
||||
|
||||
```yaml
|
||||
- main:
|
||||
- heading "404" [level=1]
|
||||
- paragraph: The requested page could not be found.
|
||||
```
|
||||
|
||||
# Test source
|
||||
|
||||
```ts
|
||||
1 | import { test, expect } from '@playwright/test';
|
||||
2 |
|
||||
3 | test('admin can view settings page', async ({ page }) => {
|
||||
4 | await page.goto('http://localhost:5173/settings');
|
||||
> 5 | await expect(page.locator('text=Settings')).toBeVisible();
|
||||
| ^ Error: expect(locator).toBeVisible() failed
|
||||
6 | });
|
||||
7 |
|
||||
```
|
||||
@@ -0,0 +1,6 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('admin can view settings page', async ({ page }) => {
|
||||
await page.goto('http://localhost:5173/settings');
|
||||
await expect(page.locator('text=Settings')).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test("JobHistory shows jobs and job detail navigates", async ({ page }) => {
|
||||
await page.goto("/stocks");
|
||||
await page.waitForSelector("table tbody tr");
|
||||
const firstRow = page.locator("tbody tr").first();
|
||||
const symbol = (await firstRow.locator("td a").textContent()) || "";
|
||||
const ticker = symbol.trim();
|
||||
|
||||
// Enqueue background analyze via API to get deterministic jobId
|
||||
const base = new URL(page.url()).origin;
|
||||
const resp = await page.request.post(`${base}/api/analyze`, {
|
||||
data: { ticker, background: true },
|
||||
});
|
||||
expect([200, 202]).toContain(resp.status());
|
||||
const body = await resp.json();
|
||||
const jobId = body.jobId || body.job?.id;
|
||||
expect(jobId).toBeTruthy();
|
||||
|
||||
// Navigate to stock detail page
|
||||
await page.goto(`/stocks/${ticker}`);
|
||||
|
||||
// Wait up to 10s for JobHistory to show at least one job
|
||||
await page.waitForSelector('text=Job History', { timeout: 10000 });
|
||||
await page.waitForFunction((id) => {
|
||||
const els = Array.from(document.querySelectorAll('div')).filter(el => el.textContent?.includes(id));
|
||||
return els.length > 0;
|
||||
}, jobId, { timeout: 10000 });
|
||||
|
||||
// Click Details for the job (opens internal route)
|
||||
const detailsLink = page.locator(`a:has-text("Details")`).first();
|
||||
await detailsLink.click();
|
||||
|
||||
// Should navigate to /jobs/:jobId
|
||||
await page.waitForSelector('h1');
|
||||
const h1 = await page.locator('h1').textContent();
|
||||
expect(h1).toContain(jobId);
|
||||
|
||||
// Try cancelling the job via API - may be already processed, but endpoint should respond
|
||||
const cancelResp = await page.request.post(`${base}/api/jobs/${jobId}/cancel`);
|
||||
expect(cancelResp.ok()).toBeTruthy();
|
||||
const cancelBody = await cancelResp.json();
|
||||
expect(Object.prototype.hasOwnProperty.call(cancelBody, 'cancelled')).toBeTruthy();
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test("Save button enqueues analyze and upserts stock", async ({ page }) => {
|
||||
await page.goto("/stocks");
|
||||
await page.waitForSelector("table tbody tr");
|
||||
const firstRow = page.locator("tbody tr").first();
|
||||
const symbol = (await firstRow.locator("td a").textContent()) || "";
|
||||
|
||||
// Click the Save button in the first row and wait for analyze enqueue response
|
||||
const [resp] = await Promise.all([
|
||||
page.waitForResponse((r) => r.url().endsWith('/api/analyze') && (r.status() === 202 || r.status() === 200)),
|
||||
firstRow.locator("button:has-text('Save')").click(),
|
||||
]);
|
||||
|
||||
expect([200, 202]).toContain(resp.status());
|
||||
|
||||
// Poll the /api/stocks until the ticker appears (give it a few seconds for background job)
|
||||
const base = new URL(page.url()).origin;
|
||||
let found = false;
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const r = await page.request.get(`${base}/api/stocks`);
|
||||
const list = await r.json();
|
||||
if (list.some((s: any) => s.ticker === symbol.trim())) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
|
||||
expect(found).toBeTruthy();
|
||||
});
|
||||
@@ -5,6 +5,7 @@
|
||||
"**/.client/**/*",
|
||||
".react-router/types/**/*"
|
||||
],
|
||||
"exclude": ["node_modules", "tests", "app/lib/__tests__"],
|
||||
"compilerOptions": {
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
||||
"types": ["node", "vite/client"],
|
||||
|
||||
Reference in New Issue
Block a user