opencandle 0.11.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/README.md +5 -4
  2. package/dist/analysts/orchestrator.d.ts +2 -0
  3. package/dist/analysts/orchestrator.js +19 -0
  4. package/dist/analysts/orchestrator.js.map +1 -1
  5. package/dist/doctor/report.js +1 -1
  6. package/dist/doctor/report.js.map +1 -1
  7. package/dist/infra/cache.d.ts +6 -0
  8. package/dist/infra/cache.js +16 -6
  9. package/dist/infra/cache.js.map +1 -1
  10. package/dist/infra/freshness.d.ts +21 -0
  11. package/dist/infra/freshness.js +119 -0
  12. package/dist/infra/freshness.js.map +1 -0
  13. package/dist/infra/market-calendar.d.ts +14 -0
  14. package/dist/infra/market-calendar.js +137 -0
  15. package/dist/infra/market-calendar.js.map +1 -0
  16. package/dist/infra/rate-limiter.js +1 -0
  17. package/dist/infra/rate-limiter.js.map +1 -1
  18. package/dist/market-state/summaries.d.ts +8 -0
  19. package/dist/market-state/summaries.js +70 -0
  20. package/dist/market-state/summaries.js.map +1 -0
  21. package/dist/onboarding/providers.d.ts +25 -1
  22. package/dist/onboarding/providers.js +26 -0
  23. package/dist/onboarding/providers.js.map +1 -1
  24. package/dist/pi/opencandle-extension.js +44 -5
  25. package/dist/pi/opencandle-extension.js.map +1 -1
  26. package/dist/providers/alpha-vantage.js +6 -0
  27. package/dist/providers/alpha-vantage.js.map +1 -1
  28. package/dist/providers/coingecko.js +1 -0
  29. package/dist/providers/coingecko.js.map +1 -1
  30. package/dist/providers/polymarket.d.ts +2 -0
  31. package/dist/providers/polymarket.js +163 -0
  32. package/dist/providers/polymarket.js.map +1 -0
  33. package/dist/providers/wrap-provider.js +5 -3
  34. package/dist/providers/wrap-provider.js.map +1 -1
  35. package/dist/providers/yahoo-finance.d.ts +3 -0
  36. package/dist/providers/yahoo-finance.js +206 -10
  37. package/dist/providers/yahoo-finance.js.map +1 -1
  38. package/dist/routing/entity-extractor.js +16 -0
  39. package/dist/routing/entity-extractor.js.map +1 -1
  40. package/dist/routing/route-manifest.js +1 -1
  41. package/dist/routing/route-manifest.js.map +1 -1
  42. package/dist/routing/router-llm-client.js +18 -3
  43. package/dist/routing/router-llm-client.js.map +1 -1
  44. package/dist/routing/router.d.ts +1 -1
  45. package/dist/routing/router.js +274 -5
  46. package/dist/routing/router.js.map +1 -1
  47. package/dist/routing/slot-resolver.d.ts +1 -0
  48. package/dist/routing/slot-resolver.js +1 -1
  49. package/dist/routing/slot-resolver.js.map +1 -1
  50. package/dist/runtime/evidence.d.ts +1 -0
  51. package/dist/runtime/evidence.js.map +1 -1
  52. package/dist/runtime/numeric-claims.d.ts +23 -0
  53. package/dist/runtime/numeric-claims.js +100 -0
  54. package/dist/runtime/numeric-claims.js.map +1 -0
  55. package/dist/runtime/planning-evidence.js +1 -76
  56. package/dist/runtime/planning-evidence.js.map +1 -1
  57. package/dist/runtime/prompt-step.d.ts +10 -1
  58. package/dist/runtime/prompt-step.js +116 -2
  59. package/dist/runtime/prompt-step.js.map +1 -1
  60. package/dist/runtime/session-coordinator.d.ts +11 -1
  61. package/dist/runtime/session-coordinator.js +352 -49
  62. package/dist/runtime/session-coordinator.js.map +1 -1
  63. package/dist/runtime/validation.d.ts +3 -1
  64. package/dist/runtime/workflow-events.d.ts +1 -1
  65. package/dist/runtime/workflow-events.js.map +1 -1
  66. package/dist/runtime/workflow-types.d.ts +3 -0
  67. package/dist/runtime/workflow-types.js.map +1 -1
  68. package/dist/tools/fundamentals/comps.d.ts +1 -0
  69. package/dist/tools/fundamentals/comps.js +20 -9
  70. package/dist/tools/fundamentals/comps.js.map +1 -1
  71. package/dist/tools/fundamentals/dcf.js +183 -112
  72. package/dist/tools/fundamentals/dcf.js.map +1 -1
  73. package/dist/tools/index.d.ts +1 -0
  74. package/dist/tools/index.js +3 -0
  75. package/dist/tools/index.js.map +1 -1
  76. package/dist/tools/macro/event-probabilities.d.ts +8 -0
  77. package/dist/tools/macro/event-probabilities.js +121 -0
  78. package/dist/tools/macro/event-probabilities.js.map +1 -0
  79. package/dist/tools/market/crypto-price.d.ts +4 -1
  80. package/dist/tools/market/crypto-price.js +10 -1
  81. package/dist/tools/market/crypto-price.js.map +1 -1
  82. package/dist/tools/market/screen-stocks.js +14 -3
  83. package/dist/tools/market/screen-stocks.js.map +1 -1
  84. package/dist/tools/market/stock-quote.d.ts +4 -1
  85. package/dist/tools/market/stock-quote.js +9 -4
  86. package/dist/tools/market/stock-quote.js.map +1 -1
  87. package/dist/tools/options/option-chain.d.ts +4 -1
  88. package/dist/tools/options/option-chain.js +12 -1
  89. package/dist/tools/options/option-chain.js.map +1 -1
  90. package/dist/tools/portfolio/risk-analysis.js +1 -1
  91. package/dist/tools/portfolio/risk-analysis.js.map +1 -1
  92. package/dist/tools/technical/indicators.js +1 -1
  93. package/dist/tools/technical/indicators.js.map +1 -1
  94. package/dist/types/fundamentals.d.ts +1 -0
  95. package/dist/types/index.d.ts +1 -0
  96. package/dist/types/market.d.ts +2 -0
  97. package/dist/types/options.d.ts +1 -0
  98. package/dist/types/prediction-markets.d.ts +13 -0
  99. package/dist/types/prediction-markets.js +2 -0
  100. package/dist/types/prediction-markets.js.map +1 -0
  101. package/gui/server/chat-event-adapter.ts +89 -5
  102. package/gui/server/http-routes.ts +285 -37
  103. package/gui/server/invoke-tool.ts +14 -13
  104. package/gui/server/live-chat-event-adapter.ts +37 -2
  105. package/gui/server/market-state-api.ts +63 -5
  106. package/gui/server/projector.ts +132 -3
  107. package/gui/server/session-actions.ts +32 -40
  108. package/gui/server/session-entry-wait.ts +22 -3
  109. package/gui/server/ws-hub.ts +16 -4
  110. package/gui/shared/chat-events.ts +10 -1
  111. package/gui/shared/event-reducer.ts +1 -0
  112. package/gui/web/dist/assets/CatalogOverlay-DZ1niyQm.js +1 -0
  113. package/gui/web/dist/assets/index-D4F9AJnn.css +2 -0
  114. package/gui/web/dist/assets/index-DtqMMBTr.js +65 -0
  115. package/gui/web/dist/index.html +2 -2
  116. package/package.json +8 -13
  117. package/src/analysts/orchestrator.ts +20 -0
  118. package/src/doctor/report.ts +1 -1
  119. package/src/infra/cache.ts +24 -11
  120. package/src/infra/freshness.ts +165 -0
  121. package/src/infra/market-calendar.ts +193 -0
  122. package/src/infra/rate-limiter.ts +1 -0
  123. package/src/market-state/summaries.ts +75 -0
  124. package/src/onboarding/providers.ts +29 -1
  125. package/src/pi/opencandle-extension.ts +55 -5
  126. package/src/providers/alpha-vantage.ts +6 -0
  127. package/src/providers/coingecko.ts +2 -0
  128. package/src/providers/polymarket.ts +214 -0
  129. package/src/providers/wrap-provider.ts +5 -3
  130. package/src/providers/yahoo-finance.ts +339 -27
  131. package/src/routing/entity-extractor.ts +17 -0
  132. package/src/routing/route-manifest.ts +1 -1
  133. package/src/routing/router-llm-client.ts +31 -19
  134. package/src/routing/router.ts +340 -5
  135. package/src/routing/slot-resolver.ts +1 -1
  136. package/src/runtime/evidence.ts +1 -0
  137. package/src/runtime/numeric-claims.ts +108 -0
  138. package/src/runtime/planning-evidence.ts +6 -91
  139. package/src/runtime/prompt-step.ts +127 -2
  140. package/src/runtime/session-coordinator.ts +462 -57
  141. package/src/runtime/validation.ts +1 -1
  142. package/src/runtime/workflow-events.ts +2 -0
  143. package/src/runtime/workflow-types.ts +3 -0
  144. package/src/tools/fundamentals/comps.ts +25 -14
  145. package/src/tools/fundamentals/dcf.ts +204 -124
  146. package/src/tools/index.ts +3 -0
  147. package/src/tools/macro/event-probabilities.ts +141 -0
  148. package/src/tools/market/crypto-price.ts +14 -2
  149. package/src/tools/market/screen-stocks.ts +14 -3
  150. package/src/tools/market/stock-quote.ts +49 -43
  151. package/src/tools/options/option-chain.ts +16 -2
  152. package/src/tools/portfolio/risk-analysis.ts +1 -1
  153. package/src/tools/technical/indicators.ts +1 -1
  154. package/src/types/fundamentals.ts +1 -0
  155. package/src/types/index.ts +1 -0
  156. package/src/types/market.ts +2 -0
  157. package/src/types/options.ts +1 -0
  158. package/src/types/prediction-markets.ts +13 -0
  159. package/gui/server/chat-run-session.ts +0 -16
  160. package/gui/web/dist/assets/CatalogOverlay-ChRTKNlf.js +0 -1
  161. package/gui/web/dist/assets/index-BzyqyVnd.css +0 -2
  162. package/gui/web/dist/assets/index-DvMjkOP9.js +0 -65
@@ -8,8 +8,8 @@
8
8
  <link rel="preconnect" href="https://fonts.googleapis.com" />
9
9
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
10
10
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
11
- <script type="module" crossorigin src="/assets/index-DvMjkOP9.js"></script>
12
- <link rel="stylesheet" crossorigin href="/assets/index-BzyqyVnd.css">
11
+ <script type="module" crossorigin src="/assets/index-DtqMMBTr.js"></script>
12
+ <link rel="stylesheet" crossorigin href="/assets/index-D4F9AJnn.css">
13
13
  </head>
14
14
  <body>
15
15
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencandle",
3
- "version": "0.11.0",
3
+ "version": "0.11.1",
4
4
  "description": "Financial trading & investing agent",
5
5
  "license": "MIT",
6
6
  "homepage": "https://opencandle.app",
@@ -105,16 +105,12 @@
105
105
  "test:e2e:credential-soft-fallback": "tsx tests/e2e/credential-soft-fallback.test.ts",
106
106
  "test:e2e:credential-per-workflow-cap": "tsx tests/e2e/credential-per-workflow-cap.test.ts",
107
107
  "test:gui:browser": "OPENCANDLE_GUI_BROWSER=1 vitest run --config vitest.config.gui.ts",
108
+ "test:gui:release-smoke": "npm run gui:web:build && OPENCANDLE_GUI_RELEASE_SMOKE=1 vitest run --config vitest.config.gui-release.ts",
108
109
  "test:e2e:providers": "tsx tests/e2e/providers.test.ts",
109
110
  "test:e2e:harness-dcf": "tsx tests/e2e/harness-dcf.test.ts",
110
- "test:evals": "vitest run --config vitest.config.evals.ts",
111
- "eval:router-live": "tsx tests/scripts/run-live-router-eval.ts",
112
- "test:evals:usually": "EVAL_TIER=usually vitest run --config vitest.config.evals.ts",
113
- "test:evals:product": "tsx tests/scripts/run-product-evals.ts",
114
- "test:evals:competitive": "tsx tests/scripts/run-competitive-finance-eval.ts",
111
+ "eval": "tsx tests/scripts/run-evals.ts",
115
112
  "test:packed-install": "node scripts/packed-install-smoke.mjs",
116
- "eval:competitive:analyze": "tsx tests/scripts/analyze-competitive-finance-report.ts",
117
- "review:pr": ".agents/skills/autoreview/scripts/autoreview --mode branch --prompt-file .agents/skills/autoreview/references/opencandle-review.md --parallel-tests \"npx tsc --noEmit && npx vitest run\"",
113
+ "review:pr": ".agents/skills/autoreview/scripts/autoreview --mode branch --prompt-file .agents/skills/autoreview/references/opencandle-review.md --parallel-tests \"npx tsc --noEmit && npx biome ci . && npx vitest run\"",
118
114
  "package:contents:check": "node scripts/check-package-contents.mjs",
119
115
  "version:patch": "npm version patch --no-git-tag-version",
120
116
  "version:minor": "npm version minor --no-git-tag-version",
@@ -122,7 +118,7 @@
122
118
  "version:set": "npm version",
123
119
  "prepublishOnly": "npm run release:check",
124
120
  "publish:dry": "npm run release:check && npm publish --access public --dry-run",
125
- "release:check": "npx tsc --noEmit && npx biome ci . && npm test && npm run docs:site:build && npm run package:contents:check && npm run test:packed-install && npm run docs:links:check",
121
+ "release:check": "npx tsc --noEmit && npx biome ci . && npm test && npm run test:gui:release-smoke && npm run docs:site:build && npm run package:contents:check && npm run test:packed-install && npm run docs:links:check",
126
122
  "release:patch": "node scripts/release.mjs patch",
127
123
  "release:minor": "node scripts/release.mjs minor",
128
124
  "release:major": "node scripts/release.mjs major"
@@ -143,17 +139,16 @@
143
139
  "@sinclair/typebox": "*"
144
140
  },
145
141
  "devDependencies": {
146
- "@agentclientprotocol/claude-agent-acp": "^0.52.0",
142
+ "@agentclientprotocol/claude-agent-acp": "^0.55.0",
147
143
  "@agentclientprotocol/codex-acp": "^1.0.1",
148
144
  "@biomejs/biome": "^2.5.1",
149
145
  "@sinclair/typebox": "^0.34.49",
150
146
  "@types/better-sqlite3": "^7.6.13",
151
147
  "@types/node": "^26.0.1",
152
- "acpx": "^0.11.2",
148
+ "acpx": "^0.12.0",
153
149
  "playwright-core": "^1.61.1",
154
150
  "typescript": "^6.0.3",
155
151
  "vite": "^8.1.0",
156
- "vitest": "^4.1.9",
157
- "vitest-evals": "^0.14.0"
152
+ "vitest": "^4.1.9"
158
153
  }
159
154
  }
@@ -1,5 +1,7 @@
1
1
  import type { WorkflowDefinition } from "../runtime/prompt-step.js";
2
2
  import { promptStep } from "../runtime/prompt-step.js";
3
+ import type { AnalystOutput } from "../runtime/workflow-types.js";
4
+ import { isAnalystSplit, tallyVotes } from "./contracts.js";
3
5
 
4
6
  export type AnalystRole = "valuation" | "momentum" | "options" | "contrarian" | "risk";
5
7
 
@@ -115,7 +117,24 @@ CONCESSIONS: [bullet list of points you concede]
115
117
  REMAINING CONVICTION: [1-10, where 10 = fully confident despite bear case]`;
116
118
  }
117
119
 
120
+ export function buildAnalystVoteTallyBlock(outputs: AnalystOutput[]): string | null {
121
+ if (outputs.length < 2) return null;
122
+ const tally = tallyVotes(outputs);
123
+ const split = isAnalystSplit(outputs);
124
+ return `## Deterministic Analyst Vote Tally
125
+ - Parsed analyst outputs: ${outputs.length}
126
+ - BUY: ${tally.buy}
127
+ - HOLD: ${tally.hold}
128
+ - SELL: ${tally.sell}
129
+ - Weighted average conviction: ${tally.weightedConviction}
130
+ - Computed verdict: ${tally.verdict}
131
+ - BUY+SELL split: ${split ? "yes" : "no"}`;
132
+ }
133
+
118
134
  export function buildSynthesisPrompt(symbol: string): string {
135
+ // The runtime tally block is injected at dispatch time by
136
+ // SessionCoordinator.prepareWorkflowPrompt, where parsed analyst outputs
137
+ // exist; this builder runs at workflow-definition time and stays static.
119
138
  return `**[Synthesis]** You have received five analyst signals with conviction scores for ${symbol}, a bull case arguing FOR the position, and a bear case arguing AGAINST.
120
139
  If a bull rebuttal with concessions appears above (not a line starting with "REBUTTAL SKIPPED"), treat the concessions as validated risks that must be addressed.
121
140
 
@@ -183,6 +202,7 @@ export function buildComprehensiveAnalysisDefinition(symbol: string): WorkflowDe
183
202
  expectedOutputs: ["bear_thesis"],
184
203
  }),
185
204
  promptStep("debate_rebuttal", "Bull rebuttal (self-gating)", buildRebuttalPrompt(symbol), {
205
+ skippable: true,
186
206
  requiredInputs: [...analystOutputs, "bull_thesis", "bear_thesis"],
187
207
  expectedOutputs: ["rebuttal"],
188
208
  }),
@@ -450,7 +450,7 @@ function providerStatusCheck(status: ProviderStatus, modeOverride?: "session"):
450
450
  summary: ok ? "Reachable" : `Not reachable (${status.state})`,
451
451
  remediation: ok
452
452
  ? undefined
453
- : "Check network connectivity and retry. Core quote and market-data workflows depend on Yahoo Finance reachability.",
453
+ : `Check network connectivity and retry. OpenCandle uses ${provider.displayName} through a public HTTP endpoint.`,
454
454
  metadata: { ...metadata, statusCode: status.statusCode ?? null },
455
455
  };
456
456
  }
@@ -12,21 +12,27 @@ export interface StaleResult<T> {
12
12
  cachedAt: number;
13
13
  }
14
14
 
15
- interface StaleMetadata {
16
- stale: boolean;
15
+ interface CacheMetadata {
16
+ status: "none" | "cached" | "stale";
17
17
  cachedAt: number;
18
18
  }
19
19
 
20
- const staleMetadataStorage = new AsyncLocalStorage<StaleMetadata>();
20
+ const cacheMetadataStorage = new AsyncLocalStorage<CacheMetadata>();
21
21
 
22
- export async function runWithStaleMetadata<T>(
23
- fn: () => Promise<T>,
24
- ): Promise<{ value: T; stale?: { cachedAt: number } }> {
25
- const metadata: StaleMetadata = { stale: false, cachedAt: 0 };
26
- const value = await staleMetadataStorage.run(metadata, fn);
22
+ export async function runWithStaleMetadata<T>(fn: () => Promise<T>): Promise<{
23
+ value: T;
24
+ cache?: { status: "cached" | "stale"; cachedAt: number };
25
+ stale?: { cachedAt: number };
26
+ }> {
27
+ const metadata: CacheMetadata = { status: "none", cachedAt: 0 };
28
+ const value = await cacheMetadataStorage.run(metadata, fn);
27
29
  return {
28
30
  value,
29
- stale: metadata.stale ? { cachedAt: metadata.cachedAt } : undefined,
31
+ cache:
32
+ metadata.status === "cached" || metadata.status === "stale"
33
+ ? { status: metadata.status, cachedAt: metadata.cachedAt }
34
+ : undefined,
35
+ stale: metadata.status === "stale" ? { cachedAt: metadata.cachedAt } : undefined,
30
36
  };
31
37
  }
32
38
 
@@ -37,6 +43,11 @@ export class Cache {
37
43
  const entry = this.store.get(key);
38
44
  if (!entry) return undefined;
39
45
  if (Date.now() > entry.expiresAt) return undefined;
46
+ const metadata = cacheMetadataStorage.getStore();
47
+ if (metadata && metadata.status === "none") {
48
+ metadata.status = "cached";
49
+ metadata.cachedAt = entry.cachedAt;
50
+ }
40
51
  return entry.value as T;
41
52
  }
42
53
 
@@ -54,9 +65,9 @@ export class Cache {
54
65
  return undefined;
55
66
  }
56
67
 
57
- const metadata = staleMetadataStorage.getStore();
68
+ const metadata = cacheMetadataStorage.getStore();
58
69
  if (metadata) {
59
- metadata.stale = true;
70
+ metadata.status = "stale";
60
71
  metadata.cachedAt = entry.cachedAt;
61
72
  }
62
73
  return { value: entry.value as T, stale: true, cachedAt: entry.cachedAt };
@@ -98,6 +109,7 @@ export const TTL = {
98
109
  CRUMB: 900_000, // 15 minutes
99
110
  WEB_SEARCH: 300_000, // 5 minutes
100
111
  FINNHUB_NEWS: 300_000, // 5 minutes
112
+ PREDICTION_MARKETS: 300_000, // 5 minutes
101
113
  } as const;
102
114
 
103
115
  // Stale limits — how long past TTL expiry a cached value is still useful as fallback
@@ -111,4 +123,5 @@ export const STALE_LIMIT = {
111
123
  SCREENER: 15 * 60_000, // 15 minutes
112
124
  WEB_SEARCH: 3_600_000, // 1 hour
113
125
  FINNHUB_NEWS: 3_600_000, // 1 hour
126
+ PREDICTION_MARKETS: 3_600_000, // 1 hour
114
127
  } as const;
@@ -0,0 +1,165 @@
1
+ import {
2
+ classifyMarketStatusAt,
3
+ lastTradingDay,
4
+ localDateTimeParts,
5
+ type MarketSession,
6
+ } from "./market-calendar.js";
7
+
8
+ export interface FreshnessStamp {
9
+ fetchedAt: string;
10
+ providerDataAt?: string;
11
+ providerDataDate?: string;
12
+ cacheStatus: "live" | "cached" | "stale";
13
+ cachedAt?: string;
14
+ marketSession: MarketSession;
15
+ dataDelayMs?: number;
16
+ isStaleForSession: boolean;
17
+ }
18
+
19
+ export function buildFreshnessStamp(input: {
20
+ asOf?: string | number | Date;
21
+ cached?: boolean;
22
+ stale?: boolean;
23
+ cachedAt?: string;
24
+ dataDelayMs?: number;
25
+ now?: Date;
26
+ assetClass?: "equity" | "crypto";
27
+ }): FreshnessStamp {
28
+ const now = input.now ?? new Date();
29
+ const parsedAsOf = normalizeAsOf(input.asOf);
30
+ const fetchedAt = now.toISOString();
31
+ const cacheStatus = input.stale ? "stale" : input.cached ? "cached" : "live";
32
+ const marketSession = input.assetClass === "crypto" ? "unknown" : classifyMarketStatusAt(now);
33
+ const cachedAt = input.cachedAt ? normalizeDate(input.cachedAt)?.toISOString() : undefined;
34
+ const providerDataAt = parsedAsOf?.date.toISOString();
35
+ const providerDataDate = parsedAsOf?.dateOnly;
36
+ const isStaleForSession =
37
+ input.assetClass === "crypto"
38
+ ? isCryptoStale(parsedAsOf?.date, cachedAt, now, cacheStatus)
39
+ : isEquityStale(parsedAsOf, cacheStatus, now);
40
+
41
+ return {
42
+ fetchedAt,
43
+ providerDataAt,
44
+ providerDataDate,
45
+ cacheStatus,
46
+ cachedAt,
47
+ marketSession,
48
+ dataDelayMs: input.dataDelayMs,
49
+ isStaleForSession,
50
+ };
51
+ }
52
+
53
+ export function formatAsOfLine(stamp: FreshnessStamp): string {
54
+ if (stamp.isStaleForSession && stamp.providerDataAt) {
55
+ const date = stamp.providerDataDate ?? formatEtDate(new Date(stamp.providerDataAt));
56
+ const parenthetical = staleSessionParenthetical(stamp.marketSession);
57
+ const cached = stamp.cachedAt
58
+ ? ` Cached from ${formatEtDateTime(new Date(stamp.cachedAt))} ET.`
59
+ : "";
60
+ return `Last available price as of ${date}${parenthetical}. This is not a live quote.${cached}`;
61
+ }
62
+
63
+ if (stamp.cacheStatus === "stale" && stamp.cachedAt) {
64
+ const notLive = stamp.isStaleForSession ? " This is not a live quote." : "";
65
+ return `Using cached data from ${formatEtDateTime(new Date(stamp.cachedAt))} ET (provider unavailable).${notLive}`;
66
+ }
67
+
68
+ if (stamp.providerDataAt || stamp.dataDelayMs !== undefined) {
69
+ const reference = stamp.providerDataAt ?? stamp.cachedAt ?? stamp.fetchedAt;
70
+ const asOf = stamp.providerDataDate ?? formatEtDateTime(new Date(reference));
71
+ if (stamp.dataDelayMs !== undefined)
72
+ return `As of ${asOf} ET (~${Math.round(stamp.dataDelayMs / 60_000)}m delayed).`;
73
+ return `As of ${asOf} ET (${marketSessionLabel(stamp.marketSession)}).`;
74
+ }
75
+
76
+ return `As of ${formatEtDateTime(new Date(stamp.fetchedAt))} ET (${marketSessionLabel(stamp.marketSession)}).`;
77
+ }
78
+
79
+ function normalizeAsOf(
80
+ asOf: string | number | Date | undefined,
81
+ ): { date: Date; dateOnly?: string } | undefined {
82
+ if (asOf === undefined) return undefined;
83
+ if (asOf instanceof Date) return Number.isNaN(asOf.getTime()) ? undefined : { date: asOf };
84
+ if (typeof asOf === "number") {
85
+ const milliseconds = asOf < 10_000_000_000 ? asOf * 1000 : asOf;
86
+ const date = new Date(milliseconds);
87
+ return Number.isNaN(date.getTime()) ? undefined : { date };
88
+ }
89
+ if (/^\d{4}-\d{2}-\d{2}$/.test(asOf)) {
90
+ const date = new Date(`${asOf}T00:00:00.000Z`);
91
+ return Number.isNaN(date.getTime()) ? undefined : { date, dateOnly: asOf };
92
+ }
93
+ const date = new Date(asOf);
94
+ return Number.isNaN(date.getTime()) ? undefined : { date };
95
+ }
96
+
97
+ function normalizeDate(value: string): Date | undefined {
98
+ const date = new Date(value);
99
+ return Number.isNaN(date.getTime()) ? undefined : date;
100
+ }
101
+
102
+ function isEquityStale(
103
+ parsedAsOf: { date: Date; dateOnly?: string } | undefined,
104
+ cacheStatus: FreshnessStamp["cacheStatus"],
105
+ now: Date,
106
+ ): boolean {
107
+ if (!parsedAsOf) return cacheStatus === "stale";
108
+ const localNow = localDateTimeParts(now, "America/New_York");
109
+ const providerTradingDate =
110
+ parsedAsOf.dateOnly ?? localDateTimeParts(parsedAsOf.date, "America/New_York").date;
111
+ const marketSession = classifyMarketStatusAt(now);
112
+ const requiredTradingDate =
113
+ marketSession === "open" ||
114
+ marketSession === "after_close" ||
115
+ marketSession === "closed_after_hours"
116
+ ? localNow.date
117
+ : lastTradingDay(localNow.date);
118
+ return providerTradingDate < requiredTradingDate;
119
+ }
120
+
121
+ function isCryptoStale(
122
+ providerDate: Date | undefined,
123
+ cachedAt: string | undefined,
124
+ now: Date,
125
+ cacheStatus: FreshnessStamp["cacheStatus"],
126
+ ): boolean {
127
+ const reference =
128
+ providerDate ?? (cacheStatus === "stale" && cachedAt ? new Date(cachedAt) : undefined);
129
+ if (!reference) return cacheStatus === "stale";
130
+ return now.getTime() - reference.getTime() > 15 * 60_000;
131
+ }
132
+
133
+ function formatEtDate(date: Date): string {
134
+ return localDateTimeParts(date, "America/New_York").date;
135
+ }
136
+
137
+ function formatEtDateTime(date: Date): string {
138
+ const parts = localDateTimeParts(date, "America/New_York");
139
+ return `${parts.date} ${parts.time}`;
140
+ }
141
+
142
+ function staleSessionParenthetical(marketSession: MarketSession): string {
143
+ if (marketSession === "closed_weekend") return " (market closed — weekend)";
144
+ if (marketSession === "closed_holiday") return " (market closed — holiday)";
145
+ return "";
146
+ }
147
+
148
+ function marketSessionLabel(marketSession: MarketSession): string {
149
+ switch (marketSession) {
150
+ case "open":
151
+ return "market open";
152
+ case "pre_market":
153
+ return "pre-market";
154
+ case "closed_weekend":
155
+ return "market closed — weekend";
156
+ case "closed_holiday":
157
+ return "market closed — holiday";
158
+ case "after_close":
159
+ return "after close";
160
+ case "closed_after_hours":
161
+ return "market closed";
162
+ case "unknown":
163
+ return "market session unknown";
164
+ }
165
+ }
@@ -0,0 +1,193 @@
1
+ export type MarketSession =
2
+ | "closed_weekend"
3
+ | "closed_holiday"
4
+ | "pre_market"
5
+ | "open"
6
+ | "after_close"
7
+ | "closed_after_hours"
8
+ | "unknown";
9
+
10
+ export interface LocalDateTimeParts {
11
+ date: string;
12
+ time: string;
13
+ weekday: string;
14
+ minutesSinceMidnight: number;
15
+ }
16
+
17
+ export const KNOWN_US_MARKET_HOLIDAYS: Record<string, string> = new Proxy(
18
+ {} as Record<string, string>,
19
+ {
20
+ get(_target, property) {
21
+ return typeof property === "string" ? marketHolidayName(property) : undefined;
22
+ },
23
+ has(_target, property) {
24
+ return typeof property === "string" && marketHolidayName(property) !== undefined;
25
+ },
26
+ },
27
+ );
28
+
29
+ export function marketHolidayName(key: string): string | undefined {
30
+ const date = dateFromKey(key);
31
+ const year = date.getUTCFullYear();
32
+ for (const candidateYear of [year - 1, year, year + 1]) {
33
+ const holiday = marketHolidaysForYear(candidateYear).find((entry) => entry.date === key);
34
+ if (holiday) return holiday.name;
35
+ }
36
+ return undefined;
37
+ }
38
+
39
+ export function classifyMarketStatus(
40
+ local: LocalDateTimeParts,
41
+ isWeekend: boolean,
42
+ isMarketHoliday: boolean,
43
+ temporalReferences: string[],
44
+ ): Exclude<MarketSession, "unknown"> {
45
+ if (isWeekend) return "closed_weekend";
46
+ if (isMarketHoliday) return "closed_holiday";
47
+ if (local.minutesSinceMidnight < 9 * 60 + 30) return "pre_market";
48
+ if (local.minutesSinceMidnight < 16 * 60) return "open";
49
+ if (temporalReferences.includes("after_close")) return "after_close";
50
+ return "closed_after_hours";
51
+ }
52
+
53
+ export function classifyMarketStatusAt(now: Date): MarketSession {
54
+ const local = localDateTimeParts(now, "America/New_York");
55
+ return classifyMarketStatus(
56
+ local,
57
+ local.weekday === "Sat" || local.weekday === "Sun",
58
+ marketHolidayName(local.date) !== undefined,
59
+ [],
60
+ );
61
+ }
62
+
63
+ export function localDateTimeParts(date: Date, timezone: string): LocalDateTimeParts {
64
+ const formatter = new Intl.DateTimeFormat("en-US", {
65
+ timeZone: timezone,
66
+ year: "numeric",
67
+ month: "2-digit",
68
+ day: "2-digit",
69
+ hour: "2-digit",
70
+ minute: "2-digit",
71
+ hour12: false,
72
+ weekday: "short",
73
+ });
74
+ const parts = formatter.formatToParts(date).reduce<Record<string, string>>((acc, part) => {
75
+ acc[part.type] = part.value;
76
+ return acc;
77
+ }, {});
78
+ const hour = Number(parts.hour);
79
+ const minute = Number(parts.minute);
80
+ return {
81
+ date: `${parts.year}-${parts.month}-${parts.day}`,
82
+ time: `${parts.hour}:${parts.minute}`,
83
+ weekday: parts.weekday ?? "",
84
+ minutesSinceMidnight: hour * 60 + minute,
85
+ };
86
+ }
87
+
88
+ export function lastTradingDay(localDate: string): string {
89
+ let cursor = dateFromKey(localDate);
90
+ do {
91
+ cursor = addDays(cursor, -1);
92
+ } while (isWeekendOrKnownHoliday(dateKey(cursor)));
93
+ return dateKey(cursor);
94
+ }
95
+
96
+ export function isWeekendOrKnownHoliday(key: string): boolean {
97
+ const date = dateFromKey(key);
98
+ const day = date.getUTCDay();
99
+ return day === 0 || day === 6 || marketHolidayName(key) !== undefined;
100
+ }
101
+
102
+ function marketHolidaysForYear(year: number): Array<{ date: string; name: string }> {
103
+ return [
104
+ observedFixedHoliday(year, 1, 1, "New Year's Day"),
105
+ nthWeekdayHoliday(year, 1, 1, 3, "Martin Luther King Jr. Day"),
106
+ nthWeekdayHoliday(year, 2, 1, 3, "Washington's Birthday"),
107
+ { date: dateKey(addDays(easterDate(year), -2)), name: "Good Friday" },
108
+ lastWeekdayHoliday(year, 5, 1, "Memorial Day"),
109
+ observedFixedHoliday(year, 6, 19, "Juneteenth"),
110
+ observedFixedHoliday(year, 7, 4, "Independence Day"),
111
+ nthWeekdayHoliday(year, 9, 1, 1, "Labor Day"),
112
+ nthWeekdayHoliday(year, 11, 4, 4, "Thanksgiving Day"),
113
+ observedFixedHoliday(year, 12, 25, "Christmas Day"),
114
+ ];
115
+ }
116
+
117
+ function observedFixedHoliday(
118
+ year: number,
119
+ month: number,
120
+ day: number,
121
+ name: string,
122
+ ): {
123
+ date: string;
124
+ name: string;
125
+ } {
126
+ const actual = new Date(Date.UTC(year, month - 1, day));
127
+ const weekday = actual.getUTCDay();
128
+ const observed =
129
+ weekday === 0 ? addDays(actual, 1) : weekday === 6 ? addDays(actual, -1) : actual;
130
+ return {
131
+ date: dateKey(observed),
132
+ name: observed.getTime() === actual.getTime() ? name : `${name} observed`,
133
+ };
134
+ }
135
+
136
+ function nthWeekdayHoliday(
137
+ year: number,
138
+ month: number,
139
+ weekday: number,
140
+ occurrence: number,
141
+ name: string,
142
+ ): { date: string; name: string } {
143
+ const first = new Date(Date.UTC(year, month - 1, 1));
144
+ const offset = (weekday - first.getUTCDay() + 7) % 7;
145
+ return { date: dateKey(addDays(first, offset + (occurrence - 1) * 7)), name };
146
+ }
147
+
148
+ function lastWeekdayHoliday(
149
+ year: number,
150
+ month: number,
151
+ weekday: number,
152
+ name: string,
153
+ ): { date: string; name: string } {
154
+ const last = new Date(Date.UTC(year, month, 0));
155
+ const offset = (last.getUTCDay() - weekday + 7) % 7;
156
+ return { date: dateKey(addDays(last, -offset)), name };
157
+ }
158
+
159
+ function easterDate(year: number): Date {
160
+ const a = year % 19;
161
+ const b = Math.floor(year / 100);
162
+ const c = year % 100;
163
+ const d = Math.floor(b / 4);
164
+ const e = b % 4;
165
+ const f = Math.floor((b + 8) / 25);
166
+ const g = Math.floor((b - f + 1) / 3);
167
+ const h = (19 * a + b - d - g + 15) % 30;
168
+ const i = Math.floor(c / 4);
169
+ const k = c % 4;
170
+ const l = (32 + 2 * e + 2 * i - h - k) % 7;
171
+ const m = Math.floor((a + 11 * h + 22 * l) / 451);
172
+ const easterMonth = Math.floor((h + l - 7 * m + 114) / 31);
173
+ const easterDay = ((h + l - 7 * m + 114) % 31) + 1;
174
+ return new Date(Date.UTC(year, easterMonth - 1, easterDay));
175
+ }
176
+
177
+ function dateFromKey(key: string): Date {
178
+ const [year, month, day] = key.split("-").map(Number);
179
+ return new Date(Date.UTC(year, month - 1, day));
180
+ }
181
+
182
+ function addDays(date: Date, days: number): Date {
183
+ const next = new Date(date);
184
+ next.setUTCDate(next.getUTCDate() + days);
185
+ return next;
186
+ }
187
+
188
+ function dateKey(date: Date): string {
189
+ const year = date.getUTCFullYear();
190
+ const month = String(date.getUTCMonth() + 1).padStart(2, "0");
191
+ const day = String(date.getUTCDate()).padStart(2, "0");
192
+ return `${year}-${month}-${day}`;
193
+ }
@@ -72,5 +72,6 @@ rateLimiter.configure("exa", 5, 0.1); // 5 req, ~6 req/min
72
72
  rateLimiter.configure("finnhub", 60, 1); // 60 req/min (free tier)
73
73
  // TradingView scanner is undocumented; keep usage batch-first and paced.
74
74
  rateLimiter.configure("tradingview", 5, 1); // 5 burst, 1 req/s sustained
75
+ rateLimiter.configure("polymarket", 10, 5); // 10 burst, 5 req/s sustained
75
76
  // SEC EDGAR fair-access guideline is 10 req/s; stay below it.
76
77
  rateLimiter.configure("sec_edgar", 5, 5); // 5 burst, 5 req/s sustained
@@ -0,0 +1,75 @@
1
+ import type { PortfolioLotRecord, ReportRunRecord, WatchlistItemRecord } from "./service.js";
2
+
3
+ export function formatPortfolioSummary(lots: PortfolioLotRecord[], limit = 8): string[] {
4
+ if (lots.length === 0) return [];
5
+ const lines = ["Portfolio lots:"];
6
+ for (const lot of lots.slice(0, limit)) {
7
+ const costBasis = formatMoney(lot.quantity * lot.avgCost, lot.currency);
8
+ const name = lot.name ? ` (${lot.name})` : "";
9
+ lines.push(
10
+ `- ${lot.symbol}: ${lot.quantity} @ ${formatMoney(lot.avgCost, lot.currency)}, cost basis ${costBasis}${name}`,
11
+ );
12
+ }
13
+ return lines;
14
+ }
15
+
16
+ export function formatWatchlistSummary(items: WatchlistItemRecord[], limit = 8): string[] {
17
+ if (items.length === 0) return [];
18
+ const lines = ["Watchlist:"];
19
+ for (const item of items.slice(0, limit)) {
20
+ const parts = [
21
+ item.targetPrice == null
22
+ ? null
23
+ : `target ${formatMoney(item.targetPrice, item.priceCurrency ?? item.currency ?? "USD")}`,
24
+ item.stopPrice == null
25
+ ? null
26
+ : `stop ${formatMoney(item.stopPrice, item.priceCurrency ?? item.currency ?? "USD")}`,
27
+ item.thesis ? `thesis: ${item.thesis}` : null,
28
+ item.tags && item.tags.length > 0 ? `tags: ${item.tags.join(", ")}` : null,
29
+ item.notes ? `notes: ${item.notes}` : null,
30
+ ].filter((part): part is string => part != null);
31
+ lines.push(
32
+ `- ${item.symbol}${item.name ? ` (${item.name})` : ""}${parts.length > 0 ? ` — ${parts.join("; ")}` : ""}`,
33
+ );
34
+ }
35
+ return lines;
36
+ }
37
+
38
+ export function formatLatestReportSummary(
39
+ report: ReportRunRecord | undefined,
40
+ options: { includeSummary?: boolean } = {},
41
+ ): string[] {
42
+ if (!report) return [];
43
+ const completed = report.completedAt ?? report.startedAt;
44
+ const summary = options.includeSummary === false ? "" : formatJsonSummary(report.summaryJson);
45
+ const lines = [
46
+ `Latest report run: ${report.status} at ${completed}${summary ? ` — ${summary}` : ""}`,
47
+ ];
48
+ const reportText = options.includeSummary === false ? "" : extractReportText(report.summaryJson);
49
+ if (reportText) lines.push("Report text:", truncateText(reportText, 8_000));
50
+ return lines;
51
+ }
52
+
53
+ export function formatMoney(value: number, currency: string): string {
54
+ const normalized = currency.toUpperCase();
55
+ if (normalized === "USD") return `$${value.toFixed(2)}`;
56
+ return `${normalized} ${value.toFixed(2)}`;
57
+ }
58
+
59
+ export function formatJsonSummary(value: unknown): string {
60
+ if (value == null) return "";
61
+ const json = JSON.stringify(value);
62
+ if (json.length <= 90) return json;
63
+ return `${json.slice(0, 87)}...`;
64
+ }
65
+
66
+ function extractReportText(value: unknown): string {
67
+ if (!value || typeof value !== "object" || !("text" in value)) return "";
68
+ const text = (value as { text?: unknown }).text;
69
+ return typeof text === "string" ? text.trim() : "";
70
+ }
71
+
72
+ function truncateText(value: string, maxLength: number): string {
73
+ if (value.length <= maxLength) return value;
74
+ return `${value.slice(0, maxLength - 3)}...`;
75
+ }