/** * Builds structured scan reports for LLM Agent consumption. * Output is a readable markdown text that the Agent can analyze and act on. */ import type { BacktestRow, StrategyRow } from "../db/repositories.js"; import type { ScanReport, StrategyScanEntry, SymbolNewsResult } from "./types.js"; /** * Build a scan report from strategies, news results, and backtest history. * The report is structured for LLM consumption with clear sections. */ export function buildScanReport( scanId: string, strategies: StrategyRow[], newsResults: SymbolNewsResult[], backtestMap: Map, ): ScanReport { const newsLookup = new Map(); for (const nr of newsResults) { newsLookup.set(nr.symbol, nr); } const entries: StrategyScanEntry[] = []; for (const strategy of strategies) { const symbols = parseJsonArray(strategy.symbols); if (symbols.length === 0) continue; const symbolData: SymbolNewsResult[] = []; let significantNewsCount = 0; for (const sym of symbols) { const nr = newsLookup.get(sym); if (nr) { symbolData.push(nr); significantNewsCount += nr.news.length; } else { symbolData.push({ symbol: sym, market: "crypto", news: [] }); } } const backtests = backtestMap.get(strategy.id) ?? []; const latest = backtests[0]; entries.push({ strategyId: strategy.id, strategyName: strategy.name, level: strategy.level ?? "L0", symbols, lastBacktest: latest ? { totalReturn: latest.total_return ?? undefined, sharpe: latest.sharpe ?? undefined, maxDrawdown: latest.max_drawdown ?? undefined, } : undefined, symbolData, significantNewsCount, }); } return { scanId, scannedAt: new Date().toISOString(), strategiesScanned: entries.length, totalNewsFound: entries.reduce((sum, e) => sum + e.significantNewsCount, 0), entries, }; } /** Format a ScanReport as readable markdown for the LLM Agent. */ export function formatScanReportMarkdown(report: ScanReport): string { const lines: string[] = []; const date = report.scannedAt.slice(0, 10); lines.push(`## 每日策略扫描报告 — ${date}`); lines.push(""); lines.push( `共扫描 ${report.strategiesScanned} 个策略,发现 ${report.totalNewsFound} 条相关新闻。`, ); lines.push(""); if (report.entries.length === 0) { lines.push("暂无活跃策略。请先通过 skill_fork 或手动创建策略。"); return lines.join("\n"); } for (const entry of report.entries) { lines.push(`### ${entry.strategyName} (${entry.level})`); lines.push(`- 标的: ${entry.symbols.join(", ")}`); if (entry.lastBacktest) { const bt = entry.lastBacktest; const parts: string[] = []; if (bt.totalReturn != null) parts.push(`总收益 ${(bt.totalReturn * 100).toFixed(1)}%`); if (bt.sharpe != null) parts.push(`夏普 ${bt.sharpe.toFixed(2)}`); if (bt.maxDrawdown != null) parts.push(`最大回撤 ${(bt.maxDrawdown * 100).toFixed(1)}%`); if (parts.length > 0) lines.push(`- 最近回测: ${parts.join(", ")}`); } for (const sd of entry.symbolData) { if (sd.currentPrice != null) { const change = sd.priceChange24h != null ? ` (24h ${sd.priceChange24h >= 0 ? "+" : ""}${sd.priceChange24h.toFixed(1)}%)` : ""; lines.push(`- ${sd.symbol} 当前价格: $${sd.currentPrice.toFixed(2)}${change}`); } if (sd.news.length > 0) { lines.push(`- 相关新闻 (${sd.symbol}):`); for (const n of sd.news.slice(0, 5)) { const sentiment = n.sentiment ? `[${n.sentiment}] ` : ""; const age = formatAge(n.publishedAt); lines.push(` ${sentiment}${n.title} (${n.source}, ${age})`); } } } if (entry.significantNewsCount === 0) { lines.push("- 无重大新闻"); } lines.push(""); } // Summary section const strategiesWithNews = report.entries.filter((e) => e.significantNewsCount > 0); if (strategiesWithNews.length > 0) { lines.push("### 需要关注的策略"); for (const e of strategiesWithNews) { lines.push( `- ${e.strategyName}: ${e.significantNewsCount} 条相关新闻,建议分析影响并考虑是否需要优化`, ); } } else { lines.push("### 汇总"); lines.push("所有策略标的无重大新闻变动,无需调整。"); } return lines.join("\n"); } // ── Helpers ─────────────────────────────────────────────────────────────── function parseJsonArray(json: string | null | undefined): string[] { if (!json) return []; try { const arr = JSON.parse(json); return Array.isArray(arr) ? arr.filter((v): v is string => typeof v === "string") : []; } catch { return []; } } function formatAge(isoDate: string): string { const ms = Date.now() - new Date(isoDate).getTime(); const hours = Math.floor(ms / 3_600_000); if (hours < 1) return `${Math.floor(ms / 60_000)}m ago`; if (hours < 24) return `${hours}h ago`; return `${Math.floor(hours / 24)}d ago`; }