opencandle 0.3.0 → 0.4.0
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/assets/logo.svg +187 -0
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +38 -2
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +9 -0
- package/dist/config.js +13 -0
- package/dist/config.js.map +1 -1
- package/dist/infra/browser.d.ts +10 -0
- package/dist/infra/browser.js +1 -0
- package/dist/infra/browser.js.map +1 -1
- package/dist/infra/native-dependencies.d.ts +1 -0
- package/dist/infra/native-dependencies.js +10 -0
- package/dist/infra/native-dependencies.js.map +1 -0
- package/dist/infra/node-version.d.ts +2 -0
- package/dist/infra/node-version.js +23 -0
- package/dist/infra/node-version.js.map +1 -0
- package/dist/memory/index.d.ts +2 -0
- package/dist/memory/index.js +1 -0
- package/dist/memory/index.js.map +1 -1
- package/dist/memory/sqlite.js +42 -4
- package/dist/memory/sqlite.js.map +1 -1
- package/dist/memory/storage.d.ts +6 -0
- package/dist/memory/storage.js +3 -3
- package/dist/memory/storage.js.map +1 -1
- package/dist/memory/tool-defaults.d.ts +8 -0
- package/dist/memory/tool-defaults.js +59 -0
- package/dist/memory/tool-defaults.js.map +1 -0
- package/dist/onboarding/connect.d.ts +13 -1
- package/dist/onboarding/connect.js +21 -10
- package/dist/onboarding/connect.js.map +1 -1
- package/dist/onboarding/prompt-user.d.ts +1 -1
- package/dist/onboarding/providers.d.ts +7 -0
- package/dist/onboarding/providers.js +6 -3
- package/dist/onboarding/providers.js.map +1 -1
- package/dist/onboarding/tool-helpers.d.ts +1 -1
- package/dist/pi/opencandle-extension.d.ts +7 -1
- package/dist/pi/opencandle-extension.js +186 -10
- package/dist/pi/opencandle-extension.js.map +1 -1
- package/dist/pi/session-storage.d.ts +2 -0
- package/dist/pi/session-storage.js +5 -0
- package/dist/pi/session-storage.js.map +1 -0
- package/dist/pi/session.d.ts +4 -1
- package/dist/pi/session.js +25 -3
- package/dist/pi/session.js.map +1 -1
- package/dist/pi/setup.d.ts +1 -1
- package/dist/pi/setup.js +1 -1
- package/dist/pi/setup.js.map +1 -1
- package/dist/pi/tool-adapter.d.ts +2 -2
- package/dist/pi/tool-adapter.js +14 -1
- package/dist/pi/tool-adapter.js.map +1 -1
- package/dist/prompts/context-builder.d.ts +22 -0
- package/dist/prompts/context-builder.js +45 -10
- package/dist/prompts/context-builder.js.map +1 -1
- package/dist/prompts/disclaimer.d.ts +6 -0
- package/dist/prompts/disclaimer.js +9 -0
- package/dist/prompts/disclaimer.js.map +1 -0
- package/dist/prompts/workflow-prompts.d.ts +8 -0
- package/dist/prompts/workflow-prompts.js +39 -5
- package/dist/prompts/workflow-prompts.js.map +1 -1
- package/dist/providers/yahoo-finance.js +70 -33
- package/dist/providers/yahoo-finance.js.map +1 -1
- package/dist/routing/defaults.js +1 -1
- package/dist/routing/defaults.js.map +1 -1
- package/dist/routing/index.d.ts +4 -0
- package/dist/routing/index.js +3 -0
- package/dist/routing/index.js.map +1 -1
- package/dist/routing/router-llm-client.d.ts +11 -0
- package/dist/routing/router-llm-client.js +42 -0
- package/dist/routing/router-llm-client.js.map +1 -0
- package/dist/routing/router-prompt.d.ts +2 -0
- package/dist/routing/router-prompt.js +138 -0
- package/dist/routing/router-prompt.js.map +1 -0
- package/dist/routing/router-types.d.ts +62 -0
- package/dist/routing/router-types.js +2 -0
- package/dist/routing/router-types.js.map +1 -0
- package/dist/routing/router.d.ts +10 -0
- package/dist/routing/router.js +194 -0
- package/dist/routing/router.js.map +1 -0
- package/dist/runtime/session-coordinator.d.ts +63 -3
- package/dist/runtime/session-coordinator.js +155 -4
- package/dist/runtime/session-coordinator.js.map +1 -1
- package/dist/runtime/tool-defaults-wrapper.d.ts +3 -0
- package/dist/runtime/tool-defaults-wrapper.js +25 -0
- package/dist/runtime/tool-defaults-wrapper.js.map +1 -0
- package/dist/sentiment/store.js +5 -0
- package/dist/sentiment/store.js.map +1 -1
- package/dist/system-prompt.js +20 -12
- package/dist/system-prompt.js.map +1 -1
- package/dist/tool-kit.d.ts +4 -4
- package/dist/tools/fundamentals/company-overview.d.ts +1 -1
- package/dist/tools/fundamentals/comps.d.ts +1 -1
- package/dist/tools/fundamentals/dcf.d.ts +1 -1
- package/dist/tools/fundamentals/earnings.d.ts +1 -1
- package/dist/tools/fundamentals/financials.d.ts +1 -1
- package/dist/tools/fundamentals/sec-filings.d.ts +1 -1
- package/dist/tools/index.d.ts +28 -1
- package/dist/tools/index.js +27 -0
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/interaction/ask-user.d.ts +1 -1
- package/dist/tools/interaction/twitter-login.d.ts +1 -1
- package/dist/tools/macro/fear-greed.d.ts +1 -1
- package/dist/tools/macro/fred-data.d.ts +1 -1
- package/dist/tools/market/crypto-history.d.ts +1 -1
- package/dist/tools/market/crypto-price.d.ts +1 -1
- package/dist/tools/market/search-ticker.d.ts +1 -1
- package/dist/tools/market/stock-history.d.ts +1 -1
- package/dist/tools/market/stock-quote.d.ts +1 -1
- package/dist/tools/options/option-chain.d.ts +1 -1
- package/dist/tools/options/option-chain.js +4 -1
- package/dist/tools/options/option-chain.js.map +1 -1
- package/dist/tools/portfolio/correlation.d.ts +1 -1
- package/dist/tools/portfolio/predictions.d.ts +1 -1
- package/dist/tools/portfolio/risk-analysis.d.ts +1 -1
- package/dist/tools/portfolio/tracker.d.ts +1 -1
- package/dist/tools/portfolio/watchlist.d.ts +1 -1
- package/dist/tools/sentiment/reddit-sentiment.d.ts +1 -1
- package/dist/tools/sentiment/sentiment-summary.d.ts +1 -1
- package/dist/tools/sentiment/sentiment-trend.d.ts +1 -1
- package/dist/tools/sentiment/twitter-sentiment.d.ts +1 -1
- package/dist/tools/sentiment/web-search.d.ts +1 -1
- package/dist/tools/sentiment/web-sentiment.d.ts +1 -1
- package/dist/tools/technical/backtest.d.ts +1 -1
- package/dist/tools/technical/indicators.d.ts +1 -1
- package/dist/tools/technical/indicators.js +7 -1
- package/dist/tools/technical/indicators.js.map +1 -1
- package/dist/workflows/options-screener.js +7 -2
- package/dist/workflows/options-screener.js.map +1 -1
- package/dist/workflows/portfolio-builder.js +3 -3
- package/dist/workflows/portfolio-builder.js.map +1 -1
- package/gui/server/background-quotes.ts +31 -0
- package/gui/server/chat-event-adapter.ts +142 -0
- package/gui/server/invoke-tool.ts +89 -0
- package/gui/server/live-chat-event-adapter.ts +181 -0
- package/gui/server/model-setup.ts +100 -0
- package/gui/server/package.json +5 -0
- package/gui/server/projector.ts +212 -0
- package/gui/server/server.ts +592 -0
- package/gui/server/session-actions.ts +31 -0
- package/gui/server/tool-metadata.ts +88 -0
- package/gui/server/websocket.ts +128 -0
- package/gui/server/writer-lock.ts +118 -0
- package/gui/shared/chat-events.ts +118 -0
- package/gui/shared/event-reducer.ts +186 -0
- package/gui/web/dist/assets/CatalogOverlay-D1ImSJTe.js +1 -0
- package/gui/web/dist/assets/index-DBrWq43L.css +1 -0
- package/gui/web/dist/assets/index-RflHaj0y.js +67 -0
- package/gui/web/dist/assets/logo-CWpt6Y2a.svg +187 -0
- package/gui/web/dist/index.html +17 -0
- package/package.json +44 -18
- package/src/analysts/contracts.ts +189 -0
- package/src/analysts/orchestrator.ts +300 -0
- package/src/cli.ts +205 -0
- package/src/config.ts +161 -0
- package/src/index.ts +5 -0
- package/src/infra/browser.ts +111 -0
- package/src/infra/cache.ts +103 -0
- package/src/infra/http-client.ts +68 -0
- package/src/infra/index.ts +18 -0
- package/src/infra/native-dependencies.ts +12 -0
- package/src/infra/node-version.ts +24 -0
- package/src/infra/open-url.ts +28 -0
- package/src/infra/opencandle-paths.ts +64 -0
- package/src/infra/rate-limiter.ts +64 -0
- package/src/memory/index.ts +10 -0
- package/src/memory/manager.ts +159 -0
- package/src/memory/preference-extractor.ts +106 -0
- package/src/memory/retrieval.ts +70 -0
- package/src/memory/sqlite.ts +172 -0
- package/src/memory/storage.ts +204 -0
- package/src/memory/tool-defaults.ts +87 -0
- package/src/memory/types.ts +67 -0
- package/src/onboarding/connect.ts +184 -0
- package/src/onboarding/credential-interceptor.ts +134 -0
- package/src/onboarding/degradation-accumulator.ts +79 -0
- package/src/onboarding/prompt-user.ts +85 -0
- package/src/onboarding/providers.ts +315 -0
- package/src/onboarding/state.ts +218 -0
- package/src/onboarding/tool-helpers.ts +111 -0
- package/src/onboarding/tool-tags.ts +201 -0
- package/src/onboarding/validation.ts +158 -0
- package/src/pi/opencandle-extension.ts +724 -0
- package/src/pi/session-storage.ts +5 -0
- package/src/pi/session.ts +81 -0
- package/src/pi/setup.ts +371 -0
- package/src/pi/tool-adapter.ts +36 -0
- package/src/prompts/context-builder.ts +204 -0
- package/src/prompts/disclaimer.ts +9 -0
- package/src/prompts/sections.ts +46 -0
- package/src/prompts/workflow-prompts.ts +279 -0
- package/src/providers/alpha-vantage.ts +292 -0
- package/src/providers/coingecko.ts +96 -0
- package/src/providers/exa-search.ts +373 -0
- package/src/providers/fear-greed.ts +45 -0
- package/src/providers/finnhub.ts +124 -0
- package/src/providers/fred.ts +83 -0
- package/src/providers/index.ts +9 -0
- package/src/providers/provider-credential-error.ts +23 -0
- package/src/providers/reddit.ts +151 -0
- package/src/providers/sec-edgar.ts +96 -0
- package/src/providers/twitter.ts +173 -0
- package/src/providers/web-search.ts +293 -0
- package/src/providers/with-fallback.ts +41 -0
- package/src/providers/wrap-provider.ts +64 -0
- package/src/providers/yahoo-finance.ts +367 -0
- package/src/routing/classify-intent.ts +194 -0
- package/src/routing/defaults.ts +29 -0
- package/src/routing/entity-extractor.ts +140 -0
- package/src/routing/index.ts +26 -0
- package/src/routing/router-llm-client.ts +51 -0
- package/src/routing/router-prompt.ts +159 -0
- package/src/routing/router-types.ts +66 -0
- package/src/routing/router.ts +213 -0
- package/src/routing/slot-resolver.ts +152 -0
- package/src/routing/types.ts +63 -0
- package/src/runtime/evidence.ts +77 -0
- package/src/runtime/index.ts +55 -0
- package/src/runtime/prompt-step.ts +75 -0
- package/src/runtime/provider-ids.ts +15 -0
- package/src/runtime/provider-tracker.ts +40 -0
- package/src/runtime/run-context.ts +22 -0
- package/src/runtime/session-coordinator.ts +406 -0
- package/src/runtime/tool-defaults-wrapper.ts +35 -0
- package/src/runtime/validation.ts +214 -0
- package/src/runtime/workflow-events.ts +75 -0
- package/src/runtime/workflow-runner.ts +188 -0
- package/src/runtime/workflow-types.ts +102 -0
- package/src/sentiment/adapters/finnhub.ts +44 -0
- package/src/sentiment/adapters/reddit.ts +65 -0
- package/src/sentiment/adapters/twitter.ts +36 -0
- package/src/sentiment/adapters/web.ts +44 -0
- package/src/sentiment/index.ts +58 -0
- package/src/sentiment/keywords.ts +9 -0
- package/src/sentiment/pipeline.ts +68 -0
- package/src/sentiment/scorer.ts +78 -0
- package/src/sentiment/store.ts +260 -0
- package/src/sentiment/trends.ts +90 -0
- package/src/sentiment/types.ts +108 -0
- package/src/system-prompt.ts +115 -0
- package/src/tool-kit.ts +68 -0
- package/src/tools/AGENTS.md +36 -0
- package/src/tools/fundamentals/company-overview.ts +54 -0
- package/src/tools/fundamentals/comps.ts +156 -0
- package/src/tools/fundamentals/dcf.ts +267 -0
- package/src/tools/fundamentals/earnings.ts +47 -0
- package/src/tools/fundamentals/financials.ts +54 -0
- package/src/tools/fundamentals/sec-filings.ts +61 -0
- package/src/tools/index.ts +88 -0
- package/src/tools/interaction/ask-user.ts +81 -0
- package/src/tools/interaction/twitter-login.ts +93 -0
- package/src/tools/macro/fear-greed.ts +41 -0
- package/src/tools/macro/fred-data.ts +54 -0
- package/src/tools/market/crypto-history.ts +51 -0
- package/src/tools/market/crypto-price.ts +53 -0
- package/src/tools/market/search-ticker.ts +53 -0
- package/src/tools/market/stock-history.ts +79 -0
- package/src/tools/market/stock-quote.ts +64 -0
- package/src/tools/options/greeks.ts +82 -0
- package/src/tools/options/option-chain.ts +91 -0
- package/src/tools/portfolio/correlation.ts +162 -0
- package/src/tools/portfolio/predictions.ts +253 -0
- package/src/tools/portfolio/risk-analysis.ts +134 -0
- package/src/tools/portfolio/tracker.ts +147 -0
- package/src/tools/portfolio/watchlist.ts +153 -0
- package/src/tools/sentiment/reddit-sentiment.ts +164 -0
- package/src/tools/sentiment/sentiment-summary.ts +256 -0
- package/src/tools/sentiment/sentiment-trend.ts +58 -0
- package/src/tools/sentiment/twitter-sentiment.ts +96 -0
- package/src/tools/sentiment/web-search.ts +150 -0
- package/src/tools/sentiment/web-sentiment.ts +76 -0
- package/src/tools/technical/backtest.ts +246 -0
- package/src/tools/technical/indicators.ts +258 -0
- package/src/types/fundamentals.ts +46 -0
- package/src/types/index.ts +20 -0
- package/src/types/macro.ts +27 -0
- package/src/types/market.ts +43 -0
- package/src/types/options.ts +35 -0
- package/src/types/portfolio.ts +41 -0
- package/src/types/sentiment.ts +70 -0
- package/src/workflows/compare-assets.ts +39 -0
- package/src/workflows/index.ts +4 -0
- package/src/workflows/options-screener.ts +49 -0
- package/src/workflows/portfolio-builder.ts +52 -0
- package/src/workflows/types.ts +4 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { httpGet } from "../infra/http-client.js";
|
|
2
|
+
import { cache, TTL, STALE_LIMIT } from "../infra/cache.js";
|
|
3
|
+
import { rateLimiter } from "../infra/rate-limiter.js";
|
|
4
|
+
import type { RedditSentimentResult } from "../types/sentiment.js";
|
|
5
|
+
import { BULLISH_TERMS, BEARISH_TERMS } from "../sentiment/keywords.js";
|
|
6
|
+
|
|
7
|
+
interface RedditListingResponse {
|
|
8
|
+
data: {
|
|
9
|
+
children: Array<{
|
|
10
|
+
data: {
|
|
11
|
+
id: string;
|
|
12
|
+
title: string;
|
|
13
|
+
selftext: string;
|
|
14
|
+
author: string;
|
|
15
|
+
score: number;
|
|
16
|
+
num_comments: number;
|
|
17
|
+
permalink: string;
|
|
18
|
+
created_utc: number;
|
|
19
|
+
};
|
|
20
|
+
}>;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const REDDIT_HEADERS = { "User-Agent": "OpenCandle/1.0 (financial analysis agent)" };
|
|
25
|
+
|
|
26
|
+
export async function getSubredditPosts(
|
|
27
|
+
subreddit: string,
|
|
28
|
+
limit: number = 25,
|
|
29
|
+
): Promise<RedditSentimentResult> {
|
|
30
|
+
const cacheKey = `reddit:${subreddit}:${limit}`;
|
|
31
|
+
const cached = cache.get<RedditSentimentResult>(cacheKey);
|
|
32
|
+
if (cached) return cached;
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
await rateLimiter.acquire("reddit");
|
|
36
|
+
const url = `https://www.reddit.com/r/${encodeURIComponent(subreddit)}/hot.json?limit=${limit}`;
|
|
37
|
+
const data = await httpGet<RedditListingResponse>(url, {
|
|
38
|
+
headers: REDDIT_HEADERS,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const posts = data.data.children.map((child) => ({
|
|
42
|
+
id: child.data.id,
|
|
43
|
+
title: child.data.title,
|
|
44
|
+
selftext: child.data.selftext ?? "",
|
|
45
|
+
author: child.data.author ?? "unknown",
|
|
46
|
+
score: child.data.score,
|
|
47
|
+
comments: child.data.num_comments,
|
|
48
|
+
url: `https://reddit.com${child.data.permalink}`,
|
|
49
|
+
created: new Date(child.data.created_utc * 1000).toISOString(),
|
|
50
|
+
}));
|
|
51
|
+
|
|
52
|
+
// Extract ticker-like mentions ($AAPL, $TSLA, etc.)
|
|
53
|
+
const tickerRegex = /\$([A-Z]{1,5})\b/g;
|
|
54
|
+
const mentionCounts = new Map<string, number>();
|
|
55
|
+
for (const post of posts) {
|
|
56
|
+
for (const match of post.title.matchAll(tickerRegex)) {
|
|
57
|
+
const ticker = match[1];
|
|
58
|
+
mentionCounts.set(ticker, (mentionCounts.get(ticker) ?? 0) + 1);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const topMentions = [...mentionCounts.entries()]
|
|
62
|
+
.sort((a, b) => b[1] - a[1])
|
|
63
|
+
.slice(0, 10)
|
|
64
|
+
.map(([ticker]) => ticker);
|
|
65
|
+
|
|
66
|
+
const sentiment = scoreSentiment(posts);
|
|
67
|
+
|
|
68
|
+
const result: RedditSentimentResult = {
|
|
69
|
+
subreddit,
|
|
70
|
+
postCount: posts.length,
|
|
71
|
+
posts,
|
|
72
|
+
topMentions,
|
|
73
|
+
sentimentScore: sentiment.score,
|
|
74
|
+
bullishCount: sentiment.bullish,
|
|
75
|
+
bearishCount: sentiment.bearish,
|
|
76
|
+
fetchedAt: new Date().toISOString(),
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
cache.set(cacheKey, result, TTL.SENTIMENT);
|
|
80
|
+
return result;
|
|
81
|
+
} catch (error) {
|
|
82
|
+
const stale = cache.getStale<RedditSentimentResult>(cacheKey, STALE_LIMIT.SENTIMENT);
|
|
83
|
+
if (stale) return stale.value;
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── Comment fetching ────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
export interface RedditComment {
|
|
91
|
+
id: string;
|
|
92
|
+
body: string;
|
|
93
|
+
author: string;
|
|
94
|
+
score: number;
|
|
95
|
+
permalink: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const COMMENT_TTL = 30 * 60 * 1000; // 30 minutes
|
|
99
|
+
|
|
100
|
+
export async function getPostComments(
|
|
101
|
+
subreddit: string,
|
|
102
|
+
postId: string,
|
|
103
|
+
limit: number = 5,
|
|
104
|
+
): Promise<RedditComment[]> {
|
|
105
|
+
const cacheKey = `reddit:comments:${subreddit}:${postId}:${limit}`;
|
|
106
|
+
const cached = cache.get<RedditComment[]>(cacheKey);
|
|
107
|
+
if (cached) return cached;
|
|
108
|
+
|
|
109
|
+
await rateLimiter.acquire("reddit_comments");
|
|
110
|
+
const url = `https://www.reddit.com/r/${encodeURIComponent(subreddit)}/comments/${postId}.json`;
|
|
111
|
+
const data = await httpGet<Array<{ data: { children: Array<{ kind: string; data: { id: string; body?: string; author?: string; score?: number; permalink?: string } }> } }>>(url, {
|
|
112
|
+
headers: REDDIT_HEADERS,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// Comments are in the second listing element
|
|
116
|
+
const commentListing = data[1]?.data?.children ?? [];
|
|
117
|
+
const comments: RedditComment[] = commentListing
|
|
118
|
+
.filter((c) => c.kind === "t1" && c.data.body)
|
|
119
|
+
.sort((a, b) => (b.data.score ?? 0) - (a.data.score ?? 0))
|
|
120
|
+
.slice(0, limit)
|
|
121
|
+
.map((c) => ({
|
|
122
|
+
id: c.data.id,
|
|
123
|
+
body: c.data.body!,
|
|
124
|
+
author: c.data.author ?? "unknown",
|
|
125
|
+
score: c.data.score ?? 0,
|
|
126
|
+
permalink: `https://reddit.com${c.data.permalink ?? ""}`,
|
|
127
|
+
}));
|
|
128
|
+
|
|
129
|
+
cache.set(cacheKey, comments, COMMENT_TTL);
|
|
130
|
+
return comments;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ── Sentiment scoring ───────────────────────────────────
|
|
134
|
+
|
|
135
|
+
export function scoreSentiment(
|
|
136
|
+
posts: Array<{ title: string }>,
|
|
137
|
+
): { score: number; bullish: number; bearish: number } {
|
|
138
|
+
let bullish = 0;
|
|
139
|
+
let bearish = 0;
|
|
140
|
+
for (const post of posts) {
|
|
141
|
+
const lower = post.title.toLowerCase();
|
|
142
|
+
bullish += BULLISH_TERMS.filter((t) => lower.includes(t)).length;
|
|
143
|
+
bearish += BEARISH_TERMS.filter((t) => lower.includes(t)).length;
|
|
144
|
+
}
|
|
145
|
+
const total = bullish + bearish;
|
|
146
|
+
return {
|
|
147
|
+
score: total === 0 ? 0 : (bullish - bearish) / total,
|
|
148
|
+
bullish,
|
|
149
|
+
bearish,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { httpGet } from "../infra/http-client.js";
|
|
2
|
+
import { cache, TTL } from "../infra/cache.js";
|
|
3
|
+
|
|
4
|
+
const EFTS_BASE = "https://efts.sec.gov/LATEST/search-index";
|
|
5
|
+
|
|
6
|
+
export interface SECFiling {
|
|
7
|
+
formType: string;
|
|
8
|
+
filedDate: string;
|
|
9
|
+
periodOfReport: string;
|
|
10
|
+
entityName: string;
|
|
11
|
+
accessionNumber: string;
|
|
12
|
+
url: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface EFTSResponse {
|
|
16
|
+
hits: {
|
|
17
|
+
hits: Array<{
|
|
18
|
+
_id: string;
|
|
19
|
+
_source: {
|
|
20
|
+
file_date: string;
|
|
21
|
+
form: string;
|
|
22
|
+
adsh: string;
|
|
23
|
+
display_names: string[];
|
|
24
|
+
period_ending: string;
|
|
25
|
+
ciks: string[];
|
|
26
|
+
};
|
|
27
|
+
}>;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function searchFilings(
|
|
32
|
+
ticker: string,
|
|
33
|
+
formTypes: string[] = ["10-K", "10-Q", "8-K"],
|
|
34
|
+
limit: number = 10,
|
|
35
|
+
): Promise<SECFiling[]> {
|
|
36
|
+
const cacheKey = `sec:${ticker}:${formTypes.join(",")}:${limit}`;
|
|
37
|
+
const cached = cache.get<SECFiling[]>(cacheKey);
|
|
38
|
+
if (cached) return cached;
|
|
39
|
+
|
|
40
|
+
const params = new URLSearchParams({
|
|
41
|
+
q: ticker,
|
|
42
|
+
forms: formTypes.join(","),
|
|
43
|
+
dateRange: "custom",
|
|
44
|
+
startdt: getDateYearsAgo(3),
|
|
45
|
+
enddt: new Date().toISOString().split("T")[0],
|
|
46
|
+
from: "0",
|
|
47
|
+
size: String(limit),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const url = `${EFTS_BASE}?${params}`;
|
|
51
|
+
const data = await httpGet<EFTSResponse>(url, {
|
|
52
|
+
headers: { "User-Agent": "OpenCandle/1.0 (financial analysis agent)" },
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// Deduplicate by accession number (EDGAR returns multiple hits per filing)
|
|
56
|
+
const seen = new Set<string>();
|
|
57
|
+
const filings: SECFiling[] = [];
|
|
58
|
+
|
|
59
|
+
for (const hit of data.hits?.hits ?? []) {
|
|
60
|
+
const src = hit._source;
|
|
61
|
+
const accession = src.adsh;
|
|
62
|
+
if (!accession || seen.has(accession)) continue;
|
|
63
|
+
seen.add(accession);
|
|
64
|
+
|
|
65
|
+
const cik = src.ciks?.[0] ?? "";
|
|
66
|
+
const displayName = src.display_names?.[0] ?? "";
|
|
67
|
+
// Extract entity name from display format: "APPLE INC (AAPL) (CIK 0000320193)"
|
|
68
|
+
const entityName = displayName.split("(")[0]?.trim() ?? displayName;
|
|
69
|
+
|
|
70
|
+
filings.push({
|
|
71
|
+
formType: src.form ?? "",
|
|
72
|
+
filedDate: src.file_date ?? "",
|
|
73
|
+
periodOfReport: src.period_ending ?? "",
|
|
74
|
+
entityName,
|
|
75
|
+
accessionNumber: accession,
|
|
76
|
+
url: buildEdgarUrl(cik, accession),
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
if (filings.length >= limit) break;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
cache.set(cacheKey, filings, TTL.FUNDAMENTALS);
|
|
83
|
+
return filings;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function buildEdgarUrl(cik: string, accession: string): string {
|
|
87
|
+
const cikNum = cik.replace(/^0+/, "");
|
|
88
|
+
const accessionNoDash = accession.replace(/-/g, "");
|
|
89
|
+
return `https://www.sec.gov/Archives/edgar/data/${cikNum}/${accessionNoDash}/${accession}-index.htm`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function getDateYearsAgo(years: number): string {
|
|
93
|
+
const d = new Date();
|
|
94
|
+
d.setFullYear(d.getFullYear() - years);
|
|
95
|
+
return d.toISOString().split("T")[0];
|
|
96
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import Database from "better-sqlite3";
|
|
4
|
+
import { Scraper, SearchMode } from "@the-convocation/twitter-scraper";
|
|
5
|
+
import { cache, TTL, STALE_LIMIT } from "../infra/cache.js";
|
|
6
|
+
import { rateLimiter } from "../infra/rate-limiter.js";
|
|
7
|
+
import { getBrowserProfileDir } from "../infra/opencandle-paths.js";
|
|
8
|
+
import type { TwitterSentimentResult, TwitterTweet } from "../types/sentiment.js";
|
|
9
|
+
|
|
10
|
+
// ── Cookie extraction ────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
interface FirefoxCookie {
|
|
13
|
+
name: string;
|
|
14
|
+
value: string;
|
|
15
|
+
domain: string;
|
|
16
|
+
path: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function readTwitterCookies(profileDir: string): FirefoxCookie[] {
|
|
20
|
+
const dbPath = join(profileDir, "cookies.sqlite");
|
|
21
|
+
if (!existsSync(dbPath)) return [];
|
|
22
|
+
|
|
23
|
+
const db = new Database(dbPath, { readonly: true });
|
|
24
|
+
try {
|
|
25
|
+
const rows = db
|
|
26
|
+
.prepare(
|
|
27
|
+
`SELECT name, value, host AS domain, path FROM moz_cookies WHERE host LIKE ? OR host LIKE ?`,
|
|
28
|
+
)
|
|
29
|
+
.all("%x.com%", "%twitter.com%") as FirefoxCookie[];
|
|
30
|
+
return rows;
|
|
31
|
+
} finally {
|
|
32
|
+
db.close();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ── Sentiment scoring ────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
import { BULLISH_TERMS, BEARISH_TERMS } from "../sentiment/keywords.js";
|
|
39
|
+
|
|
40
|
+
export function scoreTwitterSentiment(
|
|
41
|
+
tweets: Array<{ text: string; likes: number; retweets: number }>,
|
|
42
|
+
): { score: number; bullish: number; bearish: number } {
|
|
43
|
+
let bullishWeight = 0;
|
|
44
|
+
let bearishWeight = 0;
|
|
45
|
+
let bullishCount = 0;
|
|
46
|
+
let bearishCount = 0;
|
|
47
|
+
|
|
48
|
+
for (const tweet of tweets) {
|
|
49
|
+
const lower = tweet.text.toLowerCase();
|
|
50
|
+
const engagement = 1 + (tweet.likes ?? 0) + (tweet.retweets ?? 0);
|
|
51
|
+
const tweetBullish = BULLISH_TERMS.filter((t) => lower.includes(t)).length;
|
|
52
|
+
const tweetBearish = BEARISH_TERMS.filter((t) => lower.includes(t)).length;
|
|
53
|
+
|
|
54
|
+
bullishCount += tweetBullish;
|
|
55
|
+
bearishCount += tweetBearish;
|
|
56
|
+
bullishWeight += tweetBullish * engagement;
|
|
57
|
+
bearishWeight += tweetBearish * engagement;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const totalWeight = bullishWeight + bearishWeight;
|
|
61
|
+
return {
|
|
62
|
+
score: totalWeight === 0 ? 0 : (bullishWeight - bearishWeight) / totalWeight,
|
|
63
|
+
bullish: bullishCount,
|
|
64
|
+
bearish: bearishCount,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ── Query normalization ──────────────────────────────────
|
|
69
|
+
|
|
70
|
+
export function normalizeQuery(query: string): string {
|
|
71
|
+
if (/^[A-Z]{1,5}$/.test(query)) return "$" + query;
|
|
72
|
+
return query;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── Main provider function ───────────────────────────────
|
|
76
|
+
|
|
77
|
+
export async function getTwitterSentiment(
|
|
78
|
+
query: string,
|
|
79
|
+
limit: number = 50,
|
|
80
|
+
hours: number = 24,
|
|
81
|
+
): Promise<TwitterSentimentResult> {
|
|
82
|
+
const normalizedQuery = normalizeQuery(query);
|
|
83
|
+
const cacheKey = `twitter:${normalizedQuery}:${limit}:${hours}`;
|
|
84
|
+
const cached = cache.get<TwitterSentimentResult>(cacheKey);
|
|
85
|
+
if (cached) return cached;
|
|
86
|
+
|
|
87
|
+
await rateLimiter.acquire("twitter");
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
const profileDir = getBrowserProfileDir();
|
|
91
|
+
const cookies = readTwitterCookies(profileDir);
|
|
92
|
+
|
|
93
|
+
const authToken = cookies.find((c) => c.name === "auth_token");
|
|
94
|
+
const ct0 = cookies.find((c) => c.name === "ct0");
|
|
95
|
+
|
|
96
|
+
if (!authToken || !ct0) {
|
|
97
|
+
throw new Error("No Twitter session found.");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const scraper = new Scraper();
|
|
101
|
+
const cookieStrings = cookies.map(
|
|
102
|
+
(c) => `${c.name}=${c.value}; Domain=${c.domain}; Path=${c.path}`,
|
|
103
|
+
);
|
|
104
|
+
await scraper.setCookies(cookieStrings);
|
|
105
|
+
|
|
106
|
+
const loggedIn = await scraper.isLoggedIn();
|
|
107
|
+
if (!loggedIn) {
|
|
108
|
+
throw new Error("Twitter session expired.");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const cutoff = new Date(Date.now() - hours * 3_600_000);
|
|
112
|
+
const tweets: TwitterTweet[] = [];
|
|
113
|
+
const results = scraper.searchTweets(normalizedQuery, limit, SearchMode.Latest);
|
|
114
|
+
|
|
115
|
+
for await (const tweet of results) {
|
|
116
|
+
const created = tweet.timeParsed ?? new Date(0);
|
|
117
|
+
if (created < cutoff) continue;
|
|
118
|
+
|
|
119
|
+
tweets.push({
|
|
120
|
+
id: tweet.id ?? "",
|
|
121
|
+
text: tweet.text?.slice(0, 280) ?? "",
|
|
122
|
+
author: tweet.username ?? "unknown",
|
|
123
|
+
likes: tweet.likes ?? 0,
|
|
124
|
+
retweets: tweet.retweets ?? 0,
|
|
125
|
+
replies: tweet.replies ?? 0,
|
|
126
|
+
views: tweet.views ?? null,
|
|
127
|
+
url: tweet.permanentUrl ?? "",
|
|
128
|
+
created: created.toISOString(),
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
if (tweets.length >= limit) break;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Extract co-mentioned cashtags
|
|
135
|
+
const tickerRegex = /\$([A-Z]{1,5})\b/g;
|
|
136
|
+
const mentionCounts = new Map<string, number>();
|
|
137
|
+
// Exclude the searched ticker itself from co-mentions
|
|
138
|
+
const searchedTicker = normalizedQuery.startsWith("$")
|
|
139
|
+
? normalizedQuery.slice(1)
|
|
140
|
+
: null;
|
|
141
|
+
for (const t of tweets) {
|
|
142
|
+
for (const match of t.text.matchAll(tickerRegex)) {
|
|
143
|
+
const ticker = match[1];
|
|
144
|
+
if (ticker === searchedTicker) continue;
|
|
145
|
+
mentionCounts.set(ticker, (mentionCounts.get(ticker) ?? 0) + 1);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const topMentions = [...mentionCounts.entries()]
|
|
149
|
+
.sort((a, b) => b[1] - a[1])
|
|
150
|
+
.slice(0, 10)
|
|
151
|
+
.map(([ticker]) => ticker);
|
|
152
|
+
|
|
153
|
+
const sentiment = scoreTwitterSentiment(tweets);
|
|
154
|
+
|
|
155
|
+
const result: TwitterSentimentResult = {
|
|
156
|
+
query: normalizedQuery,
|
|
157
|
+
tweetCount: tweets.length,
|
|
158
|
+
tweets,
|
|
159
|
+
sentimentScore: sentiment.score,
|
|
160
|
+
bullishCount: sentiment.bullish,
|
|
161
|
+
bearishCount: sentiment.bearish,
|
|
162
|
+
topMentions,
|
|
163
|
+
fetchedAt: new Date().toISOString(),
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
cache.set(cacheKey, result, TTL.SENTIMENT);
|
|
167
|
+
return result;
|
|
168
|
+
} catch (error) {
|
|
169
|
+
const stale = cache.getStale<TwitterSentimentResult>(cacheKey, STALE_LIMIT.SENTIMENT);
|
|
170
|
+
if (stale) return stale.value;
|
|
171
|
+
throw error;
|
|
172
|
+
}
|
|
173
|
+
}
|