diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..a8fbc84 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,8 @@ +## Summary + +- + +## Checks + +- [ ] No new JavaScript source files were added under `apps/`, `packages/`, or `scripts/` +- [ ] `npm run verify` passes locally diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3409188..adce13d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,7 @@ jobs: - run: npm ci - run: npm run check:workspaces - run: npm run check:lockfile + - run: npm run check:no-js-source - run: npm run check:stage-docs - run: npm run check:runtime-env - run: npm run lint diff --git a/AGENTS.md b/AGENTS.md index 3b3a825..55f2b20 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,7 @@ | Layer | Tech | |-------|------| -| Language | TypeScript (ES2022),前后端全量 `.ts` | +| Language | TypeScript (ES2022),第一方源码目录保持 100% TypeScript | | Frontend | React 18, react-router-dom v6 | | Bundler | Vite 5 (`@vitejs/plugin-react` + `@vanilla-extract/vite-plugin`) | | Test | Vitest (web), `node --test` (backend) | @@ -93,6 +93,8 @@ npm run verify # 完整验证 (所有检查 + 所有测试 + typecheck ## Important Guardrails +- 第一方源码目录 `apps/`、`packages/`、`scripts/` 必须使用 TypeScript;禁止新增 `.js`、`.mjs`、`.cjs` 源文件 +- 生成产物、第三方代码、依赖目录不计入上述规则;当前检查会忽略 `dist`、`node_modules`、`coverage`、`.git`、`.vite` - 样式改动在对应的 `.css.ts` 文件中进行(`apps/web/src/app/styles/` 或组件同目录),不引入任何 `.css` 文件 - Canvas 图表颜色硬编码在 `ConsoleChrome.tsx` 的 `ChartCanvas` 中 - `.env` / `.env.local` 不提交,参考 `.env.example` diff --git a/README.md b/README.md index 37502b3..dca6e31 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,18 @@ QuantPilot is an AI-native quantitative trading platform built as a TypeScript m QuantPilot is not a production live-trading system. It is a platform skeleton and operating surface for controlled quantitative trading workflows, not an unattended trading bot. +## Tech Stack + +| Category | Technology | +|----------|------------| +| Language | TypeScript 5 (100% first-party source, no new JavaScript source files) | +| Frontend | React 18 + react-router-dom 6 | +| Build | Vite 5 | +| Styling | vanilla-extract | +| Backend | Node.js ESM + tsx | +| Testing | Vitest + node --test | +| Package Manager | npm workspaces | + ## What QuantPilot Includes - A multi-workbench web console for dashboard, market, strategy, backtest, risk, execution, trading terminal, agent, notifications, and settings workflows @@ -19,7 +31,6 @@ QuantPilot is not a production live-trading system. It is a platform skeleton an - JWT authentication (`jose`) and AES-256-GCM broker API key encryption for credentials at rest - Server-Sent Events push for live state updates, reducing polling to a 15-second fallback - Verification baselines that protect closed roadmap contracts across platform, research, execution, risk, scheduler, agent, and production-readiness surfaces - ## Platform Scope QuantPilot is designed around four platform-level operating loops: diff --git a/README.zh-CN.md b/README.zh-CN.md index 2b9cdff..803f3c7 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -6,6 +6,18 @@ QuantPilot 是一个以 AI 为核心能力的量化交易平台,采用 TypeScr QuantPilot 不是一个可直接用于实盘的生产交易系统。它当前定位为一套面向受控量化交易流程的平台骨架与运营界面,而不是无人值守自动交易机器人。 +## 技术栈 + +| 类别 | 技术 | +|------|------| +| 语言 | TypeScript 5(第一方源码 100%,禁止新增 JavaScript 源文件) | +| 前端 | React 18 + react-router-dom 6 | +| 构建 | Vite 5 | +| 样式 | vanilla-extract | +| 后端 | Node.js ESM + tsx | +| 测试 | Vitest + node --test | +| 包管理 | npm workspaces | + ## QuantPilot 包含什么 - 一个覆盖 dashboard、market、strategy、backtest、risk、execution、trading terminal、agent、notifications 和 settings 的多工作台前端控制台 @@ -19,7 +31,6 @@ QuantPilot 不是一个可直接用于实盘的生产交易系统。它当前定 - 基于 `jose` 的 JWT 认证与 AES-256-GCM broker API key 静态加密 - Server-Sent Events 实时推送,将轮询降级为 15 秒 fallback - 用于保护平台、研究、执行、风险、调度、Agent 与生产化基线合同的自动化验证体系 - ## 平台范围 QuantPilot 当前围绕四条平台级运行主链路组织能力: diff --git a/apps/api/src/domains/agent/services/analysis-service.js b/apps/api/src/domains/agent/services/analysis-service.ts similarity index 63% rename from apps/api/src/domains/agent/services/analysis-service.js rename to apps/api/src/domains/agent/services/analysis-service.ts index b04cedd..76d40e5 100644 --- a/apps/api/src/domains/agent/services/analysis-service.js +++ b/apps/api/src/domains/agent/services/analysis-service.ts @@ -1,33 +1,72 @@ -// @ts-nocheck import { controlPlaneRuntime } from '../../../../../../packages/control-plane-runtime/src/index.js'; +import type { LLMMessage, LLMTool } from '../../../../../../packages/llm-provider/src/index.js'; import { createLLMProvider } from '../../../../../../packages/llm-provider/src/index.js'; import { listActiveAgentInstructions } from './instruction-service.js'; +import type { AgentIntent } from './intent-service.js'; import { createAgentPlan } from './planning-service.js'; -import { executeAgentTool } from './tools-service.js'; import { ANALYSIS_SYSTEM_PROMPT } from './prompts.js'; +import { executeAgentTool } from './tools-service.js'; -/** - * Tool definitions for LLM function/tool calling. - * These map to executeAgentTool() implementations. - */ -const LLM_TOOLS = [ +type ToolExecutionResult = { + ok: boolean; + tool: string; + summary: string; + data?: Record; +}; + +type ToolCallLogEntry = { + tool: string; + input: Record; + result: ToolExecutionResult; +}; + +type AnalysisNarrative = { + thesis: string; + rationale: string[]; + warnings: string[]; + strategy?: Record | null; + recommendedNextStep: string; + requiresAction: boolean; + actionType: string; +}; + +type AnalysisLoopResult = { + narrative: AnalysisNarrative; + toolCallLog: ToolCallLogEntry[]; +}; + +type RunAgentAnalysisPayload = { + planId?: string; + sessionId?: string; + requestedBy?: string; + intent?: AgentIntent; +}; + +const LLM_TOOLS: LLMTool[] = [ { name: 'strategy_catalog_list', - description: 'List all trading strategies in the catalog with their current status and metrics.', + description: + 'List all trading strategies in the catalog with their current status and metrics.', inputSchema: { type: 'object', properties: {}, required: [] }, }, { name: 'backtest_summary_get', - description: 'Get the backtest center summary: total runs, pending reviews, and health metrics.', + description: + 'Get the backtest center summary: total runs, pending reviews, and health metrics.', inputSchema: { type: 'object', properties: {}, required: [] }, }, { name: 'backtest_runs_list', - description: 'List recent backtest runs with performance metrics like Sharpe ratio and max drawdown.', + description: + 'List recent backtest runs with performance metrics like Sharpe ratio and max drawdown.', inputSchema: { type: 'object', properties: { - status: { type: 'string', description: 'Filter by status: needs_review, completed, failed', enum: ['needs_review', 'completed', 'failed'] }, + status: { + type: 'string', + description: 'Filter by status: needs_review, completed, failed', + enum: ['needs_review', 'completed', 'failed'], + }, }, required: [], }, @@ -60,7 +99,11 @@ const LLM_TOOLS = [ inputSchema: { type: 'object', properties: { - symbols: { type: 'array', items: { type: 'string' }, description: 'List of ticker symbols e.g. ["AAPL", "NVDA"]' }, + symbols: { + type: 'array', + items: { type: 'string' }, + description: 'List of ticker symbols e.g. ["AAPL", "NVDA"]', + }, }, required: ['symbols'], }, @@ -72,55 +115,56 @@ const LLM_TOOLS = [ type: 'object', properties: { symbol: { type: 'string', description: 'Ticker symbol e.g. "AAPL"' }, - days: { type: 'number', description: 'Number of calendar days of history (default 30)' }, + days: { + type: 'number', + description: 'Number of calendar days of history (default 30)', + }, }, required: ['symbol'], }, }, ]; -/** - * Map LLM tool name (underscore format) to executeAgentTool dot-format names. - */ -function llmToolNameToAgentTool(name) { - return name.replace(/_/g, '.').replace(/\.list$/, 's.list').replace(/\.get$/, '.get'); -} - -/** - * Execute a single tool call from LLM and return the result. - */ -async function executeLLMToolCall(toolName, toolInput) { +async function executeLLMToolCall( + toolName: string, + toolInput: Record +): Promise { const dotName = (() => { switch (toolName) { - case 'strategy_catalog_list': return 'strategy.catalog.list'; - case 'backtest_summary_get': return 'backtest.summary.get'; - case 'backtest_runs_list': return 'backtest.runs.list'; - case 'risk_events_list': return 'risk.events.list'; - case 'execution_plans_list': return 'execution.plans.list'; - case 'market_quotes_get': return 'market.quotes.get'; - case 'market_history_get': return 'market.history.get'; - default: return toolName; + case 'strategy_catalog_list': + return 'strategy.catalog.list'; + case 'backtest_summary_get': + return 'backtest.summary.get'; + case 'backtest_runs_list': + return 'backtest.runs.list'; + case 'risk_events_list': + return 'risk.events.list'; + case 'execution_plans_list': + return 'execution.plans.list'; + case 'market_quotes_get': + return 'market.quotes.get'; + case 'market_history_get': + return 'market.history.get'; + default: + return toolName; } })(); - return executeAgentTool({ tool: dotName, args: toolInput || {} }); + return executeAgentTool({ + tool: dotName, + args: toolInput || {}, + }) as Promise; } -/** - * Serialize tool results for LLM context (keep it concise). - */ -function serializeToolResult(result) { +function serializeToolResult(result: ToolExecutionResult): string { if (!result.ok) return `Error: ${result.summary}`; const data = result.data || {}; return JSON.stringify(data, null, 2).slice(0, 3000); } -/** - * Build the initial analysis prompt with intent context. - */ -function buildAnalysisPrompt(intent, dailyBias) { - const biasNote = dailyBias?.length - ? `\n\nActive daily bias instructions:\n${dailyBias.map((b) => `- ${b.body}`).join('\n')}` +function buildAnalysisPrompt(intent: AgentIntent, dailyBias: Array<{ body: string }>): string { + const biasNote = dailyBias.length + ? `\n\nActive daily bias instructions:\n${dailyBias.map((item) => `- ${item.body}`).join('\n')}` : ''; return `Analyze the user's trading request and provide actionable recommendations. @@ -134,17 +178,19 @@ ${biasNote} Please use the available tools to gather relevant data, then provide your analysis in the required JSON format.`; } -/** - * Rule-based fallback narrative when LLM is unavailable. - */ -function buildFallbackNarrative(intent, toolResults = []) { - const resultMap = Object.fromEntries(toolResults.map((r) => [r.tool, r])); - const strategies = resultMap['strategy.catalog.list']?.data?.strategies || []; +function buildFallbackNarrative( + _intent: AgentIntent, + toolResults: ToolExecutionResult[] = [] +): AnalysisNarrative { + const resultMap = Object.fromEntries(toolResults.map((result) => [result.tool, result])); + const strategies = (resultMap['strategy.catalog.list']?.data?.strategies as unknown[]) || []; const backtestSummary = resultMap['backtest.summary.get']?.data || {}; - const riskEvents = resultMap['risk.events.list']?.data?.events || []; - const executionPlans = resultMap['execution.plans.list']?.data?.plans || []; + const riskEvents = + (resultMap['risk.events.list']?.data?.events as Array<{ status?: string }>) || []; - const elevatedRisk = riskEvents.some((e) => e.status === 'risk-off' || e.status === 'attention'); + const elevatedRisk = riskEvents.some( + (event) => event.status === 'risk-off' || event.status === 'attention' + ); const thesis = elevatedRisk ? 'Risk posture is elevated. Review risk events before taking action.' : `Analysis complete. Found ${strategies.length} strategies and ${Number(backtestSummary.completedRuns || 0)} completed backtests.`; @@ -165,22 +211,20 @@ function buildFallbackNarrative(intent, toolResults = []) { }; } -/** - * Run the LLM tool-use loop: gather data → analyze → produce structured report. - * Max 5 tool call rounds to prevent runaway loops. - */ -async function runLLMAnalysisLoop(intent, dailyBias, sessionId) { +async function runLLMAnalysisLoop( + intent: AgentIntent, + dailyBias: Array<{ body: string }> +): Promise { const llm = createLLMProvider(); if (!llm) return null; - const messages = [ + const messages: LLMMessage[] = [ { role: 'user', content: buildAnalysisPrompt(intent, dailyBias) }, ]; + const toolCallLog: ToolCallLogEntry[] = []; + const maxRounds = 5; - const toolCallLog = []; - const MAX_ROUNDS = 5; - - for (let round = 0; round < MAX_ROUNDS; round++) { + for (let round = 0; round < maxRounds; round++) { const response = await llm.chatWithTools(messages, LLM_TOOLS, { systemPrompt: ANALYSIS_SYSTEM_PROMPT, maxTokens: 4096, @@ -192,27 +236,28 @@ async function runLLMAnalysisLoop(intent, dailyBias, sessionId) { return null; } - // If LLM has tool calls, execute them and continue - if (response.stopReason === 'tool_use' && response.toolCalls?.length > 0) { - // Add assistant message with tool calls - const assistantContent = []; + if (response.stopReason === 'tool_use' && response.toolCalls?.length) { + const assistantContent: Array> = []; if (response.content) { assistantContent.push({ type: 'text', text: response.content }); } - for (const tc of response.toolCalls) { - assistantContent.push({ type: 'tool_use', id: tc.id, name: tc.name, input: tc.input }); + for (const toolCall of response.toolCalls) { + assistantContent.push({ + type: 'tool_use', + id: toolCall.id, + name: toolCall.name, + input: toolCall.input, + }); } messages.push({ role: 'assistant', content: assistantContent }); - // Execute all tool calls and collect results - const toolResultContent = []; - for (const tc of response.toolCalls) { - const result = await executeLLMToolCall(tc.name, tc.input); - toolCallLog.push({ tool: tc.name, input: tc.input, result }); - + const toolResultContent: Array> = []; + for (const toolCall of response.toolCalls) { + const result = await executeLLMToolCall(toolCall.name, toolCall.input); + toolCallLog.push({ tool: toolCall.name, input: toolCall.input, result }); toolResultContent.push({ type: 'tool_result', - tool_use_id: tc.id, + tool_use_id: toolCall.id, content: serializeToolResult(result), }); } @@ -220,12 +265,10 @@ async function runLLMAnalysisLoop(intent, dailyBias, sessionId) { continue; } - // LLM has stopped using tools — parse the final JSON response const finalContent = response.content?.trim() || ''; try { - // Handle markdown code blocks if LLM wraps JSON in ``` const jsonStr = finalContent.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, ''); - const parsed = JSON.parse(jsonStr); + const parsed = JSON.parse(jsonStr) as Partial; return { narrative: { thesis: parsed.thesis || 'Analysis complete.', @@ -238,8 +281,11 @@ async function runLLMAnalysisLoop(intent, dailyBias, sessionId) { }, toolCallLog, }; - } catch (parseErr) { - console.error('[analysis-service] Failed to parse LLM JSON response:', parseErr.message); + } catch (parseErr: unknown) { + console.error( + '[analysis-service] Failed to parse LLM JSON response:', + parseErr instanceof Error ? parseErr.message : 'unknown_error' + ); console.error('[analysis-service] Raw content:', finalContent.slice(0, 500)); return null; } @@ -249,8 +295,8 @@ async function runLLMAnalysisLoop(intent, dailyBias, sessionId) { return null; } -export async function runAgentAnalysis(payload = {}) { - const planned = payload.planId +export async function runAgentAnalysis(payload: RunAgentAnalysisPayload = {}) { + const planned: any = payload.planId ? { ok: true, session: payload.sessionId ? controlPlaneRuntime.getAgentSession(payload.sessionId) : null, @@ -275,7 +321,6 @@ export async function runAgentAnalysis(payload = {}) { }; } - // Mark session and plan as running controlPlaneRuntime.updateAgentSession(session.id, { status: 'running' }); controlPlaneRuntime.recordAgentSessionMessage({ sessionId: session.id, @@ -288,17 +333,20 @@ export async function runAgentAnalysis(payload = {}) { }); controlPlaneRuntime.updateAgentPlan(plan.id, { status: 'running', - steps: plan.steps.map((s) => ({ ...s, status: s.toolName ? 'running' : s.status })), + steps: plan.steps.map((step: Record) => ({ + ...step, + status: step.toolName ? 'running' : step.status, + })), }); - // Load daily bias instructions - const dailyBias = listActiveAgentInstructions({ sessionId: session.id, kind: 'daily_bias' }); + const dailyBias = listActiveAgentInstructions({ + sessionId: session.id, + kind: 'daily_bias', + }) as Array<{ body: string }>; - // Run LLM analysis loop (with tool calls) - let analysisResult = await runLLMAnalysisLoop(intent, dailyBias, session.id); + let analysisResult = await runLLMAnalysisLoop(intent as AgentIntent, dailyBias); - // Fallback: gather tool data the old way and use rule-based narrative - const toolResults = []; + const toolResults: ToolExecutionResult[] = []; if (!analysisResult) { controlPlaneRuntime.recordAgentSessionMessage({ sessionId: session.id, @@ -310,25 +358,35 @@ export async function runAgentAnalysis(payload = {}) { metadata: { agentPlanId: plan.id }, }); - for (const step of plan.steps) { + for (const step of plan.steps as Array>) { if (!step.toolName) continue; - const args = step.toolName === 'risk.events.list' ? { limit: 12 } - : step.toolName === 'execution.plans.list' ? { limit: 12 } - : step.toolName === 'backtest.runs.list' && intent.kind === 'request_backtest_review' ? { status: 'needs_review' } - : {}; - const result = await executeAgentTool({ tool: step.toolName, args }); + const args = + step.toolName === 'risk.events.list' + ? { limit: 12 } + : step.toolName === 'execution.plans.list' + ? { limit: 12 } + : step.toolName === 'backtest.runs.list' && intent.kind === 'request_backtest_review' + ? { status: 'needs_review' } + : {}; + const result = (await executeAgentTool({ + tool: String(step.toolName), + args, + })) as ToolExecutionResult; toolResults.push(result); } analysisResult = { - narrative: buildFallbackNarrative(intent, toolResults), - toolCallLog: toolResults.map((r) => ({ tool: r.tool, input: {}, result: r })), + narrative: buildFallbackNarrative(intent as AgentIntent, toolResults), + toolCallLog: toolResults.map((result) => ({ + tool: result.tool, + input: {}, + result, + })), }; } const { narrative, toolCallLog } = analysisResult; - // Build tool call records for storage const llmToolCalls = toolCallLog.map((entry) => ({ tool: entry.tool, status: entry.result?.ok ? 'completed' : 'failed', @@ -347,20 +405,19 @@ export async function runAgentAnalysis(payload = {}) { metadata: { keys: Object.keys(entry.result?.data || {}) }, })); - // Mark steps as completed - const finalizedSteps = plan.steps.map((step) => ({ + const finalizedSteps = (plan.steps as Array>).map((step) => ({ ...step, status: 'completed', - outputSummary: step.kind === 'explain' ? narrative.thesis - : step.kind === 'request_action' ? narrative.recommendedNextStep - : llmToolCalls.find((tc) => tc.tool === step.toolName)?.summary || 'Completed.', + outputSummary: + step.kind === 'explain' + ? narrative.thesis + : step.kind === 'request_action' + ? narrative.recommendedNextStep + : llmToolCalls.find((toolCall) => toolCall.tool === step.toolName)?.summary || + 'Completed.', })); - const planStatus = 'completed'; - const runStatus = 'completed'; const completedAt = new Date().toISOString(); - - // Build the full explanation object (compatible with existing UI) const explanation = { thesis: narrative.thesis, rationale: narrative.rationale, @@ -374,7 +431,7 @@ export async function runAgentAnalysis(payload = {}) { const run = controlPlaneRuntime.recordAgentAnalysisRun({ sessionId: session.id, planId: plan.id, - status: runStatus, + status: 'completed', summary: narrative.thesis, conclusion: narrative.thesis, requestedBy: payload.requestedBy || session.requestedBy || 'operator', @@ -392,7 +449,7 @@ export async function runAgentAnalysis(payload = {}) { }); const updatedPlan = controlPlaneRuntime.updateAgentPlan(plan.id, { - status: planStatus, + status: 'completed', steps: finalizedSteps, metadata: { latestAnalysisRunId: run.id }, }); @@ -402,7 +459,6 @@ export async function runAgentAnalysis(payload = {}) { metadata: { latestAnalysisCompletedAt: completedAt }, }); - // Record summarizing status before final result controlPlaneRuntime.recordAgentSessionMessage({ sessionId: session.id, role: 'system', @@ -413,7 +469,6 @@ export async function runAgentAnalysis(payload = {}) { metadata: { agentPlanId: plan.id, agentAnalysisRunId: run.id }, }); - // Record the main assistant response message controlPlaneRuntime.recordAgentSessionMessage({ sessionId: session.id, role: 'assistant', @@ -421,15 +476,17 @@ export async function runAgentAnalysis(payload = {}) { title: narrative.thesis, body: [ narrative.thesis, - ...(narrative.rationale || []), - ...(narrative.warnings || []), + ...narrative.rationale, + ...narrative.warnings, narrative.recommendedNextStep ? `Next: ${narrative.recommendedNextStep}` : '', - ].filter(Boolean).join(' '), + ] + .filter(Boolean) + .join(' '), requestedBy: payload.requestedBy || session.requestedBy || 'agent', metadata: { agentPlanId: plan.id, agentAnalysisRunId: run.id, - status: runStatus, + status: 'completed', toolCallCount: llmToolCalls.length, hasStrategy: Boolean(narrative.strategy), requiresAction: narrative.requiresAction, diff --git a/apps/api/src/domains/agent/services/intent-service.js b/apps/api/src/domains/agent/services/intent-service.ts similarity index 75% rename from apps/api/src/domains/agent/services/intent-service.js rename to apps/api/src/domains/agent/services/intent-service.ts index 0450ea5..7d87da3 100644 --- a/apps/api/src/domains/agent/services/intent-service.js +++ b/apps/api/src/domains/agent/services/intent-service.ts @@ -1,24 +1,53 @@ -// @ts-nocheck import { controlPlaneRuntime } from '../../../../../../packages/control-plane-runtime/src/index.js'; import { createLLMProvider } from '../../../../../../packages/llm-provider/src/index.js'; import { INTENT_SYSTEM_PROMPT } from './prompts.js'; -function normalizePrompt(prompt = '') { +type ExtractedStrategy = { + description: string; + symbols: string[]; + style: string; +}; + +type ExtractedTrade = { + symbol: string; + side: string; + sizeHint: string; +}; + +export type AgentIntent = { + kind: string; + summary: string; + targetType: string; + targetId: string; + extractedStrategy: ExtractedStrategy | null; + extractedTrade: ExtractedTrade | null; + urgency: string; + requiresApproval: boolean; + requestedMode: string; + confidence: number; + metadata: Record; +}; + +type ParseAgentIntentPayload = { + prompt?: string; + requestedBy?: string; + sessionId?: string; + targetId?: string; +}; + +function normalizePrompt(prompt = ''): string { return String(prompt || '') .replace(/\s+/g, ' ') .trim(); } -function createSessionTitle(prompt) { +function createSessionTitle(prompt: string): string { const trimmed = normalizePrompt(prompt); if (!trimmed) return 'Agent collaboration session'; return trimmed.length > 72 ? `${trimmed.slice(0, 69)}...` : trimmed; } -/** - * Rule-based fallback intent inference (used when LLM is unavailable). - */ -function inferIntentFromRules(prompt, explicitTargetId = '') { +function inferIntentFromRules(prompt: string, explicitTargetId = ''): AgentIntent { const normalized = prompt.toLowerCase(); const urgency = /(urgent|immediately|asap|now|立刻|马上|尽快)/.test(normalized) ? 'high' @@ -32,7 +61,12 @@ function inferIntentFromRules(prompt, explicitTargetId = '') { summary: 'User wants to execute a trade.', targetType: 'symbol', targetId: explicitTargetId || '', - extractedTrade: { symbol: '', side: /sell|卖/.test(normalized) ? 'sell' : 'buy', sizeHint: 'unspecified' }, + extractedStrategy: null, + extractedTrade: { + symbol: '', + side: /sell|卖/.test(normalized) ? 'sell' : 'buy', + sizeHint: 'unspecified', + }, urgency, requiresApproval: false, requestedMode: 'execute_paper', @@ -41,9 +75,14 @@ function inferIntentFromRules(prompt, explicitTargetId = '') { }; } - if (/(执行计划|准备执行|execution prep|prepare execution|execution plan|execution-prep|exec prep)/.test(normalized)) { - const requiresApproval = /(审批|approval|approve|需要审批|is.*approval|confirm.*approval)/.test(normalized); - // Try to extract a strategy ID from the prompt (kebab-case or dotted identifiers) + if ( + /(执行计划|准备执行|execution prep|prepare execution|execution plan|execution-prep|exec prep)/.test( + normalized + ) + ) { + const requiresApproval = /(审批|approval|approve|需要审批|is.*approval|confirm.*approval)/.test( + normalized + ); const idMatch = prompt.match(/[\w-]{4,}(?:[-.][\w-]+)+/); const targetId = explicitTargetId || (idMatch ? idMatch[0] : ''); return { @@ -51,6 +90,8 @@ function inferIntentFromRules(prompt, explicitTargetId = '') { summary: 'Prepare an execution plan for a strategy.', targetType: 'strategy', targetId, + extractedStrategy: null, + extractedTrade: null, urgency, requiresApproval, requestedMode: requiresApproval ? 'request_live' : 'execute_paper', @@ -59,13 +100,16 @@ function inferIntentFromRules(prompt, explicitTargetId = '') { }; } - if (/(策略|strategy|构建|build|设计|create.*strat|momentum|value|mean reversion)/.test(normalized)) { + if ( + /(策略|strategy|构建|build|设计|create.*strat|momentum|value|mean reversion)/.test(normalized) + ) { return { kind: 'build_strategy', summary: 'User wants to build a trading strategy.', targetType: 'unknown', targetId: explicitTargetId || '', extractedStrategy: { description: prompt, symbols: [], style: 'general' }, + extractedTrade: null, urgency, requiresApproval: false, requestedMode: 'read_only', @@ -80,6 +124,8 @@ function inferIntentFromRules(prompt, explicitTargetId = '') { summary: 'Review research and backtest posture.', targetType: 'backtest_run', targetId: explicitTargetId || '', + extractedStrategy: null, + extractedTrade: null, urgency, requiresApproval: false, requestedMode: 'read_only', @@ -94,6 +140,8 @@ function inferIntentFromRules(prompt, explicitTargetId = '') { summary: 'Explain the current risk posture.', targetType: 'unknown', targetId: explicitTargetId || '', + extractedStrategy: null, + extractedTrade: null, urgency, requiresApproval: false, requestedMode: 'read_only', @@ -107,6 +155,8 @@ function inferIntentFromRules(prompt, explicitTargetId = '') { summary: 'Read current platform context and summarize findings.', targetType: 'unknown', targetId: explicitTargetId || '', + extractedStrategy: null, + extractedTrade: null, urgency, requiresApproval: false, requestedMode: 'read_only', @@ -115,10 +165,7 @@ function inferIntentFromRules(prompt, explicitTargetId = '') { }; } -/** - * LLM-powered intent parsing with rule-based fallback. - */ -async function inferIntentWithLLM(prompt, explicitTargetId = '') { +async function inferIntentWithLLM(prompt: string, explicitTargetId = ''): Promise { const llm = createLLMProvider(); if (!llm) { return inferIntentFromRules(prompt, explicitTargetId); @@ -143,7 +190,7 @@ async function inferIntentWithLLM(prompt, explicitTargetId = '') { } try { - const parsed = JSON.parse(response.content.trim()); + const parsed = JSON.parse(response.content.trim()) as Partial; return { kind: parsed.kind || 'read_only_analysis', summary: parsed.summary || prompt, @@ -155,15 +202,22 @@ async function inferIntentWithLLM(prompt, explicitTargetId = '') { requiresApproval: Boolean(parsed.requiresApproval), requestedMode: parsed.requestedMode || 'read_only', confidence: parsed.confidence || 0.8, - metadata: { source: 'llm', model: llm.model, provider: llm.provider }, + metadata: { + source: 'llm', + model: llm.model, + provider: llm.provider, + }, }; - } catch (parseErr) { - console.error('[intent-service] JSON parse error, falling back to rules:', parseErr.message); + } catch (parseErr: unknown) { + console.error( + '[intent-service] JSON parse error, falling back to rules:', + parseErr instanceof Error ? parseErr.message : 'unknown_error' + ); return inferIntentFromRules(prompt, explicitTargetId); } } -export async function parseAgentIntent(payload = {}) { +export async function parseAgentIntent(payload: ParseAgentIntentPayload = {}) { const prompt = normalizePrompt(payload.prompt); if (!prompt) { return { @@ -177,7 +231,6 @@ export async function parseAgentIntent(payload = {}) { const existingSession = payload.sessionId ? controlPlaneRuntime.getAgentSession(payload.sessionId) : null; - const intent = await inferIntentWithLLM(prompt, payload.targetId || ''); const session = existingSession diff --git a/apps/api/src/domains/agent/services/planning-service.js b/apps/api/src/domains/agent/services/planning-service.js deleted file mode 100644 index 6ffe150..0000000 --- a/apps/api/src/domains/agent/services/planning-service.js +++ /dev/null @@ -1,192 +0,0 @@ -// @ts-nocheck -import { controlPlaneRuntime } from '../../../../../../packages/control-plane-runtime/src/index.js'; -import { createLLMProvider } from '../../../../../../packages/llm-provider/src/index.js'; -import { parseAgentIntent } from './intent-service.js'; -import { PLANNING_SYSTEM_PROMPT } from './prompts.js'; - -const AVAILABLE_TOOLS_DESCRIPTION = ` -Read tools: -- strategy.catalog.list: List all strategies -- backtest.summary.get: Get backtest statistics -- backtest.runs.list: List recent backtest runs -- risk.events.list: List risk events -- execution.plans.list: List execution plans -- market.quotes.get: Get current market quotes (args: {symbols: string[]}) -- market.history.get: Get historical OHLCV (args: {symbol: string, days: number}) - -Action tools: -- execution.paper.submit: Submit paper trade (args: {symbol, side, qty, orderType}) -- execution.live.request: Request live trade approval (args: {symbol, side, qty, rationale}) -- backtest.queue: Queue a backtest (args: {strategyDescription, symbols, days}) -`; - -/** - * Build hardcoded fallback steps when LLM is unavailable. - */ -function buildFallbackSteps(intent = {}) { - const baseReadSteps = [ - { kind: 'read', title: 'Load strategy catalog', toolName: 'strategy.catalog.list', description: 'Read strategy catalog.', outputSummary: '', metadata: { domain: 'strategy' } }, - { kind: 'read', title: 'Load backtest summary', toolName: 'backtest.summary.get', description: 'Check research posture.', outputSummary: '', metadata: { domain: 'backtest' } }, - ]; - - switch (intent.kind) { - case 'execute_trade': - return [ - { kind: 'read', title: 'Get market quote', toolName: 'market.quotes.get', description: 'Get current price for the target symbol.', outputSummary: '', metadata: { domain: 'market' } }, - { kind: 'read', title: 'Load risk events', toolName: 'risk.events.list', description: 'Check risk posture before execution.', outputSummary: '', metadata: { domain: 'risk' } }, - { kind: 'execute', title: 'Submit paper order', toolName: 'execution.paper.submit', description: 'Submit order to paper account.', outputSummary: '', metadata: { domain: 'execution' } }, - ]; - case 'build_strategy': - return [ - { kind: 'read', title: 'Load market context', toolName: 'market.quotes.get', description: 'Get current quotes for analysis.', outputSummary: '', metadata: { domain: 'market' } }, - { kind: 'read', title: 'Load strategy catalog', toolName: 'strategy.catalog.list', description: 'Check existing strategies.', outputSummary: '', metadata: { domain: 'strategy' } }, - { kind: 'explain', title: 'Build strategy plan', toolName: '', description: 'Generate strategy based on user description.', outputSummary: '', metadata: { deliverable: 'strategy-plan' } }, - ]; - case 'request_backtest_review': - return [ - { kind: 'read', title: 'Load backtest summary', toolName: 'backtest.summary.get', description: 'Read research summary.', outputSummary: '', metadata: { domain: 'backtest' } }, - { kind: 'read', title: 'Load recent runs', toolName: 'backtest.runs.list', description: 'Inspect run outcomes.', outputSummary: '', metadata: { domain: 'backtest' } }, - { kind: 'explain', title: 'Summarize research posture', toolName: '', description: 'Explain findings.', outputSummary: '', metadata: { deliverable: 'backtest-review' } }, - ]; - case 'request_risk_explanation': - return [ - { kind: 'read', title: 'Load risk events', toolName: 'risk.events.list', description: 'Inspect risk signals.', outputSummary: '', metadata: { domain: 'risk' } }, - { kind: 'read', title: 'Load execution posture', toolName: 'execution.plans.list', description: 'Correlate risk with execution.', outputSummary: '', metadata: { domain: 'execution' } }, - { kind: 'explain', title: 'Explain risk posture', toolName: '', description: 'Produce risk explanation.', outputSummary: '', metadata: { deliverable: 'risk-explanation' } }, - ]; - case 'request_execution_prep': - return [ - { kind: 'read', title: 'Load strategy catalog', toolName: 'strategy.catalog.list', description: 'Read strategy state and readiness.', outputSummary: '', metadata: { domain: 'strategy' } }, - { kind: 'read', title: 'Load execution plans', toolName: 'execution.plans.list', description: 'Check existing execution plans.', outputSummary: '', metadata: { domain: 'execution' } }, - { kind: 'read', title: 'Load risk events', toolName: 'risk.events.list', description: 'Review risk posture before prep.', outputSummary: '', metadata: { domain: 'risk' } }, - { kind: 'explain', title: 'Prepare execution readiness summary', toolName: '', description: 'Summarize execution readiness.', outputSummary: '', metadata: { deliverable: 'execution-prep' } }, - ]; - default: - return [ - ...baseReadSteps, - { kind: 'explain', title: 'Summarize findings', toolName: '', description: 'Prepare read-only analysis.', outputSummary: '', metadata: { deliverable: 'general-analysis' } }, - ]; - } -} - -/** - * Use LLM to generate dynamic plan steps based on intent. - */ -async function buildLLMSteps(intent = {}) { - const llm = createLLMProvider(); - if (!llm) { - return buildFallbackSteps(intent); - } - - const intentContext = ` -Intent kind: ${intent.kind} -Summary: ${intent.summary} -Target: ${intent.targetType} / ${intent.targetId || 'none'} -${intent.extractedStrategy ? `Strategy description: ${intent.extractedStrategy.description}` : ''} -${intent.extractedTrade ? `Trade: ${intent.extractedTrade.side} ${intent.extractedTrade.symbol || 'unspecified'}` : ''} -`; - - const response = await llm.chat( - [{ role: 'user', content: `Create execution steps for this intent:\n${intentContext}\n\nAvailable tools:\n${AVAILABLE_TOOLS_DESCRIPTION}` }], - { - systemPrompt: PLANNING_SYSTEM_PROMPT, - maxTokens: 1024, - temperature: 0.1, - } - ); - - if (!response.ok) { - console.error('[planning-service] LLM error, using fallback steps:', response.error); - return buildFallbackSteps(intent); - } - - try { - const rawSteps = JSON.parse(response.content.trim()); - if (!Array.isArray(rawSteps) || rawSteps.length === 0) { - return buildFallbackSteps(intent); - } - return rawSteps.map((step) => ({ - kind: step.kind || 'read', - title: step.title || 'Step', - toolName: step.toolName || '', - description: step.description || '', - outputSummary: '', - status: 'pending', - metadata: step.metadata || {}, - })); - } catch (parseErr) { - console.error('[planning-service] JSON parse error, using fallback steps:', parseErr.message); - return buildFallbackSteps(intent); - } -} - -function buildPlanSummary(intent = {}) { - switch (intent.kind) { - case 'execute_trade': return `Execute a ${intent.extractedTrade?.side || 'trade'} order${intent.extractedTrade?.symbol ? ` for ${intent.extractedTrade.symbol}` : ''}.`; - case 'build_strategy': return 'Build and analyze a new trading strategy based on user description.'; - case 'request_backtest_review': return 'Review recent research outputs and backtest posture.'; - case 'request_execution_prep': return 'Prepare execution readiness review for a controlled action request.'; - case 'request_risk_explanation': return 'Explain current risk posture using recent signals.'; - default: return 'Read current platform context and prepare a concise analysis.'; - } -} - -export async function createAgentPlan(payload = {}) { - const parsed = payload.intent - ? { ok: true, session: payload.sessionId ? controlPlaneRuntime.getAgentSession(payload.sessionId) : null, intent: payload.intent } - : await parseAgentIntent(payload); - - if (!parsed.ok) return parsed; - - const session = - parsed.session || - (payload.sessionId ? controlPlaneRuntime.getAgentSession(payload.sessionId) : null); - - if (!session) { - return { ok: false, error: 'missing_session', message: 'Agent planning requires a persisted session.' }; - } - - const intent = parsed.intent; - const steps = await buildLLMSteps(intent); - const requiresApproval = intent.requiresApproval || intent.requestedMode === 'request_live'; - - const plan = controlPlaneRuntime.recordAgentPlan({ - sessionId: session.id, - status: 'ready', - summary: buildPlanSummary(intent), - requiresApproval, - requestedBy: payload.requestedBy || session.requestedBy || 'operator', - steps: steps.map((s) => ({ ...s, status: 'pending' })), - metadata: { - intentKind: intent.kind, - targetType: intent.targetType, - targetId: intent.targetId, - requestedMode: intent.requestedMode, - source: 'agent-planner-llm', - confidence: intent.confidence, - }, - }); - - const updatedSession = controlPlaneRuntime.updateAgentSession(session.id, { - status: 'ready', - latestIntent: intent, - latestPlanId: plan.id, - metadata: { planCreatedAt: plan.createdAt }, - }); - controlPlaneRuntime.recordAgentSessionMessage({ - sessionId: session.id, - role: 'assistant', - kind: 'plan', - title: 'Plan prepared', - body: buildPlanSummary(intent), - requestedBy: payload.requestedBy || session.requestedBy || 'agent', - metadata: { - agentPlanId: plan.id, - requiresApproval: plan.requiresApproval, - stepCount: plan.steps.length, - intentKind: intent.kind, - }, - }); - - return { ok: true, session: updatedSession || session, intent, plan }; -} diff --git a/apps/api/src/domains/agent/services/planning-service.ts b/apps/api/src/domains/agent/services/planning-service.ts new file mode 100644 index 0000000..789e64f --- /dev/null +++ b/apps/api/src/domains/agent/services/planning-service.ts @@ -0,0 +1,358 @@ +import { controlPlaneRuntime } from '../../../../../../packages/control-plane-runtime/src/index.js'; +import { createLLMProvider } from '../../../../../../packages/llm-provider/src/index.js'; +import { type AgentIntent, parseAgentIntent } from './intent-service.js'; +import { PLANNING_SYSTEM_PROMPT } from './prompts.js'; + +type PlanStep = { + kind: string; + title: string; + toolName: string; + description: string; + outputSummary: string; + status?: string; + metadata: Record; +}; + +type CreateAgentPlanPayload = { + sessionId?: string; + requestedBy?: string; + intent?: AgentIntent; + prompt?: string; + targetId?: string; +}; + +const AVAILABLE_TOOLS_DESCRIPTION = ` +Read tools: +- strategy.catalog.list: List all strategies +- backtest.summary.get: Get backtest statistics +- backtest.runs.list: List recent backtest runs +- risk.events.list: List risk events +- execution.plans.list: List execution plans +- market.quotes.get: Get current market quotes (args: {symbols: string[]}) +- market.history.get: Get historical OHLCV (args: {symbol: string, days: number}) + +Action tools: +- execution.paper.submit: Submit paper trade (args: {symbol, side, qty, orderType}) +- execution.live.request: Request live trade approval (args: {symbol, side, qty, rationale}) +- backtest.queue: Queue a backtest (args: {strategyDescription, symbols, days}) +`; + +function buildFallbackSteps(intent: Partial = {}): PlanStep[] { + const baseReadSteps: PlanStep[] = [ + { + kind: 'read', + title: 'Load strategy catalog', + toolName: 'strategy.catalog.list', + description: 'Read strategy catalog.', + outputSummary: '', + metadata: { domain: 'strategy' }, + }, + { + kind: 'read', + title: 'Load backtest summary', + toolName: 'backtest.summary.get', + description: 'Check research posture.', + outputSummary: '', + metadata: { domain: 'backtest' }, + }, + ]; + + switch (intent.kind) { + case 'execute_trade': + return [ + { + kind: 'read', + title: 'Get market quote', + toolName: 'market.quotes.get', + description: 'Get current price for the target symbol.', + outputSummary: '', + metadata: { domain: 'market' }, + }, + { + kind: 'read', + title: 'Load risk events', + toolName: 'risk.events.list', + description: 'Check risk posture before execution.', + outputSummary: '', + metadata: { domain: 'risk' }, + }, + { + kind: 'execute', + title: 'Submit paper order', + toolName: 'execution.paper.submit', + description: 'Submit order to paper account.', + outputSummary: '', + metadata: { domain: 'execution' }, + }, + ]; + case 'build_strategy': + return [ + { + kind: 'read', + title: 'Load market context', + toolName: 'market.quotes.get', + description: 'Get current quotes for analysis.', + outputSummary: '', + metadata: { domain: 'market' }, + }, + { + kind: 'read', + title: 'Load strategy catalog', + toolName: 'strategy.catalog.list', + description: 'Check existing strategies.', + outputSummary: '', + metadata: { domain: 'strategy' }, + }, + { + kind: 'explain', + title: 'Build strategy plan', + toolName: '', + description: 'Generate strategy based on user description.', + outputSummary: '', + metadata: { deliverable: 'strategy-plan' }, + }, + ]; + case 'request_backtest_review': + return [ + { + kind: 'read', + title: 'Load backtest summary', + toolName: 'backtest.summary.get', + description: 'Read research summary.', + outputSummary: '', + metadata: { domain: 'backtest' }, + }, + { + kind: 'read', + title: 'Load recent runs', + toolName: 'backtest.runs.list', + description: 'Inspect run outcomes.', + outputSummary: '', + metadata: { domain: 'backtest' }, + }, + { + kind: 'explain', + title: 'Summarize research posture', + toolName: '', + description: 'Explain findings.', + outputSummary: '', + metadata: { deliverable: 'backtest-review' }, + }, + ]; + case 'request_risk_explanation': + return [ + { + kind: 'read', + title: 'Load risk events', + toolName: 'risk.events.list', + description: 'Inspect risk signals.', + outputSummary: '', + metadata: { domain: 'risk' }, + }, + { + kind: 'read', + title: 'Load execution posture', + toolName: 'execution.plans.list', + description: 'Correlate risk with execution.', + outputSummary: '', + metadata: { domain: 'execution' }, + }, + { + kind: 'explain', + title: 'Explain risk posture', + toolName: '', + description: 'Produce risk explanation.', + outputSummary: '', + metadata: { deliverable: 'risk-explanation' }, + }, + ]; + case 'request_execution_prep': + return [ + { + kind: 'read', + title: 'Load strategy catalog', + toolName: 'strategy.catalog.list', + description: 'Read strategy state and readiness.', + outputSummary: '', + metadata: { domain: 'strategy' }, + }, + { + kind: 'read', + title: 'Load execution plans', + toolName: 'execution.plans.list', + description: 'Check existing execution plans.', + outputSummary: '', + metadata: { domain: 'execution' }, + }, + { + kind: 'read', + title: 'Load risk events', + toolName: 'risk.events.list', + description: 'Review risk posture before prep.', + outputSummary: '', + metadata: { domain: 'risk' }, + }, + { + kind: 'explain', + title: 'Prepare execution readiness summary', + toolName: '', + description: 'Summarize execution readiness.', + outputSummary: '', + metadata: { deliverable: 'execution-prep' }, + }, + ]; + default: + return [ + ...baseReadSteps, + { + kind: 'explain', + title: 'Summarize findings', + toolName: '', + description: 'Prepare read-only analysis.', + outputSummary: '', + metadata: { deliverable: 'general-analysis' }, + }, + ]; + } +} + +async function buildLLMSteps(intent: Partial = {}): Promise { + const llm = createLLMProvider(); + if (!llm) { + return buildFallbackSteps(intent); + } + + const intentContext = ` +Intent kind: ${intent.kind} +Summary: ${intent.summary} +Target: ${intent.targetType} / ${intent.targetId || 'none'} +${intent.extractedStrategy ? `Strategy description: ${intent.extractedStrategy.description}` : ''} +${intent.extractedTrade ? `Trade: ${intent.extractedTrade.side} ${intent.extractedTrade.symbol || 'unspecified'}` : ''} +`; + + const response = await llm.chat( + [ + { + role: 'user', + content: `Create execution steps for this intent:\n${intentContext}\n\nAvailable tools:\n${AVAILABLE_TOOLS_DESCRIPTION}`, + }, + ], + { + systemPrompt: PLANNING_SYSTEM_PROMPT, + maxTokens: 1024, + temperature: 0.1, + } + ); + + if (!response.ok) { + console.error('[planning-service] LLM error, using fallback steps:', response.error); + return buildFallbackSteps(intent); + } + + try { + const rawSteps = JSON.parse(response.content.trim()) as Array>; + if (!Array.isArray(rawSteps) || rawSteps.length === 0) { + return buildFallbackSteps(intent); + } + return rawSteps.map((step) => ({ + kind: step.kind || 'read', + title: step.title || 'Step', + toolName: step.toolName || '', + description: step.description || '', + outputSummary: '', + status: 'pending', + metadata: step.metadata || {}, + })); + } catch (parseErr: unknown) { + console.error( + '[planning-service] JSON parse error, using fallback steps:', + parseErr instanceof Error ? parseErr.message : 'unknown_error' + ); + return buildFallbackSteps(intent); + } +} + +function buildPlanSummary(intent: Partial = {}): string { + switch (intent.kind) { + case 'execute_trade': + return `Execute a ${intent.extractedTrade?.side || 'trade'} order${intent.extractedTrade?.symbol ? ` for ${intent.extractedTrade.symbol}` : ''}.`; + case 'build_strategy': + return 'Build and analyze a new trading strategy based on user description.'; + case 'request_backtest_review': + return 'Review recent research outputs and backtest posture.'; + case 'request_execution_prep': + return 'Prepare execution readiness review for a controlled action request.'; + case 'request_risk_explanation': + return 'Explain current risk posture using recent signals.'; + default: + return 'Read current platform context and prepare a concise analysis.'; + } +} + +export async function createAgentPlan(payload: CreateAgentPlanPayload = {}) { + const parsed = payload.intent + ? { + ok: true, + session: payload.sessionId ? controlPlaneRuntime.getAgentSession(payload.sessionId) : null, + intent: payload.intent, + } + : await parseAgentIntent(payload); + + if (!parsed.ok) return parsed; + + const session = + parsed.session || + (payload.sessionId ? controlPlaneRuntime.getAgentSession(payload.sessionId) : null); + + if (!session) { + return { + ok: false, + error: 'missing_session', + message: 'Agent planning requires a persisted session.', + }; + } + + const intent = parsed.intent; + const steps = await buildLLMSteps(intent); + const requiresApproval = intent.requiresApproval || intent.requestedMode === 'request_live'; + + const plan = controlPlaneRuntime.recordAgentPlan({ + sessionId: session.id, + status: 'ready', + summary: buildPlanSummary(intent), + requiresApproval, + requestedBy: payload.requestedBy || session.requestedBy || 'operator', + steps: steps.map((step) => ({ ...step, status: 'pending' })), + metadata: { + intentKind: intent.kind, + targetType: intent.targetType, + targetId: intent.targetId, + requestedMode: intent.requestedMode, + source: 'agent-planner-llm', + confidence: intent.confidence, + }, + }); + + const updatedSession = controlPlaneRuntime.updateAgentSession(session.id, { + status: 'ready', + latestIntent: intent, + latestPlanId: plan.id, + metadata: { planCreatedAt: plan.createdAt }, + }); + controlPlaneRuntime.recordAgentSessionMessage({ + sessionId: session.id, + role: 'assistant', + kind: 'plan', + title: 'Plan prepared', + body: buildPlanSummary(intent), + requestedBy: payload.requestedBy || session.requestedBy || 'agent', + metadata: { + agentPlanId: plan.id, + requiresApproval: plan.requiresApproval, + stepCount: plan.steps.length, + intentKind: intent.kind, + }, + }); + + return { ok: true, session: updatedSession || session, intent, plan }; +} diff --git a/apps/api/src/domains/agent/services/prompts.js b/apps/api/src/domains/agent/services/prompts.ts similarity index 99% rename from apps/api/src/domains/agent/services/prompts.js rename to apps/api/src/domains/agent/services/prompts.ts index 1aeeeed..2c64c1d 100644 --- a/apps/api/src/domains/agent/services/prompts.js +++ b/apps/api/src/domains/agent/services/prompts.ts @@ -1,4 +1,3 @@ -// @ts-nocheck /** * LLM system prompts for the Agent analysis pipeline. * Centralized here for easy tuning and A/B testing. diff --git a/apps/api/src/domains/market/services/market-data-service.js b/apps/api/src/domains/market/services/market-data-service.ts similarity index 58% rename from apps/api/src/domains/market/services/market-data-service.js rename to apps/api/src/domains/market/services/market-data-service.ts index 5198de3..365fd09 100644 --- a/apps/api/src/domains/market/services/market-data-service.js +++ b/apps/api/src/domains/market/services/market-data-service.ts @@ -1,20 +1,51 @@ -// @ts-nocheck /** * Market data service — fetches from Alpaca when configured, falls back to * trading-engine synthetic data when QUANTPILOT_USE_MOCK_DATA=true or no credentials. */ import { generateHistoricalOhlcv } from '../../../../../../packages/trading-engine/src/backtest/data.js'; +type OhlcvBar = { + time: string; + open: number; + high: number; + low: number; + close: number; + volume: number; +}; + +type HistoricalBarsResult = { + ok: boolean; + symbol: string; + bars: OhlcvBar[]; + source: string; + timeframe?: string; + error?: string; +}; + +type MarketQuote = { + symbol: string; + price: number; + change: number; + changePct: number; + volume: number; +}; + +type MarketQuotesResult = { + ok: boolean; + quotes: MarketQuote[]; + source?: string; + note?: string; + error?: string; +}; + const ALPACA_DATA_BASE = 'https://data.alpaca.markets'; const ALPACA_KEY_ID = () => process.env.ALPACA_KEY_ID || ''; const ALPACA_SECRET_KEY = () => process.env.ALPACA_SECRET_KEY || ''; const ALPACA_DATA_FEED = () => process.env.ALPACA_DATA_FEED || 'iex'; const USE_MOCK = () => - process.env.QUANTPILOT_USE_MOCK_DATA === 'true' || - !ALPACA_KEY_ID() || - !ALPACA_SECRET_KEY(); + process.env.QUANTPILOT_USE_MOCK_DATA === 'true' || !ALPACA_KEY_ID() || !ALPACA_SECRET_KEY(); -function alpacaHeaders() { +function alpacaHeaders(): Record { return { Accept: 'application/json', 'APCA-API-KEY-ID': ALPACA_KEY_ID(), @@ -22,9 +53,9 @@ function alpacaHeaders() { }; } -function normalizeAlpacaBar(bar) { +function normalizeAlpacaBar(bar: Record): OhlcvBar { return { - time: bar.t ? bar.t.split('T')[0] : '', + time: typeof bar.t === 'string' ? bar.t.split('T')[0] : '', open: Number(bar.o || 0), high: Number(bar.h || 0), low: Number(bar.l || 0), @@ -42,7 +73,11 @@ function normalizeAlpacaBar(bar) { * @param {string} [timeframe] - "1Day" | "1Hour" | "15Min" (default: "1Day") * @returns {Promise<{ok: boolean, symbol: string, bars: Array, source: string}>} */ -export async function getHistoricalBars(symbol, days = 90, timeframe = '1Day') { +export async function getHistoricalBars( + symbol: string, + days = 90, + timeframe = '1Day' +): Promise { if (!symbol) { return { ok: false, symbol: '', bars: [], source: 'none', error: 'symbol is required' }; } @@ -78,15 +113,21 @@ export async function getHistoricalBars(symbol, days = 90, timeframe = '1Day') { if (!response.ok) { // Fallback to synthetic data on API error - console.warn(`[market-data] Alpaca bars error HTTP ${response.status} for ${upperSymbol}, using synthetic fallback`); + console.warn( + `[market-data] Alpaca bars error HTTP ${response.status} for ${upperSymbol}, using synthetic fallback` + ); const endD = new Date(); const startD = new Date(); startD.setDate(startD.getDate() - days); - const bars = generateHistoricalOhlcv(upperSymbol, startD.toISOString().split('T')[0], endD.toISOString().split('T')[0]); + const bars = generateHistoricalOhlcv( + upperSymbol, + startD.toISOString().split('T')[0], + endD.toISOString().split('T')[0] + ); return { ok: true, symbol: upperSymbol, bars, source: 'synthetic_fallback', timeframe }; } - const payload = await response.json(); + const payload = (await response.json()) as { bars?: Record[] }; const bars = Array.isArray(payload?.bars) ? payload.bars.map(normalizeAlpacaBar) : []; if (bars.length === 0) { @@ -94,17 +135,34 @@ export async function getHistoricalBars(symbol, days = 90, timeframe = '1Day') { const endD = new Date(); const startD = new Date(); startD.setDate(startD.getDate() - days); - const syntheticBars = generateHistoricalOhlcv(upperSymbol, startD.toISOString().split('T')[0], endD.toISOString().split('T')[0]); - return { ok: true, symbol: upperSymbol, bars: syntheticBars, source: 'synthetic_fallback', timeframe }; + const syntheticBars = generateHistoricalOhlcv( + upperSymbol, + startD.toISOString().split('T')[0], + endD.toISOString().split('T')[0] + ); + return { + ok: true, + symbol: upperSymbol, + bars: syntheticBars, + source: 'synthetic_fallback', + timeframe, + }; } return { ok: true, symbol: upperSymbol, bars, source: 'alpaca', timeframe }; - } catch (err) { - console.error('[market-data] Error fetching bars:', err.message); + } catch (err: unknown) { + console.error( + '[market-data] Error fetching bars:', + err instanceof Error ? err.message : 'unknown_error' + ); const endD = new Date(); const startD = new Date(); startD.setDate(startD.getDate() - days); - const bars = generateHistoricalOhlcv(upperSymbol, startD.toISOString().split('T')[0], endD.toISOString().split('T')[0]); + const bars = generateHistoricalOhlcv( + upperSymbol, + startD.toISOString().split('T')[0], + endD.toISOString().split('T')[0] + ); return { ok: true, symbol: upperSymbol, bars, source: 'synthetic_fallback', timeframe }; } } @@ -113,9 +171,15 @@ export async function getHistoricalBars(symbol, days = 90, timeframe = '1Day') { * Get current market snapshots for multiple symbols. * Returns from Alpaca when configured, otherwise returns empty (upstream from Worker sync). */ -export async function getMarketQuotes(symbols = []) { +export async function getMarketQuotes(symbols: string[] = []): Promise { if (!symbols.length) return { ok: false, quotes: [], error: 'No symbols provided' }; - if (USE_MOCK()) return { ok: true, quotes: [], source: 'none', note: 'Mock mode: quotes come from Worker market sync' }; + if (USE_MOCK()) + return { + ok: true, + quotes: [], + source: 'none', + note: 'Mock mode: quotes come from Worker market sync', + }; try { const url = new URL('/v2/stocks/snapshots', ALPACA_DATA_BASE); @@ -123,12 +187,19 @@ export async function getMarketQuotes(symbols = []) { url.searchParams.set('feed', ALPACA_DATA_FEED()); const response = await fetch(url.toString(), { headers: alpacaHeaders() }); - if (!response.ok) return { ok: false, quotes: [], error: `Alpaca snapshots HTTP ${response.status}` }; + if (!response.ok) + return { ok: false, quotes: [], error: `Alpaca snapshots HTTP ${response.status}` }; - const payload = await response.json(); + const payload = (await response.json()) as { + snapshots?: Record>; + }; const quotes = Object.entries(payload?.snapshots || {}).map(([sym, snap]) => { - const price = Number(snap?.minuteBar?.c ?? snap?.latestTrade?.p ?? snap?.dailyBar?.c ?? 0); - const prevClose = Number(snap?.prevDailyBar?.c ?? snap?.dailyBar?.o ?? price); + const minuteBar = snap.minuteBar as Record | undefined; + const latestTrade = snap.latestTrade as Record | undefined; + const dailyBar = snap.dailyBar as Record | undefined; + const prevDailyBar = snap.prevDailyBar as Record | undefined; + const price = Number(minuteBar?.c ?? latestTrade?.p ?? dailyBar?.c ?? 0); + const prevClose = Number(prevDailyBar?.c ?? dailyBar?.o ?? price); const change = price - prevClose; const changePct = prevClose > 0 ? (change / prevClose) * 100 : 0; return { @@ -136,12 +207,16 @@ export async function getMarketQuotes(symbols = []) { price, change: parseFloat(change.toFixed(2)), changePct: parseFloat(changePct.toFixed(2)), - volume: Number(snap?.dailyBar?.v ?? 0), + volume: Number(dailyBar?.v ?? 0), }; }); return { ok: true, quotes, source: 'alpaca' }; - } catch (err) { - return { ok: false, quotes: [], error: err.message }; + } catch (err: unknown) { + return { + ok: false, + quotes: [], + error: err instanceof Error ? err.message : 'unknown_error', + }; } } diff --git a/apps/api/src/domains/risk/services/parameters-service.js b/apps/api/src/domains/risk/services/parameters-service.ts similarity index 62% rename from apps/api/src/domains/risk/services/parameters-service.js rename to apps/api/src/domains/risk/services/parameters-service.ts index 9837ba4..6ed7fd1 100644 --- a/apps/api/src/domains/risk/services/parameters-service.js +++ b/apps/api/src/domains/risk/services/parameters-service.ts @@ -1,11 +1,17 @@ -// @ts-nocheck +type RiskParameters = { + maxPositionWeight: number; + maxDrawdownPct: number; + dailyLossStopPct: number; + sharpeFloor: number; + liveOrderRequiresApproval: boolean; +}; + /** * Risk parameters service — manages user-configurable risk thresholds. * Stored in memory (survives the process lifetime, resets on restart). * Defaults match the platform's conservative initial setup. */ - -export const DEFAULT_RISK_PARAMS = { +export const DEFAULT_RISK_PARAMS: RiskParameters = { /** Maximum single-position weight as a fraction (0.05 = 5%) */ maxPositionWeight: 0.05, /** Maximum allowed drawdown % before blocking a candidate */ @@ -18,23 +24,24 @@ export const DEFAULT_RISK_PARAMS = { liveOrderRequiresApproval: true, }; -let _params = { ...DEFAULT_RISK_PARAMS }; +let _params: RiskParameters = { ...DEFAULT_RISK_PARAMS }; -export function getRiskParameters() { +export function getRiskParameters(): RiskParameters { return { ...DEFAULT_RISK_PARAMS, ..._params }; } -export function updateRiskParameters(patch = {}) { +export function updateRiskParameters(patch: Partial = {}): RiskParameters { const allowed = new Set(Object.keys(DEFAULT_RISK_PARAMS)); - const updates = {}; + const updates: Partial = {}; for (const [key, value] of Object.entries(patch)) { if (!allowed.has(key)) continue; - const defaultVal = DEFAULT_RISK_PARAMS[key]; + const typedKey = key as keyof RiskParameters; + const defaultVal = DEFAULT_RISK_PARAMS[typedKey]; if (typeof defaultVal === 'number' && typeof value === 'number' && isFinite(value)) { - updates[key] = value; + updates[typedKey] = value as never; } else if (typeof defaultVal === 'boolean' && typeof value === 'boolean') { - updates[key] = value; + updates[typedKey] = value as never; } } @@ -42,7 +49,7 @@ export function updateRiskParameters(patch = {}) { return getRiskParameters(); } -export function resetRiskParameters() { +export function resetRiskParameters(): RiskParameters { _params = { ...DEFAULT_RISK_PARAMS }; return getRiskParameters(); } diff --git a/package.json b/package.json index 9683681..36f3d79 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "preview": "vite preview --config apps/web/vite.config.ts --host 0.0.0.0 --port 8080", "check:workspaces": "node --import tsx/esm scripts/check-workspace-integrity.ts", "check:lockfile": "node --import tsx/esm scripts/check-lockfile-sync.ts", + "check:no-js-source": "node --import tsx/esm scripts/check-no-js-source.ts", "check:stage-docs": "node --import tsx/esm scripts/check-stage-1-docs.ts", "check:runtime-env": "node --import tsx/esm scripts/check-runtime-env.ts", "control-plane:maintenance": "node --import tsx/esm scripts/control-plane-maintenance.ts", @@ -33,7 +34,7 @@ "lint:fix": "biome check --write .", "format": "biome format --write .", "typecheck": "tsc --noEmit -p apps/web/tsconfig.json", - "verify": "npm run check:workspaces && npm run check:lockfile && npm run check:stage-docs && npm run check:runtime-env && npm run lint && npm run test:control-plane && npm run test:runtime && npm run test:engine && npm run test:api && npm run test:worker && npm run test:web && npm run typecheck && npm run build", + "verify": "npm run check:workspaces && npm run check:lockfile && npm run check:no-js-source && npm run check:stage-docs && npm run check:runtime-env && npm run lint && npm run test:control-plane && npm run test:runtime && npm run test:engine && npm run test:api && npm run test:worker && npm run test:web && npm run typecheck && npm run build", "gateway": "node --import tsx/esm apps/api/src/main.ts", "worker": "node --import tsx/esm apps/worker/src/main.ts" }, diff --git a/packages/llm-provider/package.json b/packages/llm-provider/package.json index 98eb39d..6c5c3af 100644 --- a/packages/llm-provider/package.json +++ b/packages/llm-provider/package.json @@ -2,9 +2,9 @@ "name": "@quantpilot/llm-provider", "private": true, "type": "module", - "main": "./src/index.js", + "main": "./src/index.ts", "exports": { - ".": "./src/index.js" + ".": "./src/index.ts" }, "dependencies": { "@anthropic-ai/sdk": "^0.54.0", diff --git a/packages/llm-provider/src/claude-provider.js b/packages/llm-provider/src/claude-provider.js deleted file mode 100644 index 23c087a..0000000 --- a/packages/llm-provider/src/claude-provider.js +++ /dev/null @@ -1,124 +0,0 @@ -// @ts-nocheck -import Anthropic from '@anthropic-ai/sdk'; -import { DEFAULT_MODELS, PROVIDERS } from './types.js'; - -const DEFAULT_MAX_TOKENS = 4096; - -/** - * Claude API provider implementation. - * Uses Anthropic SDK with tool_use support. - */ -export class ClaudeProvider { - constructor(options = {}) { - this.provider = PROVIDERS.CLAUDE; - this.model = options.model || DEFAULT_MODELS[PROVIDERS.CLAUDE]; - this._client = new Anthropic({ - apiKey: options.apiKey || process.env.ANTHROPIC_API_KEY, - }); - } - - /** - * Send a chat request without tools. - * @param {import('./types.js').LLMMessage[]} messages - * @param {import('./types.js').ChatOptions} [options] - * @returns {Promise} - */ - async chat(messages, options = {}) { - try { - const systemPrompt = options.systemPrompt || ''; - const anthropicMessages = messages - .filter((m) => m.role !== 'system') - .map((m) => ({ role: m.role, content: m.content })); - - const response = await this._client.messages.create({ - model: this.model, - max_tokens: options.maxTokens || DEFAULT_MAX_TOKENS, - temperature: options.temperature, - system: systemPrompt || undefined, - messages: anthropicMessages, - }); - - const textBlock = response.content.find((b) => b.type === 'text'); - return { - ok: true, - content: textBlock?.text || '', - model: response.model, - stopReason: response.stop_reason, - usage: { - inputTokens: response.usage.input_tokens, - outputTokens: response.usage.output_tokens, - }, - }; - } catch (err) { - return { - ok: false, - content: '', - error: err?.message || 'claude_api_error', - }; - } - } - - /** - * Send a chat request with tool use support. - * @param {import('./types.js').LLMMessage[]} messages - * @param {import('./types.js').LLMTool[]} tools - * @param {import('./types.js').ChatOptions} [options] - * @returns {Promise} - */ - async chatWithTools(messages, tools, options = {}) { - try { - const systemPrompt = options.systemPrompt || ''; - const anthropicMessages = messages - .filter((m) => m.role !== 'system') - .map((m) => { - if (typeof m.content === 'string') { - return { role: m.role, content: m.content }; - } - return { role: m.role, content: m.content }; - }); - - const anthropicTools = tools.map((t) => ({ - name: t.name, - description: t.description, - input_schema: t.inputSchema, - })); - - const response = await this._client.messages.create({ - model: this.model, - max_tokens: options.maxTokens || DEFAULT_MAX_TOKENS, - temperature: options.temperature, - system: systemPrompt || undefined, - messages: anthropicMessages, - tools: anthropicTools, - }); - - const textBlocks = response.content.filter((b) => b.type === 'text'); - const toolUseBlocks = response.content.filter((b) => b.type === 'tool_use'); - - const textContent = textBlocks.map((b) => b.text).join('\n'); - const toolCalls = toolUseBlocks.map((b) => ({ - id: b.id, - name: b.name, - input: b.input, - })); - - return { - ok: true, - content: textContent, - toolCalls, - stopReason: response.stop_reason, - usage: { - inputTokens: response.usage.input_tokens, - outputTokens: response.usage.output_tokens, - }, - }; - } catch (err) { - return { - ok: false, - content: '', - toolCalls: [], - error: err?.message || 'claude_api_error', - }; - } - } -} diff --git a/packages/llm-provider/src/claude-provider.ts b/packages/llm-provider/src/claude-provider.ts new file mode 100644 index 0000000..e8b2a19 --- /dev/null +++ b/packages/llm-provider/src/claude-provider.ts @@ -0,0 +1,114 @@ +import Anthropic from '@anthropic-ai/sdk'; +import { + type ChatOptions, + DEFAULT_MODELS, + type LLMMessage, + type LLMResponse, + type LLMTool, + type LLMToolResponse, + PROVIDERS, + type ProviderConstructorOptions, +} from './types.js'; + +const DEFAULT_MAX_TOKENS = 4096; + +export class ClaudeProvider { + provider = PROVIDERS.CLAUDE; + model: string; + _client: Anthropic; + + constructor(options: ProviderConstructorOptions = {}) { + this.model = options.model || DEFAULT_MODELS[PROVIDERS.CLAUDE]; + this._client = new Anthropic({ + apiKey: options.apiKey || process.env.ANTHROPIC_API_KEY, + }); + } + + async chat(messages: LLMMessage[], options: ChatOptions = {}): Promise { + try { + const systemPrompt = options.systemPrompt || ''; + const anthropicMessages = messages + .filter((message) => message.role !== 'system') + .map((message) => ({ role: message.role, content: message.content })); + + const response = await this._client.messages.create({ + model: this.model, + max_tokens: options.maxTokens || DEFAULT_MAX_TOKENS, + temperature: options.temperature, + system: systemPrompt || undefined, + messages: anthropicMessages as never, + }); + + const textBlock = response.content.find((block) => block.type === 'text'); + return { + ok: true, + content: textBlock?.text || '', + model: response.model, + stopReason: response.stop_reason, + usage: { + inputTokens: response.usage.input_tokens, + outputTokens: response.usage.output_tokens, + }, + }; + } catch (err: unknown) { + return { + ok: false, + content: '', + error: err instanceof Error ? err.message : 'claude_api_error', + }; + } + } + + async chatWithTools( + messages: LLMMessage[], + tools: LLMTool[], + options: ChatOptions = {} + ): Promise { + try { + const systemPrompt = options.systemPrompt || ''; + const anthropicMessages = messages + .filter((message) => message.role !== 'system') + .map((message) => ({ role: message.role, content: message.content })); + + const anthropicTools = tools.map((tool) => ({ + name: tool.name, + description: tool.description, + input_schema: tool.inputSchema, + })); + + const response = await this._client.messages.create({ + model: this.model, + max_tokens: options.maxTokens || DEFAULT_MAX_TOKENS, + temperature: options.temperature, + system: systemPrompt || undefined, + messages: anthropicMessages as never, + tools: anthropicTools as never, + }); + + const textBlocks = response.content.filter((block) => block.type === 'text'); + const toolUseBlocks = response.content.filter((block) => block.type === 'tool_use'); + + return { + ok: true, + content: textBlocks.map((block) => block.text).join('\n'), + toolCalls: toolUseBlocks.map((block) => ({ + id: block.id, + name: block.name, + input: block.input as Record, + })), + stopReason: response.stop_reason, + usage: { + inputTokens: response.usage.input_tokens, + outputTokens: response.usage.output_tokens, + }, + }; + } catch (err: unknown) { + return { + ok: false, + content: '', + toolCalls: [], + error: err instanceof Error ? err.message : 'claude_api_error', + }; + } + } +} diff --git a/packages/llm-provider/src/factory.js b/packages/llm-provider/src/factory.js deleted file mode 100644 index 3f31f17..0000000 --- a/packages/llm-provider/src/factory.js +++ /dev/null @@ -1,69 +0,0 @@ -// @ts-nocheck -import { ClaudeProvider } from './claude-provider.js'; -import { OpenAIProvider } from './openai-provider.js'; -import { DEFAULT_MODELS, PROVIDERS } from './types.js'; - -/** - * Create an LLM provider instance based on environment configuration. - * - * Priority: - * 1. Explicit options.provider - * 2. QUANTPILOT_LLM_PROVIDER env var - * 3. Fallback: claude (if ANTHROPIC_API_KEY set), openai (if OPENAI_API_KEY set) - * 4. Last resort: null provider (no-op, returns rule-based fallback signal) - * - * @param {Object} [options] - * @param {'claude'|'openai'} [options.provider] - * @param {string} [options.model] - * @param {string} [options.apiKey] - * @returns {ClaudeProvider|OpenAIProvider|null} - */ -export function createLLMProvider(options = {}) { - const providerName = - options.provider || - process.env.QUANTPILOT_LLM_PROVIDER || - _inferProvider(); - - const model = - options.model || - process.env.QUANTPILOT_LLM_MODEL || - DEFAULT_MODELS[providerName]; - - if (providerName === PROVIDERS.CLAUDE) { - const apiKey = options.apiKey || process.env.ANTHROPIC_API_KEY; - if (!apiKey) return null; - return new ClaudeProvider({ model, apiKey }); - } - - if (providerName === PROVIDERS.OPENAI) { - const apiKey = options.apiKey || process.env.OPENAI_API_KEY; - if (!apiKey) return null; - return new OpenAIProvider({ model, apiKey }); - } - - return null; -} - -function _inferProvider() { - if (process.env.ANTHROPIC_API_KEY) return PROVIDERS.CLAUDE; - if (process.env.OPENAI_API_KEY) return PROVIDERS.OPENAI; - return null; -} - -/** - * Get the currently configured provider name from environment. - * @returns {'claude'|'openai'|null} - */ -export function getConfiguredProvider() { - const name = process.env.QUANTPILOT_LLM_PROVIDER || _inferProvider(); - if (name === PROVIDERS.CLAUDE || name === PROVIDERS.OPENAI) return name; - return null; -} - -/** - * Check if any LLM provider is configured and available. - * @returns {boolean} - */ -export function isLLMAvailable() { - return _inferProvider() !== null || Boolean(process.env.QUANTPILOT_LLM_PROVIDER); -} diff --git a/packages/llm-provider/src/factory.ts b/packages/llm-provider/src/factory.ts new file mode 100644 index 0000000..f135cf3 --- /dev/null +++ b/packages/llm-provider/src/factory.ts @@ -0,0 +1,52 @@ +import { ClaudeProvider } from './claude-provider.js'; +import { OpenAIProvider } from './openai-provider.js'; +import { + DEFAULT_MODELS, + PROVIDERS, + type ProviderConstructorOptions, + type ProviderName, +} from './types.js'; + +export function createLLMProvider( + options: ProviderConstructorOptions = {} +): ClaudeProvider | OpenAIProvider | null { + const envProvider = process.env.QUANTPILOT_LLM_PROVIDER; + const providerName: ProviderName | null = + options.provider || + (envProvider === PROVIDERS.CLAUDE || envProvider === PROVIDERS.OPENAI ? envProvider : null) || + inferProvider(); + const model = + options.model || + process.env.QUANTPILOT_LLM_MODEL || + (providerName ? DEFAULT_MODELS[providerName] : undefined); + + if (providerName === PROVIDERS.CLAUDE) { + const apiKey = options.apiKey || process.env.ANTHROPIC_API_KEY; + if (!apiKey) return null; + return new ClaudeProvider({ model, apiKey }); + } + + if (providerName === PROVIDERS.OPENAI) { + const apiKey = options.apiKey || process.env.OPENAI_API_KEY; + if (!apiKey) return null; + return new OpenAIProvider({ model, apiKey }); + } + + return null; +} + +function inferProvider(): ProviderName | null { + if (process.env.ANTHROPIC_API_KEY) return PROVIDERS.CLAUDE; + if (process.env.OPENAI_API_KEY) return PROVIDERS.OPENAI; + return null; +} + +export function getConfiguredProvider(): ProviderName | null { + const name = process.env.QUANTPILOT_LLM_PROVIDER || inferProvider(); + if (name === PROVIDERS.CLAUDE || name === PROVIDERS.OPENAI) return name; + return null; +} + +export function isLLMAvailable() { + return inferProvider() !== null || Boolean(process.env.QUANTPILOT_LLM_PROVIDER); +} diff --git a/packages/llm-provider/src/index.js b/packages/llm-provider/src/index.ts similarity index 60% rename from packages/llm-provider/src/index.js rename to packages/llm-provider/src/index.ts index 9cbae19..be63712 100644 --- a/packages/llm-provider/src/index.js +++ b/packages/llm-provider/src/index.ts @@ -1,5 +1,14 @@ -// @ts-nocheck export { ClaudeProvider } from './claude-provider.js'; export { createLLMProvider, getConfiguredProvider, isLLMAvailable } from './factory.js'; export { OpenAIProvider } from './openai-provider.js'; +export type { + ChatOptions, + LLMMessage, + LLMResponse, + LLMTool, + LLMToolCall, + LLMToolResponse, + ProviderConstructorOptions, + ProviderName, +} from './types.js'; export { DEFAULT_MODELS, PROVIDERS } from './types.js'; diff --git a/packages/llm-provider/src/openai-provider.js b/packages/llm-provider/src/openai-provider.js deleted file mode 100644 index 39073d6..0000000 --- a/packages/llm-provider/src/openai-provider.js +++ /dev/null @@ -1,142 +0,0 @@ -// @ts-nocheck -import OpenAI from 'openai'; -import { DEFAULT_MODELS, PROVIDERS } from './types.js'; - -const DEFAULT_MAX_TOKENS = 4096; - -/** - * OpenAI provider implementation. - * Uses OpenAI SDK with function_call / tool_calls support. - */ -export class OpenAIProvider { - constructor(options = {}) { - this.provider = PROVIDERS.OPENAI; - this.model = options.model || DEFAULT_MODELS[PROVIDERS.OPENAI]; - this._client = new OpenAI({ - apiKey: options.apiKey || process.env.OPENAI_API_KEY, - }); - } - - /** - * Send a chat request without tools. - * @param {import('./types.js').LLMMessage[]} messages - * @param {import('./types.js').ChatOptions} [options] - * @returns {Promise} - */ - async chat(messages, options = {}) { - try { - const openaiMessages = []; - if (options.systemPrompt) { - openaiMessages.push({ role: 'system', content: options.systemPrompt }); - } - for (const m of messages) { - openaiMessages.push({ role: m.role, content: m.content }); - } - - const response = await this._client.chat.completions.create({ - model: this.model, - max_tokens: options.maxTokens || DEFAULT_MAX_TOKENS, - temperature: options.temperature, - messages: openaiMessages, - }); - - const choice = response.choices[0]; - return { - ok: true, - content: choice?.message?.content || '', - model: response.model, - stopReason: choice?.finish_reason === 'stop' ? 'end_turn' : choice?.finish_reason, - usage: { - inputTokens: response.usage?.prompt_tokens || 0, - outputTokens: response.usage?.completion_tokens || 0, - }, - }; - } catch (err) { - return { - ok: false, - content: '', - error: err?.message || 'openai_api_error', - }; - } - } - - /** - * Send a chat request with tool use support. - * @param {import('./types.js').LLMMessage[]} messages - * @param {import('./types.js').LLMTool[]} tools - * @param {import('./types.js').ChatOptions} [options] - * @returns {Promise} - */ - async chatWithTools(messages, tools, options = {}) { - try { - const openaiMessages = []; - if (options.systemPrompt) { - openaiMessages.push({ role: 'system', content: options.systemPrompt }); - } - for (const m of messages) { - if (typeof m.content === 'string') { - openaiMessages.push({ role: m.role, content: m.content }); - } else { - openaiMessages.push({ role: m.role, content: m.content }); - } - } - - const openaiTools = tools.map((t) => ({ - type: 'function', - function: { - name: t.name, - description: t.description, - parameters: t.inputSchema, - }, - })); - - const response = await this._client.chat.completions.create({ - model: this.model, - max_tokens: options.maxTokens || DEFAULT_MAX_TOKENS, - temperature: options.temperature, - messages: openaiMessages, - tools: openaiTools, - tool_choice: 'auto', - }); - - const choice = response.choices[0]; - const message = choice?.message; - const textContent = message?.content || ''; - const toolCalls = (message?.tool_calls || []).map((tc) => ({ - id: tc.id, - name: tc.function.name, - input: (() => { - try { - return JSON.parse(tc.function.arguments); - } catch { - return {}; - } - })(), - })); - - const stopReason = (() => { - if (choice?.finish_reason === 'tool_calls') return 'tool_use'; - if (choice?.finish_reason === 'stop') return 'end_turn'; - return choice?.finish_reason; - })(); - - return { - ok: true, - content: textContent, - toolCalls, - stopReason, - usage: { - inputTokens: response.usage?.prompt_tokens || 0, - outputTokens: response.usage?.completion_tokens || 0, - }, - }; - } catch (err) { - return { - ok: false, - content: '', - toolCalls: [], - error: err?.message || 'openai_api_error', - }; - } - } -} diff --git a/packages/llm-provider/src/openai-provider.ts b/packages/llm-provider/src/openai-provider.ts new file mode 100644 index 0000000..f730391 --- /dev/null +++ b/packages/llm-provider/src/openai-provider.ts @@ -0,0 +1,133 @@ +import OpenAI from 'openai'; +import { + type ChatOptions, + DEFAULT_MODELS, + type LLMMessage, + type LLMResponse, + type LLMTool, + type LLMToolResponse, + PROVIDERS, + type ProviderConstructorOptions, +} from './types.js'; + +const DEFAULT_MAX_TOKENS = 4096; + +export class OpenAIProvider { + provider = PROVIDERS.OPENAI; + model: string; + _client: OpenAI; + + constructor(options: ProviderConstructorOptions = {}) { + this.model = options.model || DEFAULT_MODELS[PROVIDERS.OPENAI]; + this._client = new OpenAI({ + apiKey: options.apiKey || process.env.OPENAI_API_KEY, + }); + } + + async chat(messages: LLMMessage[], options: ChatOptions = {}): Promise { + try { + const openaiMessages: Array<{ role: string; content: unknown }> = []; + if (options.systemPrompt) { + openaiMessages.push({ role: 'system', content: options.systemPrompt }); + } + for (const message of messages) { + openaiMessages.push({ role: message.role, content: message.content }); + } + + const response = await this._client.chat.completions.create({ + model: this.model, + max_tokens: options.maxTokens || DEFAULT_MAX_TOKENS, + temperature: options.temperature, + messages: openaiMessages as never, + }); + + const choice = response.choices[0]; + return { + ok: true, + content: choice?.message?.content || '', + model: response.model, + stopReason: choice?.finish_reason === 'stop' ? 'end_turn' : choice?.finish_reason, + usage: { + inputTokens: response.usage?.prompt_tokens || 0, + outputTokens: response.usage?.completion_tokens || 0, + }, + }; + } catch (err: unknown) { + return { + ok: false, + content: '', + error: err instanceof Error ? err.message : 'openai_api_error', + }; + } + } + + async chatWithTools( + messages: LLMMessage[], + tools: LLMTool[], + options: ChatOptions = {} + ): Promise { + try { + const openaiMessages: Array<{ role: string; content: unknown }> = []; + if (options.systemPrompt) { + openaiMessages.push({ role: 'system', content: options.systemPrompt }); + } + for (const message of messages) { + openaiMessages.push({ role: message.role, content: message.content }); + } + + const openaiTools = tools.map((tool) => ({ + type: 'function' as const, + function: { + name: tool.name, + description: tool.description, + parameters: tool.inputSchema, + }, + })); + + const response = await this._client.chat.completions.create({ + model: this.model, + max_tokens: options.maxTokens || DEFAULT_MAX_TOKENS, + temperature: options.temperature, + messages: openaiMessages as never, + tools: openaiTools as never, + tool_choice: 'auto', + }); + + const choice = response.choices[0]; + const message = choice?.message; + + return { + ok: true, + content: message?.content || '', + toolCalls: (message?.tool_calls || []).map((toolCall) => ({ + id: toolCall.id, + name: toolCall.function.name, + input: (() => { + try { + return JSON.parse(toolCall.function.arguments) as Record; + } catch { + return {}; + } + })(), + })), + stopReason: + choice?.finish_reason === 'tool_calls' + ? 'tool_use' + : choice?.finish_reason === 'stop' + ? 'end_turn' + : choice?.finish_reason, + usage: { + inputTokens: response.usage?.prompt_tokens || 0, + outputTokens: response.usage?.completion_tokens || 0, + }, + }; + } catch (err: unknown) { + return { + ok: false, + content: '', + toolCalls: [], + error: err instanceof Error ? err.message : 'openai_api_error', + }; + } + } +} diff --git a/packages/llm-provider/src/types.js b/packages/llm-provider/src/types.js deleted file mode 100644 index 3820955..0000000 --- a/packages/llm-provider/src/types.js +++ /dev/null @@ -1,78 +0,0 @@ -// @ts-nocheck - -/** - * LLM Provider types shared across all provider implementations. - */ - -/** - * A single message in a conversation. - * @typedef {'user'|'assistant'|'system'} MessageRole - */ - -/** - * @typedef {Object} LLMMessage - * @property {'user'|'assistant'|'system'} role - * @property {string} content - */ - -/** - * @typedef {Object} LLMToolParameter - * @property {string} type - * @property {string} [description] - * @property {Object} [properties] - * @property {string[]} [required] - * @property {Object} [items] - * @property {string[]} [enum] - */ - -/** - * A tool definition that can be passed to the LLM for function/tool calling. - * @typedef {Object} LLMTool - * @property {string} name - * @property {string} description - * @property {Object} inputSchema - JSON Schema for the tool's input parameters - */ - -/** - * @typedef {Object} LLMResponse - * @property {boolean} ok - * @property {string} content - * @property {string} [model] - * @property {'end_turn'|'max_tokens'|'tool_use'|'stop'} [stopReason] - * @property {{inputTokens: number, outputTokens: number}} [usage] - * @property {string} [error] - */ - -/** - * @typedef {Object} LLMToolCall - * @property {string} id - * @property {string} name - * @property {Object} input - */ - -/** - * @typedef {Object} LLMToolResponse - * @property {boolean} ok - * @property {string} content - * @property {LLMToolCall[]} [toolCalls] - * @property {'end_turn'|'max_tokens'|'tool_use'|'stop'} [stopReason] - * @property {{inputTokens: number, outputTokens: number}} [usage] - * @property {string} [error] - */ - -/** - * @typedef {Object} ChatOptions - * @property {number} [maxTokens] - * @property {number} [temperature] - * @property {string} [systemPrompt] - */ - -export const PROVIDERS = /** @type {const} */ ({ - CLAUDE: 'claude', - OPENAI: 'openai', -}); - -export const DEFAULT_MODELS = { - [PROVIDERS.CLAUDE]: 'claude-sonnet-4-6', - [PROVIDERS.OPENAI]: 'gpt-4o', -}; diff --git a/packages/llm-provider/src/types.ts b/packages/llm-provider/src/types.ts new file mode 100644 index 0000000..482c176 --- /dev/null +++ b/packages/llm-provider/src/types.ts @@ -0,0 +1,78 @@ +export type MessageRole = 'user' | 'assistant' | 'system'; + +export type StopReason = 'end_turn' | 'max_tokens' | 'tool_use' | 'stop' | null; + +export type LLMMessageContent = string | Array>; + +export type LLMMessage = { + role: MessageRole; + content: LLMMessageContent; +}; + +export type LLMToolParameter = { + type: string; + description?: string; + properties?: Record; + required?: string[]; + items?: Record; + enum?: string[]; +}; + +export type LLMTool = { + name: string; + description: string; + inputSchema: Record; +}; + +export type LLMUsage = { + inputTokens: number; + outputTokens: number; +}; + +export type LLMResponse = { + ok: boolean; + content: string; + model?: string; + stopReason?: StopReason | string; + usage?: LLMUsage; + error?: string; +}; + +export type LLMToolCall = { + id: string; + name: string; + input: Record; +}; + +export type LLMToolResponse = { + ok: boolean; + content: string; + toolCalls?: LLMToolCall[]; + stopReason?: StopReason | string; + usage?: LLMUsage; + error?: string; +}; + +export type ChatOptions = { + maxTokens?: number; + temperature?: number; + systemPrompt?: string; +}; + +export const PROVIDERS = { + CLAUDE: 'claude', + OPENAI: 'openai', +} as const; + +export type ProviderName = (typeof PROVIDERS)[keyof typeof PROVIDERS]; + +export type ProviderConstructorOptions = { + provider?: ProviderName; + model?: string; + apiKey?: string; +}; + +export const DEFAULT_MODELS: Record = { + [PROVIDERS.CLAUDE]: 'claude-sonnet-4-6', + [PROVIDERS.OPENAI]: 'gpt-4o', +}; diff --git a/packages/trading-engine/src/backtest/engine.ts b/packages/trading-engine/src/backtest/engine.ts index a7f7624..58ef09c 100644 --- a/packages/trading-engine/src/backtest/engine.ts +++ b/packages/trading-engine/src/backtest/engine.ts @@ -100,7 +100,7 @@ export function runBacktestEngine(config: BacktestConfig): BacktestResult { volatilityParam: params.volatility, bars, tradingDates, - history: [], + history: [] as number[], }; }); diff --git a/scripts/check-no-js-source.ts b/scripts/check-no-js-source.ts new file mode 100644 index 0000000..82ac05c --- /dev/null +++ b/scripts/check-no-js-source.ts @@ -0,0 +1,48 @@ +import { readdirSync } from 'node:fs'; +import { join, relative } from 'node:path'; + +const repoRoot = process.cwd(); +const sourceRoots = ['apps', 'packages', 'scripts']; +const blockedExtensions = new Set(['.js', '.mjs', '.cjs']); +const ignoredDirs = new Set(['node_modules', 'dist', 'coverage', '.git', '.vite']); + +function walk(dirPath: string, hits: string[]) { + const entries = readdirSync(dirPath, { withFileTypes: true }); + + for (const entry of entries) { + if (ignoredDirs.has(entry.name)) continue; + + const absolutePath = join(dirPath, entry.name); + if (entry.isDirectory()) { + walk(absolutePath, hits); + continue; + } + + const extension = entry.name.slice(entry.name.lastIndexOf('.')); + if (blockedExtensions.has(extension)) { + hits.push(relative(repoRoot, absolutePath)); + } + } +} + +function main() { + const hits: string[] = []; + + for (const sourceRoot of sourceRoots) { + walk(join(repoRoot, sourceRoot), hits); + } + + if (hits.length > 0) { + console.error('JavaScript source files are not allowed in first-party source roots.'); + console.error('Migrate these files to TypeScript instead:'); + for (const hit of hits.sort()) { + console.error(`- ${hit}`); + } + process.exitCode = 1; + return; + } + + console.info('No JavaScript source files found in first-party source roots.'); +} + +main();