@openfinclaw/openfinclaw-strategy 2026.3.274 → 2026.3.276
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.ts +33 -6
- package/openclaw.plugin.json +47 -0
- package/package.json +1 -1
- package/src/cli.ts +17 -17
- package/src/config.ts +67 -1
- package/src/datahub/tools.ts +9 -9
- package/src/db/db.ts +16 -9
- package/src/db/repositories.ts +154 -0
- package/src/db/schema.ts +35 -0
- package/src/http/routes.ts +19 -1
- package/src/http/server.ts +16 -12
- package/src/middleware/with-logging.ts +4 -4
- package/src/prompt-guidance.ts +14 -0
- package/src/scheduler/cron-setup.ts +181 -0
- package/src/scheduler/news-provider.ts +295 -0
- package/src/scheduler/scan-report-builder.ts +162 -0
- package/src/scheduler/tools.ts +261 -0
- package/src/scheduler/types.ts +89 -0
- package/src/strategy/tools.ts +129 -120
- package/src/types.ts +27 -0
- package/web/index.html +240 -0
package/src/http/routes.ts
CHANGED
|
@@ -9,8 +9,10 @@ import { fileURLToPath } from "node:url";
|
|
|
9
9
|
import {
|
|
10
10
|
queryActivityLog,
|
|
11
11
|
queryAgentEvents,
|
|
12
|
-
queryStrategies,
|
|
13
12
|
queryBacktestResults,
|
|
13
|
+
queryPriceAlerts,
|
|
14
|
+
queryScanHistory,
|
|
15
|
+
queryStrategies,
|
|
14
16
|
} from "../db/repositories.js";
|
|
15
17
|
|
|
16
18
|
// Resolve path to web/index.html relative to this file's location
|
|
@@ -103,5 +105,21 @@ export function handleRoute(db: DatabaseSync, req: IncomingMessage, res: ServerR
|
|
|
103
105
|
return;
|
|
104
106
|
}
|
|
105
107
|
|
|
108
|
+
if (pathname === "/api/scan-history") {
|
|
109
|
+
const limit = parseQueryParam(url, "limit", 20);
|
|
110
|
+
const offset = parseQueryParam(url, "offset", 0);
|
|
111
|
+
const scanType = parseStringParam(url, "scan_type");
|
|
112
|
+
sendJson(res, queryScanHistory(db, { scanType, limit, offset }));
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (pathname === "/api/price-alerts") {
|
|
117
|
+
const limit = parseQueryParam(url, "limit", 50);
|
|
118
|
+
const offset = parseQueryParam(url, "offset", 0);
|
|
119
|
+
const strategyId = parseStringParam(url, "strategy_id");
|
|
120
|
+
sendJson(res, queryPriceAlerts(db, { strategyId, limit, offset }));
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
106
124
|
send404(res);
|
|
107
125
|
}
|
package/src/http/server.ts
CHANGED
|
@@ -24,13 +24,6 @@ const GLOBAL_SERVER_KEY = "__openfinclaw_http_server__" as const;
|
|
|
24
24
|
* it is closed first so the port is released.
|
|
25
25
|
*/
|
|
26
26
|
export function startHttpServer(port: number, logger: DashboardLogger): void {
|
|
27
|
-
// Close previous server left over from a hot-reload
|
|
28
|
-
const prev = (globalThis as Record<string, unknown>)[GLOBAL_SERVER_KEY] as Server | undefined;
|
|
29
|
-
if (prev) {
|
|
30
|
-
logger.info("[OpenFinClaw] Closing previous Dashboard server (hot-reload)");
|
|
31
|
-
prev.close();
|
|
32
|
-
}
|
|
33
|
-
|
|
34
27
|
const server = createServer((req, res) => {
|
|
35
28
|
try {
|
|
36
29
|
handleRoute(getDb(), req, res);
|
|
@@ -42,13 +35,24 @@ export function startHttpServer(port: number, logger: DashboardLogger): void {
|
|
|
42
35
|
}
|
|
43
36
|
});
|
|
44
37
|
|
|
45
|
-
(globalThis as Record<string, unknown>)[GLOBAL_SERVER_KEY] = server;
|
|
46
|
-
|
|
47
38
|
server.on("error", (err) => {
|
|
48
39
|
logger.info(`[OpenFinClaw] Dashboard server error: ${String(err)}`);
|
|
49
40
|
});
|
|
50
41
|
|
|
51
|
-
server
|
|
52
|
-
|
|
53
|
-
|
|
42
|
+
/** Bind server after ensuring the port is free. */
|
|
43
|
+
function listen() {
|
|
44
|
+
(globalThis as Record<string, unknown>)[GLOBAL_SERVER_KEY] = server;
|
|
45
|
+
server.listen(port, "127.0.0.1", () => {
|
|
46
|
+
logger.info(`[OpenFinClaw] Dashboard available at http://127.0.0.1:${port}`);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Close previous server left over from a hot-reload, then bind
|
|
51
|
+
const prev = (globalThis as Record<string, unknown>)[GLOBAL_SERVER_KEY] as Server | undefined;
|
|
52
|
+
if (prev) {
|
|
53
|
+
logger.info("[OpenFinClaw] Closing previous Dashboard server (hot-reload)");
|
|
54
|
+
prev.close(listen);
|
|
55
|
+
} else {
|
|
56
|
+
listen();
|
|
57
|
+
}
|
|
54
58
|
}
|
|
@@ -22,14 +22,14 @@ type AnyExecuteFn = (
|
|
|
22
22
|
/**
|
|
23
23
|
* Wrap a tool execute function with automatic activity logging.
|
|
24
24
|
*
|
|
25
|
-
* @param
|
|
25
|
+
* @param getDb - Lazy database getter (called at execution time, not registration).
|
|
26
26
|
* @param toolName - Name used as the `action` column value.
|
|
27
27
|
* @param category - Broad category, e.g. "market-data" or "strategy".
|
|
28
28
|
* @param fn - The original execute function to wrap.
|
|
29
29
|
* @param opts - Optional: extract strategy_id from params for context.
|
|
30
30
|
*/
|
|
31
31
|
export function withLogging<T extends AnyExecuteFn>(
|
|
32
|
-
|
|
32
|
+
getDb: () => DatabaseSync,
|
|
33
33
|
toolName: string,
|
|
34
34
|
category: string,
|
|
35
35
|
fn: T,
|
|
@@ -45,7 +45,7 @@ export function withLogging<T extends AnyExecuteFn>(
|
|
|
45
45
|
|
|
46
46
|
try {
|
|
47
47
|
const result = await fn(toolCallId, params, ...rest);
|
|
48
|
-
insertActivityLog(
|
|
48
|
+
insertActivityLog(getDb(), {
|
|
49
49
|
id: logId,
|
|
50
50
|
timestamp: new Date().toISOString(),
|
|
51
51
|
category,
|
|
@@ -56,7 +56,7 @@ export function withLogging<T extends AnyExecuteFn>(
|
|
|
56
56
|
});
|
|
57
57
|
return result;
|
|
58
58
|
} catch (err) {
|
|
59
|
-
insertActivityLog(
|
|
59
|
+
insertActivityLog(getDb(), {
|
|
60
60
|
id: logId,
|
|
61
61
|
timestamp: new Date().toISOString(),
|
|
62
62
|
category,
|
package/src/prompt-guidance.ts
CHANGED
|
@@ -8,4 +8,18 @@ export const OPENFINCLAW_AGENT_GUIDANCE = [
|
|
|
8
8
|
"当用户需要市场行情数据(价格、K线、加密货币、对比、搜索)时,必须使用 fin_price, fin_kline, fin_crypto, fin_compare, fin_slim_search 等 tool。",
|
|
9
9
|
"只有通过 tool 执行的操作才会写入 SQLite 数据库并在 Dashboard 中展示,绕过 tool 调用的数据不会落库。",
|
|
10
10
|
"tool 调用失败时优先检查 API Key 是否配置(OPENFINCLAW_API_KEY 环境变量或插件配置 apiKey)。",
|
|
11
|
+
"",
|
|
12
|
+
"## 定时扫描任务",
|
|
13
|
+
"系统通过 Gateway Cron 自动触发以下定时任务:",
|
|
14
|
+
"- 每日扫描(08:00):调用 strategy_daily_scan 扫描策略标的的新闻和行情",
|
|
15
|
+
"- 价格监控(每30分钟):调用 strategy_price_monitor 检测价格异动",
|
|
16
|
+
"- 周报(每周日 20:00)/ 月报(每月1日 20:00):调用 strategy_periodic_report",
|
|
17
|
+
"",
|
|
18
|
+
"收到每日扫描报告后,按以下步骤处理:",
|
|
19
|
+
"1. 分析每条新闻对策略的影响(利好/利空/中性)",
|
|
20
|
+
"2. 使用 fin_kline 获取最新行情确认趋势",
|
|
21
|
+
"3. 如有重大影响,调整策略参数后用 skill_publish 提交回测",
|
|
22
|
+
"4. 用 skill_publish_verify 确认回测完成",
|
|
23
|
+
"5. 将分析报告发送给用户",
|
|
24
|
+
"查看扫描历史:使用 strategy_scan_history",
|
|
11
25
|
].join("\n");
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gateway Cron job registration for openfinclaw-strategy.
|
|
3
|
+
*
|
|
4
|
+
* Directly reads/writes the Gateway cron store (~/.openclaw/cron/jobs.json)
|
|
5
|
+
* so jobs can be registered at plugin startup via the gateway_start hook
|
|
6
|
+
* without requiring a CronService API reference.
|
|
7
|
+
*
|
|
8
|
+
* The running CronService watches the store file and picks up new jobs
|
|
9
|
+
* on its next tick.
|
|
10
|
+
*
|
|
11
|
+
* Idempotent: safe to call multiple times (skips existing jobs by name).
|
|
12
|
+
*
|
|
13
|
+
* Jobs:
|
|
14
|
+
* - openfinclaw:daily-scan (0 8 * * *) 每日策略扫描
|
|
15
|
+
* - openfinclaw:price-monitor (every 30 min) 价格异动监控
|
|
16
|
+
* - openfinclaw:weekly-report (0 20 * * 0) 周报
|
|
17
|
+
* - openfinclaw:monthly-report (0 20 1 * *) 月报
|
|
18
|
+
*/
|
|
19
|
+
import { randomUUID } from "node:crypto";
|
|
20
|
+
import fs from "node:fs";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
import type { UnifiedPluginConfig } from "../types.js";
|
|
23
|
+
|
|
24
|
+
// ── Cron store types (mirrors src/cron/types.ts subset) ──────────────────
|
|
25
|
+
|
|
26
|
+
/** Minimal stored cron job shape. */
|
|
27
|
+
interface StoredCronJob {
|
|
28
|
+
id: string;
|
|
29
|
+
name: string;
|
|
30
|
+
enabled: boolean;
|
|
31
|
+
schedule: { kind: "cron"; expr: string; tz?: string };
|
|
32
|
+
payload: { kind: "systemEvent"; text: string };
|
|
33
|
+
sessionTarget: string;
|
|
34
|
+
wakeMode: string;
|
|
35
|
+
delivery: { mode: string };
|
|
36
|
+
createdAtMs: number;
|
|
37
|
+
updatedAtMs: number;
|
|
38
|
+
state: Record<string, unknown>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface CronStoreFile {
|
|
42
|
+
version: 1;
|
|
43
|
+
jobs: StoredCronJob[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ── File I/O ──────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
/** Resolve default cron store path: ~/.openclaw/cron/jobs.json */
|
|
49
|
+
function defaultStorePath(): string {
|
|
50
|
+
const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
|
|
51
|
+
return path.join(home, ".openclaw", "cron", "jobs.json");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Load cron store. Returns empty store when file doesn't exist. */
|
|
55
|
+
async function loadStore(storePath: string): Promise<CronStoreFile> {
|
|
56
|
+
try {
|
|
57
|
+
const raw = await fs.promises.readFile(storePath, "utf-8");
|
|
58
|
+
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
|
59
|
+
const jobs = Array.isArray(parsed.jobs) ? (parsed.jobs as StoredCronJob[]) : [];
|
|
60
|
+
return { version: 1, jobs };
|
|
61
|
+
} catch (err) {
|
|
62
|
+
if ((err as { code?: string }).code === "ENOENT") {
|
|
63
|
+
return { version: 1, jobs: [] };
|
|
64
|
+
}
|
|
65
|
+
throw err;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Atomic save: write to tmp then rename. */
|
|
70
|
+
async function saveStore(storePath: string, store: CronStoreFile): Promise<void> {
|
|
71
|
+
const dir = path.dirname(storePath);
|
|
72
|
+
await fs.promises.mkdir(dir, { recursive: true });
|
|
73
|
+
const json = JSON.stringify(store, null, 2);
|
|
74
|
+
const tmp = `${storePath}.${process.pid}.${Date.now()}.tmp`;
|
|
75
|
+
await fs.promises.writeFile(tmp, json, "utf-8");
|
|
76
|
+
await fs.promises.rename(tmp, storePath);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── Job definitions ──────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
/** Build cron job definitions from plugin config. */
|
|
82
|
+
function buildCronJobDefs(config: UnifiedPluginConfig): Array<{
|
|
83
|
+
name: string;
|
|
84
|
+
schedule: { kind: "cron"; expr: string; tz?: string };
|
|
85
|
+
payload: { kind: "systemEvent"; text: string };
|
|
86
|
+
}> {
|
|
87
|
+
return [
|
|
88
|
+
{
|
|
89
|
+
name: "openfinclaw:daily-scan",
|
|
90
|
+
schedule: { kind: "cron", expr: config.scanCronExpr, tz: config.scanTimezone },
|
|
91
|
+
payload: {
|
|
92
|
+
kind: "systemEvent",
|
|
93
|
+
text: [
|
|
94
|
+
"[openfinclaw-strategy 每日扫描]",
|
|
95
|
+
"1. 调用 strategy_daily_scan 获取策略扫描报告",
|
|
96
|
+
"2. 分析每条新闻对策略的影响(利好/利空/中性)",
|
|
97
|
+
"3. 如果发现重大影响,调整策略参数并用 skill_publish 提交回测",
|
|
98
|
+
"4. 用 skill_publish_verify 确认回测完成",
|
|
99
|
+
"5. 将分析报告和建议操作发送给用户",
|
|
100
|
+
].join("\n"),
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
name: "openfinclaw:price-monitor",
|
|
105
|
+
schedule: { kind: "cron", expr: config.monitorCronExpr, tz: config.scanTimezone },
|
|
106
|
+
payload: {
|
|
107
|
+
kind: "systemEvent",
|
|
108
|
+
text: [
|
|
109
|
+
"[openfinclaw-strategy 价格监控]",
|
|
110
|
+
"调用 strategy_price_monitor 检查所有策略标的的价格异动。",
|
|
111
|
+
"如有告警(涨跌幅超阈值),分析可能的原因并通知用户。",
|
|
112
|
+
].join("\n"),
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
name: "openfinclaw:weekly-report",
|
|
117
|
+
schedule: { kind: "cron", expr: config.weeklyReportCronExpr, tz: config.scanTimezone },
|
|
118
|
+
payload: {
|
|
119
|
+
kind: "systemEvent",
|
|
120
|
+
text: [
|
|
121
|
+
"[openfinclaw-strategy 周报]",
|
|
122
|
+
'调用 strategy_periodic_report(period="weekly") 生成策略绩效周报。',
|
|
123
|
+
"汇总本周回测结果、价格告警和扫描记录,发送给用户。",
|
|
124
|
+
].join("\n"),
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
name: "openfinclaw:monthly-report",
|
|
129
|
+
schedule: { kind: "cron", expr: config.monthlyReportCronExpr, tz: config.scanTimezone },
|
|
130
|
+
payload: {
|
|
131
|
+
kind: "systemEvent",
|
|
132
|
+
text: [
|
|
133
|
+
"[openfinclaw-strategy 月报]",
|
|
134
|
+
'调用 strategy_periodic_report(period="monthly") 生成策略绩效月报。',
|
|
135
|
+
"汇总本月回测结果、价格告警和扫描记录,发送给用户。",
|
|
136
|
+
].join("\n"),
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ── Public API ────────────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Idempotently register openfinclaw cron jobs by writing to the
|
|
146
|
+
* Gateway cron store file. Safe to call at plugin startup.
|
|
147
|
+
*/
|
|
148
|
+
export async function setupOpenfinclawCronJobs(
|
|
149
|
+
config: UnifiedPluginConfig,
|
|
150
|
+
): Promise<{ ok: boolean; created: number; existing: number }> {
|
|
151
|
+
const storePath = defaultStorePath();
|
|
152
|
+
const store = await loadStore(storePath);
|
|
153
|
+
const existingNames = new Set(store.jobs.map((j) => j.name));
|
|
154
|
+
const defs = buildCronJobDefs(config);
|
|
155
|
+
const now = Date.now();
|
|
156
|
+
|
|
157
|
+
let created = 0;
|
|
158
|
+
for (const def of defs) {
|
|
159
|
+
if (existingNames.has(def.name)) continue;
|
|
160
|
+
store.jobs.push({
|
|
161
|
+
id: randomUUID(),
|
|
162
|
+
name: def.name,
|
|
163
|
+
enabled: true,
|
|
164
|
+
schedule: def.schedule,
|
|
165
|
+
payload: def.payload,
|
|
166
|
+
sessionTarget: "main",
|
|
167
|
+
wakeMode: "now",
|
|
168
|
+
delivery: { mode: "none" },
|
|
169
|
+
createdAtMs: now,
|
|
170
|
+
updatedAtMs: now,
|
|
171
|
+
state: {},
|
|
172
|
+
});
|
|
173
|
+
created++;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (created > 0) {
|
|
177
|
+
await saveStore(storePath, store);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return { ok: true, created, existing: existingNames.size };
|
|
181
|
+
}
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { guessMarket } from "../datahub/client.js";
|
|
2
|
+
/**
|
|
3
|
+
* External news providers for strategy symbol monitoring.
|
|
4
|
+
*
|
|
5
|
+
* Provider priority:
|
|
6
|
+
* 1. CoinGecko Trending — free, no API key required (crypto only)
|
|
7
|
+
* 2. Finnhub — free tier 60 calls/min, requires API key
|
|
8
|
+
* 3. NewsAPI — free tier 100 calls/day, requires API key
|
|
9
|
+
*
|
|
10
|
+
* Default: CoinGecko (zero-config, works out of the box).
|
|
11
|
+
*/
|
|
12
|
+
import type { MarketType } from "../types.js";
|
|
13
|
+
import type { NewsItem, NewsProvider, SymbolNewsResult } from "./types.js";
|
|
14
|
+
|
|
15
|
+
// ── CoinGecko Trending (no key required) ─────────────────────────────────
|
|
16
|
+
|
|
17
|
+
/** CoinGecko trending + search. Free, no API key. Crypto only. */
|
|
18
|
+
export class CoinGeckoTrendingProvider implements NewsProvider {
|
|
19
|
+
readonly name = "coingecko";
|
|
20
|
+
private readonly baseUrl = "https://api.coingecko.com/api/v3";
|
|
21
|
+
|
|
22
|
+
async fetchNews(symbols: string[], market: MarketType): Promise<NewsItem[]> {
|
|
23
|
+
if (market !== "crypto") return [];
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const resp = await fetch(`${this.baseUrl}/search/trending`, {
|
|
27
|
+
signal: AbortSignal.timeout(15_000),
|
|
28
|
+
});
|
|
29
|
+
if (!resp.ok) return [];
|
|
30
|
+
const data = (await resp.json()) as {
|
|
31
|
+
coins?: Array<{
|
|
32
|
+
item: {
|
|
33
|
+
id: string;
|
|
34
|
+
coin_id: number;
|
|
35
|
+
name: string;
|
|
36
|
+
symbol: string;
|
|
37
|
+
market_cap_rank?: number;
|
|
38
|
+
price_btc?: number;
|
|
39
|
+
score: number;
|
|
40
|
+
};
|
|
41
|
+
}>;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const trendingCoins = data.coins ?? [];
|
|
45
|
+
const symbolSet = new Set(symbols.map((s) => s.split("/")[0]?.toUpperCase()));
|
|
46
|
+
const now = new Date().toISOString();
|
|
47
|
+
|
|
48
|
+
return trendingCoins
|
|
49
|
+
.filter((c) => symbolSet.has(c.item.symbol.toUpperCase()))
|
|
50
|
+
.map((c) => ({
|
|
51
|
+
id: `cg-trending-${c.item.id}`,
|
|
52
|
+
title: `${c.item.name} (${c.item.symbol.toUpperCase()}) is trending on CoinGecko`,
|
|
53
|
+
summary: `Rank #${c.item.market_cap_rank ?? "N/A"}, trending score: ${c.item.score}`,
|
|
54
|
+
source: "CoinGecko Trending",
|
|
55
|
+
url: `https://www.coingecko.com/en/coins/${c.item.id}`,
|
|
56
|
+
publishedAt: now,
|
|
57
|
+
symbols: [`${c.item.symbol.toUpperCase()}/USDT`],
|
|
58
|
+
relevanceScore: Math.max(0, 1 - c.item.score * 0.1),
|
|
59
|
+
}));
|
|
60
|
+
} catch {
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── Finnhub (requires API key) ───────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
/** Finnhub company news + market news. Requires API key. */
|
|
69
|
+
export class FinnhubNewsProvider implements NewsProvider {
|
|
70
|
+
readonly name = "finnhub";
|
|
71
|
+
private readonly baseUrl = "https://finnhub.io/api/v1";
|
|
72
|
+
|
|
73
|
+
constructor(private readonly apiKey: string) {}
|
|
74
|
+
|
|
75
|
+
async fetchNews(symbols: string[], market: MarketType): Promise<NewsItem[]> {
|
|
76
|
+
if (market === "crypto") {
|
|
77
|
+
return this.fetchMarketNews("crypto");
|
|
78
|
+
}
|
|
79
|
+
const results: NewsItem[] = [];
|
|
80
|
+
for (const symbol of symbols.slice(0, 5)) {
|
|
81
|
+
const items = await this.fetchCompanyNews(symbol);
|
|
82
|
+
results.push(...items);
|
|
83
|
+
}
|
|
84
|
+
return results;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private async fetchCompanyNews(symbol: string): Promise<NewsItem[]> {
|
|
88
|
+
const cleanSymbol = symbol.replace(/\.(SH|SZ|BJ|HK)$/i, "");
|
|
89
|
+
const to = new Date().toISOString().slice(0, 10);
|
|
90
|
+
const from = new Date(Date.now() - 86_400_000).toISOString().slice(0, 10);
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
const url = `${this.baseUrl}/company-news?symbol=${cleanSymbol}&from=${from}&to=${to}&token=${this.apiKey}`;
|
|
94
|
+
const resp = await fetch(url, { signal: AbortSignal.timeout(15_000) });
|
|
95
|
+
if (!resp.ok) return [];
|
|
96
|
+
const data = (await resp.json()) as Array<{
|
|
97
|
+
id: number;
|
|
98
|
+
headline: string;
|
|
99
|
+
summary: string;
|
|
100
|
+
source: string;
|
|
101
|
+
url: string;
|
|
102
|
+
datetime: number;
|
|
103
|
+
related: string;
|
|
104
|
+
}>;
|
|
105
|
+
|
|
106
|
+
return data.slice(0, 10).map((item) => ({
|
|
107
|
+
id: `finnhub-${item.id}`,
|
|
108
|
+
title: item.headline,
|
|
109
|
+
summary: item.summary.slice(0, 300),
|
|
110
|
+
source: item.source,
|
|
111
|
+
url: item.url,
|
|
112
|
+
publishedAt: new Date(item.datetime * 1000).toISOString(),
|
|
113
|
+
symbols: [symbol],
|
|
114
|
+
relevanceScore: 0.8,
|
|
115
|
+
}));
|
|
116
|
+
} catch {
|
|
117
|
+
return [];
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private async fetchMarketNews(category: string): Promise<NewsItem[]> {
|
|
122
|
+
try {
|
|
123
|
+
const url = `${this.baseUrl}/news?category=${category}&token=${this.apiKey}`;
|
|
124
|
+
const resp = await fetch(url, { signal: AbortSignal.timeout(15_000) });
|
|
125
|
+
if (!resp.ok) return [];
|
|
126
|
+
const data = (await resp.json()) as Array<{
|
|
127
|
+
id: number;
|
|
128
|
+
headline: string;
|
|
129
|
+
summary: string;
|
|
130
|
+
source: string;
|
|
131
|
+
url: string;
|
|
132
|
+
datetime: number;
|
|
133
|
+
}>;
|
|
134
|
+
|
|
135
|
+
return data.slice(0, 10).map((item) => ({
|
|
136
|
+
id: `finnhub-mkt-${item.id}`,
|
|
137
|
+
title: item.headline,
|
|
138
|
+
summary: item.summary.slice(0, 300),
|
|
139
|
+
source: item.source,
|
|
140
|
+
url: item.url,
|
|
141
|
+
publishedAt: new Date(item.datetime * 1000).toISOString(),
|
|
142
|
+
symbols: [],
|
|
143
|
+
relevanceScore: 0.5,
|
|
144
|
+
}));
|
|
145
|
+
} catch {
|
|
146
|
+
return [];
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ── NewsAPI (requires API key) ───────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
/** NewsAPI.org everything search. Requires API key. */
|
|
154
|
+
export class NewsApiProvider implements NewsProvider {
|
|
155
|
+
readonly name = "newsapi";
|
|
156
|
+
private readonly baseUrl = "https://newsapi.org/v2";
|
|
157
|
+
|
|
158
|
+
constructor(private readonly apiKey: string) {}
|
|
159
|
+
|
|
160
|
+
async fetchNews(symbols: string[], _market: MarketType): Promise<NewsItem[]> {
|
|
161
|
+
const query = symbols
|
|
162
|
+
.slice(0, 3)
|
|
163
|
+
.map((s) => s.split("/")[0]?.replace(/\.(SH|SZ|BJ|HK)$/i, ""))
|
|
164
|
+
.join(" OR ");
|
|
165
|
+
if (!query) return [];
|
|
166
|
+
|
|
167
|
+
try {
|
|
168
|
+
const url = `${this.baseUrl}/everything?q=${encodeURIComponent(query)}&sortBy=publishedAt&pageSize=10&apiKey=${this.apiKey}`;
|
|
169
|
+
const resp = await fetch(url, { signal: AbortSignal.timeout(15_000) });
|
|
170
|
+
if (!resp.ok) return [];
|
|
171
|
+
const data = (await resp.json()) as {
|
|
172
|
+
articles?: Array<{
|
|
173
|
+
title: string;
|
|
174
|
+
description: string;
|
|
175
|
+
source: { name: string };
|
|
176
|
+
url: string;
|
|
177
|
+
publishedAt: string;
|
|
178
|
+
}>;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
return (data.articles ?? []).map((a, i) => ({
|
|
182
|
+
id: `newsapi-${Date.now()}-${i}`,
|
|
183
|
+
title: a.title,
|
|
184
|
+
summary: (a.description ?? "").slice(0, 300),
|
|
185
|
+
source: a.source.name,
|
|
186
|
+
url: a.url,
|
|
187
|
+
publishedAt: a.publishedAt,
|
|
188
|
+
symbols,
|
|
189
|
+
relevanceScore: 0.6,
|
|
190
|
+
}));
|
|
191
|
+
} catch {
|
|
192
|
+
return [];
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ── Aggregated provider ──────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Aggregates multiple news providers with fallback.
|
|
201
|
+
* Deduplicates by news ID and sorts by publish time.
|
|
202
|
+
*/
|
|
203
|
+
export class AggregatedNewsProvider {
|
|
204
|
+
private readonly providers: NewsProvider[];
|
|
205
|
+
|
|
206
|
+
constructor(providers: NewsProvider[]) {
|
|
207
|
+
this.providers = providers;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Fetch news for a list of symbols, grouped by symbol. */
|
|
211
|
+
async fetchNewsForSymbols(symbols: string[]): Promise<SymbolNewsResult[]> {
|
|
212
|
+
const symbolsByMarket = new Map<MarketType, string[]>();
|
|
213
|
+
for (const sym of symbols) {
|
|
214
|
+
const market = guessMarket(sym);
|
|
215
|
+
const list = symbolsByMarket.get(market) ?? [];
|
|
216
|
+
list.push(sym);
|
|
217
|
+
symbolsByMarket.set(market, list);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const allNews: NewsItem[] = [];
|
|
221
|
+
for (const [market, syms] of symbolsByMarket) {
|
|
222
|
+
for (const provider of this.providers) {
|
|
223
|
+
try {
|
|
224
|
+
const items = await provider.fetchNews(syms, market);
|
|
225
|
+
allNews.push(...items);
|
|
226
|
+
if (items.length > 0) break; // first successful provider wins
|
|
227
|
+
} catch {
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Deduplicate by id
|
|
234
|
+
const seen = new Set<string>();
|
|
235
|
+
const unique = allNews.filter((item) => {
|
|
236
|
+
if (seen.has(item.id)) return false;
|
|
237
|
+
seen.add(item.id);
|
|
238
|
+
return true;
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// Group by symbol
|
|
242
|
+
const resultMap = new Map<string, SymbolNewsResult>();
|
|
243
|
+
for (const sym of symbols) {
|
|
244
|
+
resultMap.set(sym, {
|
|
245
|
+
symbol: sym,
|
|
246
|
+
market: guessMarket(sym),
|
|
247
|
+
news: [],
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
for (const item of unique) {
|
|
252
|
+
if (item.symbols.length === 0) {
|
|
253
|
+
// Market-wide news: attach to all symbols of matching market
|
|
254
|
+
for (const [sym, result] of resultMap) {
|
|
255
|
+
result.news.push(item);
|
|
256
|
+
}
|
|
257
|
+
} else {
|
|
258
|
+
for (const sym of item.symbols) {
|
|
259
|
+
const result = resultMap.get(sym);
|
|
260
|
+
if (result) result.news.push(item);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Sort each symbol's news by publish time (newest first)
|
|
266
|
+
for (const result of resultMap.values()) {
|
|
267
|
+
result.news.sort((a, b) => b.publishedAt.localeCompare(a.publishedAt));
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return [...resultMap.values()];
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ── Factory ──────────────────────────────────────────────────────────────
|
|
275
|
+
|
|
276
|
+
/** Create news providers based on config. Always includes CoinGecko as fallback. */
|
|
277
|
+
export function createNewsProviders(config: {
|
|
278
|
+
newsProvider: string;
|
|
279
|
+
newsApiKey?: string;
|
|
280
|
+
}): NewsProvider[] {
|
|
281
|
+
const providers: NewsProvider[] = [];
|
|
282
|
+
|
|
283
|
+
if (config.newsApiKey) {
|
|
284
|
+
if (config.newsProvider === "finnhub") {
|
|
285
|
+
providers.push(new FinnhubNewsProvider(config.newsApiKey));
|
|
286
|
+
} else if (config.newsProvider === "newsapi") {
|
|
287
|
+
providers.push(new NewsApiProvider(config.newsApiKey));
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// CoinGecko is always available as fallback (no key required)
|
|
292
|
+
providers.push(new CoinGeckoTrendingProvider());
|
|
293
|
+
|
|
294
|
+
return providers;
|
|
295
|
+
}
|