opencandle 0.3.0 → 0.5.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/LICENSE +1 -1
- package/README.md +106 -14
- package/assets/logo.svg +187 -0
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +40 -3
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +25 -0
- package/dist/config.js +72 -0
- package/dist/config.js.map +1 -1
- package/dist/infra/browser.d.ts +11 -3
- package/dist/infra/browser.js +2 -1
- 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/infra/rate-limiter.d.ts +4 -0
- package/dist/infra/rate-limiter.js +5 -1
- package/dist/infra/rate-limiter.js.map +1 -1
- 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/manager.d.ts +9 -0
- package/dist/memory/manager.js +28 -11
- package/dist/memory/manager.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 +7 -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/memory/types.js +4 -0
- package/dist/memory/types.js.map +1 -1
- 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 +391 -21
- 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 +11 -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 +40 -3
- package/dist/prompts/context-builder.js +140 -19
- 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/policy-cards.d.ts +13 -0
- package/dist/prompts/policy-cards.js +197 -0
- package/dist/prompts/policy-cards.js.map +1 -0
- package/dist/prompts/sections.js +3 -3
- package/dist/prompts/sections.js.map +1 -1
- package/dist/prompts/workflow-prompts.d.ts +8 -0
- package/dist/prompts/workflow-prompts.js +208 -22
- package/dist/prompts/workflow-prompts.js.map +1 -1
- package/dist/providers/alpha-vantage.js +23 -1
- package/dist/providers/alpha-vantage.js.map +1 -1
- package/dist/providers/sec-edgar.d.ts +8 -1
- package/dist/providers/sec-edgar.js +172 -5
- package/dist/providers/sec-edgar.js.map +1 -1
- package/dist/providers/yahoo-finance.d.ts +2 -0
- package/dist/providers/yahoo-finance.js +203 -35
- package/dist/providers/yahoo-finance.js.map +1 -1
- package/dist/routing/classify-intent.d.ts +3 -0
- package/dist/routing/classify-intent.js +82 -3
- package/dist/routing/classify-intent.js.map +1 -1
- package/dist/routing/defaults.js +4 -4
- package/dist/routing/defaults.js.map +1 -1
- package/dist/routing/entity-extractor.d.ts +1 -0
- package/dist/routing/entity-extractor.js +158 -12
- package/dist/routing/entity-extractor.js.map +1 -1
- package/dist/routing/index.d.ts +10 -0
- package/dist/routing/index.js +7 -0
- package/dist/routing/index.js.map +1 -1
- package/dist/routing/legacy-rule-router.d.ts +9 -0
- package/dist/routing/legacy-rule-router.js +12 -0
- package/dist/routing/legacy-rule-router.js.map +1 -0
- package/dist/routing/planning.d.ts +54 -0
- package/dist/routing/planning.js +531 -0
- package/dist/routing/planning.js.map +1 -0
- package/dist/routing/route-manifest.d.ts +35 -0
- package/dist/routing/route-manifest.js +221 -0
- package/dist/routing/route-manifest.js.map +1 -0
- 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 +141 -0
- package/dist/routing/router-prompt.js.map +1 -0
- package/dist/routing/router-types.d.ts +71 -0
- package/dist/routing/router-types.js +2 -0
- package/dist/routing/router-types.js.map +1 -0
- package/dist/routing/router.d.ts +11 -0
- package/dist/routing/router.js +638 -0
- package/dist/routing/router.js.map +1 -0
- package/dist/routing/slot-resolver.js +46 -6
- package/dist/routing/slot-resolver.js.map +1 -1
- package/dist/routing/turn-context.d.ts +44 -0
- package/dist/routing/turn-context.js +45 -0
- package/dist/routing/turn-context.js.map +1 -0
- package/dist/routing/types.d.ts +13 -1
- package/dist/runtime/answer-contracts.d.ts +82 -0
- package/dist/runtime/answer-contracts.js +414 -0
- package/dist/runtime/answer-contracts.js.map +1 -0
- package/dist/runtime/artifact-contracts.d.ts +14 -0
- package/dist/runtime/artifact-contracts.js +57 -0
- package/dist/runtime/artifact-contracts.js.map +1 -0
- package/dist/runtime/planning-evidence.d.ts +99 -0
- package/dist/runtime/planning-evidence.js +445 -0
- package/dist/runtime/planning-evidence.js.map +1 -0
- package/dist/runtime/session-coordinator.d.ts +81 -3
- package/dist/runtime/session-coordinator.js +201 -17
- 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 +23 -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/company-overview.js +1 -1
- package/dist/tools/fundamentals/company-overview.js.map +1 -1
- package/dist/tools/fundamentals/comps.d.ts +1 -1
- package/dist/tools/fundamentals/comps.js +1 -1
- package/dist/tools/fundamentals/comps.js.map +1 -1
- package/dist/tools/fundamentals/dcf.d.ts +1 -1
- package/dist/tools/fundamentals/dcf.js +1 -1
- package/dist/tools/fundamentals/dcf.js.map +1 -1
- package/dist/tools/fundamentals/earnings.d.ts +1 -1
- package/dist/tools/fundamentals/earnings.js +1 -1
- package/dist/tools/fundamentals/earnings.js.map +1 -1
- package/dist/tools/fundamentals/financials.d.ts +1 -1
- package/dist/tools/fundamentals/financials.js +1 -1
- package/dist/tools/fundamentals/financials.js.map +1 -1
- package/dist/tools/fundamentals/sec-filings.d.ts +2 -1
- package/dist/tools/fundamentals/sec-filings.js +19 -2
- package/dist/tools/fundamentals/sec-filings.js.map +1 -1
- package/dist/tools/index.d.ts +29 -1
- package/dist/tools/index.js +30 -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/fear-greed.js +1 -1
- package/dist/tools/macro/fear-greed.js.map +1 -1
- package/dist/tools/macro/fred-data.d.ts +1 -1
- package/dist/tools/macro/fred-data.js +29 -5
- package/dist/tools/macro/fred-data.js.map +1 -1
- package/dist/tools/market/crypto-history.d.ts +1 -1
- package/dist/tools/market/crypto-history.js +18 -2
- package/dist/tools/market/crypto-history.js.map +1 -1
- package/dist/tools/market/crypto-price.d.ts +1 -1
- package/dist/tools/market/crypto-price.js +1 -1
- package/dist/tools/market/crypto-price.js.map +1 -1
- package/dist/tools/market/search-ticker.d.ts +1 -1
- package/dist/tools/market/search-ticker.js +1 -1
- package/dist/tools/market/search-ticker.js.map +1 -1
- package/dist/tools/market/stock-history.d.ts +1 -1
- package/dist/tools/market/stock-history.js +1 -1
- package/dist/tools/market/stock-history.js.map +1 -1
- package/dist/tools/market/stock-quote.d.ts +1 -1
- package/dist/tools/market/stock-quote.js +1 -1
- package/dist/tools/market/stock-quote.js.map +1 -1
- package/dist/tools/options/greeks.js +0 -1
- package/dist/tools/options/greeks.js.map +1 -1
- package/dist/tools/options/option-chain.d.ts +1 -1
- package/dist/tools/options/option-chain.js +13 -5
- package/dist/tools/options/option-chain.js.map +1 -1
- package/dist/tools/portfolio/correlation.d.ts +1 -1
- package/dist/tools/portfolio/correlation.js +1 -1
- package/dist/tools/portfolio/correlation.js.map +1 -1
- package/dist/tools/portfolio/holdings-overlap.d.ts +8 -0
- package/dist/tools/portfolio/holdings-overlap.js +105 -0
- package/dist/tools/portfolio/holdings-overlap.js.map +1 -0
- package/dist/tools/portfolio/predictions.d.ts +1 -1
- package/dist/tools/portfolio/predictions.js +1 -1
- package/dist/tools/portfolio/predictions.js.map +1 -1
- package/dist/tools/portfolio/risk-analysis.d.ts +1 -1
- package/dist/tools/portfolio/risk-analysis.js +1 -1
- package/dist/tools/portfolio/risk-analysis.js.map +1 -1
- package/dist/tools/portfolio/tracker.d.ts +1 -1
- package/dist/tools/portfolio/tracker.js +1 -1
- package/dist/tools/portfolio/tracker.js.map +1 -1
- package/dist/tools/portfolio/watchlist.d.ts +1 -1
- package/dist/tools/portfolio/watchlist.js +12 -4
- package/dist/tools/portfolio/watchlist.js.map +1 -1
- package/dist/tools/sentiment/reddit-sentiment.d.ts +1 -1
- package/dist/tools/sentiment/reddit-sentiment.js +1 -1
- package/dist/tools/sentiment/reddit-sentiment.js.map +1 -1
- package/dist/tools/sentiment/sentiment-summary.d.ts +1 -1
- package/dist/tools/sentiment/sentiment-summary.js +57 -2
- package/dist/tools/sentiment/sentiment-summary.js.map +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/twitter-sentiment.js +1 -1
- package/dist/tools/sentiment/twitter-sentiment.js.map +1 -1
- package/dist/tools/sentiment/web-search.d.ts +1 -1
- package/dist/tools/sentiment/web-search.js +32 -3
- package/dist/tools/sentiment/web-search.js.map +1 -1
- package/dist/tools/sentiment/web-sentiment.d.ts +1 -1
- package/dist/tools/sentiment/web-sentiment.js +1 -1
- package/dist/tools/sentiment/web-sentiment.js.map +1 -1
- package/dist/tools/technical/backtest.d.ts +3 -3
- package/dist/tools/technical/backtest.js +41 -27
- package/dist/tools/technical/backtest.js.map +1 -1
- package/dist/tools/technical/indicators.d.ts +1 -1
- package/dist/tools/technical/indicators.js +8 -4
- package/dist/tools/technical/indicators.js.map +1 -1
- package/dist/types/options.d.ts +10 -0
- package/dist/types/portfolio.d.ts +27 -0
- package/dist/workflows/compare-assets.js +38 -2
- package/dist/workflows/compare-assets.js.map +1 -1
- package/dist/workflows/options-screener.js +94 -8
- package/dist/workflows/options-screener.js.map +1 -1
- package/dist/workflows/portfolio-builder.js +9 -5
- package/dist/workflows/portfolio-builder.js.map +1 -1
- package/gui/server/ask-user-bridge.ts +82 -0
- package/gui/server/background-quotes.ts +31 -0
- package/gui/server/chat-event-adapter.ts +142 -0
- package/gui/server/gui-session-manager.ts +5 -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 +254 -0
- package/gui/server/prompt-observation.ts +61 -0
- package/gui/server/server.ts +703 -0
- package/gui/server/session-actions.ts +31 -0
- package/gui/server/session-entry-wait.ts +81 -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-Bmp6Knu7.js +1 -0
- package/gui/web/dist/assets/index-Bxt9QpLX.css +1 -0
- package/gui/web/dist/assets/index-CZ9DHZYy.js +67 -0
- package/gui/web/dist/assets/logo-CWpt6Y2a.svg +187 -0
- package/gui/web/dist/index.html +17 -0
- package/package.json +50 -18
- package/src/analysts/contracts.ts +189 -0
- package/src/analysts/orchestrator.ts +300 -0
- package/src/cli.ts +206 -0
- package/src/config.ts +245 -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 +73 -0
- package/src/memory/index.ts +10 -0
- package/src/memory/manager.ts +192 -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 +205 -0
- package/src/memory/tool-defaults.ts +87 -0
- package/src/memory/types.ts +71 -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 +955 -0
- package/src/pi/session-storage.ts +5 -0
- package/src/pi/session.ts +81 -0
- package/src/pi/setup.ts +381 -0
- package/src/pi/tool-adapter.ts +36 -0
- package/src/prompts/context-builder.ts +315 -0
- package/src/prompts/disclaimer.ts +9 -0
- package/src/prompts/policy-cards.ts +220 -0
- package/src/prompts/sections.ts +46 -0
- package/src/prompts/workflow-prompts.ts +433 -0
- package/src/providers/alpha-vantage.ts +315 -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 +312 -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 +534 -0
- package/src/routing/classify-intent.ts +285 -0
- package/src/routing/defaults.ts +29 -0
- package/src/routing/entity-extractor.ts +291 -0
- package/src/routing/index.ts +70 -0
- package/src/routing/legacy-rule-router.ts +13 -0
- package/src/routing/planning.ts +732 -0
- package/src/routing/route-manifest.ts +287 -0
- package/src/routing/router-llm-client.ts +51 -0
- package/src/routing/router-prompt.ts +163 -0
- package/src/routing/router-types.ts +87 -0
- package/src/routing/router.ts +712 -0
- package/src/routing/slot-resolver.ts +190 -0
- package/src/routing/turn-context.ts +111 -0
- package/src/routing/types.ts +75 -0
- package/src/runtime/answer-contracts.ts +633 -0
- package/src/runtime/artifact-contracts.ts +76 -0
- package/src/runtime/evidence.ts +77 -0
- package/src/runtime/planning-evidence.ts +591 -0
- package/src/runtime/prompt-step.ts +75 -0
- package/src/runtime/provider-tracker.ts +40 -0
- package/src/runtime/run-context.ts +22 -0
- package/src/runtime/session-coordinator.ts +472 -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 +118 -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 +84 -0
- package/src/tools/index.ts +91 -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 +80 -0
- package/src/tools/market/crypto-history.ts +67 -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 +81 -0
- package/src/tools/options/option-chain.ts +96 -0
- package/src/tools/portfolio/correlation.ts +162 -0
- package/src/tools/portfolio/holdings-overlap.ts +123 -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 +159 -0
- package/src/tools/sentiment/reddit-sentiment.ts +164 -0
- package/src/tools/sentiment/sentiment-summary.ts +316 -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 +183 -0
- package/src/tools/sentiment/web-sentiment.ts +76 -0
- package/src/tools/technical/backtest.ts +267 -0
- package/src/tools/technical/indicators.ts +256 -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 +52 -0
- package/src/types/portfolio.ts +73 -0
- package/src/types/sentiment.ts +70 -0
- package/src/workflows/compare-assets.ts +75 -0
- package/src/workflows/index.ts +4 -0
- package/src/workflows/options-screener.ts +127 -0
- package/src/workflows/portfolio-builder.ts +56 -0
- package/src/workflows/types.ts +4 -0
- package/dist/runtime/index.d.ts +0 -16
- package/dist/runtime/index.js +0 -10
- package/dist/runtime/index.js.map +0 -1
- package/dist/runtime/provider-ids.d.ts +0 -14
- package/dist/runtime/provider-ids.js +0 -14
- package/dist/runtime/provider-ids.js.map +0 -1
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type { SentinelRecord, SentimentAdapter } from "../types.js";
|
|
3
|
+
import type { TwitterSentimentResult } from "../../types/sentiment.js";
|
|
4
|
+
|
|
5
|
+
export class TwitterAdapter implements SentimentAdapter {
|
|
6
|
+
readonly source = "twitter" as const;
|
|
7
|
+
|
|
8
|
+
mapToRecords(result: TwitterSentimentResult, query: string): SentinelRecord[] {
|
|
9
|
+
const fetchedAt = result.fetchedAt;
|
|
10
|
+
return result.tweets.map((tweet) => ({
|
|
11
|
+
id: randomUUID(),
|
|
12
|
+
source: this.source,
|
|
13
|
+
sourceId: tweet.id,
|
|
14
|
+
query,
|
|
15
|
+
title: null,
|
|
16
|
+
text: tweet.text,
|
|
17
|
+
author: tweet.author,
|
|
18
|
+
url: tweet.url,
|
|
19
|
+
publishedAt: tweet.created,
|
|
20
|
+
fetchedAt,
|
|
21
|
+
engagement: {
|
|
22
|
+
score: tweet.likes,
|
|
23
|
+
replies: tweet.replies,
|
|
24
|
+
shares: tweet.retweets,
|
|
25
|
+
views: tweet.views,
|
|
26
|
+
},
|
|
27
|
+
sentiment: { score: 0, confidence: 0, method: "keyword" as const, tickers: [] },
|
|
28
|
+
metadata: {},
|
|
29
|
+
}));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async fetch(_query: string, _options?: { hours?: number }): Promise<SentinelRecord[]> {
|
|
33
|
+
// Actual fetching is done by the pipeline via the provider
|
|
34
|
+
throw new Error("Use pipeline.run() instead of adapter.fetch() directly");
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type { SentinelRecord, SentimentAdapter } from "../types.js";
|
|
3
|
+
import type { WebSearchEnvelope } from "../../types/sentiment.js";
|
|
4
|
+
|
|
5
|
+
export class WebAdapter implements SentimentAdapter {
|
|
6
|
+
readonly source = "web" as const;
|
|
7
|
+
|
|
8
|
+
mapToRecords(envelope: WebSearchEnvelope, query: string): SentinelRecord[] {
|
|
9
|
+
const fetchedAt = envelope.fetchedAt;
|
|
10
|
+
return envelope.results.map((result) => ({
|
|
11
|
+
id: randomUUID(),
|
|
12
|
+
source: this.source,
|
|
13
|
+
sourceId: canonicalizeUrl(result.url),
|
|
14
|
+
query,
|
|
15
|
+
title: result.title,
|
|
16
|
+
text: result.snippet,
|
|
17
|
+
author: result.source,
|
|
18
|
+
url: result.url,
|
|
19
|
+
publishedAt: result.published,
|
|
20
|
+
fetchedAt,
|
|
21
|
+
engagement: {
|
|
22
|
+
score: 0,
|
|
23
|
+
replies: null,
|
|
24
|
+
shares: null,
|
|
25
|
+
views: null,
|
|
26
|
+
},
|
|
27
|
+
sentiment: { score: 0, confidence: 0, method: "keyword" as const, tickers: [] },
|
|
28
|
+
metadata: { category: result.category, provider: envelope.provider },
|
|
29
|
+
}));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async fetch(_query: string, _options?: { hours?: number }): Promise<SentinelRecord[]> {
|
|
33
|
+
throw new Error("Use pipeline.run() instead of adapter.fetch() directly");
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function canonicalizeUrl(url: string): string {
|
|
38
|
+
try {
|
|
39
|
+
const parsed = new URL(url);
|
|
40
|
+
return parsed.origin + parsed.pathname;
|
|
41
|
+
} catch {
|
|
42
|
+
return url;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
SentinelRecord,
|
|
3
|
+
SentinelEngagement,
|
|
4
|
+
SentinelSentiment,
|
|
5
|
+
SentimentAdapter,
|
|
6
|
+
ScorerOptions,
|
|
7
|
+
TrendBucket,
|
|
8
|
+
TrendResult,
|
|
9
|
+
DivergenceResult,
|
|
10
|
+
SentimentSummary,
|
|
11
|
+
SentimentSource,
|
|
12
|
+
} from "./types.js";
|
|
13
|
+
|
|
14
|
+
export { isSentinelRecord, SENTIMENT_SOURCES } from "./types.js";
|
|
15
|
+
export { SentimentStore } from "./store.js";
|
|
16
|
+
export { scoreRecords, keywordScore } from "./scorer.js";
|
|
17
|
+
export { SentimentPipeline } from "./pipeline.js";
|
|
18
|
+
export { renderSparkline, computeTrend, computeDivergence } from "./trends.js";
|
|
19
|
+
export { BULLISH_TERMS, BEARISH_TERMS } from "./keywords.js";
|
|
20
|
+
export { TwitterAdapter } from "./adapters/twitter.js";
|
|
21
|
+
export { RedditAdapter } from "./adapters/reddit.js";
|
|
22
|
+
export { WebAdapter } from "./adapters/web.js";
|
|
23
|
+
|
|
24
|
+
import { SentimentStore } from "./store.js";
|
|
25
|
+
import { SentimentPipeline } from "./pipeline.js";
|
|
26
|
+
import { getConfig } from "../config.js";
|
|
27
|
+
import { resolveOpenCandlePath } from "../infra/opencandle-paths.js";
|
|
28
|
+
|
|
29
|
+
let _pipeline: SentimentPipeline | null = null;
|
|
30
|
+
let _store: SentimentStore | null = null;
|
|
31
|
+
|
|
32
|
+
export function getSentimentStore(): SentimentStore {
|
|
33
|
+
if (!_store) {
|
|
34
|
+
const dbPath = resolveOpenCandlePath("sentinel.db");
|
|
35
|
+
_store = new SentimentStore(dbPath);
|
|
36
|
+
const config = getConfig();
|
|
37
|
+
_store.prune(config.sentiment?.retentionDays ?? 30);
|
|
38
|
+
}
|
|
39
|
+
return _store;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function getSentimentPipeline(): SentimentPipeline {
|
|
43
|
+
if (!_pipeline) {
|
|
44
|
+
const store = getSentimentStore();
|
|
45
|
+
const config = getConfig();
|
|
46
|
+
_pipeline = new SentimentPipeline(store, config.sentiment!);
|
|
47
|
+
}
|
|
48
|
+
return _pipeline;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** For testing: reset singletons */
|
|
52
|
+
export function _resetSentimentSingletons(): void {
|
|
53
|
+
if (_store) {
|
|
54
|
+
try { _store.close(); } catch { /* ignore */ }
|
|
55
|
+
}
|
|
56
|
+
_pipeline = null;
|
|
57
|
+
_store = null;
|
|
58
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export const BULLISH_TERMS = [
|
|
2
|
+
"moon", "buy", "undervalued", "breakout", "calls", "bullish",
|
|
3
|
+
"rocket", "diamond hands", "accumulate", "dip buy", "long", "rip", "squeeze",
|
|
4
|
+
] as const;
|
|
5
|
+
|
|
6
|
+
export const BEARISH_TERMS = [
|
|
7
|
+
"crash", "overvalued", "sell", "puts", "bearish", "bubble",
|
|
8
|
+
"dump", "short", "bagholding", "exit", "drill", "tank", "rug",
|
|
9
|
+
] as const;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { SentinelRecord, SentimentSummary, TrendResult, DivergenceResult, SentimentSource } from "./types.js";
|
|
2
|
+
import type { SentimentConfig } from "../config.js";
|
|
3
|
+
import { scoreRecords } from "./scorer.js";
|
|
4
|
+
import { SentimentStore } from "./store.js";
|
|
5
|
+
import { computeTrend, computeDivergence, type SourceStats } from "./trends.js";
|
|
6
|
+
|
|
7
|
+
export class SentimentPipeline {
|
|
8
|
+
constructor(
|
|
9
|
+
private store: SentimentStore,
|
|
10
|
+
private config: SentimentConfig,
|
|
11
|
+
) {}
|
|
12
|
+
|
|
13
|
+
async processRecords(records: SentinelRecord[], query: string): Promise<SentimentSummary> {
|
|
14
|
+
const warnings: string[] = [];
|
|
15
|
+
|
|
16
|
+
if (records.length === 0) {
|
|
17
|
+
return { fresh: [], trend: null, divergence: null, warnings };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Check if store had prior data before this fetch
|
|
21
|
+
const priorSeries = this.store.getTimeSeries(query, { days: 30, bucketHours: 24 });
|
|
22
|
+
const hadPriorData = priorSeries.length >= 2;
|
|
23
|
+
|
|
24
|
+
// Score all records
|
|
25
|
+
const scored = scoreRecords(records);
|
|
26
|
+
|
|
27
|
+
// Insert into store
|
|
28
|
+
this.store.insert(scored);
|
|
29
|
+
|
|
30
|
+
// Compute trend from historical data (only if we had prior data)
|
|
31
|
+
let trend: TrendResult[] | null = null;
|
|
32
|
+
if (hadPriorData) {
|
|
33
|
+
const series = this.store.getTimeSeries(query, { days: 7, bucketHours: 24 });
|
|
34
|
+
if (series.length >= 2) {
|
|
35
|
+
trend = [computeTrend(series, "aggregate")];
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Compute divergence from fresh records
|
|
40
|
+
let divergence: DivergenceResult | null = null;
|
|
41
|
+
const sourceGroups = groupBySource(scored);
|
|
42
|
+
const sourceStats: { twitter?: SourceStats; reddit?: SourceStats; web?: SourceStats; finnhub?: SourceStats } = {};
|
|
43
|
+
|
|
44
|
+
for (const [source, recs] of Object.entries(sourceGroups)) {
|
|
45
|
+
// Exclude comments from divergence calculation
|
|
46
|
+
const postLevel = recs.filter((r) => !r.metadata.isComment);
|
|
47
|
+
if (postLevel.length > 0) {
|
|
48
|
+
const avg = postLevel.reduce((sum, r) => sum + r.sentiment.score, 0) / postLevel.length;
|
|
49
|
+
sourceStats[source as SentimentSource] = { avg, count: postLevel.length };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (Object.keys(sourceStats).length >= 2) {
|
|
54
|
+
divergence = computeDivergence(sourceStats, this.config.divergenceThreshold);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return { fresh: scored, trend, divergence, warnings };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function groupBySource(records: SentinelRecord[]): Record<string, SentinelRecord[]> {
|
|
62
|
+
const groups: Record<string, SentinelRecord[]> = {};
|
|
63
|
+
for (const r of records) {
|
|
64
|
+
if (!groups[r.source]) groups[r.source] = [];
|
|
65
|
+
groups[r.source].push(r);
|
|
66
|
+
}
|
|
67
|
+
return groups;
|
|
68
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { BULLISH_TERMS, BEARISH_TERMS } from "./keywords.js";
|
|
2
|
+
import type { SentinelRecord } from "./types.js";
|
|
3
|
+
|
|
4
|
+
const TICKER_REGEX = /\$([A-Z]{1,5})\b/g;
|
|
5
|
+
|
|
6
|
+
interface ScoreResult {
|
|
7
|
+
score: number;
|
|
8
|
+
confidence: number;
|
|
9
|
+
tickers: string[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function keywordScore(record: SentinelRecord): ScoreResult {
|
|
13
|
+
const lower = record.text.toLowerCase();
|
|
14
|
+
const engagement = 1 + (record.engagement.score ?? 0);
|
|
15
|
+
|
|
16
|
+
let bullishWeight = 0;
|
|
17
|
+
let bearishWeight = 0;
|
|
18
|
+
let bullishCount = 0;
|
|
19
|
+
let bearishCount = 0;
|
|
20
|
+
|
|
21
|
+
for (const term of BULLISH_TERMS) {
|
|
22
|
+
if (lower.includes(term)) {
|
|
23
|
+
bullishCount++;
|
|
24
|
+
bullishWeight += engagement;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
for (const term of BEARISH_TERMS) {
|
|
29
|
+
if (lower.includes(term)) {
|
|
30
|
+
bearishCount++;
|
|
31
|
+
bearishWeight += engagement;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const totalWeight = bullishWeight + bearishWeight;
|
|
36
|
+
const score = totalWeight === 0 ? 0 : (bullishWeight - bearishWeight) / totalWeight;
|
|
37
|
+
|
|
38
|
+
const totalMatches = bullishCount + bearishCount;
|
|
39
|
+
let confidence = 0;
|
|
40
|
+
if (totalMatches > 0) {
|
|
41
|
+
// Base confidence from keyword matches
|
|
42
|
+
confidence = Math.min(totalMatches / 5, 1) * 0.6;
|
|
43
|
+
// Boost for longer text
|
|
44
|
+
const textLength = record.text.length;
|
|
45
|
+
confidence += Math.min(textLength / 500, 1) * 0.3;
|
|
46
|
+
// Multiple matches boost
|
|
47
|
+
if (totalMatches >= 3) confidence += 0.1;
|
|
48
|
+
// Twitter penalty
|
|
49
|
+
if (record.source === "twitter") confidence -= 0.1;
|
|
50
|
+
// Clamp
|
|
51
|
+
confidence = Math.max(0, Math.min(1, confidence));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Extract tickers
|
|
55
|
+
const tickers: string[] = [];
|
|
56
|
+
for (const match of record.text.matchAll(TICKER_REGEX)) {
|
|
57
|
+
if (!tickers.includes(match[1])) {
|
|
58
|
+
tickers.push(match[1]);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return { score, confidence, tickers };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function scoreRecords(records: SentinelRecord[]): SentinelRecord[] {
|
|
66
|
+
return records.map((record) => {
|
|
67
|
+
const result = keywordScore(record);
|
|
68
|
+
return {
|
|
69
|
+
...record,
|
|
70
|
+
sentiment: {
|
|
71
|
+
score: result.score,
|
|
72
|
+
confidence: result.confidence,
|
|
73
|
+
method: "keyword" as const,
|
|
74
|
+
tickers: result.tickers,
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
});
|
|
78
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
import { mkdirSync } from "node:fs";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import type { SentinelRecord, SentimentSource, TrendBucket } from "./types.js";
|
|
5
|
+
|
|
6
|
+
const SCHEMA_VERSION = 1;
|
|
7
|
+
|
|
8
|
+
const CREATE_SCHEMA = `
|
|
9
|
+
CREATE TABLE IF NOT EXISTS schema_version (
|
|
10
|
+
version INTEGER NOT NULL
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
CREATE TABLE IF NOT EXISTS sentinel_records (
|
|
14
|
+
id TEXT NOT NULL,
|
|
15
|
+
source TEXT NOT NULL,
|
|
16
|
+
source_id TEXT NOT NULL,
|
|
17
|
+
query TEXT NOT NULL,
|
|
18
|
+
title TEXT,
|
|
19
|
+
text TEXT NOT NULL,
|
|
20
|
+
author TEXT,
|
|
21
|
+
url TEXT NOT NULL,
|
|
22
|
+
published_at TEXT,
|
|
23
|
+
fetched_at TEXT NOT NULL,
|
|
24
|
+
engagement_json TEXT NOT NULL,
|
|
25
|
+
sentiment_score REAL NOT NULL,
|
|
26
|
+
sentiment_confidence REAL NOT NULL,
|
|
27
|
+
sentiment_method TEXT NOT NULL,
|
|
28
|
+
tickers_json TEXT NOT NULL,
|
|
29
|
+
metadata_json TEXT NOT NULL,
|
|
30
|
+
UNIQUE(source, source_id, fetched_at)
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
CREATE INDEX IF NOT EXISTS idx_sentinel_source ON sentinel_records(source);
|
|
34
|
+
CREATE INDEX IF NOT EXISTS idx_sentinel_fetched_at ON sentinel_records(fetched_at);
|
|
35
|
+
CREATE INDEX IF NOT EXISTS idx_sentinel_query ON sentinel_records(query);
|
|
36
|
+
|
|
37
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS sentinel_fts USING fts5(
|
|
38
|
+
text, title, author, query, source,
|
|
39
|
+
content=sentinel_records,
|
|
40
|
+
content_rowid=rowid
|
|
41
|
+
);
|
|
42
|
+
`;
|
|
43
|
+
|
|
44
|
+
const TRIGGERS = `
|
|
45
|
+
CREATE TRIGGER IF NOT EXISTS sentinel_ai AFTER INSERT ON sentinel_records BEGIN
|
|
46
|
+
INSERT INTO sentinel_fts(rowid, text, title, author, query, source)
|
|
47
|
+
VALUES (new.rowid, new.text, new.title, new.author, new.query, new.source);
|
|
48
|
+
END;
|
|
49
|
+
|
|
50
|
+
CREATE TRIGGER IF NOT EXISTS sentinel_ad AFTER DELETE ON sentinel_records BEGIN
|
|
51
|
+
INSERT INTO sentinel_fts(sentinel_fts, rowid, text, title, author, query, source)
|
|
52
|
+
VALUES ('delete', old.rowid, old.text, old.title, old.author, old.query, old.source);
|
|
53
|
+
END;
|
|
54
|
+
`;
|
|
55
|
+
|
|
56
|
+
export interface SearchOptions {
|
|
57
|
+
source?: SentimentSource;
|
|
58
|
+
since?: string;
|
|
59
|
+
until?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface TimeSeriesOptions {
|
|
63
|
+
days: number;
|
|
64
|
+
bucketHours: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export class SentimentStore {
|
|
68
|
+
private db: Database.Database;
|
|
69
|
+
|
|
70
|
+
constructor(pathOrMemory: string) {
|
|
71
|
+
if (pathOrMemory !== ":memory:") {
|
|
72
|
+
mkdirSync(dirname(pathOrMemory), { recursive: true });
|
|
73
|
+
}
|
|
74
|
+
this.db = new Database(pathOrMemory);
|
|
75
|
+
if (pathOrMemory !== ":memory:") {
|
|
76
|
+
this.db.pragma("journal_mode = WAL");
|
|
77
|
+
}
|
|
78
|
+
this.initSchema();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private initSchema(): void {
|
|
82
|
+
this.db.exec(CREATE_SCHEMA);
|
|
83
|
+
this.db.exec(TRIGGERS);
|
|
84
|
+
|
|
85
|
+
const row = this.db.prepare("SELECT version FROM schema_version").get() as
|
|
86
|
+
| { version: number }
|
|
87
|
+
| undefined;
|
|
88
|
+
if (!row) {
|
|
89
|
+
this.db.prepare("INSERT INTO schema_version (version) VALUES (?)").run(SCHEMA_VERSION);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
getSchemaVersion(): number {
|
|
94
|
+
const row = this.db.prepare("SELECT version FROM schema_version").get() as { version: number };
|
|
95
|
+
return row.version;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
insert(records: SentinelRecord[]): void {
|
|
99
|
+
const stmt = this.db.prepare(`
|
|
100
|
+
INSERT OR IGNORE INTO sentinel_records
|
|
101
|
+
(id, source, source_id, query, title, text, author, url,
|
|
102
|
+
published_at, fetched_at, engagement_json,
|
|
103
|
+
sentiment_score, sentiment_confidence, sentiment_method,
|
|
104
|
+
tickers_json, metadata_json)
|
|
105
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
106
|
+
`);
|
|
107
|
+
|
|
108
|
+
const tx = this.db.transaction((recs: SentinelRecord[]) => {
|
|
109
|
+
for (const r of recs) {
|
|
110
|
+
stmt.run(
|
|
111
|
+
r.id,
|
|
112
|
+
r.source,
|
|
113
|
+
r.sourceId,
|
|
114
|
+
r.query,
|
|
115
|
+
r.title,
|
|
116
|
+
r.text,
|
|
117
|
+
r.author,
|
|
118
|
+
r.url,
|
|
119
|
+
r.publishedAt,
|
|
120
|
+
r.fetchedAt,
|
|
121
|
+
JSON.stringify(r.engagement),
|
|
122
|
+
r.sentiment.score,
|
|
123
|
+
r.sentiment.confidence,
|
|
124
|
+
r.sentiment.method,
|
|
125
|
+
JSON.stringify(r.sentiment.tickers),
|
|
126
|
+
JSON.stringify(r.metadata),
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
tx(records);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
search(query: string, options?: SearchOptions): SentinelRecord[] {
|
|
135
|
+
let sql = `
|
|
136
|
+
SELECT sr.* FROM sentinel_records sr
|
|
137
|
+
JOIN sentinel_fts fts ON sr.rowid = fts.rowid
|
|
138
|
+
WHERE sentinel_fts MATCH ?
|
|
139
|
+
`;
|
|
140
|
+
const params: unknown[] = [query];
|
|
141
|
+
|
|
142
|
+
if (options?.source) {
|
|
143
|
+
sql += " AND sr.source = ?";
|
|
144
|
+
params.push(options.source);
|
|
145
|
+
}
|
|
146
|
+
if (options?.since) {
|
|
147
|
+
sql += " AND sr.fetched_at >= ?";
|
|
148
|
+
params.push(options.since);
|
|
149
|
+
}
|
|
150
|
+
if (options?.until) {
|
|
151
|
+
sql += " AND sr.fetched_at <= ?";
|
|
152
|
+
params.push(options.until);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
sql += " ORDER BY bm25(sentinel_fts) LIMIT 100";
|
|
156
|
+
|
|
157
|
+
const rows = this.db.prepare(sql).all(...params) as RawRow[];
|
|
158
|
+
return rows.map(rowToRecord);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
getByTicker(ticker: string, options?: { since?: string }): SentinelRecord[] {
|
|
162
|
+
let sql = `SELECT * FROM sentinel_records WHERE tickers_json LIKE ?`;
|
|
163
|
+
const pattern = `%"${ticker}"%`;
|
|
164
|
+
const params: unknown[] = [pattern];
|
|
165
|
+
|
|
166
|
+
if (options?.since) {
|
|
167
|
+
sql += " AND fetched_at >= ?";
|
|
168
|
+
params.push(options.since);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
sql += " ORDER BY fetched_at DESC LIMIT 100";
|
|
172
|
+
const rows = this.db.prepare(sql).all(...params) as RawRow[];
|
|
173
|
+
return rows.map(rowToRecord);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
getTimeSeries(query: string, options: TimeSeriesOptions): TrendBucket[] {
|
|
177
|
+
const since = new Date();
|
|
178
|
+
since.setDate(since.getDate() - options.days);
|
|
179
|
+
const sinceStr = since.toISOString();
|
|
180
|
+
const bucketSeconds = options.bucketHours * 3600;
|
|
181
|
+
|
|
182
|
+
const sql = `
|
|
183
|
+
SELECT
|
|
184
|
+
(CAST(strftime('%s', fetched_at) AS INTEGER) / ?) * ? AS bucket_ts,
|
|
185
|
+
AVG(sentiment_score) AS avg_score,
|
|
186
|
+
COUNT(*) AS cnt
|
|
187
|
+
FROM sentinel_records
|
|
188
|
+
WHERE query = ? AND fetched_at >= ?
|
|
189
|
+
GROUP BY bucket_ts
|
|
190
|
+
ORDER BY bucket_ts
|
|
191
|
+
`;
|
|
192
|
+
|
|
193
|
+
const rows = this.db.prepare(sql).all(bucketSeconds, bucketSeconds, query, sinceStr) as Array<{
|
|
194
|
+
bucket_ts: number;
|
|
195
|
+
avg_score: number;
|
|
196
|
+
cnt: number;
|
|
197
|
+
}>;
|
|
198
|
+
|
|
199
|
+
return rows.map((r) => ({
|
|
200
|
+
timestamp: new Date(r.bucket_ts * 1000).toISOString(),
|
|
201
|
+
avgScore: r.avg_score,
|
|
202
|
+
count: r.cnt,
|
|
203
|
+
}));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
prune(retentionDays: number): number {
|
|
207
|
+
const cutoff = new Date();
|
|
208
|
+
cutoff.setDate(cutoff.getDate() - retentionDays);
|
|
209
|
+
const result = this.db
|
|
210
|
+
.prepare("DELETE FROM sentinel_records WHERE fetched_at < ?")
|
|
211
|
+
.run(cutoff.toISOString());
|
|
212
|
+
return result.changes;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
close(): void {
|
|
216
|
+
this.db.close();
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
interface RawRow {
|
|
221
|
+
id: string;
|
|
222
|
+
source: string;
|
|
223
|
+
source_id: string;
|
|
224
|
+
query: string;
|
|
225
|
+
title: string | null;
|
|
226
|
+
text: string;
|
|
227
|
+
author: string | null;
|
|
228
|
+
url: string;
|
|
229
|
+
published_at: string | null;
|
|
230
|
+
fetched_at: string;
|
|
231
|
+
engagement_json: string;
|
|
232
|
+
sentiment_score: number;
|
|
233
|
+
sentiment_confidence: number;
|
|
234
|
+
sentiment_method: string;
|
|
235
|
+
tickers_json: string;
|
|
236
|
+
metadata_json: string;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function rowToRecord(row: RawRow): SentinelRecord {
|
|
240
|
+
return {
|
|
241
|
+
id: row.id,
|
|
242
|
+
source: row.source as SentinelRecord["source"],
|
|
243
|
+
sourceId: row.source_id,
|
|
244
|
+
query: row.query,
|
|
245
|
+
title: row.title,
|
|
246
|
+
text: row.text,
|
|
247
|
+
author: row.author,
|
|
248
|
+
url: row.url,
|
|
249
|
+
publishedAt: row.published_at,
|
|
250
|
+
fetchedAt: row.fetched_at,
|
|
251
|
+
engagement: JSON.parse(row.engagement_json),
|
|
252
|
+
sentiment: {
|
|
253
|
+
score: row.sentiment_score,
|
|
254
|
+
confidence: row.sentiment_confidence,
|
|
255
|
+
method: row.sentiment_method as "keyword",
|
|
256
|
+
tickers: JSON.parse(row.tickers_json),
|
|
257
|
+
},
|
|
258
|
+
metadata: JSON.parse(row.metadata_json),
|
|
259
|
+
};
|
|
260
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { TrendBucket, TrendResult, DivergenceResult, SentimentSource } from "./types.js";
|
|
2
|
+
|
|
3
|
+
const SPARKLINE_CHARS = "▁▂▃▄▅▆▇█";
|
|
4
|
+
|
|
5
|
+
export function renderSparkline(values: number[]): string {
|
|
6
|
+
if (values.length === 0) return "";
|
|
7
|
+
|
|
8
|
+
const min = Math.min(...values);
|
|
9
|
+
const max = Math.max(...values);
|
|
10
|
+
const range = max - min;
|
|
11
|
+
|
|
12
|
+
return values
|
|
13
|
+
.map((v) => {
|
|
14
|
+
if (range === 0) return SPARKLINE_CHARS[3]; // middle block for flat
|
|
15
|
+
const idx = Math.round(((v - min) / range) * 7);
|
|
16
|
+
return SPARKLINE_CHARS[idx];
|
|
17
|
+
})
|
|
18
|
+
.join("");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function computeTrend(
|
|
22
|
+
buckets: TrendBucket[],
|
|
23
|
+
source: SentimentSource | "aggregate",
|
|
24
|
+
): TrendResult {
|
|
25
|
+
const scores = buckets.map((b) => b.avgScore);
|
|
26
|
+
const totalCount = buckets.reduce((sum, b) => sum + b.count, 0);
|
|
27
|
+
const avgScore = totalCount === 0 ? 0 : buckets.reduce((sum, b) => sum + b.avgScore * b.count, 0) / totalCount;
|
|
28
|
+
|
|
29
|
+
const sparkline = renderSparkline(scores);
|
|
30
|
+
|
|
31
|
+
// Delta: last value minus first value
|
|
32
|
+
const delta = scores.length >= 2 ? scores[scores.length - 1] - scores[0] : 0;
|
|
33
|
+
|
|
34
|
+
let direction: "rising" | "falling" | "stable";
|
|
35
|
+
if (delta > 0.1) direction = "rising";
|
|
36
|
+
else if (delta < -0.1) direction = "falling";
|
|
37
|
+
else direction = "stable";
|
|
38
|
+
|
|
39
|
+
return { source, sparkline, avgScore, count: totalCount, direction, delta };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface SourceStats {
|
|
43
|
+
avg: number;
|
|
44
|
+
count: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function computeDivergence(
|
|
48
|
+
sources: { twitter?: SourceStats; reddit?: SourceStats; web?: SourceStats; finnhub?: SourceStats },
|
|
49
|
+
threshold: number,
|
|
50
|
+
): DivergenceResult {
|
|
51
|
+
const retailSources: SourceStats[] = [];
|
|
52
|
+
if (sources.twitter && sources.twitter.count >= 5) retailSources.push(sources.twitter);
|
|
53
|
+
if (sources.reddit && sources.reddit.count >= 5) retailSources.push(sources.reddit);
|
|
54
|
+
|
|
55
|
+
const newsSources: SourceStats[] = [];
|
|
56
|
+
if (sources.web && sources.web.count >= 5) newsSources.push(sources.web);
|
|
57
|
+
if (sources.finnhub && sources.finnhub.count >= 5) newsSources.push(sources.finnhub);
|
|
58
|
+
|
|
59
|
+
if (retailSources.length === 0 || newsSources.length === 0) {
|
|
60
|
+
return {
|
|
61
|
+
detected: false,
|
|
62
|
+
retailAvg: null,
|
|
63
|
+
newsAvg: null,
|
|
64
|
+
gap: null,
|
|
65
|
+
message: "Insufficient data for divergence analysis",
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const retailAvg = retailSources.reduce((sum, s) => sum + s.avg, 0) / retailSources.length;
|
|
70
|
+
const newsAvg = newsSources.reduce((sum, s) => sum + s.avg, 0) / newsSources.length;
|
|
71
|
+
const gap = Math.abs(retailAvg - newsAvg);
|
|
72
|
+
|
|
73
|
+
if (gap > threshold) {
|
|
74
|
+
return {
|
|
75
|
+
detected: true,
|
|
76
|
+
retailAvg,
|
|
77
|
+
newsAvg,
|
|
78
|
+
gap,
|
|
79
|
+
message: `⚠ DIVERGENCE: Retail sentiment (${retailAvg >= 0 ? "+" : ""}${retailAvg.toFixed(2)}) vs news sentiment (${newsAvg >= 0 ? "+" : ""}${newsAvg.toFixed(2)}) — gap of ${gap.toFixed(2)}.`,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
detected: false,
|
|
85
|
+
retailAvg,
|
|
86
|
+
newsAvg,
|
|
87
|
+
gap,
|
|
88
|
+
message: "Sources broadly aligned",
|
|
89
|
+
};
|
|
90
|
+
}
|