opencandle 0.6.0 → 0.7.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/README.md +10 -3
- package/dist/cli.js +36 -0
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +10 -0
- package/dist/config.js +13 -0
- package/dist/config.js.map +1 -1
- package/dist/infra/index.d.ts +0 -1
- package/dist/infra/index.js +0 -1
- package/dist/infra/index.js.map +1 -1
- package/dist/onboarding/connect.d.ts +2 -2
- package/dist/onboarding/connect.js +10 -3
- package/dist/onboarding/connect.js.map +1 -1
- package/dist/onboarding/provider-status.d.ts +48 -0
- package/dist/onboarding/provider-status.js +285 -0
- package/dist/onboarding/provider-status.js.map +1 -0
- package/dist/onboarding/providers.d.ts +85 -8
- package/dist/onboarding/providers.js +87 -9
- package/dist/onboarding/providers.js.map +1 -1
- package/dist/onboarding/state.d.ts +1 -0
- package/dist/onboarding/state.js +5 -0
- package/dist/onboarding/state.js.map +1 -1
- package/dist/onboarding/tool-tags.d.ts +12 -1
- package/dist/onboarding/tool-tags.js +31 -1
- package/dist/onboarding/tool-tags.js.map +1 -1
- package/dist/onboarding/validation.d.ts +2 -2
- package/dist/onboarding/validation.js.map +1 -1
- package/dist/pi/opencandle-extension.js +91 -15
- package/dist/pi/opencandle-extension.js.map +1 -1
- package/dist/pi/tool-adapter.d.ts +4 -1
- package/dist/pi/tool-adapter.js +5 -4
- package/dist/pi/tool-adapter.js.map +1 -1
- package/dist/prompts/context-builder.js +1 -1
- package/dist/prompts/policy-cards.js +1 -1
- package/dist/prompts/policy-cards.js.map +1 -1
- package/dist/providers/external-tool-error.d.ts +10 -0
- package/dist/providers/external-tool-error.js +21 -0
- package/dist/providers/external-tool-error.js.map +1 -0
- package/dist/providers/reddit-cli.d.ts +36 -0
- package/dist/providers/reddit-cli.js +201 -0
- package/dist/providers/reddit-cli.js.map +1 -0
- package/dist/providers/reddit.d.ts +1 -1
- package/dist/providers/reddit.js +7 -35
- package/dist/providers/reddit.js.map +1 -1
- package/dist/providers/twitter-cli.d.ts +40 -0
- package/dist/providers/twitter-cli.js +153 -0
- package/dist/providers/twitter-cli.js.map +1 -0
- package/dist/providers/twitter.d.ts +0 -8
- package/dist/providers/twitter.js +4 -54
- package/dist/providers/twitter.js.map +1 -1
- package/dist/providers/wrap-provider.js +30 -0
- package/dist/providers/wrap-provider.js.map +1 -1
- package/dist/providers/yahoo-finance.js +53 -32
- package/dist/providers/yahoo-finance.js.map +1 -1
- package/dist/routing/planning.d.ts +1 -1
- package/dist/routing/planning.js.map +1 -1
- package/dist/runtime/answer-contracts.d.ts +1 -1
- package/dist/runtime/answer-contracts.js +12 -1
- package/dist/runtime/answer-contracts.js.map +1 -1
- package/dist/runtime/tool-defaults-wrapper.js +6 -2
- package/dist/runtime/tool-defaults-wrapper.js.map +1 -1
- package/dist/sentiment/index.d.ts +1 -0
- package/dist/sentiment/index.js +1 -0
- package/dist/sentiment/index.js.map +1 -1
- package/dist/sentiment/insights.d.ts +17 -0
- package/dist/sentiment/insights.js +206 -0
- package/dist/sentiment/insights.js.map +1 -0
- package/dist/sentiment/pipeline.js +13 -1
- package/dist/sentiment/pipeline.js.map +1 -1
- package/dist/sentiment/scorer.d.ts +2 -0
- package/dist/sentiment/scorer.js +10 -1
- package/dist/sentiment/scorer.js.map +1 -1
- package/dist/sentiment/types.d.ts +2 -0
- package/dist/sentiment/types.js.map +1 -1
- package/dist/system-prompt.js +3 -7
- package/dist/system-prompt.js.map +1 -1
- package/dist/tools/index.d.ts +5 -2
- package/dist/tools/index.js +8 -8
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/sentiment/insight-format.d.ts +2 -0
- package/dist/tools/sentiment/insight-format.js +36 -0
- package/dist/tools/sentiment/insight-format.js.map +1 -0
- package/dist/tools/sentiment/query-match.d.ts +3 -0
- package/dist/tools/sentiment/query-match.js +113 -0
- package/dist/tools/sentiment/query-match.js.map +1 -0
- package/dist/tools/sentiment/reddit-sentiment.d.ts +12 -1
- package/dist/tools/sentiment/reddit-sentiment.js +263 -117
- package/dist/tools/sentiment/reddit-sentiment.js.map +1 -1
- package/dist/tools/sentiment/sentiment-summary.d.ts +9 -1
- package/dist/tools/sentiment/sentiment-summary.js +217 -201
- package/dist/tools/sentiment/sentiment-summary.js.map +1 -1
- package/dist/tools/sentiment/twitter-sentiment.d.ts +11 -1
- package/dist/tools/sentiment/twitter-sentiment.js +187 -64
- package/dist/tools/sentiment/twitter-sentiment.js.map +1 -1
- package/dist/tools/sentiment/web-sentiment.js +4 -0
- package/dist/tools/sentiment/web-sentiment.js.map +1 -1
- package/dist/types/sentiment.d.ts +52 -0
- package/gui/server/invoke-tool.ts +17 -3
- package/gui/server/model-setup.ts +10 -3
- package/gui/server/projector.ts +6 -2
- package/gui/server/server.ts +18 -0
- package/gui/server/tool-metadata.ts +80 -16
- package/gui/server/ws-hub.ts +19 -0
- package/gui/web/dist/assets/CatalogOverlay-CgeY5Pkp.js +1 -0
- package/gui/web/dist/assets/index-C6W_2eAn.js +69 -0
- package/gui/web/dist/assets/{index-2KZtKBmu.css → index-hwbx24a5.css} +1 -1
- package/gui/web/dist/index.html +2 -2
- package/package.json +5 -6
- package/src/cli.ts +41 -0
- package/src/config.ts +27 -0
- package/src/infra/index.ts +0 -1
- package/src/onboarding/connect.ts +20 -4
- package/src/onboarding/provider-status.ts +410 -0
- package/src/onboarding/providers.ts +148 -18
- package/src/onboarding/state.ts +9 -0
- package/src/onboarding/tool-tags.ts +45 -2
- package/src/onboarding/validation.ts +2 -2
- package/src/pi/opencandle-extension.ts +115 -17
- package/src/pi/tool-adapter.ts +14 -4
- package/src/prompts/context-builder.ts +1 -1
- package/src/prompts/policy-cards.ts +1 -1
- package/src/providers/external-tool-error.ts +20 -0
- package/src/providers/reddit-cli.ts +317 -0
- package/src/providers/reddit.ts +7 -63
- package/src/providers/twitter-cli.ts +233 -0
- package/src/providers/twitter.ts +4 -73
- package/src/providers/wrap-provider.ts +34 -0
- package/src/providers/yahoo-finance.ts +65 -32
- package/src/routing/planning.ts +1 -0
- package/src/runtime/answer-contracts.ts +23 -2
- package/src/runtime/tool-defaults-wrapper.ts +12 -2
- package/src/sentiment/index.ts +1 -0
- package/src/sentiment/insights.ts +269 -0
- package/src/sentiment/pipeline.ts +13 -1
- package/src/sentiment/scorer.ts +12 -1
- package/src/sentiment/types.ts +3 -0
- package/src/system-prompt.ts +3 -7
- package/src/tools/index.ts +9 -8
- package/src/tools/sentiment/insight-format.ts +50 -0
- package/src/tools/sentiment/query-match.ts +117 -0
- package/src/tools/sentiment/reddit-sentiment.ts +354 -141
- package/src/tools/sentiment/sentiment-summary.ts +283 -237
- package/src/tools/sentiment/twitter-sentiment.ts +262 -78
- package/src/tools/sentiment/web-sentiment.ts +4 -0
- package/src/types/sentiment.ts +59 -0
- package/dist/infra/browser.d.ts +0 -35
- package/dist/infra/browser.js +0 -105
- package/dist/infra/browser.js.map +0 -1
- package/dist/tools/interaction/twitter-login.d.ts +0 -8
- package/dist/tools/interaction/twitter-login.js +0 -87
- package/dist/tools/interaction/twitter-login.js.map +0 -1
- package/gui/web/dist/assets/CatalogOverlay-eJ2cBk33.js +0 -1
- package/gui/web/dist/assets/index-CveNgtDg.js +0 -69
- package/src/infra/browser.ts +0 -113
- package/src/tools/interaction/twitter-login.ts +0 -105
|
@@ -7,9 +7,19 @@ export function wrapWithDefaults<TParams extends TSchema, TDetails>(
|
|
|
7
7
|
): AgentTool<TParams, TDetails> {
|
|
8
8
|
return {
|
|
9
9
|
...tool,
|
|
10
|
-
execute: async (toolCallId, params, signal, onUpdate) => {
|
|
10
|
+
execute: async (toolCallId, params, signal, onUpdate, ctx?: unknown) => {
|
|
11
11
|
const merged = mergeDefaults(defaults, params as Record<string, unknown>);
|
|
12
|
-
|
|
12
|
+
const executeWithContext = tool.execute as unknown as (
|
|
13
|
+
id: string,
|
|
14
|
+
params: unknown,
|
|
15
|
+
signal?: AbortSignal,
|
|
16
|
+
onUpdate?: unknown,
|
|
17
|
+
ctx?: unknown,
|
|
18
|
+
) => ReturnType<typeof tool.execute>;
|
|
19
|
+
if (ctx === undefined) {
|
|
20
|
+
return executeWithContext(toolCallId, merged, signal, onUpdate);
|
|
21
|
+
}
|
|
22
|
+
return executeWithContext(toolCallId, merged, signal, onUpdate, ctx);
|
|
13
23
|
},
|
|
14
24
|
};
|
|
15
25
|
}
|
package/src/sentiment/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { RedditAdapter } from "./adapters/reddit.js";
|
|
2
2
|
export { TwitterAdapter } from "./adapters/twitter.js";
|
|
3
3
|
export { WebAdapter } from "./adapters/web.js";
|
|
4
|
+
export { buildSentimentInsight, labelForScore } from "./insights.js";
|
|
4
5
|
export { BEARISH_TERMS, BULLISH_TERMS } from "./keywords.js";
|
|
5
6
|
export { SentimentPipeline } from "./pipeline.js";
|
|
6
7
|
export { keywordScore, scoreRecords } from "./scorer.js";
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import type { SentimentConfig } from "../config.js";
|
|
2
|
+
import type {
|
|
3
|
+
SentimentInsight,
|
|
4
|
+
SentimentInsightConfidence,
|
|
5
|
+
SentimentInsightDriver,
|
|
6
|
+
SentimentRepresentativeItem,
|
|
7
|
+
} from "../types/sentiment.js";
|
|
8
|
+
import type { SentimentSource, SentinelRecord } from "./types.js";
|
|
9
|
+
|
|
10
|
+
const DEFAULT_MIN_SAMPLE = 10;
|
|
11
|
+
const DEFAULT_DRIVER_CAP = 3;
|
|
12
|
+
const DEFAULT_SOURCE_ITEM_CAP = 5;
|
|
13
|
+
const DEFAULT_AGGREGATE_ITEM_CAP = 8;
|
|
14
|
+
const DEFAULT_CLAIM_CAP = 5;
|
|
15
|
+
|
|
16
|
+
export interface BuildSentimentInsightOptions {
|
|
17
|
+
query: string;
|
|
18
|
+
records: SentinelRecord[];
|
|
19
|
+
score?: number;
|
|
20
|
+
label?: string;
|
|
21
|
+
source?: SentimentSource | "aggregate";
|
|
22
|
+
missingSources?: string[];
|
|
23
|
+
sourceNotes?: string[];
|
|
24
|
+
config?: Partial<SentimentConfig>;
|
|
25
|
+
aggregate?: boolean;
|
|
26
|
+
extraCaveats?: string[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface TermBucket {
|
|
30
|
+
term: string;
|
|
31
|
+
count: number;
|
|
32
|
+
sourceIds: Set<string>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function buildSentimentInsight(options: BuildSentimentInsightOptions): SentimentInsight {
|
|
36
|
+
const records = options.records;
|
|
37
|
+
const scored = records.filter((record) => Math.abs(record.sentiment.score) > 0);
|
|
38
|
+
const score =
|
|
39
|
+
typeof options.score === "number"
|
|
40
|
+
? options.score
|
|
41
|
+
: records.length > 0
|
|
42
|
+
? records.reduce((sum, record) => sum + record.sentiment.score, 0) / records.length
|
|
43
|
+
: 0;
|
|
44
|
+
const sourceCounts = countBySource(records);
|
|
45
|
+
const positiveDrivers = driversFromTerms(records, "positive", options.config);
|
|
46
|
+
const negativeDrivers = driversFromTerms(records, "negative", options.config);
|
|
47
|
+
const caveats = buildCaveats(records, scored, sourceCounts, options);
|
|
48
|
+
const confidence = buildConfidence(records, scored, sourceCounts, caveats, options.config);
|
|
49
|
+
const representativeItems = representativeItemsFor(records, options);
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
label: options.label ?? labelForScore(score),
|
|
53
|
+
score,
|
|
54
|
+
sampleSize: records.length,
|
|
55
|
+
scoredSampleSize: scored.length,
|
|
56
|
+
confidence,
|
|
57
|
+
positiveDrivers,
|
|
58
|
+
negativeDrivers,
|
|
59
|
+
mixedDrivers: buildMixedDrivers(positiveDrivers, negativeDrivers, score),
|
|
60
|
+
notableClaims: notableClaimsFor(records, options),
|
|
61
|
+
representativeItems,
|
|
62
|
+
sourceCoverage: {
|
|
63
|
+
sources: Object.keys(sourceCounts),
|
|
64
|
+
counts: sourceCounts,
|
|
65
|
+
missingSources: options.missingSources?.filter(Boolean),
|
|
66
|
+
notes: options.sourceNotes?.filter(Boolean),
|
|
67
|
+
},
|
|
68
|
+
caveats,
|
|
69
|
+
method: "deterministic-keyword-v1",
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function labelForScore(score: number): string {
|
|
74
|
+
if (score > 0.3) return "Bullish";
|
|
75
|
+
if (score < -0.3) return "Bearish";
|
|
76
|
+
if (score > 0) return "Leaning Bullish";
|
|
77
|
+
if (score < 0) return "Leaning Bearish";
|
|
78
|
+
return "Neutral";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function driversFromTerms(
|
|
82
|
+
records: SentinelRecord[],
|
|
83
|
+
polarity: "positive" | "negative",
|
|
84
|
+
config: Partial<SentimentConfig> | undefined,
|
|
85
|
+
): SentimentInsightDriver[] {
|
|
86
|
+
const buckets = new Map<string, TermBucket>();
|
|
87
|
+
const metadataKey = polarity === "positive" ? "matchedBullishTerms" : "matchedBearishTerms";
|
|
88
|
+
|
|
89
|
+
for (const record of records) {
|
|
90
|
+
const terms = stringArray(record.metadata[metadataKey]);
|
|
91
|
+
for (const term of terms) {
|
|
92
|
+
const existing = buckets.get(term) ?? { term, count: 0, sourceIds: new Set<string>() };
|
|
93
|
+
existing.count += 1;
|
|
94
|
+
existing.sourceIds.add(record.sourceId);
|
|
95
|
+
buckets.set(term, existing);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return [...buckets.values()]
|
|
100
|
+
.sort((a, b) => b.count - a.count || a.term.localeCompare(b.term))
|
|
101
|
+
.slice(0, config?.maxInsightDriversPerPolarity ?? DEFAULT_DRIVER_CAP)
|
|
102
|
+
.map((bucket) => ({
|
|
103
|
+
label: bucket.term,
|
|
104
|
+
count: bucket.count,
|
|
105
|
+
polarity,
|
|
106
|
+
terms: [bucket.term],
|
|
107
|
+
sourceIds: [...bucket.sourceIds],
|
|
108
|
+
}));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function buildMixedDrivers(
|
|
112
|
+
positiveDrivers: SentimentInsightDriver[],
|
|
113
|
+
negativeDrivers: SentimentInsightDriver[],
|
|
114
|
+
score: number,
|
|
115
|
+
): SentimentInsightDriver[] {
|
|
116
|
+
if (positiveDrivers.length === 0 || negativeDrivers.length === 0) return [];
|
|
117
|
+
if (Math.abs(score) > 0.3) return [];
|
|
118
|
+
return [
|
|
119
|
+
{
|
|
120
|
+
label: "offsetting bullish and bearish evidence",
|
|
121
|
+
count: Math.min(sumDriverCounts(positiveDrivers), sumDriverCounts(negativeDrivers)),
|
|
122
|
+
polarity: "mixed",
|
|
123
|
+
terms: [
|
|
124
|
+
...positiveDrivers.flatMap((d) => d.terms),
|
|
125
|
+
...negativeDrivers.flatMap((d) => d.terms),
|
|
126
|
+
],
|
|
127
|
+
sourceIds: [
|
|
128
|
+
...new Set([
|
|
129
|
+
...positiveDrivers.flatMap((d) => d.sourceIds),
|
|
130
|
+
...negativeDrivers.flatMap((d) => d.sourceIds),
|
|
131
|
+
]),
|
|
132
|
+
],
|
|
133
|
+
},
|
|
134
|
+
];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function representativeItemsFor(
|
|
138
|
+
records: SentinelRecord[],
|
|
139
|
+
options: BuildSentimentInsightOptions,
|
|
140
|
+
): SentimentRepresentativeItem[] {
|
|
141
|
+
const cap = options.aggregate
|
|
142
|
+
? (options.config?.maxAggregateRepresentativeItems ?? DEFAULT_AGGREGATE_ITEM_CAP)
|
|
143
|
+
: (options.config?.maxRepresentativeItemsPerSource ?? DEFAULT_SOURCE_ITEM_CAP);
|
|
144
|
+
|
|
145
|
+
return [...records]
|
|
146
|
+
.sort((a, b) => itemRank(b) - itemRank(a))
|
|
147
|
+
.slice(0, cap)
|
|
148
|
+
.map((record) => ({
|
|
149
|
+
source: record.source,
|
|
150
|
+
sourceId: record.sourceId,
|
|
151
|
+
title: record.title,
|
|
152
|
+
excerpt: excerptFor(record),
|
|
153
|
+
url: record.url || null,
|
|
154
|
+
author: record.author,
|
|
155
|
+
publishedAt: record.publishedAt,
|
|
156
|
+
engagement: Number.isFinite(record.engagement.score) ? record.engagement.score : null,
|
|
157
|
+
score: record.sentiment.score,
|
|
158
|
+
matchedTerms: [
|
|
159
|
+
...stringArray(record.metadata.matchedBullishTerms),
|
|
160
|
+
...stringArray(record.metadata.matchedBearishTerms),
|
|
161
|
+
],
|
|
162
|
+
metadata: record.metadata,
|
|
163
|
+
}));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function notableClaimsFor(
|
|
167
|
+
records: SentinelRecord[],
|
|
168
|
+
options: BuildSentimentInsightOptions,
|
|
169
|
+
): string[] {
|
|
170
|
+
const cap = options.config?.maxNotableClaims ?? DEFAULT_CLAIM_CAP;
|
|
171
|
+
const claims = new Set<string>();
|
|
172
|
+
for (const record of representativeItemsFor(records, { ...options, aggregate: false })) {
|
|
173
|
+
const claim = record.title ?? record.excerpt;
|
|
174
|
+
if (claim) claims.add(claim);
|
|
175
|
+
if (claims.size >= cap) break;
|
|
176
|
+
}
|
|
177
|
+
return [...claims];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function buildConfidence(
|
|
181
|
+
records: SentinelRecord[],
|
|
182
|
+
scored: SentinelRecord[],
|
|
183
|
+
sourceCounts: Record<string, number>,
|
|
184
|
+
caveats: string[],
|
|
185
|
+
config: Partial<SentimentConfig> | undefined,
|
|
186
|
+
): SentimentInsightConfidence {
|
|
187
|
+
const minSample = config?.minUsefulSampleSize ?? DEFAULT_MIN_SAMPLE;
|
|
188
|
+
const sampleRatio = Math.min(records.length / minSample, 1);
|
|
189
|
+
const scoredRatio = records.length === 0 ? 0 : scored.length / records.length;
|
|
190
|
+
const coverageRatio = Math.min(Object.keys(sourceCounts).length / 3, 1);
|
|
191
|
+
let score = sampleRatio * 0.4 + scoredRatio * 0.4 + coverageRatio * 0.2;
|
|
192
|
+
const reasons: string[] = [];
|
|
193
|
+
|
|
194
|
+
if (records.length < minSample) {
|
|
195
|
+
reasons.push(`low sample size (${records.length}/${minSample})`);
|
|
196
|
+
}
|
|
197
|
+
if (scoredRatio < 0.5) {
|
|
198
|
+
reasons.push("most records were neutral or had no keyword sentiment match");
|
|
199
|
+
}
|
|
200
|
+
if (Object.keys(sourceCounts).length <= 1) {
|
|
201
|
+
reasons.push("single-source sentiment coverage");
|
|
202
|
+
}
|
|
203
|
+
if (caveats.length > 0) {
|
|
204
|
+
score -= Math.min(0.2, caveats.length * 0.05);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
score = clamp01(score);
|
|
208
|
+
return {
|
|
209
|
+
level: score >= 0.7 ? "high" : score >= 0.4 ? "medium" : "low",
|
|
210
|
+
score,
|
|
211
|
+
reasons,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function buildCaveats(
|
|
216
|
+
records: SentinelRecord[],
|
|
217
|
+
scored: SentinelRecord[],
|
|
218
|
+
sourceCounts: Record<string, number>,
|
|
219
|
+
options: BuildSentimentInsightOptions,
|
|
220
|
+
): string[] {
|
|
221
|
+
const minSample = options.config?.minUsefulSampleSize ?? DEFAULT_MIN_SAMPLE;
|
|
222
|
+
const caveats = [...(options.extraCaveats ?? [])];
|
|
223
|
+
if (records.length < minSample) caveats.push(`Low sample size: ${records.length} records.`);
|
|
224
|
+
if (records.length > 0 && scored.length === 0)
|
|
225
|
+
caveats.push("No records contained keyword sentiment matches.");
|
|
226
|
+
if (records.length > 0 && scored.length / records.length < 0.5) {
|
|
227
|
+
caveats.push("Most records were neutral or unscored by keyword matching.");
|
|
228
|
+
}
|
|
229
|
+
if (Object.keys(sourceCounts).length <= 1) caveats.push("Single-source sentiment coverage.");
|
|
230
|
+
if (options.missingSources && options.missingSources.length > 0) {
|
|
231
|
+
caveats.push(`Missing sources: ${options.missingSources.join(", ")}.`);
|
|
232
|
+
}
|
|
233
|
+
return [...new Set(caveats)];
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function countBySource(records: SentinelRecord[]): Record<string, number> {
|
|
237
|
+
const counts: Record<string, number> = {};
|
|
238
|
+
for (const record of records) {
|
|
239
|
+
counts[record.source] = (counts[record.source] ?? 0) + 1;
|
|
240
|
+
}
|
|
241
|
+
return counts;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function stringArray(value: unknown): string[] {
|
|
245
|
+
return Array.isArray(value)
|
|
246
|
+
? value.filter((item): item is string => typeof item === "string")
|
|
247
|
+
: [];
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function sumDriverCounts(drivers: SentimentInsightDriver[]): number {
|
|
251
|
+
return drivers.reduce((sum, driver) => sum + driver.count, 0);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function itemRank(record: SentinelRecord): number {
|
|
255
|
+
const sentimentWeight = Math.abs(record.sentiment.score) * 100;
|
|
256
|
+
const confidenceWeight = record.sentiment.confidence * 20;
|
|
257
|
+
const engagementWeight = Math.log10(Math.max(0, record.engagement.score) + 1);
|
|
258
|
+
return sentimentWeight + confidenceWeight + engagementWeight;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function excerptFor(record: SentinelRecord): string {
|
|
262
|
+
const raw = record.title ? `${record.title} ${record.text}` : record.text;
|
|
263
|
+
const normalized = raw.replace(/\s+/g, " ").trim();
|
|
264
|
+
return normalized.length > 220 ? `${normalized.slice(0, 219)}…` : normalized;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function clamp01(value: number): number {
|
|
268
|
+
return Math.max(0, Math.min(1, value));
|
|
269
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { SentimentConfig } from "../config.js";
|
|
2
|
+
import { buildSentimentInsight } from "./insights.js";
|
|
2
3
|
import { scoreRecords } from "./scorer.js";
|
|
3
4
|
import type { SentimentStore } from "./store.js";
|
|
4
5
|
import { computeDivergence, computeTrend, type SourceStats } from "./trends.js";
|
|
@@ -65,7 +66,18 @@ export class SentimentPipeline {
|
|
|
65
66
|
divergence = computeDivergence(sourceStats, this.config.divergenceThreshold);
|
|
66
67
|
}
|
|
67
68
|
|
|
68
|
-
return {
|
|
69
|
+
return {
|
|
70
|
+
fresh: scored,
|
|
71
|
+
trend,
|
|
72
|
+
divergence,
|
|
73
|
+
warnings,
|
|
74
|
+
insight: buildSentimentInsight({
|
|
75
|
+
query,
|
|
76
|
+
records: scored,
|
|
77
|
+
config: this.config,
|
|
78
|
+
aggregate: true,
|
|
79
|
+
}),
|
|
80
|
+
};
|
|
69
81
|
}
|
|
70
82
|
}
|
|
71
83
|
|
package/src/sentiment/scorer.ts
CHANGED
|
@@ -7,6 +7,8 @@ interface ScoreResult {
|
|
|
7
7
|
score: number;
|
|
8
8
|
confidence: number;
|
|
9
9
|
tickers: string[];
|
|
10
|
+
bullishTerms: string[];
|
|
11
|
+
bearishTerms: string[];
|
|
10
12
|
}
|
|
11
13
|
|
|
12
14
|
export function keywordScore(record: SentinelRecord): ScoreResult {
|
|
@@ -17,11 +19,14 @@ export function keywordScore(record: SentinelRecord): ScoreResult {
|
|
|
17
19
|
let bearishWeight = 0;
|
|
18
20
|
let bullishCount = 0;
|
|
19
21
|
let bearishCount = 0;
|
|
22
|
+
const bullishTerms: string[] = [];
|
|
23
|
+
const bearishTerms: string[] = [];
|
|
20
24
|
|
|
21
25
|
for (const term of BULLISH_TERMS) {
|
|
22
26
|
if (lower.includes(term)) {
|
|
23
27
|
bullishCount++;
|
|
24
28
|
bullishWeight += engagement;
|
|
29
|
+
bullishTerms.push(term);
|
|
25
30
|
}
|
|
26
31
|
}
|
|
27
32
|
|
|
@@ -29,6 +34,7 @@ export function keywordScore(record: SentinelRecord): ScoreResult {
|
|
|
29
34
|
if (lower.includes(term)) {
|
|
30
35
|
bearishCount++;
|
|
31
36
|
bearishWeight += engagement;
|
|
37
|
+
bearishTerms.push(term);
|
|
32
38
|
}
|
|
33
39
|
}
|
|
34
40
|
|
|
@@ -59,7 +65,7 @@ export function keywordScore(record: SentinelRecord): ScoreResult {
|
|
|
59
65
|
}
|
|
60
66
|
}
|
|
61
67
|
|
|
62
|
-
return { score, confidence, tickers };
|
|
68
|
+
return { score, confidence, tickers, bullishTerms, bearishTerms };
|
|
63
69
|
}
|
|
64
70
|
|
|
65
71
|
export function scoreRecords(records: SentinelRecord[]): SentinelRecord[] {
|
|
@@ -73,6 +79,11 @@ export function scoreRecords(records: SentinelRecord[]): SentinelRecord[] {
|
|
|
73
79
|
method: "keyword" as const,
|
|
74
80
|
tickers: result.tickers,
|
|
75
81
|
},
|
|
82
|
+
metadata: {
|
|
83
|
+
...record.metadata,
|
|
84
|
+
matchedBullishTerms: result.bullishTerms,
|
|
85
|
+
matchedBearishTerms: result.bearishTerms,
|
|
86
|
+
},
|
|
76
87
|
};
|
|
77
88
|
});
|
|
78
89
|
}
|
package/src/sentiment/types.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { SentimentInsight } from "../types/sentiment.js";
|
|
2
|
+
|
|
1
3
|
export const SENTIMENT_SOURCES = ["twitter", "reddit", "web", "finnhub"] as const;
|
|
2
4
|
export type SentimentSource = (typeof SENTIMENT_SOURCES)[number];
|
|
3
5
|
|
|
@@ -106,4 +108,5 @@ export interface SentimentSummary {
|
|
|
106
108
|
trend: TrendResult[] | null;
|
|
107
109
|
divergence: DivergenceResult | null;
|
|
108
110
|
warnings: string[];
|
|
111
|
+
insight?: SentimentInsight;
|
|
109
112
|
}
|
package/src/system-prompt.ts
CHANGED
|
@@ -20,7 +20,7 @@ You are an analyst, not a fiduciary advisor. When asked for entry levels, price
|
|
|
20
20
|
- **Sentiment**: get_reddit_sentiment, get_twitter_sentiment, get_web_sentiment, get_sentiment_trend, get_sentiment_summary — retail and news sentiment from Reddit, Twitter/X, and web sources with historical trends
|
|
21
21
|
- **Options**: get_option_chain — full options chain with strikes, bids/asks, volume, OI, IV, and computed Greeks (delta, gamma, theta, vega, rho)
|
|
22
22
|
- **Portfolio**: track_portfolio, analyze_risk, manage_watchlist, analyze_correlation, track_prediction, manage_alerts, daily_watchlist_report, manage_notifications — position tracking, P&L, Sharpe ratio, VaR, watchlist tracking, durable local alerts, daily watchlist reports, notification history, correlation matrix, and prediction tracking with accuracy scoring
|
|
23
|
-
- **User Interaction**: ask_user — ask clarification questions
|
|
23
|
+
- **User Interaction**: ask_user — ask clarification questions and setup confirmations
|
|
24
24
|
|
|
25
25
|
## Analytical Framework
|
|
26
26
|
When analyzing a stock, follow these steps in order:
|
|
@@ -80,12 +80,8 @@ Do NOT ask clarifying questions when:
|
|
|
80
80
|
|
|
81
81
|
Keep questions concise and offer specific options when possible. Prefer select-type questions over open-ended text input to minimize user effort.
|
|
82
82
|
|
|
83
|
-
## Twitter
|
|
84
|
-
get_twitter_sentiment
|
|
85
|
-
1. Use ask_user (confirm) to ask: "Twitter sentiment requires a one-time login. A browser will open — want to proceed?"
|
|
86
|
-
2. If confirmed, call trigger_twitter_login. It opens a browser, waits for the user to log in, and returns success/failure.
|
|
87
|
-
3. On success, retry get_twitter_sentiment with the original query.
|
|
88
|
-
If the user declines, skip Twitter sentiment and continue with other available data sources.
|
|
83
|
+
## Twitter/X External Tool Setup
|
|
84
|
+
get_twitter_sentiment uses the external twitter-cli command and the user's normal browser session. If the tool says twitter-cli is missing, ask the user whether they want to install it with \`uv tool install twitter-cli\`, skip X for this query, or always skip X. If the tool says browser cookies or the X session are missing or stale, ask the user to log into or refresh x.com in a supported browser, then retry only after they confirm. Do not use the retired browser-login tool.
|
|
89
85
|
|
|
90
86
|
## After Clarification: Fetch Data Immediately
|
|
91
87
|
CRITICAL: After ask_user answers come back, your NEXT action MUST be tool calls — not a text response. You are a data agent, not a chatbot. Never respond with generic investment categories or tell the user to come back with tickers. YOU pick the relevant assets and indicators based on what you learned, then fetch the data.
|
package/src/tools/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { AgentTool } from "@earendil-works/pi-agent-core";
|
|
2
|
+
import type { AskUserHandler } from "../types/index.js";
|
|
2
3
|
import { companyOverviewTool } from "./fundamentals/company-overview.js";
|
|
3
4
|
import { compsTool } from "./fundamentals/comps.js";
|
|
4
5
|
import { dcfTool } from "./fundamentals/dcf.js";
|
|
@@ -23,10 +24,10 @@ import { predictionsTool } from "./portfolio/predictions.js";
|
|
|
23
24
|
import { riskAnalysisTool } from "./portfolio/risk-analysis.js";
|
|
24
25
|
import { portfolioTrackerTool } from "./portfolio/tracker.js";
|
|
25
26
|
import { watchlistTool } from "./portfolio/watchlist.js";
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
27
|
+
import { createRedditSentimentTool } from "./sentiment/reddit-sentiment.js";
|
|
28
|
+
import { createSentimentSummaryTool } from "./sentiment/sentiment-summary.js";
|
|
28
29
|
import { sentimentTrendTool } from "./sentiment/sentiment-trend.js";
|
|
29
|
-
import {
|
|
30
|
+
import { createTwitterSentimentTool } from "./sentiment/twitter-sentiment.js";
|
|
30
31
|
import { webSearchTool } from "./sentiment/web-search.js";
|
|
31
32
|
import { webSentimentTool } from "./sentiment/web-sentiment.js";
|
|
32
33
|
import { backtestTool } from "./technical/backtest.js";
|
|
@@ -57,7 +58,7 @@ export { riskAnalysisTool } from "./portfolio/risk-analysis.js";
|
|
|
57
58
|
export { portfolioTrackerTool } from "./portfolio/tracker.js";
|
|
58
59
|
export { watchlistTool } from "./portfolio/watchlist.js";
|
|
59
60
|
export { redditSentimentTool } from "./sentiment/reddit-sentiment.js";
|
|
60
|
-
export { sentimentSummaryTool } from "./sentiment/sentiment-summary.js";
|
|
61
|
+
export { createSentimentSummaryTool, sentimentSummaryTool } from "./sentiment/sentiment-summary.js";
|
|
61
62
|
export { sentimentTrendTool } from "./sentiment/sentiment-trend.js";
|
|
62
63
|
export { twitterSentimentTool } from "./sentiment/twitter-sentiment.js";
|
|
63
64
|
export { webSearchTool } from "./sentiment/web-search.js";
|
|
@@ -65,7 +66,7 @@ export { webSentimentTool } from "./sentiment/web-sentiment.js";
|
|
|
65
66
|
export { backtestTool } from "./technical/backtest.js";
|
|
66
67
|
export { technicalIndicatorsTool } from "./technical/indicators.js";
|
|
67
68
|
|
|
68
|
-
export function getAllTools(): AgentTool<any>[] {
|
|
69
|
+
export function getAllTools(options: { askUserHandler?: AskUserHandler } = {}): AgentTool<any>[] {
|
|
69
70
|
return [
|
|
70
71
|
searchTickerTool,
|
|
71
72
|
stockQuoteTool,
|
|
@@ -81,8 +82,8 @@ export function getAllTools(): AgentTool<any>[] {
|
|
|
81
82
|
secFilingsTool,
|
|
82
83
|
fredDataTool,
|
|
83
84
|
fearGreedTool,
|
|
84
|
-
|
|
85
|
-
|
|
85
|
+
createRedditSentimentTool({ askUserHandler: options.askUserHandler }),
|
|
86
|
+
createTwitterSentimentTool({ askUserHandler: options.askUserHandler }),
|
|
86
87
|
technicalIndicatorsTool,
|
|
87
88
|
backtestTool,
|
|
88
89
|
portfolioTrackerTool,
|
|
@@ -98,6 +99,6 @@ export function getAllTools(): AgentTool<any>[] {
|
|
|
98
99
|
webSearchTool,
|
|
99
100
|
webSentimentTool,
|
|
100
101
|
sentimentTrendTool,
|
|
101
|
-
|
|
102
|
+
createSentimentSummaryTool({ askUserHandler: options.askUserHandler }),
|
|
102
103
|
];
|
|
103
104
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { SentimentInsight } from "../../types/sentiment.js";
|
|
2
|
+
import { renderUntrustedText } from "./untrusted-text.js";
|
|
3
|
+
|
|
4
|
+
export function formatInsightSection(insight: SentimentInsight): string[] {
|
|
5
|
+
const lines: string[] = [];
|
|
6
|
+
lines.push("");
|
|
7
|
+
lines.push("Findings:");
|
|
8
|
+
lines.push(
|
|
9
|
+
`- Overall: ${insight.label} (${formatSigned(insight.score)}) from ${insight.sampleSize} records; ${insight.scoredSampleSize} had keyword sentiment evidence.`,
|
|
10
|
+
);
|
|
11
|
+
lines.push(
|
|
12
|
+
`- Confidence: ${insight.confidence.level} (${insight.confidence.score.toFixed(2)})${
|
|
13
|
+
insight.confidence.reasons.length > 0 ? ` — ${insight.confidence.reasons.join("; ")}` : ""
|
|
14
|
+
}`,
|
|
15
|
+
);
|
|
16
|
+
appendDrivers(lines, "Positive drivers", insight.positiveDrivers);
|
|
17
|
+
appendDrivers(lines, "Negative drivers", insight.negativeDrivers);
|
|
18
|
+
appendDrivers(lines, "Mixed drivers", insight.mixedDrivers);
|
|
19
|
+
if (insight.caveats.length > 0) {
|
|
20
|
+
lines.push(`- Caveats: ${insight.caveats.join("; ")}`);
|
|
21
|
+
}
|
|
22
|
+
if (insight.notableClaims.length > 0) {
|
|
23
|
+
lines.push("- Notable source claims:");
|
|
24
|
+
for (const claim of insight.notableClaims.slice(0, 3)) {
|
|
25
|
+
lines.push(` - ${renderUntrustedText(claim, 140)}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
if (insight.representativeItems.length > 0) {
|
|
29
|
+
lines.push(
|
|
30
|
+
`- Representative evidence preview: ${insight.representativeItems.length} shown from ${insight.scoredSampleSize} scored records (${insight.sampleSize} total records).`,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
return lines;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function appendDrivers(
|
|
37
|
+
lines: string[],
|
|
38
|
+
label: string,
|
|
39
|
+
drivers: SentimentInsight["positiveDrivers"],
|
|
40
|
+
): void {
|
|
41
|
+
if (drivers.length === 0) return;
|
|
42
|
+
const rendered = drivers
|
|
43
|
+
.map((driver) => `${renderUntrustedText(driver.label, 60)} (${driver.count})`)
|
|
44
|
+
.join(", ");
|
|
45
|
+
lines.push(`- ${label}: ${rendered}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function formatSigned(value: number): string {
|
|
49
|
+
return `${value >= 0 ? "+" : ""}${value.toFixed(2)}`;
|
|
50
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { extractEntities } from "../../routing/entity-extractor.js";
|
|
2
|
+
import type { SentinelRecord } from "../../sentiment/types.js";
|
|
3
|
+
|
|
4
|
+
const STOP_TERMS = new Set([
|
|
5
|
+
"about",
|
|
6
|
+
"around",
|
|
7
|
+
"are",
|
|
8
|
+
"from",
|
|
9
|
+
"how",
|
|
10
|
+
"is",
|
|
11
|
+
"latest",
|
|
12
|
+
"mood",
|
|
13
|
+
"now",
|
|
14
|
+
"on",
|
|
15
|
+
"or",
|
|
16
|
+
"people",
|
|
17
|
+
"reddit",
|
|
18
|
+
"retail",
|
|
19
|
+
"right",
|
|
20
|
+
"say",
|
|
21
|
+
"saying",
|
|
22
|
+
"says",
|
|
23
|
+
"sentiment",
|
|
24
|
+
"social",
|
|
25
|
+
"stock",
|
|
26
|
+
"stocks",
|
|
27
|
+
"talking",
|
|
28
|
+
"the",
|
|
29
|
+
"think",
|
|
30
|
+
"thinking",
|
|
31
|
+
"to",
|
|
32
|
+
"tweet",
|
|
33
|
+
"tweets",
|
|
34
|
+
"twitter",
|
|
35
|
+
"what",
|
|
36
|
+
"with",
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
export function sentimentQueryTerms(query: string): string[] {
|
|
40
|
+
const hasSp500 = /\bs\s*&\s*p\s*500\b/i.test(query) || /\bspx\b/i.test(query);
|
|
41
|
+
|
|
42
|
+
const symbols = hasSp500
|
|
43
|
+
? ["sp500"]
|
|
44
|
+
: extractEntities(query).symbols.map((symbol) => symbol.toLowerCase());
|
|
45
|
+
const punctuationTerms = [...query.toLowerCase().matchAll(/\b([a-z])\s*[&/]\s*([a-z])\b/g)].map(
|
|
46
|
+
(match) => `${match[1]}${match[2]}`,
|
|
47
|
+
);
|
|
48
|
+
const terms = query.toLowerCase().match(/[a-z0-9]{2,}/g);
|
|
49
|
+
if (!terms) {
|
|
50
|
+
if (punctuationTerms.length > 0) return [...new Set(punctuationTerms)];
|
|
51
|
+
const compact = query.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
52
|
+
if (compact.length >= 2) return [compact];
|
|
53
|
+
return query.trim().length > 0 ? [query.trim().toLowerCase()] : [];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const stopTerms = hasSp500 ? new Set([...STOP_TERMS, "sp", "spx", "500"]) : STOP_TERMS;
|
|
57
|
+
const filtered = [
|
|
58
|
+
...new Set([...punctuationTerms, ...terms.filter((term) => !stopTerms.has(term))]),
|
|
59
|
+
].slice(0, 6);
|
|
60
|
+
if (symbols.length > 0) {
|
|
61
|
+
return [
|
|
62
|
+
...new Set([
|
|
63
|
+
...symbols,
|
|
64
|
+
...filtered.filter((term) => !symbols.some((symbol) => symbol.startsWith(term))),
|
|
65
|
+
]),
|
|
66
|
+
].slice(0, 6);
|
|
67
|
+
}
|
|
68
|
+
if (filtered.length > 0) return filtered;
|
|
69
|
+
return query.trim().length > 0 ? [query.trim().toLowerCase()] : [];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function recordMatchesSentimentQuery(
|
|
73
|
+
record: SentinelRecord,
|
|
74
|
+
terms: readonly string[],
|
|
75
|
+
): boolean {
|
|
76
|
+
if (terms.length === 0) return true;
|
|
77
|
+
const text = `${record.title ?? ""}\n${record.text}`.toLowerCase();
|
|
78
|
+
const textTokens = new Set(text.match(/[a-z0-9]+/g) ?? []);
|
|
79
|
+
const metadataTokens = new Set<string>();
|
|
80
|
+
const subreddit = record.metadata.subreddit;
|
|
81
|
+
if (typeof subreddit === "string") {
|
|
82
|
+
metadataTokens.add(subreddit.toLowerCase());
|
|
83
|
+
metadataTokens.add(subreddit.toLowerCase().replace(/^r\//, ""));
|
|
84
|
+
}
|
|
85
|
+
if (/\bs\s*&\s*p\s*500\b/i.test(text) || /\bspx\b/i.test(text)) textTokens.add("sp500");
|
|
86
|
+
for (const match of text.matchAll(/\b([a-z])\s*[&/]\s*([a-z])\b/g)) {
|
|
87
|
+
textTokens.add(`${match[1]}${match[2]}`);
|
|
88
|
+
}
|
|
89
|
+
for (const match of text.matchAll(/\b([a-z]{1,5})[.-]([a-z])\b/g)) {
|
|
90
|
+
textTokens.add(`${match[1]}${match[2]}`);
|
|
91
|
+
}
|
|
92
|
+
const tickerTokens = new Set<string>();
|
|
93
|
+
for (const ticker of record.sentiment.tickers) {
|
|
94
|
+
const normalized = ticker.toLowerCase();
|
|
95
|
+
tickerTokens.add(normalized);
|
|
96
|
+
tickerTokens.add(normalized.replace(/[^a-z0-9]/g, ""));
|
|
97
|
+
}
|
|
98
|
+
const matchedTerms = terms.filter(
|
|
99
|
+
(term) => tickerTokens.has(term) || textTokens.has(term) || metadataTokens.has(term),
|
|
100
|
+
);
|
|
101
|
+
for (const term of terms) {
|
|
102
|
+
if (matchedTerms.includes(term) || !term.includes(" ")) continue;
|
|
103
|
+
if (text.includes(term)) matchedTerms.push(term);
|
|
104
|
+
}
|
|
105
|
+
for (const term of terms) {
|
|
106
|
+
if (matchedTerms.includes(term) || term.length < 4) continue;
|
|
107
|
+
if (textTokens.has(`${term}s`)) {
|
|
108
|
+
matchedTerms.push(term);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (term.endsWith("s") && textTokens.has(term.slice(0, -1))) {
|
|
112
|
+
matchedTerms.push(term);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const requiredMatches = terms.length > 1 ? 2 : 1;
|
|
116
|
+
return matchedTerms.length >= requiredMatches;
|
|
117
|
+
}
|