diff --git a/docs/architecture/investing-thesis-diffs-and-rollups.md b/docs/architecture/investing-thesis-diffs-and-rollups.md new file mode 100644 index 000000000000..460cd5921c7e --- /dev/null +++ b/docs/architecture/investing-thesis-diffs-and-rollups.md @@ -0,0 +1,84 @@ +# Investing Thesis Diffs And Portfolio Rollups + +This slice closes `#511`. + +The thesis ledger now exposes operator-facing query APIs for record reads, revision history, revision diffs, and portfolio-level rollup views. + +## Query surfaces + +New tool: + +- `zee:invest-thesis` + +Supported actions: + +- `read` + - load a thesis record by `thesisKey` or symbol +- `list` + - filter thesis records by symbol, status, conviction, or posture +- `history` + - inspect timestamped revision history for one thesis +- `diff` + - compare any two thesis versions, including evidence, watchpoint, valuation, and confidence deltas +- `portfolio-rollup` + - build a holdings/watchlist view from current thesis state + +CLI: + +```bash +zee investing thesis status +zee investing thesis read NVDA +zee investing thesis list --conviction medium +zee investing thesis history thesis:nvda +zee investing thesis diff NVDA --from-version 1 --to-version 2 +zee investing thesis rollup --audience all +``` + +## Diff contract + +Each diff payload includes: + +- `fromRevision` + - source version metadata +- `toRevision` + - target version metadata +- `changedFields[]` + - top-level fields that moved between versions +- `changes.summary` +- `changes.thesis` +- `changes.conviction` +- `changes.posture` +- `changes.watchpoints` +- `changes.evidence` +- `changes.valuation` +- `changes.confidence` + +That gives operators a stable JSON contract for timestamped thesis deltas without rereading the full revision log. + +## Portfolio rollup contract + +`portfolio-rollup` builds a view directly from: + +- `~/.local/state/zee/investing/theses.json` +- `ZEE_INVESTING_PORTFOLIO_FILE` or `~/.zee/investing/portfolio.json` +- `ZEE_INVESTING_WATCHLIST_FILE` or `~/.zee/investing/watchlist.json` + +The rollup reports: + +- holdings and watchlist coverage +- tracked thesis count and missing thesis gaps +- counts by posture and conviction +- one entry per holding or watchlist symbol with current thesis summary, latest revision metadata, watchpoints, and valuation context + +Missing thesis coverage is explicit so portfolio ops can see where the thesis system still has gaps. + +## Telemetry + +This slice emits: + +- `investing.thesis.query` + - thesis record reads, filtered lists, history queries, and diffs +- `investing.thesis.rollup` + - portfolio rollup generation with coverage counts and filter metadata + +These events complete the thesis epic metrics loop by making query usage and portfolio rollup coverage observable alongside the existing revision and confidence telemetry from `#509` and `#510`. diff --git a/packages/zee/src/cli/cmd/investing.ts b/packages/zee/src/cli/cmd/investing.ts index 332c15a93417..2e828beb4057 100644 --- a/packages/zee/src/cli/cmd/investing.ts +++ b/packages/zee/src/cli/cmd/investing.ts @@ -23,7 +23,24 @@ import { runInvestingConnector, type InvestingConnectorKind, } from "@/investing/ingestion" -import { getInvestingThesisLedgerStatus } from "@root/domain/investing/thesis" +import { + INVESTING_THESIS_CONVICTIONS, + INVESTING_THESIS_POSTURES, + INVESTING_THESIS_RECORD_STATUSES, + getInvestingThesisLedgerStatus, + type InvestingThesisConviction, + type InvestingThesisPosture, + type InvestingThesisRecordStatus, +} from "@root/domain/investing/thesis" +import { + INVESTING_THESIS_PORTFOLIO_ROLLUP_AUDIENCES, + buildInvestingThesisPortfolioRollup, + diffInvestingThesisHistory, + getInvestingThesisHistory, + queryInvestingThesisRecord, + queryInvestingTheses, + type InvestingThesisPortfolioRollupAudience, +} from "@root/domain/investing/thesis-queries" import { INVESTING_PORTFOLIO_BRIEFING_KINDS, createInvestingPortfolioBriefing, @@ -74,10 +91,7 @@ const InvestingIngestStatusCommand = cmd({ console.log(`ingestion: enabled=${status.enabled}`) for (const connector of status.connectors) { - const lastRun = - connector.lastFinishedAt > 0 - ? new Date(connector.lastFinishedAt).toISOString() - : "never" + const lastRun = connector.lastFinishedAt > 0 ? new Date(connector.lastFinishedAt).toISOString() : "never" console.log( `- ${connector.connector}: enabled=${connector.enabled} every=${connector.scheduleMinutes}m freshness=${connector.freshnessStatus} slo=${connector.freshnessSloMinutes}m lastStatus=${connector.lastStatus} items=${connector.itemCount} requests=${connector.requestCount} normalized=${connector.normalizedEntityCount} lastRun=${lastRun}`, ) @@ -101,7 +115,9 @@ const InvestingIngestRunCommand = cmd({ describe: "output as JSON", }), handler: async (args: { connector?: InvestingConnectorKind; json?: boolean }) => { - const results = args.connector ? [await runInvestingConnector(args.connector)] : await runEnabledInvestingConnectors() + const results = args.connector + ? [await runInvestingConnector(args.connector)] + : await runEnabledInvestingConnectors() if (args.json) { console.log(JSON.stringify(results, null, 2)) return @@ -329,7 +345,9 @@ const InvestingEventReadCommand = cmd({ console.log(`${event.id}`) console.log(`- classification=${event.classification} connector=${event.connector} direction=${event.direction}`) - console.log(`- materiality=${event.materiality.band} score=${event.materiality.score} audience=${event.entityLinks.audience}`) + console.log( + `- materiality=${event.materiality.band} score=${event.materiality.score} audience=${event.entityLinks.audience}`, + ) console.log(`- confidence=${event.confidence.toFixed(2)} symbol=${event.symbol ?? "n/a"} asOf=${event.asOf}`) console.log(`- sectors=${event.entityLinks.sectorLabels.join(", ") || "n/a"}`) console.log(`- holding=${event.entityLinks.holdingId ?? "n/a"} watchlist=${event.entityLinks.watchlistId ?? "n/a"}`) @@ -344,10 +362,18 @@ const InvestingEventCommand = cmd({ command: "event", describe: "classified news and earnings event intelligence", builder: (yargs: Argv) => - yargs.command(InvestingEventStatusCommand).command(InvestingEventListCommand).command(InvestingEventReadCommand).demandCommand(), + yargs + .command(InvestingEventStatusCommand) + .command(InvestingEventListCommand) + .command(InvestingEventReadCommand) + .demandCommand(), async handler() {}, }) +function thesisLookup(value: string): string { + return value.startsWith("thesis:") ? value : value.toUpperCase() +} + const InvestingThesisStatusCommand = cmd({ command: "status", describe: "show persisted thesis ledger status", @@ -371,10 +397,281 @@ const InvestingThesisStatusCommand = cmd({ }, }) +const InvestingThesisReadCommand = cmd({ + command: "read ", + describe: "read one persisted thesis record by thesis key or symbol", + builder: (yargs: Argv) => + yargs + .positional("thesis", { + type: "string", + demandOption: true, + describe: "thesis key such as thesis:nvda or a symbol such as NVDA", + }) + .option("json", { + type: "boolean", + default: false, + describe: "output as JSON", + }), + handler: async (args: { thesis?: string; json?: boolean }) => { + if (!args.thesis) { + throw new Error("thesis is required") + } + const thesis = queryInvestingThesisRecord(args.thesis) + const payload = thesis ?? { error: `Thesis not found: ${args.thesis}` } + if (args.json || !thesis) { + console.log(JSON.stringify(payload, null, 2)) + return + } + + console.log(`${thesis.id}`) + console.log(`- symbol=${thesis.symbol} status=${thesis.status} version=${thesis.currentVersion}`) + console.log(`- conviction=${thesis.conviction} posture=${thesis.posture}`) + console.log(`- summary=${thesis.summary}`) + console.log(`- updatedAt=${thesis.updatedAt} revisions=${thesis.revisions.length}`) + console.log( + `- valuationCaseId=${thesis.valuation?.valuationCaseId ?? "n/a"} signal=${thesis.valuation?.signal ?? "n/a"}`, + ) + }, +}) + +const InvestingThesisListCommand = cmd({ + command: "list", + describe: "list persisted thesis records", + builder: (yargs: Argv) => + yargs + .option("symbol", { + type: "string", + describe: "optional symbol filter", + }) + .option("status", { + type: "string", + choices: [...INVESTING_THESIS_RECORD_STATUSES], + describe: "optional thesis status filter", + }) + .option("conviction", { + type: "string", + choices: [...INVESTING_THESIS_CONVICTIONS], + describe: "optional conviction filter", + }) + .option("posture", { + type: "string", + choices: [...INVESTING_THESIS_POSTURES], + describe: "optional posture filter", + }) + .option("limit", { + type: "number", + default: 20, + describe: "maximum number of thesis records to return", + }) + .option("json", { + type: "boolean", + default: false, + describe: "output as JSON", + }), + handler: async (args: { + symbol?: string + status?: string + conviction?: string + posture?: string + limit?: number + json?: boolean + }) => { + const theses = queryInvestingTheses({ + symbol: args.symbol, + status: args.status as InvestingThesisRecordStatus | undefined, + conviction: args.conviction as InvestingThesisConviction | undefined, + posture: args.posture as InvestingThesisPosture | undefined, + limit: args.limit, + }) + if (args.json) { + console.log(JSON.stringify({ theses, count: theses.length }, null, 2)) + return + } + for (const thesis of theses) { + console.log( + `- ${thesis.id}: symbol=${thesis.symbol} status=${thesis.status} version=${thesis.currentVersion} conviction=${thesis.conviction} posture=${thesis.posture} summary=${thesis.summary}`, + ) + } + }, +}) + +const InvestingThesisHistoryCommand = cmd({ + command: "history ", + describe: "list revision history for one thesis key or symbol", + builder: (yargs: Argv) => + yargs + .positional("thesis", { + type: "string", + demandOption: true, + describe: "thesis key such as thesis:nvda or a symbol such as NVDA", + }) + .option("limit", { + type: "number", + default: 10, + describe: "maximum number of revisions to return", + }) + .option("json", { + type: "boolean", + default: false, + describe: "output as JSON", + }), + handler: async (args: { thesis?: string; limit?: number; json?: boolean }) => { + if (!args.thesis) { + throw new Error("thesis is required") + } + const history = getInvestingThesisHistory({ + thesis: args.thesis, + limit: args.limit, + }) + const payload = history ?? { error: `Thesis not found: ${args.thesis}` } + if (args.json || !history) { + console.log(JSON.stringify(payload, null, 2)) + return + } + + console.log(`${history.thesisKey}`) + console.log( + `- symbol=${history.symbol} currentVersion=${history.currentVersion} revisions=${history.revisionCount}`, + ) + for (const revision of history.revisions) { + console.log( + `- v${revision.version}: changeType=${revision.changeType} conviction=${revision.conviction} posture=${revision.posture} evidence=${revision.evidence.length} summary=${revision.summary}`, + ) + } + }, +}) + +const InvestingThesisDiffCommand = cmd({ + command: "diff ", + describe: "diff two thesis versions for one thesis key or symbol", + builder: (yargs: Argv) => + yargs + .positional("thesis", { + type: "string", + demandOption: true, + describe: "thesis key such as thesis:nvda or a symbol such as NVDA", + }) + .option("from-version", { + type: "number", + describe: "prior version, defaults to the previous revision", + }) + .option("to-version", { + type: "number", + describe: "target version, defaults to the latest revision", + }) + .option("json", { + type: "boolean", + default: false, + describe: "output as JSON", + }), + handler: async (args: { thesis?: string; fromVersion?: number; toVersion?: number; json?: boolean }) => { + if (!args.thesis) { + throw new Error("thesis is required") + } + try { + const diff = diffInvestingThesisHistory({ + thesis: args.thesis, + fromVersion: args.fromVersion, + toVersion: args.toVersion, + }) + const payload = diff ?? { error: `Thesis not found: ${args.thesis}` } + if (args.json || !diff) { + console.log(JSON.stringify(payload, null, 2)) + return + } + + console.log(`${thesisLookup(args.thesis)} diff`) + console.log(`- ${diff.summary}`) + console.log(`- from=v${diff.fromRevision.version} to=v${diff.toRevision.version}`) + console.log(`- changedFields=${diff.changedFields.join(", ") || "none"}`) + console.log( + `- conviction=${diff.changes.conviction.from}->${diff.changes.conviction.to} posture=${diff.changes.posture.from}->${diff.changes.posture.to}`, + ) + console.log( + `- watchpoints added=${diff.changes.watchpoints.added.length} removed=${diff.changes.watchpoints.removed.length} evidence added=${diff.changes.evidence.added.length} removed=${diff.changes.evidence.removed.length}`, + ) + } catch (error) { + console.log(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }, null, 2)) + } + }, +}) + +const InvestingThesisRollupCommand = cmd({ + command: "rollup", + describe: "build a portfolio-level thesis rollup view", + builder: (yargs: Argv) => + yargs + .option("audience", { + type: "string", + choices: [...INVESTING_THESIS_PORTFOLIO_ROLLUP_AUDIENCES], + default: "all", + describe: "roll up all tracked names, holdings only, or watchlist only", + }) + .option("conviction", { + type: "string", + choices: [...INVESTING_THESIS_CONVICTIONS], + describe: "optional conviction filter", + }) + .option("posture", { + type: "string", + choices: [...INVESTING_THESIS_POSTURES], + describe: "optional posture filter", + }) + .option("limit", { + type: "number", + default: 50, + describe: "maximum number of rollup entries to return", + }) + .option("json", { + type: "boolean", + default: false, + describe: "output as JSON", + }), + handler: async (args: { + audience?: string + conviction?: string + posture?: string + limit?: number + json?: boolean + }) => { + const rollup = buildInvestingThesisPortfolioRollup({ + audience: args.audience as InvestingThesisPortfolioRollupAudience | undefined, + conviction: args.conviction as InvestingThesisConviction | undefined, + posture: args.posture as InvestingThesisPosture | undefined, + limit: args.limit, + }) + if (args.json) { + console.log(JSON.stringify(rollup, null, 2)) + return + } + + console.log(rollup.summary) + console.log(`- by posture: ${JSON.stringify(rollup.countsByPosture)}`) + console.log(`- by conviction: ${JSON.stringify(rollup.countsByConviction)}`) + for (const entry of rollup.entries) { + if (!entry.thesis) { + console.log(`- ${entry.symbol} [${entry.audience}]: missing thesis record`) + continue + } + console.log( + `- ${entry.symbol} [${entry.audience}]: version=${entry.thesis.currentVersion} conviction=${entry.thesis.conviction} posture=${entry.thesis.posture} summary=${entry.thesis.summary}`, + ) + } + }, +}) + const InvestingThesisCommand = cmd({ command: "thesis", - describe: "persisted thesis ledger and version history", - builder: (yargs: Argv) => yargs.command(InvestingThesisStatusCommand).demandCommand(), + describe: "persisted thesis ledger, diffs, and portfolio rollup views", + builder: (yargs: Argv) => + yargs + .command(InvestingThesisStatusCommand) + .command(InvestingThesisReadCommand) + .command(InvestingThesisListCommand) + .command(InvestingThesisHistoryCommand) + .command(InvestingThesisDiffCommand) + .command(InvestingThesisRollupCommand) + .demandCommand(), async handler() {}, }) @@ -411,7 +708,9 @@ const InvestingEarningsPacketCreateCommand = cmd({ const plan = getInvestingResearchPlan(execution.planId) const task = plan?.tasks.find((entry) => entry.id === execution.taskId) if (!plan || !task) { - console.log(JSON.stringify({ error: `Research plan context not found for execution: ${args.executionId}` }, null, 2)) + console.log( + JSON.stringify({ error: `Research plan context not found for execution: ${args.executionId}` }, null, 2), + ) return } @@ -828,7 +1127,9 @@ const InvestingOpsScheduleRunCommand = cmd({ console.log(`${delivery.id}`) console.log(`- workflow=${delivery.workflow} status=${delivery.status} target=${delivery.deliveryTarget}`) - console.log(`- artifact=${delivery.artifactKind}:${delivery.artifactId ?? "n/a"} symbol=${delivery.symbol ?? "n/a"}`) + console.log( + `- artifact=${delivery.artifactKind}:${delivery.artifactId ?? "n/a"} symbol=${delivery.symbol ?? "n/a"}`, + ) console.log(`- summary=${delivery.summary}`) if (delivery.content) { console.log(`\n${delivery.content}`) @@ -878,7 +1179,9 @@ const InvestingOpsDeliveryReadCommand = cmd({ console.log(`${delivery.id}`) console.log(`- workflow=${delivery.workflow} status=${delivery.status} target=${delivery.deliveryTarget}`) - console.log(`- artifact=${delivery.artifactKind}:${delivery.artifactId ?? "n/a"} symbol=${delivery.symbol ?? "n/a"}`) + console.log( + `- artifact=${delivery.artifactKind}:${delivery.artifactId ?? "n/a"} symbol=${delivery.symbol ?? "n/a"}`, + ) console.log(`- summary=${delivery.summary}`) if (delivery.error) { console.log(`- error=${delivery.error}`) @@ -953,10 +1256,7 @@ const InvestingOpsDeliveryCommand = cmd({ command: "delivery", describe: "research ops delivery audit trail", builder: (yargs: Argv) => - yargs - .command(InvestingOpsDeliveryReadCommand) - .command(InvestingOpsDeliveryListCommand) - .demandCommand(), + yargs.command(InvestingOpsDeliveryReadCommand).command(InvestingOpsDeliveryListCommand).demandCommand(), async handler() {}, }) @@ -964,10 +1264,7 @@ const InvestingOpsCommand = cmd({ command: "ops", describe: "unattended research ops schedules and delivery audit trail", builder: (yargs: Argv) => - yargs - .command(InvestingOpsScheduleCommand) - .command(InvestingOpsDeliveryCommand) - .demandCommand(), + yargs.command(InvestingOpsScheduleCommand).command(InvestingOpsDeliveryCommand).demandCommand(), async handler() {}, }) @@ -1098,7 +1395,11 @@ const InvestingBriefingCommand = cmd({ command: "briefing", describe: "persisted daily portfolio briefings", builder: (yargs: Argv) => - yargs.command(InvestingBriefingCreateCommand).command(InvestingBriefingReadCommand).command(InvestingBriefingListCommand).demandCommand(), + yargs + .command(InvestingBriefingCreateCommand) + .command(InvestingBriefingReadCommand) + .command(InvestingBriefingListCommand) + .demandCommand(), async handler() {}, }) diff --git a/packages/zee/src/flux/types.ts b/packages/zee/src/flux/types.ts index d42be5e52b62..adb31f5657d9 100644 --- a/packages/zee/src/flux/types.ts +++ b/packages/zee/src/flux/types.ts @@ -73,6 +73,8 @@ export type FluxKind = | "investing.thesis.record" | "investing.thesis.revision" | "investing.thesis.confidence" + | "investing.thesis.query" + | "investing.thesis.rollup" | "investing.portfolio.briefing" | "agent.legacy_tools_alias.used" | "gateway.fallback.invoked" diff --git a/packages/zee/test/investing/thesis-queries.test.ts b/packages/zee/test/investing/thesis-queries.test.ts new file mode 100644 index 000000000000..ab86a181b30f --- /dev/null +++ b/packages/zee/test/investing/thesis-queries.test.ts @@ -0,0 +1,230 @@ +import { describe, expect, spyOn, test } from "bun:test" +import { mkdirSync, writeFileSync } from "node:fs" +import path from "node:path" +import { FluxRecorder } from "../../src/flux" +import { tmpdir } from "../fixture/fixture" +import { thesisTool } from "../../../../src/domain/investing/tools" +import { + buildInvestingThesisPortfolioRollup, + diffInvestingThesisHistory, + getInvestingThesisHistory, + queryInvestingTheses, +} from "../../../../src/domain/investing/thesis-queries" +import { + recordInvestingThesisRevision, + syncInvestingThesisContext, + thesisKeyForSymbol, +} from "../../../../src/domain/investing/thesis" + +function makeToolContext() { + return { + sessionId: "session-1", + messageId: "message-1", + agent: "zee", + abort: new AbortController().signal, + metadata: () => {}, + } +} + +async function withThesisQueryState(fn: (root: string) => Promise): Promise { + await using dir = await tmpdir() + const originalStateHome = process.env.XDG_STATE_HOME + const originalPortfolioFile = process.env.ZEE_INVESTING_PORTFOLIO_FILE + const originalWatchlistFile = process.env.ZEE_INVESTING_WATCHLIST_FILE + process.env.XDG_STATE_HOME = dir.path + process.env.ZEE_INVESTING_PORTFOLIO_FILE = path.join(dir.path, "portfolio.json") + process.env.ZEE_INVESTING_WATCHLIST_FILE = path.join(dir.path, "watchlist.json") + + try { + return await fn(dir.path) + } finally { + if (originalStateHome === undefined) delete process.env.XDG_STATE_HOME + else process.env.XDG_STATE_HOME = originalStateHome + + if (originalPortfolioFile === undefined) delete process.env.ZEE_INVESTING_PORTFOLIO_FILE + else process.env.ZEE_INVESTING_PORTFOLIO_FILE = originalPortfolioFile + + if (originalWatchlistFile === undefined) delete process.env.ZEE_INVESTING_WATCHLIST_FILE + else process.env.ZEE_INVESTING_WATCHLIST_FILE = originalWatchlistFile + } +} + +function seedThesis(symbol: string, input?: { bullish?: boolean; includeRefresh?: boolean }) { + const thesisKey = thesisKeyForSymbol(symbol) + const bullish = input?.bullish ?? true + syncInvestingThesisContext({ + thesisKey, + symbol, + summary: `${symbol} base thesis context.`, + valuation: { + valuationCaseId: `valuation_case:equity:${symbol.toLowerCase()}:base`, + packetId: `valuation-packet-${symbol.toLowerCase()}-base`, + runId: `valuation-run-${symbol.toLowerCase()}-base`, + signal: bullish ? "re-rate-up" : "balanced", + fairValue: bullish ? 150 : 110, + currentPrice: 100, + upsidePercent: bullish ? 50 : 10, + }, + }) + + recordInvestingThesisRevision({ + thesisKey, + symbol, + changeType: "initialize", + summary: `${symbol} thesis initialized.`, + thesis: `${symbol} initial thesis body.`, + conviction: bullish ? "high" : "medium", + posture: bullish ? "bullish" : "neutral", + watchpoints: [`Track ${symbol} demand`, `Track ${symbol} valuation`], + valuation: { + valuationCaseId: `valuation_case:equity:${symbol.toLowerCase()}:base`, + packetId: `valuation-packet-${symbol.toLowerCase()}-base`, + runId: `valuation-run-${symbol.toLowerCase()}-base`, + signal: bullish ? "re-rate-up" : "balanced", + fairValue: bullish ? 150 : 110, + currentPrice: 100, + upsidePercent: bullish ? 50 : 10, + }, + evidence: [ + { + kind: "research-evidence", + id: `evidence-${symbol.toLowerCase()}-research`, + label: `[E1] Research summary for ${symbol}`, + link: `evidence:${symbol}:E1`, + toolId: "zee:invest-research", + }, + { + kind: "valuation-packet", + id: `valuation-packet-${symbol.toLowerCase()}-base`, + label: `Valuation packet for ${symbol}`, + link: `valuation-packet:valuation-packet-${symbol.toLowerCase()}-base`, + toolId: "zee:invest-valuation", + }, + ], + }) + + if (input?.includeRefresh !== false) { + recordInvestingThesisRevision({ + thesisKey, + symbol, + changeType: "refresh", + summary: `${symbol} thesis refreshed after estimate revisions.`, + thesis: `${symbol} refreshed thesis body with tighter setup.`, + conviction: "high", + posture: "neutral", + watchpoints: [`Watch ${symbol} estimate revisions`, `Monitor ${symbol} event deltas`], + valuation: { + valuationCaseId: `valuation_case:equity:${symbol.toLowerCase()}:refresh`, + packetId: `valuation-packet-${symbol.toLowerCase()}-refresh`, + runId: `valuation-run-${symbol.toLowerCase()}-refresh`, + signal: "balanced", + fairValue: 112, + currentPrice: 104, + upsidePercent: 7.7, + }, + evidence: [ + { + kind: "research-evidence", + id: `evidence-${symbol.toLowerCase()}-estimates`, + label: `[E2] Estimate revision for ${symbol}`, + link: `evidence:${symbol}:E2`, + toolId: "zee:invest-estimates", + }, + ], + }) + } +} + +describe("investing thesis queries", () => { + test("exposes thesis history, diffs, and filtered queries", async () => { + await withThesisQueryState(async () => { + const recordSpy = spyOn(FluxRecorder, "record") + seedThesis("NVDA") + seedThesis("MSFT", { bullish: false, includeRefresh: false }) + + const theses = queryInvestingTheses({ conviction: "medium" }) + expect(theses).toHaveLength(2) + + const history = getInvestingThesisHistory({ thesis: "NVDA", limit: 5 }) + expect(history?.revisionCount).toBe(2) + expect(history?.revisions[0]?.version).toBe(2) + + const diff = diffInvestingThesisHistory({ thesis: "thesis:nvda" }) + expect(diff?.fromRevision.version).toBe(1) + expect(diff?.toRevision.version).toBe(2) + expect(diff?.changedFields).toContain("conviction") + expect(diff?.changedFields).toContain("watchpoints") + expect(diff?.changes.evidence.added[0]?.toolId).toBe("zee:invest-estimates") + + const tool = await thesisTool.init?.({} as never) + const result = await tool?.execute( + { + action: "diff", + thesis: "NVDA", + }, + makeToolContext() as never, + ) + const payload = JSON.parse(result?.output ?? "{}") + expect(payload.symbol).toBe("NVDA") + expect(payload.changedFields).toContain("valuation") + + expect(recordSpy.mock.calls.some((call) => call[0]?.kind === "investing.thesis.query")).toBe(true) + }) + }) + + test("builds portfolio thesis rollups and surfaces missing coverage", async () => { + await withThesisQueryState(async (root) => { + const recordSpy = spyOn(FluxRecorder, "record") + mkdirSync(root, { recursive: true }) + writeFileSync( + path.join(root, "portfolio.json"), + JSON.stringify( + { + positions: [ + { symbol: "NVDA", shares: 10, averageCost: 90 }, + { symbol: "MSFT", shares: 5, averageCost: 300 }, + ], + }, + null, + 2, + ), + ) + writeFileSync( + path.join(root, "watchlist.json"), + JSON.stringify( + { + symbols: ["AAPL"], + }, + null, + 2, + ), + ) + + seedThesis("NVDA") + seedThesis("MSFT", { bullish: false, includeRefresh: false }) + + const rollup = buildInvestingThesisPortfolioRollup() + expect(rollup.coverage.holdingsCount).toBe(2) + expect(rollup.coverage.watchlistCount).toBe(1) + expect(rollup.coverage.thesisTrackedCount).toBe(2) + expect(rollup.coverage.missingThesisCount).toBe(1) + expect(rollup.entries.find((entry) => entry.symbol === "AAPL")?.thesis).toBeNull() + expect(rollup.entries.find((entry) => entry.symbol === "NVDA")?.thesis?.currentVersion).toBe(2) + + const tool = await thesisTool.init?.({} as never) + const result = await tool?.execute( + { + action: "portfolio-rollup", + audience: "all", + limit: 20, + }, + makeToolContext() as never, + ) + const payload = JSON.parse(result?.output ?? "{}") + expect(payload.coverage.missingThesisCount).toBe(1) + expect(payload.countsByPosture.neutral).toBe(2) + + expect(recordSpy.mock.calls.some((call) => call[0]?.kind === "investing.thesis.rollup")).toBe(true) + }) + }) +}) diff --git a/src/domain/investing/thesis-queries.ts b/src/domain/investing/thesis-queries.ts new file mode 100644 index 000000000000..d06bc17f6ab9 --- /dev/null +++ b/src/domain/investing/thesis-queries.ts @@ -0,0 +1,738 @@ +/** + * Investing Thesis Query And Rollup Views + * + * Query helpers layered on top of the persisted thesis ledger so operators can + * read thesis history, compute revision diffs, and inspect portfolio-level + * thesis coverage without mutating the underlying record state. + */ + +import { existsSync, readFileSync } from "node:fs" +import os from "node:os" +import path from "node:path" +import { FluxRecorder } from "../../../packages/zee/src/flux" +import { Investing } from "../../paths" +import { + INVESTING_THESIS_CONVICTIONS, + INVESTING_THESIS_POSTURES, + type InvestingThesisConfidenceAssessment, + type InvestingThesisConviction, + type InvestingThesisPosture, + type InvestingThesisRecord, + type InvestingThesisRecordStatus, + type InvestingThesisRevision, + getInvestingThesis, + listInvestingTheses, + thesisKeyForSymbol, +} from "./thesis" + +export const INVESTING_THESIS_PORTFOLIO_ROLLUP_AUDIENCES = ["all", "holding", "watchlist"] as const +export type InvestingThesisPortfolioRollupAudience = (typeof INVESTING_THESIS_PORTFOLIO_ROLLUP_AUDIENCES)[number] + +type PortfolioPosition = { + symbol: string + shares: number + averageCost?: number +} + +type WatchlistEntry = { + symbol: string +} + +export interface InvestingThesisHistory { + thesisKey: string + symbol: string + currentVersion: number + revisionCount: number + revisions: InvestingThesisRevision[] +} + +export interface InvestingThesisValueChange { + from: T + to: T + changed: boolean +} + +export interface InvestingThesisCollectionChange { + added: T[] + removed: T[] + changed: boolean +} + +export interface InvestingThesisDiff { + thesisKey: string + symbol: string + fromRevision: Pick< + InvestingThesisRevision, + "id" | "version" | "createdAt" | "changeType" | "summary" | "conviction" | "posture" + > + toRevision: Pick< + InvestingThesisRevision, + "id" | "version" | "createdAt" | "changeType" | "summary" | "conviction" | "posture" + > + changedFields: string[] + summary: string + changes: { + summary: InvestingThesisValueChange + thesis: InvestingThesisValueChange + conviction: InvestingThesisValueChange + posture: InvestingThesisValueChange + watchpoints: InvestingThesisCollectionChange + evidence: InvestingThesisCollectionChange<{ + kind: string + id: string + label: string + link?: string + toolId?: string + }> + valuation: InvestingThesisValueChange<{ + valuationCaseId?: string + signal?: string + fairValue?: number | null + currentPrice?: number | null + upsidePercent?: number | null + } | null> + confidence: InvestingThesisValueChange<{ + ruleVersion: string + requestedConviction: InvestingThesisConviction + appliedConviction: InvestingThesisConviction + maxAllowedConviction: InvestingThesisConviction + score: number + evidenceCount: number + uniqueTools: string[] + reasons: string[] + } | null> + } +} + +export interface InvestingThesisPortfolioRollupEntry { + symbol: string + audience: "holding" | "watchlist" + shares?: number + averageCost?: number + thesis: null | { + thesisKey: string + status: InvestingThesisRecordStatus + summary: string + conviction: InvestingThesisConviction + posture: InvestingThesisPosture + currentVersion: number + updatedAt: string + confidence: InvestingThesisConfidenceAssessment | null + } + latestRevision: null | { + revisionId: string + version: number + changeType: string + createdAt: string + summary: string + evidenceCount: number + } + watchpoints: string[] + valuation: InvestingThesisRecord["valuation"] +} + +export interface InvestingThesisPortfolioRollup { + schemaVersion: "investing-thesis-rollup.v1" + createdAt: string + audience: InvestingThesisPortfolioRollupAudience + summary: string + coverage: { + holdingsCount: number + watchlistCount: number + thesisTrackedCount: number + missingThesisCount: number + } + countsByPosture: Record + countsByConviction: Record + entries: InvestingThesisPortfolioRollupEntry[] +} + +function normalizeSymbol(symbol: string | undefined): string { + return symbol?.trim().toUpperCase() ?? "" +} + +function defaultWatchlistFile(): string { + return process.env.ZEE_INVESTING_WATCHLIST_FILE || path.join(os.homedir(), ".zee", "investing", "watchlist.json") +} + +function parseNumber(value: unknown): number | undefined { + if (typeof value === "number" && Number.isFinite(value)) return value + if (typeof value === "string" && value.trim()) { + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : undefined + } + return undefined +} + +function asRecord(value: unknown): Record | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null + return value as Record +} + +function unique(items: T[]): T[] { + return [...new Set(items)] +} + +function readJsonFile(filePath: string): unknown { + if (!existsSync(filePath)) return undefined + try { + return JSON.parse(readFileSync(filePath, "utf8")) + } catch { + return undefined + } +} + +function loadPortfolioPositions(portfolioFile = Investing.portfolioFile()): PortfolioPosition[] { + const parsed = readJsonFile(portfolioFile) + const record = asRecord(parsed) + const positions = Array.isArray(parsed) + ? parsed + : Array.isArray(record?.positions) + ? record.positions + : Array.isArray(record?.holdings) + ? record.holdings + : [] + + return positions + .map((entry): PortfolioPosition | null => { + const item = asRecord(entry) + if (!item) return null + const symbol = normalizeSymbol(String(item.symbol ?? item.ticker ?? "")) + const shares = parseNumber(item.shares ?? item.quantity ?? item.position) + if (!symbol || !shares || shares <= 0) return null + const averageCost = parseNumber( + item.averageCost ?? item.average_cost ?? item.avg_cost ?? item.entryPrice ?? item.entry_price ?? item.price, + ) + return { symbol, shares, averageCost } + }) + .filter((entry): entry is PortfolioPosition => Boolean(entry)) +} + +function loadWatchlistEntries(input: { watchlistSymbols?: string[]; watchlistFile?: string }): WatchlistEntry[] { + const parsed = readJsonFile(input.watchlistFile ?? defaultWatchlistFile()) + const record = asRecord(parsed) + const items = Array.isArray(parsed) + ? parsed + : Array.isArray(record?.items) + ? record.items + : Array.isArray(record?.watchlist) + ? record.watchlist + : Array.isArray(record?.symbols) + ? record.symbols + : [] + + const symbols = [ + ...items.map((item) => { + if (typeof item === "string") return normalizeSymbol(item) + const value = asRecord(item) + return normalizeSymbol(String(value?.symbol ?? value?.ticker ?? value?.code ?? "")) + }), + ...(input.watchlistSymbols ?? []).map((item) => normalizeSymbol(item)), + ] + + return unique(symbols) + .filter(Boolean) + .map((symbol) => ({ symbol })) +} + +function withDefaultCounts(items: T): Record { + return Object.fromEntries(items.map((item) => [item, 0])) as Record +} + +function recordQueryTelemetry(input: { + kind: "investing.thesis.query" | "investing.thesis.rollup" + traceID: string + method: string + path?: string + route?: string + metadata?: Record +}): void { + FluxRecorder.record({ + traceID: input.traceID, + direction: "internal", + domain: "investing", + kind: input.kind, + status: "ok", + method: input.method, + path: input.path, + route: input.route, + metadata: input.metadata, + }) +} + +function resolveThesisKey(thesis: string): string { + return thesis.startsWith("thesis:") ? thesis : thesisKeyForSymbol(thesis) +} + +function resolveThesisRecord(thesis: string): InvestingThesisRecord | null { + return getInvestingThesis(resolveThesisKey(thesis)) +} + +function equalStringArrays(left: string[], right: string[]): boolean { + return left.length === right.length && left.every((item, index) => item === right[index]) +} + +function diffStringArrays(left: string[], right: string[]): InvestingThesisCollectionChange { + const leftSet = new Set(left) + const rightSet = new Set(right) + const added = right.filter((item) => !leftSet.has(item)) + const removed = left.filter((item) => !rightSet.has(item)) + return { + added, + removed, + changed: added.length > 0 || removed.length > 0, + } +} + +function evidenceKey(item: { kind: string; id: string }): string { + return `${item.kind}:${item.id}` +} + +function diffEvidence( + left: InvestingThesisRevision["evidence"], + right: InvestingThesisRevision["evidence"], +): InvestingThesisCollectionChange<{ + kind: string + id: string + label: string + link?: string + toolId?: string +}> { + const leftKeys = new Set(left.map((item) => evidenceKey(item))) + const rightKeys = new Set(right.map((item) => evidenceKey(item))) + const added = right.filter((item) => !leftKeys.has(evidenceKey(item))) + const removed = left.filter((item) => !rightKeys.has(evidenceKey(item))) + return { + added, + removed, + changed: added.length > 0 || removed.length > 0, + } +} + +function sameConfidence( + left: InvestingThesisConfidenceAssessment | null, + right: InvestingThesisConfidenceAssessment | null, +): boolean { + if (left === right) return true + if (!left || !right) return false + return ( + left.ruleVersion === right.ruleVersion && + left.requestedConviction === right.requestedConviction && + left.appliedConviction === right.appliedConviction && + left.maxAllowedConviction === right.maxAllowedConviction && + left.score === right.score && + left.evidenceCount === right.evidenceCount && + equalStringArrays(left.uniqueTools, right.uniqueTools) && + equalStringArrays(left.reasons, right.reasons) + ) +} + +function baselineConfidence(): InvestingThesisConfidenceAssessment { + return { + ruleVersion: "thesis-confidence.v1", + requestedConviction: "low", + appliedConviction: "low", + maxAllowedConviction: "low", + score: 0, + evidenceCount: 0, + uniqueTools: [], + reasons: ["Baseline comparison before the first persisted thesis revision."], + } +} + +function makeBaselineRevision(record: InvestingThesisRecord): InvestingThesisRevision { + return { + id: `${record.id}:baseline`, + version: 0, + changeType: "initialize", + createdAt: record.createdAt, + summary: "No previously persisted thesis revision.", + thesis: "", + conviction: "low", + posture: "neutral", + watchpoints: [], + valuation: null, + evidence: [], + confidence: baselineConfidence(), + source: {}, + } +} + +function findRevision(record: InvestingThesisRecord, version: number): InvestingThesisRevision { + if (version === 0) return makeBaselineRevision(record) + const revision = record.revisions.find((item) => item.version === version) + if (!revision) { + throw new Error(`Thesis revision v${version} not found for ${record.id}.`) + } + return revision +} + +function sortRollupEntries( + left: InvestingThesisPortfolioRollupEntry, + right: InvestingThesisPortfolioRollupEntry, +): number { + if (left.audience !== right.audience) return left.audience === "holding" ? -1 : 1 + return left.symbol.localeCompare(right.symbol) +} + +export function queryInvestingThesisRecord(thesis: string): InvestingThesisRecord | null { + const record = resolveThesisRecord(thesis) + const thesisKey = resolveThesisKey(thesis) + recordQueryTelemetry({ + kind: "investing.thesis.query", + traceID: thesisKey, + method: "read", + path: record?.symbol ?? thesis.toUpperCase(), + route: thesisKey, + metadata: { found: Boolean(record) }, + }) + return record +} + +export function queryInvestingTheses(options?: { + symbol?: string + status?: InvestingThesisRecordStatus + conviction?: InvestingThesisConviction + posture?: InvestingThesisPosture + limit?: number +}): InvestingThesisRecord[] { + const theses = listInvestingTheses({ + symbol: options?.symbol, + status: options?.status, + limit: Number.MAX_SAFE_INTEGER, + }) + .filter((record) => (options?.conviction ? record.conviction === options.conviction : true)) + .filter((record) => (options?.posture ? record.posture === options.posture : true)) + .slice(0, options?.limit ?? 20) + + recordQueryTelemetry({ + kind: "investing.thesis.query", + traceID: options?.symbol ? resolveThesisKey(options.symbol) : "thesis:list", + method: "list", + path: options?.symbol?.toUpperCase(), + route: "investing:thesis:list", + metadata: { + count: theses.length, + status: options?.status, + conviction: options?.conviction, + posture: options?.posture, + limit: options?.limit ?? 20, + }, + }) + + return theses +} + +export function getInvestingThesisHistory(input: { thesis: string; limit?: number }): InvestingThesisHistory | null { + const record = resolveThesisRecord(input.thesis) + const thesisKey = resolveThesisKey(input.thesis) + recordQueryTelemetry({ + kind: "investing.thesis.query", + traceID: thesisKey, + method: "history", + path: record?.symbol ?? input.thesis.toUpperCase(), + route: thesisKey, + metadata: { found: Boolean(record), limit: input.limit ?? 10 }, + }) + + if (!record) return null + const revisions = record.revisions.slice(0, input.limit ?? 10) + return { + thesisKey: record.id, + symbol: record.symbol, + currentVersion: record.currentVersion, + revisionCount: record.revisions.length, + revisions, + } +} + +export function diffInvestingThesisHistory(input: { + thesis: string + fromVersion?: number + toVersion?: number +}): InvestingThesisDiff | null { + const record = resolveThesisRecord(input.thesis) + const thesisKey = resolveThesisKey(input.thesis) + if (!record) { + recordQueryTelemetry({ + kind: "investing.thesis.query", + traceID: thesisKey, + method: "diff", + path: input.thesis.toUpperCase(), + route: thesisKey, + metadata: { found: false, fromVersion: input.fromVersion, toVersion: input.toVersion }, + }) + return null + } + + const latestVersion = record.revisions[0]?.version ?? 0 + const toVersion = input.toVersion ?? latestVersion + const fromVersion = input.fromVersion ?? Math.max(0, toVersion - 1) + if (fromVersion === toVersion) { + throw new Error("Thesis diff requires two distinct versions.") + } + + const fromRevision = findRevision(record, fromVersion) + const toRevision = findRevision(record, toVersion) + const watchpoints = diffStringArrays(fromRevision.watchpoints, toRevision.watchpoints) + const evidence = diffEvidence(fromRevision.evidence, toRevision.evidence) + const valuationFrom = fromRevision.valuation + ? { + valuationCaseId: fromRevision.valuation.valuationCaseId, + signal: fromRevision.valuation.signal, + fairValue: fromRevision.valuation.fairValue, + currentPrice: fromRevision.valuation.currentPrice, + upsidePercent: fromRevision.valuation.upsidePercent, + } + : null + const valuationTo = toRevision.valuation + ? { + valuationCaseId: toRevision.valuation.valuationCaseId, + signal: toRevision.valuation.signal, + fairValue: toRevision.valuation.fairValue, + currentPrice: toRevision.valuation.currentPrice, + upsidePercent: toRevision.valuation.upsidePercent, + } + : null + const confidenceFrom = fromRevision.confidence + ? { + ruleVersion: fromRevision.confidence.ruleVersion, + requestedConviction: fromRevision.confidence.requestedConviction, + appliedConviction: fromRevision.confidence.appliedConviction, + maxAllowedConviction: fromRevision.confidence.maxAllowedConviction, + score: fromRevision.confidence.score, + evidenceCount: fromRevision.confidence.evidenceCount, + uniqueTools: fromRevision.confidence.uniqueTools, + reasons: fromRevision.confidence.reasons, + } + : null + const confidenceTo = toRevision.confidence + ? { + ruleVersion: toRevision.confidence.ruleVersion, + requestedConviction: toRevision.confidence.requestedConviction, + appliedConviction: toRevision.confidence.appliedConviction, + maxAllowedConviction: toRevision.confidence.maxAllowedConviction, + score: toRevision.confidence.score, + evidenceCount: toRevision.confidence.evidenceCount, + uniqueTools: toRevision.confidence.uniqueTools, + reasons: toRevision.confidence.reasons, + } + : null + + const changedFields = [ + fromRevision.summary !== toRevision.summary ? "summary" : null, + fromRevision.thesis !== toRevision.thesis ? "thesis" : null, + fromRevision.conviction !== toRevision.conviction ? "conviction" : null, + fromRevision.posture !== toRevision.posture ? "posture" : null, + watchpoints.changed ? "watchpoints" : null, + evidence.changed ? "evidence" : null, + JSON.stringify(valuationFrom) !== JSON.stringify(valuationTo) ? "valuation" : null, + !sameConfidence(fromRevision.confidence, toRevision.confidence) ? "confidence" : null, + ].filter((field): field is string => Boolean(field)) + + const diff: InvestingThesisDiff = { + thesisKey: record.id, + symbol: record.symbol, + fromRevision: { + id: fromRevision.id, + version: fromRevision.version, + createdAt: fromRevision.createdAt, + changeType: fromRevision.changeType, + summary: fromRevision.summary, + conviction: fromRevision.conviction, + posture: fromRevision.posture, + }, + toRevision: { + id: toRevision.id, + version: toRevision.version, + createdAt: toRevision.createdAt, + changeType: toRevision.changeType, + summary: toRevision.summary, + conviction: toRevision.conviction, + posture: toRevision.posture, + }, + changedFields, + summary: `${record.symbol} thesis diff v${fromVersion} -> v${toVersion} changed ${ + changedFields.length + } field(s): ${changedFields.join(", ") || "none"}.`, + changes: { + summary: { + from: fromRevision.summary, + to: toRevision.summary, + changed: fromRevision.summary !== toRevision.summary, + }, + thesis: { from: fromRevision.thesis, to: toRevision.thesis, changed: fromRevision.thesis !== toRevision.thesis }, + conviction: { + from: fromRevision.conviction, + to: toRevision.conviction, + changed: fromRevision.conviction !== toRevision.conviction, + }, + posture: { + from: fromRevision.posture, + to: toRevision.posture, + changed: fromRevision.posture !== toRevision.posture, + }, + watchpoints, + evidence, + valuation: { + from: valuationFrom, + to: valuationTo, + changed: JSON.stringify(valuationFrom) !== JSON.stringify(valuationTo), + }, + confidence: { + from: confidenceFrom, + to: confidenceTo, + changed: !sameConfidence(fromRevision.confidence, toRevision.confidence), + }, + }, + } + + recordQueryTelemetry({ + kind: "investing.thesis.query", + traceID: thesisKey, + method: "diff", + path: record.symbol, + route: thesisKey, + metadata: { fromVersion, toVersion, changedFields, changeCount: changedFields.length }, + }) + + return diff +} + +export function buildInvestingThesisPortfolioRollup(input?: { + audience?: InvestingThesisPortfolioRollupAudience + posture?: InvestingThesisPosture + conviction?: InvestingThesisConviction + limit?: number + portfolioFile?: string + watchlistFile?: string + watchlistSymbols?: string[] +}): InvestingThesisPortfolioRollup { + const audience = input?.audience ?? "all" + const holdings = loadPortfolioPositions(input?.portfolioFile) + const watchlist = loadWatchlistEntries({ + watchlistFile: input?.watchlistFile, + watchlistSymbols: input?.watchlistSymbols, + }) + + const rawEntries: InvestingThesisPortfolioRollupEntry[] = [ + ...holdings.map((position) => { + const record = getInvestingThesis(thesisKeyForSymbol(position.symbol)) + const latestRevision = record?.revisions[0] + return { + symbol: position.symbol, + audience: "holding" as const, + shares: position.shares, + averageCost: position.averageCost, + thesis: record + ? { + thesisKey: record.id, + status: record.status, + summary: record.summary, + conviction: record.conviction, + posture: record.posture, + currentVersion: record.currentVersion, + updatedAt: record.updatedAt, + confidence: record.confidence, + } + : null, + latestRevision: latestRevision + ? { + revisionId: latestRevision.id, + version: latestRevision.version, + changeType: latestRevision.changeType, + createdAt: latestRevision.createdAt, + summary: latestRevision.summary, + evidenceCount: latestRevision.evidence.length, + } + : null, + watchpoints: record?.watchpoints ?? [], + valuation: record?.valuation ?? null, + } + }), + ...watchlist.map((entry) => { + const record = getInvestingThesis(thesisKeyForSymbol(entry.symbol)) + const latestRevision = record?.revisions[0] + return { + symbol: entry.symbol, + audience: "watchlist" as const, + thesis: record + ? { + thesisKey: record.id, + status: record.status, + summary: record.summary, + conviction: record.conviction, + posture: record.posture, + currentVersion: record.currentVersion, + updatedAt: record.updatedAt, + confidence: record.confidence, + } + : null, + latestRevision: latestRevision + ? { + revisionId: latestRevision.id, + version: latestRevision.version, + changeType: latestRevision.changeType, + createdAt: latestRevision.createdAt, + summary: latestRevision.summary, + evidenceCount: latestRevision.evidence.length, + } + : null, + watchpoints: record?.watchpoints ?? [], + valuation: record?.valuation ?? null, + } + }), + ] + + const filteredEntries = rawEntries + .filter((entry) => (audience === "all" ? true : entry.audience === audience)) + .filter((entry) => (input?.posture ? entry.thesis?.posture === input.posture : true)) + .filter((entry) => (input?.conviction ? entry.thesis?.conviction === input.conviction : true)) + .sort(sortRollupEntries) + .slice(0, input?.limit ?? 50) + + const countsByPosture = withDefaultCounts(INVESTING_THESIS_POSTURES) + const countsByConviction = withDefaultCounts(INVESTING_THESIS_CONVICTIONS) + for (const entry of filteredEntries) { + if (!entry.thesis) continue + countsByPosture[entry.thesis.posture] += 1 + countsByConviction[entry.thesis.conviction] += 1 + } + + const tracked = filteredEntries.filter((entry) => entry.thesis) + const missing = filteredEntries.filter((entry) => !entry.thesis) + const rollup: InvestingThesisPortfolioRollup = { + schemaVersion: "investing-thesis-rollup.v1", + createdAt: new Date().toISOString(), + audience, + summary: `Portfolio thesis rollup covers ${filteredEntries.filter((entry) => entry.audience === "holding").length} holding(s) and ${filteredEntries.filter((entry) => entry.audience === "watchlist").length} watchlist name(s), with ${tracked.length} tracked thesis record(s) and ${missing.length} missing thesis gap(s).`, + coverage: { + holdingsCount: filteredEntries.filter((entry) => entry.audience === "holding").length, + watchlistCount: filteredEntries.filter((entry) => entry.audience === "watchlist").length, + thesisTrackedCount: tracked.length, + missingThesisCount: missing.length, + }, + countsByPosture, + countsByConviction, + entries: filteredEntries, + } + + recordQueryTelemetry({ + kind: "investing.thesis.rollup", + traceID: `portfolio-rollup:${audience}`, + method: "build", + path: audience, + route: "investing:thesis:rollup", + metadata: { + holdingsCount: rollup.coverage.holdingsCount, + watchlistCount: rollup.coverage.watchlistCount, + thesisTrackedCount: rollup.coverage.thesisTrackedCount, + missingThesisCount: rollup.coverage.missingThesisCount, + posture: input?.posture, + conviction: input?.conviction, + limit: input?.limit ?? 50, + }, + }) + + return rollup +} diff --git a/src/domain/investing/tools.ts b/src/domain/investing/tools.ts index f35ef1950a1c..0c0db2d08d05 100644 --- a/src/domain/investing/tools.ts +++ b/src/domain/investing/tools.ts @@ -7,12 +7,12 @@ * - SEC EDGAR for regulatory filings */ -import { z } from "zod"; -import { existsSync, readFileSync } from "node:fs"; -import type { ToolDefinition, ToolRuntime, ToolExecutionContext, ToolExecutionResult } from "../../mcp/types"; -import { Investing } from "../../paths"; -import { scratchpadTool } from "./scratchpad"; -import { getResearchContextManager, resetResearchContextManager } from "./research-context"; +import { z } from "zod" +import { existsSync, readFileSync } from "node:fs" +import type { ToolDefinition, ToolRuntime, ToolExecutionContext, ToolExecutionResult } from "../../mcp/types" +import { Investing } from "../../paths" +import { scratchpadTool } from "./scratchpad" +import { getResearchContextManager, resetResearchContextManager } from "./research-context" import { INVESTING_RESEARCH_PLAN_STATUSES, INVESTING_RESEARCH_TASK_STATUSES, @@ -21,43 +21,48 @@ import { getInvestingResearchPlan, listInvestingResearchPlans, updateInvestingResearchTask, -} from "./planner"; +} from "./planner" import { getInvestingResearchExecution, listInvestingResearchExecutions, runInvestingResearchExecution, -} from "./executor"; +} from "./executor" import { INVESTING_RESEARCH_ARTIFACT_STATUSES, createInvestingResearchArtifact, getInvestingResearchArtifact, listInvestingResearchArtifacts, -} from "./artifacts"; -import { - getInvestingValuationKernel, - listInvestingValuationKernels, - runInvestingValuationKernel, -} from "./valuation"; +} from "./artifacts" +import { getInvestingValuationKernel, listInvestingValuationKernels, runInvestingValuationKernel } from "./valuation" import { createInvestingValuationPacket, exportInvestingValuationPacket, getInvestingValuationPacket, listInvestingValuationPackets, -} from "./valuation-packet"; +} from "./valuation-packet" import { createInvestingEarningsPacket, exportInvestingEarningsPacket, getInvestingEarningsPacket, INVESTING_EARNINGS_PACKET_WORKFLOWS, listInvestingEarningsPackets, -} from "./earnings-packets"; +} from "./earnings-packets" import { INVESTING_PORTFOLIO_BRIEFING_KINDS, createInvestingPortfolioBriefing, getInvestingPortfolioBriefing, getInvestingPortfolioBriefingStateFile, listInvestingPortfolioBriefings, -} from "./briefings"; +} from "./briefings" +import { INVESTING_THESIS_CONVICTIONS, INVESTING_THESIS_POSTURES, INVESTING_THESIS_RECORD_STATUSES } from "./thesis" +import { + INVESTING_THESIS_PORTFOLIO_ROLLUP_AUDIENCES, + buildInvestingThesisPortfolioRollup, + diffInvestingThesisHistory, + getInvestingThesisHistory, + queryInvestingThesisRecord, + queryInvestingTheses, +} from "./thesis-queries" import { createInvestingOpsSchedule, getInvestingOpsDeliveryRecord, @@ -69,7 +74,7 @@ import { listInvestingOpsSchedules, runInvestingOpsSchedule, updateInvestingOpsSchedule, -} from "./ops-automation"; +} from "./ops-automation" import { INVESTING_EVENT_CLASSIFICATIONS, INVESTING_EVENT_CONNECTORS, @@ -78,54 +83,50 @@ import { getInvestingEvent, getInvestingEventCatalogStatus, listInvestingEvents, -} from "../../../packages/zee/src/investing/events"; +} from "../../../packages/zee/src/investing/events" type InvestingResult = { - ok: boolean; - command?: string; - data?: unknown; - error?: string; -}; + ok: boolean + command?: string + data?: unknown + error?: string +} type InvestingEnvelope = { - success: boolean; - data: T | null; - error: string | null; - timestamp: string; -}; + success: boolean + data: T | null + error: string | null + timestamp: string +} type PortfolioPosition = { - symbol: string; - shares: number; - averageCost: number; -}; + symbol: string + shares: number + averageCost: number +} function normalizeSymbol(symbol: string): string { - return symbol.trim().toUpperCase(); + return symbol.trim().toUpperCase() } function flagValue(args: string[], flag: string): string | undefined { - const index = args.indexOf(flag); - if (index === -1 || index + 1 >= args.length) return undefined; - return args[index + 1]; + const index = args.indexOf(flag) + if (index === -1 || index + 1 >= args.length) return undefined + return args[index + 1] } function loadPortfolioHoldings(): Array<{ symbol: string; shares: number; average_cost: number }> { - const portfolioFile = Investing.portfolioFile(); - if (!existsSync(portfolioFile)) return []; + const portfolioFile = Investing.portfolioFile() + if (!existsSync(portfolioFile)) return [] try { - const parsed = JSON.parse(readFileSync(portfolioFile, "utf8")) as any; - const positions = Array.isArray(parsed) - ? parsed - : Array.isArray(parsed?.positions) - ? parsed.positions - : []; + const parsed = JSON.parse(readFileSync(portfolioFile, "utf8")) as any + const positions = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.positions) ? parsed.positions : [] return positions .map((position: any): PortfolioPosition | null => { - const symbol = typeof position?.symbol === "string" ? normalizeSymbol(position.symbol) : ""; - const shares = Number(position?.shares ?? 0); + const symbol = typeof position?.symbol === "string" ? normalizeSymbol(position.symbol) : "" + const shares = Number(position?.shares ?? 0) const averageCost = Number( position?.averageCost ?? position?.average_cost ?? @@ -134,81 +135,76 @@ function loadPortfolioHoldings(): Array<{ symbol: string; shares: number; averag position?.entry_price ?? position?.price ?? 0, - ); - if (!symbol || !Number.isFinite(shares) || shares <= 0) return null; - return { symbol, shares, averageCost }; + ) + if (!symbol || !Number.isFinite(shares) || shares <= 0) return null + return { symbol, shares, averageCost } }) .filter((position: PortfolioPosition | null): position is PortfolioPosition => Boolean(position)) .map((position: PortfolioPosition) => ({ symbol: position.symbol, shares: position.shares, average_cost: position.averageCost, - })); + })) } catch { - return []; + return [] } } async function requestInvesting(pathname: string, init?: RequestInit): Promise { try { - const baseUrl = Investing.apiUrl().replace(/\/+$/, ""); + const baseUrl = Investing.apiUrl().replace(/\/+$/, "") const response = await fetch(`${baseUrl}${pathname}`, { ...init, headers: { "content-type": "application/json", ...(init?.headers ?? {}), }, - }); - const text = await response.text(); - const payload = text ? JSON.parse(text) as InvestingEnvelope | unknown : null; - - if ( - payload && - typeof payload === "object" && - "success" in payload && - "data" in payload - ) { - const envelope = payload as InvestingEnvelope; + }) + const text = await response.text() + const payload = text ? (JSON.parse(text) as InvestingEnvelope | unknown) : null + + if (payload && typeof payload === "object" && "success" in payload && "data" in payload) { + const envelope = payload as InvestingEnvelope if (!response.ok || !envelope.success) { return { ok: false, error: envelope.error || `Investing request failed with status ${response.status}`, - }; + } } - return { ok: true, data: envelope.data }; + return { ok: true, data: envelope.data } } if (!response.ok) { return { ok: false, error: text || `Investing request failed with status ${response.status}`, - }; + } } - return { ok: true, data: payload }; + return { ok: true, data: payload } } catch (error) { return { ok: false, error: error instanceof Error ? error.message : String(error), - }; + } } } async function runInvestingCli(args: string[]): Promise { - const [domain, action] = args; + const [domain, action] = args if (domain === "status") { - return requestInvesting("/api/health"); + return requestInvesting("/api/health") } if (domain === "market") { - const symbol = normalizeSymbol(args[2] || ""); + const symbol = normalizeSymbol(args[2] || "") if (!symbol) { - return { ok: false, error: "A market symbol is required." }; + return { ok: false, error: "A market symbol is required." } } if (action === "chart") { - const period = flagValue(args, "--period") || "1mo"; - return requestInvesting(`/api/market/${symbol}/history?period=${encodeURIComponent(period)}&interval=1d`); + const period = flagValue(args, "--period") || "1mo" + return requestInvesting(`/api/market/${symbol}/history?period=${encodeURIComponent(period)}&interval=1d`) } if (action === "segments") { return { @@ -218,27 +214,24 @@ async function runInvestingCli(args: string[]): Promise { segmentType: flagValue(args, "--type") || "business", note: "Segment detail is not yet exposed on the Rust investing HTTP surface.", }, - }; + } } if (action === "fundamentals") { - return requestInvesting(`/api/market/${symbol}`); + return requestInvesting(`/api/market/${symbol}`) } - return requestInvesting(`/api/market/${symbol}/quote`); + return requestInvesting(`/api/market/${symbol}/quote`) } if (domain === "portfolio") { - const holdings = loadPortfolioHoldings(); + const holdings = loadPortfolioHoldings() if (action === "status") { return { ok: true, data: { holdings, - totalValue: holdings.reduce( - (total, holding) => total + holding.shares * holding.average_cost, - 0, - ), + totalValue: holdings.reduce((total, holding) => total + holding.shares * holding.average_cost, 0), }, - }; + } } if (action === "risk") { return requestInvesting("/api/portfolio/risk", { @@ -248,29 +241,29 @@ async function runInvestingCli(args: string[]): Promise { confidence_level: 0.95, method: "historical", }), - }); + }) } if (action === "performance") { return requestInvesting("/api/portfolio/analytics", { method: "POST", body: JSON.stringify({ holdings, benchmark: "SPY" }), - }); + }) } } if (domain === "research") { if (action === "analyze") { - const symbol = normalizeSymbol(flagValue(args, "--ticker") || args[2] || ""); - return requestInvesting(`/api/research/${symbol}`); + const symbol = normalizeSymbol(flagValue(args, "--ticker") || args[2] || "") + return requestInvesting(`/api/research/${symbol}`) } if (action === "sec") { - const symbol = normalizeSymbol(args[2] || ""); - return requestInvesting(`/api/accounting/${symbol}/filings`); + const symbol = normalizeSymbol(args[2] || "") + return requestInvesting(`/api/accounting/${symbol}/filings`) } if (action === "screen") { - const query = flagValue(args, "--criteria") || ""; + const query = flagValue(args, "--criteria") || "" if (/^[A-Za-z.\-]{1,12}$/.test(query.trim())) { - return requestInvesting(`/api/research/${normalizeSymbol(query)}`); + return requestInvesting(`/api/research/${normalizeSymbol(query)}`) } return { ok: true, @@ -279,21 +272,21 @@ async function runInvestingCli(args: string[]): Promise { results: [], note: "Broad research screening is not yet exposed on the Rust investing HTTP surface.", }, - }; + } } if (action === "estimates") { - const symbol = normalizeSymbol(args[2] || ""); - return requestInvesting(`/api/valuation/${symbol}`); + const symbol = normalizeSymbol(args[2] || "") + return requestInvesting(`/api/valuation/${symbol}`) } if (action === "insider-trades") { - const symbol = normalizeSymbol(args[2] || ""); - return requestInvesting(`/api/institutional/${symbol}/smart-money-flow`); + const symbol = normalizeSymbol(args[2] || "") + return requestInvesting(`/api/institutional/${symbol}/smart-money-flow`) } } if (domain === "nautilus") { if (action === "backtest") { - const symbol = normalizeSymbol((flagValue(args, "--symbols") || "").split(",")[0] || "SPY"); + const symbol = normalizeSymbol((flagValue(args, "--symbols") || "").split(",")[0] || "SPY") return requestInvesting("/api/signals/backtest", { method: "POST", body: JSON.stringify({ @@ -304,7 +297,7 @@ async function runInvestingCli(args: string[]): Promise { holding_period_days: 10, initial_capital: 100000, }), - }); + }) } return { ok: true, @@ -312,13 +305,13 @@ async function runInvestingCli(args: string[]): Promise { action, note: "This Nautilus action is not yet exposed on the Rust investing HTTP surface.", }, - }; + } } return { ok: false, error: `Unsupported investing command: ${args.join(" ")}`, - }; + } } function renderOutput(title: string, result: InvestingResult): ToolExecutionResult { @@ -327,14 +320,14 @@ function renderOutput(title: string, result: InvestingResult): ToolExecutionResu title, metadata: { ok: false }, output: result.error || "Investing CLI failed.", - }; + } } return { title, metadata: { ok: true }, output: JSON.stringify(result.data ?? result, null, 2), - }; + } } // ============================================================================= @@ -350,24 +343,24 @@ function withDeduplication( execute: (args: any, ctx: ToolExecutionContext) => Promise, ): (args: any, ctx: ToolExecutionContext) => Promise { return async (args, ctx) => { - const manager = getResearchContextManager(); + const manager = getResearchContextManager() if (manager.isDuplicate(toolId, args)) { - const cached = manager.getCachedResult(toolId, args)!; + const cached = manager.getCachedResult(toolId, args)! return { title: `[Cached] ${toolId}`, metadata: { cached: true, originalTimestamp: cached.timestamp }, output: cached.fullOutput, - }; + } } - const result = await execute(args, ctx); - manager.record(toolId, args, result.output); - return result; - }; + const result = await execute(args, ctx) + manager.record(toolId, args, result.output) + return result + } } -export { resetResearchContextManager }; +export { resetResearchContextManager } // ============================================================================= // Market Data Tool @@ -375,13 +368,16 @@ export { resetResearchContextManager }; const MarketDataParams = z.object({ symbol: z.string().describe("Stock ticker symbol (e.g., AAPL, MSFT)"), - dataType: z.enum(["quote", "chart", "fundamentals", "news"]).default("quote") + dataType: z + .enum(["quote", "chart", "fundamentals", "news"]) + .default("quote") .describe("Type of market data to retrieve"), - period: z.enum(["1d", "5d", "1m", "3m", "6m", "1y", "ytd", "max"]).default("1m") + period: z + .enum(["1d", "5d", "1m", "3m", "6m", "1y", "ytd", "max"]) + .default("1m") .describe("Time period for historical data"), - interval: z.enum(["1m", "5m", "15m", "1h", "1d", "1w"]).optional() - .describe("Data interval for charts"), -}); + interval: z.enum(["1m", "5m", "15m", "1h", "1d", "1w"]).optional().describe("Data interval for charts"), +}) export const marketDataTool: ToolDefinition = { id: "zee:invest-market-data", @@ -390,16 +386,16 @@ export const marketDataTool: ToolDefinition = { description: `Retrieve real-time and historical market data for stocks, ETFs, and indices. Data types: quote (current price), chart (historical), fundamentals (P/E, market cap), news.`, parameters: MarketDataParams, execute: withDeduplication("zee:invest-market-data", async (args, ctx): Promise => { - const { symbol, dataType, period } = args; + const { symbol, dataType, period } = args - ctx.metadata({ title: `Fetching ${dataType} for ${symbol}` }); + ctx.metadata({ title: `Fetching ${dataType} for ${symbol}` }) if (dataType === "news") { return { title: `Market Data: ${symbol}`, metadata: { symbol, dataType }, output: "News is not available in the Investing CLI yet.", - }; + } } const cliArgs = @@ -407,27 +403,23 @@ export const marketDataTool: ToolDefinition = { ? ["market", "chart", symbol, "--period", period] : dataType === "fundamentals" ? ["market", "fundamentals", symbol] - : ["market", "quote", symbol]; - const result = await runInvestingCli(cliArgs); - return renderOutput(`Market Data: ${symbol}`, result); + : ["market", "quote", symbol] + const result = await runInvestingCli(cliArgs) + return renderOutput(`Market Data: ${symbol}`, result) }), }), -}; +} // ============================================================================= // Portfolio Analysis Tool // ============================================================================= const PortfolioParams = z.object({ - action: z.enum(["get", "analyze", "optimize", "backtest"]).default("analyze") - .describe("Portfolio action to perform"), - portfolioId: z.string().optional() - .describe("Portfolio identifier (uses default if not specified)"), - benchmark: z.string().default("SPY") - .describe("Benchmark symbol for comparison"), - riskMetrics: z.boolean().default(true) - .describe("Include risk metrics (Sharpe, Sortino, VaR)"), -}); + action: z.enum(["get", "analyze", "optimize", "backtest"]).default("analyze").describe("Portfolio action to perform"), + portfolioId: z.string().optional().describe("Portfolio identifier (uses default if not specified)"), + benchmark: z.string().default("SPY").describe("Benchmark symbol for comparison"), + riskMetrics: z.boolean().default(true).describe("Include risk metrics (Sharpe, Sortino, VaR)"), +}) export const portfolioTool: ToolDefinition = { id: "zee:invest-portfolio", @@ -436,16 +428,16 @@ export const portfolioTool: ToolDefinition = { description: `Analyze and optimize investment portfolios. Check memory for user positions first. Actions: get (current), analyze (performance + risk metrics), optimize, backtest.`, parameters: PortfolioParams, execute: withDeduplication("zee:invest-portfolio", async (args, ctx): Promise => { - const { action, portfolioId, benchmark, riskMetrics } = args; + const { action, portfolioId, benchmark, riskMetrics } = args - ctx.metadata({ title: `Portfolio ${action}` }); + ctx.metadata({ title: `Portfolio ${action}` }) if (action === "optimize") { return { title: "Portfolio Analysis", metadata: { action, portfolioId: portfolioId || "default", benchmark, riskMetrics }, output: "Portfolio optimization is not available in the Investing CLI yet.", - }; + } } if (action === "backtest") { @@ -453,7 +445,7 @@ export const portfolioTool: ToolDefinition = { title: "Portfolio Analysis", metadata: { action, portfolioId: portfolioId || "default", benchmark, riskMetrics }, output: "Portfolio backtests should use the Nautilus tool with a strategy.", - }; + } } const cliArgs = @@ -461,12 +453,12 @@ export const portfolioTool: ToolDefinition = { ? ["portfolio", "status"] : riskMetrics ? ["portfolio", "risk", "--var", "0.95"] - : ["portfolio", "performance", "--period", "ytd"]; - const result = await runInvestingCli(cliArgs); - return renderOutput("Portfolio Analysis", result); + : ["portfolio", "performance", "--period", "ytd"] + const result = await runInvestingCli(cliArgs) + return renderOutput("Portfolio Analysis", result) }), }), -}; +} // ============================================================================= // SEC Filings Tool @@ -474,13 +466,13 @@ export const portfolioTool: ToolDefinition = { const SecFilingsParams = z.object({ ticker: z.string().describe("Company ticker symbol"), - formType: z.enum(["10-K", "10-Q", "8-K", "13F", "DEF14A", "S-1", "all"]).default("10-K") + formType: z + .enum(["10-K", "10-Q", "8-K", "13F", "DEF14A", "S-1", "all"]) + .default("10-K") .describe("SEC form type to retrieve"), - year: z.number().optional() - .describe("Filing year (defaults to most recent)"), - summarize: z.boolean().default(true) - .describe("Generate AI summary of the filing"), -}); + year: z.number().optional().describe("Filing year (defaults to most recent)"), + summarize: z.boolean().default(true).describe("Generate AI summary of the filing"), +}) export const secFilingsTool: ToolDefinition = { id: "zee:invest-sec-filings", @@ -489,20 +481,20 @@ export const secFilingsTool: ToolDefinition = { description: `Access and analyze SEC regulatory filings. Form types: 10-K (annual), 10-Q (quarterly), 8-K (events), 13F (holdings), DEF14A (proxy), S-1 (IPO). Set summarize=true for AI summary.`, parameters: SecFilingsParams, execute: withDeduplication("zee:invest-sec-filings", async (args, ctx): Promise => { - const { ticker, formType, year, summarize } = args; + const { ticker, formType, year, summarize } = args - ctx.metadata({ title: `SEC ${formType} for ${ticker}` }); + ctx.metadata({ title: `SEC ${formType} for ${ticker}` }) const cliArgs = summarize ? ["research", "analyze", ticker, "--filing", formType] - : ["research", "sec", ticker, "--type", formType]; - const result = await runInvestingCli(cliArgs); - const response = renderOutput(`SEC Filing: ${ticker} ${formType}`, result); - response.metadata = { ...response.metadata, ticker, formType, year }; - return response; + : ["research", "sec", ticker, "--type", formType] + const result = await runInvestingCli(cliArgs) + const response = renderOutput(`SEC Filing: ${ticker} ${formType}`, result) + response.metadata = { ...response.metadata, ticker, formType, year } + return response }), }), -}; +} // ============================================================================= // Research Tool @@ -510,13 +502,13 @@ export const secFilingsTool: ToolDefinition = { const ResearchParams = z.object({ query: z.string().describe("Research query or topic"), - sources: z.array(z.enum(["sec", "news", "analyst", "academic", "all"])).default(["news", "analyst"]) + sources: z + .array(z.enum(["sec", "news", "analyst", "academic", "all"])) + .default(["news", "analyst"]) .describe("Sources to search"), - dateRange: z.enum(["1d", "1w", "1m", "3m", "1y", "all"]).default("1m") - .describe("Date range for results"), - limit: z.number().default(10) - .describe("Maximum number of results"), -}); + dateRange: z.enum(["1d", "1w", "1m", "3m", "1y", "all"]).default("1m").describe("Date range for results"), + limit: z.number().default(10).describe("Maximum number of results"), +}) export const researchTool: ToolDefinition = { id: "zee:invest-research", @@ -525,26 +517,24 @@ export const researchTool: ToolDefinition = { description: `Conduct financial research across multiple sources (SEC, news, analyst, academic). Check memory for previous analyses first. Specify dateRange and limit for results.`, parameters: ResearchParams, execute: withDeduplication("zee:invest-research", async (args, ctx): Promise => { - const { query, sources, dateRange, limit } = args; + const { query, sources, dateRange, limit } = args - ctx.metadata({ title: `Researching: ${query}` }); + ctx.metadata({ title: `Researching: ${query}` }) - const result = await runInvestingCli(["research", "screen", "--criteria", query]); - const response = renderOutput(`Research: ${query}`, result); - response.metadata = { ...response.metadata, sources, dateRange, limit }; - return response; + const result = await runInvestingCli(["research", "screen", "--criteria", query]) + const response = renderOutput(`Research: ${query}`, result) + response.metadata = { ...response.metadata, sources, dateRange, limit } + return response }), }), -}; +} const ResearchPlannerParams = z.discriminatedUnion("action", [ z.object({ action: z.literal("create"), objective: z.string().describe("Research objective to decompose into a repeatable workflow"), - workflow: z.enum(INVESTING_RESEARCH_WORKFLOW_KINDS).optional() - .describe("Optional workflow override"), - symbols: z.array(z.string()).optional() - .describe("Ticker symbols in scope for the workflow"), + workflow: z.enum(INVESTING_RESEARCH_WORKFLOW_KINDS).optional().describe("Optional workflow override"), + symbols: z.array(z.string()).optional().describe("Ticker symbols in scope for the workflow"), }), z.object({ action: z.literal("read"), @@ -552,21 +542,17 @@ const ResearchPlannerParams = z.discriminatedUnion("action", [ }), z.object({ action: z.literal("list"), - status: z.enum(INVESTING_RESEARCH_PLAN_STATUSES).optional() - .describe("Optional plan status filter"), - limit: z.number().min(1).max(100).default(10) - .describe("Maximum number of plans to return"), + status: z.enum(INVESTING_RESEARCH_PLAN_STATUSES).optional().describe("Optional plan status filter"), + limit: z.number().min(1).max(100).default(10).describe("Maximum number of plans to return"), }), z.object({ action: z.literal("update"), planId: z.string().describe("Persisted research plan identifier"), taskId: z.string().describe("Task identifier within the plan"), - status: z.enum(INVESTING_RESEARCH_TASK_STATUSES) - .describe("Next status for the task"), - note: z.string().optional() - .describe("Optional operator note or execution result"), + status: z.enum(INVESTING_RESEARCH_TASK_STATUSES).describe("Next status for the task"), + note: z.string().optional().describe("Optional operator note or execution result"), }), -]); +]) export const researchPlannerTool: ToolDefinition = { id: "zee:invest-planner", @@ -577,61 +563,57 @@ export const researchPlannerTool: ToolDefinition = { execute: async (args, ctx): Promise => { switch (args.action) { case "create": { - ctx.metadata({ title: `Planning research workflow: ${args.objective.slice(0, 48)}` }); + ctx.metadata({ title: `Planning research workflow: ${args.objective.slice(0, 48)}` }) const plan = createInvestingResearchPlan({ objective: args.objective, workflow: args.workflow, symbols: args.symbols, - }); + }) return { title: "Investing Research Planner", metadata: { action: args.action, planId: plan.id, workflow: plan.workflow }, output: JSON.stringify(plan, null, 2), - }; + } } case "read": { - ctx.metadata({ title: `Loading research plan ${args.planId}` }); - const plan = getInvestingResearchPlan(args.planId); + ctx.metadata({ title: `Loading research plan ${args.planId}` }) + const plan = getInvestingResearchPlan(args.planId) return { title: "Investing Research Planner", metadata: { action: args.action, planId: args.planId, found: Boolean(plan) }, - output: JSON.stringify( - plan ?? { error: `Research plan not found: ${args.planId}` }, - null, - 2, - ), - }; + output: JSON.stringify(plan ?? { error: `Research plan not found: ${args.planId}` }, null, 2), + } } case "list": { - ctx.metadata({ title: "Listing investing research plans" }); + ctx.metadata({ title: "Listing investing research plans" }) const plans = listInvestingResearchPlans({ status: args.status, limit: args.limit, - }); + }) return { title: "Investing Research Planner", metadata: { action: args.action, count: plans.length, status: args.status }, output: JSON.stringify({ plans, count: plans.length }, null, 2), - }; + } } case "update": { - ctx.metadata({ title: `Updating research task ${args.taskId}` }); + ctx.metadata({ title: `Updating research task ${args.taskId}` }) const plan = updateInvestingResearchTask({ planId: args.planId, taskId: args.taskId, status: args.status, note: args.note, - }); + }) return { title: "Investing Research Planner", metadata: { action: args.action, planId: plan.id, status: plan.status, taskId: args.taskId }, output: JSON.stringify(plan, null, 2), - }; + } } } }, }), -}; +} const ResearchExecutorParams = z.discriminatedUnion("action", [ z.object({ @@ -649,7 +631,7 @@ const ResearchExecutorParams = z.discriminatedUnion("action", [ taskId: z.string().optional().describe("Optional task filter"), limit: z.number().min(1).max(100).default(10).describe("Maximum number of executions to return"), }), -]); +]) export const researchExecutorTool: ToolDefinition = { id: "zee:invest-executor", @@ -660,20 +642,25 @@ export const researchExecutorTool: ToolDefinition = { execute: async (args, ctx): Promise => { switch (args.action) { case "run": { - ctx.metadata({ title: `Running research execution for ${args.planId}` }); + ctx.metadata({ title: `Running research execution for ${args.planId}` }) const execution = await runInvestingResearchExecution({ planId: args.planId, taskId: args.taskId, - }); + }) return { title: "Investing Research Executor", - metadata: { action: args.action, executionId: execution.id, planId: execution.planId, status: execution.status }, + metadata: { + action: args.action, + executionId: execution.id, + planId: execution.planId, + status: execution.status, + }, output: JSON.stringify(execution, null, 2), - }; + } } case "read": { - ctx.metadata({ title: `Loading research execution ${args.executionId}` }); - const execution = getInvestingResearchExecution(args.executionId); + ctx.metadata({ title: `Loading research execution ${args.executionId}` }) + const execution = getInvestingResearchExecution(args.executionId) return { title: "Investing Research Executor", metadata: { action: args.action, executionId: args.executionId, found: Boolean(execution) }, @@ -682,25 +669,25 @@ export const researchExecutorTool: ToolDefinition = { null, 2, ), - }; + } } case "list": { - ctx.metadata({ title: "Listing research executions" }); + ctx.metadata({ title: "Listing research executions" }) const executions = listInvestingResearchExecutions({ planId: args.planId, taskId: args.taskId, limit: args.limit, - }); + }) return { title: "Investing Research Executor", metadata: { action: args.action, count: executions.length, planId: args.planId, taskId: args.taskId }, output: JSON.stringify({ executions, count: executions.length }, null, 2), - }; + } } } }, }), -}; +} const ResearchArtifactsParams = z.discriminatedUnion("action", [ z.object({ @@ -720,7 +707,7 @@ const ResearchArtifactsParams = z.discriminatedUnion("action", [ status: z.enum(INVESTING_RESEARCH_ARTIFACT_STATUSES).optional().describe("Optional artifact status filter"), limit: z.number().min(1).max(100).default(10).describe("Maximum number of artifacts to return"), }), -]); +]) export const researchArtifactsTool: ToolDefinition = { id: "zee:invest-artifacts", @@ -731,18 +718,18 @@ export const researchArtifactsTool: ToolDefinition = { execute: async (args, ctx): Promise => { switch (args.action) { case "create": { - ctx.metadata({ title: `Creating research artifact for ${args.executionId}` }); - const execution = getInvestingResearchExecution(args.executionId); + ctx.metadata({ title: `Creating research artifact for ${args.executionId}` }) + const execution = getInvestingResearchExecution(args.executionId) if (!execution) { return { title: "Investing Research Artifacts", metadata: { action: args.action, executionId: args.executionId, found: false }, output: JSON.stringify({ error: `Research execution not found: ${args.executionId}` }, null, 2), - }; + } } - const plan = getInvestingResearchPlan(execution.planId); - const task = plan?.tasks.find((entry) => entry.id === execution.taskId); + const plan = getInvestingResearchPlan(execution.planId) + const task = plan?.tasks.find((entry) => entry.id === execution.taskId) if (!plan || !task) { return { title: "Investing Research Artifacts", @@ -752,7 +739,7 @@ export const researchArtifactsTool: ToolDefinition = { null, 2, ), - }; + } } const artifact = createInvestingResearchArtifact({ @@ -760,7 +747,7 @@ export const researchArtifactsTool: ToolDefinition = { plan, task, overwrite: args.overwrite, - }); + }) return { title: "Investing Research Artifacts", metadata: { @@ -770,30 +757,26 @@ export const researchArtifactsTool: ToolDefinition = { status: artifact.status, }, output: JSON.stringify(artifact, null, 2), - }; + } } case "read": { - ctx.metadata({ title: `Loading research artifact ${args.artifactId}` }); - const artifact = getInvestingResearchArtifact(args.artifactId); + ctx.metadata({ title: `Loading research artifact ${args.artifactId}` }) + const artifact = getInvestingResearchArtifact(args.artifactId) return { title: "Investing Research Artifacts", metadata: { action: args.action, artifactId: args.artifactId, found: Boolean(artifact) }, - output: JSON.stringify( - artifact ?? { error: `Research artifact not found: ${args.artifactId}` }, - null, - 2, - ), - }; + output: JSON.stringify(artifact ?? { error: `Research artifact not found: ${args.artifactId}` }, null, 2), + } } case "list": { - ctx.metadata({ title: "Listing research artifacts" }); + ctx.metadata({ title: "Listing research artifacts" }) const artifacts = listInvestingResearchArtifacts({ planId: args.planId, taskId: args.taskId, executionId: args.executionId, status: args.status, limit: args.limit, - }); + }) return { title: "Investing Research Artifacts", metadata: { @@ -805,12 +788,12 @@ export const researchArtifactsTool: ToolDefinition = { status: args.status, }, output: JSON.stringify({ artifacts, count: artifacts.length }, null, 2), - }; + } } } }, }), -}; +} const EventIntelligenceParams = z.discriminatedUnion("action", [ z.object({ @@ -831,7 +814,7 @@ const EventIntelligenceParams = z.discriminatedUnion("action", [ action: z.literal("read"), eventId: z.string().describe("Persisted classified event identifier"), }), -]); +]) export const eventIntelligenceTool: ToolDefinition = { id: "zee:invest-events", @@ -842,16 +825,16 @@ export const eventIntelligenceTool: ToolDefinition = { execute: async (args, ctx): Promise => { switch (args.action) { case "status": { - ctx.metadata({ title: "Loading investing event intelligence status" }); - const status = await getInvestingEventCatalogStatus(); + ctx.metadata({ title: "Loading investing event intelligence status" }) + const status = await getInvestingEventCatalogStatus() return { title: "Investing Event Intelligence", metadata: { action: args.action, totalEvents: status.totalEvents }, output: JSON.stringify(status, null, 2), - }; + } } case "list": { - ctx.metadata({ title: "Listing investing event intelligence records" }); + ctx.metadata({ title: "Listing investing event intelligence records" }) const events = await listInvestingEvents({ connector: args.connector, classification: args.classification, @@ -861,7 +844,7 @@ export const eventIntelligenceTool: ToolDefinition = { holdingOnly: args.holdingOnly, watchlistOnly: args.watchlistOnly, limit: args.limit, - }); + }) return { title: "Investing Event Intelligence", metadata: { @@ -876,21 +859,21 @@ export const eventIntelligenceTool: ToolDefinition = { watchlistOnly: args.watchlistOnly, }, output: JSON.stringify({ events, count: events.length }, null, 2), - }; + } } case "read": { - ctx.metadata({ title: `Loading investing event ${args.eventId}` }); - const event = await getInvestingEvent(args.eventId); + ctx.metadata({ title: `Loading investing event ${args.eventId}` }) + const event = await getInvestingEvent(args.eventId) return { title: "Investing Event Intelligence", metadata: { action: args.action, eventId: args.eventId, found: Boolean(event) }, output: JSON.stringify(event ?? { error: `Event not found: ${args.eventId}` }, null, 2), - }; + } } } }, }), -}; +} const ValuationKernelParams = z.discriminatedUnion("action", [ z.object({ @@ -913,7 +896,7 @@ const ValuationKernelParams = z.discriminatedUnion("action", [ status: z.enum(["ok", "error"]).optional().describe("Optional valuation run status filter"), limit: z.number().min(1).max(100).default(10).describe("Maximum number of valuation runs to return"), }), -]); +]) export const valuationKernelTool: ToolDefinition = { id: "zee:invest-valuation", @@ -924,40 +907,40 @@ export const valuationKernelTool: ToolDefinition = { execute: async (args, ctx): Promise => { switch (args.action) { case "run": { - ctx.metadata({ title: `Running valuation kernel for ${args.symbol}` }); - const run = await runInvestingValuationKernel(args); + ctx.metadata({ title: `Running valuation kernel for ${args.symbol}` }) + const run = await runInvestingValuationKernel(args) return { title: "Investing Valuation Kernel", metadata: { action: args.action, runId: run.id, symbol: run.symbol, status: run.status }, output: JSON.stringify(run, null, 2), - }; + } } case "read": { - ctx.metadata({ title: `Loading valuation kernel run ${args.runId}` }); - const run = getInvestingValuationKernel(args.runId); + ctx.metadata({ title: `Loading valuation kernel run ${args.runId}` }) + const run = getInvestingValuationKernel(args.runId) return { title: "Investing Valuation Kernel", metadata: { action: args.action, runId: args.runId, found: Boolean(run) }, output: JSON.stringify(run ?? { error: `Valuation run not found: ${args.runId}` }, null, 2), - }; + } } case "list": { - ctx.metadata({ title: "Listing valuation kernel runs" }); + ctx.metadata({ title: "Listing valuation kernel runs" }) const runs = listInvestingValuationKernels({ symbol: args.symbol, status: args.status, limit: args.limit, - }); + }) return { title: "Investing Valuation Kernel", metadata: { action: args.action, count: runs.length, symbol: args.symbol, status: args.status }, output: JSON.stringify({ runs, count: runs.length }, null, 2), - }; + } } } }, }), -}; +} const ValuationPacketParams = z.discriminatedUnion("action", [ z.object({ @@ -980,7 +963,7 @@ const ValuationPacketParams = z.discriminatedUnion("action", [ packetId: z.string().describe("Persisted valuation packet identifier"), format: z.enum(["json", "markdown"]).default("json").describe("Export format"), }), -]); +]) export const valuationPacketTool: ToolDefinition = { id: "zee:invest-valuation-packets", @@ -991,54 +974,54 @@ export const valuationPacketTool: ToolDefinition = { execute: async (args, ctx): Promise => { switch (args.action) { case "create": { - ctx.metadata({ title: `Creating valuation packet for ${args.runId}` }); - const run = getInvestingValuationKernel(args.runId); + ctx.metadata({ title: `Creating valuation packet for ${args.runId}` }) + const run = getInvestingValuationKernel(args.runId) if (!run) { return { title: "Investing Valuation Packets", metadata: { action: args.action, runId: args.runId, found: false }, output: JSON.stringify({ error: `Valuation run not found: ${args.runId}` }, null, 2), - }; + } } const packet = createInvestingValuationPacket({ run, overwrite: args.overwrite, - }); + }) return { title: "Investing Valuation Packets", metadata: { action: args.action, packetId: packet.id, runId: packet.runId }, output: JSON.stringify(packet, null, 2), - }; + } } case "read": { - ctx.metadata({ title: `Loading valuation packet ${args.packetId}` }); - const packet = getInvestingValuationPacket(args.packetId); + ctx.metadata({ title: `Loading valuation packet ${args.packetId}` }) + const packet = getInvestingValuationPacket(args.packetId) return { title: "Investing Valuation Packets", metadata: { action: args.action, packetId: args.packetId, found: Boolean(packet) }, output: JSON.stringify(packet ?? { error: `Valuation packet not found: ${args.packetId}` }, null, 2), - }; + } } case "list": { - ctx.metadata({ title: "Listing valuation packets" }); + ctx.metadata({ title: "Listing valuation packets" }) const packets = listInvestingValuationPackets({ symbol: args.symbol, runId: args.runId, limit: args.limit, - }); + }) return { title: "Investing Valuation Packets", metadata: { action: args.action, count: packets.length, symbol: args.symbol, runId: args.runId }, output: JSON.stringify({ packets, count: packets.length }, null, 2), - }; + } } case "export": { - ctx.metadata({ title: `Exporting valuation packet ${args.packetId}` }); + ctx.metadata({ title: `Exporting valuation packet ${args.packetId}` }) const exported = exportInvestingValuationPacket({ packetId: args.packetId, format: args.format, - }); + }) return { title: "Investing Valuation Packets", metadata: { @@ -1048,12 +1031,12 @@ export const valuationPacketTool: ToolDefinition = { exportCount: exported.packet.audit.exportCount, }, output: exported.content, - }; + } } } }, }), -}; +} const EarningsPacketParams = z.discriminatedUnion("action", [ z.object({ @@ -1077,7 +1060,7 @@ const EarningsPacketParams = z.discriminatedUnion("action", [ packetId: z.string().describe("Persisted earnings packet identifier"), format: z.enum(["json", "markdown"]).default("json").describe("Export format"), }), -]); +]) export const earningsPacketTool: ToolDefinition = { id: "zee:invest-earnings-packets", @@ -1088,18 +1071,18 @@ export const earningsPacketTool: ToolDefinition = { execute: async (args, ctx): Promise => { switch (args.action) { case "create": { - ctx.metadata({ title: `Creating earnings packet for ${args.executionId}` }); - const execution = getInvestingResearchExecution(args.executionId); + ctx.metadata({ title: `Creating earnings packet for ${args.executionId}` }) + const execution = getInvestingResearchExecution(args.executionId) if (!execution) { return { title: "Investing Earnings Packets", metadata: { action: args.action, executionId: args.executionId, found: false }, output: JSON.stringify({ error: `Research execution not found: ${args.executionId}` }, null, 2), - }; + } } - const plan = getInvestingResearchPlan(execution.planId); - const task = plan?.tasks.find((entry) => entry.id === execution.taskId); + const plan = getInvestingResearchPlan(execution.planId) + const task = plan?.tasks.find((entry) => entry.id === execution.taskId) if (!plan || !task) { return { title: "Investing Earnings Packets", @@ -1109,7 +1092,7 @@ export const earningsPacketTool: ToolDefinition = { null, 2, ), - }; + } } const packet = await createInvestingEarningsPacket({ @@ -1117,7 +1100,7 @@ export const earningsPacketTool: ToolDefinition = { plan, task, overwrite: args.overwrite, - }); + }) return { title: "Investing Earnings Packets", metadata: { @@ -1128,25 +1111,25 @@ export const earningsPacketTool: ToolDefinition = { status: packet.status, }, output: JSON.stringify(packet, null, 2), - }; + } } case "read": { - ctx.metadata({ title: `Loading earnings packet ${args.packetId}` }); - const packet = getInvestingEarningsPacket(args.packetId); + ctx.metadata({ title: `Loading earnings packet ${args.packetId}` }) + const packet = getInvestingEarningsPacket(args.packetId) return { title: "Investing Earnings Packets", metadata: { action: args.action, packetId: args.packetId, found: Boolean(packet) }, output: JSON.stringify(packet ?? { error: `Earnings packet not found: ${args.packetId}` }, null, 2), - }; + } } case "list": { - ctx.metadata({ title: "Listing earnings packets" }); + ctx.metadata({ title: "Listing earnings packets" }) const packets = listInvestingEarningsPackets({ symbol: args.symbol, workflow: args.workflow, executionId: args.executionId, limit: args.limit, - }); + }) return { title: "Investing Earnings Packets", metadata: { @@ -1157,14 +1140,14 @@ export const earningsPacketTool: ToolDefinition = { executionId: args.executionId, }, output: JSON.stringify({ packets, count: packets.length }, null, 2), - }; + } } case "export": { - ctx.metadata({ title: `Exporting earnings packet ${args.packetId}` }); + ctx.metadata({ title: `Exporting earnings packet ${args.packetId}` }) const exported = exportInvestingEarningsPacket({ packetId: args.packetId, format: args.format, - }); + }) return { title: "Investing Earnings Packets", metadata: { @@ -1174,17 +1157,170 @@ export const earningsPacketTool: ToolDefinition = { exportCount: exported.packet.audit.exportCount, }, output: exported.content, - }; + } + } + } + }, + }), +} + +const ThesisQueryParams = z.discriminatedUnion("action", [ + z.object({ + action: z.literal("read"), + thesis: z.string().describe("Thesis key or symbol"), + }), + z.object({ + action: z.literal("list"), + symbol: z.string().optional().describe("Optional symbol filter"), + status: z.enum(INVESTING_THESIS_RECORD_STATUSES).optional().describe("Optional thesis status filter"), + conviction: z.enum(INVESTING_THESIS_CONVICTIONS).optional().describe("Optional conviction filter"), + posture: z.enum(INVESTING_THESIS_POSTURES).optional().describe("Optional posture filter"), + limit: z.number().min(1).max(100).default(20).describe("Maximum number of theses to return"), + }), + z.object({ + action: z.literal("history"), + thesis: z.string().describe("Thesis key or symbol"), + limit: z.number().min(1).max(100).default(10).describe("Maximum number of revisions to return"), + }), + z.object({ + action: z.literal("diff"), + thesis: z.string().describe("Thesis key or symbol"), + fromVersion: z.number().int().min(0).optional().describe("Prior thesis version, defaults to the previous revision"), + toVersion: z.number().int().min(1).optional().describe("Target thesis version, defaults to the latest revision"), + }), + z.object({ + action: z.literal("portfolio-rollup"), + audience: z + .enum(INVESTING_THESIS_PORTFOLIO_ROLLUP_AUDIENCES) + .optional() + .default("all") + .describe("Roll up all portfolio names, only holdings, or only watchlist entries"), + conviction: z.enum(INVESTING_THESIS_CONVICTIONS).optional().describe("Optional conviction filter"), + posture: z.enum(INVESTING_THESIS_POSTURES).optional().describe("Optional posture filter"), + limit: z.number().min(1).max(200).default(50).describe("Maximum number of rollup entries to return"), + }), +]) + +export const thesisTool: ToolDefinition = { + id: "zee:invest-thesis", + category: "domain", + init: async () => ({ + description: `Query persisted thesis records, inspect revision history and diffs, and build portfolio-level thesis rollup views.`, + parameters: ThesisQueryParams, + execute: async (args, ctx): Promise => { + switch (args.action) { + case "read": { + ctx.metadata({ title: `Loading thesis ${args.thesis}` }) + const thesis = queryInvestingThesisRecord(args.thesis) + return { + title: "Investing Thesis Ledger", + metadata: { action: args.action, thesis: args.thesis, found: Boolean(thesis) }, + output: JSON.stringify(thesis ?? { error: `Thesis not found: ${args.thesis}` }, null, 2), + } + } + case "list": { + ctx.metadata({ title: "Listing thesis records" }) + const theses = queryInvestingTheses({ + symbol: args.symbol, + status: args.status, + conviction: args.conviction, + posture: args.posture, + limit: args.limit, + }) + return { + title: "Investing Thesis Ledger", + metadata: { + action: args.action, + count: theses.length, + symbol: args.symbol, + status: args.status, + conviction: args.conviction, + posture: args.posture, + }, + output: JSON.stringify({ theses, count: theses.length }, null, 2), + } + } + case "history": { + ctx.metadata({ title: `Loading thesis history ${args.thesis}` }) + const history = getInvestingThesisHistory({ + thesis: args.thesis, + limit: args.limit, + }) + return { + title: "Investing Thesis Ledger", + metadata: { + action: args.action, + thesis: args.thesis, + found: Boolean(history), + revisionCount: history?.revisionCount, + }, + output: JSON.stringify(history ?? { error: `Thesis not found: ${args.thesis}` }, null, 2), + } + } + case "diff": { + ctx.metadata({ title: `Diffing thesis ${args.thesis}` }) + try { + const diff = diffInvestingThesisHistory({ + thesis: args.thesis, + fromVersion: args.fromVersion, + toVersion: args.toVersion, + }) + return { + title: "Investing Thesis Ledger", + metadata: { + action: args.action, + thesis: args.thesis, + found: Boolean(diff), + fromVersion: args.fromVersion, + toVersion: args.toVersion, + }, + output: JSON.stringify(diff ?? { error: `Thesis not found: ${args.thesis}` }, null, 2), + } + } catch (error) { + return { + title: "Investing Thesis Ledger", + metadata: { + action: args.action, + thesis: args.thesis, + found: true, + fromVersion: args.fromVersion, + toVersion: args.toVersion, + }, + output: JSON.stringify({ error: error instanceof Error ? error.message : String(error) }, null, 2), + } + } + } + case "portfolio-rollup": { + ctx.metadata({ title: "Building thesis portfolio rollup" }) + const rollup = buildInvestingThesisPortfolioRollup({ + audience: args.audience, + conviction: args.conviction, + posture: args.posture, + limit: args.limit, + }) + return { + title: "Investing Thesis Ledger", + metadata: { + action: args.action, + audience: args.audience, + count: rollup.entries.length, + thesisTrackedCount: rollup.coverage.thesisTrackedCount, + }, + output: JSON.stringify(rollup, null, 2), + } } } }, }), -}; +} const PortfolioBriefingParams = z.discriminatedUnion("action", [ z.object({ action: z.literal("create"), - kind: z.enum(INVESTING_PORTFOLIO_BRIEFING_KINDS).default("daily-portfolio-brief").describe("Briefing workflow kind"), + kind: z + .enum(INVESTING_PORTFOLIO_BRIEFING_KINDS) + .default("daily-portfolio-brief") + .describe("Briefing workflow kind"), watchlistSymbols: z.array(z.string()).optional().describe("Optional explicit watchlist override"), }), z.object({ @@ -1198,7 +1334,7 @@ const PortfolioBriefingParams = z.discriminatedUnion("action", [ audience: z.enum(["holding", "watchlist"]).optional().describe("Optional audience filter"), limit: z.number().min(1).max(100).default(10).describe("Maximum number of briefings to return"), }), -]); +]) export const portfolioBriefingsTool: ToolDefinition = { id: "zee:invest-briefings", @@ -1209,10 +1345,10 @@ export const portfolioBriefingsTool: ToolDefinition = { execute: async (args, ctx): Promise => { switch (args.action) { case "create": { - ctx.metadata({ title: `Creating ${args.kind}` }); + ctx.metadata({ title: `Creating ${args.kind}` }) const briefing = await createInvestingPortfolioBriefing({ watchlistSymbols: args.watchlistSymbols, - }); + }) return { title: "Investing Portfolio Briefings", metadata: { @@ -1222,25 +1358,25 @@ export const portfolioBriefingsTool: ToolDefinition = { stateFile: getInvestingPortfolioBriefingStateFile(), }, output: JSON.stringify(briefing, null, 2), - }; + } } case "read": { - ctx.metadata({ title: `Loading portfolio briefing ${args.briefingId}` }); - const briefing = getInvestingPortfolioBriefing(args.briefingId); + ctx.metadata({ title: `Loading portfolio briefing ${args.briefingId}` }) + const briefing = getInvestingPortfolioBriefing(args.briefingId) return { title: "Investing Portfolio Briefings", metadata: { action: args.action, briefingId: args.briefingId, found: Boolean(briefing) }, output: JSON.stringify(briefing ?? { error: `Portfolio briefing not found: ${args.briefingId}` }, null, 2), - }; + } } case "list": { - ctx.metadata({ title: "Listing portfolio briefings" }); + ctx.metadata({ title: "Listing portfolio briefings" }) const briefings = listInvestingPortfolioBriefings({ kind: args.kind, symbol: args.symbol, audience: args.audience, limit: args.limit, - }); + }) return { title: "Investing Portfolio Briefings", metadata: { @@ -1251,12 +1387,12 @@ export const portfolioBriefingsTool: ToolDefinition = { audience: args.audience, }, output: JSON.stringify({ briefings, count: briefings.length }, null, 2), - }; + } } } }, }), -}; +} const OpsAutomationParams = z.discriminatedUnion("action", [ z.object({ @@ -1267,7 +1403,11 @@ const OpsAutomationParams = z.discriminatedUnion("action", [ symbol: z.string().optional().describe("Required for earnings packet workflows"), watchlistSymbols: z.array(z.string()).optional().describe("Optional watchlist override for daily briefs"), format: z.enum(INVESTING_OPS_FORMATS).optional().default("markdown").describe("Delivery format"), - deliveryTarget: z.enum(INVESTING_OPS_DELIVERY_TARGETS).optional().default("audit-log").describe("Delivery destination"), + deliveryTarget: z + .enum(INVESTING_OPS_DELIVERY_TARGETS) + .optional() + .default("audit-log") + .describe("Delivery destination"), }), z.object({ action: z.literal("update-schedule"), @@ -1306,7 +1446,7 @@ const OpsAutomationParams = z.discriminatedUnion("action", [ symbol: z.string().optional().describe("Optional symbol filter"), limit: z.number().min(1).max(100).default(10).describe("Maximum number of delivery records to return"), }), -]); +]) export const opsAutomationTool: ToolDefinition = { id: "zee:invest-ops", @@ -1317,7 +1457,7 @@ export const opsAutomationTool: ToolDefinition = { execute: async (args, ctx): Promise => { switch (args.action) { case "create-schedule": { - ctx.metadata({ title: `Creating ops schedule for ${args.workflow}` }); + ctx.metadata({ title: `Creating ops schedule for ${args.workflow}` }) const schedule = createInvestingOpsSchedule({ workflow: args.workflow, scheduleMinutes: args.scheduleMinutes, @@ -1326,15 +1466,15 @@ export const opsAutomationTool: ToolDefinition = { watchlistSymbols: args.watchlistSymbols, format: args.format, deliveryTarget: args.deliveryTarget, - }); + }) return { title: "Investing Ops Automation", metadata: { action: args.action, scheduleId: schedule.id, workflow: schedule.workflow }, output: JSON.stringify(schedule, null, 2), - }; + } } case "update-schedule": { - ctx.metadata({ title: `Updating ops schedule ${args.scheduleId}` }); + ctx.metadata({ title: `Updating ops schedule ${args.scheduleId}` }) const schedule = updateInvestingOpsSchedule({ scheduleId: args.scheduleId, enabled: args.enabled, @@ -1343,30 +1483,30 @@ export const opsAutomationTool: ToolDefinition = { watchlistSymbols: args.watchlistSymbols, format: args.format, deliveryTarget: args.deliveryTarget, - }); + }) return { title: "Investing Ops Automation", metadata: { action: args.action, scheduleId: schedule.id, workflow: schedule.workflow }, output: JSON.stringify(schedule, null, 2), - }; + } } case "read-schedule": { - ctx.metadata({ title: `Loading ops schedule ${args.scheduleId}` }); - const schedule = getInvestingOpsSchedule(args.scheduleId); + ctx.metadata({ title: `Loading ops schedule ${args.scheduleId}` }) + const schedule = getInvestingOpsSchedule(args.scheduleId) return { title: "Investing Ops Automation", metadata: { action: args.action, scheduleId: args.scheduleId, found: Boolean(schedule) }, output: JSON.stringify(schedule ?? { error: `Ops schedule not found: ${args.scheduleId}` }, null, 2), - }; + } } case "list-schedules": { - ctx.metadata({ title: "Listing ops schedules" }); + ctx.metadata({ title: "Listing ops schedules" }) const schedules = listInvestingOpsSchedules({ workflow: args.workflow, enabled: args.enabled, symbol: args.symbol, limit: args.limit, - }); + }) return { title: "Investing Ops Automation", metadata: { @@ -1377,13 +1517,13 @@ export const opsAutomationTool: ToolDefinition = { symbol: args.symbol, }, output: JSON.stringify({ schedules, count: schedules.length }, null, 2), - }; + } } case "run-schedule": { - ctx.metadata({ title: `Running ops schedule ${args.scheduleId}` }); + ctx.metadata({ title: `Running ops schedule ${args.scheduleId}` }) const delivery = await runInvestingOpsSchedule({ scheduleId: args.scheduleId, - }); + }) return { title: "Investing Ops Automation", metadata: { @@ -1393,26 +1533,26 @@ export const opsAutomationTool: ToolDefinition = { status: delivery.status, }, output: JSON.stringify(delivery, null, 2), - }; + } } case "read-delivery": { - ctx.metadata({ title: `Loading ops delivery ${args.deliveryId}` }); - const delivery = getInvestingOpsDeliveryRecord(args.deliveryId); + ctx.metadata({ title: `Loading ops delivery ${args.deliveryId}` }) + const delivery = getInvestingOpsDeliveryRecord(args.deliveryId) return { title: "Investing Ops Automation", metadata: { action: args.action, deliveryId: args.deliveryId, found: Boolean(delivery) }, output: JSON.stringify(delivery ?? { error: `Ops delivery not found: ${args.deliveryId}` }, null, 2), - }; + } } case "list-deliveries": { - ctx.metadata({ title: "Listing ops deliveries" }); + ctx.metadata({ title: "Listing ops deliveries" }) const deliveries = listInvestingOpsDeliveryRecords({ scheduleId: args.scheduleId, workflow: args.workflow, status: args.status, symbol: args.symbol, limit: args.limit, - }); + }) return { title: "Investing Ops Automation", metadata: { @@ -1424,29 +1564,24 @@ export const opsAutomationTool: ToolDefinition = { symbol: args.symbol, }, output: JSON.stringify({ deliveries, count: deliveries.length }, null, 2), - }; + } } } }, }), -}; +} // ============================================================================= // Nautilus Trading Tool // ============================================================================= const NautilusParams = z.object({ - action: z.enum(["backtest", "paper_trade", "strategy_info", "market_status"]) - .describe("Trading action to perform"), - strategy: z.string().optional() - .describe("Strategy name or ID"), - symbols: z.array(z.string()).optional() - .describe("Symbols to trade"), - startDate: z.string().optional() - .describe("Start date for backtest (YYYY-MM-DD)"), - endDate: z.string().optional() - .describe("End date for backtest (YYYY-MM-DD)"), -}); + action: z.enum(["backtest", "paper_trade", "strategy_info", "market_status"]).describe("Trading action to perform"), + strategy: z.string().optional().describe("Strategy name or ID"), + symbols: z.array(z.string()).optional().describe("Symbols to trade"), + startDate: z.string().optional().describe("Start date for backtest (YYYY-MM-DD)"), + endDate: z.string().optional().describe("End date for backtest (YYYY-MM-DD)"), +}) export const nautilusTool: ToolDefinition = { id: "zee:invest-nautilus", @@ -1455,16 +1590,16 @@ export const nautilusTool: ToolDefinition = { description: `Interface with NautilusTrader for algorithmic trading research. Actions: backtest, paper_trade, strategy_info, market_status. Simulation only, no real trading.`, parameters: NautilusParams, execute: withDeduplication("zee:invest-nautilus", async (args, ctx): Promise => { - const { action, strategy, symbols, startDate, endDate } = args; + const { action, strategy, symbols, startDate, endDate } = args - ctx.metadata({ title: `Nautilus: ${action}` }); + ctx.metadata({ title: `Nautilus: ${action}` }) if (action === "market_status") { return { title: `NautilusTrader: ${action}`, metadata: { action, strategy, symbols }, output: "Market status is not available in the Investing CLI yet.", - }; + } } if (!strategy) { @@ -1472,23 +1607,23 @@ export const nautilusTool: ToolDefinition = { title: `NautilusTrader: ${action}`, metadata: { action, symbols }, output: "A strategy is required for this action.", - }; + } } - const symbolArg = symbols?.length ? symbols.join(",") : ""; + const symbolArg = symbols?.length ? symbols.join(",") : "" const cliArgs = action === "paper_trade" ? ["nautilus", "paper-trade", strategy, "--capital", "100000"] : action === "strategy_info" ? ["nautilus", "strategy-info", strategy] - : ["nautilus", "backtest", strategy, "--symbols", symbolArg, "--start", startDate || ""]; - const result = await runInvestingCli(cliArgs); - const response = renderOutput(`NautilusTrader: ${action}`, result); - response.metadata = { ...response.metadata, action, strategy, symbols, startDate, endDate }; - return response; + : ["nautilus", "backtest", strategy, "--symbols", symbolArg, "--start", startDate || ""] + const result = await runInvestingCli(cliArgs) + const response = renderOutput(`NautilusTrader: ${action}`, result) + response.metadata = { ...response.metadata, action, strategy, symbols, startDate, endDate } + return response }), }), -}; +} export const statusTool: ToolDefinition = { id: "zee:invest-status", @@ -1497,22 +1632,22 @@ export const statusTool: ToolDefinition = { description: "Check the health and connection status of the Investing investment platform.", parameters: z.object({}), execute: async (args, ctx): Promise => { - const result = await runInvestingCli(["status"]); - // If 'status' isn't supported by CLI, we can try a lightweight command like checking version or help - // Let's assume we just want to verify CLI is runnable. - - if (!result.ok && result.error?.includes("Investing CLI not found")) { - return { - title: "Investing Status", - metadata: { ok: false }, - output: "Investing is not installed or not found. Configure the investing backend.", - }; - } - - // Try a lightweight ping/version if status fails, but for now report result - return renderOutput("Investing Status", result); - } - }) + const result = await runInvestingCli(["status"]) + // If 'status' isn't supported by CLI, we can try a lightweight command like checking version or help + // Let's assume we just want to verify CLI is runnable. + + if (!result.ok && result.error?.includes("Investing CLI not found")) { + return { + title: "Investing Status", + metadata: { ok: false }, + output: "Investing is not installed or not found. Configure the investing backend.", + } + } + + // Try a lightweight ping/version if status fails, but for now report result + return renderOutput("Investing Status", result) + }, + }), } // ============================================================================= @@ -1521,9 +1656,13 @@ export const statusTool: ToolDefinition = { const EstimatesParams = z.object({ symbol: z.string().describe("Stock ticker symbol (e.g., AAPL, MSFT)"), - estimateType: z.enum(["consensus", "forward_eps", "price_target", "revisions"]).default("consensus") - .describe("Type of estimate: consensus (rating + targets), forward_eps (EPS projections), price_target (analyst targets), revisions (estimate changes)"), -}); + estimateType: z + .enum(["consensus", "forward_eps", "price_target", "revisions"]) + .default("consensus") + .describe( + "Type of estimate: consensus (rating + targets), forward_eps (EPS projections), price_target (analyst targets), revisions (estimate changes)", + ), +}) export const estimatesTool: ToolDefinition = { id: "zee:invest-estimates", @@ -1532,14 +1671,14 @@ export const estimatesTool: ToolDefinition = { description: `Get analyst estimates, consensus ratings, forward EPS, price targets, and revision history. Use consensus for quick overview, forward_eps for quarterly/annual projections, price_target for individual analyst calls, revisions for upgrade/downgrade momentum.`, parameters: EstimatesParams, execute: withDeduplication("zee:invest-estimates", async (args, ctx): Promise => { - const { symbol, estimateType } = args; - ctx.metadata({ title: `Estimates: ${symbol} (${estimateType})` }); - const cliArgs = ["research", "estimates", symbol, "--type", estimateType]; - const result = await runInvestingCli(cliArgs); - return renderOutput(`Estimates: ${symbol}`, result); + const { symbol, estimateType } = args + ctx.metadata({ title: `Estimates: ${symbol} (${estimateType})` }) + const cliArgs = ["research", "estimates", symbol, "--type", estimateType] + const result = await runInvestingCli(cliArgs) + return renderOutput(`Estimates: ${symbol}`, result) }), }), -}; +} // ============================================================================= // Insider Trades Tool @@ -1548,7 +1687,7 @@ export const estimatesTool: ToolDefinition = { const InsiderTradesParams = z.object({ symbol: z.string().describe("Stock ticker symbol (e.g., AAPL, MSFT)"), limit: z.number().default(20).describe("Maximum number of transactions to return"), -}); +}) export const insiderTradesTool: ToolDefinition = { id: "zee:invest-insider-trades", @@ -1557,14 +1696,14 @@ export const insiderTradesTool: ToolDefinition = { description: `Get recent insider buy/sell transactions for a company. Returns officer names, transaction types, share amounts, prices, and a net sentiment summary (bullish/bearish). Useful for gauging management confidence.`, parameters: InsiderTradesParams, execute: withDeduplication("zee:invest-insider-trades", async (args, ctx): Promise => { - const { symbol, limit } = args; - ctx.metadata({ title: `Insider Trades: ${symbol}` }); - const cliArgs = ["research", "insider-trades", symbol, "--limit", String(limit)]; - const result = await runInvestingCli(cliArgs); - return renderOutput(`Insider Trades: ${symbol}`, result); + const { symbol, limit } = args + ctx.metadata({ title: `Insider Trades: ${symbol}` }) + const cliArgs = ["research", "insider-trades", symbol, "--limit", String(limit)] + const result = await runInvestingCli(cliArgs) + return renderOutput(`Insider Trades: ${symbol}`, result) }), }), -}; +} // ============================================================================= // Business Segments Tool @@ -1572,9 +1711,11 @@ export const insiderTradesTool: ToolDefinition = { const SegmentsParams = z.object({ symbol: z.string().describe("Stock ticker symbol (e.g., AAPL, MSFT)"), - segmentType: z.enum(["business", "geography"]).default("business") + segmentType: z + .enum(["business", "geography"]) + .default("business") .describe("Breakdown type: business (product/service lines) or geography (regional revenue)"), -}); +}) export const segmentsTool: ToolDefinition = { id: "zee:invest-segments", @@ -1583,14 +1724,14 @@ export const segmentsTool: ToolDefinition = { description: `Get revenue breakdown by business segment or geography. Shows multi-period data with growth rates. Use business for product/service line analysis, geography for regional exposure. Essential for understanding revenue concentration and growth drivers.`, parameters: SegmentsParams, execute: withDeduplication("zee:invest-segments", async (args, ctx): Promise => { - const { symbol, segmentType } = args; - ctx.metadata({ title: `Segments: ${symbol} (${segmentType})` }); - const cliArgs = ["market", "segments", symbol, "--type", segmentType]; - const result = await runInvestingCli(cliArgs); - return renderOutput(`Segments: ${symbol}`, result); + const { symbol, segmentType } = args + ctx.metadata({ title: `Segments: ${symbol} (${segmentType})` }) + const cliArgs = ["market", "segments", symbol, "--type", segmentType] + const result = await runInvestingCli(cliArgs) + return renderOutput(`Segments: ${symbol}`, result) }), }), -}; +} // ============================================================================= // Exports @@ -1605,6 +1746,7 @@ export const INVESTING_TOOLS = [ valuationKernelTool, valuationPacketTool, earningsPacketTool, + thesisTool, portfolioBriefingsTool, opsAutomationTool, researchPlannerTool, @@ -1616,10 +1758,12 @@ export const INVESTING_TOOLS = [ insiderTradesTool, segmentsTool, scratchpadTool, -]; +] -export function registerInvestingTools(registry: { register: (tool: ToolDefinition, options: { source: string }) => void }): void { +export function registerInvestingTools(registry: { + register: (tool: ToolDefinition, options: { source: string }) => void +}): void { for (const tool of INVESTING_TOOLS) { - registry.register(tool, { source: "domain" }); + registry.register(tool, { source: "domain" }) } }